@ultimat3/cli 21.0.0 → 22.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +84 -1650
- package/README.md +40 -2
- package/package.json +30 -30
- package/src/api-registration.ts +124 -0
- package/src/app-artifacts.ts +39 -0
- package/src/app-boundaries.ts +5 -10
- 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 +11 -1
- 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 +111 -43
- package/src/cmd-dev-spec.ts +25 -0
- package/src/cmd-dev.ts +55 -36
- 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 +3 -3
- 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 +28 -30
- package/src/error-pages.ts +19 -7
- package/src/errors.ts +14 -0
- 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 -348
- package/src/invocation-flags.ts +26 -0
- package/src/island-bundle.ts +6 -1
- package/src/island-capture.ts +6 -6
- package/src/island-harness-script.ts +6 -2
- package/src/island-realtime.ts +15 -8
- package/src/island-shot.ts +5 -0
- package/src/island-store.ts +131 -0
- package/src/island-verdict.ts +1 -1
- package/src/job-registration.ts +42 -0
- package/src/jobs-driver.ts +2 -2
- package/src/load-findings.ts +51 -0
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +28 -27
- 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 +23 -17
- 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/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/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} +22 -8
- 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} +39 -16
- 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} +18 -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-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 -347
- 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/sync-url.ts +21 -1
- 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 +49 -12
- package/src/templates/resource.ts +10 -4
- 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 +24 -159
- 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 +10 -7
- package/src/verify-run.ts +11 -1
- package/src/verify-step.ts +3 -3
- package/src/verify-tests.ts +1 -26
- package/src/verify-typecheck.ts +28 -0
- package/src/web-binding.ts +2 -2
- package/src/workspace-graph.ts +10 -33
- package/src/cdp-browser.ts +0 -94
- package/src/cdp-connection.ts +0 -247
- package/src/cdp-e2e-page.ts +0 -180
- package/src/cdp-e2e-session.ts +0 -199
- package/src/cdp-errors.ts +0 -56
- package/src/cdp-launch.ts +0 -193
- package/src/cdp-offline-script.ts +0 -73
- package/src/cdp-pipe.ts +0 -77
- package/src/e2e-app.ts +0 -103
- package/src/e2e-browser-handle.ts +0 -55
- package/src/e2e-dom-fixture.ts +0 -117
- package/src/e2e-driver.ts +0 -117
- package/src/e2e-errors.ts +0 -117
- package/src/e2e-evaluate.ts +0 -156
- package/src/e2e-locator.ts +0 -86
- package/src/e2e-page.ts +0 -153
- package/src/e2e-preload.ts +0 -64
- package/src/e2e-probe.ts +0 -23
- package/src/e2e-selection.ts +0 -182
- package/src/e2e-spawn.ts +0 -169
- package/src/measurement-actor.ts +0 -26
- /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
- /package/src/{dev-render.ts → runtime-render.ts} +0 -0
package/src/e2e-driver.ts
DELETED
|
@@ -1,117 +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 interface E2eDriverOptions extends E2ePageOptions {
|
|
18
|
-
/**
|
|
19
|
-
* Switch the running app to a new build — the SERVER half no page port can speak for. Given, it
|
|
20
|
-
* becomes the `deploy` fixture's `newBuild()` and `e2eTest`'s `update()`; absent, both refuse by
|
|
21
|
-
* name. The gate's e2e preload passes the spawned app's `restart({ BUILD_ID })`.
|
|
22
|
-
*/
|
|
23
|
-
readonly newBuild?: (() => Promise<void>) | undefined;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* A member this driver cannot build is a REFUSAL, never a no-op. A fixture that silently did
|
|
28
|
-
* nothing would make the assertion after it read as proof: `offline()` followed by "the fallback
|
|
29
|
-
* rendered" is the app's ONLINE page passing an offline test.
|
|
30
|
-
*/
|
|
31
|
-
const refuse =
|
|
32
|
-
(name: string, needs: string): (() => Promise<void>) =>
|
|
33
|
-
() =>
|
|
34
|
-
Promise.reject(new FixtureUnavailableError({ name, needs }));
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* `offline()`/`online()` FORWARD, `As of 2026-08-27`. They refused until then on a reason the tree
|
|
38
|
-
* contradicted on the day it was written: this file said `CdpPageLike`
|
|
39
|
-
* (`packages/scraping/src/cdp-port.ts`) "declares twelve methods and none of them is
|
|
40
|
-
* `setOfflineMode`". It declares it at line 71 — optional, guarded, with a coded
|
|
41
|
-
* `X_NOT_IMPLEMENTED` in `cdp-target.ts` for a launcher that lacks it — and `page-over-target.ts`
|
|
42
|
-
* exposes it as `ScrapePage.offline()`. All of that landed in **the same commit as the comment**
|
|
43
|
-
* (#351), so the refusal was never true, and it is the reason issue #390 records a real browser
|
|
44
|
-
* check as out of reach.
|
|
45
|
-
*
|
|
46
|
-
* Optional on `E2eBrowserPage` rather than required, for the reason `CdpPageLike` gives about the
|
|
47
|
-
* same method: this port is the shape of somebody ELSE's object, and a six-line test double must
|
|
48
|
-
* still satisfy it. Absent, the refusal stands — and now it names the method the double is missing
|
|
49
|
-
* rather than a capability the framework does not have.
|
|
50
|
-
*/
|
|
51
|
-
const networkFixtures = (browser: E2eBrowserPage): Pick<E2eFixtures, 'offline' | 'online'> => {
|
|
52
|
-
const setOffline = browser.offline?.bind(browser);
|
|
53
|
-
if (setOffline === undefined) {
|
|
54
|
-
const needs =
|
|
55
|
-
"a page whose driver implements offline(enabled) — @ultimat3/scraping's ScrapePage does; a hand-rolled E2eBrowserPage may not";
|
|
56
|
-
return { offline: refuse('offline', needs), online: refuse('online', needs) };
|
|
57
|
-
}
|
|
58
|
-
return { offline: () => setOffline(true), online: () => setOffline(false) };
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
/** What `e2eTest` hands its body: a real page, the network condition, and one honest refusal. */
|
|
62
|
-
export const e2eFixtures = (
|
|
63
|
-
page: PageLike,
|
|
64
|
-
browser: E2eBrowserPage,
|
|
65
|
-
newBuild?: () => Promise<void>,
|
|
66
|
-
): E2eFixtures => ({
|
|
67
|
-
page,
|
|
68
|
-
...networkFixtures(browser),
|
|
69
|
-
// A new build id is a fact about the SERVER, which no page port can speak for — so it is
|
|
70
|
-
// forwarded when whoever spawned the app can restart it, and refused by name otherwise.
|
|
71
|
-
update:
|
|
72
|
-
newBuild ??
|
|
73
|
-
refuse(
|
|
74
|
-
'update',
|
|
75
|
-
'a second build served under a new immutable build id, which is a server fact',
|
|
76
|
-
),
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Install the browser-backed driver for this process.
|
|
81
|
-
*
|
|
82
|
-
* Two seams, deliberately, because they are two questions. `defineFixtures({ page })` replaces the
|
|
83
|
-
* declaration `driverFixtures()` registered — the ordinary way a driver arrives, last registration
|
|
84
|
-
* wins — so every `test('…', async ({ page }) => …)` in the suite gets a browser. `useE2eDriver`
|
|
85
|
-
* is the other half: it is what makes `hasE2eDriver()` answer true and stops `e2eTest` becoming a
|
|
86
|
-
* `test.skip` — which the gate now reports as a SKIPPED step rather than the green check it
|
|
87
|
-
* printed until #434, and which a repo whose `x.verify.json` names `e2e` gets red for.
|
|
88
|
-
*
|
|
89
|
-
* `budget` and `signIn` are deliberately NOT registered here, and `deploy` only with `newBuild`. Each needs something a
|
|
90
|
-
* page cannot supply — byte counts off a built `dist/`, an app's own sign-in route, a second build
|
|
91
|
-
* — so each keeps refusing with `X_TEST_FIXTURE_UNAVAILABLE` naming what it waits for.
|
|
92
|
-
*
|
|
93
|
-
* Returns the undo. `bun test` is one process, so a driver installed and never removed reaches
|
|
94
|
-
* every later file in the run.
|
|
95
|
-
*/
|
|
96
|
-
export function installE2eDriver(options: E2eDriverOptions): () => void {
|
|
97
|
-
const page = e2ePage(options);
|
|
98
|
-
const newBuild = options.newBuild;
|
|
99
|
-
defineFixtures({
|
|
100
|
-
page: () => page,
|
|
101
|
-
...(newBuild === undefined ? {} : { deploy: () => ({ newBuild }) }),
|
|
102
|
-
});
|
|
103
|
-
useE2eDriver((name, body: E2eBody) => {
|
|
104
|
-
bunTest(name, () => body(e2eFixtures(page, options.page, newBuild)));
|
|
105
|
-
});
|
|
106
|
-
return () => {
|
|
107
|
-
// Both halves, because both were installed. Putting the DECLARATION back — rather than
|
|
108
|
-
// deleting the key — is what keeps a later file's `{ page }` failing as
|
|
109
|
-
// `X_TEST_FIXTURE_UNAVAILABLE` (a driver is missing) instead of `X_TEST_FIXTURE_UNKNOWN`
|
|
110
|
-
// (register it), which is the wrong instruction for a name the framework declares.
|
|
111
|
-
defineFixtures({
|
|
112
|
-
page: unavailableFixture('page'),
|
|
113
|
-
...(newBuild === undefined ? {} : { deploy: unavailableFixture('deploy') }),
|
|
114
|
-
});
|
|
115
|
-
resetE2eDriver();
|
|
116
|
-
};
|
|
117
|
-
}
|
package/src/e2e-errors.ts
DELETED
|
@@ -1,117 +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
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* The app an e2e run spawns (`e2e-app.ts`) did not come up: its reset, its seed, or its boot. The
|
|
107
|
-
* cause carries that process's own output, rendered, because it is the only place the reason is.
|
|
108
|
-
*/
|
|
109
|
-
export class E2eAppFailedError extends UltimateError {
|
|
110
|
-
constructor(input: { readonly step: string; readonly output: string }) {
|
|
111
|
-
super({
|
|
112
|
-
code: 'X_E2E_APP_FAILED',
|
|
113
|
-
cause: `${renderCauseValue(input.step)} failed for the e2e app: ${renderCauseValue(input.output)}`,
|
|
114
|
-
fix: 'x dev --json # boot the same app by hand and read why it would not start; the e2e run used a throwaway ULTIMATE_STATE_DIR, so your own .x is untouched',
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
}
|
package/src/e2e-evaluate.ts
DELETED
|
@@ -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
|
-
}
|
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,153 +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. The reader is CANCELLED after that chunk: a
|
|
71
|
-
* streamed response nobody pulls stays open until its last hole fills, holding one of the page's
|
|
72
|
-
* six connections to its origin for the rest of the test.
|
|
73
|
-
*/
|
|
74
|
-
const firstFlushExpression = (url: string): string =>
|
|
75
|
-
`(() => fetch(${JSON.stringify(url)}, { credentials: 'same-origin' })
|
|
76
|
-
.then((response) => { const reader = response.body.getReader(); return reader.read()
|
|
77
|
-
.then((chunk) => { reader.cancel().catch(() => {}); return chunk; }); })
|
|
78
|
-
.then((chunk) => JSON.stringify({ html: new TextDecoder().decode(chunk.value || new Uint8Array()) })))()`;
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* `ready` alone is not control: a first load activates a worker that is not yet the page's
|
|
82
|
-
* controller, and an offline assertion made in that window tests nothing. So this waits for
|
|
83
|
-
* `controllerchange` too — an EVENT, not a poll, so the harness still has exactly one retry loop.
|
|
84
|
-
*/
|
|
85
|
-
const serviceWorkerExpression = (timeoutMs: number): string =>
|
|
86
|
-
`(() => {
|
|
87
|
-
if (!navigator.serviceWorker) return JSON.stringify({ controlled: false });
|
|
88
|
-
const controlled = new Promise((resolve) => {
|
|
89
|
-
if (navigator.serviceWorker.controller) { resolve(true); return; }
|
|
90
|
-
navigator.serviceWorker.addEventListener('controllerchange', () => resolve(true), { once: true });
|
|
91
|
-
});
|
|
92
|
-
const deadline = new Promise((resolve) => setTimeout(() => resolve(false), ${String(timeoutMs)}));
|
|
93
|
-
return Promise.race([navigator.serviceWorker.ready.then(() => controlled), deadline])
|
|
94
|
-
.catch(() => false)
|
|
95
|
-
.then((ok) => JSON.stringify({ controlled: ok === true }));
|
|
96
|
-
})()`;
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* The adapter. Every member re-reads the live page: a `PageLike` handed to a test outlives every
|
|
100
|
-
* navigation the test makes, so nothing here may capture a URL, a document or an element.
|
|
101
|
-
*/
|
|
102
|
-
export function e2ePage(options: E2ePageOptions): PageLike {
|
|
103
|
-
const page = options.page;
|
|
104
|
-
// Both are screened HERE, at construction, and not where they land: `timeout` is handed to a
|
|
105
|
-
// driver that reads it as a deadline, and `swTimeout` is INTERPOLATED into the in-page source,
|
|
106
|
-
// where `setTimeout(fn, NaN)` is `setTimeout(fn, 0)` — so a NaN budget makes every
|
|
107
|
-
// `waitForServiceWorker()` refuse a worker that really did take control. A misdiagnosis reported
|
|
108
|
-
// as a test failure is worse than the failure. Floor 0, because a driver reads `timeout: 0` as
|
|
109
|
-
// "no deadline" and that is a value an app is entitled to declare.
|
|
110
|
-
const timeout = finiteCount(SUBJECT, 'timeoutMs', options.timeoutMs ?? DEFAULT_E2E_TIMEOUT_MS);
|
|
111
|
-
const swTimeout = finiteCount(
|
|
112
|
-
SUBJECT,
|
|
113
|
-
'serviceWorkerTimeoutMs',
|
|
114
|
-
options.serviceWorkerTimeoutMs ?? DEFAULT_SERVICE_WORKER_TIMEOUT_MS,
|
|
115
|
-
);
|
|
116
|
-
const absolute = (url: string): string => new URL(url, options.baseUrl).toString();
|
|
117
|
-
const locate = (selection: E2eSelection): LocatorLike => e2eLocator(page, selection);
|
|
118
|
-
|
|
119
|
-
return {
|
|
120
|
-
url: () => page.url(),
|
|
121
|
-
goto: (url) => page.goto(absolute(url), { timeout }),
|
|
122
|
-
// The page's OWN url, re-read at call time: a reload of the url the adapter was built with
|
|
123
|
-
// would navigate away from wherever the test had got to.
|
|
124
|
-
reload: () => page.goto(page.url(), { timeout }),
|
|
125
|
-
title: async () => {
|
|
126
|
-
const title = readField(await page.evaluate(TITLE), 'title');
|
|
127
|
-
return typeof title === 'string' ? title : '';
|
|
128
|
-
},
|
|
129
|
-
gotoStreamed: async (url) => {
|
|
130
|
-
const target = absolute(url);
|
|
131
|
-
await page.goto(target, { timeout });
|
|
132
|
-
const html = readField(await page.evaluate(firstFlushExpression(target)), 'html');
|
|
133
|
-
return { html: typeof html === 'string' ? html : '' };
|
|
134
|
-
},
|
|
135
|
-
waitForServiceWorker: async () => {
|
|
136
|
-
const raw = await page.evaluate(serviceWorkerExpression(swTimeout));
|
|
137
|
-
if (readField(raw, 'controlled') !== true) {
|
|
138
|
-
throw new E2eServiceWorkerAbsentError({ url: page.url(), timeoutMs: swTimeout });
|
|
139
|
-
}
|
|
140
|
-
},
|
|
141
|
-
evaluate: <T>(fn: () => T): Promise<T> => evaluateClosure(page, fn) as unknown as Promise<T>,
|
|
142
|
-
locator: (selector) => locate({ kind: 'css', selector, first: false }),
|
|
143
|
-
getByRole: (role, roleOptions) =>
|
|
144
|
-
locate({
|
|
145
|
-
kind: 'role',
|
|
146
|
-
role,
|
|
147
|
-
first: false,
|
|
148
|
-
...(roleOptions?.name === undefined ? {} : { name: roleOptions.name }),
|
|
149
|
-
...(roleOptions?.level === undefined ? {} : { level: roleOptions.level }),
|
|
150
|
-
}),
|
|
151
|
-
getByText: (text) => locate({ kind: 'text', text, first: false }),
|
|
152
|
-
};
|
|
153
|
-
}
|