@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,80 @@
1
+ // CSS selectors over an HTML string, on Bun's own `HTMLRewriter`: the `query()` the offline
2
+ // `fakeShotDriver` answers from markup. The selector subset is lol-html's — type, `#id`, `.class`,
3
+ // `[attr]`, `[attr=value]`, descendant and child combinators. No box and no hit-target on any
4
+ // answer: this has no layout engine, and a fabricated box would make a covered button clickable.
5
+ import type { ElementSnapshot } from './browser-launcher-port';
6
+
7
+ /** Elements with no end tag: `onEndTag` never fires for them, so they close on open. */
8
+ const VOID_TAGS = new Set([
9
+ 'area',
10
+ 'base',
11
+ 'br',
12
+ 'col',
13
+ 'embed',
14
+ 'hr',
15
+ 'img',
16
+ 'input',
17
+ 'link',
18
+ 'meta',
19
+ 'source',
20
+ 'track',
21
+ 'wbr',
22
+ ]);
23
+
24
+ const HIDDEN_STYLE = /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden)\s*(?:;|$)/i;
25
+
26
+ const visible = (tag: string, attrs: ReadonlyMap<string, string>): boolean =>
27
+ !attrs.has('hidden') &&
28
+ !(tag === 'input' && attrs.get('type')?.toLowerCase() === 'hidden') &&
29
+ !HIDDEN_STYLE.test(attrs.get('style') ?? '') &&
30
+ attrs.get('aria-hidden') !== 'true';
31
+
32
+ interface Open {
33
+ readonly tag: string;
34
+ readonly attrs: Map<string, string>;
35
+ readonly parts: string[];
36
+ }
37
+
38
+ const finish = (open: Open): ElementSnapshot => ({
39
+ tag: open.tag,
40
+ attrs: Object.fromEntries(open.attrs),
41
+ text: open.parts.join('').replaceAll(/\s+/g, ' ').trim(),
42
+ value: open.attrs.get('value') ?? '',
43
+ visible: visible(open.tag, open.attrs),
44
+ enabled: !open.attrs.has('disabled') && open.attrs.get('aria-disabled') !== 'true',
45
+ });
46
+
47
+ /** Every element matching `selector`, as the markup states it. A selector lol-html refuses throws. */
48
+ export async function queryHtml(
49
+ html: string,
50
+ selector: string,
51
+ ): Promise<readonly ElementSnapshot[]> {
52
+ const done: ElementSnapshot[] = [];
53
+ const open: Open[] = [];
54
+ const rewriter = new HTMLRewriter().on(selector, {
55
+ element(element): void {
56
+ const tag = element.tagName.toLowerCase();
57
+ const attrs = new Map<string, string>();
58
+ for (const [name, value] of element.attributes) attrs.set(name.toLowerCase(), value);
59
+ const record: Open = { tag, attrs, parts: [] };
60
+ if (VOID_TAGS.has(tag)) {
61
+ done.push(finish(record));
62
+ return;
63
+ }
64
+ open.push(record);
65
+ element.onEndTag(() => {
66
+ const index = open.lastIndexOf(record);
67
+ if (index !== -1) open.splice(index, 1);
68
+ done.push(finish(record));
69
+ });
70
+ },
71
+ text(chunk): void {
72
+ // Every OPEN match: `div, span` matches both, and both contain the text.
73
+ for (const record of open) record.parts.push(chunk.text);
74
+ },
75
+ });
76
+ await rewriter.transform(new Response(html)).text();
77
+ // Never closed is malformed markup; the element still matched, so it is still reported.
78
+ for (const record of open) done.push(finish(record));
79
+ return done;
80
+ }
@@ -0,0 +1,165 @@
1
+ // `fakeShotDriver`: a `ShotDriver` over recorded pages, so every `x shot` and `ui.*` test runs with
2
+ // no Chrome. It parses markup and runs none of it — `query()` is answered from the HTML,
3
+ // `evaluate()` from a recorded table, and a click follows `data-goto` or an `<a href>`. What it
4
+ // cannot know it refuses by name rather than inventing: no layout box, and no accessibility tree.
5
+ import { hostDecision, notImplemented } from '@ultimat3/core';
6
+ import { CdpCallFailedError } from '@ultimat3/testing';
7
+ import { queryHtml } from './browser-launcher-fake-html';
8
+ import type {
9
+ CaptureClip,
10
+ ElementSnapshot,
11
+ NetworkEntry,
12
+ ShotColorScheme,
13
+ ShotDriver,
14
+ ShotPage,
15
+ ShotSession,
16
+ ShotSessionInit,
17
+ } from './browser-launcher-port';
18
+ import { awaitReady } from './cdp-shot-element';
19
+ import { ShotHostRefusedError } from './cdp-shot-errors';
20
+ import { parseKeyChord } from './cdp-shot-keys';
21
+
22
+ export const FAKE_SHOT_DRIVER = 'fake';
23
+
24
+ /** One recorded page: its url, its markup, and `expression -> JSON text` for `evaluate()`. */
25
+ export interface FakeShotPage {
26
+ readonly url: string;
27
+ readonly html: string;
28
+ readonly evaluate?: Readonly<Record<string, string>> | undefined;
29
+ }
30
+
31
+ /** A PNG signature, and nothing behind it: deterministic bytes, never a render. */
32
+ export const FAKE_PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
33
+
34
+ /**
35
+ * Different deterministic bytes for every framing fact that was set — one per rectangle, one per
36
+ * colour preference — so a caller that dropped the clip or the scheme fails a test here, which is
37
+ * the only place a framing knob can be proved without a browser (issue #338).
38
+ */
39
+ export const framedPng = (clip: CaptureClip | undefined, scheme: ShotColorScheme): Uint8Array => {
40
+ const notes =
41
+ (clip === undefined
42
+ ? ''
43
+ : ` clip ${String(clip.x)},${String(clip.y)},${String(clip.width)},${String(clip.height)}`) +
44
+ (scheme === 'no-preference' ? '' : ` scheme ${scheme}`);
45
+ if (notes === '') return FAKE_PNG;
46
+ const suffix = new TextEncoder().encode(notes);
47
+ const out = new Uint8Array(FAKE_PNG.length + suffix.length);
48
+ out.set(FAKE_PNG);
49
+ out.set(suffix, FAKE_PNG.length);
50
+ return out;
51
+ };
52
+
53
+ const normalise = (url: string): string => {
54
+ try {
55
+ return new URL(url).toString();
56
+ } catch {
57
+ return url;
58
+ }
59
+ };
60
+
61
+ function fakePage(byUrl: ReadonlyMap<string, FakeShotPage>, init: ShotSessionInit): ShotPage {
62
+ let current: FakeShotPage | undefined;
63
+ let scheme: ShotColorScheme = 'no-preference';
64
+ const network: NetworkEntry[] = [];
65
+ const url = (): string => current?.url ?? 'about:blank';
66
+
67
+ const navigate = (to: string): void => {
68
+ const page = byUrl.get(normalise(to));
69
+ if (page === undefined) {
70
+ throw new CdpCallFailedError({
71
+ method: `Page.navigate to ${to}`,
72
+ detail: 'fakeShotDriver() has no page recorded at that url',
73
+ });
74
+ }
75
+ current = page;
76
+ network.push({
77
+ method: 'GET',
78
+ url: page.url,
79
+ status: 200,
80
+ resourceType: 'document',
81
+ at: init.clock.now().getTime(),
82
+ });
83
+ };
84
+
85
+ const query = (selector: string): Promise<readonly ElementSnapshot[]> =>
86
+ queryHtml(current?.html ?? '', selector);
87
+
88
+ const ready = (selector: string, options?: Parameters<ShotPage['waitFor']>[1]) =>
89
+ awaitReady({
90
+ selector,
91
+ state: options?.state ?? 'actionable',
92
+ timeoutMs: options?.timeout ?? init.timeoutMs,
93
+ clock: init.clock,
94
+ url,
95
+ snapshot: async () => (await query(selector))[0],
96
+ });
97
+
98
+ return {
99
+ url,
100
+ async goto(to) {
101
+ if (!hostDecision(to, init.rules.allowHosts).allowed) {
102
+ throw new ShotHostRefusedError({ url: to, allowHosts: init.rules.allowHosts });
103
+ }
104
+ navigate(to);
105
+ },
106
+ waitFor: (selector, options) => ready(selector, options),
107
+ async click(selector, options) {
108
+ const target = await ready(selector, options);
109
+ // What the browser would do with the click, as far as markup can say: follow the link.
110
+ const next =
111
+ target.attrs['data-goto'] ?? (target.tag === 'a' ? target.attrs['href'] : undefined);
112
+ if (next !== undefined) navigate(new URL(next, url()).toString());
113
+ },
114
+ async type(selector, _text, options) {
115
+ await ready(selector, options);
116
+ },
117
+ async focus(selector, options) {
118
+ await ready(selector, options);
119
+ },
120
+ async press(chord) {
121
+ parseKeyChord(chord);
122
+ },
123
+ async accessibility() {
124
+ return notImplemented(
125
+ 'accessibility() on fakeShotDriver()',
126
+ 'assert on the markup with page.query(selector) — a role read off the HTML would hide the element a screen reader cannot name, which is the finding this read exists for',
127
+ );
128
+ },
129
+ query,
130
+ async evaluate(expression) {
131
+ const table = current?.evaluate ?? {};
132
+ const recorded = Object.hasOwn(table, expression) ? table[expression] : undefined;
133
+ if (recorded === undefined) {
134
+ throw new CdpCallFailedError({
135
+ method: 'Runtime.evaluate',
136
+ detail: 'fakeShotDriver() has no answer recorded for that expression on this page',
137
+ });
138
+ }
139
+ return JSON.parse(recorded) as unknown;
140
+ },
141
+ async screenshot(options) {
142
+ return framedPng(options?.clip, scheme);
143
+ },
144
+ async colorScheme(next) {
145
+ scheme = next;
146
+ },
147
+ async prepare() {},
148
+ console: () => [],
149
+ pageErrors: () => [],
150
+ pageErrorsDropped: () => 0,
151
+ network: () => [...network],
152
+ networkDropped: () => 0,
153
+ };
154
+ }
155
+
156
+ /** A driver whose every session reads from `pages`. An unrecorded url is refused, never fetched. */
157
+ export function fakeShotDriver(pages: readonly FakeShotPage[]): ShotDriver {
158
+ const byUrl = new Map(pages.map((page) => [normalise(page.url), page]));
159
+ return {
160
+ name: FAKE_SHOT_DRIVER,
161
+ async open(init): Promise<ShotSession> {
162
+ return { page: fakePage(byUrl, init), close: async () => undefined };
163
+ },
164
+ };
165
+ }
@@ -0,0 +1,159 @@
1
+ // The browser surface `x shot` and the dev MCP server's `ui.*` tools drive, declared here and
2
+ // nowhere else. `cdp-shot-driver.ts` implements it over raw CDP; a test hands in a fake. Every
3
+ // member is one a shot or a `ui.*` call reads. A member nothing reads is one the raw driver would
4
+ // have to build and nothing would test, and that is how a port rots.
5
+ import type { Clock } from '@ultimat3/core';
6
+
7
+ /** What the browser is told the OS prefers. `'no-preference'` CLEARS the override. */
8
+ export type ShotColorScheme = 'light' | 'dark' | 'no-preference';
9
+
10
+ /** CSS pixels, top-left origin: the space `getBoundingClientRect()` answers in. */
11
+ export interface CaptureClip {
12
+ readonly x: number;
13
+ readonly y: number;
14
+ readonly width: number;
15
+ readonly height: number;
16
+ }
17
+
18
+ /** `fullPage`, or a `clip` — never both. */
19
+ export interface ShotCapture {
20
+ readonly fullPage?: boolean | undefined;
21
+ readonly clip?: CaptureClip | undefined;
22
+ }
23
+
24
+ export interface ConsoleLine {
25
+ readonly level: 'log' | 'info' | 'warn' | 'error' | 'debug';
26
+ readonly text: string;
27
+ readonly at: number;
28
+ }
29
+
30
+ /** An uncaught exception the page threw. Not a `ConsoleLine`: a throw calls no console method. */
31
+ export interface PageError {
32
+ readonly message: string;
33
+ /** Absent when the exception carried no stack, never `''`. */
34
+ readonly stack?: string | undefined;
35
+ readonly at: number;
36
+ }
37
+
38
+ export type ShotResourceType =
39
+ | 'document'
40
+ | 'stylesheet'
41
+ | 'image'
42
+ | 'media'
43
+ | 'font'
44
+ | 'script'
45
+ | 'xhr'
46
+ | 'fetch'
47
+ | 'websocket'
48
+ | 'other';
49
+
50
+ export interface NetworkEntry {
51
+ readonly method: string;
52
+ readonly url: string;
53
+ /** Absent while in flight, or when the request was refused. */
54
+ readonly status?: number | undefined;
55
+ readonly resourceType: ShotResourceType;
56
+ readonly at: number;
57
+ /** `host`: the allow list refused it. The other two are what a scraping driver can also say. */
58
+ readonly refused?: 'blocked' | 'host' | 'robots' | undefined;
59
+ }
60
+
61
+ /** What a screen reader is told about one element — the browser's computed role and name. */
62
+ export interface AxNode {
63
+ readonly role: string;
64
+ readonly name: string;
65
+ readonly description?: string | undefined;
66
+ readonly value?: string | undefined;
67
+ readonly focused?: boolean | undefined;
68
+ readonly disabled?: boolean | undefined;
69
+ readonly ignored: boolean;
70
+ }
71
+
72
+ export interface ElementBox {
73
+ readonly x: number;
74
+ readonly y: number;
75
+ readonly width: number;
76
+ readonly height: number;
77
+ }
78
+
79
+ /** One element as of one observation — a value, never a live handle. */
80
+ export interface ElementSnapshot {
81
+ readonly tag: string;
82
+ readonly attrs: Readonly<Record<string, string>>;
83
+ readonly text: string;
84
+ readonly value: string;
85
+ readonly visible: boolean;
86
+ readonly enabled: boolean;
87
+ /** Absent on a driver with no layout engine; never a fabricated zero box. */
88
+ readonly box?: ElementBox | undefined;
89
+ /** Whether a click at the element's centre lands on it. Absent: no layout. */
90
+ readonly hitTarget?: boolean | undefined;
91
+ }
92
+
93
+ /** Each level implies the ones before it. */
94
+ export type ActionabilityState = 'attached' | 'visible' | 'enabled' | 'actionable';
95
+
96
+ export interface WaitOptions {
97
+ readonly state?: ActionabilityState | undefined;
98
+ /** Milliseconds. Falls back to the session's `timeoutMs`. */
99
+ readonly timeout?: number | undefined;
100
+ }
101
+
102
+ export interface AccessibilityOptions {
103
+ readonly max?: number | undefined;
104
+ }
105
+
106
+ /** Every wait goes through this, so a test of a 30-second deadline finishes in microseconds. */
107
+ export interface ShotClock extends Clock {
108
+ sleep(ms: number, signal?: AbortSignal): Promise<void>;
109
+ }
110
+
111
+ export interface ShotPage {
112
+ url(): string;
113
+ /** Refused before a byte leaves when the host is outside the session's `allowHosts`. */
114
+ goto(url: string, options?: { readonly timeout?: number | undefined }): Promise<void>;
115
+ /** Blocks until the first match reaches `state` (default `actionable`), then answers it. */
116
+ waitFor(selector: string, options?: WaitOptions): Promise<ElementSnapshot>;
117
+ click(selector: string, options?: WaitOptions): Promise<void>;
118
+ /** Appends. */
119
+ type(selector: string, text: string, options?: WaitOptions): Promise<void>;
120
+ focus(selector: string, options?: WaitOptions): Promise<void>;
121
+ /** `'Meta+K'`, `'Escape'`, `'Shift+Tab'` — on whatever holds focus. */
122
+ press(chord: string): Promise<void>;
123
+ accessibility(selector: string, options?: AccessibilityOptions): Promise<readonly AxNode[]>;
124
+ query(selector: string): Promise<readonly ElementSnapshot[]>;
125
+ /** The expression's result, `unknown` — parse it, never cast it. */
126
+ evaluate(expression: string): Promise<unknown>;
127
+ /** PNG bytes. */
128
+ screenshot(options?: ShotCapture): Promise<Uint8Array>;
129
+ colorScheme(scheme: ShotColorScheme): Promise<void>;
130
+ /** Runs in every document this page navigates to, before the document's own scripts. */
131
+ prepare(expression: string): Promise<void>;
132
+ /** The bounded tails, and how many entries each bound threw away. */
133
+ console(): readonly ConsoleLine[];
134
+ pageErrors(): readonly PageError[];
135
+ pageErrorsDropped(): number;
136
+ network(): readonly NetworkEntry[];
137
+ networkDropped(): number;
138
+ }
139
+
140
+ export interface ShotSessionInit {
141
+ /** Every error cause raised inside the session carries it. */
142
+ readonly name: string;
143
+ /** Never `*` from a shot: a headless browser inside your network is an SSRF surface. */
144
+ readonly rules: { readonly allowHosts: readonly string[] };
145
+ readonly clock: ShotClock;
146
+ /** Per-operation default, in ms. */
147
+ readonly timeoutMs: number;
148
+ }
149
+
150
+ export interface ShotSession {
151
+ readonly page: ShotPage;
152
+ /** Idempotent and never throws: it runs in a `finally`, beside the run's real failure. */
153
+ close(): Promise<void>;
154
+ }
155
+
156
+ export interface ShotDriver {
157
+ readonly name: string;
158
+ open(init: ShotSessionInit): Promise<ShotSession>;
159
+ }
@@ -1,26 +1,14 @@
1
- // The app's own browser library, resolved from the app's own `node_modules` — never a dependency
2
- // of this package. `@ultimat3/scraping` declares the launcher's shape structurally (`cdp-port.ts`)
3
- // precisely so the framework can drive a browser without shipping one, and `x shot` is a CLI
4
- // command holding to the same bargain: the app installs `puppeteer-core`, the CLI asks for it.
5
- //
6
- // Two ways to get a browser, and the second is the one production uses. `localBrowser()` starts
7
- // Chrome in this container; `remoteBrowser({ cdpUrl })` ATTACHES to one somebody else is running,
8
- // which is what every stealth provider sells — a session created over their API answers with a
9
- // `wss://` CDP endpoint and a real, unfingerprintable Chromium behind it. `driver-cdp.ts` has
10
- // called attach its primary path since it shipped, and until now no CLI command could reach it.
1
+ // Which browser `x shot` drives, and the raw-CDP driver over it (`cdp-shot-driver.ts`). No browser
2
+ // library, in the app or here: Chrome is launched on its debugging pipe by `@ultimat3/testing`'s
3
+ // launcher — the one `x verify`'s e2e step already runs on — or ATTACHED to over `--cdp-url`,
4
+ // which is what every stealth provider sells. Until 22.0.0 this resolved the APP's `puppeteer-core`,
5
+ // so `x shot` needed an install the framework could not make, and two launchers drifted.
11
6
 
