@ultimat3/cli 20.2.1 → 22.0.0

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 (237) hide show
  1. package/CLAUDE.md +84 -1582
  2. package/README.md +40 -2
  3. package/package.json +31 -31
  4. package/src/api-registration.ts +124 -0
  5. package/src/app-artifacts.ts +39 -0
  6. package/src/app-boundaries.ts +5 -10
  7. package/src/app-env.ts +2 -2
  8. package/src/app-load.ts +37 -5
  9. package/src/app-openapi.ts +27 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-root.ts +1 -1
  12. package/src/async-pages.ts +32 -0
  13. package/src/bin.ts +7 -1
  14. package/src/boundary-findings.ts +36 -0
  15. package/src/browser-launcher-fake-html.ts +80 -0
  16. package/src/browser-launcher-fake.ts +165 -0
  17. package/src/browser-launcher-port.ts +159 -0
  18. package/src/browser-launcher.ts +26 -133
  19. package/src/budgets.ts +56 -13
  20. package/src/build-errors.ts +54 -0
  21. package/src/cdp-shot-a11y.ts +77 -0
  22. package/src/cdp-shot-clock.ts +14 -0
  23. package/src/cdp-shot-driver.ts +150 -0
  24. package/src/cdp-shot-element.ts +147 -0
  25. package/src/cdp-shot-errors.ts +62 -0
  26. package/src/cdp-shot-keys.ts +152 -0
  27. package/src/cdp-shot-page.ts +230 -0
  28. package/src/cdp-shot-watch.ts +241 -0
  29. package/src/cmd-affected-spec.ts +24 -0
  30. package/src/cmd-affected.ts +3 -19
  31. package/src/cmd-build-spec.ts +16 -0
  32. package/src/cmd-build.ts +46 -20
  33. package/src/cmd-ci-spec.ts +24 -0
  34. package/src/cmd-ci.ts +17 -21
  35. package/src/cmd-db-branch.ts +2 -2
  36. package/src/cmd-db-spec.ts +99 -0
  37. package/src/cmd-db.ts +15 -94
  38. package/src/cmd-deploy-helm.ts +136 -0
  39. package/src/cmd-deploy-spec.ts +40 -0
  40. package/src/cmd-deploy.ts +117 -42
  41. package/src/cmd-dev-spec.ts +25 -0
  42. package/src/cmd-dev.ts +78 -122
  43. package/src/cmd-docs-spec.ts +16 -0
  44. package/src/cmd-docs.ts +2 -11
  45. package/src/cmd-doctor-spec.ts +21 -0
  46. package/src/cmd-doctor.ts +4 -18
  47. package/src/cmd-env-spec.ts +18 -0
  48. package/src/cmd-env.ts +4 -13
  49. package/src/cmd-errors-spec.ts +23 -0
  50. package/src/cmd-errors.ts +3 -17
  51. package/src/cmd-fix-spec.ts +18 -0
  52. package/src/cmd-fix.ts +4 -13
  53. package/src/cmd-generate-spec.ts +31 -0
  54. package/src/cmd-generate.ts +46 -42
  55. package/src/cmd-i18n-spec.ts +16 -0
  56. package/src/cmd-i18n.ts +7 -14
  57. package/src/cmd-jobs-spec.ts +56 -0
  58. package/src/cmd-jobs.ts +3 -50
  59. package/src/cmd-manifest-spec.ts +15 -0
  60. package/src/cmd-manifest.ts +19 -20
  61. package/src/cmd-mcp-spec.ts +21 -0
  62. package/src/cmd-mcp.ts +2 -15
  63. package/src/cmd-new-spec.ts +33 -0
  64. package/src/cmd-new.ts +38 -33
  65. package/src/cmd-policy-spec.ts +13 -0
  66. package/src/cmd-policy.ts +2 -9
  67. package/src/cmd-pr-spec.ts +39 -0
  68. package/src/cmd-pr.ts +5 -65
  69. package/src/cmd-registries-spec.ts +32 -0
  70. package/src/cmd-registries.ts +4 -25
  71. package/src/cmd-routes-spec.ts +12 -0
  72. package/src/cmd-routes.ts +2 -7
  73. package/src/cmd-secrets-spec.ts +19 -0
  74. package/src/cmd-secrets.ts +22 -34
  75. package/src/cmd-shot-island.ts +12 -13
  76. package/src/cmd-shot-spec.ts +53 -0
  77. package/src/cmd-shot.ts +11 -60
  78. package/src/cmd-tasks-spec.ts +21 -0
  79. package/src/cmd-tasks.ts +2 -16
  80. package/src/cmd-test-spec.ts +54 -0
  81. package/src/cmd-test.ts +4 -49
  82. package/src/cmd-verify-spec.ts +28 -0
  83. package/src/cmd-verify.ts +3 -23
  84. package/src/db-seed.ts +4 -2
  85. package/src/dev-dashboard.ts +4 -4
  86. package/src/dev-lock.ts +18 -2
  87. package/src/dev-port.ts +21 -0
  88. package/src/dev-route-table.ts +119 -0
  89. package/src/dispatch.ts +12 -5
  90. package/src/document-styles.ts +1 -1
  91. package/src/drift.ts +4 -18
  92. package/src/error-catalog.ts +16 -9
  93. package/src/error-codes.ts +33 -28
  94. package/src/error-pages.ts +19 -7
  95. package/src/error-unthrown.ts +130 -0
  96. package/src/errors.ts +22 -29
  97. package/src/favicon.ts +2 -2
  98. package/src/fix-imports.ts +1 -1
  99. package/src/fix-scan.ts +2 -9
  100. package/src/flag-reads.ts +1 -2
  101. package/src/foreign-text.ts +36 -0
  102. package/src/framework-schema.ts +5 -6
  103. package/src/generate-feature.ts +42 -0
  104. package/src/generate-files.ts +7 -3
  105. package/src/generate-grants.ts +83 -0
  106. package/src/generate-kinds.ts +56 -4
  107. package/src/i18n-index.ts +59 -10
  108. package/src/icon-assets.ts +1 -1
  109. package/src/image-prepare.ts +14 -0
  110. package/src/import-scan.ts +63 -0
  111. package/src/index.ts +67 -334
  112. package/src/invocation-flags.ts +26 -0
  113. package/src/island-bundle.ts +38 -11
  114. package/src/island-capture.ts +6 -6
  115. package/src/island-harness-script.ts +6 -2
  116. package/src/island-realtime.ts +98 -0
  117. package/src/island-shot.ts +5 -0
  118. package/src/island-store.ts +131 -0
  119. package/src/island-verdict.ts +2 -2
  120. package/src/job-registration.ts +42 -0
  121. package/src/jobs-driver.ts +2 -2
  122. package/src/live-routes.ts +82 -42
  123. package/src/load-findings.ts +51 -0
  124. package/src/mcp-db-target.ts +1 -1
  125. package/src/mcp-errors.ts +30 -26
  126. package/src/mcp-host.ts +27 -12
  127. package/src/mcp-ui-diff.ts +27 -0
  128. package/src/mcp-ui-inspect.ts +4 -4
  129. package/src/mcp-ui-interact.ts +21 -15
  130. package/src/mcp-ui.ts +19 -15
  131. package/src/measure-database.ts +73 -0
  132. package/src/measure-paths.ts +74 -0
  133. package/src/measure-scope.ts +74 -0
  134. package/src/messages.ts +1 -3
  135. package/src/metrics-endpoint.ts +1 -1
  136. package/src/otlp-export.ts +1 -1
  137. package/src/output.ts +6 -0
  138. package/src/page-sync.ts +54 -0
  139. package/src/permission-grants.ts +86 -0
  140. package/src/prerender-out.ts +25 -0
  141. package/src/prerender.ts +121 -102
  142. package/src/pwa-artifacts.ts +3 -3
  143. package/src/realtime-browser-probe-fixture.ts +2 -2
  144. package/src/reexport-manifest.ts +2 -1
  145. package/src/registry.ts +80 -56
  146. package/src/role-realtime.ts +36 -0
  147. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  148. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  149. package/src/role-start-types.ts +112 -0
  150. package/src/{dev-roles.ts → role-start.ts} +42 -115
  151. package/src/{dev-sync.ts → role-sync.ts} +23 -11
  152. package/src/root-env.ts +67 -0
  153. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  154. package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
  155. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  156. package/src/runtime-jobs.ts +87 -0
  157. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
  158. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  159. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  160. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  161. package/src/runtime-realtime.ts +55 -0
  162. package/src/{dev-render.ts → runtime-render.ts} +119 -20
  163. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  164. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  165. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  166. package/src/scaffold-fixture.ts +28 -6
  167. package/src/scaffold-typecheck.ts +6 -3
  168. package/src/schema-drift.ts +7 -1
  169. package/src/script-csp.ts +5 -2
  170. package/src/secrets-rotation.ts +59 -0
  171. package/src/serve-boot.ts +192 -0
  172. package/src/serve-drain.ts +24 -0
  173. package/src/serve-entry.ts +6 -0
  174. package/src/serve-env.ts +116 -0
  175. package/src/serve-types.ts +55 -0
  176. package/src/serve.ts +44 -338
  177. package/src/shot-server.ts +2 -2
  178. package/src/shot-settle.ts +10 -1
  179. package/src/shot-theme.ts +3 -3
  180. package/src/shot-verdict.ts +16 -7
  181. package/src/signal-shred.ts +27 -0
  182. package/src/solid-loader.ts +26 -2
  183. package/src/static-report.ts +8 -1
  184. package/src/sw-artifacts.ts +13 -3
  185. package/src/sync-url.ts +31 -0
  186. package/src/templates/action.ts +30 -16
  187. package/src/templates/entity.ts +12 -7
  188. package/src/templates/index.ts +1 -1
  189. package/src/templates/job.ts +10 -7
  190. package/src/templates/policy.ts +20 -2
  191. package/src/templates/resource-create.ts +127 -0
  192. package/src/templates/resource-form-island.ts +76 -30
  193. package/src/templates/resource.ts +10 -4
  194. package/src/templates/route.ts +3 -0
  195. package/src/templates/scaffold-app.ts +4 -1
  196. package/src/templates/scaffold-auth.ts +3 -1
  197. package/src/templates/scaffold-container-compose.ts +184 -0
  198. package/src/templates/scaffold-container.ts +25 -145
  199. package/src/templates/scaffold-dashboard-example.ts +2 -2
  200. package/src/templates/scaffold-db-package.ts +16 -3
  201. package/src/templates/scaffold-demo-org.ts +41 -0
  202. package/src/templates/scaffold-entries.ts +1 -1
  203. package/src/templates/scaffold-env.ts +6 -0
  204. package/src/templates/scaffold-helm-templates.ts +66 -7
  205. package/src/templates/scaffold-helm.ts +27 -5
  206. package/src/templates/scaffold-i18n.ts +15 -10
  207. package/src/templates/scaffold-repo.ts +13 -10
  208. package/src/templates/scaffold-roles.ts +38 -10
  209. package/src/templates/slice-foundation.ts +1 -1
  210. package/src/templates/wrap.ts +4 -1
  211. package/src/test-passes.ts +2 -1
  212. package/src/test-workers.ts +26 -0
  213. package/src/ts-scan.ts +3 -6
  214. package/src/tsconfig-references.ts +1 -2
  215. package/src/verify-checks.ts +41 -39
  216. package/src/verify-e2e.ts +41 -0
  217. package/src/verify-run.ts +115 -50
  218. package/src/verify-step.ts +3 -3
  219. package/src/verify-tests.ts +22 -30
  220. package/src/verify-typecheck.ts +28 -0
  221. package/src/web-binding.ts +2 -2
  222. package/src/worker-bundle.ts +192 -0
  223. package/src/workspace-graph.ts +10 -33
  224. package/src/cdp-browser.ts +0 -100
  225. package/src/cdp-connection.ts +0 -211
  226. package/src/cdp-e2e-page.ts +0 -209
  227. package/src/cdp-errors.ts +0 -56
  228. package/src/cdp-launch.ts +0 -139
  229. package/src/e2e-dom-fixture.ts +0 -117
  230. package/src/e2e-driver.ts +0 -97
  231. package/src/e2e-errors.ts +0 -103
  232. package/src/e2e-evaluate.ts +0 -156
  233. package/src/e2e-locator.ts +0 -86
  234. package/src/e2e-page.ts +0 -150
  235. package/src/e2e-selection.ts +0 -182
  236. package/src/measurement-actor.ts +0 -26
  237. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
