Skip to content

Commit cf3c5d0

Browse files
test: nightly parser fuzz lane with typed-AppError invariant (#1414)
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent e8b779c commit cf3c5d0

15 files changed

Lines changed: 907 additions & 6 deletions

File tree

‎.github/workflows/replays-nightly.yml‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ on:
44
schedule:
55
- cron: '0 3 * * *'
66
workflow_dispatch:
7+
inputs:
8+
fuzz-iterations:
9+
description: 'Parser fuzz cases per target'
10+
required: false
11+
default: '50000'
12+
fuzz-seed:
13+
description: 'Parser fuzz PRNG seed (defaults to the run number)'
14+
required: false
715

816
permissions:
917
contents: read
@@ -13,6 +21,41 @@ concurrency:
1321
cancel-in-progress: true
1422

1523
jobs:
24+
# Parser fuzz lane (#1414): hostile input into parseArgs, selector parsing, .ad replay
25+
# scripts, batch --steps JSON, and the Maestro compat parser. One invariant — every
26+
# rejection is a typed AppError with a non-empty hint, and no case hangs. Device-free, so
27+
# it rides this nightly rather than owning a workflow; the seed varies per run so the lane
28+
# keeps exploring, and anything it catches is appended to the checked-in corpus that the
29+
# unit lane replays (scripts/fuzz/corpus/regressions.json).
30+
nightly-parser-fuzz:
31+
name: Parser Fuzz Lane
32+
runs-on: ubuntu-latest
33+
timeout-minutes: 20
34+
steps:
35+
- name: Checkout
36+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
37+
38+
- name: Setup toolchain
39+
uses: ./.github/actions/setup-node-pnpm
40+
41+
- name: Fuzz parsers
42+
env:
43+
FUZZ_ITERATIONS: ${{ github.event.inputs.fuzz-iterations || '50000' }}
44+
FUZZ_SEED: ${{ github.event.inputs.fuzz-seed || github.run_number }}
45+
run: |
46+
pnpm fuzz:parsers \
47+
--iterations "$FUZZ_ITERATIONS" \
48+
--seed "$FUZZ_SEED" \
49+
--artifact-dir .tmp/fuzz
50+
51+
- name: Upload failing cases
52+
if: failure()
53+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
54+
with:
55+
name: parser-fuzz-failing-cases
56+
path: .tmp/fuzz
57+
if-no-files-found: ignore
58+
1659
nightly-android:
1760
name: Android Replay Suite
1861
runs-on: ubuntu-latest

‎docs/agents/testing.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,25 @@ The plan documents the rule and changed path behind every selected check.
103103
Model and catalog live under `scripts/check-affected/`; the derivation is guarded
104104
by `pnpm check:affected:test` (the `Affected-check Selector` CI job).
105105

106+
## Parser fuzz lane
107+
108+
`pnpm fuzz:parsers` feeds mutated hostile input to `parseArgs`, selector parsing,
109+
`parseReplayScriptDetailed`, `batch --steps` JSON, and the Maestro compat parser, and enforces one
110+
invariant: every rejection is a typed `AppError` whose normalized `hint` is non-empty, and no case
111+
hangs (a worker-thread watchdog attributes a stall to the exact input).
112+
113+
```sh
114+
pnpm fuzz:parsers # all targets, 2,000 cases each, seed 1
115+
pnpm fuzz:parsers --target selector --iterations 50000 --seed 7
116+
pnpm fuzz:parsers --input-file .tmp/fuzz/<case>.json # repro one saved failing case
117+
```
118+
119+
The generating run is nightly (`Parser Fuzz Lane` in `.github/workflows/replays-nightly.yml`, seeded
120+
by the run number); failing cases upload as artifacts and print that repro command. Every case the
121+
fuzzer catches is appended to `scripts/fuzz/corpus/regressions.json` (`--append-corpus`) and replayed
122+
in the unit lane by `scripts/fuzz/corpus-replay.test.ts`, so a fixed parser stays fixed on PRs.
123+
Adding a parser to the lane means adding a target to `scripts/fuzz/targets.ts` — nothing else.
124+
106125
## Live web smoke
107126

108127
The live web platform smoke runs the public built CLI against a local fixture page through the managed web backend:

‎package.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,9 @@
109109
"perf:ios": "node --experimental-strip-types scripts/perf/run.ts --platform ios",
110110
"perf:android": "node --experimental-strip-types scripts/perf/run.ts --platform android",
111111
"lint": "oxlint . --deny-warnings",
112-
"format": "node ./node_modules/oxfmt/bin/oxfmt --write src test skills scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'",
113-
"format:check": "node ./node_modules/oxfmt/bin/oxfmt --check src test skills scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'",
112+
"format": "node ./node_modules/oxfmt/bin/oxfmt --write src test skills scripts/fuzz scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'",
113+
"format:check": "node ./node_modules/oxfmt/bin/oxfmt --check src test skills scripts/fuzz scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'",
114+
"fuzz:parsers": "node --experimental-strip-types scripts/fuzz/run.ts",
114115
"fallow": "fallow audit --base origin/main",
115116
"fallow:all": "fallow --summary",
116117
"fallow:baseline": "(fallow dead-code --save-baseline fallow-baselines/dead-code.json --summary || true) && (fallow health --save-baseline fallow-baselines/health.json --summary || true)",

‎scripts/fuzz/corpus-replay.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Unit-lane replay of the parser fuzz regression corpus (#1414).
2+
//
3+
// The nightly lane finds cases; this replays every case it ever found, in-process and
4+
// without a watchdog, so a regression fails in seconds on a PR instead of a night later.
5+
// Cases run synchronously here on purpose: a corpus case that hangs would hang the unit
6+
// suite, which is exactly the signal (the nightly lane is where hangs are diagnosed).
7+
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9+
import { readCorpus } from './corpus.ts';
10+
import { checkCase } from './invariant.ts';
11+
import { FUZZ_TARGETS, getFuzzTarget } from './targets.ts';
12+
import { generateCases } from './mutate.ts';
13+
14+
describe('parser fuzz regression corpus', () => {
15+
const corpus = readCorpus();
16+
17+
// The batch-steps parser warns on deprecated step shapes; replaying those cases would
18+
// print one warning line per case into the unit-suite output.
19+
beforeEach(() => void vi.spyOn(process.stderr, 'write').mockReturnValue(true));
20+
afterEach(() => void vi.restoreAllMocks());
21+
22+
it('is non-empty and free of duplicates', () => {
23+
expect(corpus.length).toBeGreaterThan(0);
24+
const keys = corpus.map((entry) => `${entry.target}\u0000${entry.input}`);
25+
expect(new Set(keys).size).toBe(keys.length);
26+
});
27+
28+
it('names only known targets and explains every entry', () => {
29+
for (const entry of corpus) {
30+
expect(() => getFuzzTarget(entry.target)).not.toThrow();
31+
expect(entry.note.trim()).not.toBe('');
32+
}
33+
});
34+
35+
it.each(corpus.map((entry, index) => [index, entry] as const))(
36+
'case %i holds the typed-AppError invariant',
37+
(_index, entry) => {
38+
expect(checkCase(getFuzzTarget(entry.target), entry.input)).toBeNull();
39+
},
40+
);
41+
42+
it.each(FUZZ_TARGETS.map((target) => [target.name, target] as const))(
43+
'%s seeds hold the invariant',
44+
(_name, target) => {
45+
for (const seed of target.seeds) expect(checkCase(target, seed)).toBeNull();
46+
},
47+
);
48+
});
49+
50+
describe('fuzz case generation', () => {
51+
it('is deterministic for a seed, so a repro command reproduces', () => {
52+
const seeds = ['text=Login', 'label="Sign in" && role=button'];
53+
expect(generateCases(seeds, 32, 7)).toEqual(generateCases(seeds, 32, 7));
54+
expect(generateCases(seeds, 32, 7)).not.toEqual(generateCases(seeds, 32, 8));
55+
});
56+
57+
it('always covers the verbatim seeds before mutating', () => {
58+
const seeds = ['a', 'b', 'c'];
59+
expect(generateCases(seeds, 1, 1)).toEqual(seeds);
60+
expect(generateCases(seeds, 10, 1).slice(0, 3)).toEqual(seeds);
61+
});
62+
});

‎scripts/fuzz/corpus.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// The checked-in regression corpus for the parser fuzz lane (#1414).
2+
//
3+
// Every input the fuzzer ever catches is appended here and replayed by the unit lane
4+
// (scripts/fuzz/corpus-replay.test.ts), so a fixed parser stays fixed without waiting for
5+
// the nightly to rediscover the case. Entries are sorted and deduplicated on write, which
6+
// keeps the diff of an append small and the replay order deterministic.
7+
8+
import fs from 'node:fs';
9+
import path from 'node:path';
10+
import { fileURLToPath } from 'node:url';
11+
import type { FuzzTargetName } from './targets.ts';
12+
13+
export type CorpusEntry = {
14+
target: FuzzTargetName;
15+
/** The failing input, verbatim. */
16+
input: string;
17+
/** Why it was added — the invariant it broke, or where it came from. */
18+
note: string;
19+
};
20+
21+
const CORPUS_PATH = path.join(
22+
path.dirname(fileURLToPath(import.meta.url)),
23+
'corpus',
24+
'regressions.json',
25+
);
26+
27+
export function readCorpus(corpusPath = CORPUS_PATH): CorpusEntry[] {
28+
const raw = fs.readFileSync(corpusPath, 'utf8');
29+
const parsed: unknown = JSON.parse(raw);
30+
if (!Array.isArray(parsed)) throw new Error(`${corpusPath} must contain a JSON array.`);
31+
return parsed.map((entry, index) => readEntry(entry, index, corpusPath));
32+
}
33+
34+
function readEntry(entry: unknown, index: number, corpusPath: string): CorpusEntry {
35+
if (entry === null || typeof entry !== 'object') {
36+
throw new Error(`${corpusPath}[${index}] must be an object.`);
37+
}
38+
const record = entry as Record<string, unknown>;
39+
const { target, input, note } = record;
40+
if (typeof target !== 'string' || typeof input !== 'string' || typeof note !== 'string') {
41+
throw new Error(`${corpusPath}[${index}] needs string target, input, and note fields.`);
42+
}
43+
return { target: target as FuzzTargetName, input, note };
44+
}
45+
46+
function entryKey(entry: CorpusEntry): string {
47+
return `${entry.target}\u0000${entry.input}`;
48+
}
49+
50+
/** Merges `additions` into the corpus file. Returns the entries actually added. */
51+
export function appendToCorpus(
52+
additions: readonly CorpusEntry[],
53+
corpusPath = CORPUS_PATH,
54+
): CorpusEntry[] {
55+
const existing = readCorpus(corpusPath);
56+
const seen = new Set(existing.map(entryKey));
57+
const added = additions.filter((entry) => {
58+
if (seen.has(entryKey(entry))) return false;
59+
seen.add(entryKey(entry));
60+
return true;
61+
});
62+
if (added.length === 0) return [];
63+
const merged = [...existing, ...added].sort((a, b) => entryKey(a).localeCompare(entryKey(b)));
64+
fs.writeFileSync(corpusPath, `${JSON.stringify(merged, null, 2)}\n`);
65+
return added;
66+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
[
2+
{
3+
"target": "batch-steps",
4+
"input": "[{\"command\":\"snapshot\",\"input\":{\"__proto__\":{\"polluted\":true}}}]",
5+
"note": "seed: prototype-pollution shaped batch step must reject or ignore, never throw untyped"
6+
},
7+
{
8+
"target": "cli-args",
9+
"input": "click --selector",
10+
"note": "seed: flag with a missing value must reject as INVALID_ARGS with a hint"
11+
},
12+
{
13+
"target": "maestro",
14+
"input": "appId: com.example.app\n---\n- tapOn: \"unterminated\n",
15+
"note": "seed: unterminated YAML scalar must surface as a typed parse error"
16+
},
17+
{
18+
"target": "replay-script",
19+
"input": "# target-v1 role=button\n\nclick @e1\n",
20+
"note": "seed: unbound target-v1 annotation must reject with a typed error"
21+
},
22+
{
23+
"target": "replay-script",
24+
"input": "fill @e1 --text \"hello\tworld\"\na",
25+
"note": "untyped-throw: raw tab inside a quoted value leaked a SyntaxError (fixed in readQuotedReplayToken)"
26+
},
27+
{
28+
"target": "replay-script",
29+
"input": "fill @e1 --text \"hello wor\\ld\"\nassert text=Welcome\n",
30+
"note": "untyped-throw: quoted value with an invalid JSON escape leaked a SyntaxError (fixed in readQuotedReplayToken)"
31+
},
32+
{
33+
"target": "replay-script",
34+
"input": "fill @e1 --text \"hel\u0000lo world\"\n",
35+
"note": "untyped-throw: raw control character inside a quoted value leaked a SyntaxError (fixed in readQuotedReplayToken)"
36+
},
37+
{
38+
"target": "selector",
39+
"input": "label=\"unterminated",
40+
"note": "seed: unterminated quoted selector value must reject with a typed error"
41+
}
42+
]

‎scripts/fuzz/invariant.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// The single invariant the parser fuzz lane enforces (#1414).
2+
//
3+
// Parsers are the front door for agent-authored input, so the contract is not "parses
4+
// correctly" (nobody can say what a mutated string should mean) but "fails well":
5+
//
6+
// 1. a rejection is an `AppError` — never a bare Error, TypeError, string, or undefined;
7+
// 2. the normalized error carries a non-empty `hint`, so the caller is told what to do;
8+
// 3. the case terminates — enforced by the harness watchdog, not by this module, because
9+
// synchronous parsers cannot be interrupted from inside their own tick.
10+
11+
import { AppError, normalizeError } from '../../src/kernel/errors.ts';
12+
import type { FuzzTarget, FuzzTargetName } from './targets.ts';
13+
14+
export type FuzzFailureKind = 'untyped-throw' | 'empty-hint' | 'hang';
15+
16+
export type FuzzFailure = {
17+
target: FuzzTargetName;
18+
input: string;
19+
kind: FuzzFailureKind;
20+
detail: string;
21+
};
22+
23+
/**
24+
* Runs one case and returns the invariant violation it produced, or `null`.
25+
* Accepting the parse is a pass: the lane judges rejections, not results.
26+
*/
27+
export function checkCase(target: FuzzTarget, input: string): FuzzFailure | null {
28+
try {
29+
target.run(input);
30+
return null;
31+
} catch (error) {
32+
if (!(error instanceof AppError)) {
33+
return {
34+
target: target.name,
35+
input,
36+
kind: 'untyped-throw',
37+
detail: describeThrown(error),
38+
};
39+
}
40+
const hint = normalizeError(error).hint;
41+
if (typeof hint !== 'string' || hint.trim().length === 0) {
42+
return {
43+
target: target.name,
44+
input,
45+
kind: 'empty-hint',
46+
detail: `AppError ${error.code} has no hint: ${error.message}`,
47+
};
48+
}
49+
return null;
50+
}
51+
}
52+
53+
function describeThrown(error: unknown): string {
54+
if (error instanceof Error) {
55+
const stackLine = error.stack?.split('\n')[1]?.trim();
56+
return `${error.name}: ${error.message}${stackLine ? ` (at ${stackLine})` : ''}`;
57+
}
58+
return `non-Error throw: ${typeof error} ${String(error)}`;
59+
}
60+
61+
export function describeFailure(failure: FuzzFailure): string {
62+
return `[${failure.target}] ${failure.kind}: ${failure.detail}`;
63+
}

0 commit comments

Comments
 (0)