@ultimat3/mcp 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.
- package/package.json +9 -9
- package/src/dev-server.ts +75 -127
- package/src/dev-ui-interact.ts +226 -0
- package/src/dev-ui-tools.ts +439 -0
- package/src/index.ts +28 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/mcp",
|
|
3
|
-
"version": "20.1
|
|
3
|
+
"version": "20.2.1",
|
|
4
4
|
"description": "MCP server, dev tools, and the action-to-tool projection \u2014 one authz system, two surfaces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,13 +31,13 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/action": "20.1
|
|
35
|
-
"@ultimat3/core": "20.1
|
|
36
|
-
"@ultimat3/entity": "20.1
|
|
37
|
-
"@ultimat3/http": "20.1
|
|
38
|
-
"@ultimat3/jobs": "20.1
|
|
39
|
-
"@ultimat3/policy": "20.1
|
|
40
|
-
"@ultimat3/query": "20.1
|
|
41
|
-
"@ultimat3/schema": "20.1
|
|
34
|
+
"@ultimat3/action": "20.2.1",
|
|
35
|
+
"@ultimat3/core": "20.2.1",
|
|
36
|
+
"@ultimat3/entity": "20.2.1",
|
|
37
|
+
"@ultimat3/http": "20.2.1",
|
|
38
|
+
"@ultimat3/jobs": "20.2.1",
|
|
39
|
+
"@ultimat3/policy": "20.2.1",
|
|
40
|
+
"@ultimat3/query": "20.2.1",
|
|
41
|
+
"@ultimat3/schema": "20.2.1"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/dev-server.ts
CHANGED
|
@@ -10,6 +10,19 @@
|
|
|
10
10
|
// packages of this same tier, and the shell-side capabilities (db, tests, logs) belong to
|
|
11
11
|
// the CLI, so this file defines the interface and the CLI satisfies it.
|
|
12
12
|
|
|
13
|
+
import type { UiInteractInput, UiInteractResult } from './dev-ui-interact';
|
|
14
|
+
import { uiInteractTools } from './dev-ui-interact';
|
|
15
|
+
import type {
|
|
16
|
+
UiDiffInput,
|
|
17
|
+
UiDiffResult,
|
|
18
|
+
UiInspectInput,
|
|
19
|
+
UiInspectResult,
|
|
20
|
+
UiIslandInput,
|
|
21
|
+
UiIslandResult,
|
|
22
|
+
UiShotInput,
|
|
23
|
+
UiShotResult,
|
|
24
|
+
} from './dev-ui-tools';
|
|
25
|
+
import { uiTools } from './dev-ui-tools';
|
|
13
26
|
import type { QueryLimits, QueryRows } from './query-limits';
|
|
14
27
|
import { capQueryRows, DEFAULT_QUERY_ROWS, QUERY_LIMITS, resolveQueryLimits } from './query-limits';
|
|
15
28
|
import type { DatabaseTarget } from './readonly-sql';
|
|
@@ -19,6 +32,45 @@ import { jsonResult, textResult } from './registry';
|
|
|
19
32
|
import type { JsonSchema } from './wire';
|
|
20
33
|
import { NO_ARGS } from './wire';
|
|
21
34
|
|
|
35
|
+
export type {
|
|
36
|
+
UiInspectSpec,
|
|
37
|
+
UiInteractInput,
|
|
38
|
+
UiInteractInspect,
|
|
39
|
+
UiInteractResult,
|
|
40
|
+
UiInteractStep,
|
|
41
|
+
UiInteractStepKind,
|
|
42
|
+
UiInteractStepResult,
|
|
43
|
+
} from './dev-ui-interact';
|
|
44
|
+
export { UI_INTERACT_LIMITS, UI_INTERACT_STEP_SCHEMA, uiInteractTools } from './dev-ui-interact';
|
|
45
|
+
// Re-exported, not re-declared: `index.ts` and the CLI import the `ui.*` vocabulary from here, and
|
|
46
|
+
// the extraction to `dev-ui-tools.ts` was about the ceiling, never about moving the API.
|
|
47
|
+
export type {
|
|
48
|
+
UiColorScheme,
|
|
49
|
+
UiDiffInput,
|
|
50
|
+
UiDiffResult,
|
|
51
|
+
UiInspectActive,
|
|
52
|
+
UiInspectBox,
|
|
53
|
+
UiInspectInput,
|
|
54
|
+
UiInspectIslands,
|
|
55
|
+
UiInspectMatch,
|
|
56
|
+
UiInspectResult,
|
|
57
|
+
UiInspectSelector,
|
|
58
|
+
UiIslandInput,
|
|
59
|
+
UiIslandResult,
|
|
60
|
+
UiScopes,
|
|
61
|
+
UiShotInput,
|
|
62
|
+
UiShotResult,
|
|
63
|
+
UiViewportName,
|
|
64
|
+
} from './dev-ui-tools';
|
|
65
|
+
export {
|
|
66
|
+
STYLE_NAME,
|
|
67
|
+
UI_DIFF_DEFAULT_THRESHOLD,
|
|
68
|
+
UI_INSPECT_LIMITS,
|
|
69
|
+
UI_VIEWPORTS,
|
|
70
|
+
uiTools,
|
|
71
|
+
viewportOf,
|
|
72
|
+
} from './dev-ui-tools';
|
|
73
|
+
|
|
22
74
|
/** Scopes the dev server gates on. A token carries a subset; the rest is invisible. */
|
|
23
75
|
export const DEV_SCOPES = {
|
|
24
76
|
read: 'dev:read',
|
|
@@ -68,59 +120,6 @@ export interface VerifyResult {
|
|
|
68
120
|
}
|
|
69
121
|
|
|
70
122
|
/** Description sources. Satisfied by `frameworkIntrospection` in a real app. */
|
|
71
|
-
/**
|
|
72
|
-
* The viewports `ui.shot` names. Named, not free, so two agents (or one agent twice) photograph
|
|
73
|
-
* the same thing and can compare the pictures; `{ width, height }` stays available for the one
|
|
74
|
-
* case a name does not cover.
|
|
75
|
-
*/
|
|
76
|
-
export const UI_VIEWPORTS = {
|
|
77
|
-
phone: { width: 390, height: 844 },
|
|
78
|
-
tablet: { width: 820, height: 1180 },
|
|
79
|
-
desktop: { width: 1440, height: 900 },
|
|
80
|
-
} as const;
|
|
81
|
-
export type UiViewportName = keyof typeof UI_VIEWPORTS;
|
|
82
|
-
export type UiColorScheme = 'light' | 'dark';
|
|
83
|
-
|
|
84
|
-
export interface UiShotInput {
|
|
85
|
-
/** The route's path — `/dashboard`, `/links/abc123` — never a full URL. */
|
|
86
|
-
readonly route: string;
|
|
87
|
-
readonly viewport: { readonly width: number; readonly height: number };
|
|
88
|
-
/**
|
|
89
|
-
* What `prefers-color-scheme` the page sees. Emulated on the page BEFORE navigation, so a
|
|
90
|
-
* capture never depends on the box that took it; an app whose boot script honours a stored
|
|
91
|
-
* choice still wins, because the stored choice is what "explicit" means.
|
|
92
|
-
*/
|
|
93
|
-
readonly colorScheme: UiColorScheme;
|
|
94
|
-
readonly fullPage: boolean;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* What a picture is worth: the file, and the verdict beside it. The verdict is the SAME shape
|
|
99
|
-
* `x shot` writes to `verdict.json` — console lines, page errors, network refusals, whether every
|
|
100
|
-
* island mounted — so a picture with a hydration error is a finding, never merely a picture.
|
|
101
|
-
* The PNG is a PATH, never inlined bytes: an agent reads the picture it wants and pays for one.
|
|
102
|
-
*/
|
|
103
|
-
export interface UiShotResult {
|
|
104
|
-
readonly ok: boolean;
|
|
105
|
-
readonly image: string;
|
|
106
|
-
readonly verdictFile: string;
|
|
107
|
-
readonly verdict: unknown;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export interface UiIslandInput {
|
|
111
|
-
/** The island's name as `x shot --island <name>` takes it. */
|
|
112
|
-
readonly island: string;
|
|
113
|
-
/** One declared state, or every state the island declares. */
|
|
114
|
-
readonly state?: string | undefined;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export interface UiIslandResult {
|
|
118
|
-
readonly ok: boolean;
|
|
119
|
-
readonly dir: string;
|
|
120
|
-
readonly verdictFile: string;
|
|
121
|
-
readonly verdict: unknown;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
123
|
export interface DevIntrospection {
|
|
125
124
|
routes(): unknown;
|
|
126
125
|
entities(): unknown;
|
|
@@ -156,6 +155,24 @@ export interface DevCapabilities {
|
|
|
156
155
|
shotRoute(input: UiShotInput): Promise<UiShotResult>;
|
|
157
156
|
/** `x shot --island <name> [--state <id>]` as a tool: every declared state, photographed and judged. */
|
|
158
157
|
shotIsland(input: UiIslandInput): Promise<UiIslandResult>;
|
|
158
|
+
/**
|
|
159
|
+
* DOM, computed-style and (optionally) accessibility facts for a set of selectors, read in ONE
|
|
160
|
+
* navigation of the route — the browser is the cost, so one call reads many. Same route gate
|
|
161
|
+
* as `shotRoute`; takes the same PNG and verdict, under an `inspect/` subdirectory.
|
|
162
|
+
*/
|
|
163
|
+
inspectRoute(input: UiInspectInput): Promise<UiInspectResult>;
|
|
164
|
+
/**
|
|
165
|
+
* Drive the route through a bounded step list (click, type, press, focus, wait), each step
|
|
166
|
+
* followed by an island settle, then photograph it and optionally read `inspectRoute`'s facts —
|
|
167
|
+
* ONE navigation. Same route gate; the PNG lands under `interact-<hash of the steps>/`.
|
|
168
|
+
*/
|
|
169
|
+
interactRoute(input: UiInteractInput): Promise<UiInteractResult>;
|
|
170
|
+
/**
|
|
171
|
+
* Compare two PNGs the `ui.*` tools wrote — both paths relative to the app root and confined to
|
|
172
|
+
* `.x/shot/`, which is what lets a tool that reads files sit under `dev:read`. No browser: the
|
|
173
|
+
* pixels are decoded, counted and written back by `@ultimat3/core`'s raw-pixel seam.
|
|
174
|
+
*/
|
|
175
|
+
diffShots(input: UiDiffInput): Promise<UiDiffResult>;
|
|
159
176
|
}
|
|
160
177
|
|
|
161
178
|
export type DevHost = DevIntrospection & DevCapabilities;
|
|
@@ -166,19 +183,6 @@ const NAME_ARG: JsonSchema = {
|
|
|
166
183
|
additionalProperties: false,
|
|
167
184
|
};
|
|
168
185
|
|
|
169
|
-
/**
|
|
170
|
-
* An explicit `width`+`height` wins over the name; one of the pair alone is not a viewport and
|
|
171
|
-
* falls back to the name (default `desktop`) rather than to a half-sized frame.
|
|
172
|
-
*/
|
|
173
|
-
export function viewportOf(args: ToolArgs): { readonly width: number; readonly height: number } {
|
|
174
|
-
const width = args['width'];
|
|
175
|
-
const height = args['height'];
|
|
176
|
-
if (typeof width === 'number' && typeof height === 'number') return { width, height };
|
|
177
|
-
const name = args['viewport'];
|
|
178
|
-
const named = typeof name === 'string' && Object.hasOwn(UI_VIEWPORTS, name) ? name : 'desktop';
|
|
179
|
-
return UI_VIEWPORTS[named as UiViewportName];
|
|
180
|
-
}
|
|
181
|
-
|
|
182
186
|
/** Every dev tool, in one array so `x mcp serve` and the HTTP transport share the catalog. */
|
|
183
187
|
export function devTools(host: DevHost): readonly AnyMcpTool[] {
|
|
184
188
|
return [
|
|
@@ -333,67 +337,11 @@ export function devTools(host: DevHost): readonly AnyMcpTool[] {
|
|
|
333
337
|
return { ...jsonResult(result), ...(result.ok ? {} : { isError: true }) };
|
|
334
338
|
},
|
|
335
339
|
},
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
'verdict x shot writes: console, page errors, refused requests, whether every island ' +
|
|
342
|
-
'mounted. Refuses a route with no declared JS budget. Launches a browser.',
|
|
343
|
-
scope: DEV_SCOPES.test,
|
|
344
|
-
destructive: true,
|
|
345
|
-
inputSchema: {
|
|
346
|
-
type: 'object',
|
|
347
|
-
properties: {
|
|
348
|
-
route: { type: 'string', description: 'Route path, e.g. /dashboard.' },
|
|
349
|
-
viewport: {
|
|
350
|
-
type: 'string',
|
|
351
|
-
enum: Object.keys(UI_VIEWPORTS),
|
|
352
|
-
default: 'desktop',
|
|
353
|
-
description: 'phone 390×844, tablet 820×1180, desktop 1440×900.',
|
|
354
|
-
},
|
|
355
|
-
width: { type: 'integer', minimum: 320, maximum: 3840 },
|
|
356
|
-
height: { type: 'integer', minimum: 320, maximum: 2160 },
|
|
357
|
-
theme: { type: 'string', enum: ['light', 'dark'], default: 'dark' },
|
|
358
|
-
fullPage: { type: 'boolean', default: true },
|
|
359
|
-
},
|
|
360
|
-
required: ['route'],
|
|
361
|
-
additionalProperties: false,
|
|
362
|
-
},
|
|
363
|
-
async handle(args: ToolArgs) {
|
|
364
|
-
const route = typeof args['route'] === 'string' ? args['route'] : '';
|
|
365
|
-
const result = await host.shotRoute({
|
|
366
|
-
route,
|
|
367
|
-
viewport: viewportOf(args),
|
|
368
|
-
colorScheme: args['theme'] === 'light' ? 'light' : 'dark',
|
|
369
|
-
fullPage: args['fullPage'] !== false,
|
|
370
|
-
});
|
|
371
|
-
return { ...jsonResult(result), ...(result.ok ? {} : { isError: true }) };
|
|
372
|
-
},
|
|
373
|
-
},
|
|
374
|
-
{
|
|
375
|
-
name: 'ui.island',
|
|
376
|
-
description:
|
|
377
|
-
'Photograph an island in every state its *.island.states.ts declares (or one state), ' +
|
|
378
|
-
'as x shot --island does: PNGs plus a verdict per state. Launches a browser.',
|
|
379
|
-
scope: DEV_SCOPES.test,
|
|
380
|
-
destructive: true,
|
|
381
|
-
inputSchema: {
|
|
382
|
-
type: 'object',
|
|
383
|
-
properties: {
|
|
384
|
-
island: { type: 'string', description: 'Island name, e.g. links-table.' },
|
|
385
|
-
state: { type: 'string', description: 'One declared state id; omit for all.' },
|
|
386
|
-
},
|
|
387
|
-
required: ['island'],
|
|
388
|
-
additionalProperties: false,
|
|
389
|
-
},
|
|
390
|
-
async handle(args: ToolArgs) {
|
|
391
|
-
const island = typeof args['island'] === 'string' ? args['island'] : '';
|
|
392
|
-
const state = typeof args['state'] === 'string' ? args['state'] : undefined;
|
|
393
|
-
const result = await host.shotIsland({ island, ...(state === undefined ? {} : { state }) });
|
|
394
|
-
return { ...jsonResult(result), ...(result.ok ? {} : { isError: true }) };
|
|
395
|
-
},
|
|
396
|
-
},
|
|
340
|
+
// The five `ui.*` tools live in `dev-ui-tools.ts` and `dev-ui-interact.ts` (ceiling); same
|
|
341
|
+
// catalog. Four launch a browser under `dev:test`; `ui.diff` reads two files they already
|
|
342
|
+
// wrote, under `dev:read`.
|
|
343
|
+
...uiTools(host, { test: DEV_SCOPES.test, read: DEV_SCOPES.read }),
|
|
344
|
+
...uiInteractTools(host, DEV_SCOPES.test),
|
|
397
345
|
{
|
|
398
346
|
name: 'logs.tail',
|
|
399
347
|
description: 'Last N log lines, optionally for one runtime role (web/sync/worker/...).',
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// `ui.interact` — the dev server's hand. An agent that needs the ⌘K palette open, a dialog up or a
|
|
2
|
+
// field filled before the picture is worth taking drives the route through a BOUNDED list of
|
|
3
|
+
// steps, and gets the picture, the verdict and (on request) `ui.inspect`'s facts in ONE
|
|
4
|
+
// navigation. Its own file for the reason `dev-ui-tools.ts` has one: that file stands near the
|
|
5
|
+
// 500-line ceiling, and this tool's step schema alone is a screen. `dev-server.ts` spreads both.
|
|
6
|
+
//
|
|
7
|
+
// Every bound lives in the HANDLER's contract, not the schema: `validate-args.ts` enforces no
|
|
8
|
+
// `maxItems`, and a step list the schema cannot bound is one the CLI refuses whole — never trims,
|
|
9
|
+
// because dropping a step changes what the picture is of. The schema's job is the SHAPE of a step
|
|
10
|
+
// (`anyOf` over five one-key objects); the CLI's is the count, the lengths and the policies.
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
UiColorScheme,
|
|
14
|
+
UiInspectActive,
|
|
15
|
+
UiInspectInput,
|
|
16
|
+
UiInspectSelector,
|
|
17
|
+
} from './dev-ui-tools';
|
|
18
|
+
import { inspectSpecOf, VIEWPORT_ARGS, viewportOf } from './dev-ui-tools';
|
|
19
|
+
import type { AnyMcpTool, ToolArgs } from './registry';
|
|
20
|
+
import { jsonResult } from './registry';
|
|
21
|
+
import type { JsonSchema } from './wire';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Bounds the CLI holds a step list to. `steps` is a scene, not a script: twelve is enough to open
|
|
25
|
+
* a palette, type a query and pick a row, and a longer list is a test that belongs in `x test`.
|
|
26
|
+
* `waitMs` is a CSS transition's worth, not a network's — a `wait` on a selector waits within the
|
|
27
|
+
* shot timeout instead. `textChars` bounds what one `type` step may put into a field.
|
|
28
|
+
*/
|
|
29
|
+
export const UI_INTERACT_LIMITS = { steps: 12, textChars: 500, waitMs: 5000 } as const;
|
|
30
|
+
|
|
31
|
+
/** One step, as the wire carries it: exactly ONE key, naming the verb. */
|
|
32
|
+
export type UiInteractStep =
|
|
33
|
+
| { readonly click: string }
|
|
34
|
+
| { readonly type: { readonly selector: string; readonly text: string } }
|
|
35
|
+
| { readonly press: string }
|
|
36
|
+
| { readonly focus: string }
|
|
37
|
+
| { readonly wait: number | string };
|
|
38
|
+
|
|
39
|
+
export type UiInteractStepKind = 'click' | 'type' | 'press' | 'focus' | 'wait';
|
|
40
|
+
|
|
41
|
+
/** The `ui.inspect` block `ui.interact` runs AFTER its steps — the same four fields, same bounds. */
|
|
42
|
+
export type UiInspectSpec = Pick<UiInspectInput, 'selectors' | 'styles' | 'a11y' | 'activeElement'>;
|
|
43
|
+
|
|
44
|
+
export interface UiInteractInput {
|
|
45
|
+
readonly route: string;
|
|
46
|
+
readonly viewport: { readonly width: number; readonly height: number };
|
|
47
|
+
readonly colorScheme: UiColorScheme;
|
|
48
|
+
/** Default FALSE, unlike `ui.shot`: a dialog or a palette is judged on the fold it opened in. */
|
|
49
|
+
readonly fullPage: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* What the wire carried, one record per step, already shaped by the schema's `anyOf`. Records
|
|
52
|
+
* and not `UiInteractStep`: the CLI parses them — count, lengths, the one-key rule — and refuses
|
|
53
|
+
* the whole list with `X_UI_INTERACT_STEPS_INVALID`, so a host reached without the schema (a
|
|
54
|
+
* test, a script) is held to the same rule.
|
|
55
|
+
*/
|
|
56
|
+
readonly steps: readonly Readonly<Record<string, unknown>>[];
|
|
57
|
+
readonly inspect?: UiInspectSpec | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface UiInteractStepResult {
|
|
61
|
+
readonly index: number;
|
|
62
|
+
readonly kind: UiInteractStepKind;
|
|
63
|
+
/** Wall time the step took, settle included. */
|
|
64
|
+
readonly ms: number;
|
|
65
|
+
/** The page's URL changed during this step — a same-origin navigation the step caused. */
|
|
66
|
+
readonly navigated: boolean;
|
|
67
|
+
readonly url: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `ui.inspect`'s document-and-selectors block, read after the last step settled. */
|
|
71
|
+
export interface UiInteractInspect {
|
|
72
|
+
readonly title: string;
|
|
73
|
+
readonly theme: string | null;
|
|
74
|
+
readonly activeElement: UiInspectActive | null;
|
|
75
|
+
readonly selectors: readonly UiInspectSelector[];
|
|
76
|
+
readonly truncated: boolean;
|
|
77
|
+
readonly droppedStyles: readonly string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface UiInteractResult {
|
|
81
|
+
/**
|
|
82
|
+
* The verdict's `ok` — with one reading of its own: a same-origin navigation a STEP caused is
|
|
83
|
+
* not the redirect `ui.shot` fails a capture for. The verdict still reports `redirected`.
|
|
84
|
+
*/
|
|
85
|
+
readonly ok: boolean;
|
|
86
|
+
readonly route: string;
|
|
87
|
+
readonly finalUrl: string;
|
|
88
|
+
readonly image: string;
|
|
89
|
+
readonly verdictFile: string;
|
|
90
|
+
readonly verdict: unknown;
|
|
91
|
+
readonly steps: readonly UiInteractStepResult[];
|
|
92
|
+
readonly inspect?: UiInteractInspect | undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface UiInteractHost {
|
|
96
|
+
interactRoute(input: UiInteractInput): Promise<UiInteractResult>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const selector = (what: string): JsonSchema => ({
|
|
100
|
+
type: 'string',
|
|
101
|
+
minLength: 1,
|
|
102
|
+
description: what,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const oneKey = (key: string, value: JsonSchema): JsonSchema => ({
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: { [key]: value },
|
|
108
|
+
required: [key],
|
|
109
|
+
additionalProperties: false,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** Five shapes, one key each. A step with two verbs has no order, so it has no shape here. */
|
|
113
|
+
export const UI_INTERACT_STEP_SCHEMA: JsonSchema = {
|
|
114
|
+
anyOf: [
|
|
115
|
+
oneKey('click', selector('CSS selector of the element to click (first match).')),
|
|
116
|
+
oneKey('type', {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
selector: selector('CSS selector of the field; a password field is refused.'),
|
|
120
|
+
text: {
|
|
121
|
+
type: 'string',
|
|
122
|
+
minLength: 1,
|
|
123
|
+
description: `Keystrokes to send, at most ${UI_INTERACT_LIMITS.textChars} characters.`,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
required: ['selector', 'text'],
|
|
127
|
+
additionalProperties: false,
|
|
128
|
+
}),
|
|
129
|
+
oneKey('press', selector("A key chord on whatever holds focus: 'Meta+K', 'Escape', 'Enter'.")),
|
|
130
|
+
oneKey('focus', selector('CSS selector of the element to move focus to.')),
|
|
131
|
+
oneKey('wait', {
|
|
132
|
+
anyOf: [
|
|
133
|
+
{
|
|
134
|
+
type: 'integer',
|
|
135
|
+
minimum: 0,
|
|
136
|
+
description: `Milliseconds to pause, at most ${UI_INTERACT_LIMITS.waitMs}.`,
|
|
137
|
+
},
|
|
138
|
+
selector('CSS selector to wait VISIBLE, within the shot timeout.'),
|
|
139
|
+
],
|
|
140
|
+
}),
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
145
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
146
|
+
|
|
147
|
+
/** Everything array-shaped goes through; the CLI is the parser, and it refuses whole. */
|
|
148
|
+
const stepsOf = (value: unknown): readonly Readonly<Record<string, unknown>>[] =>
|
|
149
|
+
Array.isArray(value)
|
|
150
|
+
? value.map((item): Readonly<Record<string, unknown>> => (isRecord(item) ? item : {}))
|
|
151
|
+
: [];
|
|
152
|
+
|
|
153
|
+
/** The one `ui.interact` tool, spread into the dev catalog beside the three `ui.*` eyes. */
|
|
154
|
+
export function uiInteractTools(host: UiInteractHost, scope: string): readonly AnyMcpTool[] {
|
|
155
|
+
return [
|
|
156
|
+
{
|
|
157
|
+
name: 'ui.interact',
|
|
158
|
+
description:
|
|
159
|
+
`Drive one route through at most ${UI_INTERACT_LIMITS.steps} steps — click, type, press a ` +
|
|
160
|
+
'key chord, focus, wait — then photograph it and (optionally) read the same DOM, style and ' +
|
|
161
|
+
'a11y facts ui.inspect reads, all in ONE navigation. Each step waits for the islands to ' +
|
|
162
|
+
'settle; per step you get whether it navigated and where. Refuses a step list over the ' +
|
|
163
|
+
"bounds (never trims), typing into a password field, and any step that leaves the app's " +
|
|
164
|
+
'origin. Same PNG and verdict as ui.shot, under interact-<hash>/; `ok` is the verdict. ' +
|
|
165
|
+
'Launches a browser.',
|
|
166
|
+
scope,
|
|
167
|
+
destructive: true,
|
|
168
|
+
inputSchema: {
|
|
169
|
+
type: 'object',
|
|
170
|
+
properties: {
|
|
171
|
+
route: { type: 'string', description: 'Route path, e.g. /dashboard.' },
|
|
172
|
+
...VIEWPORT_ARGS,
|
|
173
|
+
fullPage: { type: 'boolean', default: false },
|
|
174
|
+
steps: {
|
|
175
|
+
type: 'array',
|
|
176
|
+
items: UI_INTERACT_STEP_SCHEMA,
|
|
177
|
+
description:
|
|
178
|
+
`In order, at most ${UI_INTERACT_LIMITS.steps}. Each is ONE of {click}, {type: {selector, text}}, ` +
|
|
179
|
+
'{press}, {focus}, {wait: ms | selector}.',
|
|
180
|
+
},
|
|
181
|
+
inspect: {
|
|
182
|
+
type: 'object',
|
|
183
|
+
description:
|
|
184
|
+
'ui.inspect’s block, run after the last step: selectors, styles, a11y, activeElement.',
|
|
185
|
+
properties: {
|
|
186
|
+
selectors: { type: 'array', items: { type: 'string', minLength: 1 } },
|
|
187
|
+
styles: { type: 'array', items: { type: 'string' } },
|
|
188
|
+
a11y: { type: 'boolean', default: false },
|
|
189
|
+
activeElement: { type: 'boolean', default: true },
|
|
190
|
+
},
|
|
191
|
+
required: ['selectors'],
|
|
192
|
+
additionalProperties: false,
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
required: ['route', 'steps'],
|
|
196
|
+
additionalProperties: false,
|
|
197
|
+
},
|
|
198
|
+
async handle(args: ToolArgs) {
|
|
199
|
+
const route = typeof args['route'] === 'string' ? args['route'] : '';
|
|
200
|
+
const asked = isRecord(args['inspect']) ? inspectSpecOf(args['inspect']) : undefined;
|
|
201
|
+
const result = await host.interactRoute({
|
|
202
|
+
route,
|
|
203
|
+
viewport: viewportOf(args),
|
|
204
|
+
colorScheme: args['theme'] === 'light' ? 'light' : 'dark',
|
|
205
|
+
fullPage: args['fullPage'] === true,
|
|
206
|
+
steps: stepsOf(args['steps']),
|
|
207
|
+
...(asked === undefined ? {} : { inspect: asked.spec }),
|
|
208
|
+
});
|
|
209
|
+
// The same confession `ui.inspect` makes: what the handler trimmed, named in the answer.
|
|
210
|
+
const inspect =
|
|
211
|
+
result.inspect === undefined || asked === undefined
|
|
212
|
+
? result.inspect
|
|
213
|
+
: {
|
|
214
|
+
...result.inspect,
|
|
215
|
+
truncated: result.inspect.truncated || asked.trimmed,
|
|
216
|
+
droppedStyles: asked.droppedStyles,
|
|
217
|
+
};
|
|
218
|
+
const answer: UiInteractResult = {
|
|
219
|
+
...result,
|
|
220
|
+
...(inspect === undefined ? {} : { inspect }),
|
|
221
|
+
};
|
|
222
|
+
return { ...jsonResult(answer), ...(answer.ok ? {} : { isError: true }) };
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
}
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
// The dev server's eyes: `ui.shot` (a route), `ui.island` (a component's states), `ui.inspect`
|
|
2
|
+
// (DOM, computed-style and accessibility facts for a set of selectors, in ONE navigation) and
|
|
3
|
+
// `ui.diff` (two captures the others wrote, compared without a browser). Split
|
|
4
|
+
// out of `dev-server.ts` because that file stood at 439 lines against the 500-line ceiling and a
|
|
5
|
+
// third `ui.*` literal would have crossed it — the catalog is still one array; `devTools` spreads
|
|
6
|
+
// this one in. Every type here is re-exported from `dev-server.ts`, so the CLI and the package
|
|
7
|
+
// index import what they always did.
|
|
8
|
+
//
|
|
9
|
+
// A browser is the expensive half of every call here, which is why `ui.inspect` reads MANY
|
|
10
|
+
// selectors per navigation and bounds each answer in the handler: a model that asks for a
|
|
11
|
+
// thousand selectors gets twenty and a `truncated: true`, never a thousand navigations.
|
|
12
|
+
|
|
13
|
+
import type { AnyMcpTool, ToolArgs } from './registry';
|
|
14
|
+
import { jsonResult } from './registry';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The viewports `ui.shot` names. Named, not free, so two agents (or one agent twice) photograph
|
|
18
|
+
* the same thing and can compare the pictures; `{ width, height }` stays available for the one
|
|
19
|
+
* case a name does not cover.
|
|
20
|
+
*/
|
|
21
|
+
export const UI_VIEWPORTS = {
|
|
22
|
+
phone: { width: 390, height: 844 },
|
|
23
|
+
tablet: { width: 820, height: 1180 },
|
|
24
|
+
desktop: { width: 1440, height: 900 },
|
|
25
|
+
} as const;
|
|
26
|
+
export type UiViewportName = keyof typeof UI_VIEWPORTS;
|
|
27
|
+
export type UiColorScheme = 'light' | 'dark';
|
|
28
|
+
|
|
29
|
+
export interface UiShotInput {
|
|
30
|
+
/** The route's path — `/dashboard`, `/links/abc123` — never a full URL. */
|
|
31
|
+
readonly route: string;
|
|
32
|
+
readonly viewport: { readonly width: number; readonly height: number };
|
|
33
|
+
/**
|
|
34
|
+
* What `prefers-color-scheme` the page sees. Emulated on the page BEFORE navigation, so a
|
|
35
|
+
* capture never depends on the box that took it; an app whose boot script honours a stored
|
|
36
|
+
* choice still wins, because the stored choice is what "explicit" means.
|
|
37
|
+
*/
|
|
38
|
+
readonly colorScheme: UiColorScheme;
|
|
39
|
+
readonly fullPage: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What a picture is worth: the file, and the verdict beside it. The verdict is the SAME shape
|
|
44
|
+
* `x shot` writes to `verdict.json` — console lines, page errors, network refusals, whether every
|
|
45
|
+
* island mounted — so a picture with a hydration error is a finding, never merely a picture.
|
|
46
|
+
* The PNG is a PATH, never inlined bytes: an agent reads the picture it wants and pays for one.
|
|
47
|
+
*/
|
|
48
|
+
export interface UiShotResult {
|
|
49
|
+
readonly ok: boolean;
|
|
50
|
+
readonly image: string;
|
|
51
|
+
readonly verdictFile: string;
|
|
52
|
+
readonly verdict: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface UiIslandInput {
|
|
56
|
+
/** The island's name as `x shot --island <name>` takes it. */
|
|
57
|
+
readonly island: string;
|
|
58
|
+
/** One declared state, or every state the island declares. */
|
|
59
|
+
readonly state?: string | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface UiIslandResult {
|
|
63
|
+
readonly ok: boolean;
|
|
64
|
+
readonly dir: string;
|
|
65
|
+
readonly verdictFile: string;
|
|
66
|
+
readonly verdict: unknown;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Every bound `ui.inspect` applies, in one place, so the handler (selectors, styles), the in-page
|
|
71
|
+
* probe (matches, text, attrs) and the wire cap (bytes) cannot drift apart. Each one exists
|
|
72
|
+
* because the alternative is unbounded: a `*` selector on a long page is thousands of matches,
|
|
73
|
+
* and a match's `textContent` on `<body>` is the whole document.
|
|
74
|
+
*/
|
|
75
|
+
export const UI_INSPECT_LIMITS = {
|
|
76
|
+
selectors: 20,
|
|
77
|
+
styles: 32,
|
|
78
|
+
matches: 25,
|
|
79
|
+
textChars: 200,
|
|
80
|
+
attrs: 20,
|
|
81
|
+
bytes: 64 * 1024,
|
|
82
|
+
} as const;
|
|
83
|
+
|
|
84
|
+
/** A computed-style property name as CSS spells it: `color`, `font-size`, never `fontSize`. */
|
|
85
|
+
export const STYLE_NAME = /^[a-z][a-z0-9-]*$/;
|
|
86
|
+
|
|
87
|
+
export interface UiInspectInput {
|
|
88
|
+
readonly route: string;
|
|
89
|
+
readonly viewport: { readonly width: number; readonly height: number };
|
|
90
|
+
readonly colorScheme: UiColorScheme;
|
|
91
|
+
/** At most `UI_INSPECT_LIMITS.selectors`; the handler trims and says so. */
|
|
92
|
+
readonly selectors: readonly string[];
|
|
93
|
+
/** Computed property names, already filtered to `STYLE_NAME` and at most `.styles` of them. */
|
|
94
|
+
readonly styles: readonly string[];
|
|
95
|
+
/** Read the browser's computed role and name per match. Off by default: one round trip each. */
|
|
96
|
+
readonly a11y: boolean;
|
|
97
|
+
readonly activeElement: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface UiInspectBox {
|
|
101
|
+
readonly x: number;
|
|
102
|
+
readonly y: number;
|
|
103
|
+
readonly width: number;
|
|
104
|
+
readonly height: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface UiInspectMatch {
|
|
108
|
+
readonly tag: string;
|
|
109
|
+
/** Whitespace-collapsed `textContent`, at most `UI_INSPECT_LIMITS.textChars`. */
|
|
110
|
+
readonly text: string;
|
|
111
|
+
readonly box: UiInspectBox;
|
|
112
|
+
readonly visible: boolean;
|
|
113
|
+
/** At most `UI_INSPECT_LIMITS.attrs` entries, in document order. */
|
|
114
|
+
readonly attrs: Readonly<Record<string, string>>;
|
|
115
|
+
readonly styles: Readonly<Record<string, string>>;
|
|
116
|
+
/** Present only when `a11y: true` was asked and the driver answered for this match. */
|
|
117
|
+
readonly a11y?: { readonly role: string; readonly name: string } | undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface UiInspectSelector {
|
|
121
|
+
readonly selector: string;
|
|
122
|
+
/** `false` when `querySelectorAll` threw: a malformed selector is a fact, not a crash. */
|
|
123
|
+
readonly valid: boolean;
|
|
124
|
+
readonly count: number;
|
|
125
|
+
/** More matched than `matches` carries — the in-page cap, or the wire cap dropping them. */
|
|
126
|
+
readonly truncated: boolean;
|
|
127
|
+
readonly matches: readonly UiInspectMatch[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface UiInspectActive {
|
|
131
|
+
readonly tag: string;
|
|
132
|
+
readonly id: string;
|
|
133
|
+
readonly role: string;
|
|
134
|
+
readonly name: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface UiInspectIslands {
|
|
138
|
+
readonly declared: number;
|
|
139
|
+
readonly booted: number;
|
|
140
|
+
readonly mounted: number;
|
|
141
|
+
readonly failed: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface UiInspectResult {
|
|
145
|
+
/** The verdict's `ok`, by `ui.shot`'s rule: no console error, no throw, no failed mount, no redirect. */
|
|
146
|
+
readonly ok: boolean;
|
|
147
|
+
readonly route: string;
|
|
148
|
+
readonly finalUrl: string;
|
|
149
|
+
readonly title: string;
|
|
150
|
+
/** `document.documentElement`'s `data-theme`, or `null` when the app sets none. */
|
|
151
|
+
readonly theme: string | null;
|
|
152
|
+
readonly activeElement: UiInspectActive | null;
|
|
153
|
+
/** The same island count `ui.island` reads; `null` when the page answered no probe. */
|
|
154
|
+
readonly islands: UiInspectIslands | null;
|
|
155
|
+
readonly consoleErrors: readonly string[];
|
|
156
|
+
readonly pageErrors: readonly string[];
|
|
157
|
+
readonly refused: number;
|
|
158
|
+
readonly selectors: readonly UiInspectSelector[];
|
|
159
|
+
readonly image: string;
|
|
160
|
+
readonly verdictFile: string;
|
|
161
|
+
/** Selectors were trimmed, or matches were dropped to fit the wire cap. */
|
|
162
|
+
readonly truncated: boolean;
|
|
163
|
+
/** Style names that failed `STYLE_NAME` or fell past the cap — named, so a typo is visible. */
|
|
164
|
+
readonly droppedStyles: readonly string[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface UiDiffInput {
|
|
168
|
+
/** Both paths relative to the app root, and both under `.x/shot/` — the host refuses the rest. */
|
|
169
|
+
readonly before: string;
|
|
170
|
+
readonly after: string;
|
|
171
|
+
/** Per-channel delta as a fraction of 255 above which a pixel counts as changed. */
|
|
172
|
+
readonly threshold: number;
|
|
173
|
+
/** Where to write the diff PNG, relative to the app root; default beside `after`. */
|
|
174
|
+
readonly out?: string | undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface UiDiffResult {
|
|
178
|
+
readonly ok: true;
|
|
179
|
+
readonly before: string;
|
|
180
|
+
readonly after: string;
|
|
181
|
+
readonly width: number;
|
|
182
|
+
readonly height: number;
|
|
183
|
+
readonly changedPixels: number;
|
|
184
|
+
/** Two decimals. */
|
|
185
|
+
readonly changedPercent: number;
|
|
186
|
+
/** The bounding box of every changed pixel, or `null` when the two captures match. */
|
|
187
|
+
readonly changedBox: UiInspectBox | null;
|
|
188
|
+
/** The written diff PNG's path. */
|
|
189
|
+
readonly diff: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The default `threshold`: a tenth of the channel range, which absorbs a one-level wobble. */
|
|
193
|
+
export const UI_DIFF_DEFAULT_THRESHOLD = 0.1;
|
|
194
|
+
|
|
195
|
+
/** The capability half these four tools need. `DevCapabilities` satisfies it structurally. */
|
|
196
|
+
export interface UiHost {
|
|
197
|
+
shotRoute(input: UiShotInput): Promise<UiShotResult>;
|
|
198
|
+
shotIsland(input: UiIslandInput): Promise<UiIslandResult>;
|
|
199
|
+
inspectRoute(input: UiInspectInput): Promise<UiInspectResult>;
|
|
200
|
+
diffShots(input: UiDiffInput): Promise<UiDiffResult>;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The scopes the `ui.*` tools split across: the browser-launching three, and the one that reads. */
|
|
204
|
+
export interface UiScopes {
|
|
205
|
+
readonly test: string;
|
|
206
|
+
readonly read: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* An explicit `width`+`height` wins over the name; one of the pair alone is not a viewport and
|
|
211
|
+
* falls back to the name (default `desktop`) rather than to a half-sized frame.
|
|
212
|
+
*/
|
|
213
|
+
export function viewportOf(args: ToolArgs): { readonly width: number; readonly height: number } {
|
|
214
|
+
const width = args['width'];
|
|
215
|
+
const height = args['height'];
|
|
216
|
+
if (typeof width === 'number' && typeof height === 'number') return { width, height };
|
|
217
|
+
const name = args['viewport'];
|
|
218
|
+
const named = typeof name === 'string' && Object.hasOwn(UI_VIEWPORTS, name) ? name : 'desktop';
|
|
219
|
+
return UI_VIEWPORTS[named as UiViewportName];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const strings = (value: unknown): readonly string[] =>
|
|
223
|
+
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
|
224
|
+
|
|
225
|
+
export interface InspectSpecRead {
|
|
226
|
+
readonly spec: Pick<UiInspectInput, 'selectors' | 'styles' | 'a11y' | 'activeElement'>;
|
|
227
|
+
/** Selectors past the cap were dropped — the answer must say `truncated: true`. */
|
|
228
|
+
readonly trimmed: boolean;
|
|
229
|
+
readonly droppedStyles: readonly string[];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The four `ui.inspect` fields, bounded HERE and not in the schema: `validate-args.ts` enforces
|
|
234
|
+
* no `maxItems`, and a bound the catalog cannot state is one the handler must apply and then
|
|
235
|
+
* confess to. Shared with `ui.interact`, whose `inspect` block is these same four fields.
|
|
236
|
+
*/
|
|
237
|
+
export function inspectSpecOf(args: ToolArgs): InspectSpecRead {
|
|
238
|
+
const wanted = strings(args['selectors']);
|
|
239
|
+
const selectors = wanted.slice(0, UI_INSPECT_LIMITS.selectors);
|
|
240
|
+
const named = strings(args['styles']);
|
|
241
|
+
const valid = named.filter((name) => STYLE_NAME.test(name));
|
|
242
|
+
const styles = valid.slice(0, UI_INSPECT_LIMITS.styles);
|
|
243
|
+
return {
|
|
244
|
+
spec: {
|
|
245
|
+
selectors,
|
|
246
|
+
styles,
|
|
247
|
+
a11y: args['a11y'] === true,
|
|
248
|
+
activeElement: args['activeElement'] !== false,
|
|
249
|
+
},
|
|
250
|
+
trimmed: wanted.length > selectors.length,
|
|
251
|
+
droppedStyles: named.filter((name) => !styles.includes(name)),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const VIEWPORT_ARGS = {
|
|
256
|
+
viewport: {
|
|
257
|
+
type: 'string',
|
|
258
|
+
enum: Object.keys(UI_VIEWPORTS),
|
|
259
|
+
default: 'desktop',
|
|
260
|
+
description: 'phone 390×844, tablet 820×1180, desktop 1440×900.',
|
|
261
|
+
},
|
|
262
|
+
width: { type: 'integer', minimum: 320, maximum: 3840 },
|
|
263
|
+
height: { type: 'integer', minimum: 320, maximum: 2160 },
|
|
264
|
+
theme: {
|
|
265
|
+
type: 'string',
|
|
266
|
+
enum: ['light', 'dark'],
|
|
267
|
+
default: 'dark',
|
|
268
|
+
description:
|
|
269
|
+
'The theme the capture is of: prefers-color-scheme is emulated AND the scheme is stored as ' +
|
|
270
|
+
"the visitor's choice (localStorage, the boot's key) before navigation, so an app whose " +
|
|
271
|
+
'theme.defaultMode differs still captures what a visitor who chose this theme sees.',
|
|
272
|
+
},
|
|
273
|
+
} as const;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The four `ui.*` tools. The scopes are the dev server's `dev:test` and `dev:read`, passed in to
|
|
277
|
+
* keep this file a leaf; `ui.diff` is the one under `read` — it opens no browser.
|
|
278
|
+
*/
|
|
279
|
+
export function uiTools(host: UiHost, scopes: UiScopes): readonly AnyMcpTool[] {
|
|
280
|
+
const scope = scopes.test;
|
|
281
|
+
return [
|
|
282
|
+
{
|
|
283
|
+
name: 'ui.shot',
|
|
284
|
+
description:
|
|
285
|
+
'Photograph one route at a named viewport (phone/tablet/desktop) or an explicit size, ' +
|
|
286
|
+
'in light or dark, against the running dev server. Returns the PNG path and the same ' +
|
|
287
|
+
'verdict x shot writes: console, page errors, refused requests, whether every island ' +
|
|
288
|
+
'mounted. Refuses a route with no declared JS budget. Launches a browser.',
|
|
289
|
+
scope,
|
|
290
|
+
destructive: true,
|
|
291
|
+
inputSchema: {
|
|
292
|
+
type: 'object',
|
|
293
|
+
properties: {
|
|
294
|
+
route: { type: 'string', description: 'Route path, e.g. /dashboard.' },
|
|
295
|
+
...VIEWPORT_ARGS,
|
|
296
|
+
fullPage: { type: 'boolean', default: true },
|
|
297
|
+
},
|
|
298
|
+
required: ['route'],
|
|
299
|
+
additionalProperties: false,
|
|
300
|
+
},
|
|
301
|
+
async handle(args: ToolArgs) {
|
|
302
|
+
const route = typeof args['route'] === 'string' ? args['route'] : '';
|
|
303
|
+
const result = await host.shotRoute({
|
|
304
|
+
route,
|
|
305
|
+
viewport: viewportOf(args),
|
|
306
|
+
colorScheme: args['theme'] === 'light' ? 'light' : 'dark',
|
|
307
|
+
fullPage: args['fullPage'] !== false,
|
|
308
|
+
});
|
|
309
|
+
return { ...jsonResult(result), ...(result.ok ? {} : { isError: true }) };
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: 'ui.island',
|
|
314
|
+
description:
|
|
315
|
+
'Photograph an island in every state its *.island.states.ts declares (or one state), ' +
|
|
316
|
+
'as x shot --island does: PNGs plus a verdict per state. Launches a browser.',
|
|
317
|
+
scope,
|
|
318
|
+
destructive: true,
|
|
319
|
+
inputSchema: {
|
|
320
|
+
type: 'object',
|
|
321
|
+
properties: {
|
|
322
|
+
island: { type: 'string', description: 'Island name, e.g. links-table.' },
|
|
323
|
+
state: { type: 'string', description: 'One declared state id; omit for all.' },
|
|
324
|
+
},
|
|
325
|
+
required: ['island'],
|
|
326
|
+
additionalProperties: false,
|
|
327
|
+
},
|
|
328
|
+
async handle(args: ToolArgs) {
|
|
329
|
+
const island = typeof args['island'] === 'string' ? args['island'] : '';
|
|
330
|
+
const state = typeof args['state'] === 'string' ? args['state'] : undefined;
|
|
331
|
+
const result = await host.shotIsland({ island, ...(state === undefined ? {} : { state }) });
|
|
332
|
+
return { ...jsonResult(result), ...(result.ok ? {} : { isError: true }) };
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
name: 'ui.inspect',
|
|
337
|
+
description:
|
|
338
|
+
'Read DOM, computed-style and accessibility facts for up to ' +
|
|
339
|
+
`${UI_INSPECT_LIMITS.selectors} selectors in ONE navigation of a route: per match the tag, ` +
|
|
340
|
+
'text, bounding box, visibility, attributes, the computed styles you name and (a11y: true) ' +
|
|
341
|
+
'the browser-computed role and name; plus the document title, data-theme, the focused ' +
|
|
342
|
+
'element, the island count, console and page errors. Takes the same PNG and verdict ' +
|
|
343
|
+
'ui.shot does; `ok` is the verdict. Refuses a route with no declared JS budget. Launches ' +
|
|
344
|
+
'a browser.',
|
|
345
|
+
scope,
|
|
346
|
+
destructive: true,
|
|
347
|
+
inputSchema: {
|
|
348
|
+
type: 'object',
|
|
349
|
+
properties: {
|
|
350
|
+
route: { type: 'string', description: 'Route path, e.g. /dashboard.' },
|
|
351
|
+
...VIEWPORT_ARGS,
|
|
352
|
+
selectors: {
|
|
353
|
+
type: 'array',
|
|
354
|
+
items: { type: 'string', minLength: 1 },
|
|
355
|
+
description: `CSS selectors, at most ${UI_INSPECT_LIMITS.selectors}; extras are dropped and truncated: true says so.`,
|
|
356
|
+
},
|
|
357
|
+
styles: {
|
|
358
|
+
type: 'array',
|
|
359
|
+
items: { type: 'string' },
|
|
360
|
+
description: `Computed property names (kebab-case), at most ${UI_INSPECT_LIMITS.styles}; invalid names land in droppedStyles.`,
|
|
361
|
+
},
|
|
362
|
+
a11y: {
|
|
363
|
+
type: 'boolean',
|
|
364
|
+
default: false,
|
|
365
|
+
description: 'Read the computed role and name per match (one round trip per selector).',
|
|
366
|
+
},
|
|
367
|
+
activeElement: { type: 'boolean', default: true },
|
|
368
|
+
},
|
|
369
|
+
required: ['route', 'selectors'],
|
|
370
|
+
additionalProperties: false,
|
|
371
|
+
},
|
|
372
|
+
async handle(args: ToolArgs) {
|
|
373
|
+
const route = typeof args['route'] === 'string' ? args['route'] : '';
|
|
374
|
+
const asked = inspectSpecOf(args);
|
|
375
|
+
const result = await host.inspectRoute({
|
|
376
|
+
route,
|
|
377
|
+
viewport: viewportOf(args),
|
|
378
|
+
colorScheme: args['theme'] === 'light' ? 'light' : 'dark',
|
|
379
|
+
...asked.spec,
|
|
380
|
+
});
|
|
381
|
+
const answer: UiInspectResult = {
|
|
382
|
+
...result,
|
|
383
|
+
truncated: result.truncated || asked.trimmed,
|
|
384
|
+
droppedStyles: asked.droppedStyles,
|
|
385
|
+
};
|
|
386
|
+
return { ...jsonResult(answer), ...(answer.ok ? {} : { isError: true }) };
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
name: 'ui.diff',
|
|
391
|
+
description:
|
|
392
|
+
'Compare two PNGs the other ui.* tools wrote (paths relative to the app root, under ' +
|
|
393
|
+
'.x/shot/ only — anything else is refused) pixel by pixel: the changed-pixel count and ' +
|
|
394
|
+
'percentage, the bounding box of the change, and the path of a diff PNG (after faded to ' +
|
|
395
|
+
'a quarter, changed pixels solid red). A pixel is changed when any channel moved by more ' +
|
|
396
|
+
'than threshold × 255; no anti-alias detection. Refuses two sizes. Launches no browser.',
|
|
397
|
+
scope: scopes.read,
|
|
398
|
+
destructive: false,
|
|
399
|
+
inputSchema: {
|
|
400
|
+
type: 'object',
|
|
401
|
+
properties: {
|
|
402
|
+
before: {
|
|
403
|
+
type: 'string',
|
|
404
|
+
description: 'Path under .x/shot/, e.g. .x/shot/dashboard/1440x900-dark/page.png.',
|
|
405
|
+
},
|
|
406
|
+
after: { type: 'string', description: 'Path under .x/shot/ of the later capture.' },
|
|
407
|
+
threshold: {
|
|
408
|
+
type: 'number',
|
|
409
|
+
minimum: 0,
|
|
410
|
+
maximum: 1,
|
|
411
|
+
default: UI_DIFF_DEFAULT_THRESHOLD,
|
|
412
|
+
description:
|
|
413
|
+
'Per-channel delta as a fraction of 255 above which a pixel counts as changed.',
|
|
414
|
+
},
|
|
415
|
+
out: {
|
|
416
|
+
type: 'string',
|
|
417
|
+
description: 'Where to write the diff PNG (under .x/shot/); default beside `after`.',
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
required: ['before', 'after'],
|
|
421
|
+
additionalProperties: false,
|
|
422
|
+
},
|
|
423
|
+
async handle(args: ToolArgs) {
|
|
424
|
+
const before = typeof args['before'] === 'string' ? args['before'] : '';
|
|
425
|
+
const after = typeof args['after'] === 'string' ? args['after'] : '';
|
|
426
|
+
const threshold =
|
|
427
|
+
typeof args['threshold'] === 'number' ? args['threshold'] : UI_DIFF_DEFAULT_THRESHOLD;
|
|
428
|
+
const out = typeof args['out'] === 'string' ? args['out'] : undefined;
|
|
429
|
+
const result = await host.diffShots({
|
|
430
|
+
before,
|
|
431
|
+
after,
|
|
432
|
+
threshold,
|
|
433
|
+
...(out === undefined ? {} : { out }),
|
|
434
|
+
});
|
|
435
|
+
return jsonResult(result);
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
];
|
|
439
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -26,15 +26,42 @@ export type {
|
|
|
26
26
|
QueueDepth,
|
|
27
27
|
TestRun,
|
|
28
28
|
UiColorScheme,
|
|
29
|
+
UiDiffInput,
|
|
30
|
+
UiDiffResult,
|
|
31
|
+
UiInspectActive,
|
|
32
|
+
UiInspectBox,
|
|
33
|
+
UiInspectInput,
|
|
34
|
+
UiInspectIslands,
|
|
35
|
+
UiInspectMatch,
|
|
36
|
+
UiInspectResult,
|
|
37
|
+
UiInspectSelector,
|
|
38
|
+
UiInspectSpec,
|
|
39
|
+
UiInteractInput,
|
|
40
|
+
UiInteractInspect,
|
|
41
|
+
UiInteractResult,
|
|
42
|
+
UiInteractStep,
|
|
43
|
+
UiInteractStepKind,
|
|
44
|
+
UiInteractStepResult,
|
|
29
45
|
UiIslandInput,
|
|
30
46
|
UiIslandResult,
|
|
47
|
+
UiScopes,
|
|
31
48
|
UiShotInput,
|
|
32
49
|
UiShotResult,
|
|
33
50
|
UiViewportName,
|
|
34
51
|
VerifyResult,
|
|
35
52
|
VerifyStep,
|
|
36
53
|
} from './dev-server';
|
|
37
|
-
export {
|
|
54
|
+
export {
|
|
55
|
+
DEV_SCOPES,
|
|
56
|
+
devTools,
|
|
57
|
+
STYLE_NAME,
|
|
58
|
+
UI_DIFF_DEFAULT_THRESHOLD,
|
|
59
|
+
UI_INSPECT_LIMITS,
|
|
60
|
+
UI_INTERACT_LIMITS,
|
|
61
|
+
UI_INTERACT_STEP_SCHEMA,
|
|
62
|
+
UI_VIEWPORTS,
|
|
63
|
+
viewportOf,
|
|
64
|
+
} from './dev-server';
|
|
38
65
|
export type { McpErrorCode } from './errors';
|
|
39
66
|
export {
|
|
40
67
|
MCP_ERROR_CODES,
|