Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/ui-review-recipe-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,16 @@ allowlisted flow. The selection is capped and any overflow logged.
- `uiReview.flows[].steps[].value` — required for `upload` (the file path);
also the typed/selected value for `fill`/`select`.
- `uiReview.flows[].steps[].event` — optional event name for `dispatch`.
- `uiReview.flows[].steps[].viewport` — optional `{ width, height }`; resizes the
page before the step and slugs the capture so a responsive render lands in its
own reviewable directory. The viewport is **sticky**: it persists to later steps
until another step sets one, and an omitted `viewport` inherits the last set size
rather than resetting to default — so a later step's slug faithfully reflects the
size the page is actually at. There is no reset sentinel: to render a later step
at a different size (or back at the original one), give that step an explicit
`viewport` with the exact dimensions you want.
- `uiReview.flows[].steps[].interactionState` — optional `none`/`focus`/`hover`/`error`;
labels a stateful render the route names, slugged into its own directory.

```yaml
uiReview:
Expand Down Expand Up @@ -270,6 +280,9 @@ from the schema or if the schema gains a key not listed here.
- `uiReview.flows[].steps[].path`
- `uiReview.flows[].steps[].value`
- `uiReview.flows[].steps[].event`
- `uiReview.flows[].steps[].viewport.width`
- `uiReview.flows[].steps[].viewport.height`
- `uiReview.flows[].steps[].interactionState`
- `uiReview.caps.maxScreenshots`
- `uiReview.caps.maxFlows`
- `uiReview.caps.maxStepsPerFlow`
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/config/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,12 @@ const UiReviewFlowStepConfig = z.strictObject({
path: z.string().trim().min(1).optional(),
value: z.string().optional(),
event: z.string().trim().min(1).optional(),
// Responsive/stateful captures: a declared viewport resizes the page before the
// step and bakes into the named-state slug, so the mobile vs desktop (or
// default vs error) render lands in a distinct reviewable directory. The route
// NAMES its interaction states — the drive never enumerates them itself.
viewport: z.strictObject({ width: z.number().int().positive(), height: z.number().int().positive() }).optional(),
interactionState: z.enum(["none", "focus", "hover", "error"]).optional(),
}).superRefine((step, ctx) => {
// Every action but `goto` targets an element, so a missing selector is a
// config error, not a runtime step-failure. (`goto` uses `path`/url.)
Expand Down
17 changes: 17 additions & 0 deletions packages/core/test/config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,23 @@ describe("schema validation", () => {
}).success);
});

test("S35d: a step may declare a viewport and an interactionState; both are validated", () => {
assert.ok(DevLoopConfigSchema.safeParse({
version: 1,
uiReview: { flows: [{ name: "decks", steps: [{ action: "goto", path: "/", viewport: { width: 390, height: 844 }, interactionState: "error" }] }] },
}).success);
// Non-positive viewport dimensions are rejected (a bad descriptor must fail closed).
assert.ok(!DevLoopConfigSchema.safeParse({
version: 1,
uiReview: { flows: [{ name: "decks", steps: [{ action: "goto", path: "/", viewport: { width: 0, height: 844 } }] }] },
}).success);
// Only the route-named interaction states are accepted.
assert.ok(!DevLoopConfigSchema.safeParse({
version: 1,
uiReview: { flows: [{ name: "decks", steps: [{ action: "goto", path: "/", interactionState: "wiggle" }] }] },
}).success);
});

test("S36: uiReview.serverLogExceptionPattern rejects a malformed regex", () => {
assert.ok(!DevLoopConfigSchema.safeParse({
version: 1,
Expand Down
13 changes: 13 additions & 0 deletions scripts/loop/ui-review-drive.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,19 @@ async function dismissInterstitials({ page, interstitials, timeoutMs = 2000 }) {
* Exported so the per-state attribution + consolePath threading has a regression
* test against the REAL production wiring (not a substitute fake runStep). */
export function makeRunStep({ page, outputDir, sliceCapturedEvents }) {
// The Playwright page keeps its size across steps, so a declared viewport stays
// in effect for later undeclared steps too. Carry it forward so the slug always
// names the viewport the page is ACTUALLY at — never a stale `default`. Before
// any explicit resize the page is at the context default, which slugs as `default`.
let effectiveViewport;
return async ({ appUrl, flow, step, index }) => {
const sel = step.selector;
// Apply a declared viewport BEFORE the action so the render (and its capture)
// reflects it — otherwise a mobile-slugged directory would hold desktop pixels.
if (step.viewport) {
await page.setViewportSize(step.viewport);
effectiveViewport = step.viewport;
}
switch (step.action) {
case "goto":
await page.goto(new URL(step.path ?? "/", appUrl).toString(), { waitUntil: "domcontentloaded" });
Expand Down Expand Up @@ -318,6 +329,8 @@ export function makeRunStep({ page, outputDir, sliceCapturedEvents }) {
page,
sliceId: flow.name,
stateName,
viewport: effectiveViewport,
interactionState: step.interactionState,
fullPage: false,
outputDir,
// Hand the already-sliced window to this state's console.json (same attribution
Expand Down
29 changes: 29 additions & 0 deletions test/contracts/deck-fit-harness-viewport-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';

// Source-level pin for the deck-fit CALLER change: the harness is exercised only
// by live-browser playwright specs (outside `npm run verify`), so this asserts
// the shape here. A revert re-introducing the `(mobile 390)` name-hack (or
// dropping `viewport: MOBILE`) would otherwise pass verify green.
test('deck-fit harness slugs the mobile viewport via `viewport:`, not a name-hacked stateName', async () => {
const source = await readFile(new URL('../../test/playwright/harness/deck-fit-harness.mjs', import.meta.url), 'utf8');

// Both mobile captures (deck + article) carry the viewport, so the slug — not
// the state name — distinguishes the responsive render.
const viewportCalls = source.match(/viewport:\s*MOBILE\b/g) ?? [];
assert.ok(viewportCalls.length >= 2, 'deck and article mobile captures must pass `viewport: MOBILE`');

// No capture may re-encode a viewport into the state NAME (the dropped hack was
// `(mobile 390)`). Anchor on the `stateName:` assignment and forbid a viewport
// word on that line, in ANY string form — backtick, quote, or concatenation all
// put `mobile`/`desktop`/`tablet` on the `stateName:` line, so a regression can't
// slip back under a different quote style. Variable refs like `mobileCapture`
// are unaffected (no word boundary after `mobile`), and the check is scoped to
// `stateName:` lines so unrelated comments/test-names don't trip it.
assert.doesNotMatch(
source,
/stateName:[^\n]*\b(?:mobile|desktop|tablet)\b/i,
'stateName must not encode a viewport label — the slug carries it',
);
});
2 changes: 1 addition & 1 deletion test/docs/ui-review-recipe-doc.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ test("recipe doc config keys match the shipped uiReview/worktree schema", async

// Anchor the count so a wrapped-then-dropped key (see below) can't pass by
// matching a doc that dropped the same key.
assert.equal(schema.size, 31, "expected 31 uiReview/worktree schema leaves");
assert.equal(schema.size, 34, "expected 34 uiReview/worktree schema leaves");
});

test("collectKeys descends through effects/preprocess wrappers at nested levels", () => {
Expand Down
48 changes: 48 additions & 0 deletions test/loop/ui-review-drive.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,54 @@ test("makeRunStep: threads consolePath into the runStep result and points state.
assert.equal(state.artifacts.console.path, outcome.consolePath, "state.json back-references the same console.json");
});

test("makeRunStep: a declared step viewport resizes the page and slugs the capture into a distinct directory (same state name, different viewport)", async () => {
const outputDir = tempDir();
const resized = [];
const page = {
on: () => {},
goto: async () => {},
setViewportSize: async (v) => resized.push(v),
screenshot: async () => {},
};
const runStep = makeRunStep({ page, outputDir });

const desktop = await runStep({ appUrl: "http://app", flow: { name: "decks" }, step: { name: "Hero", action: "goto", path: "/" }, index: 0 });
const mobile = await runStep({ appUrl: "http://app", flow: { name: "decks" }, step: { name: "Hero", action: "goto", path: "/", viewport: { width: 390, height: 844 } }, index: 1 });

// The declared viewport is applied to the page (honest pixels, not a mislabeled slug).
assert.deepEqual(resized, [{ width: 390, height: 844 }]);
// Same state name, different viewport → distinct on-disk directories (no collision).
assert.notEqual(desktop.statePath, mobile.statePath);
assert.match(path.dirname(desktop.statePath), /hero-default-none$/);
assert.match(path.dirname(mobile.statePath), /hero-w390h844-none$/);
const mobileState = JSON.parse(readFileSync(mobile.statePath, "utf8"));
assert.equal(mobileState.viewport, "w390h844");
});

test("makeRunStep: a viewport set by an earlier step carries into a later undeclared step's slug (the page stays resized — no stale `default`)", async () => {
const outputDir = tempDir();
const page = { on: () => {}, goto: async () => {}, setViewportSize: async () => {}, screenshot: async () => {} };
const runStep = makeRunStep({ page, outputDir });

// Step 1 resizes to mobile; step 2 declares NO viewport but the page is STILL at 390.
await runStep({ appUrl: "http://app", flow: { name: "decks" }, step: { name: "Hero", action: "goto", path: "/", viewport: { width: 390, height: 844 } }, index: 0 });
const later = await runStep({ appUrl: "http://app", flow: { name: "decks" }, step: { name: "Detail", action: "goto", path: "/d" }, index: 1 });

// The slug + state.json must name the viewport the page is ACTUALLY at, not `default`.
assert.match(path.dirname(later.statePath), /detail-w390h844-none$/);
assert.equal(JSON.parse(readFileSync(later.statePath, "utf8")).viewport, "w390h844");
});

test("makeRunStep: a declared interactionState slugs the capture (route names it — the drive never enumerates)", async () => {
const outputDir = tempDir();
const page = { on: () => {}, goto: async () => {}, click: async () => {}, screenshot: async () => {} };
const runStep = makeRunStep({ page, outputDir });

const errored = await runStep({ appUrl: "http://app", flow: { name: "form" }, step: { name: "Email", action: "click", selector: "#submit", interactionState: "error" }, index: 0 });
assert.match(path.dirname(errored.statePath), /email-default-error$/);
assert.equal(JSON.parse(readFileSync(errored.statePath, "utf8")).interactionState, "error");
});

test("makeRunStep: a capture failure still advances the attribution cursor — the next step's console.json does not inherit the prior step's events (no misattribution)", async () => {
const outputDir = tempDir();
const handlers = {};
Expand Down
8 changes: 6 additions & 2 deletions test/playwright/harness/deck-fit-harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,9 @@ export function defineDeckSuite({ sliceId, deckPath, sectionIds, mobileCapture }
page,
testInfo,
sliceId,
stateName: `${mobileCapture.stateName} (mobile 390)`,
stateName: mobileCapture.stateName,
// The slug carries the viewport — no need to encode it in the state name.
viewport: MOBILE,
fullPage: false,
metadata: {
fixture: deckName,
Expand Down Expand Up @@ -364,7 +366,9 @@ export function defineArticleSuite({ sliceId, articlePath }) {
page,
testInfo,
sliceId,
stateName: `${sliceId} (mobile 390)`,
stateName: sliceId,
// The slug carries the viewport — no need to encode it in the state name.
viewport: MOBILE,
fullPage: false,
metadata: {
fixture: articleName,
Expand Down