@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
@@ -1,64 +0,0 @@
1
- // The e2e step's own preload: when the gate names an app root (`ULTIMATE_E2E_ROOT`, set by
2
- // `verify-e2e.ts`), spawn that app on a throwaway database, open ONE browser, install it as the
3
- // `page` fixture and the `e2eTest` driver, register `deploy.newBuild()` as a restart of the app on
4
- // its own port, and hand both to any test that asks `e2eBrowser()` / `e2eApp()`.
5
-
6
- import { afterAll, beforeEach } from 'bun:test';
7
- import type { E2eBrowser } from './cdp-browser';
8
- import { openE2eBrowser } from './cdp-browser';
9
- import { startE2eApp } from './e2e-app';
10
- import { E2E_ROOT_ENV, e2eBrowser, publishE2eRun, republishE2eBrowser } from './e2e-browser-handle';
11
- import { installE2eDriver } from './e2e-driver';
12
- import type { E2eBrowserPage } from './e2e-page';
13
- import { answersWithin } from './e2e-probe';
14
-
15
- /** How long a live browser gets to answer `1` before it is declared hung and relaunched. */
16
- const PROBE_MS = 5_000;
17
-
18
- const root = Bun.env[E2E_ROOT_ENV];
19
- if (root !== undefined && root !== '') {
20
- const app = await startE2eApp({ root });
21
- let builds = 0;
22
- // A deploy leaves the browser holding the OLD build's state: a SharedWorker whose socket went
23
- // down with the restart and is now deep in its reconnect backoff, and tabs rendered by the old
24
- // build. The test that deployed asserts on exactly that; the NEXT test must not inherit it.
25
- let deployed = false;
26
- const newBuild = async (): Promise<void> => {
27
- deployed = true;
28
- builds += 1;
29
- await app.restart({ BUILD_ID: `e2e-build-${String(builds)}-${String(Date.now())}` });
30
- };
31
- // `openE2eBrowser`, never the `IfAvailable` door: the step only names a root after finding one.
32
- let browser: E2eBrowser = await openE2eBrowser();
33
- publishE2eRun({ browser, app });
34
- // Installed ONCE, over a page that delegates to whichever browser is current: an `e2eTest` body
35
- // is bound to its fixtures when the file DEFINES it, so reinstalling on a relaunch would leave
36
- // every test defined before it driving a closed browser.
37
- const current: E2eBrowserPage = {
38
- url: () => browser.page.url(),
39
- goto: (url, options) => browser.page.goto(url, options),
40
- evaluate: (expression) => browser.page.evaluate(expression),
41
- click: (selector) => browser.page.click(selector),
42
- offline: (enabled) => browser.page.offline(enabled),
43
- };
44
- const uninstall = installE2eDriver({ page: current, baseUrl: app.base, newBuild });
45
-
46
- // A browser that stopped answering takes every later suite down with it, one call deadline per
47
- // call (run 8). So each test starts with a short probe, and a browser that fails it — or one a
48
- // deploy ran under — is closed and relaunched: the app is untouched, and the test gets a fresh
49
- // profile, a fresh SharedWorker and a fresh tab on the same origin.
50
- beforeEach(async () => {
51
- const alive = !deployed && (await answersWithin(e2eBrowser().page, PROBE_MS));
52
- if (alive) return;
53
- deployed = false;
54
- browser.close();
55
- browser = await openE2eBrowser();
56
- republishE2eBrowser(browser);
57
- });
58
-
59
- afterAll(async () => {
60
- uninstall();
61
- browser.close();
62
- await app.stop();
63
- });
64
- }
package/src/e2e-probe.ts DELETED
@@ -1,23 +0,0 @@
1
- // Is the browser still answering? One cheap `evaluate('1')` raced against a short budget — the
2
- // question the e2e preload asks before every test, because a hung browser otherwise costs every
3
- // later suite one full CDP deadline per call (run 8) instead of one relaunch.
4
-
5
- /** `true` when the page evaluated `1` within `ms`; a rejection or a stall is `false`, never a throw. */
6
- export async function answersWithin(
7
- page: { evaluate(expression: string): Promise<unknown> },
8
- ms: number,
9
- ): Promise<boolean> {
10
- let timer: ReturnType<typeof setTimeout> | undefined;
11
- const stalled = new Promise<false>((resolve) => {
12
- timer = setTimeout(() => resolve(false), ms);
13
- });
14
- const answered = page.evaluate('1').then(
15
- () => true,
16
- () => false,
17
- );
18
- try {
19
- return await Promise.race([answered, stalled]);
20
- } finally {
21
- clearTimeout(timer);
22
- }
23
- }
@@ -1,182 +0,0 @@
1
- // What an e2e locator SELECTS, as a value, plus the one in-page expression that resolves it.
2
- // `locator`/`getByRole`/`getByText` are lazy handles, so the selection has to survive as data
3
- // until something asks a question about it — and only then does it become a string the browser
4
- // can run.
5
-
6
- /** Where the driver parks its click target. Removed again as soon as the click has been made. */
7
- export const MARK_ATTRIBUTE = 'data-x-e2e';
8
-
9
- /** One selection, exactly as the test spelled it. `first` is `.first()`, applied at resolve time. */
10
- export type E2eSelection =
11
- | { readonly kind: 'css'; readonly selector: string; readonly first: boolean }
12
- | {
13
- readonly kind: 'role';
14
- readonly role: string;
15
- readonly name?: string | undefined;
16
- readonly level?: number | undefined;
17
- readonly first: boolean;
18
- }
19
- | { readonly kind: 'text'; readonly text: string; readonly first: boolean };
20
-
21
- /**
22
- * What the page answers for one selection. `count` is how many elements matched AFTER `first`
23
- * narrowed it, `visible` is about the first match alone, and `marked` says the click target now
24
- * carries `MARK_ATTRIBUTE` — three facts in one round trip, because a locator that asked twice
25
- * could be answered about two different renders.
26
- */
27
- export interface E2eResolution {
28
- readonly count: number;
29
- readonly visible: boolean;
30
- readonly marked: boolean;
31
- }
32
-
33
- /**
34
- * The elements that carry a role IMPLICITLY, so `getByRole('button')` finds a `<button>` that
35
- * never wrote the attribute. A `Map` and not an object literal: the key is a role a test typed,
36
- * and a computed read of a `Record` answers `Object.prototype` members — the defect
37
- * `bun run proto-index` exists for.
38
- *
39
- * Deliberately not the whole of WAI-ARIA. A role absent from this table still resolves through
40
- * its explicit `[role="…"]` attribute, which is why an unknown role is not refused: refusing
41
- * `role="feed"` because a table in the framework is short would be the framework deciding an app's
42
- * markup is wrong.
43
- */
44
- const IMPLICIT_ROLE_ELEMENTS = new Map<string, readonly string[]>([
45
- ['banner', ['header']],
46
- ['button', ['button', 'input[type="button"]', 'input[type="submit"]', 'input[type="reset"]']],
47
- ['checkbox', ['input[type="checkbox"]']],
48
- ['combobox', ['select']],
49
- ['contentinfo', ['footer']],
50
- ['dialog', ['dialog']],
51
- ['form', ['form']],
52
- ['heading', ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']],
53
- ['img', ['img']],
54
- ['link', ['a[href]']],
55
- ['list', ['ul', 'ol']],
56
- ['listitem', ['li']],
57
- ['main', ['main']],
58
- ['navigation', ['nav']],
59
- ['option', ['option']],
60
- ['radio', ['input[type="radio"]']],
61
- ['table', ['table']],
62
- ['textbox', ['input[type="text"]', 'input[type="email"]', 'input[type="search"]', 'textarea']],
63
- ]);
64
-
65
- /** Elements whose text is markup rather than page copy — `getByText` must never land on one. */
66
- const TEXT_SKIP_TAGS = ['SCRIPT', 'STYLE', 'HEAD', 'TITLE', 'META', 'LINK', 'NOSCRIPT'];
67
-
68
- /** A CSS attribute selector takes a double-quoted string, which is what `JSON.stringify` writes. */
69
- const roleSelector = (role: string): string =>
70
- [`[role=${JSON.stringify(role)}]`, ...(IMPLICIT_ROLE_ELEMENTS.get(role) ?? [])].join(',');
71
-
72
- /**
73
- * The call a test wrote, rebuilt from the selection. Every refusal below quotes it, because
74
- * "an e2e locator matched nothing" names no line of the test file and this does.
75
- */
76
- export function selectionCall(selection: E2eSelection): string {
77
- const tail = selection.first ? '.first()' : '';
78
- if (selection.kind === 'css') return `page.locator(${JSON.stringify(selection.selector)})${tail}`;
79
- if (selection.kind === 'text') return `page.getByText(${JSON.stringify(selection.text)})${tail}`;
80
- const options = [
81
- ...(selection.name === undefined ? [] : [`name: ${JSON.stringify(selection.name)}`]),
82
- ...(selection.level === undefined ? [] : [`level: ${String(selection.level)}`]),
83
- ];
84
- const role = JSON.stringify(selection.role);
85
- const suffix = options.length === 0 ? '' : `, { ${options.join(', ')} }`;
86
- return `page.getByRole(${role}${suffix})${tail}`;
87
- }
88
-
89
- /**
90
- * The candidate list, as a JS expression evaluating to an array of elements.
91
- *
92
- * `getByRole` and `getByText` are not CSS and cannot be: a role is implicit in a tag name, an
93
- * accessible name comes off four different attributes before it comes off the text, and
94
- * `getByText` has to pick the INNERMOST element that contains the string. So the union selector
95
- * is only the cheap first pass and every rule after it runs in JS, in the page.
96
- */
97
- function candidateSource(selection: E2eSelection): string {
98
- if (selection.kind === 'css') return `all(${JSON.stringify(selection.selector)})`;
99
- if (selection.kind === 'text') {
100
- const needle = JSON.stringify(selection.text.replace(/\s+/g, ' ').trim().toLowerCase());
101
- // Innermost FIRST and the skip list second, in that order. Reversed, `<html>` becomes the
102
- // innermost survivor of a page whose only copy of the string is inside a `<script>` — the
103
- // ancestor inherits the match its own excluded child made, which is worse than not filtering
104
- // at all: it reports one match, at the document root, for text no reader can see.
105
- return `innermost(all('*').filter((el) => norm(el.textContent).toLowerCase().indexOf(${needle}) !== -1)).filter((el) => ${JSON.stringify(TEXT_SKIP_TAGS)}.indexOf(el.tagName) === -1)`;
106
- }
107
- const byRole = `all(${JSON.stringify(roleSelector(selection.role))}).filter((el) => { const own = el.getAttribute('role'); return own === null || norm(own).toLowerCase() === ${JSON.stringify(selection.role.toLowerCase())}; })`;
108
- const byLevel =
109
- selection.level === undefined
110
- ? byRole
111
- : `${byRole}.filter((el) => level(el) === ${String(selection.level)})`;
112
- return selection.name === undefined
113
- ? byLevel
114
- : `${byLevel}.filter((el) => accName(el).toLowerCase() === ${JSON.stringify(selection.name.replace(/\s+/g, ' ').trim().toLowerCase())})`;
115
- }
116
-
117
- /**
118
- * The helpers the candidate source above calls, defined once inside the IIFE.
119
- *
120
- * `visible` is `display`/`visibility`/`opacity`, which is character for character what
121
- * `@ultimat3/scraping`'s `snapshotExpression` computes for `ElementSnapshot.visible`. That is a
122
- * COPY and it is the wrong shape: `ScrapeTarget.query()` already answers a snapshot per element
123
- * and `ScrapeFrame` does not expose it, so this driver cannot reach the framework's one definition
124
- * of visible without an edit to `packages/scraping/src/page.ts`. Mirrored deliberately rather than
125
- * invented, so the two cannot disagree about a page until that edit lands.
126
- */
127
- const HELPERS = `
128
- const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim();
129
- const all = (sel) => Array.prototype.slice.call(document.querySelectorAll(sel));
130
- const innermost = (found) => found.filter((el) => !found.some((other) => other !== el && el.contains(other)));
131
- const level = (el) => {
132
- const aria = el.getAttribute('aria-level');
133
- if (aria !== null) return Number(aria);
134
- const tag = el.tagName.toLowerCase();
135
- return /^h[1-6]$/.test(tag) ? Number(tag.slice(1)) : undefined;
136
- };
137
- const accName = (el) => {
138
- const label = el.getAttribute('aria-label');
139
- if (label !== null && norm(label) !== '') return norm(label);
140
- const by = el.getAttribute('aria-labelledby');
141
- if (by !== null) {
142
- const parts = norm(by).split(' ').map((id) => document.getElementById(id)).filter((n) => n).map((n) => norm(n.textContent));
143
- if (norm(parts.join(' ')) !== '') return norm(parts.join(' '));
144
- }
145
- const tag = el.tagName.toLowerCase();
146
- if (tag === 'input') { const v = el.getAttribute('value'); if (v !== null && norm(v) !== '') return norm(v); }
147
- if (tag === 'img') { const a = el.getAttribute('alt'); if (a !== null) return norm(a); }
148
- const text = norm(el.textContent);
149
- if (text !== '') return text;
150
- const title = el.getAttribute('title');
151
- return title !== null ? norm(title) : '';
152
- };`;
153
-
154
- /**
155
- * Returns JSON TEXT rather than an object, the same bargain `cdp-snapshot.ts` makes: a CDP round
156
- * trip serialises the answer anyway, and a string has ONE deserialiser — a schema parse — instead
157
- * of an implicit one inside the browser library plus a cast on this side.
158
- */
159
- export function selectionExpression(selection: E2eSelection, mark?: string): string {
160
- const marker = mark === undefined ? 'null' : JSON.stringify(mark);
161
- return `(() => {${HELPERS}
162
- const found = ${candidateSource(selection)};
163
- const matches = ${selection.first ? 'found.slice(0, 1)' : 'found'};
164
- const el = matches[0];
165
- let visible = false;
166
- if (el) {
167
- const style = getComputedStyle(el);
168
- visible = style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
169
- }
170
- const mark = ${marker};
171
- let marked = false;
172
- if (mark !== null && el) { el.setAttribute(${JSON.stringify(MARK_ATTRIBUTE)}, mark); marked = true; }
173
- return JSON.stringify({ count: matches.length, visible: visible, marked: marked });
174
- })()`;
175
- }
176
-
177
- /** The CSS the marked element answers to — what `ScrapePage.click` is handed once one is marked. */
178
- export const markSelector = (mark: string): string => `[${MARK_ATTRIBUTE}=${JSON.stringify(mark)}]`;
179
-
180
- /** Undo. Best effort by design: a click that navigated took the whole document with it. */
181
- export const unmarkExpression = (mark: string): string =>
182
- `(() => { const el = document.querySelector(${JSON.stringify(markSelector(mark))}); if (el) el.removeAttribute(${JSON.stringify(MARK_ATTRIBUTE)}); return JSON.stringify(true); })()`;
package/src/e2e-spawn.ts DELETED
@@ -1,169 +0,0 @@
1
- // The e2e app's PROCESS half: spawn it on a free port, wait for `/readyz`, restart it on the same
2
- // port as a deploy, stop it. Split from `e2e-app.ts`, which owns the DATABASE half (the throwaway
3
- // state directory, the reset and the seed), so this half runs against any root with an entry —
4
- // which is what lets a unit test drive it without a Postgres.
5
-
6
- // why: the readiness poll must not go through `globalThis.fetch` — inside `bun test` the testing
7
- // preload SEALS it, and the app's `/readyz` would be refused as egress. `node:http` is not sealed.
8
- import { get } from 'node:http';
9
- // why: Bun exposes no path API — the CLI's own bin is joined, not concatenated.
10
- import { join } from 'node:path';
11
- import { assert } from '@ultimat3/core';
12
- import { E2eAppFailedError } from './e2e-errors';
13
-
14
- /** `x dev` (sync included), or the production entry `apps/web/server.ts` under `ROLE=web`. */
15
- export type E2eAppMode = 'dev' | 'serve';
16
-
17
- /** The CLI this process IS — never a global `x`, which may be a different version of the framework. */
18
- export const X_BIN = join(import.meta.dir, 'bin.ts');
19
- const POLL_MS = 250;
20
-
21
- export interface SpawnedE2eApp {
22
- /** `http://localhost:<port>`, no trailing slash. */
23
- readonly base: string;
24
- /** Kill the process. Idempotent. */
25
- stop(): Promise<void>;
26
- /** Kill it and start it again on the SAME port, with `env` added — a deploy. Refused after `stop()`. */
27
- restart(env?: Readonly<Record<string, string>>): Promise<void>;
28
- }
29
-
30
- export interface SpawnE2eAppOptions {
31
- readonly root: string;
32
- readonly mode: E2eAppMode;
33
- /** Every spawn's environment, on top of this process's minus `NODE_ENV`. */
34
- readonly env: Readonly<Record<string, string>>;
35
- /** Already screened finite by the caller. */
36
- readonly readyTimeoutMs: number;
37
- }
38
-
39
- /** A port nothing holds right now, asked of the OS and handed to the child. */
40
- const freePort = (): number => {
41
- const probe = Bun.serve({ port: 0, fetch: () => new Response() });
42
- const port = probe.port ?? 0;
43
- probe.stop(true);
44
- return port;
45
- };
46
-
47
- /**
48
- * This process's environment minus what makes the child a TEST process. Spawned from `bun test`,
49
- * `NODE_ENV=test` rode along and the app resolved its environment as `test` — so a development-only
50
- * seam (the demo viewer an app installs instead of a sign-in route) was off and every page 401'd.
51
- * The app under e2e is a development app unless the caller's `env` says otherwise.
52
- */
53
- export const inherited = (): Record<string, string | undefined> => {
54
- const { NODE_ENV: _node, ...rest } = Bun.env;
55
- return { ...rest, ULTIMATE_ENV: 'development' };
56
- };
57
-
58
- export const refuse = (step: string, output: string): E2eAppFailedError =>
59
- new E2eAppFailedError({ step, output });
60
-
61
- /** Spawn the app and answer once `/readyz` does; refuses with the app's own output tail. */
62
- export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE2eApp> {
63
- const deadline = options.readyTimeoutMs;
64
- const port = freePort();
65
- // Its own scrape port too: every role opens one, the default is a fixed 9090, and a second app —
66
- // or a developer's `x dev` — already holding it is an app that dies at boot with X_PORT_IN_USE.
67
- const metricsPort = freePort();
68
- const base = `http://localhost:${String(port)}`;
69
- const command =
70
- options.mode === 'serve'
71
- ? ['bun', 'apps/web/server.ts']
72
- : ['bun', X_BIN, 'dev', '--port', String(port)];
73
- const spawnApp = (extra: Readonly<Record<string, string>>) =>
74
- Bun.spawn(command, {
75
- cwd: options.root,
76
- env: {
77
- ...inherited(),
78
- ...options.env,
79
- METRICS_PORT: String(metricsPort),
80
- // The origin the app is actually reachable at. A page that renders a typed client builds
81
- // its absolute URLs from it, and without it `/feed` answered 500 with X_ENV_MISSING APP_URL.
82
- APP_URL: base,
83
- ...(options.mode === 'serve' ? { ROLE: 'web', PORT: String(port) } : {}),
84
- ...extra,
85
- },
86
- stdout: 'pipe',
87
- stderr: 'pipe',
88
- });
89
- const ready = async (child: ReturnType<typeof spawnApp>, tail: () => string): Promise<void> => {
90
- for (let waited = 0; waited < deadline; waited += POLL_MS) {
91
- if (child.exitCode !== null) break;
92
- if (await answersOk(`${base}/readyz`)) return;
93
- await Bun.sleep(POLL_MS);
94
- }
95
- child.kill();
96
- await child.exited;
97
- throw refuse(`${command.join(' ')} never answered ${base}/readyz`, tail());
98
- };
99
-
100
- let child = spawnApp({});
101
- // Drained from the start and kept bounded: an app's log is unbounded, and a pipe nobody reads
102
- // fills its buffer and blocks the child on its next write — an app that "never got ready".
103
- let tail = drainTail(child.stdout, child.stderr);
104
- let stopped = false;
105
- const stop = async (): Promise<void> => {
106
- if (stopped) return;
107
- stopped = true;
108
- child.kill();
109
- await child.exited;
110
- };
111
- try {
112
- await ready(child, tail);
113
- } catch (error) {
114
- await stop();
115
- throw error;
116
- }
117
- return {
118
- base,
119
- stop,
120
- async restart(next: Readonly<Record<string, string>> = {}): Promise<void> {
121
- // `stop()` is final. A child respawned here would be one no later `stop()` kills — the flag
122
- // already says done — and `startE2eApp` has deleted the state directory it would boot on.
123
- assert(
124
- !stopped,
125
- 'restart() was called on an e2e app that was already stopped, so there is no app to deploy over',
126
- 'startE2eApp({ root }) again for a fresh app — restart() is for an app that is still running',
127
- );
128
- // The same port and the same state directory — a deploy, not a second app: a tab already
129
- // open on `base` sees the new build on its next request, and the data it wrote is still there.
130
- child.kill();
131
- await child.exited;
132
- child = spawnApp(next);
133
- tail = drainTail(child.stdout, child.stderr);
134
- await ready(child, tail);
135
- },
136
- };
137
- }
138
-
139
- const TAIL_CHARS = 16_000;
140
-
141
- /** Read both streams to their end in the background; answer the last `TAIL_CHARS` of either. */
142
- function drainTail(...streams: readonly ReadableStream<Uint8Array>[]): () => string {
143
- let text = '';
144
- for (const stream of streams) {
145
- void (async () => {
146
- const decoder = new TextDecoder();
147
- for await (const chunk of stream) {
148
- text = (text + decoder.decode(chunk, { stream: true })).slice(-TAIL_CHARS);
149
- }
150
- })().catch(() => undefined);
151
- }
152
- return () => text;
153
- }
154
-
155
- /** One GET, answered as "2xx or not" — never a throw, and never through the sealed `fetch`. */
156
- function answersOk(url: string): Promise<boolean> {
157
- return new Promise((resolve) => {
158
- const request = get(url, (response) => {
159
- response.resume();
160
- const status = response.statusCode ?? 0;
161
- resolve(status >= 200 && status < 300);
162
- });
163
- request.on('error', () => resolve(false));
164
- request.setTimeout(POLL_MS * 4, () => {
165
- request.destroy();
166
- resolve(false);
167
- });
168
- });
169
- }
@@ -1,26 +0,0 @@
1
- // The actor a route is rendered AS when the render exists only to be weighed. `app/` pages are
2
- // authed by construction: a `load` that calls a policy-guarded query denies an anonymous actor with
3
- // `X_UNAUTHENTICATED`, so under the anonymous build context every authed page was reported
4
- // `X_BUDGET_UNMEASURED` and a real app could not pass the `budgets` step (measured 2026-09-05).
5
- // Weighing bytes needs no data authority — the rendered document is discarded — so this actor holds
6
- // every permission. It is handed ONLY to the weigh-and-discard branch of `prerender.ts`, never to
7
- // `renderStatic`: a `site/` artifact is published to everyone, and a guarded query inside its `load`
8
- // must keep failing the build rather than rendering another actor's rows into a file.
9
-
10
- import type { Actor } from '@ultimat3/core';
11
-
12
- /** The id every trace and log line under a measurement render carries, so it is recognisable. */
13
- export const MEASUREMENT_ACTOR_ID = 'x-build-measure';
14
-
15
- /**
16
- * `kind: 'service'` and not `'user'`: an app's own `requireMember()`-style helper that resolves a
17
- * user to a row has nothing to resolve here, and the honest kind says so. `'*'` is the grant
18
- * `actorHas` reads as everything — the same spelling a role map uses for a superuser.
19
- */
20
- export const measurementActor = (): Actor => ({
21
- kind: 'service',
22
- id: MEASUREMENT_ACTOR_ID,
23
- roles: [],
24
- scopes: [],
25
- permissions: ['*'],
26
- });
File without changes
File without changes