@ultimat3/cli 21.0.0 → 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 (234) hide show
  1. package/CLAUDE.md +84 -1650
  2. package/README.md +40 -2
  3. package/package.json +30 -30
  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-load.ts +37 -5
  8. package/src/app-openapi.ts +27 -0
  9. package/src/app-permissions.ts +0 -0
  10. package/src/app-root.ts +1 -1
  11. package/src/async-pages.ts +32 -0
  12. package/src/bin.ts +7 -1
  13. package/src/boundary-findings.ts +36 -0
  14. package/src/browser-launcher-fake-html.ts +80 -0
  15. package/src/browser-launcher-fake.ts +165 -0
  16. package/src/browser-launcher-port.ts +159 -0
  17. package/src/browser-launcher.ts +26 -133
  18. package/src/budgets.ts +11 -1
  19. package/src/cdp-shot-a11y.ts +77 -0
  20. package/src/cdp-shot-clock.ts +14 -0
  21. package/src/cdp-shot-driver.ts +150 -0
  22. package/src/cdp-shot-element.ts +147 -0
  23. package/src/cdp-shot-errors.ts +62 -0
  24. package/src/cdp-shot-keys.ts +152 -0
  25. package/src/cdp-shot-page.ts +230 -0
  26. package/src/cdp-shot-watch.ts +241 -0
  27. package/src/cmd-affected-spec.ts +24 -0
  28. package/src/cmd-affected.ts +3 -19
  29. package/src/cmd-build-spec.ts +16 -0
  30. package/src/cmd-build.ts +46 -20
  31. package/src/cmd-ci-spec.ts +24 -0
  32. package/src/cmd-ci.ts +17 -21
  33. package/src/cmd-db-branch.ts +2 -2
  34. package/src/cmd-db-spec.ts +99 -0
  35. package/src/cmd-db.ts +15 -94
  36. package/src/cmd-deploy-helm.ts +136 -0
  37. package/src/cmd-deploy-spec.ts +40 -0
  38. package/src/cmd-deploy.ts +111 -43
  39. package/src/cmd-dev-spec.ts +25 -0
  40. package/src/cmd-dev.ts +55 -36
  41. package/src/cmd-docs-spec.ts +16 -0
  42. package/src/cmd-docs.ts +2 -11
  43. package/src/cmd-doctor-spec.ts +21 -0
  44. package/src/cmd-doctor.ts +4 -18
  45. package/src/cmd-env-spec.ts +18 -0
  46. package/src/cmd-env.ts +4 -13
  47. package/src/cmd-errors-spec.ts +23 -0
  48. package/src/cmd-errors.ts +3 -17
  49. package/src/cmd-fix-spec.ts +18 -0
  50. package/src/cmd-fix.ts +4 -13
  51. package/src/cmd-generate-spec.ts +31 -0
  52. package/src/cmd-generate.ts +46 -42
  53. package/src/cmd-i18n-spec.ts +16 -0
  54. package/src/cmd-i18n.ts +7 -14
  55. package/src/cmd-jobs-spec.ts +56 -0
  56. package/src/cmd-jobs.ts +3 -50
  57. package/src/cmd-manifest-spec.ts +15 -0
  58. package/src/cmd-manifest.ts +19 -20
  59. package/src/cmd-mcp-spec.ts +21 -0
  60. package/src/cmd-mcp.ts +2 -15
  61. package/src/cmd-new-spec.ts +33 -0
  62. package/src/cmd-new.ts +38 -33
  63. package/src/cmd-policy-spec.ts +13 -0
  64. package/src/cmd-policy.ts +2 -9
  65. package/src/cmd-pr-spec.ts +39 -0
  66. package/src/cmd-pr.ts +5 -65
  67. package/src/cmd-registries-spec.ts +32 -0
  68. package/src/cmd-registries.ts +4 -25
  69. package/src/cmd-routes-spec.ts +12 -0
  70. package/src/cmd-routes.ts +2 -7
  71. package/src/cmd-secrets-spec.ts +19 -0
  72. package/src/cmd-secrets.ts +22 -34
  73. package/src/cmd-shot-island.ts +12 -13
  74. package/src/cmd-shot-spec.ts +53 -0
  75. package/src/cmd-shot.ts +11 -60
  76. package/src/cmd-tasks-spec.ts +21 -0
  77. package/src/cmd-tasks.ts +2 -16
  78. package/src/cmd-test-spec.ts +54 -0
  79. package/src/cmd-test.ts +4 -49
  80. package/src/cmd-verify-spec.ts +28 -0
  81. package/src/cmd-verify.ts +3 -23
  82. package/src/db-seed.ts +4 -2
  83. package/src/dev-dashboard.ts +4 -4
  84. package/src/dev-lock.ts +18 -2
  85. package/src/dev-port.ts +21 -0
  86. package/src/dev-route-table.ts +3 -3
  87. package/src/dispatch.ts +12 -5
  88. package/src/document-styles.ts +1 -1
  89. package/src/drift.ts +4 -18
  90. package/src/error-catalog.ts +16 -9
  91. package/src/error-codes.ts +28 -30
  92. package/src/error-pages.ts +19 -7
  93. package/src/errors.ts +14 -0
  94. package/src/favicon.ts +2 -2
  95. package/src/fix-imports.ts +1 -1
  96. package/src/fix-scan.ts +2 -9
  97. package/src/flag-reads.ts +1 -2
  98. package/src/foreign-text.ts +36 -0
  99. package/src/framework-schema.ts +5 -6
  100. package/src/generate-feature.ts +42 -0
  101. package/src/generate-files.ts +7 -3
  102. package/src/generate-grants.ts +83 -0
  103. package/src/generate-kinds.ts +56 -4
  104. package/src/i18n-index.ts +59 -10
  105. package/src/icon-assets.ts +1 -1
  106. package/src/image-prepare.ts +14 -0
  107. package/src/import-scan.ts +63 -0
  108. package/src/index.ts +67 -348
  109. package/src/invocation-flags.ts +26 -0
  110. package/src/island-bundle.ts +6 -1
  111. package/src/island-capture.ts +6 -6
  112. package/src/island-harness-script.ts +6 -2
  113. package/src/island-realtime.ts +15 -8
  114. package/src/island-shot.ts +5 -0
  115. package/src/island-store.ts +131 -0
  116. package/src/island-verdict.ts +1 -1
  117. package/src/job-registration.ts +42 -0
  118. package/src/jobs-driver.ts +2 -2
  119. package/src/load-findings.ts +51 -0
  120. package/src/mcp-db-target.ts +1 -1
  121. package/src/mcp-errors.ts +28 -27
  122. package/src/mcp-host.ts +27 -12
  123. package/src/mcp-ui-diff.ts +27 -0
  124. package/src/mcp-ui-inspect.ts +4 -4
  125. package/src/mcp-ui-interact.ts +21 -15
  126. package/src/mcp-ui.ts +19 -15
  127. package/src/measure-database.ts +73 -0
  128. package/src/measure-paths.ts +74 -0
  129. package/src/measure-scope.ts +74 -0
  130. package/src/messages.ts +1 -3
  131. package/src/metrics-endpoint.ts +1 -1
  132. package/src/otlp-export.ts +1 -1
  133. package/src/output.ts +6 -0
  134. package/src/permission-grants.ts +86 -0
  135. package/src/prerender-out.ts +25 -0
  136. package/src/prerender.ts +121 -102
  137. package/src/pwa-artifacts.ts +3 -3
  138. package/src/reexport-manifest.ts +2 -1
  139. package/src/registry.ts +80 -56
  140. package/src/role-realtime.ts +36 -0
  141. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  142. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  143. package/src/role-start-types.ts +112 -0
  144. package/src/{dev-roles.ts → role-start.ts} +42 -115
  145. package/src/{dev-sync.ts → role-sync.ts} +18 -8
  146. package/src/root-env.ts +67 -0
  147. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  148. package/src/{dev-services.ts → runtime-bindings.ts} +39 -16
  149. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  150. package/src/runtime-jobs.ts +87 -0
  151. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +18 -5
  152. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  153. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  154. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  155. package/src/runtime-realtime.ts +55 -0
  156. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  157. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  158. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  159. package/src/scaffold-fixture.ts +28 -6
  160. package/src/scaffold-typecheck.ts +6 -3
  161. package/src/schema-drift.ts +7 -1
  162. package/src/script-csp.ts +5 -2
  163. package/src/secrets-rotation.ts +59 -0
  164. package/src/serve-boot.ts +192 -0
  165. package/src/serve-drain.ts +24 -0
  166. package/src/serve-entry.ts +6 -0
  167. package/src/serve-env.ts +116 -0
  168. package/src/serve-types.ts +55 -0
  169. package/src/serve.ts +44 -347
  170. package/src/shot-server.ts +2 -2
  171. package/src/shot-settle.ts +10 -1
  172. package/src/shot-theme.ts +3 -3
  173. package/src/shot-verdict.ts +16 -7
  174. package/src/signal-shred.ts +27 -0
  175. package/src/solid-loader.ts +26 -2
  176. package/src/static-report.ts +8 -1
  177. package/src/templates/action.ts +30 -16
  178. package/src/templates/entity.ts +12 -7
  179. package/src/templates/index.ts +1 -1
  180. package/src/templates/job.ts +10 -7
  181. package/src/templates/policy.ts +20 -2
  182. package/src/templates/resource-create.ts +127 -0
  183. package/src/templates/resource-form-island.ts +49 -12
  184. package/src/templates/resource.ts +10 -4
  185. package/src/templates/scaffold-app.ts +4 -1
  186. package/src/templates/scaffold-auth.ts +3 -1
  187. package/src/templates/scaffold-container-compose.ts +184 -0
  188. package/src/templates/scaffold-container.ts +24 -159
  189. package/src/templates/scaffold-dashboard-example.ts +2 -2
  190. package/src/templates/scaffold-db-package.ts +16 -3
  191. package/src/templates/scaffold-demo-org.ts +41 -0
  192. package/src/templates/scaffold-entries.ts +1 -1
  193. package/src/templates/scaffold-helm-templates.ts +66 -7
  194. package/src/templates/scaffold-helm.ts +27 -5
  195. package/src/templates/scaffold-i18n.ts +15 -10
  196. package/src/templates/scaffold-repo.ts +13 -10
  197. package/src/templates/scaffold-roles.ts +38 -10
  198. package/src/templates/slice-foundation.ts +1 -1
  199. package/src/templates/wrap.ts +4 -1
  200. package/src/test-passes.ts +2 -1
  201. package/src/test-workers.ts +26 -0
  202. package/src/ts-scan.ts +3 -6
  203. package/src/tsconfig-references.ts +1 -2
  204. package/src/verify-checks.ts +41 -39
  205. package/src/verify-e2e.ts +10 -7
  206. package/src/verify-run.ts +11 -1
  207. package/src/verify-step.ts +3 -3
  208. package/src/verify-tests.ts +1 -26
  209. package/src/verify-typecheck.ts +28 -0
  210. package/src/web-binding.ts +2 -2
  211. package/src/workspace-graph.ts +10 -33
  212. package/src/cdp-browser.ts +0 -94
  213. package/src/cdp-connection.ts +0 -247
  214. package/src/cdp-e2e-page.ts +0 -180
  215. package/src/cdp-e2e-session.ts +0 -199
  216. package/src/cdp-errors.ts +0 -56
  217. package/src/cdp-launch.ts +0 -193
  218. package/src/cdp-offline-script.ts +0 -73
  219. package/src/cdp-pipe.ts +0 -77
  220. package/src/e2e-app.ts +0 -103
  221. package/src/e2e-browser-handle.ts +0 -55
  222. package/src/e2e-dom-fixture.ts +0 -117
  223. package/src/e2e-driver.ts +0 -117
  224. package/src/e2e-errors.ts +0 -117
  225. package/src/e2e-evaluate.ts +0 -156
  226. package/src/e2e-locator.ts +0 -86
  227. package/src/e2e-page.ts +0 -153
  228. package/src/e2e-preload.ts +0 -64
  229. package/src/e2e-probe.ts +0 -23
  230. package/src/e2e-selection.ts +0 -182
  231. package/src/e2e-spawn.ts +0 -169
  232. package/src/measurement-actor.ts +0 -26
  233. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
  234. /package/src/{dev-render.ts → runtime-render.ts} +0 -0
