@ultimat3/cli 21.0.0 → 22.1.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 (236) 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 +23 -17
  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} +22 -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/sync-url.ts +21 -1
  178. package/src/templates/action.ts +30 -16
  179. package/src/templates/entity.ts +12 -7
  180. package/src/templates/index.ts +1 -1
  181. package/src/templates/job.ts +10 -7
  182. package/src/templates/policy.ts +20 -2
  183. package/src/templates/resource-create.ts +127 -0
  184. package/src/templates/resource-form-island.ts +49 -12
  185. package/src/templates/resource.ts +10 -4
  186. package/src/templates/scaffold-app.ts +4 -1
  187. package/src/templates/scaffold-auth.ts +3 -1
  188. package/src/templates/scaffold-container-compose.ts +184 -0
  189. package/src/templates/scaffold-container.ts +24 -159
  190. package/src/templates/scaffold-dashboard-example.ts +2 -2
  191. package/src/templates/scaffold-db-package.ts +16 -3
  192. package/src/templates/scaffold-demo-org.ts +41 -0
  193. package/src/templates/scaffold-entries.ts +1 -1
  194. package/src/templates/scaffold-env.ts +6 -0
  195. package/src/templates/scaffold-helm-templates.ts +66 -7
  196. package/src/templates/scaffold-helm.ts +27 -5
  197. package/src/templates/scaffold-i18n.ts +15 -10
  198. package/src/templates/scaffold-repo.ts +13 -10
  199. package/src/templates/scaffold-roles.ts +38 -10
  200. package/src/templates/slice-foundation.ts +1 -1
  201. package/src/templates/wrap.ts +4 -1
  202. package/src/test-passes.ts +2 -1
  203. package/src/test-workers.ts +26 -0
  204. package/src/ts-scan.ts +3 -6
  205. package/src/tsconfig-references.ts +1 -2
  206. package/src/verify-checks.ts +41 -39
  207. package/src/verify-e2e.ts +10 -7
  208. package/src/verify-run.ts +11 -1
  209. package/src/verify-step.ts +3 -3
  210. package/src/verify-tests.ts +1 -26
  211. package/src/verify-typecheck.ts +28 -0
  212. package/src/web-binding.ts +2 -2
  213. package/src/workspace-graph.ts +10 -33
  214. package/src/cdp-browser.ts +0 -94
  215. package/src/cdp-connection.ts +0 -247
  216. package/src/cdp-e2e-page.ts +0 -180
  217. package/src/cdp-e2e-session.ts +0 -199
  218. package/src/cdp-errors.ts +0 -56
  219. package/src/cdp-launch.ts +0 -193
  220. package/src/cdp-offline-script.ts +0 -73
  221. package/src/cdp-pipe.ts +0 -77
  222. package/src/e2e-app.ts +0 -103
  223. package/src/e2e-browser-handle.ts +0 -55
  224. package/src/e2e-dom-fixture.ts +0 -117
  225. package/src/e2e-driver.ts +0 -117
  226. package/src/e2e-errors.ts +0 -117
  227. package/src/e2e-evaluate.ts +0 -156
  228. package/src/e2e-locator.ts +0 -86
  229. package/src/e2e-page.ts +0 -153
  230. package/src/e2e-preload.ts +0 -64
  231. package/src/e2e-probe.ts +0 -23
  232. package/src/e2e-selection.ts +0 -182
  233. package/src/e2e-spawn.ts +0 -169
  234. package/src/measurement-actor.ts +0 -26
  235. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
  236. /package/src/{dev-render.ts → runtime-render.ts} +0 -0
