From fc11edf311f382c4d13b775e7eef2a803a8815c7 Mon Sep 17 00:00:00 2001 From: waterWang Date: Wed, 12 Aug 2026 05:00:56 +0800 Subject: [PATCH] feat: add optional viewport field to plan files + --viewport CLI flag Adds an optional viewport field to the plan file schema (x format, e.g. "390x844") and a --viewport CLI flag to test create --plan-from. This allows users to test responsive/mobile-only UI by telling the frontend browser runner what viewport size to use. - CliPlanInput: add viewport?: string - plan.schema.json: add viewport property (pattern: ^[1-9]\d*x[1-9]\d*$) - assertPlanShape / collectPlanIssues: validate viewport format - runCreateFromPlan: pass viewport in POST /tests body - test create: add --viewport flag (overrides plan JSON value) - DOCUMENTATION.md: add viewport to plan field table - Tests: 2 new viewport tests (valid + invalid) Closes #174 --- DOCUMENTATION.md | 1 + schemas/plan.schema.json | 5 ++++ src/commands/test.ts | 60 ++++++++++++++++++++++++++++++++++++- src/lib/plan-schema.spec.ts | 27 +++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 8fd3bbb..7d1bf5b 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -166,6 +166,7 @@ Get this exact skeleton without hand-copying it from this file: `testsprite test | `name` | yes | string | An assertable behavior statement (subject + verb + outcome), not a noun fragment. | | `description` | no | string | One-sentence elaboration of `name` — the condition plus the expected outcome. | | `priority` | no | `"p0"` \| `"p1"` \| `"p2"` \| `"p3"` | p0 = must-pass, p1 = important paths, p2 = edge cases, p3 = cosmetic. | +| `viewport` | no | string | Browser viewport for the frontend runner, in `x` form (e.g. `"390x844"` for a mobile device). Forwarded to the backend so the browser-use runner can size the viewport before executing plan steps. Absent means the runner's desktop default. | | `planSteps` | yes | `Array<{ type: "action" \| "assertion", description: string }>` | **1–200 steps**, describing user intent in plain language, not selectors. | **Size cap:** the whole file must be **≤ 256 KB** (`test create-batch` caps the aggregate batch at 5 MB / 50 specs). Both caps are enforced client-side before any network call. diff --git a/schemas/plan.schema.json b/schemas/plan.schema.json index 788b6f1..2f79e8b 100644 --- a/schemas/plan.schema.json +++ b/schemas/plan.schema.json @@ -35,6 +35,11 @@ "enum": ["p0", "p1", "p2", "p3"], "description": "Optional. p0 = must-pass, p1 = important paths, p2 = edge cases, p3 = cosmetic." }, + "viewport": { + "type": "string", + "pattern": "^[1-9]\\d*x[1-9]\\d*$", + "description": "Optional. Desktop viewport for the frontend browser run, in `x` form (e.g. \"390x844\" for a mobile device). Forwarded to the backend so the browser-use runner can size the viewport before executing plan steps — the only way responsive/mobile-only UI (e.g. an `md:hidden` bottom nav) can be exercised. Absent means the runner's desktop default." + }, "planSteps": { "type": "array", "minItems": 1, diff --git a/src/commands/test.ts b/src/commands/test.ts index b7fe1af..15965bc 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -1895,6 +1895,15 @@ export interface CliPlanInput { name: string; description?: string; priority?: CliCreatePriority; + /** + * Optional desktop viewport for the frontend browser run, in + * `x` form (e.g. `"390x844"` for a mobile device). + * When present it is forwarded to the backend so the browser-use + * runner can size the viewport before executing plan steps — the + * only way responsive/mobile-only UI (e.g. `md:hidden` bottom nav) + * can be exercised. Absent means the runner's desktop default. + */ + viewport?: string; planSteps: CliPlanStep[]; } @@ -2336,6 +2345,13 @@ interface CreateFromPlanOptions extends CommonOptions { timeoutIsDefault?: boolean; /** Reserved for the M3.3 chain. Per-run target URL override. */ targetUrl?: string; + /** + * Optional browser viewport override for the frontend runner, in + * `x` form (e.g. "390x844"). When set alongside + * `--plan-from`, overrides any viewport in the plan JSON file. + * Validated client-side before the POST. + */ + viewport?: string; /** * Names of `test create` flags the caller supplied that `--plan-from` * ignores (identity lives in the JSON). Surfaced as a stderr advisory @@ -2425,6 +2441,23 @@ export async function runCreateFromPlan( const plan = readPlanFromGuarded(opts.planFrom, { ignoredFlags: opts.ignoredFlags }); + // `--viewport` is a CLI-level override of the viewport in the plan JSON — + // the one `--plan-from` field that is legitimately overridable from the + // command line (the plan file pins projectId/type/name/planSteps, but the + // viewport is a run-environment concern the caller may want to vary + // without editing the file, e.g. a mobile smoke pass on a desktop plan). + if (opts.viewport !== undefined) { + if (!/^\d+x\d+$/.test(opts.viewport)) { + throw localValidationError( + 'viewport', + 'must be a string in `x` format (e.g. "390x844")', + undefined, + 'flag', + ); + } + plan.viewport = opts.viewport; + } + const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); // Non-fatal advisory for `{{...}}`-style placeholders in step @@ -2472,6 +2505,7 @@ export async function runCreateFromPlan( name: plan.name, description: plan.description, priority: plan.priority, + viewport: plan.viewport, planSteps: plan.planSteps, }; @@ -2726,6 +2760,17 @@ function assertPlanShape( requireEnum(`${prefix}priority`, obj.priority, CLI_CREATE_PRIORITIES); } + if (obj.viewport !== undefined) { + if (typeof obj.viewport !== 'string' || !/^\d+x\d+$/.test(obj.viewport)) { + throw localValidationError( + `${prefix}viewport`, + 'must be a string in `x` format when present (e.g. "390x844")', + undefined, + 'field', + ); + } + } + // `planSteps` missing is the single most common agent // hallucination: LLMs (Copilot included) reliably nest steps under // `plan.steps` or a bare top-level `steps`. Point directly at the fix @@ -2806,6 +2851,11 @@ function collectPlanIssues( if (obj.priority !== undefined) { check(() => requireEnum(`${prefix}priority`, obj.priority, CLI_CREATE_PRIORITIES)); } + if (obj.viewport !== undefined) { + if (typeof obj.viewport !== 'string' || !/^\d+x\d+$/.test(obj.viewport)) { + issues.push({ field: `${prefix}viewport`, reason: 'must be a string in `x` format' }); + } + } check(() => requireArrayLength(`${prefix}planSteps`, obj.planSteps, { min: 1, @@ -9548,10 +9598,16 @@ export function createTestCommand(deps: TestDeps = {}): Command { .option('--name ', 'human-readable test name (becomes `title` in storage)') .option('--description ', 'optional human description (≤ 2000 chars)') .option('--priority ', 'optional priority — one of: p0, p1, p2, p3') + .option( + '--viewport ', + 'optional browser viewport for the frontend runner (e.g. "390x844" for mobile). ' + + 'With --plan-from, overrides the viewport in the plan JSON.', + ) .option('--code-file ', 'file containing the test code (≤ 350 KB)') .option( '--plan-from ', - 'JSON file with the full FE test definition — projectId, type, name, planSteps[] all live in the file ' + + 'JSON file with the full FE test definition — projectId, type, name, planSteps[], ' + + 'and optional viewport/description/priority all live in the file ' + '(≤ 256 KB; mutually exclusive with --code-file). In this mode --project/--type/--name/--description/--priority are ignored.', ) .option( @@ -9649,6 +9705,7 @@ export function createTestCommand(deps: TestDeps = {}): Command { { ...resolveCommonOptions(command), planFrom: cmdOpts.planFrom, + viewport: cmdOpts.viewport, run: cmdOpts.run === true, wait: cmdOpts.wait === true, timeout: parseTimeoutFlag(cmdOpts.timeout, 'timeout'), @@ -10774,6 +10831,7 @@ interface CreateFlagOpts { planFrom?: string; /** Print the canonical plan-file skeleton and exit. */ planTemplate?: boolean; + viewport?: string; run?: boolean; wait?: boolean; timeout?: string; diff --git a/src/lib/plan-schema.spec.ts b/src/lib/plan-schema.spec.ts index 9559755..600622e 100644 --- a/src/lib/plan-schema.spec.ts +++ b/src/lib/plan-schema.spec.ts @@ -134,6 +134,33 @@ describe('schemas/plan.schema.json', () => { expect(await passesRealValidator(dir, plan)).toBe(true); }); + it('accepts a valid viewport string (e.g. "390x844") and rejects an invalid one', async () => { + const valid = { ...PLAN_TEMPLATE_WITH_SCHEMA, viewport: '390x844' }; + expect(validate(valid)).toBe(true); + expect(await passesRealValidator(dir, valid)).toBe(true); + + const invalid = { ...PLAN_TEMPLATE_WITH_SCHEMA, viewport: 'abc' }; + expect(validate(invalid)).toBe(false); + expect(await passesRealValidator(dir, invalid)).toBe(false); + }); + + it('accepts a plan with viewport + all other optional fields', async () => { + const plan = { + projectId: 'prj_abc123', + type: 'frontend', + name: 'Mobile test plan', + description: 'Exercises mobile layout.', + priority: 'p1', + viewport: '390x844', + planSteps: [ + { type: 'action', description: 'tap the bottom nav' }, + { type: 'assertion', description: 'verify the mobile sidebar is visible' }, + ], + }; + expect(validate(plan)).toBe(true); + expect(await passesRealValidator(dir, plan)).toBe(true); + }); + it('rejects type: "backend" — schema is the ground truth for the --plan-from COMMAND, which rejects backend end-to-end (both sides must agree)', async () => { const plan = { ...PLAN_TEMPLATE_WITH_SCHEMA, type: 'backend' }; expect(validate(plan)).toBe(false);