Skip to content

Commit 3305dfa

Browse files
authored
feat(test): add "test scaffold" to emit a schema-correct starter plan or backend skeleton (#180)
1 parent 946f5b3 commit 3305dfa

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
runTestWaitMany,
4041
runUpdate,
@@ -134,6 +135,7 @@ describe('createTestCommand — surface', () => {
134135
'rerun',
135136
'result',
136137
'run',
138+
'scaffold',
137139
'steps',
138140
'update',
139141
'wait',
@@ -2325,6 +2327,94 @@ describe('runCodePut', () => {
23252327
});
23262328
});
23272329

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

8125+
test
8126+
.command('scaffold')
8127+
.description(
8128+
'Emit a schema-correct starter test definition (frontend plan JSON by default, or a backend Python skeleton). Pure-local: no network, no credentials.',
8129+
)
8130+
.option('--type <type>', 'frontend|backend (default: frontend)')
8131+
.option('--out <path>', 'write the scaffold to a file instead of stdout')
8132+
.option('--force', 'overwrite an existing --out file', false)
8133+
.addHelpText(
8134+
'after',
8135+
'\nExamples:\n' +
8136+
' testsprite test scaffold > first-test.plan.json\n' +
8137+
' testsprite test scaffold --type backend --out tests/health.py\n' +
8138+
' testsprite test scaffold --out plan.json # then edit, and create with --plan-from plan.json',
8139+
)
8140+
.addHelpText('after', GLOBAL_OPTS_HINT)
8141+
.action(async (cmdOpts: ScaffoldFlagOpts, command: Command) => {
8142+
await runScaffold(
8143+
{
8144+
...resolveCommonOptions(command),
8145+
scaffoldType: parseEnumFlag(cmdOpts.type, 'type', TEST_TYPES) ?? 'frontend',
8146+
out: cmdOpts.out,
8147+
force: cmdOpts.force === true,
8148+
},
8149+
deps,
8150+
);
8151+
});
8152+
80108153
test
80118154
.command('steps <test-id>')
80128155
.description(

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

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

0 commit comments

Comments
 (0)