|
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'; |
@@ -3854,6 +3861,114 @@ function renderRunDiffText(diff: CliRunDiff): string { |
3854 | 3861 | return lines.join('\n'); |
3855 | 3862 | } |
3856 | 3863 |
|
| 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 | + |
3857 | 3972 | export async function runSteps( |
3858 | 3973 | opts: StepsOptions, |
3859 | 3974 | deps: TestDeps = {}, |
@@ -7624,6 +7739,34 @@ export function createTestCommand(deps: TestDeps = {}): Command { |
7624 | 7739 | ); |
7625 | 7740 | }); |
7626 | 7741 |
|
| 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 | + |
7627 | 7770 | test |
7628 | 7771 | .command('steps <test-id>') |
7629 | 7772 | .description( |
|
0 commit comments