@@ -1,247 +0,0 @@
1
- // One responsibility: a Chrome DevTools Protocol connection — request framing, response
2
- // correlation, and the per-call deadline — over a TRANSPORT that moves whole messages. Two exist:
3
- // the pipe a launched Chrome is driven over (`cdp-pipe.ts`, what the e2e driver uses) and a remote
4
- // browser's WebSocket (`cdpConnect` below). Launching is `cdp-launch.ts`; the page surface is
5
- // `cdp-e2e-page.ts`; this file knows nothing about either.
6
- //
7
- // **No library, and that is the point rather than an economy.** `packages/scraping/src/cdp-port.ts`
8
- // declares a ~25-method port because `ScrapePage` is a full scraping surface, and its intended
9
- // implementation is `puppeteer-core`. `E2eBrowserPage` is FIVE methods, and CDP's wire format is
10
- // one JSON object with an `id` — so the whole thing an e2e driver needs is this file plus two
11
- // small ones, on Bun's native `WebSocket`, with no dependency to add to a repo whose first
12
- // non-negotiable is that Bun's natives replace most of them.
13
-
14
- import { assert } from '@ultimat3/core';
15
- import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
16
-
17
- /** One CDP result. `unknown` because every payload here is somebody else's JSON. */
18
- export interface CdpResult {
19
- readonly result?: Record<string, unknown> | undefined;
20
- }
21
-
22
- export interface CdpConnection {
23
- /** Send one command. `sessionId` targets an attached page rather than the browser itself. */
24
- send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<CdpResult>;
25
- /**
26
- * Wait for the next occurrence of one CDP **event**, or for the deadline. Answers `true` when the
27
- * event arrived and `false` when it did not — it never throws, because every caller has a better
28
- * assertion to fail on than "the event was late".
29
- *
30
- * It exists because a command's reply is not always the signal. `Page.navigate`'s reply is
31
- * DROPPED whenever the navigation swaps the render process — measured on Chrome 150: the page
32
- * loads, the server is hit, a later `Runtime.evaluate` answers, and the navigate frame never
33
- * comes back at all. A driver that treated the reply as the completion signal waits out its full
34
- * deadline on the most ordinary navigation there is.
35
- */
36
- once(method: string, sessionId: string | undefined, timeoutMs: number): Promise<boolean>;
37
- /**
38
- * Subscribe to every occurrence of one CDP event, until the returned function is called.
39
- *
40
- * `once` cannot express what this is for: a target that attaches AFTER the driver stopped
41
- * listening is a service worker whose network conditions nobody set, which is an `offline()`
42
- * that silently does nothing to the one thing serving the page.
43
- */
44
- on(method: string, listener: (params: Record<string, unknown>) => void): () => void;
45
- close(): void;
46
- }
47
-
48
- interface Pending {
49
- readonly resolve: (value: CdpResult) => void;
50
- readonly reject: (reason: Error) => void;
51
- readonly timer: ReturnType<typeof setTimeout>;
52
- }
53
-
54
- /** A CDP error frame: `{ error: { code, message } }`, both fields somebody else's. */
55
- const errorText = (frame: Record<string, unknown>): string | undefined => {
56
- const error = frame['error'];
57
- if (typeof error !== 'object' || error === null) return undefined;
58
- const message = (error as Record<string, unknown>)['message'];
59
- return typeof message === 'string' ? message : 'the browser refused the call';
60
- };
61
-
62
- /**
63
- * What a connection needs of the wire: whole messages out, whole messages in, and a close. Framing
64
- * is the transport's — a WebSocket frames per message, the pipe splits on NUL — so correlation and
65
- * deadlines are written once, over either.
66
- */
67
- export interface CdpTransport {
68
- send(text: string): void;
69
- close(): void;
70
- /** Installed exactly once, by the connection, before the first `send`. */
71
- listen(handlers: {
72
- readonly message: (text: string) => void;
73
- readonly closed: (reason: string) => void;
74
- }): void;
75
- }
76
-
77
- export interface CdpConnectionOptions {
78
- readonly endpoint: string;
79
- /** Per-call deadline. A CDP call that never answers is a suite that never finishes. */
80
- readonly timeoutMs: number;
81
- }
82
-
83
- /**
84
- * A remote browser over its WebSocket url. NOT the e2e driver's wire: measured in the dummy's
85
- * `offline-feed` suite on Bun 1.4.0, Bun's WebSocket client handed `onmessage` text spliced out of
86
- * several frames — 64 unparseable frames in one run, one of them the reply to a `Runtime.evaluate`
87
- * that then waited out its whole deadline. A launched Chrome is driven over `cdp-pipe.ts` instead.
88
- */
89
- export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConnection> {
90
- assert(
91
- options.endpoint.startsWith('ws://') || options.endpoint.startsWith('wss://'),
92
- `the CDP endpoint is ${options.endpoint === '' ? 'empty' : 'not a WebSocket url'}`,
93
- 'pass the `webSocketDebuggerUrl` Chrome prints on stderr, or the one /json/version answers',
94
- );
95
- const socket = new WebSocket(options.endpoint);
96
- await new Promise<void>((resolve, reject) => {
97
- const timer = setTimeout(() => {
98
- reject(new CdpTimeoutError({ method: 'connect', timeoutMs: options.timeoutMs }));
99
- }, options.timeoutMs);
100
- socket.onopen = (): void => {
101
- clearTimeout(timer);
102
- resolve();
103
- };
104
- // `onerror` is the handshake's only until `listen` below reassigns it: a failure BEFORE open
105
- // has no pending call to abandon, and rejecting is the only way the caller hears about it.
106
- socket.onerror = (): void => {
107
- clearTimeout(timer);
108
- reject(
109
- new CdpCallFailedError({ method: 'connect', detail: 'the browser refused the connection' }),
110
- );
111
- };
112
- });
113
-
114
- const transport: CdpTransport = {
115
- send: (text) => socket.send(text),
116
- close: () => socket.close(),
117
- listen(handlers) {
118
- socket.onmessage = (event: MessageEvent): void => {
119
- handlers.message(typeof event.data === 'string' ? event.data : '');
120
- };
121
- socket.onclose = (): void => handlers.closed('the browser closed the CDP connection');
122
- socket.onerror = (): void => handlers.closed('the CDP connection failed');
123
- },
124
- };
125
- return cdpConnectOver(transport, options.timeoutMs);
126
- }
127
-
128
- /** A connection over any transport — correlation, deadlines and events, written once. */
129
- export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpConnection {
130
- const pending = new Map<number, Pending>();
131
- const waiters = new Set<(method: string, sessionId: string | undefined) => void>();
132
- const listeners = new Map<string, Set<(params: Record<string, unknown>) => void>>();
133
- let nextId = 0;
134
- let closed = false;
135
-
136
- // Every in-flight call is settled on close. Without this a suite whose browser died waits out
137
- // one full deadline per call and reports a timeout, where the true fault is a dead browser.
138
- const abandon = (reason: string): void => {
139
- closed = true;
140
- for (const [, one] of pending) {
141
- clearTimeout(one.timer);
142
- one.reject(new CdpCallFailedError({ method: 'the connection', detail: reason }));
143
- }
144
- pending.clear();
145
- // A waiter is a "did this happen" question, and on a dead connection the answer is no. Its
146
- // own timer settles it, so nothing is left hanging; clearing the set only stops a late frame
147
- // from resolving a waiter whose connection has gone.
148
- waiters.clear();
149
- };
150
-
151
- const message = (raw: string): void => {
152
- let frame: Record<string, unknown>;
153
- try {
154
- frame = JSON.parse(raw) as Record<string, unknown>;
155
- } catch {
156
- // An unparseable frame is the browser's, not ours, and there is no call to fail with it:
157
- // correlation is by `id`, and a frame we cannot read has none. Events land here too.
158
- return;
159
- }
160
- const id = frame['id'];
161
- if (typeof id !== 'number') {
162
- const method = frame['method'];
163
- if (typeof method !== 'string') return;
164
- const on = frame['sessionId'];
165
- for (const waiter of [...waiters]) waiter(method, typeof on === 'string' ? on : undefined);
166
- const subscribed = listeners.get(method);
167
- if (subscribed !== undefined) {
168
- const params = frame['params'];
169
- const payload: Record<string, unknown> =
170
- typeof params === 'object' && params !== null ? (params as Record<string, unknown>) : {};
171
- // A copy, because a listener may unsubscribe itself while this loop is running.
172
- for (const listener of [...subscribed]) listener(payload);
173
- }
174
- return;
175
- }
176
- const one = pending.get(id);
177
- if (one === undefined) return;
178
- pending.delete(id);
179
- clearTimeout(one.timer);
180
- const failed = errorText(frame);
181
- if (failed !== undefined) {
182
- one.reject(new CdpCallFailedError({ method: `call ${String(id)}`, detail: failed }));
183
- return;
184
- }
185
- const result = frame['result'];
186
- one.resolve({
187
- result:
188
- typeof result === 'object' && result !== null
189
- ? (result as Record<string, unknown>)
190
- : undefined,
191
- });
192
- };
193
- transport.listen({ message, closed: abandon });
194
-
195
- return {
196
- send(method, params = {}, sessionId): Promise<CdpResult> {
197
- if (closed) {
198
- return Promise.reject(
199
- new CdpCallFailedError({ method, detail: 'the CDP connection is already closed' }),
200
- );
201
- }
202
- nextId += 1;
203
- const id = nextId;
204
- return new Promise<CdpResult>((resolve, reject) => {
205
- const timer = setTimeout(() => {
206
- pending.delete(id);
207
- reject(new CdpTimeoutError({ method, timeoutMs: timeoutMs }));
208
- }, timeoutMs);
209
- pending.set(id, { resolve, reject, timer });
210
- transport.send(
211
- JSON.stringify({ id, method, params, ...(sessionId === undefined ? {} : { sessionId }) }),
212
- );
213
- });
214
- },
215
- once(method, sessionId, timeoutMs): Promise<boolean> {
216
- if (closed) return Promise.resolve(false);
217
- return new Promise<boolean>((resolve) => {
218
- const waiter = (seen: string, on: string | undefined): void => {
219
- if (seen !== method) return;
220
- if (sessionId !== undefined && on !== sessionId) return;
221
- clearTimeout(timer);
222
- waiters.delete(waiter);
223
- resolve(true);
224
- };
225
- const timer = setTimeout(() => {
226
- waiters.delete(waiter);
227
- resolve(false);
228
- }, timeoutMs);
229
- waiters.add(waiter);
230
- });
231
- },
232
- on(method, listener): () => void {
233
- const subscribed = listeners.get(method) ?? new Set();
234
- subscribed.add(listener);
235
- listeners.set(method, subscribed);
236
- return () => {
237
- subscribed.delete(listener);
238
- if (subscribed.size === 0) listeners.delete(method);
239
- };
240
- },
241
- close(): void {
242
- listeners.clear();
243
- abandon('the driver closed the CDP connection');
244
- transport.close();
245
- },
246
- };
247
- }
@@ -1,180 +0,0 @@
1
- // One responsibility: one TAB over a raw CDP session — navigate, reload, evaluate, click, wait. The
2
- // session (`cdp-e2e-session.ts`) attaches it and owns everything browser-wide: the network
3
- // condition, the init scripts, the socket and request log. Launching is `cdp-launch.ts` and the
4
- // wire is `cdp-connection.ts`.
5
- //
6
- // FIVE methods, which is the whole reason this exists next to `@ultimat3/scraping` rather than
7
- // through it: `ScrapePage` is a full scraping surface whose intended implementation is
8
- // `puppeteer-core`, and an e2e driver needs none of it.
9
-
10
- import type { CdpConnection } from './cdp-connection';
11
- import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
12
- import type { E2eBrowserPage } from './e2e-page';
13
-
14
- /** What the page threw, when `Runtime.evaluate` answered with an exception rather than a value. */
15
- const thrownIn = (result: Record<string, unknown> | undefined): string | undefined => {
16
- const thrown = result?.['exceptionDetails'];
17
- if (typeof thrown !== 'object' || thrown === null) return undefined;
18
- const text = (thrown as Record<string, unknown>)['text'];
19
- return typeof text === 'string' ? text : 'the expression threw in the page';
20
- };
21
-
22
- /** `Runtime.evaluate`'s answer, unwrapped. Every field here is somebody else's JSON. */
23
- const evaluated = (result: Record<string, unknown> | undefined): unknown => {
24
- const threw = thrownIn(result);
25
- if (threw !== undefined) {
26
- throw new CdpCallFailedError({ method: 'Runtime.evaluate', detail: threw });
27
- }
28
- const remote = result?.['result'];
29
- if (typeof remote !== 'object' || remote === null) return undefined;
30
- return (remote as Record<string, unknown>)['value'];
31
- };
32
-
33
- /** One tab: the port the driver drives, plus what a multi-tab acceptance suite asks of one. */
34
- export interface E2eTab extends E2eBrowserPage {
35
- readonly targetId: string;
36
- /** Browser-wide, like the switch it models: every tab AND every worker goes with it. */
37
- offline(enabled: boolean): Promise<void>;
38
- /** Reload and wait for the load, at the url the tab already had. */
39
- reload(): Promise<void>;
40
- /** Poll `expression` in the page until it is truthy, or refuse naming `what`. */
41
- waitFor(expression: string, what: string, timeoutMs?: number): Promise<void>;
42
- /** The IndexedDB databases this tab's origin holds, by name, sorted. */
43
- indexedDbNames(): Promise<readonly string[]>;
44
- close(): Promise<void>;
45
- }
46
-
47
- export interface CdpE2eTabOptions {
48
- readonly connection: CdpConnection;
49
- /** The attached tab's flattened session — every page call carries it. */
50
- readonly sessionId: string;
51
- readonly targetId: string;
52
- /**
53
- * How long a navigation's load event may take. Distinct from the connection's per-call deadline:
54
- * `Page.navigate` ANSWERS as soon as the navigation is committed, so the wait for the load event
55
- * is a second budget and is the one an app makes long.
56
- */
57
- readonly loadTimeoutMs: number;
58
- /** The session's browser-wide switch, which `offline()` forwards to. */
59
- readonly offline: (enabled: boolean) => Promise<void>;
60
- }
61
-
62
- const POLL_MS = 100;
63
-
64
- /** A tab over a session the caller has already attached and enabled (`cdp-e2e-session.ts`). */
65
- export function cdpE2eTab(options: CdpE2eTabOptions): E2eTab {
66
- const send = options.connection.send.bind(options.connection);
67
- const { sessionId } = options;
68
-
69
- // `url()` is SYNCHRONOUS on the port, and CDP has no synchronous read — so the last committed
70
- // url is tracked here. Seeded with the tab's own starting url rather than '' so a `reload()`
71
- // before any `goto()` navigates somewhere real.
72
- let current = 'about:blank';
73
-
74
- const evaluateRaw = (expression: string) =>
75
- send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId);
76
- const evaluate = async (expression: string): Promise<unknown> =>
77
- evaluated((await evaluateRaw(expression)).result);
78
-
79
- // A poll that THROWS is a poll that does not hold yet, not a refusal: a click that navigates
80
- // leaves the next poll reading a document whose `<body>` is not parsed, and
81
- // `document.body.textContent` throws there once and holds a poll later. The last throw is kept
82
- // and named at the deadline, so an expression that can never evaluate still says why.
83
- const waitFor = async (expression: string, what: string, timeoutMs = options.loadTimeoutMs) => {
84
- // Only the PAGE's throw is swallowed: a connection that died still refuses at once.
85
- let threw: string | undefined;
86
- for (let waited = 0; waited < timeoutMs; waited += POLL_MS) {
87
- const answer = (await evaluateRaw(`Boolean(${expression})`)).result;
88
- threw = thrownIn(answer);
89
- if (threw === undefined && evaluated(answer) === true) return;
90
- await Bun.sleep(POLL_MS);
91
- }
92
- const last = threw === undefined ? '' : `, and its last poll threw: ${threw}`;
93
- throw new CdpTimeoutError({ method: `waitFor(${what})${last}`, timeoutMs });
94
- };
95
-
96
- const tab: E2eTab = {
97
- targetId: options.targetId,
98
- url: () => current,
99
- async goto(url: string): Promise<unknown> {
100
- // **The load EVENT is the signal, not the reply.** Chrome drops `Page.navigate`'s own reply
101
- // whenever the navigation swaps the render process — measured on Chrome 150 against a local
102
- // server: the page loads, the server is hit, a later `Runtime.evaluate` answers `document
103
- // .title` from the new document, and the navigate frame never comes back at all. Awaiting
104
- // the reply therefore waited out the full deadline on the most ordinary navigation there is,
105
- // `about:blank` → `http://localhost:<port>/`. So the waiter is registered BEFORE the send,
106
- // and the reply is raced against it rather than depended on.
107
- const loaded = options.connection.once(
108
- 'Page.loadEventFired',
109
- sessionId,
110
- options.loadTimeoutMs,
111
- );
112
- // A dropped reply is expected, so its rejection is answered rather than thrown: what the
113
- // reply is still worth reading for is `errorText`, which is the ONLY place a refused
114
- // navigation is named — an unreachable host loads no page and fires no load event.
115
- const answered = send('Page.navigate', { url }, sessionId).then(
116
- (answer) => {
117
- const failed = answer.result?.['errorText'];
118
- return typeof failed === 'string' && failed !== '' ? failed : undefined;
119
- },
120
- () => undefined,
121
- );
122
- const failed = await Promise.race([loaded.then(() => undefined), answered]);
123
- if (failed !== undefined) {
124
- throw new CdpCallFailedError({ method: `Page.navigate to ${url}`, detail: failed });
125
- }
126
- current = url;
127
- // The load event may already have fired before the waiter was registered on a same-process
128
- // navigation, and `answered` can win the race on one too. So the document is asked directly:
129
- // a `readyState` that is already `complete` resolves at once, and the deadline resolves
130
- // rather than throwing — a slow page is the app's business, and the assertion after this is
131
- // what should fail. HALF the budget, because the budget is also the connection's per-call
132
- // deadline: a page timer of the full budget raced that deadline and lost, reporting a page
133
- // that never fired `load` as "Runtime.evaluate did not answer" instead.
134
- await evaluate(`(() => new Promise((resolve) => {
135
- if (document.readyState === 'complete') { resolve(true); return; }
136
- const done = () => resolve(true);
137
- addEventListener('load', done, { once: true });
138
- setTimeout(done, ${String(Math.floor(options.loadTimeoutMs / 2))});
139
- }))()`);
140
- // The app may have redirected, so the committed url is re-read rather than assumed.
141
- const settled = await evaluate('location.href');
142
- if (typeof settled === 'string' && settled !== '') current = settled;
143
- return undefined;
144
- },
145
- evaluate,
146
- async click(selector: string): Promise<void> {
147
- // In-page rather than a synthesised `Input.dispatchMouseEvent`: the port takes a SELECTOR,
148
- // and turning one into coordinates means a box model read, a scroll and a hit test — three
149
- // more CDP calls, each with its own way to be wrong about an element the page can click.
150
- const clicked = await evaluate(
151
- `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.click(); return true; })()`,
152
- );
153
- if (clicked !== true) {
154
- throw new CdpCallFailedError({
155
- method: `click(${selector})`,
156
- detail: 'no element in the page matches that selector',
157
- });
158
- }
159
- },
160
- offline: (enabled: boolean) => options.offline(enabled),
161
- async reload(): Promise<void> {
162
- const at = current;
163
- // Reload is a navigation to the url the tab already has — the one wait the port proves.
164
- await tab.goto(at);
165
- },
166
- waitFor,
167
- async indexedDbNames(): Promise<readonly string[]> {
168
- const names = await evaluate(
169
- '(async () => (await indexedDB.databases()).map((db) => db.name ?? "").sort())()',
170
- );
171
- return Array.isArray(names)
172
- ? names.filter((name): name is string => typeof name === 'string')
173
- : [];
174
- },
175
- async close(): Promise<void> {
176
- await send('Target.closeTarget', { targetId: options.targetId });
177
- },
178
- };
179
- return tab;
180
- }
@@ -1,199 +0,0 @@
1
- // One responsibility: the BROWSER half of the e2e driver — every target auto-attached at browser
2
- // level, so a second tab, a SharedWorker, a dedicated worker and a service worker are all watched
3
- // from their first byte; the offline switch, the init scripts, and the log of every WebSocket and
4
- // request the browser made. A tab is `cdp-e2e-page.ts`'s; this file decides what every tab shares.
5
-
6
- import type { CdpConnection } from './cdp-connection';
7
- import type { E2eTab } from './cdp-e2e-page';
8
- import { cdpE2eTab } from './cdp-e2e-page';
9
- import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
10
- import { offlineScripts } from './cdp-offline-script';
11
-
12
- export interface E2eSession {
13
- /** A new tab in the same profile — same cookies, same origin storage, same SharedWorker. */
14
- newTab(): Promise<E2eTab>;
15
- /** Runs in every page before its own scripts, in tabs open now and tabs opened later. */
16
- addInitScript(source: string): Promise<void>;
17
- /** Cut or restore the network for every page AND every worker, including ones attached later. */
18
- offline(enabled: boolean): Promise<void>;
19
- setCookie(url: string, name: string, value: string): Promise<void>;
20
- /** Every WebSocket url the browser opened, in any realm — page, dedicated, shared or service worker. */
21
- sockets(): readonly string[];
22
- /** Every request the browser sent, `METHOD url`, in order. */
23
- requests(): readonly string[];
24
- }
25
-
26
- export interface CdpE2eSessionOptions {
27
- readonly connection: CdpConnection;
28
- /** The load budget every tab's `goto` waits on, and how long a new tab may take to attach. */
29
- readonly loadTimeoutMs: number;
30
- }
31
-
32
- const POLL_MS = 50;
33
-
34
- const field = (from: unknown, key: string): string | undefined => {
35
- const value =
36
- typeof from === 'object' && from !== null ? (from as Record<string, unknown>)[key] : undefined;
37
- return typeof value === 'string' ? value : undefined;
38
- };
39
-
40
- /**
41
- * Start watching the browser. Targets are attached PAUSED (`waitForDebuggerOnStart: true`) and
42
- * released only once their network domain is ENABLED, in dispatch order — a SharedWorker opens its
43
- * socket at start-up, so a target configured any later than that is a socket nothing ever counted.
44
- * Released with `Runtime.runIfWaitingForDebugger` whatever happens and without waiting for any
45
- * answer, or a page that registered a worker never becomes controlled.
46
- */
47
- export async function cdpE2eSession(options: CdpE2eSessionOptions): Promise<E2eSession> {
48
- const { connection } = options;
49
- const send = connection.send.bind(connection);
50
- const sessions = new Set<string>();
51
- const pages = new Map<string, string>(); // targetId → sessionId
52
- const scripts: string[] = [];
53
- const sockets: string[] = [];
54
- const requests: string[] = [];
55
- let cut = false;
56
-
57
- // `-1` is CDP's "no throttling" for both throughputs; 0 would be a browser that can never
58
- // transfer a byte, which is a different failure wearing the same name.
59
- const pageSessions = new Set<string>();
60
- const onLineScripts = offlineScripts(send);
61
- const condition = (session: string): Promise<unknown> =>
62
- send(
63
- 'Network.emulateNetworkConditions',
64
- { offline: cut, latency: 0, downloadThroughput: -1, uploadThroughput: -1 },
65
- session,
66
- );
67
-
68
- connection.on('Network.webSocketCreated', (params) => {
69
- sockets.push(field(params, 'url') ?? '');
70
- });
71
- connection.on('Network.requestWillBeSent', (params) => {
72
- const request = params['request'];
73
- requests.push(`${field(request, 'method') ?? '?'} ${field(request, 'url') ?? ''}`);
74
- });
75
- connection.on('Target.attachedToTarget', (params) => {
76
- const session = field(params, 'sessionId');
77
- if (session === undefined) return;
78
- const info = params['targetInfo'];
79
- const type = field(info, 'type');
80
- sessions.add(session);
81
- void (async () => {
82
- // Every configuration call is SENT before the release and none is AWAITED before it. A
83
- // session dispatches in order, so the target is still configured before its first byte runs —
84
- // but a paused target may answer nothing until it is released: measured in a full `x verify`,
85
- // a SharedWorker's `Network.enable` went unanswered for the whole 30 s deadline, the worker
86
- // stayed paused that long, and the shared gate page stalled behind it. Awaiting the answers
87
- // before releasing was a deadlock with a timeout for an exit.
88
- //
89
- // `Network.enable` first: `emulateNetworkConditions` is silently ignored on a session whose
90
- // Network domain is off — an `offline()` that does nothing while the assertion after it
91
- // reads as proof.
92
- const configured: Promise<unknown>[] = [send('Network.enable', {}, session)];
93
- if (cut) configured.push(condition(session));
94
- if (type === 'page') {
95
- configured.push(send('Page.enable', {}, session), send('Runtime.enable', {}, session));
96
- pageSessions.add(session);
97
- for (const source of scripts) {
98
- configured.push(send('Page.addScriptToEvaluateOnNewDocument', { source }, session));
99
- }
100
- if (cut) configured.push(onLineScripts.add(session));
101
- // The page's own workers attach under it UNPAUSED. Measured: paused here, the emitted
102
- // service worker never took control of its page (`e2e/service-worker.e2e.test.ts` went
103
- // red), because it is also attached at browser level. A dedicated worker's first request
104
- // can therefore precede its Network.enable — the SharedWorker, which is what owns the
105
- // socket, is a browser-level target and IS paused.
106
- configured.push(
107
- send(
108
- 'Target.setAutoAttach',
109
- { autoAttach: true, waitForDebuggerOnStart: false, flatten: true },
110
- session,
111
- ),
112
- );
113
- }
114
- const released = send('Runtime.runIfWaitingForDebugger', {}, session).catch(() => undefined);
115
- // Settled, not `all`: a target that went away refuses EVERY call, and `all` would leave the
116
- // refusals after the first as unhandled rejections. It has nothing left to watch.
117
- const answers = await Promise.allSettled(configured);
118
- if (answers.some((answer) => answer.status === 'rejected')) sessions.delete(session);
119
- await released;
120
- // Published only once RELEASED: a tab handed out while still paused would take its first
121
- // `goto` into a page that is waiting for a debugger.
122
- const targetId = field(info, 'targetId');
123
- if (type === 'page' && targetId !== undefined && sessions.has(session)) {
124
- pages.set(targetId, session);
125
- }
126
- })();
127
- });
128
- await send('Target.setDiscoverTargets', { discover: true });
129
- await send('Target.setAutoAttach', {
130
- autoAttach: true,
131
- waitForDebuggerOnStart: true,
132
- flatten: true,
133
- });
134
-
135
- const offline = async (enabled: boolean): Promise<void> => {
136
- cut = enabled;
137
- // Sequential, and a session that has gone away is dropped rather than taking the rest down.
138
- for (const session of [...sessions]) {
139
- await condition(session).catch(() => sessions.delete(session));
140
- }
141
- // And `navigator.onLine` from a new document's first script (`cdp-offline-script.ts`) — the
142
- // network condition alone reaches a reloaded page only after its scripts have run.
143
- for (const session of [...pageSessions]) {
144
- const toggled = enabled ? onLineScripts.add(session) : onLineScripts.remove(session);
145
- await toggled.catch(() => pageSessions.delete(session));
146
- }
147
- };
148
-
149
- const attached = async (targetId: string): Promise<string> => {
150
- for (let waited = 0; waited < options.loadTimeoutMs; waited += POLL_MS) {
151
- const session = pages.get(targetId);
152
- if (session !== undefined) return session;
153
- await Bun.sleep(POLL_MS);
154
- }
155
- throw new CdpTimeoutError({
156
- method: `Target.attachedToTarget for tab ${targetId}`,
157
- timeoutMs: options.loadTimeoutMs,
158
- });
159
- };
160
-
161
- return {
162
- async newTab(): Promise<E2eTab> {
163
- const created = await send('Target.createTarget', { url: 'about:blank' });
164
- const targetId = field(created.result, 'targetId');
165
- if (targetId === undefined) {
166
- throw new CdpCallFailedError({
167
- method: 'Target.createTarget',
168
- detail: 'the browser created a tab and answered no targetId',
169
- });
170
- }
171
- const sessionId = await attached(targetId);
172
- return cdpE2eTab({
173
- connection,
174
- sessionId,
175
- targetId,
176
- loadTimeoutMs: options.loadTimeoutMs,
177
- offline,
178
- });
179
- },
180
- async addInitScript(source: string): Promise<void> {
181
- scripts.push(source);
182
- // A closed tab refuses every call; it is dropped rather than taking the open tabs down with
183
- // it, exactly as `offline()` drops one.
184
- for (const [targetId, session] of [...pages]) {
185
- await send('Page.addScriptToEvaluateOnNewDocument', { source }, session).catch(() => {
186
- pages.delete(targetId);
187
- sessions.delete(session);
188
- pageSessions.delete(session);
189
- });
190
- }
191
- },
192
- offline,
193
- async setCookie(url: string, name: string, value: string): Promise<void> {
194
- await send('Storage.setCookies', { cookies: [{ name, value, url, path: '/' }] });
195
- },
196
- sockets: () => [...sockets],
197
- requests: () => [...requests],
198
- };
199
- }