@@ -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
@@ -0,0 +1,16 @@
1
+ // `x build`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-build.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+
6
+ export const buildSpec: CommandSpec = {
7
+ name: 'build',
8
+ summary: 'build a container image, a single binary, or a prerendered static site',
9
+ usage: 'x build --target docker|binary|static [--tag name] [--out path] [--json]',
10
+ requiresApp: true,
11
+ flags: [
12
+ { name: 'target', type: 'string', summary: 'docker | binary | static', default: 'docker' },
13
+ { name: 'tag', type: 'string', summary: 'image tag (docker target)' },
14
+ { name: 'out', type: 'string', summary: 'output path (binary and static targets)' },
15
+ ],
16
+ };
package/src/cmd-build.ts CHANGED
@@ -2,15 +2,17 @@
2
2
  // means "anywhere that runs a container or a binary"; nothing here knows the name of a cloud.
3
3
 
4
4
  import { existsSync } from 'node:fs';
5
- import { join } from 'node:path';
5
+ import { join, resolve } from 'node:path';
6
6
  import { ERROR_DOCS_URL, frameworkVersion, VERSION_DEFINE } from '@ultimat3/core';
7
7
  import { requireAppRoot } from './app-root';
8
+ import { buildSpec } from './cmd-build-spec';
8
9
  import { runVerify } from './cmd-verify';
