Skip to content

Commit 925bab4

Browse files
committed
feat(test): add "test scaffold" to emit a schema-correct starter plan or backend skeleton
1 parent b9e9601 commit 925bab4

3 files changed

Lines changed: 238 additions & 1 deletion

File tree

src/commands/test.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
runList,
3636
runPlanPut,
3737
runResult,
38+
runScaffold,
3839
runSteps,
3940
runUpdate,
4041
} from './test.js';
@@ -133,6 +134,7 @@ describe('createTestCommand — surface', () => {
133134
'rerun',
134135
'result',
135136
'run',
137+
'scaffold',
136138
'steps',
137139
'update',
138140
'wait',
@@ -2324,6 +2326,94 @@ describe('runCodePut', () => {
23242326
});
23252327
});
23262328

2329+
describe('runScaffold', () => {
2330+
it('frontend scaffold is a valid CliPlanInput and prints as JSON on stdout', async () => {
2331+
const out: string[] = [];
2332+
const result = await runScaffold(
2333+
{ profile: 'default', output: 'text', debug: false, scaffoldType: 'frontend', force: false },
2334+
{ stdout: line => out.push(line), stderr: () => undefined, env: {} },
2335+
);
2336+
const plan = result as { projectId: string; type: string; planSteps: Array<{ type: string }> };
2337+
expect(plan.type).toBe('frontend');
2338+
// Placeholder project id when TESTSPRITE_PROJECT_ID is unset.
2339+
expect(plan.projectId).toContain('testsprite project list');
2340+
expect(plan.planSteps.length).toBeGreaterThanOrEqual(2);
2341+
// Every emitted step type must come from the real enum (no drift).
2342+
for (const step of plan.planSteps) expect(['action', 'assertion']).toContain(step.type);
2343+
// At least one assertion step so the scaffold is a meaningful test.
2344+
expect(plan.planSteps.some(step => step.type === 'assertion')).toBe(true);
2345+
// stdout body parses back to the same plan (`> plan.json` works).
2346+
expect(JSON.parse(out.join('\n'))).toEqual(plan);
2347+
});
2348+
2349+
it('pre-fills projectId from TESTSPRITE_PROJECT_ID when set', async () => {
2350+
const result = await runScaffold(
2351+
{ profile: 'default', output: 'json', debug: false, scaffoldType: 'frontend', force: false },
2352+
{
2353+
stdout: () => undefined,
2354+
stderr: () => undefined,
2355+
env: { TESTSPRITE_PROJECT_ID: 'project_env' },
2356+
},
2357+
);
2358+
expect((result as { projectId: string }).projectId).toBe('project_env');
2359+
});
2360+
2361+
it('backend scaffold defines a requests test with a status assertion AND calls it', async () => {
2362+
const out: string[] = [];
2363+
const result = await runScaffold(
2364+
{ profile: 'default', output: 'text', debug: false, scaffoldType: 'backend', force: false },
2365+
{ stdout: line => out.push(line), stderr: () => undefined, env: {} },
2366+
);
2367+
const code = (result as { code: string }).code;
2368+
expect(code).toContain('import requests');
2369+
expect(code).toContain('assert response.status_code == 200');
2370+
// The onboarding rule: the function must be CALLED, not just defined.
2371+
expect(code).toContain('\ntest_health_endpoint()');
2372+
expect(out.join('\n')).toContain('import requests');
2373+
});
2374+
2375+
it('--out writes the file and refuses to overwrite without --force', async () => {
2376+
const dir = mkdtempSync(join(tmpdir(), 'cli-scaffold-'));
2377+
const target = join(dir, 'plan.json');
2378+
const opts = {
2379+
profile: 'default',
2380+
output: 'text',
2381+
debug: false,
2382+
scaffoldType: 'frontend',
2383+
out: target,
2384+
force: false,
2385+
} as const;
2386+
const deps = { stdout: () => undefined, stderr: () => undefined, env: {} };
2387+
await runScaffold({ ...opts }, deps);
2388+
const written = JSON.parse(readFileSync(target, 'utf8')) as { type: string };
2389+
expect(written.type).toBe('frontend');
2390+
// Second run without --force must not clobber the (possibly edited) file.
2391+
await expect(runScaffold({ ...opts }, deps)).rejects.toMatchObject({
2392+
code: 'VALIDATION_ERROR',
2393+
exitCode: 5,
2394+
});
2395+
// --force overwrites.
2396+
await expect(runScaffold({ ...opts, force: true }, deps)).resolves.toBeDefined();
2397+
});
2398+
2399+
it('--out pointing at an existing path (here a directory) rejects without --force', async () => {
2400+
const dir = mkdtempSync(join(tmpdir(), 'cli-scaffold-dir-'));
2401+
await expect(
2402+
runScaffold(
2403+
{
2404+
profile: 'default',
2405+
output: 'text',
2406+
debug: false,
2407+
scaffoldType: 'frontend',
2408+
out: dir,
2409+
force: false,
2410+
},
2411+
{ stdout: () => undefined, stderr: () => undefined, env: {} },
2412+
),
2413+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
2414+
});
2415+
});
2416+
23272417
describe('runSteps', () => {
23282418
it('JSON mode returns the §6.4 wire shape and forwards pageSize/cursor', async () => {
23292419
const { credentialsPath } = makeCreds();

src/commands/test.ts

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { createWriteStream, readFileSync, readdirSync, statSync, type WriteStream } from 'node:fs';
1+
import {
2+
createWriteStream,
3+
existsSync,
4+
readFileSync,
5+
readdirSync,
6+
statSync,
7+
type WriteStream,
8+
} from 'node:fs';
29
import { rename, stat, unlink } from 'node:fs/promises';
310
import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path';
411
import { randomUUID } from 'node:crypto';
@@ -4000,6 +4007,114 @@ export async function runLint(opts: LintOptions, deps: TestDeps = {}): Promise<C
40004007
return report;
40014008
}
40024009

4010+
/** Flag options for `test scaffold`. */
4011+
interface ScaffoldFlagOpts {
4012+
type?: string;
4013+
out?: string;
4014+
force?: boolean;
4015+
}
4016+
4017+
export interface ScaffoldOptions extends CommonOptions {
4018+
scaffoldType: 'frontend' | 'backend';
4019+
out?: string;
4020+
force: boolean;
4021+
}
4022+
4023+
/** JSON payload `test scaffold --type backend` prints under --output json. */
4024+
export interface CliBackendScaffold {
4025+
type: 'backend';
4026+
language: 'python';
4027+
code: string;
4028+
}
4029+
4030+
/**
4031+
* `test scaffold` — emit a schema-correct starter test definition so a first
4032+
* test never starts from hand-copied JSON. Pure-local: no network, no
4033+
* credentials, no filesystem reads. The frontend template is a `CliPlanInput`
4034+
* (the exact shape `--plan-from` ingests; sourceRef: CliPlanInput /
4035+
* PLAN_STEP_TYPES above), so `scaffold | create --plan-from -`-style flows
4036+
* validate out of the box. The backend template is the minimal `requests`
4037+
* script the onboarding skill mandates: define a test function with a
4038+
* concrete status assertion, then CALL it (a defined-but-never-called test
4039+
* would pass without asserting anything).
4040+
*/
4041+
export async function runScaffold(
4042+
opts: ScaffoldOptions,
4043+
deps: TestDeps = {},
4044+
): Promise<CliPlanInput | CliBackendScaffold> {
4045+
const out = makeOutput(opts.output, deps);
4046+
const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
4047+
const env = deps.env ?? process.env;
4048+
// Pre-fill the project id from TESTSPRITE_PROJECT_ID when the caller's
4049+
// environment carries one; otherwise a clearly-marked placeholder the user
4050+
// swaps after running `testsprite project list`.
4051+
const projectId =
4052+
typeof env.TESTSPRITE_PROJECT_ID === 'string' && env.TESTSPRITE_PROJECT_ID.length > 0
4053+
? env.TESTSPRITE_PROJECT_ID
4054+
: '<run: testsprite project list>';
4055+
4056+
let payload: CliPlanInput | CliBackendScaffold;
4057+
let body: string;
4058+
if (opts.scaffoldType === 'frontend') {
4059+
const plan: CliPlanInput = {
4060+
projectId,
4061+
type: 'frontend',
4062+
name: 'My first frontend test',
4063+
description: 'Replace with one sentence describing what this test verifies.',
4064+
priority: 'p2',
4065+
planSteps: [
4066+
{
4067+
type: 'action',
4068+
description: 'Navigate to /login and sign in with a seeded test account',
4069+
},
4070+
{ type: 'action', description: 'Open the first product page and click "Add to cart"' },
4071+
{ type: 'assertion', description: 'Assert that the cart badge shows 1 item' },
4072+
],
4073+
};
4074+
payload = plan;
4075+
body = `${JSON.stringify(plan, null, 2)}\n`;
4076+
} else {
4077+
const code = [
4078+
'import requests',
4079+
'',
4080+
'# Replace with your API base URL (must be reachable from the internet).',
4081+
'BASE_URL = "https://staging.example.com"',
4082+
'',
4083+
'',
4084+
'def test_health_endpoint() -> None:',
4085+
' response = requests.get(f"{BASE_URL}/health", timeout=30)',
4086+
' assert response.status_code == 200, f"expected 200, got {response.status_code}"',
4087+
'',
4088+
'',
4089+
'# The test function MUST be called: TestSprite executes this file top to',
4090+
'# bottom, so a defined-but-never-called function would pass vacuously.',
4091+
'test_health_endpoint()',
4092+
'',
4093+
].join('\n');
4094+
payload = { type: 'backend', language: 'python', code };
4095+
body = code;
4096+
}
4097+
4098+
if (opts.out !== undefined) {
4099+
const resolved = isAbsolute(opts.out) ? opts.out : resolve(process.cwd(), opts.out);
4100+
// Never clobber silently: scaffolds are starting points the user edits, so
4101+
// an accidental re-run must not erase their work. --force opts in.
4102+
if (!opts.force && existsSync(resolved)) {
4103+
throw localValidationError('out', `already exists: ${resolved}. Pass --force to overwrite`);
4104+
}
4105+
const sink = openOutputFile(opts.out); // reuses the directory/parent guards
4106+
const fileOut = makeFileOutput(opts.output, sink);
4107+
await fileOut.writeChunk(body);
4108+
await closeOutputFile(sink, true);
4109+
stderrFn(`Scaffold written to ${resolved}`);
4110+
return payload;
4111+
}
4112+
4113+
// No --out: the scaffold body IS the stdout payload (`> plan.json` works).
4114+
out.print(payload, () => body.trimEnd());
4115+
return payload;
4116+
}
4117+
40034118
export async function runSteps(
40044119
opts: StepsOptions,
40054120
deps: TestDeps = {},
@@ -7770,6 +7885,34 @@ export function createTestCommand(deps: TestDeps = {}): Command {
77707885
);
77717886
});
77727887

7888+
test
7889+
.command('scaffold')
7890+
.description(
7891+
'Emit a schema-correct starter test definition (frontend plan JSON by default, or a backend Python skeleton). Pure-local: no network, no credentials.',
7892+
)
7893+
.option('--type <type>', 'frontend|backend (default: frontend)')
7894+
.option('--out <path>', 'write the scaffold to a file instead of stdout')
7895+
.option('--force', 'overwrite an existing --out file', false)
7896+
.addHelpText(
7897+
'after',
7898+
'\nExamples:\n' +
7899+
' testsprite test scaffold > first-test.plan.json\n' +
7900+
' testsprite test scaffold --type backend --out tests/health.py\n' +
7901+
' testsprite test scaffold --out plan.json # then edit, and create with --plan-from plan.json',
7902+
)
7903+
.addHelpText('after', GLOBAL_OPTS_HINT)
7904+
.action(async (cmdOpts: ScaffoldFlagOpts, command: Command) => {
7905+
await runScaffold(
7906+
{
7907+
...resolveCommonOptions(command),
7908+
scaffoldType: parseEnumFlag(cmdOpts.type, 'type', TEST_TYPES) ?? 'frontend',
7909+
out: cmdOpts.out,
7910+
force: cmdOpts.force === true,
7911+
},
7912+
deps,
7913+
);
7914+
});
7915+
77737916
test
77747917
.command('steps <test-id>')
77757918
.description(

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,10 @@ Commands:
200200
(--plan-from, FE-only, M3.2 piece-5)
201201
create-batch [options] Create multiple FE tests from a JSONL
202202
of plan specs (FE-only)
203+
scaffold [options] Emit a schema-correct starter test
204+
definition (frontend plan JSON by
205+
default, or a backend Python skeleton).
206+
Pure-local: no network, no credentials.
203207
steps [options] <test-id> List the steps for a test (server
204208
returns the cumulative log across every
205209
run; use --run-id to scope to one run)

0 commit comments

Comments
 (0)