Skip to content

Commit 6121944

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

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
@@ -34,6 +34,7 @@ import {
3434
runList,
3535
runPlanPut,
3636
runResult,
37+
runScaffold,
3738
runSteps,
3839
runUpdate,
3940
} from './test.js';
@@ -130,6 +131,7 @@ describe('createTestCommand — surface', () => {
130131
'rerun',
131132
'result',
132133
'run',
134+
'scaffold',
133135
'steps',
134136
'update',
135137
'wait',
@@ -2321,6 +2323,94 @@ describe('runCodePut', () => {
23212323
});
23222324
});
23232325

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

3864+
/** Flag options for `test scaffold`. */
3865+
interface ScaffoldFlagOpts {
3866+
type?: string;
3867+
out?: string;
3868+
force?: boolean;
3869+
}
3870+
3871+
export interface ScaffoldOptions extends CommonOptions {
3872+
scaffoldType: 'frontend' | 'backend';
3873+
out?: string;
3874+
force: boolean;
3875+
}
3876+
3877+
/** JSON payload `test scaffold --type backend` prints under --output json. */
3878+
export interface CliBackendScaffold {
3879+
type: 'backend';
3880+
language: 'python';
3881+
code: string;
3882+
}
3883+
3884+
/**
3885+
* `test scaffold` — emit a schema-correct starter test definition so a first
3886+
* test never starts from hand-copied JSON. Pure-local: no network, no
3887+
* credentials, no filesystem reads. The frontend template is a `CliPlanInput`
3888+
* (the exact shape `--plan-from` ingests; sourceRef: CliPlanInput /
3889+
* PLAN_STEP_TYPES above), so `scaffold | create --plan-from -`-style flows
3890+
* validate out of the box. The backend template is the minimal `requests`
3891+
* script the onboarding skill mandates: define a test function with a
3892+
* concrete status assertion, then CALL it (a defined-but-never-called test
3893+
* would pass without asserting anything).
3894+
*/
3895+
export async function runScaffold(
3896+
opts: ScaffoldOptions,
3897+
deps: TestDeps = {},
3898+
): Promise<CliPlanInput | CliBackendScaffold> {
3899+
const out = makeOutput(opts.output, deps);
3900+
const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
3901+
const env = deps.env ?? process.env;
3902+
// Pre-fill the project id from TESTSPRITE_PROJECT_ID when the caller's
3903+
// environment carries one; otherwise a clearly-marked placeholder the user
3904+
// swaps after running `testsprite project list`.
3905+
const projectId =
3906+
typeof env.TESTSPRITE_PROJECT_ID === 'string' && env.TESTSPRITE_PROJECT_ID.length > 0
3907+
? env.TESTSPRITE_PROJECT_ID
3908+
: '<run: testsprite project list>';
3909+
3910+
let payload: CliPlanInput | CliBackendScaffold;
3911+
let body: string;
3912+
if (opts.scaffoldType === 'frontend') {
3913+
const plan: CliPlanInput = {
3914+
projectId,
3915+
type: 'frontend',
3916+
name: 'My first frontend test',
3917+
description: 'Replace with one sentence describing what this test verifies.',
3918+
priority: 'p2',
3919+
planSteps: [
3920+
{
3921+
type: 'action',
3922+
description: 'Navigate to /login and sign in with a seeded test account',
3923+
},
3924+
{ type: 'action', description: 'Open the first product page and click "Add to cart"' },
3925+
{ type: 'assertion', description: 'Assert that the cart badge shows 1 item' },
3926+
],
3927+
};
3928+
payload = plan;
3929+
body = `${JSON.stringify(plan, null, 2)}\n`;
3930+
} else {
3931+
const code = [
3932+
'import requests',
3933+
'',
3934+
'# Replace with your API base URL (must be reachable from the internet).',
3935+
'BASE_URL = "https://staging.example.com"',
3936+
'',
3937+
'',
3938+
'def test_health_endpoint() -> None:',
3939+
' response = requests.get(f"{BASE_URL}/health", timeout=30)',
3940+
' assert response.status_code == 200, f"expected 200, got {response.status_code}"',
3941+
'',
3942+
'',
3943+
'# The test function MUST be called: TestSprite executes this file top to',
3944+
'# bottom, so a defined-but-never-called function would pass vacuously.',
3945+
'test_health_endpoint()',
3946+
'',
3947+
].join('\n');
3948+
payload = { type: 'backend', language: 'python', code };
3949+
body = code;
3950+
}
3951+
3952+
if (opts.out !== undefined) {
3953+
const resolved = isAbsolute(opts.out) ? opts.out : resolve(process.cwd(), opts.out);
3954+
// Never clobber silently: scaffolds are starting points the user edits, so
3955+
// an accidental re-run must not erase their work. --force opts in.
3956+
if (!opts.force && existsSync(resolved)) {
3957+
throw localValidationError('out', `already exists: ${resolved}. Pass --force to overwrite`);
3958+
}
3959+
const sink = openOutputFile(opts.out); // reuses the directory/parent guards
3960+
const fileOut = makeFileOutput(opts.output, sink);
3961+
await fileOut.writeChunk(body);
3962+
await closeOutputFile(sink, true);
3963+
stderrFn(`Scaffold written to ${resolved}`);
3964+
return payload;
3965+
}
3966+
3967+
// No --out: the scaffold body IS the stdout payload (`> plan.json` works).
3968+
out.print(payload, () => body.trimEnd());
3969+
return payload;
3970+
}
3971+
38573972
export async function runSteps(
38583973
opts: StepsOptions,
38593974
deps: TestDeps = {},
@@ -7624,6 +7739,34 @@ export function createTestCommand(deps: TestDeps = {}): Command {
76247739
);
76257740
});
76267741

7742+
test
7743+
.command('scaffold')
7744+
.description(
7745+
'Emit a schema-correct starter test definition (frontend plan JSON by default, or a backend Python skeleton). Pure-local: no network, no credentials.',
7746+
)
7747+
.option('--type <type>', 'frontend|backend (default: frontend)')
7748+
.option('--out <path>', 'write the scaffold to a file instead of stdout')
7749+
.option('--force', 'overwrite an existing --out file', false)
7750+
.addHelpText(
7751+
'after',
7752+
'\nExamples:\n' +
7753+
' testsprite test scaffold > first-test.plan.json\n' +
7754+
' testsprite test scaffold --type backend --out tests/health.py\n' +
7755+
' testsprite test scaffold --out plan.json # then edit, and create with --plan-from plan.json',
7756+
)
7757+
.addHelpText('after', GLOBAL_OPTS_HINT)
7758+
.action(async (cmdOpts: ScaffoldFlagOpts, command: Command) => {
7759+
await runScaffold(
7760+
{
7761+
...resolveCommonOptions(command),
7762+
scaffoldType: parseEnumFlag(cmdOpts.type, 'type', TEST_TYPES) ?? 'frontend',
7763+
out: cmdOpts.out,
7764+
force: cmdOpts.force === true,
7765+
},
7766+
deps,
7767+
);
7768+
});
7769+
76277770
test
76287771
.command('steps <test-id>')
76297772
.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)