@ultimat3/testing 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.
- package/CLAUDE.md +123 -85
- package/README.md +17 -1
- package/package.json +15 -13
- package/src/cdp-browser.ts +94 -0
- package/src/cdp-connection.ts +260 -0
- package/src/cdp-e2e-page.ts +180 -0
- package/src/cdp-e2e-session.ts +199 -0
- package/src/cdp-errors.ts +58 -0
- package/src/cdp-launch.ts +192 -0
- package/src/cdp-offline-script.ts +76 -0
- package/src/cdp-pipe.ts +77 -0
- package/src/e2e-app.ts +106 -0
- package/src/e2e-browser-handle.ts +55 -0
- package/src/e2e-dom-fixture.ts +122 -0
- package/src/e2e-driver.ts +114 -0
- package/src/e2e-error-codes.ts +42 -0
- package/src/e2e-errors.ts +126 -0
- package/src/e2e-evaluate.ts +157 -0
- package/src/e2e-locator.ts +86 -0
- package/src/e2e-page.ts +153 -0
- package/src/e2e-preload.ts +22 -0
- package/src/e2e-probe.ts +23 -0
- package/src/e2e-run.ts +87 -0
- package/src/e2e-selection.ts +192 -0
- package/src/e2e-spawn.ts +195 -0
- package/src/errors.ts +6 -0
- package/src/fixture-subscribe.ts +2 -2
- package/src/index.ts +68 -2
- package/src/island-dom.ts +18 -1
- package/src/island-observers.ts +3 -0
- package/src/matcher-receiver-errors.ts +39 -0
- package/src/matchers.ts +20 -20
- package/src/live-replicator.ts +0 -159
package/src/e2e-page.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
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 { E2eServiceWorkerAbsentError } from './e2e-errors';
|
|
7
|
+
import { evaluateClosure } from './e2e-evaluate';
|
|
8
|
+
import { e2eLocator } from './e2e-locator';
|
|
9
|
+
import type { E2eSelection } from './e2e-selection';
|
|
10
|
+
import type { LocatorLike, PageLike } from './test-types';
|
|
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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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 and start one run over it
|
|
3
|
+
// (`e2e-run.ts`) with Bun's own hooks and a real browser. With no root named it is INERT.
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeEach } from 'bun:test';
|
|
6
|
+
import { openE2eBrowser } from './cdp-browser';
|
|
7
|
+
import { startE2eApp } from './e2e-app';
|
|
8
|
+
import { E2E_ROOT_ENV } from './e2e-browser-handle';
|
|
9
|
+
import { installE2eDriver } from './e2e-driver';
|
|
10
|
+
import { startE2eRun } from './e2e-run';
|
|
11
|
+
|
|
12
|
+
const root = Bun.env[E2E_ROOT_ENV];
|
|
13
|
+
if (root !== undefined && root !== '') {
|
|
14
|
+
await startE2eRun({
|
|
15
|
+
app: await startE2eApp({ root }),
|
|
16
|
+
// `openE2eBrowser`, never the `IfAvailable` door: the step only names a root after finding one.
|
|
17
|
+
openBrowser: openE2eBrowser,
|
|
18
|
+
install: installE2eDriver,
|
|
19
|
+
beforeEach,
|
|
20
|
+
afterAll,
|
|
21
|
+
});
|
|
22
|
+
}
|
package/src/e2e-probe.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
}
|
package/src/e2e-run.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// One e2e run: the app spawned, ONE browser opened and installed as the `page` fixture, a deploy
|
|
2
|
+
// wired as a restart of the app, a hung or deployed-under browser relaunched before the next test,
|
|
3
|
+
// and everything released after the last. The preload (`e2e-preload.ts`) is this with Bun's own
|
|
4
|
+
// hooks and the real app and browser; a test is this with doubles, which is how it is measured.
|
|
5
|
+
|
|
6
|
+
import { finiteCount } from '@ultimat3/core';
|
|
7
|
+
import type { E2eBrowser } from './cdp-browser';
|
|
8
|
+
import type { E2eApp } from './e2e-app';
|
|
9
|
+
import { e2eBrowser, publishE2eRun, republishE2eBrowser } from './e2e-browser-handle';
|
|
10
|
+
import type { E2eDriverOptions } from './e2e-driver';
|
|
11
|
+
import type { E2eBrowserPage } from './e2e-page';
|
|
12
|
+
import { answersWithin } from './e2e-probe';
|
|
13
|
+
|
|
14
|
+
/** How long a live browser gets to answer `1` before it is declared hung and relaunched. */
|
|
15
|
+
export const PROBE_MS = 5_000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The browser, or the app stopped: a browser that would not open left the spawned `x dev` child
|
|
19
|
+
* running after the suite gave up — `cdp-browser.ts`'s own open-or-release shape, one level up.
|
|
20
|
+
*/
|
|
21
|
+
export async function openOrStop<T>(
|
|
22
|
+
app: { stop(): Promise<void> },
|
|
23
|
+
open: () => Promise<T>,
|
|
24
|
+
): Promise<T> {
|
|
25
|
+
try {
|
|
26
|
+
return await open();
|
|
27
|
+
} catch (error) {
|
|
28
|
+
await app.stop().catch(() => undefined);
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface E2eRunDeps {
|
|
34
|
+
readonly app: E2eApp;
|
|
35
|
+
readonly openBrowser: () => Promise<E2eBrowser>;
|
|
36
|
+
readonly install: (options: E2eDriverOptions) => () => void;
|
|
37
|
+
readonly beforeEach: (hook: () => Promise<void>) => void;
|
|
38
|
+
readonly afterAll: (hook: () => Promise<void>) => void;
|
|
39
|
+
readonly probeMs?: number | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function startE2eRun(deps: E2eRunDeps): Promise<void> {
|
|
43
|
+
const probeMs = finiteCount('startE2eRun', 'probeMs', deps.probeMs ?? PROBE_MS, 1);
|
|
44
|
+
const { app } = deps;
|
|
45
|
+
let builds = 0;
|
|
46
|
+
// A deploy leaves the browser holding the OLD build's state: a SharedWorker whose socket went
|
|
47
|
+
// down with the restart and is now deep in its reconnect backoff, and tabs rendered by the old
|
|
48
|
+
// build. The test that deployed asserts on exactly that; the NEXT test must not inherit it.
|
|
49
|
+
let deployed = false;
|
|
50
|
+
const newBuild = async (): Promise<void> => {
|
|
51
|
+
deployed = true;
|
|
52
|
+
builds += 1;
|
|
53
|
+
await app.restart({ BUILD_ID: `e2e-build-${String(builds)}-${String(Date.now())}` });
|
|
54
|
+
};
|
|
55
|
+
let browser = await openOrStop(app, deps.openBrowser);
|
|
56
|
+
publishE2eRun({ browser, app });
|
|
57
|
+
// Installed ONCE, over a page that delegates to whichever browser is current: an `e2eTest` body
|
|
58
|
+
// is bound to its fixtures when the file DEFINES it, so reinstalling on a relaunch would leave
|
|
59
|
+
// every test defined before it driving a closed browser.
|
|
60
|
+
const current: E2eBrowserPage = {
|
|
61
|
+
url: () => browser.page.url(),
|
|
62
|
+
goto: (url, options) => browser.page.goto(url, options),
|
|
63
|
+
evaluate: (expression) => browser.page.evaluate(expression),
|
|
64
|
+
click: (selector) => browser.page.click(selector),
|
|
65
|
+
offline: (enabled) => browser.page.offline(enabled),
|
|
66
|
+
};
|
|
67
|
+
const uninstall = deps.install({ page: current, baseUrl: app.base, newBuild });
|
|
68
|
+
|
|
69
|
+
// A browser that stopped answering takes every later suite down with it, one call deadline per
|
|
70
|
+
// call (run 8). So each test starts with a short probe, and a browser that fails it — or one a
|
|
71
|
+
// deploy ran under — is closed and relaunched: the app is untouched, and the test gets a fresh
|
|
72
|
+
// profile, a fresh SharedWorker and a fresh tab on the same origin.
|
|
73
|
+
deps.beforeEach(async () => {
|
|
74
|
+
const alive = !deployed && (await answersWithin(e2eBrowser().page, probeMs));
|
|
75
|
+
if (alive) return;
|
|
76
|
+
deployed = false;
|
|
77
|
+
browser.close();
|
|
78
|
+
browser = await deps.openBrowser();
|
|
79
|
+
republishE2eBrowser(browser);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
deps.afterAll(async () => {
|
|
83
|
+
uninstall();
|
|
84
|
+
browser.close();
|
|
85
|
+
await app.stop();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
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
|
+
// `input:not([type])`: an untyped input is a text box — `text` is the default type.
|
|
63
|
+
[
|
|
64
|
+
'textbox',
|
|
65
|
+
[
|
|
66
|
+
'input:not([type])',
|
|
67
|
+
'input[type="text"]',
|
|
68
|
+
'input[type="email"]',
|
|
69
|
+
'input[type="search"]',
|
|
70
|
+
'textarea',
|
|
71
|
+
],
|
|
72
|
+
],
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/** Elements whose text is markup rather than page copy — `getByText` must never land on one. */
|
|
76
|
+
const TEXT_SKIP_TAGS = ['SCRIPT', 'STYLE', 'HEAD', 'TITLE', 'META', 'LINK', 'NOSCRIPT'];
|
|
77
|
+
|
|
78
|
+
/** A CSS attribute selector takes a double-quoted string, which is what `JSON.stringify` writes. */
|
|
79
|
+
const roleSelector = (role: string): string =>
|
|
80
|
+
[`[role=${JSON.stringify(role)}]`, ...(IMPLICIT_ROLE_ELEMENTS.get(role) ?? [])].join(',');
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The call a test wrote, rebuilt from the selection. Every refusal below quotes it, because
|
|
84
|
+
* "an e2e locator matched nothing" names no line of the test file and this does.
|
|
85
|
+
*/
|
|
86
|
+
export function selectionCall(selection: E2eSelection): string {
|
|
87
|
+
const tail = selection.first ? '.first()' : '';
|
|
88
|
+
if (selection.kind === 'css') return `page.locator(${JSON.stringify(selection.selector)})${tail}`;
|
|
89
|
+
if (selection.kind === 'text') return `page.getByText(${JSON.stringify(selection.text)})${tail}`;
|
|
90
|
+
const options = [
|
|
91
|
+
...(selection.name === undefined ? [] : [`name: ${JSON.stringify(selection.name)}`]),
|
|
92
|
+
...(selection.level === undefined ? [] : [`level: ${String(selection.level)}`]),
|
|
93
|
+
];
|
|
94
|
+
const role = JSON.stringify(selection.role);
|
|
95
|
+
const suffix = options.length === 0 ? '' : `, { ${options.join(', ')} }`;
|
|
96
|
+
return `page.getByRole(${role}${suffix})${tail}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The candidate list, as a JS expression evaluating to an array of elements.
|
|
101
|
+
*
|
|
102
|
+
* `getByRole` and `getByText` are not CSS and cannot be: a role is implicit in a tag name, an
|
|
103
|
+
* accessible name comes off four different attributes before it comes off the text, and
|
|
104
|
+
* `getByText` has to pick the INNERMOST element that contains the string. So the union selector
|
|
105
|
+
* is only the cheap first pass and every rule after it runs in JS, in the page.
|
|
106
|
+
*/
|
|
107
|
+
function candidateSource(selection: E2eSelection): string {
|
|
108
|
+
if (selection.kind === 'css') return `all(${JSON.stringify(selection.selector)})`;
|
|
109
|
+
if (selection.kind === 'text') {
|
|
110
|
+
const needle = JSON.stringify(selection.text.replace(/\s+/g, ' ').trim().toLowerCase());
|
|
111
|
+
// Innermost FIRST and the skip list second, in that order. Reversed, `<html>` becomes the
|
|
112
|
+
// innermost survivor of a page whose only copy of the string is inside a `<script>` — the
|
|
113
|
+
// ancestor inherits the match its own excluded child made, which is worse than not filtering
|
|
114
|
+
// at all: it reports one match, at the document root, for text no reader can see.
|
|
115
|
+
return `innermost(all('*').filter((el) => norm(el.textContent).toLowerCase().indexOf(${needle}) !== -1)).filter((el) => ${JSON.stringify(TEXT_SKIP_TAGS)}.indexOf(el.tagName) === -1)`;
|
|
116
|
+
}
|
|
117
|
+
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())}; })`;
|
|
118
|
+
const byLevel =
|
|
119
|
+
selection.level === undefined
|
|
120
|
+
? byRole
|
|
121
|
+
: `${byRole}.filter((el) => level(el) === ${String(selection.level)})`;
|
|
122
|
+
return selection.name === undefined
|
|
123
|
+
? byLevel
|
|
124
|
+
: `${byLevel}.filter((el) => accName(el).toLowerCase() === ${JSON.stringify(selection.name.replace(/\s+/g, ' ').trim().toLowerCase())})`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The helpers the candidate source above calls, defined once inside the IIFE.
|
|
129
|
+
*
|
|
130
|
+
* `visible` is `display`/`visibility`/`opacity`, which is character for character what
|
|
131
|
+
* `@ultimat3/scraping`'s `snapshotExpression` computes for `ElementSnapshot.visible`. That is a
|
|
132
|
+
* COPY and it is the wrong shape: `ScrapeTarget.query()` already answers a snapshot per element
|
|
133
|
+
* and `ScrapeFrame` does not expose it, so this driver cannot reach the framework's one definition
|
|
134
|
+
* of visible without an edit to `packages/scraping/src/page.ts`. Mirrored deliberately rather than
|
|
135
|
+
* invented, so the two cannot disagree about a page until that edit lands.
|
|
136
|
+
*/
|
|
137
|
+
const HELPERS = `
|
|
138
|
+
const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim();
|
|
139
|
+
const all = (sel) => Array.prototype.slice.call(document.querySelectorAll(sel));
|
|
140
|
+
const innermost = (found) => found.filter((el) => !found.some((other) => other !== el && el.contains(other)));
|
|
141
|
+
const level = (el) => {
|
|
142
|
+
const aria = el.getAttribute('aria-level');
|
|
143
|
+
if (aria !== null) return Number(aria);
|
|
144
|
+
const tag = el.tagName.toLowerCase();
|
|
145
|
+
return /^h[1-6]$/.test(tag) ? Number(tag.slice(1)) : undefined;
|
|
146
|
+
};
|
|
147
|
+
const accName = (el) => {
|
|
148
|
+
const label = el.getAttribute('aria-label');
|
|
149
|
+
if (label !== null && norm(label) !== '') return norm(label);
|
|
150
|
+
const by = el.getAttribute('aria-labelledby');
|
|
151
|
+
if (by !== null) {
|
|
152
|
+
const parts = norm(by).split(' ').map((id) => document.getElementById(id)).filter((n) => n).map((n) => norm(n.textContent));
|
|
153
|
+
if (norm(parts.join(' ')) !== '') return norm(parts.join(' '));
|
|
154
|
+
}
|
|
155
|
+
const tag = el.tagName.toLowerCase();
|
|
156
|
+
if (tag === 'input') { const v = el.getAttribute('value'); if (v !== null && norm(v) !== '') return norm(v); }
|
|
157
|
+
if (tag === 'img') { const a = el.getAttribute('alt'); if (a !== null) return norm(a); }
|
|
158
|
+
const text = norm(el.textContent);
|
|
159
|
+
if (text !== '') return text;
|
|
160
|
+
const title = el.getAttribute('title');
|
|
161
|
+
return title !== null ? norm(title) : '';
|
|
162
|
+
};`;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Returns JSON TEXT rather than an object, the same bargain `cdp-snapshot.ts` makes: a CDP round
|
|
166
|
+
* trip serialises the answer anyway, and a string has ONE deserialiser — a schema parse — instead
|
|
167
|
+
* of an implicit one inside the browser library plus a cast on this side.
|
|
168
|
+
*/
|
|
169
|
+
export function selectionExpression(selection: E2eSelection, mark?: string): string {
|
|
170
|
+
const marker = mark === undefined ? 'null' : JSON.stringify(mark);
|
|
171
|
+
return `(() => {${HELPERS}
|
|
172
|
+
const found = ${candidateSource(selection)};
|
|
173
|
+
const matches = ${selection.first ? 'found.slice(0, 1)' : 'found'};
|
|
174
|
+
const el = matches[0];
|
|
175
|
+
let visible = false;
|
|
176
|
+
if (el) {
|
|
177
|
+
const style = getComputedStyle(el);
|
|
178
|
+
visible = style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
|
|
179
|
+
}
|
|
180
|
+
const mark = ${marker};
|
|
181
|
+
let marked = false;
|
|
182
|
+
if (mark !== null && el) { el.setAttribute(${JSON.stringify(MARK_ATTRIBUTE)}, mark); marked = true; }
|
|
183
|
+
return JSON.stringify({ count: matches.length, visible: visible, marked: marked });
|
|
184
|
+
})()`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The CSS the marked element answers to — what `ScrapePage.click` is handed once one is marked. */
|
|
188
|
+
export const markSelector = (mark: string): string => `[${MARK_ATTRIBUTE}=${JSON.stringify(mark)}]`;
|
|
189
|
+
|
|
190
|
+
/** Undo. Best effort by design: a click that navigated took the whole document with it. */
|
|
191
|
+
export const unmarkExpression = (mark: string): string =>
|
|
192
|
+
`(() => { 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
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
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 bin is joined onto its package directory.
|
|
10
|
+
import { dirname, 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
|
+
/**
|
|
18
|
+
* The `x` the APP installed, read off `@ultimat3/cli`'s own `bin` entry and resolved from the app
|
|
19
|
+
* root — never a global `x`, which may be a different version of the framework, and never beside
|
|
20
|
+
* this module: `@ultimat3/testing` does not depend on the CLI, and the `import.meta.dir/bin.ts`
|
|
21
|
+
* this was while the driver lived in cli named a file that does not exist once it moved here.
|
|
22
|
+
*/
|
|
23
|
+
export async function xBin(root: string): Promise<string> {
|
|
24
|
+
let manifest: string;
|
|
25
|
+
try {
|
|
26
|
+
manifest = Bun.resolveSync('@ultimat3/cli/package.json', root);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new E2eAppFailedError({
|
|
29
|
+
step: 'resolve @ultimat3/cli',
|
|
30
|
+
output: `@ultimat3/cli does not resolve from ${root}`,
|
|
31
|
+
fix: 'bun add -d @ultimat3/cli # in the app root: the e2e app is spawned through the CLI the app itself installed',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
const declared: unknown = await Bun.file(manifest).json();
|
|
35
|
+
const bin =
|
|
36
|
+
typeof declared === 'object' && declared !== null ? Reflect.get(declared, 'bin') : undefined;
|
|
37
|
+
const x = typeof bin === 'object' && bin !== null ? Reflect.get(bin, 'x') : undefined;
|
|
38
|
+
if (typeof x !== 'string') {
|
|
39
|
+
throw refuse('resolve @ultimat3/cli', `${manifest} declares no "bin": { "x": … } entry`);
|
|
40
|
+
}
|
|
41
|
+
return join(dirname(manifest), x);
|
|
42
|
+
}
|
|
43
|
+
const POLL_MS = 250;
|
|
44
|
+
|
|
45
|
+
export interface SpawnedE2eApp {
|
|
46
|
+
/** `http://localhost:<port>`, no trailing slash. */
|
|
47
|
+
readonly base: string;
|
|
48
|
+
/** Kill the process. Idempotent. */
|
|
49
|
+
stop(): Promise<void>;
|
|
50
|
+
/** Kill it and start it again on the SAME port, with `env` added — a deploy. Refused after `stop()`. */
|
|
51
|
+
restart(env?: Readonly<Record<string, string>>): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SpawnE2eAppOptions {
|
|
55
|
+
readonly root: string;
|
|
56
|
+
readonly mode: E2eAppMode;
|
|
57
|
+
/** Every spawn's environment, on top of this process's minus `NODE_ENV`. */
|
|
58
|
+
readonly env: Readonly<Record<string, string>>;
|
|
59
|
+
/** Already screened finite by the caller. */
|
|
60
|
+
readonly readyTimeoutMs: number;
|
|
61
|
+
/** `xBin(root)`, when the caller already resolved it; resolved here otherwise, in `dev` mode only. */
|
|
62
|
+
readonly bin?: string | undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A port nothing holds right now, asked of the OS and handed to the child. */
|
|
66
|
+
const freePort = (): number => {
|
|
67
|
+
const probe = Bun.serve({ port: 0, fetch: () => new Response() });
|
|
68
|
+
const port = probe.port ?? 0;
|
|
69
|
+
probe.stop(true);
|
|
70
|
+
return port;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* This process's environment minus what makes the child a TEST process. Spawned from `bun test`,
|
|
75
|
+
* `NODE_ENV=test` rode along and the app resolved its environment as `test` — so a development-only
|
|
76
|
+
* seam (the demo viewer an app installs instead of a sign-in route) was off and every page 401'd.
|
|
77
|
+
* The app under e2e is a development app unless the caller's `env` says otherwise.
|
|
78
|
+
*/
|
|
79
|
+
export const inherited = (): Record<string, string | undefined> => {
|
|
80
|
+
const { NODE_ENV: _node, ...rest } = Bun.env;
|
|
81
|
+
return { ...rest, ULTIMATE_ENV: 'development' };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const refuse = (step: string, output: string): E2eAppFailedError =>
|
|
85
|
+
new E2eAppFailedError({ step, output });
|
|
86
|
+
|
|
87
|
+
/** Spawn the app and answer once `/readyz` does; refuses with the app's own output tail. */
|
|
88
|
+
export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE2eApp> {
|
|
89
|
+
const deadline = options.readyTimeoutMs;
|
|
90
|
+
const port = freePort();
|
|
91
|
+
// Its own scrape port too: every role opens one, the default is a fixed 9090, and a second app —
|
|
92
|
+
// or a developer's `x dev` — already holding it is an app that dies at boot with X_PORT_IN_USE.
|
|
93
|
+
const metricsPort = freePort();
|
|
94
|
+
const base = `http://localhost:${String(port)}`;
|
|
95
|
+
const command =
|
|
96
|
+
options.mode === 'serve'
|
|
97
|
+
? ['bun', 'apps/web/server.ts']
|
|
98
|
+
: ['bun', options.bin ?? (await xBin(options.root)), 'dev', '--port', String(port)];
|
|
99
|
+
const spawnApp = (extra: Readonly<Record<string, string>>) =>
|
|
100
|
+
Bun.spawn(command, {
|
|
101
|
+
cwd: options.root,
|
|
102
|
+
env: {
|
|
103
|
+
...inherited(),
|
|
104
|
+
...options.env,
|
|
105
|
+
METRICS_PORT: String(metricsPort),
|
|
106
|
+
// The origin the app is actually reachable at. A page that renders a typed client builds
|
|
107
|
+
// its absolute URLs from it, and without it `/feed` answered 500 with X_ENV_MISSING APP_URL.
|
|
108
|
+
APP_URL: base,
|
|
109
|
+
...(options.mode === 'serve' ? { ROLE: 'web', PORT: String(port) } : {}),
|
|
110
|
+
...extra,
|
|
111
|
+
},
|
|
112
|
+
stdout: 'pipe',
|
|
113
|
+
stderr: 'pipe',
|
|
114
|
+
});
|
|
115
|
+
const ready = async (child: ReturnType<typeof spawnApp>, tail: () => string): Promise<void> => {
|
|
116
|
+
for (let waited = 0; waited < deadline; waited += POLL_MS) {
|
|
117
|
+
if (child.exitCode !== null) break;
|
|
118
|
+
if (await answersOk(`${base}/readyz`)) return;
|
|
119
|
+
await Bun.sleep(POLL_MS);
|
|
120
|
+
}
|
|
121
|
+
child.kill();
|
|
122
|
+
await child.exited;
|
|
123
|
+
throw refuse(`${command.join(' ')} never answered ${base}/readyz`, tail());
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
let child = spawnApp({});
|
|
127
|
+
// Drained from the start and kept bounded: an app's log is unbounded, and a pipe nobody reads
|
|
128
|
+
// fills its buffer and blocks the child on its next write — an app that "never got ready".
|
|
129
|
+
let tail = drainTail(child.stdout, child.stderr);
|
|
130
|
+
let stopped = false;
|
|
131
|
+
const stop = async (): Promise<void> => {
|
|
132
|
+
if (stopped) return;
|
|
133
|
+
stopped = true;
|
|
134
|
+
child.kill();
|
|
135
|
+
await child.exited;
|
|
136
|
+
};
|
|
137
|
+
try {
|
|
138
|
+
await ready(child, tail);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
await stop();
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
base,
|
|
145
|
+
stop,
|
|
146
|
+
async restart(next: Readonly<Record<string, string>> = {}): Promise<void> {
|
|
147
|
+
// `stop()` is final. A child respawned here would be one no later `stop()` kills — the flag
|
|
148
|
+
// already says done — and `startE2eApp` has deleted the state directory it would boot on.
|
|
149
|
+
assert(
|
|
150
|
+
!stopped,
|
|
151
|
+
'restart() was called on an e2e app that was already stopped, so there is no app to deploy over',
|
|
152
|
+
'startE2eApp({ root }) again for a fresh app — restart() is for an app that is still running',
|
|
153
|
+
);
|
|
154
|
+
// The same port and the same state directory — a deploy, not a second app: a tab already
|
|
155
|
+
// open on `base` sees the new build on its next request, and the data it wrote is still there.
|
|
156
|
+
child.kill();
|
|
157
|
+
await child.exited;
|
|
158
|
+
child = spawnApp(next);
|
|
159
|
+
tail = drainTail(child.stdout, child.stderr);
|
|
160
|
+
await ready(child, tail);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const TAIL_CHARS = 16_000;
|
|
166
|
+
|
|
167
|
+
/** Read both streams to their end in the background; answer the last `TAIL_CHARS` of either. */
|
|
168
|
+
function drainTail(...streams: readonly ReadableStream<Uint8Array>[]): () => string {
|
|
169
|
+
let text = '';
|
|
170
|
+
for (const stream of streams) {
|
|
171
|
+
void (async () => {
|
|
172
|
+
const decoder = new TextDecoder();
|
|
173
|
+
for await (const chunk of stream) {
|
|
174
|
+
text = (text + decoder.decode(chunk, { stream: true })).slice(-TAIL_CHARS);
|
|
175
|
+
}
|
|
176
|
+
})().catch(() => undefined);
|
|
177
|
+
}
|
|
178
|
+
return () => text;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** One GET, answered as "2xx or not" — never a throw, and never through the sealed `fetch`. */
|
|
182
|
+
function answersOk(url: string): Promise<boolean> {
|
|
183
|
+
return new Promise((resolve) => {
|
|
184
|
+
const request = get(url, (response) => {
|
|
185
|
+
response.resume();
|
|
186
|
+
const status = response.statusCode ?? 0;
|
|
187
|
+
resolve(status >= 200 && status < 300);
|
|
188
|
+
});
|
|
189
|
+
request.on('error', () => resolve(false));
|
|
190
|
+
request.setTimeout(POLL_MS * 4, () => {
|
|
191
|
+
request.destroy();
|
|
192
|
+
resolve(false);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
}
|