@@ -0,0 +1,152 @@
1
+ // A key chord — `'Meta+K'`, `'Shift+Tab'`, `'Escape'` — parsed once and turned into the
2
+ // `Input.dispatchKeyEvent` frames a real keyboard would send. Parsed BEFORE any key goes down: a
3
+ // refusal halfway through would leave a modifier held for every later verb on the page.
4
+ import { ShotKeyInvalidError } from './cdp-shot-errors';
5
+
6
+ /** The four modifiers, in the browser's own names. */
7
+ type Modifier = 'Alt' | 'Control' | 'Meta' | 'Shift';
8
+
9
+ /** CDP's bit for each modifier. A `Map`, so a chord word like `constructor` is simply absent. */
10
+ const MODIFIER_BITS: ReadonlyMap<string, number> = new Map([
11
+ ['Alt', 1],
12
+ ['Control', 2],
13
+ ['Meta', 4],
14
+ ['Shift', 8],
15
+ ]);
16
+
17
+ const isModifier = (word: string): word is Modifier => MODIFIER_BITS.has(word);
18
+
19
+ const bitOf = (modifier: Modifier): number => MODIFIER_BITS.get(modifier) ?? 0;
20
+
21
+ /** The modifiers that make a key a SHORTCUT rather than a character. */
22
+ const SHORTCUT_BITS = bitOf('Alt') | bitOf('Control') | bitOf('Meta');
23
+
24
+ /** The named keys a shot presses: code, Windows virtual key code, and the text it types if any. */
25
+ const NAMED_KEYS: ReadonlyMap<string, { code: string; keyCode: number; text?: string }> = new Map([
26
+ ['Enter', { code: 'Enter', keyCode: 13, text: '\r' }],
27
+ ['Tab', { code: 'Tab', keyCode: 9 }],
28
+ ['Escape', { code: 'Escape', keyCode: 27 }],
29
+ ['Backspace', { code: 'Backspace', keyCode: 8 }],
30
+ ['Delete', { code: 'Delete', keyCode: 46 }],
31
+ ['Insert', { code: 'Insert', keyCode: 45 }],
32
+ ['Home', { code: 'Home', keyCode: 36 }],
33
+ ['End', { code: 'End', keyCode: 35 }],
34
+ ['PageUp', { code: 'PageUp', keyCode: 33 }],
35
+ ['PageDown', { code: 'PageDown', keyCode: 34 }],
36
+ ['ArrowUp', { code: 'ArrowUp', keyCode: 38 }],
37
+ ['ArrowDown', { code: 'ArrowDown', keyCode: 40 }],
38
+ ['ArrowLeft', { code: 'ArrowLeft', keyCode: 37 }],
39
+ ['ArrowRight', { code: 'ArrowRight', keyCode: 39 }],
40
+ ['Space', { code: 'Space', keyCode: 32, text: ' ' }],
41
+ ['Alt', { code: 'AltLeft', keyCode: 18 }],
42
+ ['Control', { code: 'ControlLeft', keyCode: 17 }],
43
+ ['Meta', { code: 'MetaLeft', keyCode: 91 }],
44
+ ['Shift', { code: 'ShiftLeft', keyCode: 16 }],
45
+ ...Array.from({ length: 12 }, (_, i): [string, { code: string; keyCode: number }] => [
46
+ `F${String(i + 1)}`,
47
+ { code: `F${String(i + 1)}`, keyCode: 112 + i },
48
+ ]),
49
+ ]);
50
+
51
+ /** One key, as the dispatch frames need it. `key` is what `KeyboardEvent.key` will read. */
52
+ export interface KeyDefinition {
53
+ readonly key: string;
54
+ readonly code: string;
55
+ readonly keyCode: number;
56
+ readonly text?: string | undefined;
57
+ }
58
+
59
+ /** A printable character, or a named key; `undefined` for a name the browser does not have. */
60
+ export function keyDefinition(key: string): KeyDefinition | undefined {
61
+ const named = NAMED_KEYS.get(key);
62
+ if (named !== undefined) return { key: key === 'Space' ? ' ' : key, ...named };
63
+ if ([...key].length !== 1) return undefined;
64
+ if (key === ' ') return { key, code: 'Space', keyCode: 32, text: ' ' };
65
+ const upper = key.toUpperCase();
66
+ if (/^[A-Z]$/.test(upper))
67
+ return { key, code: `Key${upper}`, keyCode: upper.charCodeAt(0), text: key };
68
+ if (/^[0-9]$/.test(key))
69
+ return { key, code: `Digit${key}`, keyCode: key.charCodeAt(0), text: key };
70
+ return { key, code: '', keyCode: 0, text: key };
71
+ }
72
+
73
+ export interface KeyChord {
74
+ /** In the order written, which is the order they go down; they come up in reverse. */
75
+ readonly modifiers: readonly Modifier[];
76
+ readonly key: KeyDefinition;
77
+ }
78
+
79
+ /** Refuses an empty chord, a trailing `+`, an unknown modifier or key, and a modifier held twice. */
80
+ export function parseKeyChord(chord: string): KeyChord {
81
+ if (chord === '') throw new ShotKeyInvalidError({ chord, reason: 'is empty' });
82
+ const parts = chord.split('+');
83
+ const last = parts.at(-1) ?? '';
84
+ if (last === '') {
85
+ throw new ShotKeyInvalidError({ chord, reason: "ends in '+', so it names no key to press" });
86
+ }
87
+ const modifiers: Modifier[] = [];
88
+ for (const word of parts.slice(0, -1)) {
89
+ if (!isModifier(word)) {
90
+ throw new ShotKeyInvalidError({
91
+ chord,
92
+ reason: `names ${JSON.stringify(word)} as a modifier, and the browser holds only Meta, Control, Alt, Shift`,
93
+ });
94
+ }
95
+ if (modifiers.includes(word)) {
96
+ throw new ShotKeyInvalidError({ chord, reason: `holds ${word} twice` });
97
+ }
98
+ modifiers.push(word);
99
+ }
100
+ const key = keyDefinition(last);
101
+ if (key === undefined) {
102
+ throw new ShotKeyInvalidError({
103
+ chord,
104
+ reason: `names ${JSON.stringify(last)}, which is neither one character nor a key the browser names`,
105
+ });
106
+ }
107
+ return { modifiers, key };
108
+ }
109
+
110
+ /** One `Input.dispatchKeyEvent` payload. */
111
+ export type KeyFrame = Readonly<Record<string, unknown>>;
112
+
113
+ const frame = (type: string, key: KeyDefinition, bits: number, text?: string): KeyFrame => ({
114
+ type,
115
+ key: key.key,
116
+ code: key.code,
117
+ windowsVirtualKeyCode: key.keyCode,
118
+ modifiers: bits,
119
+ ...(text === undefined ? {} : { text, unmodifiedText: text }),
120
+ });
121
+
122
+ /**
123
+ * Every frame of one chord, in order: each modifier down, the key down and up, the modifiers up in
124
+ * reverse. A key under Control, Alt or Meta sends NO text — a shortcut types nothing, and a
125
+ * `keyDown` carrying text would put the letter into whatever field holds focus.
126
+ */
127
+ export function chordFrames(chord: KeyChord): readonly KeyFrame[] {
128
+ const frames: KeyFrame[] = [];
129
+ let bits = 0;
130
+ for (const modifier of chord.modifiers) {
131
+ bits |= bitOf(modifier);
132
+ const held = keyDefinition(modifier);
133
+ if (held !== undefined) frames.push(frame('rawKeyDown', held, bits));
134
+ }
135
+ const shortcut = (bits & SHORTCUT_BITS) !== 0;
136
+ const text = shortcut ? undefined : chord.key.text;
137
+ frames.push(frame(text === undefined ? 'rawKeyDown' : 'keyDown', chord.key, bits, text));
138
+ frames.push(frame('keyUp', chord.key, bits));
139
+ for (const modifier of [...chord.modifiers].reverse()) {
140
+ const held = keyDefinition(modifier);
141
+ bits &= ~bitOf(modifier);
142
+ if (held !== undefined) frames.push(frame('keyUp', held, bits));
143
+ }
144
+ return frames;
145
+ }
146
+
147
+ /** The frames that type one character into whatever holds focus. */
148
+ export function characterFrames(character: string): readonly KeyFrame[] {
149
+ const key = keyDefinition(character) ?? { key: character, code: '', keyCode: 0, text: character };
150
+ const text = key.text ?? character;
151
+ return [frame('keyDown', key, 0, text), frame('keyUp', key, 0)];
152
+ }
@@ -0,0 +1,230 @@
1
+ // One `ShotPage` over one attached CDP session: navigate, read, act, photograph. The session is
2
+ // the driver's (`cdp-shot-driver.ts`), the rings and the allow list are `cdp-shot-watch.ts`'s, and
3
+ // what "ready to act on" means is `cdp-shot-element.ts`'s. Acts are real input — a click is a
4
+ // mouse press at the element's centre, a keystroke is a key event — because an in-page `.click()`
5
+ // skips `pointerdown`, which is what a dialog's backdrop listens to.
6
+ import { assert, finiteCount, hostDecision } from '@ultimat3/core';
7
+ import type { CdpConnection } from '@ultimat3/testing';
8
+ import { CdpCallFailedError, CdpTimeoutError } from '@ultimat3/testing';
9
+ import type {
10
+ ElementSnapshot,
11
+ ShotCapture,
12
+ ShotColorScheme,
13
+ ShotPage,
14
+ ShotSessionInit,
15
+ WaitOptions,
16
+ } from './browser-launcher-port';
17
+ import { axNodesFor } from './cdp-shot-a11y';
18
+ import { awaitReady, parseSnapshots, snapshotExpression } from './cdp-shot-element';
19
+ import { ShotHostRefusedError } from './cdp-shot-errors';
20
+ import { characterFrames, chordFrames, parseKeyChord } from './cdp-shot-keys';
21
+ import type { PageWatch } from './cdp-shot-watch';
22
+
23
+ export const DEFAULT_ACCESSIBILITY_MAX = 25;
24
+
25
+ const record = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
26
+ typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;
27
+
28
+ /** What the page threw, when `Runtime.evaluate` answered with an exception rather than a value. */
29
+ const thrownIn = (answer: Readonly<Record<string, unknown>> | undefined): string | undefined => {
30
+ const thrown = record(answer?.['exceptionDetails']);
31
+ if (thrown === undefined) return undefined;
32
+ const description = record(thrown['exception'])?.['description'];
33
+ if (typeof description === 'string') return description.split('\n')[0];
34
+ const text = thrown['text'];
35
+ return typeof text === 'string' ? text : 'the expression threw in the page';
36
+ };
37
+
38
+ const selectorArg = (selector: string): string => JSON.stringify(selector);
39
+
40
+ export interface CdpShotPageInput {
41
+ readonly connection: CdpConnection;
42
+ readonly sessionId: string;
43
+ readonly init: ShotSessionInit;
44
+ readonly watch: PageWatch;
45
+ }
46
+
47
+ export function cdpShotPage(input: CdpShotPageInput): ShotPage {
48
+ const { connection, sessionId, init, watch } = input;
49
+ const defaultTimeout = finiteCount(init.name, 'timeoutMs', init.timeoutMs, 1);
50
+ const send = async (method: string, params: Record<string, unknown> = {}) =>
51
+ (await connection.send(method, params, sessionId)).result;
52
+ let current = 'about:blank';
53
+
54
+ const evaluate = async (expression: string): Promise<unknown> => {
55
+ const answer = await send('Runtime.evaluate', {
56
+ expression,
57
+ returnByValue: true,
58
+ awaitPromise: true,
59
+ });
60
+ const threw = thrownIn(answer);
61
+ if (threw !== undefined) {
62
+ throw new CdpCallFailedError({ method: 'Runtime.evaluate', detail: threw });
63
+ }
64
+ return record(answer?.['result'])?.['value'];
65
+ };
66
+
67
+ const query = async (selector: string): Promise<readonly ElementSnapshot[]> =>
68
+ parseSnapshots(await evaluate(snapshotExpression(selector)));
69
+
70
+ const ready = (
71
+ selector: string,
72
+ options: WaitOptions | undefined,
73
+ before?: () => Promise<unknown>,
74
+ ): Promise<ElementSnapshot> =>
75
+ awaitReady({
76
+ selector,
77
+ state: options?.state ?? 'actionable',
78
+ timeoutMs: finiteCount('page.waitFor', 'timeout', options?.timeout ?? defaultTimeout, 1),
79
+ clock: init.clock,
80
+ url: () => current,
81
+ snapshot: async () => {
82
+ await before?.();
83
+ return (await query(selector))[0];
84
+ },
85
+ });
86
+
87
+ const key = async (frame: Readonly<Record<string, unknown>>): Promise<void> => {
88
+ await send('Input.dispatchKeyEvent', { ...frame });
89
+ };
90
+
91
+ const focusOn = (selector: string): Promise<unknown> =>
92
+ evaluate(`document.querySelector(${selectorArg(selector)})?.focus()`);
93
+
94
+ return {
95
+ url: () => current,
96
+ async goto(url, options) {
97
+ if (!hostDecision(url, init.rules.allowHosts).allowed) {
98
+ throw new ShotHostRefusedError({ url, allowHosts: init.rules.allowHosts });
99
+ }
100
+ const timeoutMs = finiteCount('page.goto', 'timeout', options?.timeout ?? defaultTimeout, 1);
101
+ // The waiter goes up BEFORE the send, and the reply is raced rather than awaited: Chrome
102
+ // drops `Page.navigate`'s reply when the navigation swaps the render process
103
+ // (`@ultimat3/testing`'s `cdp-e2e-page.ts` measured it), and the load event still fires.
104
+ const loaded = connection.once('Page.loadEventFired', sessionId, timeoutMs);
105
+ const answered = send('Page.navigate', { url }).then(
106
+ (answer) => {
107
+ const failed = answer?.['errorText'];
108
+ return typeof failed === 'string' && failed !== '' ? failed : undefined;
109
+ },
110
+ () => undefined,
111
+ );
112
+ const first = await Promise.race([
113
+ loaded.then((fired) => ({ fired, failed: undefined })),
114
+ answered.then((failed) => ({ fired: undefined, failed })),
115
+ ]);
116
+ if (first.failed !== undefined) {
117
+ throw new CdpCallFailedError({ method: `Page.navigate to ${url}`, detail: first.failed });
118
+ }
119
+ const fired = first.fired ?? (await loaded);
120
+ // A same-document load may have fired before the waiter existed; the document is asked.
121
+ if (!fired && (await evaluate('document.readyState')) !== 'complete') {
122
+ throw new CdpTimeoutError({ method: `Page.navigate to ${url}`, timeoutMs });
123
+ }
124
+ const settled = await evaluate('location.href');
125
+ current = typeof settled === 'string' && settled !== '' ? settled : url;
126
+ },
127
+ waitFor: (selector, options) => ready(selector, options),
128
+ async click(selector, options) {
129
+ // Scrolled into view on every poll: `elementFromPoint` answers nothing off-screen, so an
130
+ // element below the fold would read as covered forever.
131
+ const target = await ready(selector, options, () =>
132
+ evaluate(
133
+ `document.querySelector(${selectorArg(selector)})?.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' })`,
134
+ ),
135
+ );
136
+ const box = target.box ?? { x: 0, y: 0, width: 0, height: 0 };
137
+ const at = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
138
+ await send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...at });
139
+ const press = { ...at, button: 'left', clickCount: 1 };
140
+ await send('Input.dispatchMouseEvent', { type: 'mousePressed', buttons: 1, ...press });
141
+ await send('Input.dispatchMouseEvent', { type: 'mouseReleased', buttons: 0, ...press });
142
+ },
143
+ async type(selector, text, options) {
144
+ await ready(selector, options);
145
+ await focusOn(selector);
146
+ for (const character of text) {
147
+ for (const frame of characterFrames(character)) await key(frame);
148
+ }
149
+ },
150
+ async focus(selector, options) {
151
+ await ready(selector, options);
152
+ await focusOn(selector);
153
+ },
154
+ async press(chord) {
155
+ // Parsed before a key goes down: a refusal halfway would leave a modifier held.
156
+ const frames = chordFrames(parseKeyChord(chord));
157
+ for (const frame of frames) await key(frame);
158
+ },
159
+ accessibility: (selector, options) =>
160
+ axNodesFor(
161
+ send,
162
+ selector,
163
+ finiteCount('page.accessibility', 'max', options?.max ?? DEFAULT_ACCESSIBILITY_MAX, 1),
164
+ ),
165
+ query,
166
+ evaluate,
167
+ screenshot: (options?: ShotCapture) => capture(send, options ?? {}),
168
+ async colorScheme(scheme: ShotColorScheme) {
169
+ // An EMPTY list is CDP's reset; an explicit `no-preference` would be an override.
170
+ const features =
171
+ scheme === 'no-preference' ? [] : [{ name: 'prefers-color-scheme', value: scheme }];
172
+ await send('Emulation.setEmulatedMedia', { features });
173
+ },
174
+ async prepare(expression) {
175
+ await send('Page.addScriptToEvaluateOnNewDocument', { source: expression });
176
+ },
177
+ console: () => watch.console(),
178
+ pageErrors: () => watch.pageErrors(),
179
+ pageErrorsDropped: () => watch.pageErrorsDropped(),
180
+ network: () => watch.network(),
181
+ networkDropped: () => watch.networkDropped(),
182
+ };
183
+ }
184
+
185
+ type Send = (
186
+ method: string,
187
+ params?: Record<string, unknown>,
188
+ ) => Promise<Readonly<Record<string, unknown>> | undefined>;
189
+
190
+ /**
191
+ * PNG bytes of the viewport, the whole document, or one clip — never both of the last two, which
192
+ * CDP would resolve silently in favour of one. A clip is CSS pixels in page coordinates.
193
+ */
194
+ async function capture(send: Send, framing: ShotCapture): Promise<Uint8Array> {
195
+ const clip = framing.clip;
196
+ assert(
197
+ !(framing.fullPage === true && clip !== undefined),
198
+ 'a screenshot was asked for the full page AND a clip, and a capture is one or the other',
199
+ 'pass { clip } for one component or { fullPage: true } for the document, never both',
200
+ );
201
+ assert(
202
+ clip === undefined ||
203
+ ([clip.x, clip.y, clip.width, clip.height].every(Number.isFinite) &&
204
+ clip.width > 0 &&
205
+ clip.height > 0),
206
+ 'a screenshot clip has no area or is not four finite numbers',
207
+ 'pass a clip with a positive width and height: { clip: { x: 0, y: 0, width: 320, height: 200 } }',
208
+ );
209
+ let region = clip;
210
+ if (framing.fullPage === true) {
211
+ const metrics = record((await send('Page.getLayoutMetrics'))?.['cssContentSize']);
212
+ const width = metrics?.['width'];
213
+ const height = metrics?.['height'];
214
+ if (typeof width === 'number' && typeof height === 'number') {
215
+ region = { x: 0, y: 0, width: Math.ceil(width), height: Math.ceil(height) };
216
+ }
217
+ }
218
+ const answer = await send('Page.captureScreenshot', {
219
+ format: 'png',
220
+ ...(region === undefined ? {} : { clip: { ...region, scale: 1 }, captureBeyondViewport: true }),
221
+ });
222
+ const data = answer?.['data'];
223
+ if (typeof data !== 'string') {
224
+ throw new CdpCallFailedError({
225
+ method: 'Page.captureScreenshot',
226
+ detail: 'the browser answered no image data',
227
+ });
228
+ }
229
+ return Uint8Array.fromBase64(data);
230
+ }
@@ -0,0 +1,241 @@
1
+ // What one shot page saw: its console, its uncaught exceptions and its requests, each in a BOUNDED
2
+ // tail with a count of what the bound threw away. And the allow list, enforced in the browser:
3
+ // every request is paused (`Fetch.requestPaused`) and refused unless its host is allowed, so an
4
+ // injected `<img src="http://169.254.169.254/…">` never leaves — it is recorded as `refused`.
5
+ import { hostDecision } from '@ultimat3/core';
6
+ import type { CdpConnection } from '@ultimat3/testing';
7
+ import type {
8
+ ConsoleLine,
9
+ NetworkEntry,
10
+ PageError,
11
+ ShotClock,
12
+ ShotResourceType,
13
+ } from './browser-launcher-port';
14
+
15
+ export const SHOT_RING_CAPACITY = 200;
16
+
17
+ /** One `PageError` field's cap: one stack overflow's trace is thousands of frames. */
18
+ export const MAX_PAGE_ERROR_CHARS = 4_000;
19
+
20
+ const clamped = (text: string): string =>
21
+ text.length <= MAX_PAGE_ERROR_CHARS ? text : `${text.slice(0, MAX_PAGE_ERROR_CHARS - 1)}…`;
22
+
23
+ /** A bounded, keyed tail: the oldest entry goes first, and every one that goes is counted. */
24
+ class Tail<T> {
25
+ readonly #items = new Map<string, T>();
26
+ #dropped = 0;
27
+ #next = 0;
28
+
29
+ put(value: T, key = `#${String(this.#next++)}`): void {
30
+ this.#items.set(key, value);
31
+ while (this.#items.size > SHOT_RING_CAPACITY) {
32
+ const oldest = this.#items.keys().next().value;
33
+ if (oldest === undefined) break;
34
+ this.#items.delete(oldest);
35
+ this.#dropped += 1;
36
+ }
37
+ }
38
+ get(key: string): T | undefined {
39
+ return this.#items.get(key);
40
+ }
41
+ /** Move the entry under `key` to a key of its own at the end, freeing `key` for its successor. */
42
+ retire(key: string): void {
43
+ const held = this.#items.get(key);
44
+ if (held === undefined) return;
45
+ this.#items.delete(key);
46
+ this.put(held);
47
+ }
48
+ /** Re-set in place: `Map.set` on a present key keeps its position. */
49
+ update(key: string, value: T): void {
50
+ if (this.#items.has(key)) this.#items.set(key, value);
51
+ }
52
+ entries = (): readonly T[] => [...this.#items.values()];
53
+ dropped = (): number => this.#dropped;
54
+ }
55
+
56
+ const record = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
57
+ typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;
58
+
59
+ const text = (value: unknown): string | undefined =>
60
+ typeof value === 'string' ? value : undefined;
61
+
62
+ const LEVELS: ReadonlyMap<string, ConsoleLine['level']> = new Map([
63
+ ['warning', 'warn'],
64
+ ['error', 'error'],
65
+ ['assert', 'error'],
66
+ ['debug', 'debug'],
67
+ ['info', 'info'],
68
+ ]);
69
+
70
+ const RESOURCE_TYPES: ReadonlyMap<string, ShotResourceType> = new Map([
71
+ ['Document', 'document'],
72
+ ['Stylesheet', 'stylesheet'],
73
+ ['Image', 'image'],
74
+ ['Media', 'media'],
75
+ ['Font', 'font'],
76
+ ['Script', 'script'],
77
+ ['XHR', 'xhr'],
78
+ ['Fetch', 'fetch'],
79
+ ['WebSocket', 'websocket'],
80
+ ]);
81
+
82
+ const resourceType = (type: unknown): ShotResourceType =>
83
+ RESOURCE_TYPES.get(text(type) ?? '') ?? 'other';
84
+
85
+ /** A console argument as the line a reader is shown: a value, else what the browser described. */
86
+ const argText = (arg: unknown): string => {
87
+ const remote = record(arg);
88
+ const value = remote?.['value'];
89
+ if (typeof value === 'string') return value;
90
+ if (value !== undefined) return JSON.stringify(value) ?? '';
91
+ return text(remote?.['unserializableValue']) ?? text(remote?.['description']) ?? '';
92
+ };
93
+
94
+ /** `Runtime.exceptionThrown`'s details as a `PageError`: message without its `Name: `, stack whole. */
95
+ export const pageErrorOf = (params: Readonly<Record<string, unknown>>, at: number): PageError => {
96
+ const details = record(params['exceptionDetails']);
97
+ const exception = record(details?.['exception']);
98
+ const description = text(exception?.['description']);
99
+ if (description === undefined) {
100
+ const thrown = exception?.['value'];
101
+ const message = thrown === undefined ? (text(details?.['text']) ?? '') : String(thrown);
102
+ return { message: clamped(message), at };
103
+ }
104
+ const first = description.split('\n')[0] ?? '';
105
+ const name = text(exception?.['className']);
106
+ const message =
107
+ name !== undefined && first.startsWith(`${name}: `) ? first.slice(name.length + 2) : first;
108
+ const stack = description.includes('\n') ? clamped(description) : undefined;
109
+ return { message: clamped(message), ...(stack === undefined ? {} : { stack }), at };
110
+ };
111
+
112
+ export interface PageWatch {
113
+ console(): readonly ConsoleLine[];
114
+ pageErrors(): readonly PageError[];
115
+ pageErrorsDropped(): number;
116
+ network(): readonly NetworkEntry[];
117
+ networkDropped(): number;
118
+ /** Stop listening. Idempotent. */
119
+ stop(): void;
120
+ }
121
+
122
+ /**
123
+ * Subscribe BEFORE the domains are enabled (the caller's order), so the first request of the first
124
+ * navigation is already counted. Only events on `sessionId` are read: a remote browser carries
125
+ * other people's pages.
126
+ */
127
+ export function watchPage(input: {
128
+ readonly connection: CdpConnection;
129
+ readonly sessionId: string;
130
+ readonly clock: ShotClock;
131
+ readonly allowHosts: readonly string[];
132
+ }): PageWatch {
133
+ const { connection, sessionId } = input;
134
+ const lines = new Tail<ConsoleLine>();
135
+ const errors = new Tail<PageError>();
136
+ const requests = new Tail<NetworkEntry>();
137
+ const now = (): number => input.clock.now().getTime();
138
+ const mine =
139
+ (listener: (params: Readonly<Record<string, unknown>>) => void) =>
140
+ (params: Record<string, unknown>, on: string | undefined): void => {
141
+ if (on === sessionId) listener(params);
142
+ };
143
+
144
+ const offs = [
145
+ connection.on(
146
+ 'Runtime.consoleAPICalled',
147
+ mine((params) => {
148
+ const args = params['args'];
149
+ lines.put({
150
+ level: LEVELS.get(text(params['type']) ?? '') ?? 'log',
151
+ text: (Array.isArray(args) ? args : []).map(argText).join(' '),
152
+ at: now(),
153
+ });
154
+ }),
155
+ ),
156
+ connection.on(
157
+ 'Runtime.exceptionThrown',
158
+ mine((params) => errors.put(pageErrorOf(params, now()))),
159
+ ),
160
+ connection.on(
161
+ 'Network.requestWillBeSent',
162
+ mine((params) => {
163
+ const id = text(params['requestId']) ?? '';
164
+ const request = record(params['request']);
165
+ const held = requests.get(id);
166
+ const entry: NetworkEntry = {
167
+ method: text(request?.['method']) ?? 'GET',
168
+ url: text(request?.['url']) ?? '',
169
+ resourceType: resourceType(params['type']),
170
+ at: now(),
171
+ ...(held?.refused === undefined ? {} : { refused: held.refused }),
172
+ };
173
+ // A redirect re-sends under the SAME id: the hop before it is a request of its own,
174
+ // answered with the redirect's status.
175
+ const hop = record(params['redirectResponse'])?.['status'];
176
+ if (held !== undefined && held.refused === undefined && typeof hop === 'number') {
177
+ requests.update(id, { ...held, status: hop });
178
+ requests.retire(id);
179
+ requests.put(entry, id);
180
+ } else if (held === undefined) requests.put(entry, id);
181
+ else requests.update(id, entry);
182
+ }),
183
+ ),
184
+ connection.on(
185
+ 'Network.responseReceived',
186
+ mine((params) => {
187
+ const id = text(params['requestId']) ?? '';
188
+ const status = record(params['response'])?.['status'];
189
+ const held = requests.get(id);
190
+ if (held !== undefined && typeof status === 'number')
191
+ requests.update(id, { ...held, status });
192
+ }),
193
+ ),
194
+ connection.on(
195
+ 'Fetch.requestPaused',
196
+ mine((params) => {
197
+ const pausedId = text(params['requestId']) ?? '';
198
+ const request = record(params['request']);
199
+ const url = text(request?.['url']) ?? '';
200
+ if (hostDecision(url, input.allowHosts).allowed) {
201
+ void connection
202
+ .send('Fetch.continueRequest', { requestId: pausedId }, sessionId)
203
+ .catch(() => undefined);
204
+ return;
205
+ }
206
+ const id = text(params['networkId']) ?? pausedId;
207
+ const held = requests.get(id);
208
+ const refused: NetworkEntry = {
209
+ method: text(request?.['method']) ?? held?.method ?? 'GET',
210
+ url,
211
+ resourceType: resourceType(params['resourceType']),
212
+ at: held?.at ?? now(),
213
+ refused: 'host',
214
+ };
215
+ if (held === undefined) requests.put(refused, id);
216
+ else requests.update(id, refused);
217
+ void connection
218
+ .send(
219
+ 'Fetch.failRequest',
220
+ { requestId: pausedId, errorReason: 'BlockedByClient' },
221
+ sessionId,
222
+ )
223
+ .catch(() => undefined);
224
+ }),
225
+ ),
226
+ ];
227
+
228
+ let stopped = false;
229
+ return {
230
+ console: () => lines.entries(),
231
+ pageErrors: () => errors.entries(),
232
+ pageErrorsDropped: () => errors.dropped(),
233
+ network: () => requests.entries(),
234
+ networkDropped: () => requests.dropped(),
235
+ stop(): void {
236
+ if (stopped) return;
237
+ stopped = true;
238
+ for (const off of offs) off();
239
+ },
240
+ };
241
+ }
@@ -0,0 +1,24 @@
1
+ // `x affected`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-affected.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import { DEFAULT_BASE } from './affected';
5
+ import type { CommandSpec } from './parse';
6
+
7
+ export const affectedSpec: CommandSpec = {
8
+ name: 'affected',
9
+ summary: 'the workspaces a diff touches, and every workspace that depends on one of them',
10
+ usage: 'x affected [--base <ref>] [--dirty] [--paths] [--json]',
11
+ flags: [
12
+ {
13
+ name: 'base',
14
+ type: 'string',
15
+ summary: `git ref to diff against, merge-base style (default: ${DEFAULT_BASE})`,
16
+ },
17
+ {
18
+ name: 'dirty',
19
+ type: 'boolean',
20
+ summary: 'also count uncommitted work — every agent sharing this checkout, not only yours',
21
+ },
22
+ { name: 'paths', type: 'boolean', summary: 'print bare directories instead of a table' },
23
+ ],
24
+ };
@@ -6,7 +6,8 @@
6
6
  // CLI wiring only. What a diff touches is `affected.ts`, what the workspaces are is
7
7
  // `workspace-graph.ts` — the `cmd-jobs.ts` / `jobs-report.ts` split, repeated.
8
8
 
9
- import { affectedScope, DEFAULT_BASE } from './affected';
9
+ import { affectedScope } from './affected';
10
+ import { affectedSpec } from './cmd-affected-spec';
10
11
  import type { CliCommand, CommandContext } from './command';
11
12
  import { msg } from './messages';
12
13
  import type { CommandResult, JsonValue } from './output';
@@ -33,24 +34,7 @@ export const AFFECTED_MESSAGE_KEYS = [
33
34
  ] as const;
34
35
 
35
36
  export const affectedCommand: CliCommand = {
36
- spec: {
37
- name: 'affected',
38
- summary: 'the workspaces a diff touches, and every workspace that depends on one of them',
39
- usage: 'x affected [--base <ref>] [--dirty] [--paths] [--json]',
40
- flags: [
41
- {
42
- name: 'base',
43
- type: 'string',
44
- summary: `git ref to diff against, merge-base style (default: ${DEFAULT_BASE})`,
45
- },
46
- {
47
- name: 'dirty',
48
- type: 'boolean',
49
- summary: 'also count uncommitted work — every agent sharing this checkout, not only yours',
50
- },
51
- { name: 'paths', type: 'boolean', summary: 'print bare directories instead of a table' },
52
- ],
53
- },
37
+ spec: affectedSpec,
54
38
  async run(ctx: CommandContext): Promise<CommandResult> {
55
39
  // The one resolver `x test --affected` narrows with, so this command reports exactly what that
56
40
  // one runs. It reads `--base` before git is spawned (a malformed ref must not cost a