@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.
- package/CLAUDE.md +84 -1582
- package/README.md +40 -2
- package/package.json +31 -31
- package/src/api-registration.ts +124 -0
- package/src/app-artifacts.ts +39 -0
- package/src/app-boundaries.ts +5 -10
- package/src/app-env.ts +2 -2
- package/src/app-load.ts +37 -5
- package/src/app-openapi.ts +27 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-root.ts +1 -1
- package/src/async-pages.ts +32 -0
- package/src/bin.ts +7 -1
- package/src/boundary-findings.ts +36 -0
- package/src/browser-launcher-fake-html.ts +80 -0
- package/src/browser-launcher-fake.ts +165 -0
- package/src/browser-launcher-port.ts +159 -0
- package/src/browser-launcher.ts +26 -133
- package/src/budgets.ts +56 -13
- package/src/build-errors.ts +54 -0
- package/src/cdp-shot-a11y.ts +77 -0
- package/src/cdp-shot-clock.ts +14 -0
- package/src/cdp-shot-driver.ts +150 -0
- package/src/cdp-shot-element.ts +147 -0
- package/src/cdp-shot-errors.ts +62 -0
- package/src/cdp-shot-keys.ts +152 -0
- package/src/cdp-shot-page.ts +230 -0
- package/src/cdp-shot-watch.ts +241 -0
- package/src/cmd-affected-spec.ts +24 -0
- package/src/cmd-affected.ts +3 -19
- package/src/cmd-build-spec.ts +16 -0
- package/src/cmd-build.ts +46 -20
- package/src/cmd-ci-spec.ts +24 -0
- package/src/cmd-ci.ts +17 -21
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db-spec.ts +99 -0
- package/src/cmd-db.ts +15 -94
- package/src/cmd-deploy-helm.ts +136 -0
- package/src/cmd-deploy-spec.ts +40 -0
- package/src/cmd-deploy.ts +117 -42
- package/src/cmd-dev-spec.ts +25 -0
- package/src/cmd-dev.ts +78 -122
- package/src/cmd-docs-spec.ts +16 -0
- package/src/cmd-docs.ts +2 -11
- package/src/cmd-doctor-spec.ts +21 -0
- package/src/cmd-doctor.ts +4 -18
- package/src/cmd-env-spec.ts +18 -0
- package/src/cmd-env.ts +4 -13
- package/src/cmd-errors-spec.ts +23 -0
- package/src/cmd-errors.ts +3 -17
- package/src/cmd-fix-spec.ts +18 -0
- package/src/cmd-fix.ts +4 -13
- package/src/cmd-generate-spec.ts +31 -0
- package/src/cmd-generate.ts +46 -42
- package/src/cmd-i18n-spec.ts +16 -0
- package/src/cmd-i18n.ts +7 -14
- package/src/cmd-jobs-spec.ts +56 -0
- package/src/cmd-jobs.ts +3 -50
- package/src/cmd-manifest-spec.ts +15 -0
- package/src/cmd-manifest.ts +19 -20
- package/src/cmd-mcp-spec.ts +21 -0
- package/src/cmd-mcp.ts +2 -15
- package/src/cmd-new-spec.ts +33 -0
- package/src/cmd-new.ts +38 -33
- package/src/cmd-policy-spec.ts +13 -0
- package/src/cmd-policy.ts +2 -9
- package/src/cmd-pr-spec.ts +39 -0
- package/src/cmd-pr.ts +5 -65
- package/src/cmd-registries-spec.ts +32 -0
- package/src/cmd-registries.ts +4 -25
- package/src/cmd-routes-spec.ts +12 -0
- package/src/cmd-routes.ts +2 -7
- package/src/cmd-secrets-spec.ts +19 -0
- package/src/cmd-secrets.ts +22 -34
- package/src/cmd-shot-island.ts +12 -13
- package/src/cmd-shot-spec.ts +53 -0
- package/src/cmd-shot.ts +11 -60
- package/src/cmd-tasks-spec.ts +21 -0
- package/src/cmd-tasks.ts +2 -16
- package/src/cmd-test-spec.ts +54 -0
- package/src/cmd-test.ts +4 -49
- package/src/cmd-verify-spec.ts +28 -0
- package/src/cmd-verify.ts +3 -23
- package/src/db-seed.ts +4 -2
- package/src/dev-dashboard.ts +4 -4
- package/src/dev-lock.ts +18 -2
- package/src/dev-port.ts +21 -0
- package/src/dev-route-table.ts +119 -0
- package/src/dispatch.ts +12 -5
- package/src/document-styles.ts +1 -1
- package/src/drift.ts +4 -18
- package/src/error-catalog.ts +16 -9
- package/src/error-codes.ts +33 -28
- package/src/error-pages.ts +19 -7
- package/src/error-unthrown.ts +130 -0
- package/src/errors.ts +22 -29
- package/src/favicon.ts +2 -2
- package/src/fix-imports.ts +1 -1
- package/src/fix-scan.ts +2 -9
- package/src/flag-reads.ts +1 -2
- package/src/foreign-text.ts +36 -0
- package/src/framework-schema.ts +5 -6
- package/src/generate-feature.ts +42 -0
- package/src/generate-files.ts +7 -3
- package/src/generate-grants.ts +83 -0
- package/src/generate-kinds.ts +56 -4
- package/src/i18n-index.ts +59 -10
- package/src/icon-assets.ts +1 -1
- package/src/image-prepare.ts +14 -0
- package/src/import-scan.ts +63 -0
- package/src/index.ts +67 -334
- package/src/invocation-flags.ts +26 -0
- package/src/island-bundle.ts +38 -11
- package/src/island-capture.ts +6 -6
- package/src/island-harness-script.ts +6 -2
- package/src/island-realtime.ts +98 -0
- package/src/island-shot.ts +5 -0
- package/src/island-store.ts +131 -0
- package/src/island-verdict.ts +2 -2
- package/src/job-registration.ts +42 -0
- package/src/jobs-driver.ts +2 -2
- package/src/live-routes.ts +82 -42
- package/src/load-findings.ts +51 -0
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +30 -26
- package/src/mcp-host.ts +27 -12
- package/src/mcp-ui-diff.ts +27 -0
- package/src/mcp-ui-inspect.ts +4 -4
- package/src/mcp-ui-interact.ts +21 -15
- package/src/mcp-ui.ts +19 -15
- package/src/measure-database.ts +73 -0
- package/src/measure-paths.ts +74 -0
- package/src/measure-scope.ts +74 -0
- package/src/messages.ts +1 -3
- package/src/metrics-endpoint.ts +1 -1
- package/src/otlp-export.ts +1 -1
- package/src/output.ts +6 -0
- package/src/page-sync.ts +54 -0
- package/src/permission-grants.ts +86 -0
- package/src/prerender-out.ts +25 -0
- package/src/prerender.ts +121 -102
- package/src/pwa-artifacts.ts +3 -3
- package/src/realtime-browser-probe-fixture.ts +2 -2
- package/src/reexport-manifest.ts +2 -1
- package/src/registry.ts +80 -56
- package/src/role-realtime.ts +36 -0
- package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
- package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
- package/src/role-start-types.ts +112 -0
- package/src/{dev-roles.ts → role-start.ts} +42 -115
- package/src/{dev-sync.ts → role-sync.ts} +23 -11
- package/src/root-env.ts +67 -0
- package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
- package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
- package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
- package/src/runtime-jobs.ts +87 -0
- package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
- package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
- package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
- package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
- package/src/runtime-realtime.ts +55 -0
- package/src/{dev-render.ts → runtime-render.ts} +119 -20
- package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
- package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
- package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
- package/src/scaffold-fixture.ts +28 -6
- package/src/scaffold-typecheck.ts +6 -3
- package/src/schema-drift.ts +7 -1
- package/src/script-csp.ts +5 -2
- package/src/secrets-rotation.ts +59 -0
- package/src/serve-boot.ts +192 -0
- package/src/serve-drain.ts +24 -0
- package/src/serve-entry.ts +6 -0
- package/src/serve-env.ts +116 -0
- package/src/serve-types.ts +55 -0
- package/src/serve.ts +44 -338
- package/src/shot-server.ts +2 -2
- package/src/shot-settle.ts +10 -1
- package/src/shot-theme.ts +3 -3
- package/src/shot-verdict.ts +16 -7
- package/src/signal-shred.ts +27 -0
- package/src/solid-loader.ts +26 -2
- package/src/static-report.ts +8 -1
- package/src/sw-artifacts.ts +13 -3
- package/src/sync-url.ts +31 -0
- package/src/templates/action.ts +30 -16
- package/src/templates/entity.ts +12 -7
- package/src/templates/index.ts +1 -1
- package/src/templates/job.ts +10 -7
- package/src/templates/policy.ts +20 -2
- package/src/templates/resource-create.ts +127 -0
- package/src/templates/resource-form-island.ts +76 -30
- package/src/templates/resource.ts +10 -4
- package/src/templates/route.ts +3 -0
- package/src/templates/scaffold-app.ts +4 -1
- package/src/templates/scaffold-auth.ts +3 -1
- package/src/templates/scaffold-container-compose.ts +184 -0
- package/src/templates/scaffold-container.ts +25 -145
- package/src/templates/scaffold-dashboard-example.ts +2 -2
- package/src/templates/scaffold-db-package.ts +16 -3
- package/src/templates/scaffold-demo-org.ts +41 -0
- package/src/templates/scaffold-entries.ts +1 -1
- package/src/templates/scaffold-env.ts +6 -0
- package/src/templates/scaffold-helm-templates.ts +66 -7
- package/src/templates/scaffold-helm.ts +27 -5
- package/src/templates/scaffold-i18n.ts +15 -10
- package/src/templates/scaffold-repo.ts +13 -10
- package/src/templates/scaffold-roles.ts +38 -10
- package/src/templates/slice-foundation.ts +1 -1
- package/src/templates/wrap.ts +4 -1
- package/src/test-passes.ts +2 -1
- package/src/test-workers.ts +26 -0
- package/src/ts-scan.ts +3 -6
- package/src/tsconfig-references.ts +1 -2
- package/src/verify-checks.ts +41 -39
- package/src/verify-e2e.ts +41 -0
- package/src/verify-run.ts +115 -50
- package/src/verify-step.ts +3 -3
- package/src/verify-tests.ts +22 -30
- package/src/verify-typecheck.ts +28 -0
- package/src/web-binding.ts +2 -2
- package/src/worker-bundle.ts +192 -0
- package/src/workspace-graph.ts +10 -33
- package/src/cdp-browser.ts +0 -100
- package/src/cdp-connection.ts +0 -211
- package/src/cdp-e2e-page.ts +0 -209
- package/src/cdp-errors.ts +0 -56
- package/src/cdp-launch.ts +0 -139
- package/src/e2e-dom-fixture.ts +0 -117
- package/src/e2e-driver.ts +0 -97
- package/src/e2e-errors.ts +0 -103
- package/src/e2e-evaluate.ts +0 -156
- package/src/e2e-locator.ts +0 -86
- package/src/e2e-page.ts +0 -150
- package/src/e2e-selection.ts +0 -182
- package/src/measurement-actor.ts +0 -26
- /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
package/src/e2e-locator.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
// `LocatorLike` over a browser page: a handle that holds a SELECTION and resolves nothing until
|
|
2
|
-
// something asks. One round trip per question, and no waiting of its own — `toBeVisible()` owns
|
|
3
|
-
// the only retry budget in the harness and drives this by looking again.
|
|
4
|
-
|
|
5
|
-
import type { LocatorLike } from '@ultimat3/testing';
|
|
6
|
-
import { E2eLocatorAmbiguousError, E2eLocatorEmptyError } from './e2e-errors';
|
|
7
|
-
import type { E2eResolution, E2eSelection } from './e2e-selection';
|
|
8
|
-
import { markSelector, selectionExpression, unmarkExpression } from './e2e-selection';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* What a locator needs from the browser. Narrower than `ScrapePage` on purpose: three methods is
|
|
12
|
-
* what a test has to stand up to prove the mapping, and `click` is taken from the page rather than
|
|
13
|
-
* re-derived here so the actionability wait `@ultimat3/scraping` already performs is the one that
|
|
14
|
-
* runs.
|
|
15
|
-
*/
|
|
16
|
-
export interface LocatablePage {
|
|
17
|
-
evaluate(expression: string): Promise<unknown>;
|
|
18
|
-
click(selector: string): Promise<void>;
|
|
19
|
-
url(): string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const readResolution = (raw: unknown): E2eResolution => {
|
|
23
|
-
const decoded = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
|
|
24
|
-
const held = decoded as { count?: unknown; visible?: unknown; marked?: unknown };
|
|
25
|
-
return {
|
|
26
|
-
count: typeof held?.count === 'number' ? held.count : 0,
|
|
27
|
-
visible: held?.visible === true,
|
|
28
|
-
marked: held?.marked === true,
|
|
29
|
-
};
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* One mark per click, never a constant: two locators marking the same page at once would each
|
|
34
|
-
* click whichever element the other had just tagged. Seeded from a counter and not from
|
|
35
|
-
* `Math.random()`, which `bun run flight-copies` refuses in shipped source and which would make
|
|
36
|
-
* two runs of one test address two different elements.
|
|
37
|
-
*/
|
|
38
|
-
let marks = 0;
|
|
39
|
-
const nextMark = (): string => {
|
|
40
|
-
marks += 1;
|
|
41
|
-
return `m${String(marks)}`;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
/** Test seam: the counter is process-global, so a test that asserts a mark resets it first. */
|
|
45
|
-
export const resetLocatorMarks = (): void => {
|
|
46
|
-
marks = 0;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Nothing is cached. A locator taken before a navigation must address the page that is there when
|
|
51
|
-
* it is USED — the same rule `ScrapeFrame` states for a frame handle — so every method below
|
|
52
|
-
* re-resolves, and a handle can never go stale behind the caller's back.
|
|
53
|
-
*/
|
|
54
|
-
export function e2eLocator(page: LocatablePage, selection: E2eSelection): LocatorLike {
|
|
55
|
-
const resolve = async (mark?: string): Promise<E2eResolution> =>
|
|
56
|
-
readResolution(await page.evaluate(selectionExpression(selection, mark)));
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
count: async () => (await resolve()).count,
|
|
60
|
-
/**
|
|
61
|
-
* A point-in-time look, and deliberately not an ambiguity check: an assertion handed a locator
|
|
62
|
-
* that matched three elements has an ANSWER — was the first one visible — and a refusal there
|
|
63
|
-
* would turn `expect(...).toBeVisible()` into a crash rather than a verdict. Only `click`
|
|
64
|
-
* refuses, because only `click` has to pick one.
|
|
65
|
-
*/
|
|
66
|
-
isVisible: async () => (await resolve()).visible,
|
|
67
|
-
first: () => e2eLocator(page, { ...selection, first: true }),
|
|
68
|
-
click: async () => {
|
|
69
|
-
const mark = nextMark();
|
|
70
|
-
const resolution = await resolve(mark);
|
|
71
|
-
if (resolution.count === 0) {
|
|
72
|
-
throw new E2eLocatorEmptyError({ selection, url: page.url() });
|
|
73
|
-
}
|
|
74
|
-
if (resolution.count > 1 && !selection.first) {
|
|
75
|
-
throw new E2eLocatorAmbiguousError({ selection, count: resolution.count });
|
|
76
|
-
}
|
|
77
|
-
try {
|
|
78
|
-
await page.click(markSelector(mark));
|
|
79
|
-
} finally {
|
|
80
|
-
// Best effort, and never allowed to replace the click's own failure: a click that
|
|
81
|
-
// navigated took the whole document — attribute included — with it.
|
|
82
|
-
await page.evaluate(unmarkExpression(mark)).catch(() => undefined);
|
|
83
|
-
}
|
|
84
|
-
},
|
|
85
|
-
};
|
|
86
|
-
}
|
package/src/e2e-page.ts
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
// `PageLike` over a browser page. The adapter itself: `@ultimat3/testing` declares the surface an
|
|
2
|
-
// e2e test drives and `@ultimat3/scraping` owns the only driver that can drive one, and neither
|
|
3
|
-
// may import the other — so the join is here, in the one package allowed to know about both.
|
|
4
|
-
|
|
5
|
-
import { finiteCount } from '@ultimat3/core';
|
|
6
|
-
import type { LocatorLike, PageLike } from '@ultimat3/testing';
|
|
7
|
-
import { E2eServiceWorkerAbsentError } from './e2e-errors';
|
|
8
|
-
import { evaluateClosure } from './e2e-evaluate';
|
|
9
|
-
import { e2eLocator } from './e2e-locator';
|
|
10
|
-
import type { E2eSelection } from './e2e-selection';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* What this adapter needs of a browser: four required members, every one of them on `ScrapePage`.
|
|
14
|
-
* Declared structurally rather than as `ScrapePage` so a test can stand one up in six lines — the
|
|
15
|
-
* same bargain `cdp-port.ts` makes about puppeteer, one layer up.
|
|
16
|
-
*/
|
|
17
|
-
export interface E2eBrowserPage {
|
|
18
|
-
url(): string;
|
|
19
|
-
goto(url: string, options?: { readonly timeout?: number | undefined }): Promise<unknown>;
|
|
20
|
-
evaluate(expression: string): Promise<unknown>;
|
|
21
|
-
click(selector: string): Promise<void>;
|
|
22
|
-
/**
|
|
23
|
-
* The browser's own network condition, which `E2eFixtures.offline()`/`online()` forward to.
|
|
24
|
-
* `ScrapePage` has it (`page-over-target.ts`), reaching `CdpPageLike.setOfflineMode` through
|
|
25
|
-
* `cdp-target.ts`'s guard.
|
|
26
|
-
*
|
|
27
|
-
* OPTIONAL for the reason the four above are structural: this port is the shape of somebody
|
|
28
|
-
* ELSE's object, and requiring it would cost every six-line double a type error for a capability
|
|
29
|
-
* a test that never goes offline does not need. Absent, `e2e-driver.ts` keeps refusing by name —
|
|
30
|
-
* a coded refusal, never a silent no-op, because an `offline()` that did nothing would let the
|
|
31
|
-
* app's ONLINE page pass an offline test.
|
|
32
|
-
*/
|
|
33
|
-
offline?(enabled: boolean): Promise<void>;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface E2ePageOptions {
|
|
37
|
-
readonly page: E2eBrowserPage;
|
|
38
|
-
/** Every `goto('/feed')` in an e2e suite is app-relative; this is what makes one absolute. */
|
|
39
|
-
readonly baseUrl: string;
|
|
40
|
-
/** Per-navigation deadline, handed to the driver rather than enforced here. */
|
|
41
|
-
readonly timeoutMs?: number | undefined;
|
|
42
|
-
/** How long `waitForServiceWorker()` waits before refusing. Bounded IN THE PAGE. */
|
|
43
|
-
readonly serviceWorkerTimeoutMs?: number | undefined;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Named once, so both refusals below name the call an app's test preload actually makes. */
|
|
47
|
-
const SUBJECT = 'installE2eDriver';
|
|
48
|
-
|
|
49
|
-
export const DEFAULT_E2E_TIMEOUT_MS = 30_000;
|
|
50
|
-
export const DEFAULT_SERVICE_WORKER_TIMEOUT_MS = 10_000;
|
|
51
|
-
|
|
52
|
-
/** Every in-page expression here answers JSON text, for the reason `cdp-snapshot.ts` states. */
|
|
53
|
-
const readField = (raw: unknown, key: string): unknown => {
|
|
54
|
-
const decoded = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
|
|
55
|
-
return typeof decoded === 'object' && decoded !== null
|
|
56
|
-
? (decoded as Record<string, unknown>)[key]
|
|
57
|
-
: undefined;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const TITLE = '(() => JSON.stringify({ title: document.title }))()';
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* The first flush, read by a `fetch` INSIDE the page after the navigation, because neither
|
|
64
|
-
* `ScrapePage` nor `CdpPageLike` exposes a response body — puppeteer's `page.on('response')` is
|
|
65
|
-
* not on the port and adding it would be an edit to `@ultimat3/scraping`.
|
|
66
|
-
*
|
|
67
|
-
* The cost, stated rather than hidden: this is a SECOND request to the same route, so what it
|
|
68
|
-
* measures is that route's streaming behaviour and not the byte-for-byte first chunk the open
|
|
69
|
-
* document received. It runs in the page, so it carries the page's cookies and its origin — a
|
|
70
|
-
* `fetch` from the test process would carry neither.
|
|
71
|
-
*/
|
|
72
|
-
const firstFlushExpression = (url: string): string =>
|
|
73
|
-
`(() => fetch(${JSON.stringify(url)}, { credentials: 'same-origin' })
|
|
74
|
-
.then((response) => response.body.getReader().read())
|
|
75
|
-
.then((chunk) => JSON.stringify({ html: new TextDecoder().decode(chunk.value || new Uint8Array()) })))()`;
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* `ready` alone is not control: a first load activates a worker that is not yet the page's
|
|
79
|
-
* controller, and an offline assertion made in that window tests nothing. So this waits for
|
|
80
|
-
* `controllerchange` too — an EVENT, not a poll, so the harness still has exactly one retry loop.
|
|
81
|
-
*/
|
|
82
|
-
const serviceWorkerExpression = (timeoutMs: number): string =>
|
|
83
|
-
`(() => {
|
|
84
|
-
if (!navigator.serviceWorker) return JSON.stringify({ controlled: false });
|
|
85
|
-
const controlled = new Promise((resolve) => {
|
|
86
|
-
if (navigator.serviceWorker.controller) { resolve(true); return; }
|
|
87
|
-
navigator.serviceWorker.addEventListener('controllerchange', () => resolve(true), { once: true });
|
|
88
|
-
});
|
|
89
|
-
const deadline = new Promise((resolve) => setTimeout(() => resolve(false), ${String(timeoutMs)}));
|
|
90
|
-
return Promise.race([navigator.serviceWorker.ready.then(() => controlled), deadline])
|
|
91
|
-
.catch(() => false)
|
|
92
|
-
.then((ok) => JSON.stringify({ controlled: ok === true }));
|
|
93
|
-
})()`;
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* The adapter. Every member re-reads the live page: a `PageLike` handed to a test outlives every
|
|
97
|
-
* navigation the test makes, so nothing here may capture a URL, a document or an element.
|
|
98
|
-
*/
|
|
99
|
-
export function e2ePage(options: E2ePageOptions): PageLike {
|
|
100
|
-
const page = options.page;
|
|
101
|
-
// Both are screened HERE, at construction, and not where they land: `timeout` is handed to a
|
|
102
|
-
// driver that reads it as a deadline, and `swTimeout` is INTERPOLATED into the in-page source,
|
|
103
|
-
// where `setTimeout(fn, NaN)` is `setTimeout(fn, 0)` — so a NaN budget makes every
|
|
104
|
-
// `waitForServiceWorker()` refuse a worker that really did take control. A misdiagnosis reported
|
|
105
|
-
// as a test failure is worse than the failure. Floor 0, because a driver reads `timeout: 0` as
|
|
106
|
-
// "no deadline" and that is a value an app is entitled to declare.
|
|
107
|
-
const timeout = finiteCount(SUBJECT, 'timeoutMs', options.timeoutMs ?? DEFAULT_E2E_TIMEOUT_MS);
|
|
108
|
-
const swTimeout = finiteCount(
|
|
109
|
-
SUBJECT,
|
|
110
|
-
'serviceWorkerTimeoutMs',
|
|
111
|
-
options.serviceWorkerTimeoutMs ?? DEFAULT_SERVICE_WORKER_TIMEOUT_MS,
|
|
112
|
-
);
|
|
113
|
-
const absolute = (url: string): string => new URL(url, options.baseUrl).toString();
|
|
114
|
-
const locate = (selection: E2eSelection): LocatorLike => e2eLocator(page, selection);
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
url: () => page.url(),
|
|
118
|
-
goto: (url) => page.goto(absolute(url), { timeout }),
|
|
119
|
-
// The page's OWN url, re-read at call time: a reload of the url the adapter was built with
|
|
120
|
-
// would navigate away from wherever the test had got to.
|
|
121
|
-
reload: () => page.goto(page.url(), { timeout }),
|
|
122
|
-
title: async () => {
|
|
123
|
-
const title = readField(await page.evaluate(TITLE), 'title');
|
|
124
|
-
return typeof title === 'string' ? title : '';
|
|
125
|
-
},
|
|
126
|
-
gotoStreamed: async (url) => {
|
|
127
|
-
const target = absolute(url);
|
|
128
|
-
await page.goto(target, { timeout });
|
|
129
|
-
const html = readField(await page.evaluate(firstFlushExpression(target)), 'html');
|
|
130
|
-
return { html: typeof html === 'string' ? html : '' };
|
|
131
|
-
},
|
|
132
|
-
waitForServiceWorker: async () => {
|
|
133
|
-
const raw = await page.evaluate(serviceWorkerExpression(swTimeout));
|
|
134
|
-
if (readField(raw, 'controlled') !== true) {
|
|
135
|
-
throw new E2eServiceWorkerAbsentError({ url: page.url(), timeoutMs: swTimeout });
|
|
136
|
-
}
|
|
137
|
-
},
|
|
138
|
-
evaluate: <T>(fn: () => T): Promise<T> => evaluateClosure(page, fn) as unknown as Promise<T>,
|
|
139
|
-
locator: (selector) => locate({ kind: 'css', selector, first: false }),
|
|
140
|
-
getByRole: (role, roleOptions) =>
|
|
141
|
-
locate({
|
|
142
|
-
kind: 'role',
|
|
143
|
-
role,
|
|
144
|
-
first: false,
|
|
145
|
-
...(roleOptions?.name === undefined ? {} : { name: roleOptions.name }),
|
|
146
|
-
...(roleOptions?.level === undefined ? {} : { level: roleOptions.level }),
|
|
147
|
-
}),
|
|
148
|
-
getByText: (text) => locate({ kind: 'text', text, first: false }),
|
|
149
|
-
};
|
|
150
|
-
}
|
package/src/e2e-selection.ts
DELETED
|
@@ -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/measurement-actor.ts
DELETED
|
@@ -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
|