12
7
  import { existsSync } from 'node:fs';
13
8
  import { UltimateError } from '@ultimat3/core';
14
- import type { CdpLauncherLike, ScrapeDriver } from '@ultimat3/scraping';
15
- import { localBrowser, remoteBrowser } from '@ultimat3/scraping';
16
- import { CHROME_CANDIDATES, CONTAINER_CHROME_ARGS } from './cdp-launch';
17
-
18
- /**
19
- * The one library this works against. Playwright is not an alternative and is not a flag:
20
- * `packages/scraping/src/cdp-port.ts` records that its `connectOverCDP` cannot perform the
21
- * WebSocket upgrade under Bun (oven-sh/bun#9911), verified against puppeteer-core 25.8.0.
22
- */
23
- export const BROWSER_PACKAGE = 'puppeteer-core';
9
+ import { CHROME_CANDIDATES } from '@ultimat3/testing';
10
+ import type { ShotDriver } from './browser-launcher-port';
11
+ import { cdpShotDriver } from './cdp-shot-driver';
24
12
 
25
13
  /** Where a browser binary is named when the flag does not name one. Read in this order. */
26
14
  export const BROWSER_PATH_VARS = ['PUPPETEER_EXECUTABLE_PATH', 'CHROME_PATH'] as const;
@@ -37,33 +25,7 @@ export const BROWSER_CDP_URL_VAR = 'SCRAPE_CDP_URL';
37
25
  const CDP_SCHEMES = ['ws:', 'wss:', 'http:', 'https:'] as const;
