@crouton-kit/capture 1.3.6 → 1.3.8

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.
Files changed (43) hide show
  1. package/bin/capture +3269 -1281
  2. package/package.json +4 -1
  3. package/src/capture.ts +30 -0
  4. package/src/cdp/args.ts +113 -10
  5. package/src/cdp/bridge/client.ts +49 -0
  6. package/src/cdp/bridge/protocol.ts +137 -0
  7. package/src/cdp/bridge/server.ts +241 -0
  8. package/src/cdp/bridge/spawn.ts +127 -0
  9. package/src/cdp/client.ts +7 -1
  10. package/src/cdp/commands/bridge-serve.ts +43 -0
  11. package/src/cdp/commands/cdp.ts +203 -0
  12. package/src/cdp/commands/measure/census.ts +31 -0
  13. package/src/cdp/commands/measure/check.ts +35 -0
  14. package/src/cdp/commands/measure/diff.ts +32 -0
  15. package/src/cdp/commands/measure/explain.ts +31 -0
  16. package/src/cdp/commands/measure/index.ts +114 -0
  17. package/src/cdp/commands/measure/map-focus.ts +24 -0
  18. package/src/cdp/commands/measure/map-layers.ts +25 -0
  19. package/src/cdp/commands/measure/map-scroll.ts +24 -0
  20. package/src/cdp/commands/measure/snap.ts +36 -0
  21. package/src/cdp/commands/measure/sweep.ts +30 -0
  22. package/src/cdp/commands/motion/index.ts +60 -0
  23. package/src/cdp/commands/motion/jank.ts +22 -0
  24. package/src/cdp/commands/motion/mask.ts +23 -0
  25. package/src/cdp/commands/motion/rec.ts +33 -0
  26. package/src/cdp/commands/motion/response.ts +26 -0
  27. package/src/cdp/commands/motion/timeline.ts +27 -0
  28. package/src/cdp/coordinates.ts +158 -0
  29. package/src/cdp/dispatch.ts +8 -0
  30. package/src/cdp/domains.ts +75 -0
  31. package/src/cdp/recorder-bridge.ts +521 -0
  32. package/src/cdp/source-map.ts +309 -0
  33. package/src/cdp/targets.ts +8 -1
  34. package/src/cdp/timing.ts +82 -0
  35. package/src/cdp/types.ts +71 -0
  36. package/src/cdp/usage.ts +9 -0
  37. package/src/output/artifact.ts +520 -0
  38. package/src/output/diff.ts +111 -0
  39. package/src/output/render.ts +593 -0
  40. package/src/output/selector.ts +185 -0
  41. package/src/session/artifacts.ts +229 -0
  42. package/src/session/commands.ts +183 -20
  43. package/src/session-context.ts +50 -8
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Spawns/stops the detached bridge process for a held session
3
+ * (`capture session start --hold`). Mirrors the existing detached-child +
4
+ * pid-tracking pattern used for session log tailers (`session/commands.ts`).
5
+ */
6
+
7
+ import { spawn } from 'child_process';
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+
11
+ export function bridgeSocketPath(sessionDir: string): string {
12
+ return path.join(sessionDir, 'bridge.sock');
13
+ }
14
+
15
+ /** The recorder bridge's own socket, scoped to its recording dir so it can't collide with a session's plain `--hold` bridge. */
16
+ export function recorderSocketPath(recDir: string): string {
17
+ return path.join(recDir, 'recorder.sock');
18
+ }
19
+
20
+ export async function startBridge(
21
+ sessionDir: string,
22
+ port: number,
23
+ timeoutMs = 5000,
24
+ ): Promise<{ socketPath: string; pid: number }> {
25
+ const socketPath = bridgeSocketPath(sessionDir);
26
+ // Re-invoke the currently-running capture entrypoint in a hidden mode.
27
+ // Only works against the built bin (a bundled, plain-node-runnable JS
28
+ // file) \u2014 running `npm run dev` (tsx) can't self-spawn this way.
29
+ const scriptPath = process.argv[1];
30
+ const child = spawn(
31
+ process.execPath,
32
+ [scriptPath, '__bridge-serve', '--socket', socketPath, '--port', String(port)],
33
+ { detached: true, stdio: 'ignore' },
34
+ );
35
+ child.unref();
36
+ const pid = child.pid;
37
+ if (!pid) {
38
+ throw new Error('Failed to spawn CDP bridge process.');
39
+ }
40
+
41
+ const start = Date.now();
42
+ while (!fs.existsSync(socketPath)) {
43
+ if (Date.now() - start > timeoutMs) {
44
+ throw new Error(
45
+ `CDP bridge (pid ${pid}) did not come up within ${timeoutMs}ms. ` +
46
+ `Check that a browser is reachable on port ${port} (capture detect).`,
47
+ );
48
+ }
49
+ await new Promise((r) => setTimeout(r, 50));
50
+ }
51
+
52
+ return { socketPath, pid };
53
+ }
54
+
55
+ export function stopBridge(pid: number | null | undefined, socketPath: string | null | undefined): void {
56
+ if (pid) {
57
+ try {
58
+ process.kill(pid, 'SIGTERM');
59
+ } catch {
60
+ // Already dead.
61
+ }
62
+ }
63
+ if (socketPath) {
64
+ try {
65
+ fs.unlinkSync(socketPath);
66
+ } catch {
67
+ // Already gone.
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Spawns the same detached `__bridge-serve` entrypoint in recorder mode:
74
+ * it connects to one tab (`targetId`) instead of the browser level, and
75
+ * drives `capture motion rec`'s screencast/tracing/observers instead of
76
+ * proxying arbitrary `--browser` CDP calls. Mirrors `startBridge()`'s
77
+ * spawn + "wait for the socket file to appear" pattern; the caller
78
+ * (U14's lifecycle routing) tears it down with the same `stopBridge()`
79
+ * used for the plain held bridge.
80
+ *
81
+ * Recorder mode is selected via a positional (`recorder <recDir>`) rather
82
+ * than a new flag — `capture`'s CLI arg parser (`src/cdp/args.ts`) is not
83
+ * owned by this unit, but `positional` is already generic passthrough.
84
+ */
85
+ export async function startRecorderBridge(
86
+ socketPath: string,
87
+ port: number,
88
+ targetId: string,
89
+ recDir: string,
90
+ timeoutMs = 5000,
91
+ ): Promise<{ socketPath: string; pid: number }> {
92
+ const scriptPath = process.argv[1];
93
+ const child = spawn(
94
+ process.execPath,
95
+ [
96
+ scriptPath,
97
+ '__bridge-serve',
98
+ '--socket',
99
+ socketPath,
100
+ '--port',
101
+ String(port),
102
+ '--target',
103
+ targetId,
104
+ 'recorder',
105
+ recDir,
106
+ ],
107
+ { detached: true, stdio: 'ignore' },
108
+ );
109
+ child.unref();
110
+ const pid = child.pid;
111
+ if (!pid) {
112
+ throw new Error('Failed to spawn recorder bridge process.');
113
+ }
114
+
115
+ const start = Date.now();
116
+ while (!fs.existsSync(socketPath)) {
117
+ if (Date.now() - start > timeoutMs) {
118
+ throw new Error(
119
+ `Recorder bridge (pid ${pid}) did not come up within ${timeoutMs}ms. ` +
120
+ `Check that target "${targetId}" is reachable on port ${port}.`,
121
+ );
122
+ }
123
+ await new Promise((r) => setTimeout(r, 50));
124
+ }
125
+
126
+ return { socketPath, pid };
127
+ }
package/src/cdp/client.ts CHANGED
@@ -60,13 +60,14 @@ export class CDPClient {
60
60
  method: string,
61
61
  params: Record<string, unknown> = {},
62
62
  timeout = 60000,
63
+ sessionId?: string,
63
64
  ): Promise<unknown> {
64
65
  await this.ready;
65
66
  const id = ++this.messageId;
66
67
 
67
68
  return new Promise((resolve, reject) => {
68
69
  this.pendingRequests.set(id, { resolve, reject });
69
- this.ws.send(JSON.stringify({ id, method, params }));
70
+ this.ws.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
70
71
 
71
72
  const timer = setTimeout(() => {
72
73
  if (this.pendingRequests.has(id)) {
@@ -86,6 +87,11 @@ export class CDPClient {
86
87
  this.eventHandlers.get(event)!.push(handler);
87
88
  }
88
89
 
90
+ /** Fires when the underlying websocket goes away (browser closed, crashed, CDP endpoint dropped). */
91
+ onDisconnect(handler: () => void): void {
92
+ this.ws.on('close', handler);
93
+ }
94
+
89
95
  close(): void {
90
96
  // Clear all pending timeouts to allow process to exit
91
97
  for (const timer of this.pendingTimeouts.values()) {
@@ -0,0 +1,43 @@
1
+ import { runBridgeServer } from '../bridge/server.js';
2
+ import { runRecorderBridge } from '../recorder-bridge.js';
3
+ import { type ParsedArgs } from '../types.js';
4
+
5
+ /**
6
+ * Hidden entrypoint spawned detached by `capture session start --hold`
7
+ * (see `bridge/spawn.ts`'s `startBridge()`) or by `capture motion rec
8
+ * --start` in recorder mode (`startRecorderBridge()`). Not documented in
9
+ * `capture --help` — nothing calls this directly.
10
+ *
11
+ * Recorder mode is selected by a `recorder <recDir>` positional rather
12
+ * than a new flag, since `src/cdp/args.ts`'s fixed flag set isn't owned by
13
+ * this unit but `parsed.positional` is already generic passthrough:
14
+ * capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> recorder <recDir>
15
+ */
16
+ export async function cmdBridgeServe(parsed: ParsedArgs, _args: string[]): Promise<void> {
17
+ if (!parsed.socket) {
18
+ console.error(
19
+ 'Usage: capture __bridge-serve --socket <path> [--port <cdpPort>] [--target <tabId> recorder <recDir>]',
20
+ );
21
+ process.exit(1);
22
+ }
23
+
24
+ if (parsed.positional[0] === 'recorder') {
25
+ const recDir = parsed.positional[1];
26
+ if (!parsed.target || !recDir) {
27
+ console.error(
28
+ 'Usage: capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> recorder <recDir>',
29
+ );
30
+ process.exit(1);
31
+ }
32
+ await runRecorderBridge({ socketPath: parsed.socket, port: parsed.port, targetId: parsed.target, recDir });
33
+ // Deliberately does not exit: the open Unix socket server and the live
34
+ // tab websocket keep this detached process alive until the caller sends
35
+ // it SIGTERM (same teardown as the plain held bridge).
36
+ return;
37
+ }
38
+
39
+ await runBridgeServer(parsed.socket, parsed.port);
40
+ // Deliberately does not exit: the open Unix socket server and the live
41
+ // browser websocket keep this detached process alive until `session stop`
42
+ // sends it SIGTERM.
43
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Raw CDP passthrough \u2014 the escape hatch for domains no other capture
3
+ * command wraps (`Browser.grantPermissions`, `ServiceWorker.*`, etc.).
4
+ *
5
+ * `--browser` routes through the HELD connection (see `session start
6
+ * --hold` / `src/cdp/bridge/`) instead of a fresh page/tab websocket.
7
+ * Browser-scoped state (permission grants) and target-scoped state that was
8
+ * enabled on that same connection (`ServiceWorker.enable`, ...) is
9
+ * per-CLIENT and reverts the instant its websocket disconnects, so a plain
10
+ * one-shot call here only lasts for that single command \u2014 running inside a
11
+ * held session is what keeps it alive across commands.
12
+ */
13
+
14
+ import * as fs from 'fs';
15
+ import { getActiveSession } from '../../session-context.js';
16
+ import { connectForCommand } from '../connection.js';
17
+ import { getBrowserClient, findTabById } from '../targets.js';
18
+ import { detectCdpPort } from '../detect.js';
19
+ import { sendBridgeRequest } from '../bridge/client.js';
20
+ import { type CDPClient } from '../client.js';
21
+ import { type ParsedArgs } from '../types.js';
22
+
23
+ const DEFAULT_TIMEOUT_MS = 10000;
24
+
25
+ function printHelp(): void {
26
+ console.log(
27
+ `Usage: capture cdp <Domain.method> [--params '<json>'] [--browser] [--target <id>] [--wait-event <Domain.event>] [--timeout <ms>]
28
+
29
+ Send a raw CDP command \u2014 the escape hatch for domains no other capture
30
+ command wraps (Browser.grantPermissions, ServiceWorker.*, Target.*, ...).
31
+
32
+ --browser Route through the held connection (session --hold)
33
+ instead of a fresh page/tab websocket. Required for
34
+ state that must survive across separate commands.
35
+ --target <id> With --browser: attach to this target (a flattened CDP
36
+ session on the SAME held connection) for target-scoped
37
+ domains, e.g. ServiceWorker.enable / .deliverPushMessage.
38
+ Without --browser: the page target to run against, as
39
+ with every other capture command.
40
+ --params '<json>' JSON-encoded params object for the method.
41
+ --wait-event <name> Wait for (and return) the next occurrence of a CDP
42
+ event, e.g. ServiceWorker.workerRegistrationUpdated.
43
+ Can be combined with a method (sent first) or alone.
44
+ --timeout <ms> Wait timeout (default: ${DEFAULT_TIMEOUT_MS}ms).
45
+
46
+ Browser-level and target-enablement state reverts the instant its connection
47
+ disconnects \u2014 a one-shot call here only lasts for that single command. Run
48
+ inside a held session to keep it alive across commands:
49
+
50
+ capture session start --url http://localhost:3000 --hold
51
+ capture cdp Browser.grantPermissions --browser \\
52
+ --params '{"origin":"http://localhost:3000","permissions":["notifications"]}'
53
+ capture cdp ServiceWorker.enable --browser --target <pageTabId>
54
+ capture cdp --browser --target <pageTabId> --wait-event ServiceWorker.workerRegistrationUpdated
55
+ capture cdp ServiceWorker.deliverPushMessage --browser --target <pageTabId> \\
56
+ --params '{"origin":"...","registrationId":"...","data":"..."}'
57
+ capture session stop <session-id>
58
+
59
+ Without --browser, the method runs against the current page target (session
60
+ context or --target), like any other capture command \u2014 e.g. DOM.* or
61
+ Accessibility.* calls beyond what \`capture a11y\` exposes.`,
62
+ );
63
+ }
64
+
65
+ export async function cmdCdp(parsed: ParsedArgs, _args: string[]): Promise<void> {
66
+ if (parsed.help) {
67
+ printHelp();
68
+ process.exit(0);
69
+ }
70
+
71
+ const method = parsed.positional[0];
72
+ if (!method && !parsed.waitEvent) {
73
+ console.error('ERROR: Provide a CDP method (e.g. Browser.grantPermissions) or --wait-event.\n');
74
+ printHelp();
75
+ process.exit(1);
76
+ }
77
+
78
+ let params: Record<string, unknown> | undefined;
79
+ if (parsed.params) {
80
+ try {
81
+ params = JSON.parse(parsed.params);
82
+ } catch (err) {
83
+ console.error(`ERROR: --params is not valid JSON: ${err instanceof Error ? err.message : err}`);
84
+ process.exit(1);
85
+ }
86
+ }
87
+
88
+ const timeoutMs = parsed.timeoutMs ?? DEFAULT_TIMEOUT_MS;
89
+
90
+ if (parsed.browser) {
91
+ await runBrowserScope(method, params, parsed, timeoutMs);
92
+ return;
93
+ }
94
+
95
+ await runPageScope(method, params, parsed, timeoutMs);
96
+ }
97
+
98
+ async function runBrowserScope(
99
+ method: string | undefined,
100
+ params: Record<string, unknown> | undefined,
101
+ parsed: ParsedArgs,
102
+ timeoutMs: number,
103
+ ): Promise<void> {
104
+ const session = getActiveSession();
105
+ if (session?.bridgeSocket && fs.existsSync(session.bridgeSocket)) {
106
+ const resp = await sendBridgeRequest(session.bridgeSocket, {
107
+ method,
108
+ params,
109
+ targetId: parsed.target,
110
+ waitEvent: parsed.waitEvent,
111
+ timeoutMs,
112
+ });
113
+ if (!resp.ok) {
114
+ console.error(`ERROR: ${resp.error}`);
115
+ if (!parsed.target) {
116
+ console.error(
117
+ '\nMany CDP domains (Storage.*, Page.*, DOM.*, Emulation.*, ...) are scoped to a specific ' +
118
+ 'tab, not the browser connection as a whole \u2014 sent bare like this, Chrome often rejects them ' +
119
+ 'with just "Internal error" and no further detail. Pass --target <tabId> to attach to the tab ' +
120
+ 'the call should apply to (run "capture list" to find one).',
121
+ );
122
+ }
123
+ process.exit(1);
124
+ }
125
+ console.log(JSON.stringify({ result: resp.result, event: resp.event }, null, 2));
126
+ return;
127
+ }
128
+
129
+ console.error(
130
+ 'No held session bridge active \u2014 this connection closes when the command exits, ' +
131
+ 'so any browser-level grant or target enablement made here reverts immediately after.\n' +
132
+ 'For state that must survive multiple commands: capture session start --hold\n',
133
+ );
134
+
135
+ const port = parsed.port ?? (await detectCdpPort());
136
+ const { client } = await getBrowserClient(port);
137
+ try {
138
+ let sessionId: string | undefined;
139
+ if (parsed.target) {
140
+ // Accept the same 8-char-prefix targeting every other capture command
141
+ // promises (see the top-level --help TARGETING section) instead of
142
+ // requiring the full 32-char target id here.
143
+ const tab = await findTabById(port, parsed.target);
144
+ if (!tab) {
145
+ console.error(`ERROR: No target found for "${parsed.target}" on port ${port}. Run "capture list" to see available tabs.`);
146
+ process.exit(1);
147
+ }
148
+ const attached = (await client.send('Target.attachToTarget', {
149
+ targetId: tab.id,
150
+ flatten: true,
151
+ })) as { sessionId: string };
152
+ sessionId = attached.sessionId;
153
+ }
154
+ const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : undefined;
155
+ const result = method ? await client.send(method, params ?? {}, 60000, sessionId) : undefined;
156
+ const event = eventPromise ? await eventPromise : undefined;
157
+ console.log(JSON.stringify({ result, event }, null, 2));
158
+ } catch (err) {
159
+ const message = err instanceof Error ? err.message : String(err);
160
+ console.error(`ERROR: ${message}`);
161
+ if (!parsed.target) {
162
+ console.error(
163
+ '\nMany CDP domains (Storage.*, Page.*, DOM.*, Emulation.*, ...) are scoped to a specific ' +
164
+ 'tab, not the browser connection as a whole \u2014 sent bare like this, Chrome often rejects them ' +
165
+ 'with just "Internal error" and no further detail. Pass --target <tabId> to attach to the tab ' +
166
+ `the call should apply to (run "capture list" to find one${method ? `, e.g. capture cdp ${method} --browser --target <tabId> ...` : ''}).`,
167
+ );
168
+ }
169
+ process.exit(1);
170
+ } finally {
171
+ client.close();
172
+ }
173
+ }
174
+
175
+ async function runPageScope(
176
+ method: string | undefined,
177
+ params: Record<string, unknown> | undefined,
178
+ parsed: ParsedArgs,
179
+ timeoutMs: number,
180
+ ): Promise<void> {
181
+ const { client } = await connectForCommand(parsed);
182
+ try {
183
+ const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : undefined;
184
+ const result = method ? await client.send(method, params ?? {}) : undefined;
185
+ const event = eventPromise ? await eventPromise : undefined;
186
+ console.log(JSON.stringify({ result, event }, null, 2));
187
+ } finally {
188
+ client.close();
189
+ }
190
+ }
191
+
192
+ function waitForEventOnce(client: CDPClient, eventName: string, timeoutMs: number): Promise<unknown> {
193
+ return new Promise((resolve, reject) => {
194
+ const timer = setTimeout(
195
+ () => reject(new Error(`Timed out after ${timeoutMs}ms waiting for event "${eventName}"`)),
196
+ timeoutMs,
197
+ );
198
+ client.on(eventName, (params) => {
199
+ clearTimeout(timer);
200
+ resolve(params);
201
+ });
202
+ });
203
+ }
@@ -0,0 +1,31 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { emitResult, fact, type RenderableResult } from '../../../output/render.js';
3
+
4
+ const USAGE = `Usage: capture measure census [--snap <id>]... [--url <url>]... [--set-file <path>] --axis <axis>
5
+
6
+ Value distributions and token audits across one or more snapshots. Each
7
+ --snap/--url is repeatable to build a cross-page distribution; a --url
8
+ target creates a one-shot snap first.
9
+
10
+ Options:
11
+ --snap <id> Existing snapshot id/path (repeatable)
12
+ --url <url> URL to snap first (repeatable)
13
+ --set-file <path> File listing additional snap/url targets, one per line
14
+ --viewport <WxH> Viewport to snap at (repeatable)
15
+ --axis <axis> color|font|spacing|radius|shadow|animation|geometry|media|queries
16
+
17
+ Not yet implemented.`;
18
+
19
+ export async function cmdMeasureCensus(parsed: ParsedArgs, _args: string[]): Promise<void> {
20
+ if (parsed.help) {
21
+ console.log(USAGE);
22
+ process.exit(0);
23
+ }
24
+ const result: RenderableResult = {
25
+ tag: 'error',
26
+ attrs: { command: 'measure census', status: 'not_implemented' },
27
+ summary: fact`\`measure census\` is not implemented yet.`,
28
+ };
29
+ emitResult(result, { json: parsed.json });
30
+ process.exit(1);
31
+ }
@@ -0,0 +1,35 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { emitResult, fact, type RenderableResult } from '../../../output/render.js';
3
+
4
+ const USAGE = `Usage: capture measure check [url|snap] [--for <checks>] [--viewport <WxH>]... [--gate]
5
+
6
+ Threshold/fact checks over a settled snapshot's substrate: overlap,
7
+ offscreen, overflow, tap-target size, contrast, hit-test receiver,
8
+ truncation, form visibility, media/replaced anomalies, and animation
9
+ facts. A URL target creates a one-shot snap first.
10
+
11
+ Options:
12
+ --for <checks> Comma-separated check names (e.g.
13
+ overlap,offscreen,overflow,tap-targets,contrast,
14
+ hit-test,truncation) or a category
15
+ (geometry|content|targetability|forms|animation|all).
16
+ Omit for every check.
17
+ --viewport <WxH> Viewport to check at (repeatable, for a discrete
18
+ multi-viewport sweep)
19
+ --gate Exit 2 if any check finds something (default: exit 0)
20
+
21
+ Not yet implemented.`;
22
+
23
+ export async function cmdMeasureCheck(parsed: ParsedArgs, _args: string[]): Promise<void> {
24
+ if (parsed.help) {
25
+ console.log(USAGE);
26
+ process.exit(0);
27
+ }
28
+ const result: RenderableResult = {
29
+ tag: 'error',
30
+ attrs: { command: 'measure check', status: 'not_implemented' },
31
+ summary: fact`\`measure check\` is not implemented yet.`,
32
+ };
33
+ emitResult(result, { json: parsed.json });
34
+ process.exit(1);
35
+ }
@@ -0,0 +1,32 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { emitResult, fact, type RenderableResult } from '../../../output/render.js';
3
+
4
+ const USAGE = `Usage: capture measure diff --before <snap> --after <snap> [--pixels] [--full] [--gate]
5
+
6
+ Structured before/after diff over two snapshots: style/geometry/text/form/
7
+ media deltas, cascade provenance, and reflow attribution. No positional
8
+ target — both snapshots must already exist.
9
+
10
+ Options:
11
+ --before <snap> Earlier snapshot id/path (required)
12
+ --after <snap> Later snapshot id/path (required)
13
+ --pixels Also report raster regions not explained by geometry
14
+ --full Expand to state-matrix deltas and complete per-element
15
+ change records
16
+ --gate Exit 2 if any element changed (default: exit 0)
17
+
18
+ Not yet implemented.`;
19
+
20
+ export async function cmdMeasureDiff(parsed: ParsedArgs, _args: string[]): Promise<void> {
21
+ if (parsed.help) {
22
+ console.log(USAGE);
23
+ process.exit(0);
24
+ }
25
+ const result: RenderableResult = {
26
+ tag: 'error',
27
+ attrs: { command: 'measure diff', status: 'not_implemented' },
28
+ summary: fact`\`measure diff\` is not implemented yet.`,
29
+ };
30
+ emitResult(result, { json: parsed.json });
31
+ process.exit(1);
32
+ }
@@ -0,0 +1,31 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { emitResult, fact, type RenderableResult } from '../../../output/render.js';
3
+
4
+ const USAGE = `Usage: capture measure explain <snap> --selector <sel> [--size] [--text] [--form]
5
+
6
+ Per-element cascade/stacking/clipping explanation over one snapshot, plus
7
+ optional detail sections.
8
+
9
+ Options:
10
+ --selector <sel> Element selector (required)
11
+ --size Include size/layout provenance (flex/grid/intrinsic)
12
+ --text Include text/line-box/wrap detail
13
+ --form Include form/caret/selection/autofill detail (redacted)
14
+
15
+ With no detail flag, returns the standard compact element explanation.
16
+
17
+ Not yet implemented.`;
18
+
19
+ export async function cmdMeasureExplain(parsed: ParsedArgs, _args: string[]): Promise<void> {
20
+ if (parsed.help) {
21
+ console.log(USAGE);
22
+ process.exit(0);
23
+ }
24
+ const result: RenderableResult = {
25
+ tag: 'error',
26
+ attrs: { command: 'measure explain', status: 'not_implemented' },
27
+ summary: fact`\`measure explain\` is not implemented yet.`,
28
+ };
29
+ emitResult(result, { json: parsed.json });
30
+ process.exit(1);
31
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `capture measure` branch router.
3
+ *
4
+ * Shifts the consumed leaf token(s) out of `parsed.positional` before
5
+ * handing off, so every leaf command still finds its own primary target
6
+ * (a URL, a snap id, ...) at `positional[0]` exactly as every other capture
7
+ * command does — leaf commands never need to know they're nested under a
8
+ * branch.
9
+ */
10
+ import { type ParsedArgs } from '../../types.js';
11
+ import { cmdMeasureSnap } from './snap.js';
12
+ import { cmdMeasureCheck } from './check.js';
13
+ import { cmdMeasureDiff } from './diff.js';
14
+ import { cmdMeasureCensus } from './census.js';
15
+ import { cmdMeasureExplain } from './explain.js';
16
+ import { cmdMeasureSweep } from './sweep.js';
17
+ import { cmdMeasureMapFocus } from './map-focus.js';
18
+ import { cmdMeasureMapScroll } from './map-scroll.js';
19
+ import { cmdMeasureMapLayers } from './map-layers.js';
20
+
21
+ export const MEASURE_USAGE = `capture measure — enriched snapshot substrate + read-only queries over it.
22
+
23
+ \`snap\` drives the page (or a base snapshot) and writes one settled artifact
24
+ directory; every other leaf below is a cheap read over that artifact and
25
+ never re-drives the browser unless it explicitly accepts a URL target.
26
+
27
+ Leaves:
28
+ snap [url|snap] Drive + write a settled snapshot substrate
29
+ [--freeze-animations] [--settle-timeout <ms>] [--capture-unsettled]
30
+ [--pixels] [--state <state[:selector]>]...
31
+
32
+ check [url|snap] [--for <checks>] [--viewport <WxH>]... [--gate]
33
+ Threshold/fact checks over a snapshot
34
+
35
+ diff --before <snap> --after <snap> [--pixels] [--full] [--gate]
36
+ Structured before/after snapshot diff
37
+
38
+ census [--snap <id>]... [--url <url>]... [--set-file <path>] --axis <axis>
39
+ Value distributions across one or more snapshots
40
+ (--snap/--url repeatable)
41
+
42
+ explain <snap> --selector <sel> [--size] [--text] [--form]
43
+ Per-element cascade/stacking/clipping/size/text/form explanation
44
+
45
+ sweep [url] --axis <axis> [--from <val>] [--to <val>] [--viewport-height <val>]
46
+ Responsive/environment sampling
47
+
48
+ map focus|scroll|layers [url|snap] Read one facet of a snapshot's substrate
49
+ (see \`capture measure map --help\`)
50
+
51
+ Every leaf defaults to rendered prose; --json mirrors the same result as JSON.
52
+ Exit codes: 0 by default — findings are a report, not a failure.
53
+ --gate exits 2 on findings/changes; only \`check\` and \`diff\` accept it.
54
+
55
+ capture measure <leaf> --help Per-leaf usage`;
56
+
57
+ export const MEASURE_MAP_USAGE = `capture measure map — read one facet of a snapshot's substrate (no browser re-drive).
58
+
59
+ Leaves:
60
+ focus [url|snap] Keyboard traversal order (focus.json)
61
+ scroll [url|snap] Scroll-container topology (scroll.json)
62
+ layers [url|snap] Paint/compositor layer map (layers.json)
63
+
64
+ A URL target first creates a snap; a snap target reads its existing artifact.
65
+
66
+ capture measure map <leaf> --help Per-leaf usage`;
67
+
68
+ export async function measureMain(parsed: ParsedArgs, args: string[]): Promise<void> {
69
+ const leaf = parsed.positional[0];
70
+ const rest: ParsedArgs = { ...parsed, positional: parsed.positional.slice(1) };
71
+
72
+ switch (leaf) {
73
+ case 'snap':
74
+ return cmdMeasureSnap(rest, args);
75
+ case 'check':
76
+ return cmdMeasureCheck(rest, args);
77
+ case 'diff':
78
+ return cmdMeasureDiff(rest, args);
79
+ case 'census':
80
+ return cmdMeasureCensus(rest, args);
81
+ case 'explain':
82
+ return cmdMeasureExplain(rest, args);
83
+ case 'sweep':
84
+ return cmdMeasureSweep(rest, args);
85
+ case 'map':
86
+ return measureMapMain(rest, args);
87
+ case undefined:
88
+ console.log(MEASURE_USAGE);
89
+ return;
90
+ default:
91
+ console.error(`Unknown measure leaf: ${leaf}\n\n${MEASURE_USAGE}`);
92
+ process.exit(1);
93
+ }
94
+ }
95
+
96
+ async function measureMapMain(parsed: ParsedArgs, args: string[]): Promise<void> {
97
+ const sub = parsed.positional[0];
98
+ const rest: ParsedArgs = { ...parsed, positional: parsed.positional.slice(1) };
99
+
100
+ switch (sub) {
101
+ case 'focus':
102
+ return cmdMeasureMapFocus(rest, args);
103
+ case 'scroll':
104
+ return cmdMeasureMapScroll(rest, args);
105
+ case 'layers':
106
+ return cmdMeasureMapLayers(rest, args);
107
+ case undefined:
108
+ console.log(MEASURE_MAP_USAGE);
109
+ return;
110
+ default:
111
+ console.error(`Unknown measure map leaf: ${sub}\n\n${MEASURE_MAP_USAGE}`);
112
+ process.exit(1);
113
+ }
114
+ }
@@ -0,0 +1,24 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { emitResult, fact, type RenderableResult } from '../../../output/render.js';
3
+
4
+ const USAGE = `Usage: capture measure map focus [url|snap]
5
+
6
+ Keyboard traversal order recorded in a snapshot's focus.json: forward and
7
+ reverse Tab sequence, rects, scroll jumps, focus-visible facts, and
8
+ unreached focusable elements. A URL target creates a snap first.
9
+
10
+ Not yet implemented.`;
11
+
12
+ export async function cmdMeasureMapFocus(parsed: ParsedArgs, _args: string[]): Promise<void> {
13
+ if (parsed.help) {
14
+ console.log(USAGE);
15
+ process.exit(0);
16
+ }
17
+ const result: RenderableResult = {
18
+ tag: 'error',
19
+ attrs: { command: 'measure map focus', status: 'not_implemented' },
20
+ summary: fact`\`measure map focus\` is not implemented yet.`,
21
+ };
22
+ emitResult(result, { json: parsed.json });
23
+ process.exit(1);
24
+ }