@ultimat3/cli 21.0.0 → 22.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/CLAUDE.md +84 -1650
  2. package/README.md +40 -2
  3. package/package.json +30 -30
  4. package/src/api-registration.ts +124 -0
  5. package/src/app-artifacts.ts +39 -0
  6. package/src/app-boundaries.ts +5 -10
  7. package/src/app-load.ts +37 -5
  8. package/src/app-openapi.ts +27 -0
  9. package/src/app-permissions.ts +0 -0
  10. package/src/app-root.ts +1 -1
  11. package/src/async-pages.ts +32 -0
  12. package/src/bin.ts +7 -1
  13. package/src/boundary-findings.ts +36 -0
  14. package/src/browser-launcher-fake-html.ts +80 -0
  15. package/src/browser-launcher-fake.ts +165 -0
  16. package/src/browser-launcher-port.ts +159 -0
  17. package/src/browser-launcher.ts +26 -133
  18. package/src/budgets.ts +11 -1
  19. package/src/cdp-shot-a11y.ts +77 -0
  20. package/src/cdp-shot-clock.ts +14 -0
  21. package/src/cdp-shot-driver.ts +150 -0
  22. package/src/cdp-shot-element.ts +147 -0
  23. package/src/cdp-shot-errors.ts +62 -0
  24. package/src/cdp-shot-keys.ts +152 -0
  25. package/src/cdp-shot-page.ts +230 -0
  26. package/src/cdp-shot-watch.ts +241 -0
  27. package/src/cmd-affected-spec.ts +24 -0
  28. package/src/cmd-affected.ts +3 -19
  29. package/src/cmd-build-spec.ts +16 -0
  30. package/src/cmd-build.ts +46 -20
  31. package/src/cmd-ci-spec.ts +24 -0
  32. package/src/cmd-ci.ts +17 -21
  33. package/src/cmd-db-branch.ts +2 -2
  34. package/src/cmd-db-spec.ts +99 -0
  35. package/src/cmd-db.ts +15 -94
  36. package/src/cmd-deploy-helm.ts +136 -0
  37. package/src/cmd-deploy-spec.ts +40 -0
  38. package/src/cmd-deploy.ts +111 -43
  39. package/src/cmd-dev-spec.ts +25 -0
  40. package/src/cmd-dev.ts +55 -36
  41. package/src/cmd-docs-spec.ts +16 -0
  42. package/src/cmd-docs.ts +2 -11
  43. package/src/cmd-doctor-spec.ts +21 -0
  44. package/src/cmd-doctor.ts +4 -18
  45. package/src/cmd-env-spec.ts +18 -0
  46. package/src/cmd-env.ts +4 -13
  47. package/src/cmd-errors-spec.ts +23 -0
  48. package/src/cmd-errors.ts +3 -17
  49. package/src/cmd-fix-spec.ts +18 -0
  50. package/src/cmd-fix.ts +4 -13
  51. package/src/cmd-generate-spec.ts +31 -0
  52. package/src/cmd-generate.ts +46 -42
  53. package/src/cmd-i18n-spec.ts +16 -0
  54. package/src/cmd-i18n.ts +7 -14
  55. package/src/cmd-jobs-spec.ts +56 -0
  56. package/src/cmd-jobs.ts +3 -50
  57. package/src/cmd-manifest-spec.ts +15 -0
  58. package/src/cmd-manifest.ts +19 -20
  59. package/src/cmd-mcp-spec.ts +21 -0
  60. package/src/cmd-mcp.ts +2 -15
  61. package/src/cmd-new-spec.ts +33 -0
  62. package/src/cmd-new.ts +38 -33
  63. package/src/cmd-policy-spec.ts +13 -0
  64. package/src/cmd-policy.ts +2 -9
  65. package/src/cmd-pr-spec.ts +39 -0
  66. package/src/cmd-pr.ts +5 -65
  67. package/src/cmd-registries-spec.ts +32 -0
  68. package/src/cmd-registries.ts +4 -25
  69. package/src/cmd-routes-spec.ts +12 -0
  70. package/src/cmd-routes.ts +2 -7
  71. package/src/cmd-secrets-spec.ts +19 -0
  72. package/src/cmd-secrets.ts +22 -34
  73. package/src/cmd-shot-island.ts +12 -13
  74. package/src/cmd-shot-spec.ts +53 -0
  75. package/src/cmd-shot.ts +11 -60
  76. package/src/cmd-tasks-spec.ts +21 -0
  77. package/src/cmd-tasks.ts +2 -16
  78. package/src/cmd-test-spec.ts +54 -0
  79. package/src/cmd-test.ts +4 -49
  80. package/src/cmd-verify-spec.ts +28 -0
  81. package/src/cmd-verify.ts +3 -23
  82. package/src/db-seed.ts +4 -2
  83. package/src/dev-dashboard.ts +4 -4
  84. package/src/dev-lock.ts +18 -2
  85. package/src/dev-port.ts +21 -0
  86. package/src/dev-route-table.ts +3 -3
  87. package/src/dispatch.ts +12 -5
  88. package/src/document-styles.ts +1 -1
  89. package/src/drift.ts +4 -18
  90. package/src/error-catalog.ts +16 -9
  91. package/src/error-codes.ts +28 -30
  92. package/src/error-pages.ts +19 -7
  93. package/src/errors.ts +14 -0
  94. package/src/favicon.ts +2 -2
  95. package/src/fix-imports.ts +1 -1
  96. package/src/fix-scan.ts +2 -9
  97. package/src/flag-reads.ts +1 -2
  98. package/src/foreign-text.ts +36 -0
  99. package/src/framework-schema.ts +5 -6
  100. package/src/generate-feature.ts +42 -0
  101. package/src/generate-files.ts +7 -3
  102. package/src/generate-grants.ts +83 -0
  103. package/src/generate-kinds.ts +56 -4
  104. package/src/i18n-index.ts +59 -10
  105. package/src/icon-assets.ts +1 -1
  106. package/src/image-prepare.ts +14 -0
  107. package/src/import-scan.ts +63 -0
  108. package/src/index.ts +67 -348
  109. package/src/invocation-flags.ts +26 -0
  110. package/src/island-bundle.ts +6 -1
  111. package/src/island-capture.ts +6 -6
  112. package/src/island-harness-script.ts +6 -2
  113. package/src/island-realtime.ts +15 -8
  114. package/src/island-shot.ts +5 -0
  115. package/src/island-store.ts +131 -0
  116. package/src/island-verdict.ts +1 -1
  117. package/src/job-registration.ts +42 -0
  118. package/src/jobs-driver.ts +2 -2
  119. package/src/load-findings.ts +51 -0
  120. package/src/mcp-db-target.ts +1 -1
  121. package/src/mcp-errors.ts +28 -27
  122. package/src/mcp-host.ts +27 -12
  123. package/src/mcp-ui-diff.ts +27 -0
  124. package/src/mcp-ui-inspect.ts +4 -4
  125. package/src/mcp-ui-interact.ts +21 -15
  126. package/src/mcp-ui.ts +19 -15
  127. package/src/measure-database.ts +73 -0
  128. package/src/measure-paths.ts +74 -0
  129. package/src/measure-scope.ts +74 -0
  130. package/src/messages.ts +1 -3
  131. package/src/metrics-endpoint.ts +1 -1
  132. package/src/otlp-export.ts +1 -1
  133. package/src/output.ts +6 -0
  134. package/src/permission-grants.ts +86 -0
  135. package/src/prerender-out.ts +25 -0
  136. package/src/prerender.ts +121 -102
  137. package/src/pwa-artifacts.ts +3 -3
  138. package/src/reexport-manifest.ts +2 -1
  139. package/src/registry.ts +80 -56
  140. package/src/role-realtime.ts +36 -0
  141. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  142. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  143. package/src/role-start-types.ts +112 -0
  144. package/src/{dev-roles.ts → role-start.ts} +42 -115
  145. package/src/{dev-sync.ts → role-sync.ts} +18 -8
  146. package/src/root-env.ts +67 -0
  147. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  148. package/src/{dev-services.ts → runtime-bindings.ts} +39 -16
  149. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  150. package/src/runtime-jobs.ts +87 -0
  151. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +18 -5
  152. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  153. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  154. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  155. package/src/runtime-realtime.ts +55 -0
  156. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  157. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  158. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  159. package/src/scaffold-fixture.ts +28 -6
  160. package/src/scaffold-typecheck.ts +6 -3
  161. package/src/schema-drift.ts +7 -1
  162. package/src/script-csp.ts +5 -2
  163. package/src/secrets-rotation.ts +59 -0
  164. package/src/serve-boot.ts +192 -0
  165. package/src/serve-drain.ts +24 -0
  166. package/src/serve-entry.ts +6 -0
  167. package/src/serve-env.ts +116 -0
  168. package/src/serve-types.ts +55 -0
  169. package/src/serve.ts +44 -347
  170. package/src/shot-server.ts +2 -2
  171. package/src/shot-settle.ts +10 -1
  172. package/src/shot-theme.ts +3 -3
  173. package/src/shot-verdict.ts +16 -7
  174. package/src/signal-shred.ts +27 -0
  175. package/src/solid-loader.ts +26 -2
  176. package/src/static-report.ts +8 -1
  177. package/src/templates/action.ts +30 -16
  178. package/src/templates/entity.ts +12 -7
  179. package/src/templates/index.ts +1 -1
  180. package/src/templates/job.ts +10 -7
  181. package/src/templates/policy.ts +20 -2
  182. package/src/templates/resource-create.ts +127 -0
  183. package/src/templates/resource-form-island.ts +49 -12
  184. package/src/templates/resource.ts +10 -4
  185. package/src/templates/scaffold-app.ts +4 -1
  186. package/src/templates/scaffold-auth.ts +3 -1
  187. package/src/templates/scaffold-container-compose.ts +184 -0
  188. package/src/templates/scaffold-container.ts +24 -159
  189. package/src/templates/scaffold-dashboard-example.ts +2 -2
  190. package/src/templates/scaffold-db-package.ts +16 -3
  191. package/src/templates/scaffold-demo-org.ts +41 -0
  192. package/src/templates/scaffold-entries.ts +1 -1
  193. package/src/templates/scaffold-helm-templates.ts +66 -7
  194. package/src/templates/scaffold-helm.ts +27 -5
  195. package/src/templates/scaffold-i18n.ts +15 -10
  196. package/src/templates/scaffold-repo.ts +13 -10
  197. package/src/templates/scaffold-roles.ts +38 -10
  198. package/src/templates/slice-foundation.ts +1 -1
  199. package/src/templates/wrap.ts +4 -1
  200. package/src/test-passes.ts +2 -1
  201. package/src/test-workers.ts +26 -0
  202. package/src/ts-scan.ts +3 -6
  203. package/src/tsconfig-references.ts +1 -2
  204. package/src/verify-checks.ts +41 -39
  205. package/src/verify-e2e.ts +10 -7
  206. package/src/verify-run.ts +11 -1
  207. package/src/verify-step.ts +3 -3
  208. package/src/verify-tests.ts +1 -26
  209. package/src/verify-typecheck.ts +28 -0
  210. package/src/web-binding.ts +2 -2
  211. package/src/workspace-graph.ts +10 -33
  212. package/src/cdp-browser.ts +0 -94
  213. package/src/cdp-connection.ts +0 -247
  214. package/src/cdp-e2e-page.ts +0 -180
  215. package/src/cdp-e2e-session.ts +0 -199
  216. package/src/cdp-errors.ts +0 -56
  217. package/src/cdp-launch.ts +0 -193
  218. package/src/cdp-offline-script.ts +0 -73
  219. package/src/cdp-pipe.ts +0 -77
  220. package/src/e2e-app.ts +0 -103
  221. package/src/e2e-browser-handle.ts +0 -55
  222. package/src/e2e-dom-fixture.ts +0 -117
  223. package/src/e2e-driver.ts +0 -117
  224. package/src/e2e-errors.ts +0 -117
  225. package/src/e2e-evaluate.ts +0 -156
  226. package/src/e2e-locator.ts +0 -86
  227. package/src/e2e-page.ts +0 -153
  228. package/src/e2e-preload.ts +0 -64
  229. package/src/e2e-probe.ts +0 -23
  230. package/src/e2e-selection.ts +0 -182
  231. package/src/e2e-spawn.ts +0 -169
  232. package/src/measurement-actor.ts +0 -26
  233. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
  234. /package/src/{dev-render.ts → runtime-render.ts} +0 -0
