@ultimat3/cli 20.2.1 → 22.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/CLAUDE.md +84 -1582
  2. package/README.md +40 -2
  3. package/package.json +31 -31
  4. package/src/api-registration.ts +124 -0
  5. package/src/app-artifacts.ts +39 -0
  6. package/src/app-boundaries.ts +5 -10
  7. package/src/app-env.ts +2 -2
  8. package/src/app-load.ts +37 -5
  9. package/src/app-openapi.ts +27 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-root.ts +1 -1
  12. package/src/async-pages.ts +32 -0
  13. package/src/bin.ts +7 -1
  14. package/src/boundary-findings.ts +36 -0
  15. package/src/browser-launcher-fake-html.ts +80 -0
  16. package/src/browser-launcher-fake.ts +165 -0
  17. package/src/browser-launcher-port.ts +159 -0
  18. package/src/browser-launcher.ts +26 -133
  19. package/src/budgets.ts +56 -13
  20. package/src/build-errors.ts +54 -0
  21. package/src/cdp-shot-a11y.ts +77 -0
  22. package/src/cdp-shot-clock.ts +14 -0
  23. package/src/cdp-shot-driver.ts +150 -0
  24. package/src/cdp-shot-element.ts +147 -0
  25. package/src/cdp-shot-errors.ts +62 -0
  26. package/src/cdp-shot-keys.ts +152 -0
  27. package/src/cdp-shot-page.ts +230 -0
  28. package/src/cdp-shot-watch.ts +241 -0
  29. package/src/cmd-affected-spec.ts +24 -0
  30. package/src/cmd-affected.ts +3 -19
  31. package/src/cmd-build-spec.ts +16 -0
  32. package/src/cmd-build.ts +46 -20
  33. package/src/cmd-ci-spec.ts +24 -0
  34. package/src/cmd-ci.ts +17 -21
  35. package/src/cmd-db-branch.ts +2 -2
  36. package/src/cmd-db-spec.ts +99 -0
  37. package/src/cmd-db.ts +15 -94
  38. package/src/cmd-deploy-helm.ts +136 -0
  39. package/src/cmd-deploy-spec.ts +40 -0
  40. package/src/cmd-deploy.ts +117 -42
  41. package/src/cmd-dev-spec.ts +25 -0
  42. package/src/cmd-dev.ts +78 -122
  43. package/src/cmd-docs-spec.ts +16 -0
  44. package/src/cmd-docs.ts +2 -11
  45. package/src/cmd-doctor-spec.ts +21 -0
  46. package/src/cmd-doctor.ts +4 -18
  47. package/src/cmd-env-spec.ts +18 -0
  48. package/src/cmd-env.ts +4 -13
  49. package/src/cmd-errors-spec.ts +23 -0
  50. package/src/cmd-errors.ts +3 -17
  51. package/src/cmd-fix-spec.ts +18 -0
  52. package/src/cmd-fix.ts +4 -13
  53. package/src/cmd-generate-spec.ts +31 -0
  54. package/src/cmd-generate.ts +46 -42
  55. package/src/cmd-i18n-spec.ts +16 -0
  56. package/src/cmd-i18n.ts +7 -14
  57. package/src/cmd-jobs-spec.ts +56 -0
  58. package/src/cmd-jobs.ts +3 -50
  59. package/src/cmd-manifest-spec.ts +15 -0
  60. package/src/cmd-manifest.ts +19 -20
  61. package/src/cmd-mcp-spec.ts +21 -0
  62. package/src/cmd-mcp.ts +2 -15
  63. package/src/cmd-new-spec.ts +33 -0
  64. package/src/cmd-new.ts +38 -33
  65. package/src/cmd-policy-spec.ts +13 -0
  66. package/src/cmd-policy.ts +2 -9
  67. package/src/cmd-pr-spec.ts +39 -0
  68. package/src/cmd-pr.ts +5 -65
  69. package/src/cmd-registries-spec.ts +32 -0
  70. package/src/cmd-registries.ts +4 -25
  71. package/src/cmd-routes-spec.ts +12 -0
  72. package/src/cmd-routes.ts +2 -7
  73. package/src/cmd-secrets-spec.ts +19 -0
  74. package/src/cmd-secrets.ts +22 -34
  75. package/src/cmd-shot-island.ts +12 -13
  76. package/src/cmd-shot-spec.ts +53 -0
  77. package/src/cmd-shot.ts +11 -60
  78. package/src/cmd-tasks-spec.ts +21 -0
  79. package/src/cmd-tasks.ts +2 -16
  80. package/src/cmd-test-spec.ts +54 -0
  81. package/src/cmd-test.ts +4 -49
  82. package/src/cmd-verify-spec.ts +28 -0
  83. package/src/cmd-verify.ts +3 -23
  84. package/src/db-seed.ts +4 -2
  85. package/src/dev-dashboard.ts +4 -4
  86. package/src/dev-lock.ts +18 -2
  87. package/src/dev-port.ts +21 -0
  88. package/src/dev-route-table.ts +119 -0
  89. package/src/dispatch.ts +12 -5
  90. package/src/document-styles.ts +1 -1
  91. package/src/drift.ts +4 -18
  92. package/src/error-catalog.ts +16 -9
  93. package/src/error-codes.ts +33 -28
  94. package/src/error-pages.ts +19 -7
  95. package/src/error-unthrown.ts +130 -0
  96. package/src/errors.ts +22 -29
  97. package/src/favicon.ts +2 -2
  98. package/src/fix-imports.ts +1 -1
  99. package/src/fix-scan.ts +2 -9
  100. package/src/flag-reads.ts +1 -2
  101. package/src/foreign-text.ts +36 -0
  102. package/src/framework-schema.ts +5 -6
  103. package/src/generate-feature.ts +42 -0
  104. package/src/generate-files.ts +7 -3
  105. package/src/generate-grants.ts +83 -0
  106. package/src/generate-kinds.ts +56 -4
  107. package/src/i18n-index.ts +59 -10
  108. package/src/icon-assets.ts +1 -1
  109. package/src/image-prepare.ts +14 -0
  110. package/src/import-scan.ts +63 -0
  111. package/src/index.ts +67 -334
  112. package/src/invocation-flags.ts +26 -0
  113. package/src/island-bundle.ts +38 -11
  114. package/src/island-capture.ts +6 -6
  115. package/src/island-harness-script.ts +6 -2
  116. package/src/island-realtime.ts +98 -0
  117. package/src/island-shot.ts +5 -0
  118. package/src/island-store.ts +131 -0
  119. package/src/island-verdict.ts +2 -2
  120. package/src/job-registration.ts +42 -0
  121. package/src/jobs-driver.ts +2 -2
  122. package/src/live-routes.ts +82 -42
  123. package/src/load-findings.ts +51 -0
  124. package/src/mcp-db-target.ts +1 -1
  125. package/src/mcp-errors.ts +30 -26
  126. package/src/mcp-host.ts +27 -12
  127. package/src/mcp-ui-diff.ts +27 -0
  128. package/src/mcp-ui-inspect.ts +4 -4
  129. package/src/mcp-ui-interact.ts +21 -15
  130. package/src/mcp-ui.ts +19 -15
  131. package/src/measure-database.ts +73 -0
  132. package/src/measure-paths.ts +74 -0
  133. package/src/measure-scope.ts +74 -0
  134. package/src/messages.ts +1 -3
  135. package/src/metrics-endpoint.ts +1 -1
  136. package/src/otlp-export.ts +1 -1
  137. package/src/output.ts +6 -0
  138. package/src/page-sync.ts +54 -0
  139. package/src/permission-grants.ts +86 -0
  140. package/src/prerender-out.ts +25 -0
  141. package/src/prerender.ts +121 -102
  142. package/src/pwa-artifacts.ts +3 -3
  143. package/src/realtime-browser-probe-fixture.ts +2 -2
  144. package/src/reexport-manifest.ts +2 -1
  145. package/src/registry.ts +80 -56
  146. package/src/role-realtime.ts +36 -0
  147. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  148. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  149. package/src/role-start-types.ts +112 -0
  150. package/src/{dev-roles.ts → role-start.ts} +42 -115
  151. package/src/{dev-sync.ts → role-sync.ts} +23 -11
  152. package/src/root-env.ts +67 -0
  153. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  154. package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
  155. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  156. package/src/runtime-jobs.ts +87 -0
  157. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
  158. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  159. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  160. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  161. package/src/runtime-realtime.ts +55 -0
  162. package/src/{dev-render.ts → runtime-render.ts} +119 -20
  163. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  164. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  165. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  166. package/src/scaffold-fixture.ts +28 -6
  167. package/src/scaffold-typecheck.ts +6 -3
  168. package/src/schema-drift.ts +7 -1
  169. package/src/script-csp.ts +5 -2
  170. package/src/secrets-rotation.ts +59 -0
  171. package/src/serve-boot.ts +192 -0
  172. package/src/serve-drain.ts +24 -0
  173. package/src/serve-entry.ts +6 -0
  174. package/src/serve-env.ts +116 -0
  175. package/src/serve-types.ts +55 -0
  176. package/src/serve.ts +44 -338
  177. package/src/shot-server.ts +2 -2
  178. package/src/shot-settle.ts +10 -1
  179. package/src/shot-theme.ts +3 -3
  180. package/src/shot-verdict.ts +16 -7
  181. package/src/signal-shred.ts +27 -0
  182. package/src/solid-loader.ts +26 -2
  183. package/src/static-report.ts +8 -1
  184. package/src/sw-artifacts.ts +13 -3
  185. package/src/sync-url.ts +31 -0
  186. package/src/templates/action.ts +30 -16
  187. package/src/templates/entity.ts +12 -7
  188. package/src/templates/index.ts +1 -1
  189. package/src/templates/job.ts +10 -7
  190. package/src/templates/policy.ts +20 -2
  191. package/src/templates/resource-create.ts +127 -0
  192. package/src/templates/resource-form-island.ts +76 -30
  193. package/src/templates/resource.ts +10 -4
  194. package/src/templates/route.ts +3 -0
  195. package/src/templates/scaffold-app.ts +4 -1
  196. package/src/templates/scaffold-auth.ts +3 -1
  197. package/src/templates/scaffold-container-compose.ts +184 -0
  198. package/src/templates/scaffold-container.ts +25 -145
  199. package/src/templates/scaffold-dashboard-example.ts +2 -2
  200. package/src/templates/scaffold-db-package.ts +16 -3
  201. package/src/templates/scaffold-demo-org.ts +41 -0
  202. package/src/templates/scaffold-entries.ts +1 -1
  203. package/src/templates/scaffold-env.ts +6 -0
  204. package/src/templates/scaffold-helm-templates.ts +66 -7
  205. package/src/templates/scaffold-helm.ts +27 -5
  206. package/src/templates/scaffold-i18n.ts +15 -10
  207. package/src/templates/scaffold-repo.ts +13 -10
  208. package/src/templates/scaffold-roles.ts +38 -10
  209. package/src/templates/slice-foundation.ts +1 -1
  210. package/src/templates/wrap.ts +4 -1
  211. package/src/test-passes.ts +2 -1
  212. package/src/test-workers.ts +26 -0
  213. package/src/ts-scan.ts +3 -6
  214. package/src/tsconfig-references.ts +1 -2
  215. package/src/verify-checks.ts +41 -39
  216. package/src/verify-e2e.ts +41 -0
  217. package/src/verify-run.ts +115 -50
  218. package/src/verify-step.ts +3 -3
  219. package/src/verify-tests.ts +22 -30
  220. package/src/verify-typecheck.ts +28 -0
  221. package/src/web-binding.ts +2 -2
  222. package/src/worker-bundle.ts +192 -0
  223. package/src/workspace-graph.ts +10 -33
  224. package/src/cdp-browser.ts +0 -100
  225. package/src/cdp-connection.ts +0 -211
  226. package/src/cdp-e2e-page.ts +0 -209
  227. package/src/cdp-errors.ts +0 -56
  228. package/src/cdp-launch.ts +0 -139
  229. package/src/e2e-dom-fixture.ts +0 -117
  230. package/src/e2e-driver.ts +0 -97
  231. package/src/e2e-errors.ts +0 -103
  232. package/src/e2e-evaluate.ts +0 -156
  233. package/src/e2e-locator.ts +0 -86
  234. package/src/e2e-page.ts +0 -150
  235. package/src/e2e-selection.ts +0 -182
  236. package/src/measurement-actor.ts +0 -26
  237. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