38
26
 
39
27
  /**
40
- * A missing browser is an instruction, not a crash (axiom 4). The cause distinguishes the two
41
- * shapes — nothing resolved, or something resolved that is not a launcher — while the fix is the
42
- * same install either way, because both are answered by putting the real package in the app.
43
- */
44
- export class ShotBrowserMissingError extends UltimateError {
45
- constructor(input: { root: string; detail: string }) {
46
- super({
47
- code: 'X_SHOT_BROWSER_MISSING',
48
- // The same install either way: attaching over CDP needs no Chrome on this box, but it still
49
- // needs a client that speaks the protocol, and `puppeteer-core` is that client.
50
- cause: `x shot drives a real browser and ${BROWSER_PACKAGE} ${input.detail} from ${input.root}`,
51
- // One literal, not `bun add -d ${BROWSER_PACKAGE}`: `fix-scan.ts` can only read a fix that IS
52
- // one literal, and a fix line the gate cannot read is a fix line nothing holds to the
53
- // contract. `browser-launcher.test.ts` pins it against the constant instead.
54
- fix: 'bun add -d puppeteer-core',
55
- meta: { root: input.root, package: BROWSER_PACKAGE },
56
- });
57
- }
58
- }
59
-
60
- /**
61
- * The OTHER half of "no browser": the library is installed and there is no Chrome for it to start.
62
- *
63
- * The same code as the class above because it is the same question to a reader — `x shot` has no
64
- * browser — and a code is a stable public name, not a taxonomy. The cause and the fix are what
65
- * differ, and they are the half that is acted on: `bun add -d puppeteer-core` cannot install
66
- * Chrome, and `export CHROME_PATH=…` cannot install a client that speaks CDP.
28
+ * `x shot` has no browser: none was named and none of the probed paths is on disk.
67
29
  *
68
30
  * Raised BEFORE the dev server, which is the whole point of it: the launch that used to report this
69
31
  * happens inside `driver.open()`, one embedded Postgres past the point where the answer was already
@@ -74,8 +36,7 @@ export class ShotChromeMissingError extends UltimateError {
74
36
  super({
75
37
  code: 'X_SHOT_BROWSER_MISSING',
76
38
  cause: `x shot launches a browser here and none was named or found — no ${BROWSER_PATH_VARS.join(' or ')} is set, and none of ${CHROME_CANDIDATES.join(', ')} is on disk`,
77
- // One literal, for `fix-scan.ts`'s reason — the same rule the class above states. It names an
78
- // export rather than an install because a path is the one repair that works whatever the
39
+ // One literal, so `fix-scan.ts` can read it. A path is the one repair that works whatever the
79
40
  // browser is and wherever the distribution put it; `--cdp-url` is the answer for a box that
80
41
  // will never have one, and it is a flag `x shot` already ships.
81
42
  fix: 'export CHROME_PATH=/usr/bin/google-chrome # any Chrome or Chromium binary; a box that will never have one attaches instead: x shot / --cdp-url wss://cdp.example.com/session/abc',
@@ -84,47 +45,20 @@ export class ShotChromeMissingError extends UltimateError {
84
45
  }
85
46
  }
86
47
 
87
- /**
88
- * Structural, because this is somebody else's module: a namespace object, a CJS `default`, or a
89
- * transpiled interop wrapper are all shapes `import()` legitimately hands back, and only one
90
- * question decides — is the method this run needs there to call?
91
- *
92
- * WHICH method is the run's, not this function's. `cdp-port.ts` declares `launch` and `connect`
93
- * both optional precisely so an attach-only provider SDK satisfies the port, and asking for
94
- * `launch` when the run is going to `connect` would refuse exactly the library that works.
95
- */
96
- const launcherIn = (module: unknown, method: 'launch' | 'connect'): CdpLauncherLike | undefined => {
97
- if (typeof module !== 'object' || module === null) return undefined;
98
- const candidate = module as Record<string, unknown>;
99
- if (typeof candidate[method] === 'function') return candidate as CdpLauncherLike;
100
- // `module.exports.default = module.exports` is a real CJS interop shape, so the self-reference is
101
- // refused rather than followed: one unbounded recursion here is a stack overflow instead of the
102
- // instruction this whole function exists to produce.
103
- const inner = candidate['default'];
104
- if (inner === undefined || inner === candidate) return undefined;
105
- return launcherIn(inner, method);
106
- };
107
-
108
48
  export interface AppBrowserOptions {
109
- readonly root: string;
110
- /** `--browser`, then `PUPPETEER_EXECUTABLE_PATH`, then `CHROME_PATH`. */
49
+ /** `--browser`, then `PUPPETEER_EXECUTABLE_PATH`, then `CHROME_PATH`, then the probe. */
111
50
  readonly executablePath?: string | undefined;
112
51
  /**
113
52
  * A CDP endpoint to ATTACH to. When it is set nothing is launched here and `executablePath` is
114
- * not read: the browser is somebody else's, and closing it ends their session too — which
115
- * `remoteBrowser()` does deliberately, so a provider stops billing for a run that ended.
53
+ * not read: the browser is somebody else's, and closing it ends their session too — deliberately,
54
+ * so a provider stops billing for a run that ended.
116
55
  */
117
56
  readonly cdpUrl?: string | undefined;
118
57
  /**
119
- * The page size this browser lays out at. A LAUNCH option and not a per-capture one, because
120
- * that is the only place the shipped port has for it: `CaptureRequest` is `fullPage` alone
121
- * (`packages/scraping/src/page.ts`), so the viewport IS the frame of every picture taken with
122
- * this driver — which is why `x shot --island` builds one browser per declared viewport.
58
+ * The page size this browser lays out at — the frame of every viewport picture, which is why
59
+ * `x shot --island` builds one browser per declared viewport.
123
60
  */
124
61
  readonly viewport?: { readonly width: number; readonly height: number } | undefined;
125
- /** Test seam: the resolver and the loader, so a test proves the refusal without an install. */
126
- readonly resolve?: (specifier: string, from: string) => string;
127
- readonly load?: (path: string) => Promise<unknown>;
128
62
  }
