@ultimat3/cli 20.1.6 → 20.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ // `ui.diff` — the one `ui.*` tool with no browser in it. Two PNGs the others wrote, decoded through
2
+ // `@ultimat3/core`'s raw-pixel seam, compared by `ui-diff.ts`, and written back as a third PNG
3
+ // beside the second. No dependency: the seam already reads and writes 8-bit RGBA, and a capture
4
+ // that arrives in another PNG shape (Chrome writes RGB when a page has no transparency) is
5
+ // normalised through `transformImageBytes`, whose encoder emits the one shape the seam reads.
6
+ //
7
+ // The path gate is what makes `dev:read` defensible for a tool that opens files: `before`, `after`
8
+ // and `out` are relative to the app root and must resolve — lexically AND through any symlink —
9
+ // inside `.x/shot/`, the directory only `x shot` and the `ui.*` tools write. A token that may read
10
+ // the route table may read the pictures of those routes; it may not read `.env` through a tool
11
+ // that says "diff".
12
+
13
+ // why: Bun has no realpath of its own, and a symlink under .x/shot/ pointing out of it is the
14
+ // one path the lexical check cannot see.
15
+ import { realpath } from 'node:fs/promises';
16
+ // why: Bun exposes no path primitives; the gate is a resolve-then-prefix check on joined paths.
17
+ import { dirname, resolve, sep } from 'node:path';
18
+ import type { Raster } from '@ultimat3/core';
19
+ import {
20
+ decodeImage,
21
+ encodeImage,
22
+ ImageUnsupportedError,
23
+ transformImageBytes,
24
+ UltimateError,
25
+ } from '@ultimat3/core';
26
+ import type { UiDiffInput, UiDiffResult } from '@ultimat3/mcp';
27
+ import { SHOT_DIR } from './shot-server';
28
+ import { changedPercent, diffPixels } from './ui-diff';
29
+
30
+ export interface DiffDeps {
31
+ readonly root: string;
32
+ }
33
+
34
+ const SHOT_FIX =
35
+ 'x shot / --json # then pass the image path it answers, relative to the app root: ui.diff reads .x/shot/ and nothing else';
36
+
37
+ /** `true` when `path` is `dir` or lies under it — a prefix check on whole segments, never on chars. */
38
+ const under = (path: string, dir: string): boolean => path === dir || path.startsWith(dir + sep);
39
+
40
+ /**
41
+ * The lexical half of the gate: `root/<relative>` resolved, then held under `root/.x/shot`. An
42
+ * absolute `relative` resolves to itself, and `..` segments resolve away, so both leave through
43
+ * the same refusal.
44
+ */
45
+ export function shotPath(root: string, relative: string, field: string): string {
46
+ const shotDir = resolve(root, SHOT_DIR);
47
+ const path = resolve(root, relative);
48
+ if (!under(path, shotDir)) {
49
+ throw new UltimateError({
50
+ code: 'X_UI_DIFF_PATH_OUTSIDE',
51
+ cause: `${field} resolves to ${path}, which is not inside ${shotDir}`,
52
+ fix: SHOT_FIX,
53
+ meta: { field, path, shotDir },
54
+ });
55
+ }
56
+ return path;
57
+ }
58
+
59
+ /** The symlink half: the file's real location is held under the shot directory's real location. */
60
+ async function readCapture(root: string, relative: string, field: string): Promise<Uint8Array> {
61
+ const path = shotPath(root, relative, field);
62
+ const file = Bun.file(path);
63
+ if (!(await file.exists())) {
64
+ throw new UltimateError({
65
+ code: 'X_UI_DIFF_FILE_MISSING',
66
+ cause: `${field} names ${path}, and there is no file there`,
67
+ fix: 'x shot / --json # then diff the image path it answers; a capture ui.shot wrote is listed in its own answer',
68
+ meta: { field, path },
69
+ });
70
+ }
71
+ const real = await realpath(path);
72
+ const shotDir = await realpath(resolve(root, SHOT_DIR));
73
+ if (!under(real, shotDir)) {
74
+ throw new UltimateError({
75
+ code: 'X_UI_DIFF_PATH_OUTSIDE',
76
+ cause: `${field} is a link to ${real}, which is not inside ${shotDir}`,
77
+ fix: SHOT_FIX,
78
+ meta: { field, path, real, shotDir },
79
+ });
80
+ }
81
+ return file.bytes();
82
+ }
83
+
84
+ /**
85
+ * The seam reads 8-bit RGBA and nothing else. A PNG in any other shape — Chrome's RGB when the page
86
+ * is opaque, a palette from an optimiser — goes once through Bun's codecs, which always write the
87
+ * shape the seam reads. Only `imageUnsupported` is retried that way: a truncated file is a
88
+ * truncated file in either decoder.
89
+ */
90
+ export async function decodeCapture(bytes: Uint8Array): Promise<Raster> {
91
+ try {
92
+ return decodeImage(bytes);
93
+ } catch (error) {
94
+ if (!(error instanceof ImageUnsupportedError)) throw error;
95
+ return decodeImage(await transformImageBytes(bytes, { format: 'png' }));
96
+ }
97
+ }
98
+
99
+ /** Eight hex digits of the path's 64-bit hash: enough to keep two diffs of one `after` apart. */
100
+ export const hash8 = (text: string): string =>
101
+ Bun.hash(text).toString(16).padStart(16, '0').slice(0, 8);
102
+
103
+ export async function diffShots(deps: DiffDeps, input: UiDiffInput): Promise<UiDiffResult> {
104
+ const { root } = deps;
105
+ // The output path is gated BEFORE any decoding: a refusal should cost nothing, and `out` is the
106
+ // one path this tool writes, so it is the one that most needs holding under `.x/shot/`.
107
+ const afterPath = shotPath(root, input.after, 'after');
108
+ const diff =
109
+ input.out === undefined
110
+ ? resolve(dirname(afterPath), `diff-${hash8(input.before)}.png`)
111
+ : shotPath(root, input.out, 'out');
112
+ const [before, after] = await Promise.all([
113
+ readCapture(root, input.before, 'before').then(decodeCapture),
114
+ readCapture(root, input.after, 'after').then(decodeCapture),
115
+ ]);
116
+ if (before.width !== after.width || before.height !== after.height) {
117
+ throw new UltimateError({
118
+ code: 'X_UI_DIFF_SIZE_MISMATCH',
119
+ cause: `before is ${before.width}x${before.height} and after is ${after.width}x${after.height}; a diff needs one size`,
120
+ fix: 'x shot / --json # photograph both captures at one viewport with one fullPage setting, then diff those two',
121
+ meta: {
122
+ before: { width: before.width, height: before.height },
123
+ after: { width: after.width, height: after.height },
124
+ },
125
+ });
126
+ }
127
+ const { width, height } = after;
128
+ const result = diffPixels(before.pixels, after.pixels, width, height, input.threshold);
129
+ await Bun.write(diff, encodeImage({ width, height, pixels: result.diffRgba }));
130
+ return {
131
+ ok: true,
132
+ before: input.before,
133
+ after: input.after,
134
+ width,
135
+ height,
136
+ changedPixels: result.changedPixels,
137
+ changedPercent: changedPercent(result.changedPixels, width, height),
138
+ changedBox: result.changedBox,
139
+ diff,
140
+ };
141
+ }
@@ -0,0 +1,183 @@
1
+ // `ui.inspect` — the dev MCP server's third eye, and the one that reads instead of looking. A
2
+ // browser is the cost of every `ui.*` call, so this one reads MANY facts per navigation: for each
3
+ // selector the tag, text, box, visibility, attributes, named computed styles and (on request) the
4
+ // browser-computed role and name; for the document its title, `data-theme`, the focused element,
5
+ // the island count and both error streams. It is `runShot` with an `act` hook — the same boot,
6
+ // the same driver, the same verdict, the same PNG under an `inspect/` subdirectory — so `ok` here
7
+ // means exactly what `ui.shot`'s `ok` means, and a page that threw while an agent was reading
8
+ // its styles is a finding beside the styles.
9
+
10
+ // why: Bun exposes no path-join primitive, and the directory is a path an agent opens.
11
+ import { join } from 'node:path';
12
+ import type { UiInspectInput, UiInspectResult, UiInspectSelector } from '@ultimat3/mcp';
13
+ import { UI_INSPECT_LIMITS } from '@ultimat3/mcp';
14
+ import type { AxNode, ScrapeDriver, ScrapePage } from '@ultimat3/scraping';
15
+ import { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
16
+ import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
17
+ import type { ShotServer } from './shot-server';
18
+ import type { ShotVerdict } from './shot-verdict';
19
+ import type { InspectProbe } from './ui-inspect-probe';
20
+ import { inspectExpression, parseInspectProbe } from './ui-inspect-probe';
21
+
22
+ export interface InspectDeps {
23
+ readonly root: string;
24
+ /** The memoised scratch server (or the running `x dev`), as `uiCapabilities` hands it out. */
25
+ readonly boot: () => Promise<ShotServer>;
26
+ readonly driver: (viewport: UiInspectInput['viewport']) => Promise<ScrapeDriver>;
27
+ }
28
+
29
+ /** What the page answered before the parser had a say — `null` selectors when it answered nothing. */
30
+ export interface Seen {
31
+ probe: InspectProbe | null;
32
+ /** Per selector, in the input's order: the a11y nodes, or `null` when not asked. */
33
+ a11y: readonly (readonly AxNode[] | null)[];
34
+ }
35
+
36
+ /** The four fields the read is built from — `ui.inspect`'s input, or `ui.interact`'s block. */
37
+ export type InspectSpecInput = Pick<
38
+ UiInspectInput,
39
+ 'selectors' | 'styles' | 'a11y' | 'activeElement'
40
+ >;
41
+
42
+ /**
43
+ * The read itself, on a page somebody else navigated: the one probe expression, then (on request)
44
+ * one accessibility round trip per selector. `.catch(() => null)` for the island probe's reason:
45
+ * a page that refuses evaluation is a page with no facts, and the verdict — not a throw here — is
46
+ * what says why. Shared by `ui.inspect` and `ui.interact`, so both read the same facts.
47
+ */
48
+ export async function readInspect(page: ScrapePage, spec: InspectSpecInput): Promise<Seen> {
49
+ const expression = inspectExpression({
50
+ selectors: spec.selectors,
51
+ styles: spec.styles,
52
+ activeElement: spec.activeElement,
53
+ });
54
+ const probe = await page
55
+ .evaluate(expression)
56
+ .then(parseInspectProbe)
57
+ .catch(() => null);
58
+ if (!spec.a11y) return { probe, a11y: [] };
59
+ const nodes: (readonly AxNode[] | null)[] = [];
60
+ for (const selector of spec.selectors) {
61
+ nodes.push(await page.accessibility(selector, { max: UI_INSPECT_LIMITS.matches }));
62
+ }
63
+ return { probe, a11y: nodes };
64
+ }
65
+
66
+ /**
67
+ * The empty answer for a selector the page never described: the probe refused to run, or answered
68
+ * a shape the parser did not accept. `valid: false` is the honest reading — nothing about this
69
+ * selector was established — and the verdict beside it says whether the page was broken.
70
+ */
71
+ const unanswered = (selector: string): UiInspectSelector => ({
72
+ selector,
73
+ valid: false,
74
+ count: 0,
75
+ truncated: false,
76
+ matches: [],
77
+ });
78
+
79
+ export function selectorsOf(input: InspectSpecInput, seen: Seen): readonly UiInspectSelector[] {
80
+ return input.selectors.map((selector, index): UiInspectSelector => {
81
+ const found = seen.probe?.selectors[index];
82
+ if (found === undefined) return unanswered(selector);
83
+ const nodes = seen.a11y[index] ?? null;
84
+ return {
85
+ selector,
86
+ valid: found.valid,
87
+ count: found.count,
88
+ truncated: found.truncated,
89
+ // Merged BY INDEX: both reads walk `querySelectorAll` in document order over the same DOM,
90
+ // one navigation apart from nothing. A node the a11y read did not cover keeps no `a11y` key.
91
+ matches: found.matches.map((match, at) => {
92
+ const node = nodes?.[at];
93
+ return node === undefined
94
+ ? match
95
+ : { ...match, a11y: { role: node.role, name: node.name } };
96
+ }),
97
+ };
98
+ });
99
+ }
100
+
101
+ const encoded = (value: unknown): number => new TextEncoder().encode(JSON.stringify(value)).length;
102
+
103
+ /**
104
+ * The wire cap. Matches are dropped from the LAST selectors first — an agent lists what it cares
105
+ * about most first, and a cap that emptied the first selector would take the fact the call was
106
+ * for. The selector keeps its `count` and gains `truncated: true`, so the drop is visible.
107
+ */
108
+ export function capInspect(result: UiInspectResult): UiInspectResult {
109
+ if (encoded(result) <= UI_INSPECT_LIMITS.bytes) return result;
110
+ const selectors = [...result.selectors];
111
+ let truncated = result.truncated;
112
+ for (let index = selectors.length - 1; index >= 0; index -= 1) {
113
+ const entry = selectors[index];
114
+ if (entry === undefined || entry.matches.length === 0) continue;
115
+ selectors[index] = { ...entry, truncated: true, matches: [] };
116
+ truncated = true;
117
+ if (encoded({ ...result, selectors, truncated }) <= UI_INSPECT_LIMITS.bytes) break;
118
+ }
119
+ return { ...result, selectors, truncated };
120
+ }
121
+
122
+ const errorsOf = (verdict: ShotVerdict): readonly string[] =>
123
+ verdict.console.filter((line) => line.level === 'error').map((line) => line.text);
124
+
125
+ export async function inspectRoute(
126
+ deps: InspectDeps,
127
+ input: UiInspectInput,
128
+ ): Promise<UiInspectResult> {
129
+ // A holder rather than a `let`: an assignment inside the `act` closure does not reach the
130
+ // narrowing below, and a `let` typed `null` after the await is what the compiler would see.
131
+ const seen: Seen = { probe: null, a11y: [] };
132
+ const driver = await deps.driver(input.viewport);
133
+ // Its own subdirectory under the (route, viewport, scheme) one, so an inspect and a shot of the
134
+ // same route at the same size never overwrite each other's verdict.
135
+ const outDir = join(
136
+ deps.root,
137
+ SHOT_DIR,
138
+ shotSlug(input.route),
139
+ `${input.viewport.width}x${input.viewport.height}-${input.colorScheme}`,
140
+ 'inspect',
141
+ );
142
+ const artifacts = await runShot({
143
+ route: input.route,
144
+ outDir,
145
+ driver,
146
+ boot: deps.boot,
147
+ settleMs: DEFAULT_SETTLE_MS,
148
+ timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
149
+ fullPage: true,
150
+ colorScheme: input.colorScheme,
151
+ act: async (page) => {
152
+ const read = await readInspect(page, input);
153
+ seen.probe = read.probe;
154
+ seen.a11y = read.a11y;
155
+ },
156
+ });
157
+ const verdict = artifacts.verdict;
158
+ return capInspect({
159
+ ok: verdict.ok,
160
+ route: input.route,
161
+ finalUrl: verdict.finalUrl,
162
+ title: seen.probe?.title ?? '',
163
+ theme: seen.probe?.theme ?? null,
164
+ activeElement: seen.probe?.activeElement ?? null,
165
+ islands:
166
+ verdict.islands === null
167
+ ? null
168
+ : {
169
+ declared: verdict.islands.declared,
170
+ booted: verdict.islands.booted,
171
+ mounted: verdict.islands.mounted,
172
+ failed: verdict.islands.failed,
173
+ },
174
+ consoleErrors: errorsOf(verdict),
175
+ pageErrors: verdict.pageErrors.map((error) => error.message),
176
+ refused: verdict.refused,
177
+ selectors: selectorsOf(input, seen),
178
+ image: artifacts.image,
179
+ verdictFile: artifacts.verdictFile,
180
+ truncated: false,
181
+ droppedStyles: [],
182
+ });
183
+ }
@@ -0,0 +1,304 @@
1
+ // `ui.interact` — the dev MCP server's hand. The three `ui.*` eyes photograph a route as it loads;
2
+ // this one drives it first — opens the ⌘K palette, raises a dialog, types into a field — and
3
+ // then takes the same picture, the same verdict and (on request) `ui.inspect`'s facts, in ONE
4
+ // navigation. It is `runShot` with an `act` hook whose body is the step list: after EVERY step the
5
+ // island poll re-runs (a click that mounts something changes the count the verdict reports) and
6
+ // one poll interval passes for the CSS transition the click started.
7
+ //
8
+ // Four refusals, and every one refuses WHOLE rather than trims, skips or follows: a dropped step,
9
+ // a swallowed keystroke or a navigation quietly followed changes what the picture is of, and an
10
+ // agent judging that picture would judge the wrong thing.
11
+
12
+ // why: Bun exposes no path-join primitive, and the directory is a path an agent opens.
13
+ import { join } from 'node:path';
14
+ import { isUltimateError, toUltimateError, UltimateError } from '@ultimat3/core';
15
+ import type { UiInteractInput, UiInteractResult, UiInteractStepResult } from '@ultimat3/mcp';
16
+ import { UI_INTERACT_LIMITS } from '@ultimat3/mcp';
17
+ import type { ScrapePage } from '@ultimat3/scraping';
18
+ import { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
19
+ import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
20
+ import type { InspectDeps, Seen } from './mcp-ui-inspect';
21
+ import { readInspect, selectorsOf } from './mcp-ui-inspect';
22
+ import { SETTLE_POLL_MS } from './shot-settle';
23
+ import type { IslandCount } from './shot-verdict';
24
+ import { verdictJson } from './shot-verdict';
25
+
26
+ export interface InteractDeps extends InspectDeps {
27
+ /** Injected by the test, so the per-step transition frame is proved without spending it. */
28
+ readonly sleep?: ((ms: number) => Promise<void>) | undefined;
29
+ }
30
+
31
+ /** A step after parsing: one verb, its argument, and the bounds already held. */
32
+ export type InteractStep =
33
+ | { readonly kind: 'click'; readonly selector: string }
34
+ | { readonly kind: 'type'; readonly selector: string; readonly text: string }
35
+ | { readonly kind: 'press'; readonly chord: string }
36
+ | { readonly kind: 'focus'; readonly selector: string }
37
+ | { readonly kind: 'wait'; readonly ms: number }
38
+ | { readonly kind: 'wait'; readonly selector: string };
39
+
40
+ /**
41
+ * The four fix lines, verbatim from `mcp-errors.ts`'s `CLI_FIXES` — an error's fix travels IN the
42
+ * error, and `errors.explain` answers the same text.
43
+ */
44
+ const FIX = {
45
+ invalid:
46
+ 'x help shot --json # then resend ui.interact with at most 12 one-key steps, type.text under 500 chars and wait under 5000 ms',
47
+ secret:
48
+ 'x shot --all-islands --json # or --island <name>: a declared state renders the filled form without the secret ever being typed',
49
+ left: 'x routes --json # then resend ui.interact with steps that stay on one of its paths',
50
+ failed:
51
+ 'x routes --json # then run ui.inspect on the route first and copy a selector it reports with count >= 1',
52
+ } as const;
53
+
54
+ const invalid = (cause: string): UltimateError =>
55
+ new UltimateError({ code: 'X_UI_INTERACT_STEPS_INVALID', cause, fix: FIX.invalid });
56
+
57
+ const nonEmpty = (value: unknown, at: string): string => {
58
+ if (typeof value !== 'string' || value === '') throw invalid(`${at} must be a non-empty string`);
59
+ return value;
60
+ };
61
+
62
+ function parseStep(raw: Readonly<Record<string, unknown>>, index: number): InteractStep {
63
+ const keys = Object.keys(raw);
64
+ const key = keys[0];
65
+ if (keys.length !== 1 || key === undefined) {
66
+ throw invalid(
67
+ `step ${index} names ${keys.length} verbs (${keys.join(', ')}); a step is ONE of click, type, press, focus, wait`,
68
+ );
69
+ }
70
+ const at = `step ${index} (${key})`;
71
+ // `Object.hasOwn` narrowed the key set above; the read below is on a literal the parser owns.
72
+ const value: unknown = Object.hasOwn(raw, key) ? raw[key] : undefined;
73
+ switch (key) {
74
+ case 'click':
75
+ return { kind: 'click', selector: nonEmpty(value, at) };
76
+ case 'focus':
77
+ return { kind: 'focus', selector: nonEmpty(value, at) };
78
+ case 'press':
79
+ return { kind: 'press', chord: nonEmpty(value, at) };
80
+ case 'type': {
81
+ if (typeof value !== 'object' || value === null)
82
+ throw invalid(`${at} needs { selector, text }`);
83
+ const field = value as { readonly selector?: unknown; readonly text?: unknown };
84
+ const text = nonEmpty(field.text, `${at}.text`);
85
+ if (text.length > UI_INTERACT_LIMITS.textChars) {
86
+ throw invalid(
87
+ `${at}.text is ${text.length} chars; the cap is ${UI_INTERACT_LIMITS.textChars}`,
88
+ );
89
+ }
90
+ return { kind: 'type', selector: nonEmpty(field.selector, `${at}.selector`), text };
91
+ }
92
+ case 'wait': {
93
+ if (typeof value === 'number') {
94
+ if (!Number.isFinite(value) || value < 0 || value > UI_INTERACT_LIMITS.waitMs) {
95
+ throw invalid(`${at} is ${value} ms; the cap is ${UI_INTERACT_LIMITS.waitMs}`);
96
+ }
97
+ return { kind: 'wait', ms: value };
98
+ }
99
+ return { kind: 'wait', selector: nonEmpty(value, at) };
100
+ }
101
+ default:
102
+ throw invalid(`${at} is not a verb; a step is ONE of click, type, press, focus, wait`);
103
+ }
104
+ }
105
+
106
+ /** The whole list or nothing: a trimmed list photographs a different scene than the one asked for. */
107
+ export function parseSteps(raw: UiInteractInput['steps']): readonly InteractStep[] {
108
+ if (raw.length > UI_INTERACT_LIMITS.steps) {
109
+ throw invalid(`${raw.length} steps; the cap is ${UI_INTERACT_LIMITS.steps}`);
110
+ }
111
+ return raw.map(parseStep);
112
+ }
113
+
114
+ /** Eight hex chars of `Bun.hash` over the raw list — deterministic per step list, never per run. */
115
+ export const stepsHash = (raw: UiInteractInput['steps']): string =>
116
+ Bun.hash(JSON.stringify(raw)).toString(16).padStart(16, '0').slice(0, 8);
117
+
118
+ const describe = (step: InteractStep): string => {
119
+ switch (step.kind) {
120
+ case 'click':
121
+ case 'focus':
122
+ return `${step.kind} ${JSON.stringify(step.selector)}`;
123
+ case 'press':
124
+ return `press ${JSON.stringify(step.chord)}`;
125
+ case 'type':
126
+ return `type into ${JSON.stringify(step.selector)}`;
127
+ case 'wait':
128
+ return 'ms' in step ? `wait ${step.ms}ms` : `wait for ${JSON.stringify(step.selector)}`;
129
+ }
130
+ };
131
+
132
+ async function perform(
133
+ page: ScrapePage,
134
+ step: InteractStep,
135
+ sleep: (ms: number) => Promise<void>,
136
+ ): Promise<void> {
137
+ switch (step.kind) {
138
+ case 'click':
139
+ return page.click(step.selector);
140
+ case 'focus':
141
+ return page.focus(step.selector);
142
+ case 'press':
143
+ return page.press(step.chord);
144
+ case 'type':
145
+ return page.type(step.selector, step.text);
146
+ case 'wait':
147
+ if ('ms' in step) return sleep(step.ms);
148
+ await page.waitFor(step.selector, { state: 'visible', timeout: DEFAULT_PAGE_TIMEOUT_MS });
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Before any keystroke: a password field's value would land in the PNG, the verdict's console and
154
+ * an agent's transcript. The read is the driver's own `query`, so the fake answers it offline.
155
+ */
156
+ async function refuseSecretField(
157
+ page: ScrapePage,
158
+ step: InteractStep,
159
+ index: number,
160
+ ): Promise<void> {
161
+ if (step.kind !== 'type') return;
162
+ const [first] = await page.query(step.selector);
163
+ if (first?.attrs['type'] !== 'password') return;
164
+ throw new UltimateError({
165
+ code: 'X_UI_INTERACT_SECRET_FIELD',
166
+ cause: `step ${index} would type into ${JSON.stringify(step.selector)}, an <input type="password">`,
167
+ fix: FIX.secret,
168
+ meta: { step: index, selector: step.selector },
169
+ });
170
+ }
171
+
172
+ export interface StepsRun {
173
+ readonly steps: readonly UiInteractStepResult[];
174
+ /** Some step changed the page's URL — the navigation `ok` must not read as a redirect. */
175
+ readonly navigated: boolean;
176
+ }
177
+
178
+ /**
179
+ * The steps in order, each followed by a settle and one poll interval, each checked against the
180
+ * origin. A scraping error is wrapped, never passed through: the agent needs to know WHICH step,
181
+ * and the fix is a selector `ui.inspect` reports rather than whatever the driver's fix names.
182
+ */
183
+ export async function runSteps(
184
+ page: ScrapePage,
185
+ steps: readonly InteractStep[],
186
+ settle: () => Promise<IslandCount | null>,
187
+ origin: string,
188
+ sleep: (ms: number) => Promise<void>,
189
+ ): Promise<StepsRun> {
190
+ const results: UiInteractStepResult[] = [];
191
+ let navigated = false;
192
+ for (const [index, step] of steps.entries()) {
193
+ const before = page.url();
194
+ const started = performance.now();
195
+ await refuseSecretField(page, step, index);
196
+ try {
197
+ await perform(page, step, sleep);
198
+ } catch (error) {
199
+ const inner = isUltimateError(error) ? error : toUltimateError(error);
200
+ throw new UltimateError({
201
+ code: 'X_UI_INTERACT_STEP_FAILED',
202
+ cause: `step ${index} (${describe(step)}): ${inner.code} — ${inner.cause}`,
203
+ fix: FIX.failed,
204
+ meta: { step: index, code: inner.code },
205
+ sourceError: error,
206
+ });
207
+ }
208
+ const url = page.url();
209
+ if (new URL(url).origin !== origin) {
210
+ throw new UltimateError({
211
+ code: 'X_UI_INTERACT_LEFT_APP',
212
+ cause: `step ${index} (${describe(step)}) navigated to ${url}, off ${origin}`,
213
+ fix: FIX.left,
214
+ meta: { step: index, url },
215
+ });
216
+ }
217
+ await settle();
218
+ await sleep(SETTLE_POLL_MS);
219
+ const moved = url !== before;
220
+ navigated = navigated || moved;
221
+ results.push({
222
+ index,
223
+ kind: step.kind,
224
+ ms: Math.round(performance.now() - started),
225
+ navigated: moved,
226
+ url,
227
+ });
228
+ }
229
+ return { steps: results, navigated };
230
+ }
231
+
232
+ export async function interactRoute(
233
+ deps: InteractDeps,
234
+ input: UiInteractInput,
235
+ ): Promise<UiInteractResult> {
236
+ // Parsed BEFORE a browser exists: a refused list costs no navigation.
237
+ const steps = parseSteps(input.steps);
238
+ const sleep = deps.sleep ?? ((ms: number): Promise<void> => Bun.sleep(ms));
239
+ const server = await deps.boot();
240
+ const origin = new URL(server.url).origin;
241
+ const requestedUrl = new URL(input.route, server.url).toString();
242
+ const driver = await deps.driver(input.viewport);
243
+ const outDir = join(
244
+ deps.root,
245
+ SHOT_DIR,
246
+ shotSlug(input.route),
247
+ `${input.viewport.width}x${input.viewport.height}-${input.colorScheme}`,
248
+ `interact-${stepsHash(input.steps)}`,
249
+ );
250
+ // Holders rather than `let`s, for `ui.inspect`'s reason: an assignment inside `act` does not
251
+ // reach the narrowing below.
252
+ const ran: { run: StepsRun | null; seen: Seen | null; landedOn: string } = {
253
+ run: null,
254
+ seen: null,
255
+ landedOn: '',
256
+ };
257
+ const artifacts = await runShot({
258
+ route: input.route,
259
+ outDir,
260
+ driver,
261
+ boot: deps.boot,
262
+ settleMs: DEFAULT_SETTLE_MS,
263
+ timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
264
+ fullPage: input.fullPage,
265
+ colorScheme: input.colorScheme,
266
+ act: async (page, settle) => {
267
+ ran.landedOn = page.url();
268
+ ran.run = await runSteps(page, steps, settle, origin, sleep);
269
+ if (input.inspect !== undefined) ran.seen = await readInspect(page, input.inspect);
270
+ },
271
+ });
272
+ const verdict = artifacts.verdict;
273
+ const run = ran.run ?? { steps: [], navigated: false };
274
+ // The verdict fails a capture whose final URL is not the one requested — the sign-in redirect
275
+ // rule. A step that navigated is the agent's doing, not the server's, so it is excused ONLY
276
+ // when the page landed where it was asked and the verdict's other three rules hold.
277
+ const excused =
278
+ run.navigated &&
279
+ ran.landedOn === requestedUrl &&
280
+ verdict.errors === 0 &&
281
+ verdict.pageErrors.length === 0 &&
282
+ (verdict.islands?.failed ?? 0) === 0;
283
+ const inspect =
284
+ input.inspect === undefined || ran.seen === null
285
+ ? undefined
286
+ : {
287
+ title: ran.seen.probe?.title ?? '',
288
+ theme: ran.seen.probe?.theme ?? null,
289
+ activeElement: ran.seen.probe?.activeElement ?? null,
290
+ selectors: selectorsOf(input.inspect, ran.seen),
291
+ truncated: false,
292
+ droppedStyles: [],
293
+ };
294
+ return {
295
+ ok: verdict.ok || excused,
296
+ route: input.route,
297
+ finalUrl: verdict.finalUrl,
298
+ image: artifacts.image,
299
+ verdictFile: artifacts.verdictFile,
300
+ verdict: verdictJson(verdict),
301
+ steps: run.steps,
302
+ ...(inspect === undefined ? {} : { inspect }),
303
+ };
304
+ }