Files
OpenTerminal/tests/worker-artifact.mjs
T

173 lines
6.7 KiB
JavaScript

/**
* Probe-worker build artifact smoke test (worker-artifact.mjs).
*
* The ReDoS gate is only real if the SHIPPED worker chunk is real. The pool's
* factory is the `?nodeWorker` import in `main/index.ts`, which electron-vite
* emits into `out/main` — and nothing else in the suite ever loads that file:
* `tests/redos-validator.mjs` spawns its own esbuild bundle of the same source,
* so a build that stopped emitting the worker (a renamed entry, a plugin that
* no longer fires, a broken chunk) would leave every unit test green while the
* packaged app reported every custom rule as "pending" forever.
*
* So this is a BUILD test, not a unit test: it runs against `out/main` and is
* wired into `npm run build` / `npm run dist` / `npm run dist:dir` (after the
* build, before electron-builder). It is deliberately NOT in `npm test`: CI runs
* `npm test` before `npm run build`, so a test that requires build output would
* fail a clean checkout.
*
* What it proves, in a real thread: the artifact loads, answers a benign pattern
* within budget with the corpus size and probe version the source declares, and
* reports a pattern that does not compile as `invalid-regex` instead of crashing.
*
* Run: node tests/worker-artifact.mjs (must exit 0; needs a completed build)
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { dirname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Worker } from 'node:worker_threads'
import { PROBE_BUDGET_MS, PROBE_VERSION, REDOS_PROBES } from '../src/shared/redosProbes.ts'
const __dirname = dirname(fileURLToPath(import.meta.url))
const OUT_MAIN = join(__dirname, '..', 'out', 'main')
/** How long the artifact gets to answer one request before it counts as broken. */
const REPLY_TIMEOUT_MS = 10_000
/**
* Content fallback for the case where the emitted chunk no longer looks like the
* worker by name (a build-tool rename must not silently turn this test into a
* no-op). `parentPort` is the worker ENTRY marker — the corpus literal is not
* usable here, because electron-vite emits it as its own shared data chunk that
* is `require`d by both the worker and the main bundle.
*/
const WORKER_MARKERS = ['parentPort', 'worker_threads']
let failed = 0
let passed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
else passed += 1
}
/** Every `.js`/`.cjs`/`.mjs` file under a directory, recursively. */
function listScripts(dir) {
const out = []
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return out
}
for (const entry of entries) {
const full = join(dir, entry.name)
if (entry.isDirectory()) out.push(...listScripts(full))
else if (/\.(js|cjs|mjs)$/.test(entry.name)) out.push(full)
}
return out
}
/**
* The emitted worker chunk: by name when the build still names it after the
* source file, otherwise by the content markers a worker entry point must carry.
*/
function findWorkerArtifact(scripts) {
const byName = scripts.filter((file) => /redosProbeWorker/i.test(relative(OUT_MAIN, file)))
if (byName.length > 0) return { files: byName, how: 'file name' }
const byContent = scripts.filter((file) => {
try {
if (statSync(file).size >= 5_000_000) return false
const text = readFileSync(file, 'utf8')
return WORKER_MARKERS.every((marker) => text.includes(marker))
} catch {
return false
}
})
return { files: byContent, how: 'worker markers' }
}
/** One request/answer round trip against the artifact, in a real thread. */
function ask(artifact, request, timeoutMs = REPLY_TIMEOUT_MS) {
const worker = new Worker(artifact)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
void worker.terminate()
reject(new Error(`no reply within ${timeoutMs}ms`))
}, timeoutMs)
const done = (fn, value) => {
clearTimeout(timer)
fn(value)
}
worker.on('message', (message) => done(resolve, message))
worker.on('error', (err) => done(reject, err))
worker.on('exit', (code) => done(reject, new Error(`worker exited (code ${code}) before answering`)))
worker.postMessage(request)
}).finally(() => {
void worker.terminate()
})
}
console.log('[worker artifact]')
{
const scripts = listScripts(OUT_MAIN)
if (scripts.length === 0) {
console.error(`\n[worker-artifact] FAILED: no build output under ${OUT_MAIN} — run "npm run build" first`)
process.exit(1)
}
const { files, how } = findWorkerArtifact(scripts)
ok(files.length > 0, `the probe worker chunk is present in out/main (found by ${how})`)
if (files.length === 0) {
console.error(`\n[worker-artifact] FAILED: ${scripts.length} build script(s) scanned, none is the probe worker`)
process.exit(1)
}
console.log(` info: ${files.map((file) => relative(OUT_MAIN, file)).join(', ')}`)
const artifact = files[0]
// 1. a benign rule: the answer carries the corpus it actually ran.
let reply
try {
reply = await ask(artifact, { id: 7, pattern: '\\bOK\\b', flags: 'g' })
} catch (err) {
ok(false, `the artifact answers a benign probe (${err instanceof Error ? err.message : String(err)})`)
reply = null
}
if (reply !== null) {
ok(reply.id === 7, `the answer echoes the request id (${reply.id})`)
ok(reply.ok === true, `a benign pattern is reported as ok (${JSON.stringify(reply)})`)
ok(reply.probeVersion === PROBE_VERSION, `the answer carries the source probe version (${reply.probeVersion})`)
ok(
reply.probes === REDOS_PROBES.length,
`the shipped corpus is what ran (${reply.probes} of ${REDOS_PROBES.length} probes)`
)
ok(
typeof reply.worstMs === 'number' && Number.isFinite(reply.worstMs) && reply.worstMs >= 0,
`the slowest probe is a real measurement (${reply.worstMs}ms)`
)
ok(
typeof reply.worstMs === 'number' && reply.worstMs < PROBE_BUDGET_MS,
`a benign rule stays inside the budget (${reply.worstMs}ms < ${PROBE_BUDGET_MS}ms)`
)
}
// 2. a pattern that does not compile: a verdict, not a crash.
let invalid
try {
invalid = await ask(artifact, { id: 8, pattern: '(', flags: 'g' })
} catch (err) {
ok(false, `the artifact survives an uncompilable pattern (${err instanceof Error ? err.message : String(err)})`)
invalid = null
}
if (invalid !== null) {
ok(invalid.ok === false && invalid.reason === 'invalid-regex', `an uncompilable pattern is reported as such (${JSON.stringify(invalid)})`)
ok(invalid.id === 8, 'the failure answer echoes the request id too')
}
}
console.log(`\n[worker-artifact] ${passed} passed, ${failed} failed`)
if (failed > 0) {
console.error(`\n[worker-artifact] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[worker-artifact] ALL CHECKS PASSED')