9
10
  import type { CliCommand, CommandContext } from './command';
10
11
  import { externalArgs } from './compile-externals';
11
12
  import { BuildEntryMissingError, UnknownCommandError } from './errors';
12
13
  import type { ExecResult } from './exec';
13
14
  import { execOutput } from './exec';
15
+ import { prepareImage } from './image-prepare';
14
16
  import { msg } from './messages';
15
17
  import type { CommandResult } from './output';
16
18
  import { flagString } from './parse';
@@ -56,9 +58,24 @@ export function requireEntry(root: string, target: BuildTarget): string {
56
58
  return absolute;
57
59
  }
58
60
 
59
- /** One image for every role; ROLE selects behaviour at start, so there is one artifact to promote. */
60
- export function dockerArgs(root: string, tag: string): readonly string[] {
61
- return ['docker', 'build', '-f', join(root, BUILD_ENTRY.docker), '-t', tag, root];
61
+ /**
62
+ * One image for every role; ROLE selects behaviour at start, so there is one artifact to promote.
63
+ * `BUILD_ID` is the manifest's own build id, passed as the build arg the scaffolded Dockerfile
64
+ * declares: without it the image's `BUILD_ID` was empty and every role computed one at boot, so
65
+ * two replicas of one image could disagree about the id their clients are served against.
66
+ */
67
+ export function dockerArgs(root: string, tag: string, buildId: string): readonly string[] {
68
+ return [
69
+ 'docker',
70
+ 'build',
71
+ '-f',
72
+ join(root, BUILD_ENTRY.docker),
73
+ '--build-arg',
74
+ `BUILD_ID=${buildId}`,
75
+ '-t',
76
+ tag,
77
+ root,
78
+ ];
62
79
  }
63
80
 
64
81
  /**
@@ -93,9 +110,15 @@ export function staticArgs(root: string, out: string): readonly string[] {
93
110
 
94
111
  export function argsFor(
95
112
  target: BuildTarget,
96
- paths: { readonly root: string; readonly tag: string; readonly out: string },
113
+ paths: {
114
+ readonly root: string;
115
+ readonly tag: string;
116
+ readonly out: string;
117
+ /** The docker target's `BUILD_ID` build arg — `appManifest(root)`'s own. */
118
+ readonly buildId?: string;
119
+ },
97
120
  ): readonly string[] {
98
- if (target === 'docker') return dockerArgs(paths.root, paths.tag);
121
+ if (target === 'docker') return dockerArgs(paths.root, paths.tag, paths.buildId ?? 'dev');
99
122
  if (target === 'binary') return binaryArgs(paths.root, paths.out);
100
123
  return staticArgs(paths.root, paths.out);
101
124
  }
