|
| 1 | +import { expect, test } from "bun:test"; |
| 2 | +import { spawn } from "node:child_process"; |
| 3 | +import { join } from "node:path"; |
| 4 | +import { ROOT } from "../root.js"; |
| 5 | +import { withTempDir } from "../temp-dir.js"; |
| 6 | + |
| 7 | +const RUN = join(ROOT, "test", "e2e", "run.ts"); |
| 8 | +const USAGE = "the flags are --sections <key,key|all>, --scenario <name>, --print-stdout"; |
| 9 | + |
| 10 | +/** A runner that does not refuse at the entry is into the corpus with a node child under it, so the bound kills the |
| 11 | + * process group, not one pid, and the scenario directories it leaves land in the case's own TMPDIR. */ |
| 12 | +function run( |
| 13 | + args: string[], |
| 14 | +): Promise<{ exitCode: number | string | null; stdout: string; stderr: string }> { |
| 15 | + return withTempDir( |
| 16 | + "run-flags-", |
| 17 | + (tmp) => |
| 18 | + new Promise((resolve) => { |
| 19 | + const child = spawn(process.execPath, [RUN, ...args], { |
| 20 | + cwd: ROOT, |
| 21 | + env: { ...process.env, TMPDIR: tmp }, |
| 22 | + stdio: ["ignore", "pipe", "pipe"], |
| 23 | + detached: true, |
| 24 | + }); |
| 25 | + let stdout = ""; |
| 26 | + let stderr = ""; |
| 27 | + child.stdout.setEncoding("utf8").on("data", (chunk: string) => { |
| 28 | + stdout += chunk; |
| 29 | + }); |
| 30 | + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { |
| 31 | + stderr += chunk; |
| 32 | + }); |
| 33 | + const bound = setTimeout(() => { |
| 34 | + if (child.pid !== undefined) { |
| 35 | + process.kill(-child.pid, "SIGKILL"); |
| 36 | + } |
| 37 | + }, 10_000); |
| 38 | + child.on("close", (code, signal) => { |
| 39 | + clearTimeout(bound); |
| 40 | + resolve({ exitCode: code ?? signal, stdout, stderr }); |
| 41 | + }); |
| 42 | + }), |
| 43 | + ); |
| 44 | +} |
| 45 | + |
| 46 | +// The hand-rolled parser these replaced ignored every argument it did not recognize, so a typo'd flag, an inline |
| 47 | +// `--sections=labels`, or a stray word ran the whole corpus for minutes and exited green. Each case is an input |
| 48 | +// the shell cannot refuse for the script. |
| 49 | +test.each<[label: string, args: string[], error: string]>([ |
| 50 | + ["--sections with no value", ["--sections"], `--sections needs a value; ${USAGE}`], |
| 51 | + ["--scenario with no value", ["--scenario"], `--scenario needs a value; ${USAGE}`], |
| 52 | + [ |
| 53 | + "--sections with an empty list", |
| 54 | + ["--sections", " , "], |
| 55 | + `--sections got an empty list; name at least one section, or all; ${USAGE}`, |
| 56 | + ], |
| 57 | + ["a misspelled flag", ["--section", "labels"], `unknown flag --section; ${USAGE}`], |
| 58 | + // parseArgs hands a string flag the next word whatever it is, so a "--" or a flag standing where the value |
| 59 | + // belongs would otherwise become the value: here the later "all" would clear the filter and run the corpus. |
| 60 | + [ |
| 61 | + "--sections swallowing a --", |
| 62 | + ["--sections", "--", "--sections", "all"], |
| 63 | + `--sections needs a value; ${USAGE}`, |
| 64 | + ], |
| 65 | + [ |
| 66 | + "--sections swallowing the next flag", |
| 67 | + ["--sections", "--print-stdout"], |
| 68 | + `--sections needs a value; ${USAGE}`, |
| 69 | + ], |
| 70 | + ["a stray argument", ["labels"], `unexpected argument "labels"; ${USAGE}`], |
| 71 | + // bun drops a "--" that directly follows the script path, so the row puts a flag ahead of it. |
| 72 | + [ |
| 73 | + "a -- and the flags after it", |
| 74 | + ["--print-stdout", "--", "--sections", "labels"], |
| 75 | + `unexpected argument "--"; ${USAGE}`, |
| 76 | + ], |
| 77 | + [ |
| 78 | + "--print-stdout with a value", |
| 79 | + ["--print-stdout=yes"], |
| 80 | + `--print-stdout takes no value; ${USAGE}`, |
| 81 | + ], |
| 82 | +])( |
| 83 | + "%s is refused at the entry, naming the flag and the fix", |
| 84 | + async (_label, args, error) => { |
| 85 | + expect(await run(args)).toEqual({ exitCode: 2, stdout: "", stderr: `e2e: ${error}\n` }); |
| 86 | + }, |
| 87 | + 20_000, |
| 88 | +); |
| 89 | + |
| 90 | +// A filter nothing matches exits 1 before any scenario runs, and its message echoes the filters as parsed, so the |
| 91 | +// two parsings the old code got wrong (an inline value, a later `all`) are pinned without running the corpus. |
| 92 | +test.each<[label: string, args: string[], filters: string]>([ |
| 93 | + [ |
| 94 | + "--sections=<key> is read as the flag's value", |
| 95 | + ["--sections=no-such-section"], |
| 96 | + '--sections "no-such-section"', |
| 97 | + ], |
| 98 | + [ |
| 99 | + "a later --sections all clears the list", |
| 100 | + ["--scenario", "no-such-scenario", "--sections", "labels", "--sections", "all"], |
| 101 | + '--scenario "no-such-scenario"', |
| 102 | + ], |
| 103 | +])( |
| 104 | + "%s", |
| 105 | + async (_label, args, filters) => { |
| 106 | + const result = await run(args); |
| 107 | + expect([result.exitCode, result.stdout]).toEqual([1, ""]); |
| 108 | + expect(result.stderr).toMatch( |
| 109 | + new RegExp( |
| 110 | + `^none of the \\d+ scenario\\(s\\) under .* match ${filters.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}; check`, |
| 111 | + ), |
| 112 | + ); |
| 113 | + }, |
| 114 | + 20_000, |
| 115 | +); |
0 commit comments