@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
package/src/budgets.ts CHANGED
@@ -48,8 +48,21 @@ export interface RouteStats {
48
48
 
49
49
  export interface BuildStats {
50
50
  readonly routes: readonly RouteStats[];
51
+ /** The measurement rules that wrote the file — `BUILD_STATS_RULES` at the time. */
52
+ readonly measuredBy?: number;
53
+ /** Set by `readBuildStats` on a file an earlier rule wrote: there are no numbers to read. */
54
+ readonly stale?: true;
51
55
  }
52
56
 
57
+ /**
58
+ * The version of the rules `measureDocumentJs` measures by. BUMP IT whenever what is charged
59
+ * changes — a script exempted, a kind of tag excluded — so every stats file written under the old
60
+ * rule stops being read as a measurement. `.x/` survives across framework upgrades, and
61
+ * `examples/dummy` was charged 250 B for `/x-sw-register.js` by a file written before
62
+ * `FRAMEWORK_SCRIPTS` exempted it. `2`: that exemption and the page-boot decision (ledger #28).
63
+ */
64
+ export const BUILD_STATS_RULES = 2;
65
+
53
66
  const chainOf = (stats: RouteStats): string =>
54
67
  stats.heaviestChain === undefined ? 'unknown import chain' : stats.heaviestChain.join(' -> ');
55
68
 
@@ -78,7 +91,16 @@ function declaredBudgets(js: number | null, lcp: number | undefined): string {
78
91
  * second build's. Reporting the first as the second is what sends a reader to re-run a build that
79
92
  * already did everything it was going to do.
80
93
  */
81
- function unmeasuredFinding(url: string, declared: string, built: boolean): Finding {
94
+ function unmeasuredFinding(url: string, declared: string, built: boolean, stale = false): Finding {
95
+ if (stale) {
96
+ return {
97
+ code: 'X_BUDGET_UNMEASURED',
98
+ cause: `${url} declares a ${declared} budget and ${BUILD_STATS_FILE} was written by an earlier measurement rule than this gate's (v${String(BUILD_STATS_RULES)}), so its numbers are not this gate's to read`,
99
+ fix: 'x build --target static --json && x verify --json',
100
+ docs: ERROR_DOCS_URL,
101
+ at: url,
102
+ };
103
+ }
82
104
  return {
83
105
  code: 'X_BUDGET_UNMEASURED',
84
106
  cause: built
@@ -109,7 +131,11 @@ function unmeasuredFinding(url: string, declared: string, built: boolean): Findi
109
131
  * reporting them under their own codes would tell the author to fix a database the gate never
110
132
  * had. The list grows by a decision, per code, here.
111
133
  */
112
- const REPORTED_BY_OWN_CODE: ReadonlySet<string> = new Set(['X_ISLAND_PROPS_INVALID']);
134
+ const REPORTED_BY_OWN_CODE: ReadonlySet<string> = new Set([
135
+ 'X_ISLAND_PROPS_INVALID',
136
+ // A dynamic route that lists no `prerender()` path: the edit is in the route, not the build.
137
+ 'X_BUDGET_PARAMS_UNDECLARED',
138
+ ]);
113
139
 
114
140
  /**
115
141
  * The build's own finding for a route it could not weigh, when that failure is an instruction.
@@ -144,8 +170,14 @@ export function checkBudgets(
144
170
  unmeasured: readonly UnmeasuredRoute[] = [],
145
171
  ): readonly Finding[] {
146
172
  const byPath = new Map((stats?.routes ?? []).map((route) => [route.path, route]));
173
+ // Routes no build can weigh by construction (`UnmeasuredRoute.weighable`): printed by the step,
174
+ // never a finding — an instruction with no edit behind it is noise.
175
+ const unweighable = new Set(
176
+ unmeasured.filter((one) => one.weighable === false).map((one) => one.path),
177
+ );
147
178
  const findings: Finding[] = [];
148
179
  for (const route of manifest.routes) {
180
+ if (unweighable.has(route.url)) continue;
149
181
  const measured = byPath.get(route.url);
150
182
  const js = jsBudgetOf(route);
151
183
  const lcp = route.budget?.lcp;
@@ -153,7 +185,12 @@ export function checkBudgets(
153
185
  if (js !== null || lcp !== undefined) {
154
186
  findings.push(
155
187
  ownCodeFinding(route.url, unmeasured) ??
156
- unmeasuredFinding(route.url, declaredBudgets(js, lcp), stats !== undefined),
188
+ unmeasuredFinding(
189
+ route.url,
190
+ declaredBudgets(js, lcp),
191
+ stats !== undefined,
192
+ stats?.stale === true,
193
+ ),
157
194
  );
158
195
  }
159
196
  continue;
@@ -183,7 +220,9 @@ export function checkBudgets(
183
220
  export async function readBuildStats(root: string): Promise<BuildStats | undefined> {
184
221
  const path = join(root, BUILD_STATS_FILE);
185
222
  if (!existsSync(path)) return undefined;
186
- return (await Bun.file(path).json()) as BuildStats;
223
+ const read = (await Bun.file(path).json()) as BuildStats;
224
+ // A file the current rules did not write carries no numbers this gate may read.
225
+ return read.measuredBy === BUILD_STATS_RULES ? read : { routes: [], stale: true };
187
226
  }
188
227
 
189
228
  const SCRIPT_TAG = /<script(?<attrs>[^>]*)>(?<body>[\s\S]*?)<\/script>/g;
@@ -242,14 +281,17 @@ export interface MeasuredJs {
242
281
  * and the next build measured the one before it and recorded 250. Same commit, green then red,
243
282
  * decided by whether anything had cleaned the output directory.
244
283
  *
245
- * ENUMERATED, and it is one entry: `serviceWorkerHead` is the only `<script src>` the framework
246
- * puts in a prerendered document — measured on a fresh scaffold plus `x g island` and
247
- * `x g route`, where `<script src="/x-sw-register.js" defer>` is the only script tag across every
248
- * emitted page. Two framework scripts are deliberately NOT here. `render/src/hydrate.ts`'s inline
249
- * module runtime is charged, because it exists only when the page ships an island — it is the
250
- * cost of the app's own interactivity, and a page with a `0kb` budget has none. `island-props.ts`'
251
- * `<script type="application/json">` is already excluded as data, by `carriesJson`. A third one
252
- * joins this set by a decision, here, with the same argument.
284
+ * ENUMERATED, and it is one entry — the service-worker register is the only framework
285
+ * `<script src>` EXEMPTED, not the only one emitted. Three framework scripts are deliberately
286
+ * CHARGED. The page boot (plan 101: the page client, the principal fence, the sync target) is
287
+ * framework-emitted too, and charged by decision (DX ledger #28, 2026-09-22): it ships only on a
288
+ * page that hydrates something, so it is part of the interactivity the app opted into, and
289
+ * `examples/dummy`'s route budgets already include it. `render/src/hydrate.ts`'s inline module
290
+ * runtime is charged for the same reason — it exists only when the page ships an island, and a
291
+ * page with a `0kb` budget has none. `island-props.ts`' `<script type="application/json">` is
292
+ * excluded as data, by `carriesJson`, not by this set. A further entry joins this set by a
293
+ * decision, here, with the register's argument: every document carries it and no author can
294
+ * remove it.
253
295
  */
254
296
  export const FRAMEWORK_SCRIPTS: ReadonlySet<string> = new Set([SW_REGISTER_PATH]);
255
297
 
@@ -335,6 +377,7 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
335
377
  */
336
378
  export async function writeBuildStats(root: string, stats: BuildStats): Promise<string> {
337
379
  const path = join(root, BUILD_STATS_FILE);
338
- await Bun.write(path, `${JSON.stringify(stats, null, 2)}\n`);
380
+ const stamped: BuildStats = { ...stats, measuredBy: BUILD_STATS_RULES };
381
+ await Bun.write(path, `${JSON.stringify(stamped, null, 2)}\n`);
339
382
  return path;
340
383
  }
@@ -0,0 +1,54 @@
1
+ // The CLI's build and bundle refusals: an entry an app does not have, an island that will not
2
+ // compile, a framework browser script that will not bundle. Split from `errors.ts` at its 500-line
3
+ // ceiling; `errors.ts` re-exports every name, so no import site moves.
4
+
5
+ import { UltimateError } from '@ultimat3/core';
6
+ import { quoteArg } from './shell-quote';
7
+
8
+ /**
9
+ * A build target names an entry file the app does not have. `x build` refuses before it spawns the
10
+ * builder: `bun build`'s own "module not found" says nothing about which file an Ultimate app is
11
+ * supposed to own, and `docker build`'s says nothing about which target wanted it.
12
+ */
13
+ export class BuildEntryMissingError extends UltimateError {
14
+ constructor(input: { target: string; entry: string }) {
15
+ super({
16
+ code: 'X_BUILD_ENTRY_MISSING',
17
+ cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
18
+ fix: `x new scratch-app --dry-run --json # its file list carries ${input.entry}; copy that file into this app`,
19
+ });
20
+ }
21
+ }
22
+
23
+ /**
24
+ * A client entry would not compile. `X_BUILD_FAILED`, not a code of its own: an island is a bundle
25
+ * entry point like any other, and the target's own logs are what says which line. The fix builds
26
+ * exactly that one file, so the next message an author reads is the compiler's and not the CLI's.
27
+ */
28
+ export class IslandBuildFailedError extends UltimateError {
29
+ constructor(input: { file: string; logs: string }) {
30
+ super({
31
+ code: 'X_BUILD_FAILED',
32
+ cause: `${input.file} is an island entry point and would not bundle: ${input.logs}`,
33
+ fix: `bun build --target browser ${input.file}`,
34
+ });
35
+ }
36
+ }
37
+
38
+ /** Which framework script a page ships: the one sync worker, or the one page boot. */
39
+ export type FrameworkScriptKind = 'sync worker' | 'page boot';
40
+
41
+ /**
42
+ * One of the page's framework scripts would not bundle. Same code as an island: "a browser entry
43
+ * the framework builds did not build" is one condition, and the entry here is framework code, not
44
+ * the app's — the cause names WHICH script, so the reader knows what the page is now missing.
45
+ */
46
+ export class FrameworkScriptBuildFailedError extends UltimateError {
47
+ constructor(input: { what: FrameworkScriptKind; entry: string; logs: string }) {
48
+ super({
49
+ code: 'X_BUILD_FAILED',
50
+ cause: `the ${input.what} (${input.entry}) would not bundle for the browser: ${input.logs}`,
51
+ fix: `bun build --target browser --format iife ${quoteArg(input.entry)}`,
52
+ });
53
+ }
54
+ }
@@ -0,0 +1,77 @@
1
+ // The accessibility tree, read over the page's own CDP session: what the BROWSER computed for a
2
+ // screen reader, after ARIA, label association and `aria-hidden` pruning. A `<div onclick>`
3
+ // answers no role and no name, which is the finding — no read of the markup can reproduce it.
4
+ import type { AxNode } from './browser-launcher-port';
5
+
6
+ /** One session-scoped CDP call. Every answer is somebody else's JSON, read and never cast. */
7
+ export type SessionSend = (
8
+ method: string,
9
+ params?: Record<string, unknown>,
10
+ ) => Promise<Readonly<Record<string, unknown>> | undefined>;
11
+
12
+ const record = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
13
+ typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;
14
+
15
+ /** CDP's `AXValue.value` is any scalar; a reader is told text. */
16
+ const textOf = (wrapped: unknown): string | undefined => {
17
+ const value = record(wrapped)?.['value'];
18
+ return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
19
+ ? String(value)
20
+ : undefined;
21
+ };
22
+
23
+ const flagOf = (node: Readonly<Record<string, unknown>>, name: string): boolean | undefined => {
24
+ const properties = node['properties'];
25
+ if (!Array.isArray(properties)) return undefined;
26
+ const found = properties.map(record).find((property) => property?.['name'] === name);
27
+ return found === undefined ? undefined : record(found['value'])?.['value'] === true;
28
+ };
29
+
30
+ /**
31
+ * `nodes[0]` is the element's own node when `fetchRelatives` is false. An EMPTY list is a real
32
+ * answer — an element that never entered the tree — and is kept as ignored, so the count the
33
+ * caller gets is the count of matches.
34
+ */
35
+ export const toAxNode = (answer: Readonly<Record<string, unknown>> | undefined): AxNode => {
36
+ const nodes = answer?.['nodes'];
37
+ const node = Array.isArray(nodes) ? record(nodes[0]) : undefined;
38
+ if (node === undefined) return { role: '', name: '', ignored: true };
39
+ const description = textOf(node['description']);
40
+ const value = textOf(node['value']);
41
+ const focused = flagOf(node, 'focused');
42
+ const disabled = flagOf(node, 'disabled');
43
+ return {
44
+ role: textOf(node['role']) ?? '',
45
+ name: textOf(node['name']) ?? '',
46
+ ...(description === undefined ? {} : { description }),
47
+ ...(value === undefined ? {} : { value }),
48
+ ...(focused === undefined ? {} : { focused }),
49
+ ...(disabled === undefined ? {} : { disabled }),
50
+ ignored: node['ignored'] === true,
51
+ };
52
+ };
53
+
54
+ /** Every match of `selector`, in document order, at most `max` — one round trip per match. */
55
+ export async function axNodesFor(
56
+ send: SessionSend,
57
+ selector: string,
58
+ max: number,
59
+ ): Promise<readonly AxNode[]> {
60
+ const root = record(record((await send('DOM.getDocument', { depth: 0 }))?.['root']));
61
+ const nodeIds = (await send('DOM.querySelectorAll', { nodeId: root?.['nodeId'], selector }))?.[
62
+ 'nodeIds'
63
+ ];
64
+ const out: AxNode[] = [];
65
+ for (const nodeId of Array.isArray(nodeIds) ? nodeIds.slice(0, max) : []) {
66
+ const described = record((await send('DOM.describeNode', { nodeId }))?.['node']);
67
+ out.push(
68
+ toAxNode(
69
+ await send('Accessibility.getPartialAXTree', {
70
+ backendNodeId: described?.['backendNodeId'],
71
+ fetchRelatives: false,
72
+ }),
73
+ ),
74
+ );
75
+ }
76
+ return out;
77
+ }
@@ -0,0 +1,14 @@
1
+ // The clock a shot session runs on, and the page deadline a run uses when it names none. The
2
+ // system clock and `Bun.sleep`: a test hands `open()` its own `ShotClock`, where sleeping IS
3
+ // advancing, so a 30-second deadline under test finishes in microseconds.
4
+ import { systemClock } from '@ultimat3/core';
5
+ import type { ShotClock } from './browser-launcher-port';
6
+
7
+ /** A navigation or a wait with no `timeout` of its own gets this. */
8
+ export const DEFAULT_PAGE_TIMEOUT_MS = 30_000;
9
+
10
+ export const systemShotClock: ShotClock = Object.freeze({
11
+ now: () => systemClock.now(),
12
+ monotonic: () => systemClock.monotonic(),
13
+ sleep: (ms: number) => Bun.sleep(ms),
14
+ });
@@ -0,0 +1,150 @@
1
+ // `x shot`'s browser, over raw CDP and with no library: launch Chrome here on its debugging pipe
2
+ // (`@ultimat3/testing`'s launcher, the one the e2e step already runs on) or ATTACH to one somebody
3
+ // else runs over `--cdp-url`. One `open()` is one browser and one page, so a picture's console and
4
+ // network are its own. The page is `cdp-shot-page.ts`; this file is the session around it.
5
+ import type { CdpConnection, LaunchedBrowser } from '@ultimat3/testing';
6
+ import {
7
+ CdpBrowserMissingError,
8
+ CdpCallFailedError,
9
+ CHROME_CANDIDATES,
10
+ cdpConnect,
11
+ launchChrome,
12
+ } from '@ultimat3/testing';
13
+ import type { ShotDriver, ShotSession, ShotSessionInit } from './browser-launcher-port';
14
+ import { cdpShotPage } from './cdp-shot-page';
15
+ import { watchPage } from './cdp-shot-watch';
16
+
17
+ export const CDP_SHOT_DRIVER = 'cdp';
18
+
19
+ /** What a picture is laid out in when nothing declares a size — the size every earlier shot had. */
20
+ export const DEFAULT_SHOT_VIEWPORT = Object.freeze({ width: 800, height: 600 });
21
+
22
+ export interface CdpShotDriverOptions {
23
+ /** Launch this binary here. Ignored when `cdpUrl` is set. */
24
+ readonly executablePath?: string | undefined;
25
+ /** Attach here: a provider's `wss://` session, or a sidecar's `http://host:9222`. */
26
+ readonly cdpUrl?: string | undefined;
27
+ readonly viewport?: { readonly width: number; readonly height: number } | undefined;
28
+ /** Test seams: the two ways a connection comes to exist, so no test needs a browser. */
29
+ readonly launch?:
30
+ | ((executable: string, timeoutMs: number) => Promise<LaunchedBrowser>)
31
+ | undefined;
32
+ readonly connect?: ((endpoint: string, timeoutMs: number) => Promise<CdpConnection>) | undefined;
33
+ readonly fetchJson?: ((url: string) => Promise<unknown>) | undefined;
34
+ }
35
+
36
+ const field = (value: unknown, key: string): string | undefined => {
37
+ if (typeof value !== 'object' || value === null) return undefined;
38
+ const found = (value as Record<string, unknown>)[key];
39
+ return typeof found === 'string' ? found : undefined;
40
+ };
41
+
42
+ /**
43
+ * A sidecar publishes `http://host:9222`, and the socket is what `/json/version` names. A provider
44
+ * hands out the socket itself. Either way the answer is one `ws:`/`wss:` url.
45
+ */
46
+ async function socketUrl(
47
+ cdpUrl: string,
48
+ fetchJson: (url: string) => Promise<unknown>,
49
+ ): Promise<string> {
50
+ if (cdpUrl.startsWith('ws://') || cdpUrl.startsWith('wss://')) return cdpUrl;
51
+ const version = new URL('/json/version', cdpUrl).toString();
52
+ const socket = field(await fetchJson(version), 'webSocketDebuggerUrl');
53
+ if (socket === undefined) {
54
+ throw new CdpCallFailedError({
55
+ method: `GET ${version}`,
56
+ detail: 'the endpoint answered no webSocketDebuggerUrl',
57
+ });
58
+ }
59
+ return socket;
60
+ }
61
+
62
+ const defaultFetchJson = async (url: string): Promise<unknown> => (await fetch(url)).json();
63
+
64
+ export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
65
+ const viewport = options.viewport ?? DEFAULT_SHOT_VIEWPORT;
66
+ const launch =
67
+ options.launch ??
68
+ ((executable: string, timeoutMs: number) => launchChrome({ executable, timeoutMs }));
69
+ const connect =
70
+ options.connect ??
71
+ ((endpoint: string, timeoutMs: number) => cdpConnect({ endpoint, timeoutMs }));
72
+
73
+ /** The browser half of a session: its connection, and how to end it. */
74
+ const browser = async (
75
+ timeoutMs: number,
76
+ ): Promise<{ connection: CdpConnection; end: () => Promise<void> }> => {
77
+ if (options.cdpUrl !== undefined) {
78
+ const endpoint = await socketUrl(options.cdpUrl, options.fetchJson ?? defaultFetchJson);
79
+ const connection = await connect(endpoint, timeoutMs);
80
+ return {
81
+ connection,
82
+ // BOTH halves: a remote browser is somebody else's bill, and a close that only hung up
83
+ // would leave it running until the provider timed it out.
84
+ end: async () => {
85
+ await connection.send('Browser.close').catch(() => undefined);
86
+ connection.close();
87
+ },
88
+ };
89
+ }
90
+ if (options.executablePath === undefined) {
91
+ throw new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
92
+ }
93
+ const launched = await launch(options.executablePath, timeoutMs);
94
+ return { connection: launched.connection, end: async () => launched.close() };
95
+ };
96
+
97
+ return {
98
+ name: CDP_SHOT_DRIVER,
99
+ async open(init: ShotSessionInit): Promise<ShotSession> {
100
+ const { connection, end } = await browser(init.timeoutMs);
101
+ let closed = false;
102
+ let stopWatch: () => void = () => undefined;
103
+ const close = async (): Promise<void> => {
104
+ if (closed) return;
105
+ closed = true;
106
+ stopWatch();
107
+ await end().catch(() => undefined);
108
+ };
109
+ try {
110
+ const created = await connection.send('Target.createTarget', { url: 'about:blank' });
111
+ const targetId = field(created.result, 'targetId');
112
+ const attached = await connection.send('Target.attachToTarget', {
113
+ targetId,
114
+ flatten: true,
115
+ });
116
+ const sessionId = field(attached.result, 'sessionId');
117
+ if (targetId === undefined || sessionId === undefined) {
118
+ throw new CdpCallFailedError({
119
+ method: 'Target.attachToTarget',
120
+ detail: 'the browser opened a page and answered no target or session id',
121
+ });
122
+ }
123
+ // Watching BEFORE the domains are on, so the first request is already counted.
124
+ const watch = watchPage({
125
+ connection,
126
+ sessionId,
127
+ clock: init.clock,
128
+ allowHosts: init.rules.allowHosts,
129
+ });
130
+ stopWatch = () => watch.stop();
131
+ const on = (method: string, params: Record<string, unknown> = {}) =>
132
+ connection.send(method, params, sessionId);
133
+ await on('Runtime.enable');
134
+ await on('Page.enable');
135
+ await on('Network.enable');
136
+ await on('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
137
+ await on('Emulation.setDeviceMetricsOverride', {
138
+ width: viewport.width,
139
+ height: viewport.height,
140
+ deviceScaleFactor: 1,
141
+ mobile: false,
142
+ });
143
+ return { page: cdpShotPage({ connection, sessionId, init, watch }), close };
144
+ } catch (error) {
145
+ await close();
146
+ throw error;
147
+ }
148
+ },
149
+ };
150
+ }
@@ -0,0 +1,147 @@
1
+ // What "the element is ready" means for the raw-CDP shot page: the in-page snapshot of every match
2
+ // (layout box and hit-test included, which no DOM-only check can see), and the poll that waits for
3
+ // the first match to reach a state. Two refusals, because they are two questions: the page does
4
+ // not have the element, or it does and something keeps it from being acted on.
5
+ import type { ActionabilityState, ElementSnapshot, ShotClock } from './browser-launcher-port';
6
+ import { ShotElementMissingError, ShotElementUnreadyError } from './cdp-shot-errors';
7
+
8
+ /** JSON text, so the answer has one deserialiser: `parseSnapshots` below. */
9
+ export const snapshotExpression = (selector: string): string => `(() => {
10
+ const out = [];
11
+ for (const el of document.querySelectorAll(${JSON.stringify(selector)})) {
12
+ const style = getComputedStyle(el);
13
+ const rect = el.getBoundingClientRect();
14
+ const attrs = [];
15
+ for (const attribute of el.attributes) attrs.push([attribute.name, attribute.value]);
16
+ const top = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
17
+ out.push({
18
+ tag: el.tagName.toLowerCase(),
19
+ attrs,
20
+ text: (el.textContent || '').replace(/\\s+/g, ' ').trim(),
21
+ value: typeof el.value === 'string' ? el.value : '',
22
+ visible: style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0',
23
+ enabled: el.disabled !== true && el.getAttribute('aria-disabled') !== 'true',
24
+ box: { x: rect.left, y: rect.top, width: rect.width, height: rect.height },
25
+ hitTarget: top !== null && (top === el || el.contains(top)),
26
+ });
27
+ }
28
+ return JSON.stringify(out);
29
+ })()`;
30
+
31
+ const record = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
32
+ typeof value === 'object' && value !== null && !Array.isArray(value)
33
+ ? (value as Record<string, unknown>)
34
+ : undefined;
35
+
36
+ const str = (value: unknown): string => (typeof value === 'string' ? value : '');
37
+ const num = (value: unknown): number => (typeof value === 'number' ? value : 0);
38
+
39
+ /** Attribute pairs, rebuilt with `fromEntries` so a page's `constructor="…"` stays an own key. */
40
+ const attrsOf = (value: unknown): Readonly<Record<string, string>> =>
41
+ Object.fromEntries(
42
+ (Array.isArray(value) ? value : [])
43
+ .filter((pair): pair is [string, string] => Array.isArray(pair) && pair.length === 2)
44
+ .map(([name, attr]) => [str(name), str(attr)]),
45
+ );
46
+
47
+ /** The page's answer is `unknown` until this reads it. A row that is not an element is dropped. */
48
+ export function parseSnapshots(raw: unknown): readonly ElementSnapshot[] {
49
+ let rows: unknown;
50
+ try {
51
+ rows = typeof raw === 'string' ? JSON.parse(raw) : raw;
52
+ } catch {
53
+ return [];
54
+ }
55
+ if (!Array.isArray(rows)) return [];
56
+ const out: ElementSnapshot[] = [];
57
+ for (const row of rows) {
58
+ const el = record(row);
59
+ if (el === undefined || typeof el['tag'] !== 'string') continue;
60
+ const box = record(el['box']);
61
+ out.push({
62
+ tag: el['tag'],
63
+ attrs: attrsOf(el['attrs']),
64
+ text: str(el['text']),
65
+ value: str(el['value']),
66
+ visible: el['visible'] === true,
67
+ enabled: el['enabled'] === true,
68
+ box: {
69
+ x: num(box?.['x']),
70
+ y: num(box?.['y']),
71
+ width: num(box?.['width']),
72
+ height: num(box?.['height']),
73
+ },
74
+ hitTarget: el['hitTarget'] === true,
75
+ });
76
+ }
77
+ return out;
78
+ }
79
+
80
+ const sameBox = (a: ElementSnapshot, b: ElementSnapshot): boolean =>
81
+ a.box?.x === b.box?.x &&
82
+ a.box?.y === b.box?.y &&
83
+ a.box?.width === b.box?.width &&
84
+ a.box?.height === b.box?.height;
85
+
86
+ /** Why this snapshot is not yet at `state`, or `undefined` when it is. Still = two polls agree. */
87
+ export function unreadiness(
88
+ current: ElementSnapshot,
89
+ previous: ElementSnapshot | undefined,
90
+ state: ActionabilityState,
91
+ ): string | undefined {
92
+ if (state === 'attached') return undefined;
93
+ if (!current.visible) return 'not visible';
94
+ if (current.box !== undefined && (current.box.width === 0 || current.box.height === 0)) {
95
+ return 'a zero-sized box';
96
+ }
97
+ if (state === 'visible') return undefined;
98
+ if (!current.enabled) return 'disabled';
99
+ if (state === 'enabled') return undefined;
100
+ if (current.hitTarget === false) return 'covered by another element at its centre';
101
+ if (previous === undefined || !sameBox(current, previous)) return 'moving';
102
+ return undefined;
103
+ }
104
+
105
+ export const READY_POLL_MS = 50;
106
+
107
+ export interface ReadyWait {
108
+ readonly selector: string;
109
+ readonly state: ActionabilityState;
110
+ readonly timeoutMs: number;
111
+ readonly clock: ShotClock;
112
+ /** Re-read on every poll: never a handle captured before the loop. */
113
+ readonly snapshot: () => Promise<ElementSnapshot | undefined>;
114
+ readonly url: () => string;
115
+ }
116
+
117
+ /** Poll until the first match reaches `state`, or refuse with the reason it never did. */
118
+ export async function awaitReady(wait: ReadyWait): Promise<ElementSnapshot> {
119
+ const started = wait.clock.monotonic();
120
+ let previous: ElementSnapshot | undefined;
121
+ let problem: string | undefined;
122
+ let seen = false;
123
+ for (;;) {
124
+ const current = await wait.snapshot();
125
+ if (current !== undefined) {
126
+ seen = true;
127
+ problem = unreadiness(current, previous, wait.state);
128
+ if (problem === undefined) return current;
129
+ previous = current;
130
+ }
131
+ const left = wait.timeoutMs - (wait.clock.monotonic() - started);
132
+ if (!(left > 0)) break;
133
+ await wait.clock.sleep(Math.max(1, Math.min(READY_POLL_MS, left)));
134
+ }
135
+ if (!seen) {
136
+ throw new ShotElementMissingError({
137
+ selector: wait.selector,
138
+ url: wait.url(),
139
+ timeoutMs: wait.timeoutMs,
140
+ });
141
+ }
142
+ throw new ShotElementUnreadyError({
143
+ selector: wait.selector,
144
+ problem: problem ?? 'not ready',
145
+ timeoutMs: wait.timeoutMs,
146
+ });
147
+ }
@@ -0,0 +1,62 @@
1
+ // The four ways `x shot`'s raw-CDP page refuses a verb. A browser that is
2
+ // missing, dies or never answers is `@ultimat3/testing`'s `X_CDP_*` — the wire is the same one the
3
+ // e2e driver runs on. These four are what only a shot can be wrong about: which host it may open,
4
+ // which element it was pointed at, and which key it was asked to press. Their codes and titles are
5
+ // registered with every other CLI code, in `error-codes.ts` — never a second registration here.
6
+ import { renderCauseValue, UltimateError } from '@ultimat3/core';
7
+
8
+ /** A navigation to a host the run did not allow. Refused before a byte leaves. */
9
+ export class ShotHostRefusedError extends UltimateError {
10
+ constructor(input: { readonly url: string; readonly allowHosts: readonly string[] }) {
11
+ super({
12
+ code: 'X_SHOT_HOST_REFUSED',
13
+ cause: `${renderCauseValue(input.url)} is on no host in the allow list ${renderCauseValue(input.allowHosts)}`,
14
+ fix: 'x shot /route --allow-hosts cdn.example.com # name every extra host the page needs; the app host is always allowed',
15
+ meta: { allowHosts: [...input.allowHosts] },
16
+ });
17
+ }
18
+ }
19
+
20
+ /** Nothing ever matched: the markup changed, or the selector was never right. */
21
+ export class ShotElementMissingError extends UltimateError {
22
+ constructor(input: {
23
+ readonly selector: string;
24
+ readonly url: string;
25
+ readonly timeoutMs: number;
26
+ }) {
27
+ super({
28
+ code: 'X_SHOT_ELEMENT_MISSING',
29
+ cause: `nothing on ${renderCauseValue(input.url)} matched ${renderCauseValue(input.selector)} within ${String(input.timeoutMs)}ms`,
30
+ fix: 'run ui.inspect on the route first and copy a selector it reports with count >= 1',
31
+ meta: { selector: input.selector },
32
+ });
33
+ }
34
+ }
35
+
36
+ /** It matched, and something kept it from being acted on: hidden, disabled, covered, moving. */
37
+ export class ShotElementUnreadyError extends UltimateError {
38
+ constructor(input: {
39
+ readonly selector: string;
40
+ readonly problem: string;
41
+ readonly timeoutMs: number;
42
+ }) {
43
+ super({
44
+ code: 'X_SHOT_ELEMENT_UNREADY',
45
+ cause: `${renderCauseValue(input.selector)} was still ${renderCauseValue(input.problem)} after ${String(input.timeoutMs)}ms`,
46
+ fix: 'run ui.inspect with the selector and "a11y": true to see what covers or disables it, then act on that element first',
47
+ meta: { selector: input.selector, problem: input.problem },
48
+ });
49
+ }
50
+ }
51
+
52
+ /** A chord the grammar refuses — the caller's own literal, so the refusal is terminal. */
53
+ export class ShotKeyInvalidError extends UltimateError {
54
+ constructor(input: { readonly chord: string; readonly reason: string }) {
55
+ super({
56
+ code: 'X_SHOT_KEY_INVALID',
57
+ cause: `the key chord ${renderCauseValue(input.chord)} ${input.reason}`,
58
+ fix: "spell it as modifiers then one key, in the browser's names: 'Meta+K', 'Control+Enter', 'Shift+Tab', 'Escape'",
59
+ meta: { chord: input.chord },
60
+ });
61
+ }
62
+ }