package/src/cdp-errors.ts DELETED
@@ -1,56 +0,0 @@
1
- // One constructor per way the raw-CDP e2e browser refuses. Every cause quotes a value that came
2
- // out of a BROWSER or off a spawned process's stderr, so every one is rendered rather than
3
- // interpolated — the rule `e2e-errors.ts` already states.
4
-
5
- import { renderCauseValue, UltimateError } from '@ultimat3/core';
6
-
7
- /**
8
- * No browser to drive. This is the one an author meets first, so its fix names the two ways out:
9
- * point the driver at a binary, or accept that this machine cannot run the check.
10
- */
11
- export class CdpBrowserMissingError extends UltimateError {
12
- constructor(input: { readonly tried: readonly string[] }) {
13
- super({
14
- code: 'X_CDP_BROWSER_MISSING',
15
- cause: `no Chrome or Chromium executable was found — tried ${renderCauseValue(input.tried)}`,
16
- fix: 'set CHROME_PATH to a Chrome or Chromium binary (GitHub-hosted ubuntu runners ship one at /usr/bin/google-chrome), or skip the browser-backed e2e suite by leaving it unset',
17
- });
18
- }
19
- }
20
-
21
- /** The binary ran and never announced an endpoint — a crash, a bad flag, or a sandbox refusal. */
22
- export class CdpLaunchFailedError extends UltimateError {
23
- constructor(input: { readonly executable: string; readonly detail: string }) {
24
- super({
25
- code: 'X_CDP_LAUNCH_FAILED',
26
- cause: `${renderCauseValue(input.executable)} did not announce a DevTools endpoint: ${renderCauseValue(input.detail)}`,
27
- fix: 'run the same binary by hand with --headless=new --remote-debugging-port=0 and read its stderr; inside a container add --no-sandbox --disable-dev-shm-usage, which this launcher already passes',
28
- });
29
- }
30
- }
31
-
32
- /** A CDP call answered with an error frame, or the connection died under it. */
33
- export class CdpCallFailedError extends UltimateError {
34
- constructor(input: { readonly method: string; readonly detail: string }) {
35
- super({
36
- code: 'X_CDP_CALL_FAILED',
37
- cause: `the browser refused ${renderCauseValue(input.method)}: ${renderCauseValue(input.detail)}`,
38
- fix: 'print what the page had: await page.evaluate(() => document.body.innerHTML) — a call that refuses the same way means the browser itself is gone, so read the launch above it',
39
- });
40
- }
41
- }
42
-
43
- /**
44
- * A call that never answered. Its own code rather than `X_TIMEOUT`, because the actionable half is
45
- * WHICH call: a hung `Page.navigate` is an app that never finishes responding, and a hung
46
- * `Runtime.evaluate` is an expression that never settles.
47
- */
48
- export class CdpTimeoutError extends UltimateError {
49
- constructor(input: { readonly method: string; readonly timeoutMs: number }) {
50
- super({
51
- code: 'X_CDP_TIMEOUT',
52
- cause: `${renderCauseValue(input.method)} did not answer inside ${String(input.timeoutMs)}ms`,
53
- fix: 'raise timeoutMs on installE2eDriver({ timeoutMs }), or find the request the page is still waiting on — a navigation that never settles is an app that never finishes its response',
54
- });
55
- }
56
- }
package/src/cdp-launch.ts DELETED
@@ -1,193 +0,0 @@
1
- // One responsibility: start a Chrome in this container and hand back its DevTools endpoint and the
2
- // way to stop it. The connection is `cdp-connection.ts` and the page surface `cdp-e2e-page.ts`.
3
-
4
- // why: Bun exposes no recursive-remove and no temp-root primitive, so the throwaway profile
5
- // directory this launcher must create and delete needs both.
6
- import { mkdtempSync, rmSync } from 'node:fs';
7
- // why: Bun exposes no tmpdir(), so only node:os answers the platform temp root.
8
- import { tmpdir } from 'node:os';
9
- // why: Bun exposes no path-join primitive.
10
- import { join } from 'node:path';
11
- import type { CdpConnection } from './cdp-connection';
12
- import { cdpConnectOver } from './cdp-connection';
13
- import { CdpBrowserMissingError, CdpLaunchFailedError } from './cdp-errors';
14
- import { pipeTransport } from './cdp-pipe';
15
-
16
- /**
17
- * Where a Chrome is, in the order worth trying. `CHROME_PATH` first because it is the operator's
18
- * answer and the only one that can be right on a machine none of the rest describes; the two
19
- * `/usr/bin` names after it are what GitHub-hosted `ubuntu-latest` ships, which is what lets the
20
- * browser-backed suite run in CI with **no download step and no new dependency**.
21
- */
22
- export const CHROME_PATH_ENV = 'CHROME_PATH';
23
- export const CHROME_CANDIDATES: readonly string[] = [
24
- '/usr/bin/google-chrome',
25
- '/usr/bin/google-chrome-stable',
26
- '/usr/bin/chromium',
27
- '/usr/bin/chromium-browser',
28
- ];
29
-
30
- /** The first candidate that exists, or `undefined`. An absent browser is a SKIP, never a failure. */
31
- export async function findChrome(
32
- env: Readonly<Record<string, string | undefined>>,
33
- ): Promise<string | undefined> {
34
- const declared = env[CHROME_PATH_ENV];
35
- const candidates = declared === undefined || declared === '' ? CHROME_CANDIDATES : [declared];
36
- for (const candidate of candidates) {
37
- if (await Bun.file(candidate).exists()) return candidate;
38
- }
39
- return undefined;
40
- }
41
-
42
- /**
43
- * The two flags a CONTAINER needs, regardless of which process launches Chrome: the sandbox needs
44
- * privileges CI (and an Ubuntu 23.10+ host with AppArmor's unprivileged-user-namespace restriction
45
- * — Chrome exits "No usable sandbox" there with neither) does not grant, and `/dev/shm` is 64 MB in
46
- * a default container, which crashes the renderer on any real page.
47
- *
48
- * Exported so `browser-launcher.ts`'s `appBrowser` — a DIFFERENT launch path, `puppeteer-core`'s
49
- * own `launch()` rather than the `Bun.spawn` below — passes the SAME two, rather than a second
50
- * list that agrees today and drifts the next time either changes. `x shot` had neither before this
51
- * export existed, so a box where `x verify`'s e2e gate ran green could not run `x shot` at all.
52
- */
53
- export const CONTAINER_CHROME_ARGS: readonly string[] = ['--no-sandbox', '--disable-dev-shm-usage'];
54
-
55
- /**
56
- * The flags, and every one of them earns its line.
57
- *
58
- * `--headless=new` is Chrome's own headless rather than the retired shim. `--remote-debugging-pipe`
59
- * is the wire (`cdp-pipe.ts` says why it is not the WebSocket): no port, so two suites on one
60
- * machine can never collide, and nothing but this process can drive the browser. A throwaway
61
- * `--user-data-dir` because a run sharing a profile with a real browser inherits its cookies and
62
- * locks its files.
63
- */
64
- export const chromeLaunchFlags = (profileDir: string): readonly string[] => [
65
- '--headless=new',
66
- '--remote-debugging-pipe',
67
- `--user-data-dir=${profileDir}`,
68
- ...CONTAINER_CHROME_ARGS,
69
- '--disable-gpu',
70
- // The cookie store's encryption key comes from the OS keyring, asked over D-Bus on the first
71
- // cookie access — which is the first navigation. With no keyring answering, Chrome waits out the
72
- // D-Bus timeout: measured 7-25 s on the first `Page.navigate` of every launch, against a 30 s CDP
73
- // deadline, which is the intermittent `X_CDP_TIMEOUT` of a full e2e run. A throwaway profile has
74
- // no secret worth a keyring. `puppeteer-core` passes both by default, so `x shot` never had this.
75
- '--password-store=basic',
76
- '--use-mock-keychain',
77
- // Nothing here should reach the network on its own account, and a first-run bubble or an update
78
- // check is a page load the test did not ask for.
79
- '--no-first-run',
80
- '--no-default-browser-check',
81
- '--disable-extensions',
82
- 'about:blank',
83
- ];
84
-
85
- export interface LaunchedBrowser {
86
- /** The browser's own CDP connection, over its debugging pipe. Already answering. */
87
- readonly connection: CdpConnection;
88
- /** Idempotent: closes the connection, kills the process, deletes the profile. */
89
- close(): void;
90
- }
91
-
92
- export interface LaunchOptions {
93
- readonly executable: string;
94
- /** How long Chrome has to answer its first call, and every call's deadline after that. */
95
- readonly timeoutMs: number;
96
- }
97
-
98
- const STDERR_TAIL_CHARS = 4_000;
99
-
100
- /**
101
- * Read stderr to its end for the life of the process, keeping only a bounded tail. A pipe nobody
102
- * reads fills, and Chrome's next stderr write then blocks the thread making it — a browser that
103
- * stops answering mid-run for a reason no log shows. The tail is the launch-failure diagnostics:
104
- * a missing library, a sandbox refusal and a bad flag are all named there and nowhere else.
105
- */
106
- function stderrTail(stream: ReadableStream<Uint8Array>): {
107
- readonly text: () => string;
108
- /** Settles once the stream has ended — every byte the process wrote has been read. */
109
- readonly drained: Promise<void>;
110
- } {
111
- let text = '';
112
- const drained = (async () => {
113
- const decoder = new TextDecoder();
114
- for await (const chunk of stream) {
115
- text = (text + decoder.decode(chunk, { stream: true })).slice(-STDERR_TAIL_CHARS);
116
- }
117
- })().catch(() => undefined);
118
- return { text: () => text, drained };
119
- }
120
-
121
- /**
122
- * How long a browser that failed its first call gets to finish dying, and its stderr to finish
123
- * draining, before the tail is read. The pipe ending and the stderr reader reaching the last line
124
- * are two unordered events; read at the first, the reason a browser died was reported as "printed
125
- * nothing". Bounded, because a WEDGED browser neither exits nor closes stderr.
126
- */
127
- const FAILURE_DRAIN_MS = 1_000;
128
-
129
- const within = (ms: number, work: Promise<unknown>): Promise<unknown> =>
130
- Promise.race([work, Bun.sleep(ms)]);
131
-
132
- /**
133
- * Start Chrome on a throwaway profile and answer once it has answered one CDP call. With a pipe
134
- * there is no "DevTools listening" line to wait for — the first reply IS the readiness signal, and
135
- * a browser that dies or stays silent before it is `X_CDP_LAUNCH_FAILED` carrying its own stderr.
136
- */
137
- export async function launchChrome(options: LaunchOptions): Promise<LaunchedBrowser> {
138
- const profileDir = mkdtempSync(join(tmpdir(), 'x-e2e-chrome-'));
139
- const child = Bun.spawn([options.executable, ...chromeLaunchFlags(profileDir)], {
140
- // Chrome's fd 3 is where it READS commands and fd 4 where it WRITES replies and events.
141
- stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
142
- });
143
- const tail = stderrTail(child.stderr as ReadableStream<Uint8Array>);
144
- const [, , , toBrowser, fromBrowser] = child.stdio as unknown as readonly number[];
145
- const sink = Bun.file(toBrowser ?? -1).writer();
146
- const connection = cdpConnectOver(
147
- pipeTransport({
148
- write: (bytes) => {
149
- sink.write(bytes);
150
- void sink.flush();
151
- },
152
- read: Bun.file(fromBrowser ?? -1).stream(),
153
- end: () => {
154
- void Promise.resolve(sink.end()).catch(() => undefined);
155
- },
156
- }),
157
- options.timeoutMs,
158
- );
159
- let closed = false;
160
- const close = (): void => {
161
- if (closed) return;
162
- closed = true;
163
- connection.close();
164
- child.kill();
165
- rmSync(profileDir, { recursive: true, force: true });
166
- };
167
- try {
168
- await connection.send('Browser.getVersion');
169
- return { connection, close };
170
- } catch {
171
- await within(FAILURE_DRAIN_MS, child.exited);
172
- close();
173
- await within(FAILURE_DRAIN_MS, tail.drained);
174
- const seen = tail.text().trim();
175
- throw new CdpLaunchFailedError({
176
- executable: options.executable,
177
- detail:
178
- seen === ''
179
- ? 'it answered no DevTools call and printed nothing before the deadline'
180
- : seen.split('\n').slice(-3).join(' | '),
181
- });
182
- }
183
- }
184
-
185
- /** `findChrome` then `launchChrome`. Refuses by name when there is no browser to drive. */
186
- export async function launchFoundChrome(
187
- env: Readonly<Record<string, string | undefined>>,
188
- timeoutMs: number,
189
- ): Promise<LaunchedBrowser> {
190
- const executable = await findChrome(env);
191
- if (executable === undefined) throw new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
192
- return launchChrome({ executable, timeoutMs });
193
- }
@@ -1,73 +0,0 @@
1
- // One responsibility: `navigator.onLine` reads `false` from a document's FIRST script while the
2
- // session's offline switch is thrown, and reads `true` again — with the `online` event a page
3
- // reconnects on — when it goes back. `Network.emulateNetworkConditions` cuts the NETWORK for a
4
- // document created under the switch but, measured on Chrome 150, never tells it so: a reload under
5
- // the switch read `navigator.onLine === true` at its first script every time, COOP or not, still
6
- // read `true` a second later, and got no `online` event when the switch went back.
7
- // `Network.overrideNetworkState` changed nothing. The dummy's page boot asks at its first script
8
- // and replayed its outbox with a real POST under the cut (`offline-like.e2e.test.ts`, two attempts).
9
- //
10
- // The override is an OWN property on `navigator`, so the real getter on `Navigator.prototype` is one
11
- // `delete` away. A document Chrome DID tell (it saw `offline`) gets Chrome's own `online` event on
12
- // restore; one it never told gets one from `RESTORE_ONLINE`, so a page reconnects exactly once.
13
-
14
- import type { CdpResult } from './cdp-connection';
15
-
16
- type Send = (
17
- method: string,
18
- params: Record<string, unknown>,
19
- session: string,
20
- ) => Promise<CdpResult>;
21
-
22
- /** Runs before the page's own scripts, in every document a page session creates while cut. */
23
- export const OFFLINE_FIRST_SCRIPT = `(() => {
24
- let told = false;
25
- Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => false });
26
- addEventListener('offline', () => { told = true; });
27
- addEventListener('online', () => { delete navigator.onLine; }, { once: true });
28
- Object.defineProperty(window, '__xRestoreOnLine', { configurable: true, value: () => {
29
- if (!Object.getOwnPropertyDescriptor(navigator, 'onLine')) return;
30
- delete navigator.onLine;
31
- if (!told) dispatchEvent(new Event('online'));
32
- } });
33
- })();`;
34
-
35
- /** Evaluated in every open page when the switch goes back: a no-op in a document never cut. */
36
- export const RESTORE_ONLINE = `window.__xRestoreOnLine?.()`;
37
-
38
- export interface OfflineScripts {
39
- /** Register the script on one page session; answers once the browser has it. */
40
- add(session: string): Promise<unknown>;
41
- /** Unregister it from one page session, if it holds it, and restore the open document. */
42
- remove(session: string): Promise<unknown>;
43
- }
44
-
45
- const field = (from: unknown, key: string): string | undefined => {
46
- const value =
47
- typeof from === 'object' && from !== null ? (from as Record<string, unknown>)[key] : undefined;
48
- return typeof value === 'string' ? value : undefined;
49
- };
50
-
51
- /** Per-session registration, keyed by the identifier the browser hands back. */
52
- export function offlineScripts(send: Send): OfflineScripts {
53
- const held = new Map<string, string>();
54
- return {
55
- async add(session) {
56
- const answer = await send(
57
- 'Page.addScriptToEvaluateOnNewDocument',
58
- { source: OFFLINE_FIRST_SCRIPT },
59
- session,
60
- );
61
- const identifier = field(answer.result, 'identifier');
62
- if (identifier !== undefined) held.set(session, identifier);
63
- return answer;
64
- },
65
- async remove(session) {
66
- const identifier = held.get(session);
67
- if (identifier === undefined) return undefined;
68
- held.delete(session);
69
- await send('Page.removeScriptToEvaluateOnNewDocument', { identifier }, session);
70
- return send('Runtime.evaluate', { expression: RESTORE_ONLINE }, session);
71
- },
72
- };
73
- }
package/src/cdp-pipe.ts DELETED
@@ -1,77 +0,0 @@
1
- // One responsibility: CDP over the pipe `--remote-debugging-pipe` opens — Chrome reads commands on
2
- // its fd 3 and writes replies and events on its fd 4, each message one JSON text ended by a NUL
3
- // byte. This is the e2e driver's wire; the WebSocket in `cdp-connection.ts` is for a remote browser.
4
- //
5
- // Why a pipe rather than the WebSocket Chrome also offers: Bun 1.4.0's WebSocket client handed
6
- // `onmessage` text spliced from several frames under the dummy's `offline-feed` load — 64
7
- // unparseable frames in one run, one of them a `Runtime.evaluate` reply that then waited out its
8
- // 30 s deadline. A frame nobody can parse has no `id`, so no layer above can even tell which call
9
- // it lost. A pipe is bytes and a delimiter, read here, and nothing in between.
10
-
11
- import type { CdpTransport } from './cdp-connection';
12
-
13
- /** The two ends a transport needs: a sink for whole messages, and the byte stream Chrome writes. */
14
- export interface PipeEnds {
15
- /** Write these bytes to Chrome's fd 3, in order. */
16
- readonly write: (bytes: Uint8Array) => void;
17
- /** Chrome's fd 4. */
18
- readonly read: ReadableStream<Uint8Array>;
19
- /** Release the write end — Chrome treats its fd 3 closing as the client going away. */
20
- readonly end: () => void;
21
- }
22
-
23
- const NUL = 0;
24
-
25
- /** Concatenate two byte arrays; the reader only ever holds the unterminated tail. */
26
- const join = (a: Uint8Array, b: Uint8Array): Uint8Array => {
27
- if (a.length === 0) return b;
28
- const out = new Uint8Array(a.length + b.length);
29
- out.set(a);
30
- out.set(b, a.length);
31
- return out;
32
- };
33
-
34
- export function pipeTransport(ends: PipeEnds): CdpTransport {
35
- const encoder = new TextEncoder();
36
- let closed = false;
37
- const reader = ends.read.getReader();
38
- return {
39
- send(text: string): void {
40
- if (closed) return;
41
- const body = encoder.encode(text);
42
- const framed = new Uint8Array(body.length + 1);
43
- framed.set(body);
44
- framed[body.length] = NUL;
45
- ends.write(framed);
46
- },
47
- close(): void {
48
- if (closed) return;
49
- closed = true;
50
- ends.end();
51
- void reader.cancel().catch(() => undefined);
52
- },
53
- listen(handlers): void {
54
- void (async () => {
55
- // Split on BYTES, decoded per message: a multi-byte character may straddle two reads, and
56
- // decoding each read on its own would corrupt it — the failure this file exists to end.
57
- const decoder = new TextDecoder();
58
- let tail: Uint8Array = new Uint8Array(0);
59
- try {
60
- for (;;) {
61
- const { value, done } = await reader.read();
62
- if (done) break;
63
- tail = join(tail, value);
64
- for (let at = tail.indexOf(NUL); at !== -1; at = tail.indexOf(NUL)) {
65
- handlers.message(decoder.decode(tail.subarray(0, at)));
66
- tail = tail.subarray(at + 1);
67
- }
68
- }
69
- } catch {
70
- // A read that fails is a pipe that is gone — reported as the close it is, below.
71
- }
72
- closed = true;
73
- handlers.closed('the browser closed the CDP pipe');
74
- })();
75
- },
76
- };
77
- }
package/src/e2e-app.ts DELETED
@@ -1,103 +0,0 @@
1
- // The app an e2e suite drives, spawned on a THROWAWAY state directory: its own embedded database,
2
- // its own disk, its own dev lock, created per call and removed on `stop()`. Never the developer's
3
- // `.x/pgdata` — resetting that from a test run destroys the data an `x dev` beside it is using.
4
- // This file is the DATABASE half; spawning, readiness and the restart are `e2e-spawn.ts`'s.
5
-
6
- // why: Bun ships no temp-directory primitive or recursive remove; `tmpdir()` is node:os's alone.
7
- import { mkdtemp, rm } from 'node:fs/promises';
8
- // why: Bun exposes no tmpdir() — only node:os answers the platform temp root.
9
- import { tmpdir } from 'node:os';
10
- // why: Bun exposes no path API — the state dir is joined, not concatenated.
11
- import { join } from 'node:path';
12
- import { finiteCount } from '@ultimat3/core';
13
- import type { E2eAppMode } from './e2e-spawn';
14
- import { inherited, refuse, spawnE2eApp, X_BIN } from './e2e-spawn';
15
-
16
- export type { E2eAppMode } from './e2e-spawn';
17
-
18
- export interface StartE2eAppOptions {
19
- /** The app root — the directory holding `app.config.ts`. */
20
- readonly root: string;
21
- readonly mode?: E2eAppMode | undefined;
22
- /**
23
- * The arguments after `x db seed`, or `false` for no seeding. Default `['--tier', 'dev']`: every
24
- * dev-tier seed, which is what a developer's own `x dev` starts from.
25
- */
26
- readonly seed?: readonly string[] | false | undefined;
27
- /** Extra environment for every process — the reset, the seed and the app. */
28
- readonly env?: Readonly<Record<string, string>> | undefined;
29
- /** How long the app may take to answer `/readyz`. */
30
- readonly readyTimeoutMs?: number | undefined;
31
- }
32
-
33
- export interface E2eApp {
34
- /** `http://localhost:<port>`, no trailing slash. */
35
- readonly base: string;
36
- /** The throwaway `.x` this app runs on — the one directory a test may inspect or corrupt. */
37
- readonly stateDir: string;
38
- /** Kill the app and delete its state directory. Idempotent. */
39
- stop(): Promise<void>;
40
- /**
41
- * Stop the app and start it again on the SAME port and state directory, with `env` added — a
42
- * deploy. `{ BUILD_ID: 'b2' }` is a new build the open tabs have not seen (`deploy.newBuild()`).
43
- */
44
- restart(env?: Readonly<Record<string, string>>): Promise<void>;
45
- }
46
-
47
- const DEFAULT_READY_TIMEOUT_MS = 90_000;
48
-
49
- function x(args: readonly string[], root: string, env: Record<string, string>): void {
50
- const run = Bun.spawnSync(['bun', X_BIN, ...args], {
51
- cwd: root,
52
- env: { ...inherited(), ...env },
53
- stdout: 'pipe',
54
- stderr: 'pipe',
55
- });
56
- if (run.exitCode !== 0) {
57
- throw refuse(`x ${args.join(' ')}`, `${run.stdout.toString()}${run.stderr.toString()}`);
58
- }
59
- }
60
-
61
- /**
62
- * Reset and seed a fresh state directory, then spawn the app on a free port and wait for `/readyz`.
63
- * The reset runs against the throwaway directory, so it is a first migration, never a data loss.
64
- */
65
- export async function startE2eApp(options: StartE2eAppOptions): Promise<E2eApp> {
66
- // Screened FIRST, before a directory or a process exists: `waited < NaN` is false, so a NaN budget would never poll and report a dead app.
67
- const deadline = finiteCount(
68
- 'startE2eApp',
69
- 'readyTimeoutMs',
70
- options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
71
- );
72
- const stateDir = await mkdtemp(join(tmpdir(), 'ultimate-e2e-'));
73
- const env: Record<string, string> = { ...options.env, ULTIMATE_STATE_DIR: stateDir };
74
- const cleanup = (): Promise<void> => rm(stateDir, { recursive: true, force: true });
75
- try {
76
- x(['db', 'reset'], options.root, env);
77
- const seed = options.seed ?? ['--tier', 'dev'];
78
- if (seed !== false) x(['db', 'seed', ...seed], options.root, env);
79
- } catch (error) {
80
- await cleanup();
81
- throw error;
82
- }
83
- try {
84
- const spawned = await spawnE2eApp({
85
- root: options.root,
86
- mode: options.mode ?? 'dev',
87
- env,
88
- readyTimeoutMs: deadline,
89
- });
90
- return {
91
- base: spawned.base,
92
- stateDir,
93
- restart: (next) => spawned.restart(next),
94
- async stop(): Promise<void> {
95
- await spawned.stop();
96
- await cleanup();
97
- },
98
- };
99
- } catch (error) {
100
- await cleanup();
101
- throw error;
102
- }
103
- }
@@ -1,55 +0,0 @@
1
- // The browser and the app the e2e step opened, reachable from a test file: `e2eBrowser()` and
2
- // `e2eApp()`. On `globalThis` under one `Symbol.for` key, because the preload and a test may each
3
- // hold their own copy of this module — the page-client handle's reason, one runtime over.
4
-
5
- import type { E2eBrowser } from './cdp-browser';
6
- import { CdpBrowserMissingError } from './cdp-errors';
7
- import { CHROME_CANDIDATES } from './cdp-launch';
8
- import type { E2eApp } from './e2e-app';
9
-
10
- /** Set by the e2e step to the app root; the preload spawns that app and opens a browser. */
11
- export const E2E_ROOT_ENV = 'ULTIMATE_E2E_ROOT';
12
-
13
- interface E2eRun {
14
- browser: E2eBrowser;
15
- readonly app: E2eApp;
16
- }
17
-
18
- const KEY = Symbol.for('ultimate.e2e.run');
19
-
20
- export function publishE2eRun(run: E2eRun): void {
21
- Object.defineProperty(globalThis, KEY, { value: run, configurable: true });
22
- }
23
-
24
- const current = (): E2eRun | undefined => Reflect.get(globalThis, KEY) as E2eRun | undefined;
25
-
26
- /** Swap in a relaunched browser — the app stays; only the dead browser is replaced. */
27
- export function republishE2eBrowser(browser: E2eBrowser): void {
28
- const run = current();
29
- if (run !== undefined) run.browser = browser;
30
- }
31
-
32
- const missing = (): CdpBrowserMissingError =>
33
- new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
34
-
35
- /**
36
- * The run's browser: `page`, and `session` for a second tab, an init script, the socket and request
37
- * log and the offline switch for every worker. Refuses by name outside an e2e run that found one.
38
- */
39
- export function e2eBrowser(): E2eBrowser {
40
- const run = current();
41
- if (run === undefined) throw missing();
42
- return run.browser;
43
- }
44
-
45
- /** The app the run spawned: `base`, `stateDir`, and `restart({ BUILD_ID })` — a deploy. */
46
- export function e2eApp(): E2eApp {
47
- const run = current();
48
- if (run === undefined) throw missing();
49
- return run.app;
50
- }
51
-
52
- /** The spawned app's origin, or `undefined` outside an e2e run. */
53
- export function e2eBaseUrl(): string | undefined {
54
- return current()?.app.base;
55
- }
@@ -1,117 +0,0 @@
1
- // A document small enough to hold in a test and real enough to RUN the driver's own in-page
2
- // expressions. Without it every claim about `getByRole` and `getByText` would be an assertion
3
- // about a string, and a string that never executes cannot be wrong about a page.
4
- //
5
- // Its own file, the pattern `dev-roles-fixture.ts` and `policy-fixture.ts` already set here.
6
-
7
- /** One element. `attrs` is data, so every read of it goes through `Object.hasOwn` below. */
8
- export interface FakeE2eElement {
9
- readonly tag: string;
10
- readonly attrs: Readonly<Record<string, string>>;
11
- readonly text?: string;
12
- readonly children?: readonly FakeE2eElement[];
13
- /** What `getComputedStyle` answers. Absent is the browser's own default — visible. */
14
- readonly style?: { display?: string; visibility?: string; opacity?: string };
15
- }
16
-
17
- interface Node {
18
- tagName: string;
19
- textContent: string;
20
- readonly attributes: Record<string, string>;
21
- readonly descendants: Node[];
22
- readonly style: { display: string; visibility: string; opacity: string };
23
- getAttribute(name: string): string | null;
24
- setAttribute(name: string, value: string): void;
25
- removeAttribute(name: string): void;
26
- contains(other: Node): boolean;
27
- }
28
-
29
- const textOf = (element: FakeE2eElement): string =>
30
- [element.text ?? '', ...(element.children ?? []).map(textOf)]
31
- .join(' ')
32
- .replace(/\s+/g, ' ')
33
- .trim();
34
-
35
- function build(element: FakeE2eElement): Node {
36
- const attributes: Record<string, string> = { ...element.attrs };
37
- const children = (element.children ?? []).map(build);
38
- const descendants = children.flatMap((child) => [child, ...child.descendants]);
39
- const node: Node = {
40
- tagName: element.tag.toUpperCase(),
41
- textContent: textOf(element),
42
- attributes,
43
- descendants,
44
- style: {
45
- display: element.style?.display ?? 'block',
46
- visibility: element.style?.visibility ?? 'visible',
47
- opacity: element.style?.opacity ?? '1',
48
- },
49
- getAttribute: (name) => (Object.hasOwn(attributes, name) ? (attributes[name] as string) : null),
50
- setAttribute: (name, value) => {
51
- attributes[name] = value;
52
- },
53
- removeAttribute: (name) => {
54
- delete attributes[name];
55
- },
56
- contains: (other) => descendants.includes(other),
57
- };
58
- return node;
59
- }
60
-
61
- /** `tag`, `*`, `[attr]`, `[attr="value"]` and any combination — every shape this driver emits. */
62
- const SIMPLE = /^([a-zA-Z0-9*]*)((?:\[[^\]]*\])*)$/;
63
-
64
- function matchesSimple(node: Node, selector: string): boolean {
65
- const parsed = SIMPLE.exec(selector.trim());
66
- if (parsed === null) return false;
67
- const tag = parsed[1] ?? '';
68
- if (tag !== '' && tag !== '*' && tag.toUpperCase() !== node.tagName) return false;
69
- for (const clause of (parsed[2] ?? '').matchAll(/\[([^\]=]+)(?:=("[^"]*"|[^\]]*))?\]/g)) {
70
- const name = (clause[1] ?? '').trim();
71
- const held = node.getAttribute(name);
72
- if (held === null) return false;
73
- const raw = clause[2];
74
- if (raw !== undefined && held !== raw.replace(/^"|"$/g, '')) return false;
75
- }
76
- return true;
77
- }
78
-
79
- const matches = (node: Node, selector: string): boolean =>
80
- selector.split(',').some((part) => part.trim() !== '' && matchesSimple(node, part));
81
-
82
- /**
83
- * The globals a driver expression names, bound to one tree. `getComputedStyle` and `document` are
84
- * handed in as arguments rather than assigned to `globalThis`: a test that installed a fake
85
- * `document` on the process would leak it into every later file in the run.
86
- */
87
- export function fakeE2eDocument(root: FakeE2eElement): Readonly<Record<string, unknown>> {
88
- const rootNode = build(root);
89
- const all = [rootNode, ...rootNode.descendants];
90
- return {
91
- document: {
92
- querySelectorAll: (selector: string): Node[] => all.filter((node) => matches(node, selector)),
93
- querySelector: (selector: string): Node | null =>
94
- all.find((node) => matches(node, selector)) ?? null,
95
- getElementById: (id: string): Node | null =>
96
- all.find((node) => node.getAttribute('id') === id) ?? null,
97
- },
98
- getComputedStyle: (node: Node) => node.style,
99
- };
100
- }
101
-
102
- /**
103
- * Run an expression the driver built, in THIS process, against a stubbed global scope.
104
- *
105
- * `new Function` and not `eval`: the body evaluates with no access to this module's scope, so a
106
- * name the expression does not receive as a parameter is genuinely free — which is exactly the
107
- * `ReferenceError` a captured closure produces in a real browser, and the thing the evaluate
108
- * wrapper has to be proved against.
109
- */
110
- export async function runInFakePage(
111
- expression: string,
112
- globals: Readonly<Record<string, unknown>> = {},
113
- ): Promise<unknown> {
114
- const names = Object.keys(globals);
115
- const body = new Function(...names, `return (${expression});`) as (...args: unknown[]) => unknown;
116
- return await body(...names.map((name) => globals[name]));
117
- }