Skip to content

Commit f445397

Browse files
committed
feat(test): add "test lint" offline plan/steps validator
1 parent e53257d commit f445397

3 files changed

Lines changed: 248 additions & 0 deletions

File tree

src/commands/test.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
runFailureGet,
3131
runFailureSummary,
3232
runGet,
33+
runLint,
3334
runList,
3435
runPlanPut,
3536
runResult,
@@ -123,6 +124,7 @@ describe('createTestCommand — surface', () => {
123124
'delete-batch',
124125
'failure',
125126
'get',
127+
'lint',
126128
'list',
127129
'plan',
128130
'rerun',
@@ -2721,6 +2723,81 @@ describe('runSteps', () => {
27212723
});
27222724
});
27232725

2726+
describe('runLint', () => {
2727+
const VALID_PLAN = JSON.stringify({
2728+
projectId: 'project_alice',
2729+
type: 'frontend',
2730+
name: 'Checkout works',
2731+
planSteps: [
2732+
{ type: 'action', description: 'Open the cart' },
2733+
{ type: 'assertion', description: 'Assert the total is visible' },
2734+
],
2735+
});
2736+
const INVALID_PLAN = JSON.stringify({
2737+
projectId: 'project_alice',
2738+
type: 'frontend',
2739+
name: 'Broken',
2740+
planSteps: [{ type: 'hover', description: 'Bad step type' }],
2741+
});
2742+
2743+
it('a directory with valid and invalid plans reports EVERY problem and exits 5', async () => {
2744+
const dir = mkdtempSync(join(tmpdir(), 'cli-lint-'));
2745+
writeFileSync(join(dir, 'a-valid.json'), VALID_PLAN, 'utf8');
2746+
writeFileSync(join(dir, 'b-invalid.json'), INVALID_PLAN, 'utf8');
2747+
writeFileSync(join(dir, 'c-notjson.json'), '{oops', 'utf8');
2748+
const out: string[] = [];
2749+
const rejection = await runLint(
2750+
{ profile: 'default', output: 'json', debug: false, planFromDir: dir },
2751+
{ stdout: line => out.push(line) },
2752+
).catch((error: unknown) => error);
2753+
expect(rejection).toMatchObject({ exitCode: 5 });
2754+
const report = JSON.parse(out.join('')) as {
2755+
checked: number;
2756+
valid: number;
2757+
issues: Array<{ file: string; field: string }>;
2758+
};
2759+
// All three files were checked (no first-error-fatal bailout).
2760+
expect(report.checked).toBe(3);
2761+
expect(report.valid).toBe(1);
2762+
expect(report.issues.map(issue => issue.file).sort()).toEqual([
2763+
'b-invalid.json',
2764+
'c-notjson.json',
2765+
]);
2766+
});
2767+
2768+
it('an all-valid directory resolves with exit 0 and no network/credentials needed', async () => {
2769+
const dir = mkdtempSync(join(tmpdir(), 'cli-lint-ok-'));
2770+
writeFileSync(join(dir, 'a.json'), VALID_PLAN, 'utf8');
2771+
const report = await runLint(
2772+
{ profile: 'default', output: 'json', debug: false, planFromDir: dir },
2773+
{ stdout: () => undefined },
2774+
);
2775+
expect(report).toMatchObject({ checked: 1, valid: 1, issues: [] });
2776+
});
2777+
2778+
it('a JSONL file reports each bad line with its line number', async () => {
2779+
const dir = mkdtempSync(join(tmpdir(), 'cli-lint-jsonl-'));
2780+
const file = join(dir, 'plans.jsonl');
2781+
writeFileSync(file, `${VALID_PLAN}\nnot json at all\n${INVALID_PLAN}\n`, 'utf8');
2782+
const out: string[] = [];
2783+
const rejection = await runLint(
2784+
{ profile: 'default', output: 'json', debug: false, plans: file },
2785+
{ stdout: line => out.push(line) },
2786+
).catch((error: unknown) => error);
2787+
expect(rejection).toMatchObject({ exitCode: 5 });
2788+
const report = JSON.parse(out.join('')) as { checked: number; issues: Array<{ file: string }> };
2789+
expect(report.checked).toBe(3);
2790+
expect(report.issues.some(issue => issue.file.endsWith(':2'))).toBe(true);
2791+
expect(report.issues.some(issue => issue.file.endsWith(':3'))).toBe(true);
2792+
});
2793+
2794+
it('requires exactly one input source', async () => {
2795+
await expect(
2796+
runLint({ profile: 'default', output: 'json', debug: false }, { stdout: () => undefined }),
2797+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
2798+
});
2799+
});
2800+
27242801
describe('runResult', () => {
27252802
it('JSON mode prints the §6.5 LatestResult shape verbatim', async () => {
27262803
const { credentialsPath } = makeCreds();

src/commands/test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3630,6 +3630,141 @@ function mapRunStepToCliTestStep(step: RunStepDto, run: RunResponse): CliTestSte
36303630
};
36313631
}
36323632

3633+
export interface LintOptions extends CommonOptions {
3634+
planFrom?: string;
3635+
planFromDir?: string;
3636+
plans?: string;
3637+
steps?: string;
3638+
}
3639+
3640+
export interface CliLintIssue {
3641+
file: string;
3642+
field: string;
3643+
reason: string;
3644+
}
3645+
3646+
export interface CliLintReport {
3647+
checked: number;
3648+
valid: number;
3649+
issues: CliLintIssue[];
3650+
}
3651+
3652+
/**
3653+
* `test lint` (issue #98): validate plan/steps files fully OFFLINE with the
3654+
* SAME validators the create paths run, but collecting EVERY problem instead
3655+
* of dying on the first one, and without any network write. The create-batch
3656+
* reader is first-error-fatal and only reachable through a command that POSTs,
3657+
* so authoring a 12-plan directory meant one error per paid round-trip. Zero
3658+
* network, zero credentials: exit 0 when everything is valid, 5 otherwise, so
3659+
* it drops into a pre-commit hook or CI step before `create-batch`.
3660+
*/
3661+
export async function runLint(opts: LintOptions, deps: TestDeps = {}): Promise<CliLintReport> {
3662+
const out = makeOutput(opts.output, deps);
3663+
const sources = [opts.planFrom, opts.planFromDir, opts.plans, opts.steps].filter(
3664+
source => source !== undefined,
3665+
);
3666+
if (sources.length !== 1) {
3667+
throw localValidationError(
3668+
'plan-from',
3669+
'exactly one of --plan-from, --plan-from-dir, --plans, or --steps is required',
3670+
);
3671+
}
3672+
3673+
const issues: CliLintIssue[] = [];
3674+
let checked = 0;
3675+
// Run one existing validator, converting its typed throw into a report row
3676+
// (same envelopes, so `details.field` pointers like planSteps[2].type
3677+
// survive verbatim).
3678+
const collect = (file: string, validate: () => void): void => {
3679+
checked += 1;
3680+
try {
3681+
validate();
3682+
} catch (err) {
3683+
if (err instanceof ApiError) {
3684+
issues.push({
3685+
file,
3686+
field: String(err.getDetail('field') ?? '(file)'),
3687+
reason: String(err.getDetail('reason') ?? err.nextAction ?? err.message),
3688+
});
3689+
} else {
3690+
issues.push({
3691+
file,
3692+
field: '(file)',
3693+
reason: err instanceof Error ? err.message : String(err),
3694+
});
3695+
}
3696+
}
3697+
};
3698+
3699+
if (opts.planFrom !== undefined) {
3700+
const planFrom = opts.planFrom;
3701+
collect(planFrom, () => void readPlanFromGuarded(planFrom));
3702+
} else if (opts.steps !== undefined) {
3703+
const steps = opts.steps;
3704+
collect(steps, () => void readPlanStepsFileGuarded(steps));
3705+
} else if (opts.planFromDir !== undefined) {
3706+
const dir = resolveAbsolute(opts.planFromDir);
3707+
let entries: string[];
3708+
try {
3709+
entries = readdirSync(dir)
3710+
.filter(name => name.endsWith('.json'))
3711+
.sort();
3712+
} catch {
3713+
throw localValidationError('plan-from-dir', `cannot read directory: ${dir}`);
3714+
}
3715+
if (entries.length === 0) {
3716+
throw localValidationError('plan-from-dir', 'contains no *.json plan files');
3717+
}
3718+
for (const entry of entries) {
3719+
collect(entry, () => void readPlanFromGuarded(join(dir, entry)));
3720+
}
3721+
} else if (opts.plans !== undefined) {
3722+
// JSONL: validate PER LINE so every bad line reports (the create path's
3723+
// reader stays throw-on-first; this is the collecting counterpart).
3724+
const absolute = resolveAbsolute(opts.plans);
3725+
let content: string;
3726+
try {
3727+
content = readFileSync(absolute, 'utf8');
3728+
} catch {
3729+
throw localValidationError('plans', `cannot read file: ${absolute}`);
3730+
}
3731+
const lines = content
3732+
.split('\n')
3733+
.map(line => line.trim())
3734+
.filter(line => line.length > 0);
3735+
if (lines.length === 0) throw localValidationError('plans', 'contains no plan lines');
3736+
lines.forEach((line, index) => {
3737+
collect(`${opts.plans}:${index + 1}`, () => {
3738+
let parsed: unknown;
3739+
try {
3740+
parsed = JSON.parse(line);
3741+
} catch {
3742+
throw localValidationError(
3743+
'plans',
3744+
`line ${index + 1} is not valid JSON`,
3745+
undefined,
3746+
'field',
3747+
);
3748+
}
3749+
assertPlanShape(parsed, { specIndex: index });
3750+
});
3751+
});
3752+
}
3753+
3754+
const filesWithIssues = new Set(issues.map(issue => issue.file)).size;
3755+
const report: CliLintReport = { checked, valid: checked - filesWithIssues, issues };
3756+
out.print(report, () =>
3757+
[
3758+
...issues.map(issue => `${issue.file}: ${issue.field}: ${issue.reason}`),
3759+
`${report.valid}/${report.checked} valid, ${issues.length} problem(s)`,
3760+
].join('\n'),
3761+
);
3762+
if (issues.length > 0) {
3763+
throw new CLIError(`lint: ${issues.length} problem(s) across ${report.checked} file(s)`, 5);
3764+
}
3765+
return report;
3766+
}
3767+
36333768
export async function runSteps(
36343769
opts: StepsOptions,
36353770
deps: TestDeps = {},
@@ -7234,6 +7369,37 @@ export function createTestCommand(deps: TestDeps = {}): Command {
72347369
);
72357370
});
72367371

7372+
test
7373+
.command('lint')
7374+
.description(
7375+
'Validate plan/steps files offline with the same validators `create` runs, collecting EVERY problem. No network, no credentials. Exit 0 when all valid, 5 otherwise.',
7376+
)
7377+
.option('--plan-from <file>', 'single plan JSON file')
7378+
.option(
7379+
'--plan-from-dir <dir>',
7380+
'directory of *.json plan files (each checked, all errors reported)',
7381+
)
7382+
.option('--plans <file>', 'JSONL file with one plan spec per line (each line checked)')
7383+
.option('--steps <file>', 'plan-steps JSON file (the shape `test plan put` ingests)')
7384+
.addHelpText('after', GLOBAL_OPTS_HINT)
7385+
.action(
7386+
async (
7387+
cmdOpts: { planFrom?: string; planFromDir?: string; plans?: string; steps?: string },
7388+
command: Command,
7389+
) => {
7390+
await runLint(
7391+
{
7392+
...resolveCommonOptions(command),
7393+
planFrom: cmdOpts.planFrom,
7394+
planFromDir: cmdOpts.planFromDir,
7395+
plans: cmdOpts.plans,
7396+
steps: cmdOpts.steps,
7397+
},
7398+
deps,
7399+
);
7400+
},
7401+
);
7402+
72377403
test
72387404
.command('result <test-id>')
72397405
.description(

test/__snapshots__/help.snapshot.test.ts.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,11 @@ Commands:
199199
steps [options] <test-id> List the steps for a test (server
200200
returns the cumulative log across every
201201
run; use --run-id to scope to one run)
202+
lint [options] Validate plan/steps files offline with
203+
the same validators \`create\` runs,
204+
collecting EVERY problem. No network,
205+
no credentials. Exit 0 when all valid,
206+
5 otherwise.
202207
result [options] <test-id> Get the latest result for a test (default) or list prior runs (--history).
203208
204209
--output json shape differs by mode:

0 commit comments

Comments
 (0)