|
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'; |
@@ -3630,6 +3637,114 @@ function mapRunStepToCliTestStep(step: RunStepDto, run: RunResponse): CliTestSte |
3630 | 3637 | }; |
3631 | 3638 | } |
3632 | 3639 |
|
| 3640 | +/** Flag options for `test scaffold`. */ |
| 3641 | +interface ScaffoldFlagOpts { |
| 3642 | + type?: string; |
| 3643 | + out?: string; |
| 3644 | + force?: boolean; |
| 3645 | +} |
| 3646 | + |
| 3647 | +export interface ScaffoldOptions extends CommonOptions { |
| 3648 | + scaffoldType: 'frontend' | 'backend'; |
| 3649 | + out?: string; |
| 3650 | + force: boolean; |
| 3651 | +} |
| 3652 | + |
| 3653 | +/** JSON payload `test scaffold --type backend` prints under --output json. */ |
| 3654 | +export interface CliBackendScaffold { |
| 3655 | + type: 'backend'; |
| 3656 | + language: 'python'; |
| 3657 | + code: string; |
| 3658 | +} |
| 3659 | + |
| 3660 | +/** |
| 3661 | + * `test scaffold` — emit a schema-correct starter test definition so a first |
| 3662 | + * test never starts from hand-copied JSON. Pure-local: no network, no |
| 3663 | + * credentials, no filesystem reads. The frontend template is a `CliPlanInput` |
| 3664 | + * (the exact shape `--plan-from` ingests; sourceRef: CliPlanInput / |
| 3665 | + * PLAN_STEP_TYPES above), so `scaffold | create --plan-from -`-style flows |
| 3666 | + * validate out of the box. The backend template is the minimal `requests` |
| 3667 | + * script the onboarding skill mandates: define a test function with a |
| 3668 | + * concrete status assertion, then CALL it (a defined-but-never-called test |
| 3669 | + * would pass without asserting anything). |
| 3670 | + */ |
| 3671 | +export async function runScaffold( |
| 3672 | + opts: ScaffoldOptions, |
| 3673 | + deps: TestDeps = {}, |
| 3674 | +): Promise<CliPlanInput | CliBackendScaffold> { |
| 3675 | + const out = makeOutput(opts.output, deps); |
| 3676 | + const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); |
| 3677 | + const env = deps.env ?? process.env; |
| 3678 | + // Pre-fill the project id from TESTSPRITE_PROJECT_ID when the caller's |
| 3679 | + // environment carries one; otherwise a clearly-marked placeholder the user |
| 3680 | + // swaps after running `testsprite project list`. |
| 3681 | + const projectId = |
| 3682 | + typeof env.TESTSPRITE_PROJECT_ID === 'string' && env.TESTSPRITE_PROJECT_ID.length > 0 |
| 3683 | + ? env.TESTSPRITE_PROJECT_ID |
| 3684 | + : '<run: testsprite project list>'; |
| 3685 | + |
| 3686 | + let payload: CliPlanInput | CliBackendScaffold; |
| 3687 | + let body: string; |
| 3688 | + if (opts.scaffoldType === 'frontend') { |
| 3689 | + const plan: CliPlanInput = { |
| 3690 | + projectId, |
| 3691 | + type: 'frontend', |
| 3692 | + name: 'My first frontend test', |
| 3693 | + description: 'Replace with one sentence describing what this test verifies.', |
| 3694 | + priority: 'p2', |
| 3695 | + planSteps: [ |
| 3696 | + { |
| 3697 | + type: 'action', |
| 3698 | + description: 'Navigate to /login and sign in with a seeded test account', |
| 3699 | + }, |
| 3700 | + { type: 'action', description: 'Open the first product page and click "Add to cart"' }, |
| 3701 | + { type: 'assertion', description: 'Assert that the cart badge shows 1 item' }, |
| 3702 | + ], |
| 3703 | + }; |
| 3704 | + payload = plan; |
| 3705 | + body = `${JSON.stringify(plan, null, 2)}\n`; |
| 3706 | + } else { |
| 3707 | + const code = [ |
| 3708 | + 'import requests', |
| 3709 | + '', |
| 3710 | + '# Replace with your API base URL (must be reachable from the internet).', |
| 3711 | + 'BASE_URL = "https://staging.example.com"', |
| 3712 | + '', |
| 3713 | + '', |
| 3714 | + 'def test_health_endpoint() -> None:', |
| 3715 | + ' response = requests.get(f"{BASE_URL}/health", timeout=30)', |
| 3716 | + ' assert response.status_code == 200, f"expected 200, got {response.status_code}"', |
| 3717 | + '', |
| 3718 | + '', |
| 3719 | + '# The test function MUST be called: TestSprite executes this file top to', |
| 3720 | + '# bottom, so a defined-but-never-called function would pass vacuously.', |
| 3721 | + 'test_health_endpoint()', |
| 3722 | + '', |
| 3723 | + ].join('\n'); |
| 3724 | + payload = { type: 'backend', language: 'python', code }; |
| 3725 | + body = code; |
| 3726 | + } |
| 3727 | + |
| 3728 | + if (opts.out !== undefined) { |
| 3729 | + const resolved = isAbsolute(opts.out) ? opts.out : resolve(process.cwd(), opts.out); |
| 3730 | + // Never clobber silently: scaffolds are starting points the user edits, so |
| 3731 | + // an accidental re-run must not erase their work. --force opts in. |
| 3732 | + if (!opts.force && existsSync(resolved)) { |
| 3733 | + throw localValidationError('out', `already exists: ${resolved}. Pass --force to overwrite`); |
| 3734 | + } |
| 3735 | + const sink = openOutputFile(opts.out); // reuses the directory/parent guards |
| 3736 | + const fileOut = makeFileOutput(opts.output, sink); |
| 3737 | + await fileOut.writeChunk(body); |
| 3738 | + await closeOutputFile(sink, true); |
| 3739 | + stderrFn(`Scaffold written to ${resolved}`); |
| 3740 | + return payload; |
| 3741 | + } |
| 3742 | + |
| 3743 | + // No --out: the scaffold body IS the stdout payload (`> plan.json` works). |
| 3744 | + out.print(payload, () => body.trimEnd()); |
| 3745 | + return payload; |
| 3746 | +} |
| 3747 | + |
3633 | 3748 | export async function runSteps( |
3634 | 3749 | opts: StepsOptions, |
3635 | 3750 | deps: TestDeps = {}, |
@@ -7207,6 +7322,34 @@ export function createTestCommand(deps: TestDeps = {}): Command { |
7207 | 7322 | ); |
7208 | 7323 | }); |
7209 | 7324 |
|
| 7325 | + test |
| 7326 | + .command('scaffold') |
| 7327 | + .description( |
| 7328 | + 'Emit a schema-correct starter test definition (frontend plan JSON by default, or a backend Python skeleton). Pure-local: no network, no credentials.', |
| 7329 | + ) |
| 7330 | + .option('--type <type>', 'frontend|backend (default: frontend)') |
| 7331 | + .option('--out <path>', 'write the scaffold to a file instead of stdout') |
| 7332 | + .option('--force', 'overwrite an existing --out file', false) |
| 7333 | + .addHelpText( |
| 7334 | + 'after', |
| 7335 | + '\nExamples:\n' + |
| 7336 | + ' testsprite test scaffold > first-test.plan.json\n' + |
| 7337 | + ' testsprite test scaffold --type backend --out tests/health.py\n' + |
| 7338 | + ' testsprite test scaffold --out plan.json # then edit, and create with --plan-from plan.json', |
| 7339 | + ) |
| 7340 | + .addHelpText('after', GLOBAL_OPTS_HINT) |
| 7341 | + .action(async (cmdOpts: ScaffoldFlagOpts, command: Command) => { |
| 7342 | + await runScaffold( |
| 7343 | + { |
| 7344 | + ...resolveCommonOptions(command), |
| 7345 | + scaffoldType: parseEnumFlag(cmdOpts.type, 'type', TEST_TYPES) ?? 'frontend', |
| 7346 | + out: cmdOpts.out, |
| 7347 | + force: cmdOpts.force === true, |
| 7348 | + }, |
| 7349 | + deps, |
| 7350 | + ); |
| 7351 | + }); |
| 7352 | + |
7210 | 7353 | test |
7211 | 7354 | .command('steps <test-id>') |
7212 | 7355 | .description( |
|
0 commit comments