package/src/cdp-launch.ts DELETED
@@ -1,139 +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 { CdpBrowserMissingError, CdpLaunchFailedError } from './cdp-errors';
12
-
13
- /**
14
- * Where a Chrome is, in the order worth trying. `CHROME_PATH` first because it is the operator's
15
- * answer and the only one that can be right on a machine none of the rest describes; the two
16
- * `/usr/bin` names after it are what GitHub-hosted `ubuntu-latest` ships, which is what lets the
17
- * browser-backed suite run in CI with **no download step and no new dependency**.
18
- */
19
- export const CHROME_PATH_ENV = 'CHROME_PATH';
20
- export const CHROME_CANDIDATES: readonly string[] = [
21
- '/usr/bin/google-chrome',
22
- '/usr/bin/google-chrome-stable',
23
- '/usr/bin/chromium',
24
- '/usr/bin/chromium-browser',
25
- ];
26
-
27
- /** The first candidate that exists, or `undefined`. An absent browser is a SKIP, never a failure. */
28
- export async function findChrome(
29
- env: Readonly<Record<string, string | undefined>>,
30
- ): Promise<string | undefined> {
31
- const declared = env[CHROME_PATH_ENV];
32
- const candidates = declared === undefined || declared === '' ? CHROME_CANDIDATES : [declared];
33
- for (const candidate of candidates) {
34
- if (await Bun.file(candidate).exists()) return candidate;
35
- }
36
- return undefined;
37
- }
38
-
39
- /**
40
- * The two flags a CONTAINER needs, regardless of which process launches Chrome: the sandbox needs
41
- * privileges CI (and an Ubuntu 23.10+ host with AppArmor's unprivileged-user-namespace restriction
42
- * — Chrome exits "No usable sandbox" there with neither) does not grant, and `/dev/shm` is 64 MB in
43
- * a default container, which crashes the renderer on any real page.
44
- *
45
- * Exported so `browser-launcher.ts`'s `appBrowser` — a DIFFERENT launch path, `puppeteer-core`'s
46
- * own `launch()` rather than the `Bun.spawn` below — passes the SAME two, rather than a second
47
- * list that agrees today and drifts the next time either changes. `x shot` had neither before this
48
- * export existed, so a box where `x verify`'s e2e gate ran green could not run `x shot` at all.
49
- */
50
- export const CONTAINER_CHROME_ARGS: readonly string[] = ['--no-sandbox', '--disable-dev-shm-usage'];
51
-
52
- /**
53
- * The flags, and every one of them earns its line.
54
- *
55
- * `--headless=new` is Chrome's own headless rather than the retired shim. `--remote-debugging-port=0`
56
- * asks the OS for a free port, so two suites on one machine never collide — the port is read back
57
- * off stderr, which is the only place Chrome states the one it took. A throwaway `--user-data-dir`
58
- * because a run sharing a profile with a real browser inherits its cookies and locks its files.
59
- */
60
- const flags = (profileDir: string): readonly string[] => [
61
- '--headless=new',
62
- '--remote-debugging-port=0',
63
- `--user-data-dir=${profileDir}`,
64
- ...CONTAINER_CHROME_ARGS,
65
- '--disable-gpu',
66
- // Nothing here should reach the network on its own account, and a first-run bubble or an update
67
- // check is a page load the test did not ask for.
68
- '--no-first-run',
69
- '--no-default-browser-check',
70
- '--disable-extensions',
71
- 'about:blank',
72
- ];
73
-
74
- const ENDPOINT = /DevTools listening on (ws:\/\/\S+)/;
75
-
76
- export interface LaunchedBrowser {
77
- readonly endpoint: string;
78
- /** Idempotent: killing a dead process and deleting a gone directory are both no-ops. */
79
- close(): void;
80
- }
81
-
82
- export interface LaunchOptions {
83
- readonly executable: string;
84
- /** How long Chrome has to announce its endpoint before this gives up and kills it. */
85
- readonly timeoutMs: number;
86
- }
87
-
88
- /**
89
- * Chrome announces `DevTools listening on ws://…` on **stderr**, once, before it is usable. Reading
90
- * it there rather than polling `/json/version` is what makes `--remote-debugging-port=0` safe: with
91
- * a random port there is no URL to poll until Chrome has said which one it took.
92
- */
93
- export async function launchChrome(options: LaunchOptions): Promise<LaunchedBrowser> {
94
- const profileDir = mkdtempSync(join(tmpdir(), 'x-e2e-chrome-'));
95
- const child = Bun.spawn([options.executable, ...flags(profileDir)], {
96
- stderr: 'pipe',
97
- stdout: 'ignore',
98
- });
99
- const close = (): void => {
100
- child.kill();
101
- rmSync(profileDir, { recursive: true, force: true });
102
- };
103
-
104
- const reader = (child.stderr as ReadableStream<Uint8Array>).getReader();
105
- const decoder = new TextDecoder();
106
- let seen = '';
107
- const deadline = Bun.nanoseconds() + options.timeoutMs * 1_000_000;
108
- try {
109
- while (Bun.nanoseconds() < deadline) {
110
- const { value, done } = await reader.read();
111
- if (done) break;
112
- seen += decoder.decode(value, { stream: true });
113
- const found = ENDPOINT.exec(seen);
114
- if (found?.[1] !== undefined) return { endpoint: found[1], close };
115
- }
116
- } finally {
117
- reader.releaseLock();
118
- }
119
- close();
120
- // Chrome's own stderr is the actionable half — a missing library, a sandbox refusal, a bad flag
121
- // are all named there — so it is reported rather than "the launch failed".
122
- throw new CdpLaunchFailedError({
123
- executable: options.executable,
124
- detail:
125
- seen.trim() === ''
126
- ? 'it printed nothing before the deadline'
127
- : seen.trim().split('\n').slice(-3).join(' | '),
128
- });
129
- }
130
-
131
- /** `findChrome` then `launchChrome`. Refuses by name when there is no browser to drive. */
132
- export async function launchFoundChrome(
133
- env: Readonly<Record<string, string | undefined>>,
134
- timeoutMs: number,
135
- ): Promise<LaunchedBrowser> {
136
- const executable = await findChrome(env);
137
- if (executable === undefined) throw new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
138
- return launchChrome({ executable, timeoutMs });
139
- }
@@ -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
- }
package/src/e2e-driver.ts DELETED
@@ -1,97 +0,0 @@
1
- // The registration. One call from an app's test preload turns the declared `page` fixture into a
2
- // browser-backed one and gives `e2eTest` a driver — and nothing here runs unless that call is
3
- // made, which is what keeps a CI box with no Chrome answering `hasE2eDriver() === false`.
4
-
5
- import { test as bunTest } from 'bun:test';
6
- import type { E2eBody, E2eFixtures, PageLike } from '@ultimat3/testing';
7
- import {
8
- defineFixtures,
9
- FixtureUnavailableError,
10
- resetE2eDriver,
11
- unavailableFixture,
12
- useE2eDriver,
13
- } from '@ultimat3/testing';
14
- import type { E2eBrowserPage, E2ePageOptions } from './e2e-page';
15
- import { e2ePage } from './e2e-page';
16
-
17
- export type E2eDriverOptions = E2ePageOptions;
18
-
19
- /**
20
- * A member this driver cannot build is a REFUSAL, never a no-op. A fixture that silently did
21
- * nothing would make the assertion after it read as proof: `offline()` followed by "the fallback
22
- * rendered" is the app's ONLINE page passing an offline test.
23
- */
24
- const refuse =
25
- (name: string, needs: string): (() => Promise<void>) =>
26
- () =>
27
- Promise.reject(new FixtureUnavailableError({ name, needs }));
28
-
29
- /**
30
- * `offline()`/`online()` FORWARD, `As of 2026-08-27`. They refused until then on a reason the tree
31
- * contradicted on the day it was written: this file said `CdpPageLike`
32
- * (`packages/scraping/src/cdp-port.ts`) "declares twelve methods and none of them is
33
- * `setOfflineMode`". It declares it at line 71 — optional, guarded, with a coded
34
- * `X_NOT_IMPLEMENTED` in `cdp-target.ts` for a launcher that lacks it — and `page-over-target.ts`
35
- * exposes it as `ScrapePage.offline()`. All of that landed in **the same commit as the comment**
36
- * (#351), so the refusal was never true, and it is the reason issue #390 records a real browser
37
- * check as out of reach.
38
- *
39
- * Optional on `E2eBrowserPage` rather than required, for the reason `CdpPageLike` gives about the
40
- * same method: this port is the shape of somebody ELSE's object, and a six-line test double must
41
- * still satisfy it. Absent, the refusal stands — and now it names the method the double is missing
42
- * rather than a capability the framework does not have.
43
- */
44
- const networkFixtures = (browser: E2eBrowserPage): Pick<E2eFixtures, 'offline' | 'online'> => {
45
- const setOffline = browser.offline?.bind(browser);
46
- if (setOffline === undefined) {
47
- const needs =
48
- "a page whose driver implements offline(enabled) — @ultimat3/scraping's ScrapePage does; a hand-rolled E2eBrowserPage may not";
49
- return { offline: refuse('offline', needs), online: refuse('online', needs) };
50
- }
51
- return { offline: () => setOffline(true), online: () => setOffline(false) };
52
- };
53
-
54
- /** What `e2eTest` hands its body: a real page, the network condition, and one honest refusal. */
55
- export const e2eFixtures = (page: PageLike, browser: E2eBrowserPage): E2eFixtures => ({
56
- page,
57
- ...networkFixtures(browser),
58
- // The one that is still genuinely out of reach, and it is not a port gap: a new build id is a
59
- // fact about the SERVER, which no page port has ever been able to speak for.
60
- update: refuse(
61
- 'update',
62
- 'a second build served under a new immutable build id, which is a server fact',
63
- ),
64
- });
65
-
66
- /**
67
- * Install the browser-backed driver for this process.
68
- *
69
- * Two seams, deliberately, because they are two questions. `defineFixtures({ page })` replaces the
70
- * declaration `driverFixtures()` registered — the ordinary way a driver arrives, last registration
71
- * wins — so every `test('…', async ({ page }) => …)` in the suite gets a browser. `useE2eDriver`
72
- * is the other half: it is what makes `hasE2eDriver()` answer true and stops `e2eTest` becoming a
73
- * `test.skip` — which the gate now reports as a SKIPPED step rather than the green check it
74
- * printed until #434, and which a repo whose `x.verify.json` names `e2e` gets red for.
75
- *
76
- * `budget`, `signIn` and `deploy` are deliberately NOT registered here. Each needs something a
77
- * page cannot supply — byte counts off a built `dist/`, an app's own sign-in route, a second build
78
- * — so each keeps refusing with `X_TEST_FIXTURE_UNAVAILABLE` naming what it waits for.
79
- *
80
- * Returns the undo. `bun test` is one process, so a driver installed and never removed reaches
81
- * every later file in the run.
82
- */
83
- export function installE2eDriver(options: E2eDriverOptions): () => void {
84
- const page = e2ePage(options);
85
- defineFixtures({ page: () => page });
86
- useE2eDriver((name, body: E2eBody) => {
87
- bunTest(name, () => body(e2eFixtures(page, options.page)));
88
- });
89
- return () => {
90
- // Both halves, because both were installed. Putting the DECLARATION back — rather than
91
- // deleting the key — is what keeps a later file's `{ page }` failing as
92
- // `X_TEST_FIXTURE_UNAVAILABLE` (a driver is missing) instead of `X_TEST_FIXTURE_UNKNOWN`
93
- // (register it), which is the wrong instruction for a name the framework declares.
94
- defineFixtures({ page: unavailableFixture('page') });
95
- resetE2eDriver();
96
- };
97
- }
package/src/e2e-errors.ts DELETED
@@ -1,103 +0,0 @@
1
- // One constructor per way the browser-backed e2e page refuses. Every cause below quotes a value
2
- // that came out of a BROWSER or out of a test's own `toString()`, so every one of them is
3
- // rendered rather than interpolated.
4
-
5
- import { renderCauseValue, renderFixLiteral, UltimateError } from '@ultimat3/core';
6
- import type { E2eSelection } from './e2e-selection';
7
- import { selectionCall } from './e2e-selection';
8
-
9
- /** A page URL is uncontrolled text; a fix line has to parse after one lands inside it. */
10
- const URL_PLACEHOLDER = '<the url the cause names>';
11
-
12
- /**
13
- * The closure never left the test process. `PageLike.evaluate` takes a function and CDP takes a
14
- * string, so the only thing that can cross is `Function.prototype.toString()` — and a function
15
- * with no readable source, or one expecting an argument nothing in the page will pass, has no
16
- * honest string form at all.
17
- */
18
- export class E2eEvaluateUnsupportedError extends UltimateError {
19
- constructor(input: { readonly reason: string; readonly source: string }) {
20
- super({
21
- code: 'X_E2E_EVALUATE_UNSUPPORTED',
22
- cause: `page.evaluate() was given a function that ${input.reason}: ${renderCauseValue(input.source)}`,
23
- fix: 'page.evaluate(() => document.title) # a zero-parameter arrow whose body names only page globals',
24
- });
25
- }
26
- }
27
-
28
- /**
29
- * The closure crossed and then named something the page has never heard of. This is the failure
30
- * the whole `evaluate` seam is built around: `const wanted = 3; page.evaluate(() => rows === wanted)`
31
- * sends the source, so the page receives the NAME `wanted` and a `ReferenceError`. Reported with
32
- * the binding's own name, because "it threw in the browser" sends the reader to the app.
33
- */
34
- export class E2eEvaluateCapturedError extends UltimateError {
35
- constructor(input: { readonly binding: string; readonly source: string }) {
36
- const binding = renderFixLiteral(input.binding, '<the binding the cause names>');
37
- super({
38
- code: 'X_E2E_EVALUATE_CAPTURED',
39
- cause: `page.evaluate() ran ${renderCauseValue(input.source)} in the browser and ${renderCauseValue(input.binding)} is not defined there — a closure sends its source, never its scope`,
40
- fix: `page.evaluate(() => document.querySelectorAll("script[src]").length) # write the value into the closure literally; the page has no binding named ${binding}`,
41
- });
42
- }
43
- }
44
-
45
- /**
46
- * The expression ran and the PAGE threw — the app's own failure, not the driver's. Kept apart from
47
- * the two above so the reader is sent to the app rather than to the test's closure.
48
- */
49
- export class E2eEvaluateThrewError extends UltimateError {
50
- constructor(input: { readonly thrown: string; readonly url: string }) {
51
- super({
52
- code: 'X_E2E_EVALUATE_THREW',
53
- cause: `the expression page.evaluate() ran threw inside the browser: ${renderCauseValue(input.thrown)}`,
54
- fix: `x dev — then open ${renderFixLiteral(input.url, URL_PLACEHOLDER)} and run the same expression in the console; the throw is the page's`,
55
- });
56
- }
57
- }
58
-
59
- /**
60
- * Nothing matched. The fix is the retrying assertion and not a longer sleep: `toBeVisible()` looks
61
- * again to a budget, while `click()` resolves the selection once — so a test that raced the render
62
- * is asserting in the wrong order rather than waiting for the wrong length of time.
63
- */
64
- export class E2eLocatorEmptyError extends UltimateError {
65
- constructor(input: { readonly selection: E2eSelection; readonly url: string }) {
66
- const call = selectionCall(input.selection);
67
- super({
68
- code: 'X_E2E_LOCATOR_EMPTY',
69
- cause: `${call} matched no element on ${renderCauseValue(input.url)}`,
70
- fix: `await expect(${call}).toBeVisible() before acting on it — that assertion retries to a budget, click() resolves once`,
71
- });
72
- }
73
- }
74
-
75
- /**
76
- * More than one matched and the caller asked to ACT. Never raised by `count()` or `isVisible()`:
77
- * an assertion handed an ambiguous locator has an answer, and refusing there would turn a question
78
- * into a crash. A click has no such answer — one of them is about to be pressed.
79
- */
80
- export class E2eLocatorAmbiguousError extends UltimateError {
81
- constructor(input: { readonly selection: E2eSelection; readonly count: number }) {
82
- const call = selectionCall(input.selection);
83
- super({
84
- code: 'X_E2E_LOCATOR_AMBIGUOUS',
85
- cause: `${call} matched ${String(input.count)} elements and click() presses exactly one`,
86
- fix: `${call}.first().click() # or narrow the selection until it matches one element`,
87
- });
88
- }
89
- }
90
-
91
- /**
92
- * `waitForServiceWorker()` gave up. Bounded IN THE PAGE rather than here: an unbounded wait is a
93
- * test that hangs, and CI then reports a runner timeout with no assertion anywhere in it.
94
- */
95
- export class E2eServiceWorkerAbsentError extends UltimateError {
96
- constructor(input: { readonly url: string; readonly timeoutMs: number }) {
97
- super({
98
- code: 'X_E2E_SERVICE_WORKER_ABSENT',
99
- cause: `no service worker took control of ${renderCauseValue(input.url)} within ${String(input.timeoutMs)}ms`,
100
- fix: `x build --target static — the worker is generated by the pwa build; then confirm the route at ${renderFixLiteral(input.url, URL_PLACEHOLDER)} registers it`,
101
- });
102
- }
103
- }
@@ -1,156 +0,0 @@
1
- // The one crossing in this driver that cannot be lossless: `PageLike.evaluate` takes a CLOSURE and
2
- // every browser port in the framework takes a STRING. What is supported is stated here, what is
3
- // not is refused by name, and a page-side throw comes back as itself rather than as a driver fault.
4
-
5
- import { renderCauseValue, stringField } from '@ultimat3/core';
6
- import {
7
- E2eEvaluateCapturedError,
8
- E2eEvaluateThrewError,
9
- E2eEvaluateUnsupportedError,
10
- } from './e2e-errors';
11
-
12
- /** Only what this driver needs from a page, so a test proves the crossing with two methods. */
13
- export interface EvaluablePage {
14
- evaluate(expression: string): Promise<unknown>;
15
- url(): string;
16
- }
17
-
18
- /**
19
- * A function with no source. `Function.prototype.toString` answers `[native code]` for a native
20
- * function and for anything `.bind()` produced, so there is nothing to send and the page would be
21
- * asked to evaluate a syntax error.
22
- */
23
- const NATIVE = /\{\s*\[native code\]\s*\}/;
24
-
25
- /**
26
- * The parameter list of an arrow or a function expression, as written. `PageLike.evaluate` declares
27
- * `() => T`, so a declared parameter is a value the author meant to pass and cannot: nothing in
28
- * the page will supply it, and it would silently arrive `undefined`.
29
- */
30
- const takesParameters = (source: string): boolean => {
31
- const arrow = /^(?:async\s+)?\(([^)]*)\)\s*=>/.exec(source);
32
- if (arrow !== null) return arrow[1]?.trim() !== '';
33
- // `x => x * 2` — a single unparenthesised parameter, which is still a parameter.
34
- if (/^(?:async\s+)?[A-Za-z_$][\w$]*\s*=>/.test(source)) return true;
35
- const fn = /^(?:async\s+)?function\s*\*?\s*[A-Za-z_$][\w$]*?\s*\(([^)]*)\)/.exec(source);
36
- return fn !== null && fn[1]?.trim() !== '';
37
- };
38
-
39
- /**
40
- * Is the source a standalone expression at all? A method shorthand — what `{ evaluate() {} }.evaluate`
41
- * stringifies to — is `evaluate() { … }`, which is legal in an object literal and a syntax error
42
- * anywhere else. `new Function` is the parser, and it PARSES only: nothing is called here, so no
43
- * page code and no test code runs in this process.
44
- */
45
- const parses = (source: string): boolean => {
46
- try {
47
- new Function(`"use strict"; return (${source});`);
48
- return true;
49
- } catch {
50
- return false;
51
- }
52
- };
53
-
54
- /**
55
- * The static half. Everything it refuses is refused before a byte reaches the browser, because the
56
- * page's own answer for each of these would be a syntax error with the driver's wrapper in it.
57
- */
58
- export function closureSource(fn: (...args: never[]) => unknown): string {
59
- const source = fn.toString();
60
- if (NATIVE.test(source)) {
61
- throw new E2eEvaluateUnsupportedError({
62
- reason: 'is native or bound, so it has no source to send',
63
- source,
64
- });
65
- }
66
- if (takesParameters(source)) {
67
- throw new E2eEvaluateUnsupportedError({
68
- reason: 'declares a parameter, and nothing in the page will pass one',
69
- source,
70
- });
71
- }
72
- if (!parses(source)) {
73
- throw new E2eEvaluateUnsupportedError({
74
- reason: 'does not stringify to an expression the browser can parse',
75
- source,
76
- });
77
- }
78
- return source;
79
- }
80
-
81
- /**
82
- * The wrapper. It CATCHES in the page and answers a value, rather than letting the throw cross the
83
- * wire: `@ultimat3/scraping`'s `cdp-target.ts` wraps anything its `evaluate` rejects with as
84
- * `X_SCRAPE_BROWSER_UNREACHABLE`, so an app error that travelled as a rejection would arrive
85
- * labelled a dead socket.
86
- *
87
- * `Promise.resolve().then(…)` because the closure may be async and because a `ReferenceError` for
88
- * a captured binding is raised when the body RUNS, not when the arrow is built.
89
- */
90
- export const evaluateExpression = (source: string): string =>
91
- `(() => { const fn = (${source});
92
- return Promise.resolve().then(() => fn()).then(
93
- (value) => JSON.stringify({ ok: true, value: value }),
94
- (error) => JSON.stringify({ ok: false, name: String(error && error.name || 'Error'), message: String(error && error.message || error) }),
95
- );
96
- })()`;
97
-
98
- /**
99
- * The envelope, read by hand rather than through a schema, and the reason is `value`: it is
100
- * whatever the test's own closure returned, so no schema in this package can describe it and a
101
- * `t.object` would strip the one field the caller came for. Everything the DRIVER reads — the
102
- * discriminant, the error name, the message — is read defensively through core's `stringField`,
103
- * which is total against a getter that throws.
104
- *
105
- * A malformed answer degrades into the failure branch instead of a branch of its own: the wrapper
106
- * below is the only writer, so anything else is a page that shadowed `JSON.stringify`, and the
107
- * reader needs to see what came back either way.
108
- */
109
- type Envelope =
110
- | { readonly ok: true; readonly value: unknown }
111
- | { readonly ok: false; readonly name: string; readonly message: string };
112
-
113
- const decode = (raw: unknown): unknown => {
114
- if (typeof raw !== 'string') return raw;
115
- try {
116
- return JSON.parse(raw) as unknown;
117
- } catch {
118
- return raw;
119
- }
120
- };
121
-
122
- const readEnvelope = (raw: unknown): Envelope => {
123
- const decoded = decode(raw);
124
- if (typeof decoded === 'object' && decoded !== null) {
125
- const held = decoded as { readonly ok?: unknown; readonly value?: unknown };
126
- if (held.ok === true) return { ok: true, value: held.value };
127
- return {
128
- ok: false,
129
- name: stringField(decoded, 'name') ?? 'Error',
130
- message: stringField(decoded, 'message') ?? renderCauseValue(raw),
131
- };
132
- }
133
- return { ok: false, name: 'Error', message: renderCauseValue(raw) };
134
- };
135
-
136
- /** V8's wording for a free identifier. The name is the whole value of the refusal it produces. */
137
- const NOT_DEFINED = /^([A-Za-z_$][\w$]*) is not defined$/;
138
-
139
- /**
140
- * Run the closure in the page and hand back what it answered.
141
- *
142
- * The cast on the way out is the generic boundary and nothing more: `evaluate<T>` is the CALLER's
143
- * claim about what its own closure returns, and no schema in this process can check a claim the
144
- * browser was never told about. Everything the driver itself reads is read above.
145
- */
146
- export async function evaluateClosure<T>(page: EvaluablePage, fn: () => T): Promise<Awaited<T>> {
147
- const source = closureSource(fn);
148
- const envelope = readEnvelope(await page.evaluate(evaluateExpression(source)));
149
- if (envelope.ok) return envelope.value as Awaited<T>;
150
- const captured = envelope.name === 'ReferenceError' ? NOT_DEFINED.exec(envelope.message) : null;
151
- if (captured !== null) throw new E2eEvaluateCapturedError({ binding: captured[1] ?? '', source });
152
- throw new E2eEvaluateThrewError({
153
- thrown: `${envelope.name}: ${envelope.message}`,
154
- url: page.url(),
155
- });
156
- }