129
63
 
130
64
  /** True when a named executable is really there — a bad `--browser` is refused before a boot. */
@@ -133,12 +67,9 @@ export const browserBinaryExists = (path: string): boolean => existsSync(path);
133
67
  /**
134
68
  * The path a run will launch, or `undefined` when this machine has no browser to launch.
135
69
  *
136
- * NOT "let the library find its own": `puppeteer-core` ships no browser and has no default, so a
137
- * launch with no `executablePath` throws ``An `executablePath` or `channel` must be specified``
138
- * from inside somebody else's library, one dev-server boot after the point where the answer was
139
- * knowable. So the last step is a PROBE — the same four paths `cdp-launch.ts` already tries for the
140
- * e2e driver, imported rather than restated, because two lists of Chrome locations that must agree
141
- * is the drift axiom 2 refuses.
70
+ * The last step is a PROBE — the four paths `cdp-launch.ts` tries for the e2e driver, imported
71
+ * rather than restated, because two lists of Chrome locations that must agree is the drift axiom 2
72
+ * refuses — so a box with no browser is refused before a dev-server boot, not one launch later.
142
73
  *
143
74
  * A value NAMED in the flag or the environment is answered without touching the filesystem, even
144
75
  * when nothing is there: an operator who typed a path has a belief about which binary runs, and
@@ -192,54 +123,16 @@ export const cdpUrlProblem = (url: string): string | undefined => {
192
123
  };
193
124
 
194
125
  /**
195
- * The app's `puppeteer-core`, as a `ScrapeDriver`. Resolved FROM THE APP ROOT rather than from
196
- * this module: `import('puppeteer-core')` here would find the CLI's own tree, which by design has
197
- * no such dependency, and would answer "missing" for an app that installed it correctly.
126
+ * The driver a run photographs with. Refused HERE when there is nothing to launch and nothing to
127
+ * attach to, so a missing browser costs no dev-server boot.
198
128
  */
