mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
006c4cadc9
* test: nightly parser fuzz lane with typed-AppError invariant (#1414) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(fuzz): run envelope, artifact promotion, and harness self-check tests (#1414) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(fuzz): shared scheduled-lane envelope on every terminal path, watchdog after ready (#1414) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(fuzz): envelope for malformed options; add scheduled-lane health consumer (#1414, #1430) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(lanes): actions:read scope, terminal error envelope, first-due grace (#1414, #1430) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(lanes): anchor first-run grace to schedule registration, use exec helper in tests (#1414, #1430) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(lanes): portable POSIX pickaxe pattern for schedule registration (#1414, #1430) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(fuzz): fast-check generators over the shared hazard list, drop the bundled lane-health work (#1414) - Strip scripts/scheduled-lane/* and scheduled-lane-health.yml: that watcher is #1430's own deliverable and collides with PR #1439's implementation of the same lane. What this lane owes (a per-run envelope) moves into scripts/fuzz/envelope.ts. - Rebase onto #1437 and rebuild the generator layer on fast-check: cases come from arbitraries sharing SELECTOR_VALUE_HAZARDS with the property suite, and counterexamples are shrunk, so a failure names a minimal input plus fast-check's seed/path instead of a 20k-char random string. - Route harness.test.ts into the serialized subprocess-stub project. - Drop the AGENT_DEVICE_FUZZ_STARTUP_DELAY_MS test seam: the ready handshake is now proven by a case budget far below real worker startup. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(fuzz): replay the regression corpus through the worker watchdog (#1414) A promoted hang case used to wedge the unit job until the CI timeout, because corpus replay called checkCase in-process. It now goes through the same worker-backed watchdog the nightly lane uses, so such a case fails against a 5s per-case budget; the file moves to the serialized subprocess-stub project with the rest of the worker-driven fuzz tests. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(fuzz): let the watchdog outlive vitest's default case timeout (#1414) A wedged parser was surfacing as a bare 'Test timed out in 5000ms' instead of the named hang: failure that says which input wedged, because the file's vitest timeout was shorter than the watchdog budget times the number of replayed cases. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(fuzz): complete drift provenance in the lane envelope (#1414) configHash now covers every input that decides what a seed generates (generate.ts and the shared property arbitraries, not just the arbitraries/targets/invariant), and tool records fast-check's installed version. A generation-loop edit or a fast-check upgrade previously changed the case set while the envelope looked unchanged. A test recomputes the hash with each input omitted so a future omission fails. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
220 lines
7.7 KiB
TypeScript
220 lines
7.7 KiB
TypeScript
// Entry point for `pnpm fuzz:parsers` — the nightly parser fuzz lane (#1414).
|
|
//
|
|
// Feeds mutated hostile input to the CLI/selector/replay/batch/Maestro parsers and enforces
|
|
// one invariant: a rejection is a typed AppError with a non-empty hint, and no case hangs.
|
|
// Every terminal path — pass, fail, self-check, or a crash in the harness itself — writes one
|
|
// scheduled-lane envelope, so monitoring never reads a missing file. Failing cases are written
|
|
// as artifacts and printed with repro and corpus-promotion commands.
|
|
|
|
import { readCorpus } from './corpus.ts';
|
|
import {
|
|
type FuzzEnvelopeDetails,
|
|
type FuzzRunMode,
|
|
type FuzzTargetRun,
|
|
writeFuzzEnvelope,
|
|
} from './envelope.ts';
|
|
import { runCases } from './execute.ts';
|
|
import { generateAndCheck } from './generate.ts';
|
|
import { describeFailure, type FuzzFailure } from './invariant.ts';
|
|
import { fallbackFuzzOptions, FUZZ_USAGE, readFuzzOptions, type FuzzOptions } from './options.ts';
|
|
import { getFuzzTarget } from './registry.ts';
|
|
import { promoteFailures, reportFailures } from './report.ts';
|
|
import { readSavedCase } from './saved-case.ts';
|
|
import { runSelfCheck } from './self-check.ts';
|
|
import type { FuzzTarget } from './target-types.ts';
|
|
import { FUZZ_TARGETS } from './targets.ts';
|
|
|
|
function selectTargets(name: string | undefined): FuzzTarget[] {
|
|
return name === undefined ? [...FUZZ_TARGETS] : [getFuzzTarget(name)];
|
|
}
|
|
|
|
function corpusCasesFor(target: FuzzTarget): string[] {
|
|
return readCorpus()
|
|
.filter((entry) => entry.target === target.name)
|
|
.map((entry) => entry.input);
|
|
}
|
|
|
|
/** One target's run: generated (fast-check, shrinking) or a replay of the checked-in corpus. */
|
|
async function runOneTarget(
|
|
target: FuzzTarget,
|
|
options: FuzzOptions,
|
|
): Promise<{ cases: number; failures: FuzzFailure[]; replayHint?: string }> {
|
|
if (options.replayCorpus) {
|
|
const cases = corpusCasesFor(target);
|
|
return { cases: cases.length, failures: await runCases(target, cases, options.caseTimeoutMs) };
|
|
}
|
|
const run = await generateAndCheck(target, options);
|
|
return {
|
|
cases: run.cases,
|
|
failures: run.failure ? [run.failure] : [],
|
|
...(run.replay
|
|
? { replayHint: `fast-check replay: --seed ${run.replay.seed} (path ${run.replay.path})` }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
/** Fuzzes every selected target, printing a per-target line as each finishes. */
|
|
async function fuzzTargets(targets: readonly FuzzTarget[], options: FuzzOptions) {
|
|
const failures: FuzzFailure[] = [];
|
|
const targetRuns: FuzzTargetRun[] = [];
|
|
for (const target of targets) {
|
|
const started = Date.now();
|
|
const { cases, failures: found, replayHint } = await runOneTarget(target, options);
|
|
const durationMs = Date.now() - started;
|
|
targetRuns.push({ target: target.name, cases, failures: found.length, durationMs });
|
|
process.stdout.write(
|
|
`${target.name}: ${cases} cases, ${found.length} failures (${durationMs}ms)\n`,
|
|
);
|
|
if (replayHint !== undefined) process.stdout.write(` ${replayHint}\n`);
|
|
failures.push(...found);
|
|
}
|
|
return { failures, targetRuns };
|
|
}
|
|
|
|
function verdictLine(targetName: string, failed: boolean): string {
|
|
return failed
|
|
? `Case still violates the invariant (${targetName}).\n`
|
|
: `Case passes the invariant now (${targetName}).\n`;
|
|
}
|
|
|
|
type ModeOutcome = {
|
|
mode: FuzzRunMode;
|
|
failed: boolean;
|
|
targetRuns: FuzzTargetRun[];
|
|
details?: Partial<FuzzEnvelopeDetails>;
|
|
summary: (envelope: string) => string;
|
|
};
|
|
|
|
/** Replays a saved artifact, optionally promoting it into the checked-in corpus. */
|
|
async function replaySavedCase(options: FuzzOptions): Promise<ModeOutcome> {
|
|
const started = Date.now();
|
|
const { target, input } = readSavedCase(options.inputFile!);
|
|
const failures = await runCases(target, [input], options.caseTimeoutMs);
|
|
for (const failure of failures) process.stdout.write(`${describeFailure(failure)}\n`);
|
|
process.stdout.write(verdictLine(target.name, failures.length > 0));
|
|
if (options.appendCorpus) promoteFailures(failures);
|
|
return {
|
|
mode: 'replay-artifact',
|
|
failed: failures.length > 0,
|
|
targetRuns: [
|
|
{
|
|
target: target.name,
|
|
cases: 1,
|
|
failures: failures.length,
|
|
durationMs: Date.now() - started,
|
|
},
|
|
],
|
|
details: { failures },
|
|
summary: (envelope) => `Envelope: ${envelope}\n`,
|
|
};
|
|
}
|
|
|
|
async function selfCheckMode(options: FuzzOptions): Promise<ModeOutcome> {
|
|
const { ok, targetRuns } = await runSelfCheck(options);
|
|
return {
|
|
mode: 'self-check',
|
|
failed: !ok,
|
|
targetRuns,
|
|
summary: (envelope) => `Envelope: ${envelope}\n`,
|
|
};
|
|
}
|
|
|
|
async function fuzzMode(options: FuzzOptions): Promise<ModeOutcome> {
|
|
const targets = selectTargets(options.target);
|
|
const { failures, targetRuns } = await fuzzTargets(targets, options);
|
|
const { reported, reproCommands } = reportFailures(failures, options.artifactDir);
|
|
if (options.appendCorpus) promoteFailures(failures);
|
|
const failed = failures.length > 0;
|
|
return {
|
|
mode: options.replayCorpus ? 'replay-corpus' : 'generate',
|
|
failed,
|
|
targetRuns,
|
|
details: { failures: reported, reproCommands },
|
|
summary: (envelope) =>
|
|
failed
|
|
? `\nEnvelope: ${envelope}\n`
|
|
: `Invariant held across ${targets.length} target(s), seed ${options.seed}. Envelope: ${envelope}\n`,
|
|
};
|
|
}
|
|
|
|
function runMode(options: FuzzOptions): Promise<ModeOutcome> {
|
|
if (options.selfCheck) return selfCheckMode(options);
|
|
if (options.inputFile !== undefined) return replaySavedCase(options);
|
|
return fuzzMode(options);
|
|
}
|
|
|
|
function configFor(options: FuzzOptions): Record<string, unknown> {
|
|
return {
|
|
seed: options.seed,
|
|
iterations: options.iterations,
|
|
caseTimeoutMs: options.caseTimeoutMs,
|
|
targets: options.target === undefined ? 'all' : [options.target],
|
|
};
|
|
}
|
|
|
|
/** Writes the envelope every terminal path owes the scheduled-lane contract. */
|
|
function writeOutcomeEnvelope(
|
|
options: FuzzOptions,
|
|
startedAt: number,
|
|
outcome: ModeOutcome,
|
|
result: 'pass' | 'fail' | 'error',
|
|
): string {
|
|
return writeFuzzEnvelope({
|
|
artifactDir: options.artifactDir,
|
|
startedAt,
|
|
finishedAt: Date.now(),
|
|
result,
|
|
config: configFor(options),
|
|
details: {
|
|
mode: outcome.mode,
|
|
corpusEntries: readCorpus().length,
|
|
targetRuns: outcome.targetRuns,
|
|
failures: [],
|
|
reproCommands: [],
|
|
...outcome.details,
|
|
},
|
|
});
|
|
}
|
|
|
|
/** A harness crash still owes an envelope, otherwise monitoring sees nothing at all. */
|
|
function reportCrash(options: FuzzOptions, startedAt: number, error: unknown): number {
|
|
const mode: FuzzRunMode = options.selfCheck ? 'self-check' : 'generate';
|
|
const outcome: ModeOutcome = { mode, failed: true, targetRuns: [], summary: () => '' };
|
|
const envelope = writeOutcomeEnvelope(options, startedAt, outcome, 'error');
|
|
process.stderr.write(`${String(error)}\nEnvelope: ${envelope}\n`);
|
|
return 1;
|
|
}
|
|
|
|
async function run(options: FuzzOptions, startedAt: number): Promise<number> {
|
|
try {
|
|
const outcome = await runMode(options);
|
|
const result = outcome.failed ? 'fail' : 'pass';
|
|
process.stdout.write(
|
|
outcome.summary(writeOutcomeEnvelope(options, startedAt, outcome, result)),
|
|
);
|
|
return outcome.failed ? 1 : 0;
|
|
} catch (error) {
|
|
return reportCrash(options, startedAt, error);
|
|
}
|
|
}
|
|
|
|
async function main(): Promise<number> {
|
|
const startedAt = Date.now();
|
|
const argv = process.argv.slice(2);
|
|
// Option parsing is inside the guarded path on purpose: a malformed workflow-dispatch input is
|
|
// exactly the kind of terminal failure monitoring must still see an envelope for.
|
|
let options: FuzzOptions | null;
|
|
try {
|
|
options = readFuzzOptions(argv);
|
|
} catch (error) {
|
|
return reportCrash(fallbackFuzzOptions(argv), startedAt, error);
|
|
}
|
|
if (!options) {
|
|
process.stdout.write(FUZZ_USAGE);
|
|
return 0;
|
|
}
|
|
return await run(options, startedAt);
|
|
}
|
|
|
|
process.exitCode = await main();
|