@@ -162,17 +185,7 @@ export function buildResult(input: {
162
185
  }
163
186
 
164
187
  export const buildCommand: CliCommand = {
165
- spec: {
166
- name: 'build',
167
- summary: 'build a container image, a single binary, or a prerendered static site',
168
- usage: 'x build --target docker|binary|static [--tag name] [--out path] [--json]',
169
- requiresApp: true,
170
- flags: [
171
- { name: 'target', type: 'string', summary: 'docker | binary | static', default: 'docker' },
172
- { name: 'tag', type: 'string', summary: 'image tag (docker target)' },
173
- { name: 'out', type: 'string', summary: 'output path (binary and static targets)' },
174
- ],
175
- },
188
+ spec: buildSpec,
176
189
  async run(ctx: CommandContext): Promise<CommandResult> {
177
190
  const root = requireAppRoot('build', ctx.cwd).dir;
178
191
  const target = readTarget(flagString(ctx.args, 'target'));
@@ -186,15 +199,28 @@ export const buildCommand: CliCommand = {
186
199
  const verifySteps = (await import('./cmd-verify')).VERIFY_STEPS.filter((step) =>
187
200
  staticSteps.includes(step.name),
188
201
  );
189
- const verifyResult = await runVerify(verifySteps, { root, runner: ctx.runner });
202
+ const verifyResult = await runVerify(verifySteps, { root, runner: ctx.runner, env: ctx.env });
190
203
  if (!verifyResult.ok) {
191
204
  return preflightResult(verifyResult);
192
205
  }
193
206
 
207
+ // A relative `--out` is a path the caller typed from where they stand, so it resolves against
208
+ // the cwd — against the root it landed somewhere else whenever `x build` ran from `apps/web`.
209
+ const outFlag = flagString(ctx.args, 'out');
194
210
  const out =
195
- flagString(ctx.args, 'out') ?? join(root, '.x', target === 'static' ? 'static' : 'app');
211
+ outFlag === undefined
212
+ ? join(root, '.x', target === 'static' ? 'static' : 'app')
213
+ : resolve(ctx.cwd, outFlag);
196
214
  const tag = flagString(ctx.args, 'tag') ?? 'ultimate-app:dev';
197
- const command = argsFor(target, { root, tag, out });
215
+ // The docker target stamps the manifest's build id into the image, and writes the island
216
+ // chunks the image serves, so a container boot neither re-derives the one nor rebuilds the other.
217
+ const buildId = target === 'docker' ? await prepareImage(root) : undefined;
218
+ const command = argsFor(target, {
219
+ root,
220
+ tag,
221
+ out,
222
+ ...(buildId === undefined ? {} : { buildId }),
223
+ });
198
224
  // Removed BEFORE the builder runs, so a build that writes no inventory can never be reported
199
225
  // with the last one's: a stale emitted list is worse than none, because it reads as this run's.
200
226
  if (target === 'static') await removeStaticReport(root);