199
- export async function appBrowser(options: AppBrowserOptions): Promise<ScrapeDriver> {
200
- const resolve = options.resolve ?? ((specifier, from) => Bun.resolveSync(specifier, from));
201
- const load = options.load ?? ((path: string) => import(path) as Promise<unknown>);
202
- const method = options.cdpUrl === undefined ? 'launch' : 'connect';
203
- let entry: string;
204
- try {
205
- entry = resolve(BROWSER_PACKAGE, options.root);
206
- } catch {
207
- throw new ShotBrowserMissingError({ root: options.root, detail: 'does not resolve' });
208
- }
209
- const launcher = launcherIn(await load(entry), method);
210
- if (launcher === undefined) {
211
- throw new ShotBrowserMissingError({
212
- root: options.root,
213
- detail: `resolved to ${entry}, which exports no ${method}()`,
214
- });
215
- }
216
- if (options.cdpUrl !== undefined) {
217
- return remoteBrowser({
218
- launcher,
219
- cdpUrl: options.cdpUrl,
220
- // The viewport reaches `connect()` the same way it reaches `launch()` — through the
221
- // pass-through slot — so `x shot --island`'s one-browser-per-viewport loop is unchanged by
222
- // which half of the port a run took.
223
- ...(options.viewport === undefined
224
- ? {}
225
- : { options: { defaultViewport: { ...options.viewport } } }),
226
- });
129
+ export async function appBrowser(options: AppBrowserOptions): Promise<ShotDriver> {
130
+ if (options.cdpUrl === undefined && options.executablePath === undefined) {
131
+ throw new ShotChromeMissingError();
227
132
  }
228
- return localBrowser({
229
- launcher,
230
- headless: true,
133
+ return cdpShotDriver({
134
+ ...(options.cdpUrl === undefined ? {} : { cdpUrl: options.cdpUrl }),
231
135
  ...(options.executablePath === undefined ? {} : { executablePath: options.executablePath }),
232
- // `LocalBrowserOptions.options` is passed through to `launch()` untouched, which is the seam
233
- // that lets the CLI size a browser without `@ultimat3/scraping` naming a puppeteer type.
234
- //
235
- // `args` carries `CONTAINER_CHROME_ARGS` on EVERY local launch, viewport or not — the same
236
- // `--no-sandbox` / `--disable-dev-shm-usage` `cdp-launch.ts`'s e2e driver already needed for
237
- // this container, read from the one export rather than restated. Before this, `x shot` was the
238
- // only browser-launching command in the tree with neither, so a box where the e2e gate ran
239
- // green could not run `x shot` at all — Chrome exits "No usable sandbox".
240
- options: {
241
- args: [...CONTAINER_CHROME_ARGS],
242
- ...(options.viewport === undefined ? {} : { defaultViewport: { ...options.viewport } }),
243
- },
136
+ ...(options.viewport === undefined ? {} : { viewport: options.viewport }),
244
137
  });
245
138
  }