|
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'; |
2 | 9 | import { rename, stat, unlink } from 'node:fs/promises'; |
3 | 10 | import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'; |
4 | 11 | import { randomUUID } from 'node:crypto'; |
@@ -4000,6 +4007,114 @@ export async function runLint(opts: LintOptions, deps: TestDeps = {}): Promise<C |
4000 | 4007 | return report; |
4001 | 4008 | } |
4002 | 4009 |
|
| 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 | + |
4003 | 4118 | export async function runSteps( |
4004 | 4119 | opts: StepsOptions, |
4005 | 4120 | deps: TestDeps = {}, |
@@ -7770,6 +7885,34 @@ export function createTestCommand(deps: TestDeps = {}): Command { |
7770 | 7885 | ); |
7771 | 7886 | }); |
7772 | 7887 |
|
| 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 | + |
7773 | 7916 | test |
7774 | 7917 | .command('steps <test-id>') |
7775 | 7918 | .description( |
|
0 commit comments