@ultimat3/testing 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 +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/live-node.ts +2 -5
- package/src/matcher-receiver-errors.ts +39 -0
- package/src/matchers.ts +20 -20
- package/src/live-replicator.ts +0 -147
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// One constructor per way the raw-CDP e2e browser refuses. Every cause quotes a value that came
|
|
2
|
+
// out of a BROWSER or off a spawned process's stderr, so every one is rendered rather than
|
|
3
|
+
// interpolated — the rule `e2e-errors.ts` already states.
|
|
4
|
+
|
|
5
|
+
import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
6
|
+
// Bare: the titles these constructors' codes render with are registered there.
|
|
7
|
+
import './e2e-error-codes';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* No browser to drive. This is the one an author meets first, so its fix names the two ways out:
|
|
11
|
+
* point the driver at a binary, or accept that this machine cannot run the check.
|
|
12
|
+
*/
|
|
13
|
+
export class CdpBrowserMissingError extends UltimateError {
|
|
14
|
+
constructor(input: { readonly tried: readonly string[] }) {
|
|
15
|
+
super({
|
|
16
|
+
code: 'X_CDP_BROWSER_MISSING',
|
|
17
|
+
cause: `no Chrome or Chromium executable was found — tried ${renderCauseValue(input.tried)}`,
|
|
18
|
+
fix: 'set CHROME_PATH to a Chrome or Chromium binary (GitHub-hosted ubuntu runners ship one at /usr/bin/google-chrome), or skip the browser-backed e2e suite by leaving it unset',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The binary ran and never announced an endpoint — a crash, a bad flag, or a sandbox refusal. */
|
|
24
|
+
export class CdpLaunchFailedError extends UltimateError {
|
|
25
|
+
constructor(input: { readonly executable: string; readonly detail: string }) {
|
|
26
|
+
super({
|
|
27
|
+
code: 'X_CDP_LAUNCH_FAILED',
|
|
28
|
+
cause: `${renderCauseValue(input.executable)} did not announce a DevTools endpoint: ${renderCauseValue(input.detail)}`,
|
|
29
|
+
fix: 'run the same binary by hand with --headless=new --remote-debugging-port=0 and read its stderr; inside a container add --no-sandbox --disable-dev-shm-usage, which this launcher already passes',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A CDP call answered with an error frame, or the connection died under it. */
|
|
35
|
+
export class CdpCallFailedError extends UltimateError {
|
|
36
|
+
constructor(input: { readonly method: string; readonly detail: string }) {
|
|
37
|
+
super({
|
|
38
|
+
code: 'X_CDP_CALL_FAILED',
|
|
39
|
+
cause: `the browser refused ${renderCauseValue(input.method)}: ${renderCauseValue(input.detail)}`,
|
|
40
|
+
fix: 'print what the page had: await page.evaluate(() => document.body.innerHTML) — a call that refuses the same way means the browser itself is gone, so read the launch above it',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A call that never answered. Its own code rather than `X_TIMEOUT`, because the actionable half is
|
|
47
|
+
* WHICH call: a hung `Page.navigate` is an app that never finishes responding, and a hung
|
|
48
|
+
* `Runtime.evaluate` is an expression that never settles.
|
|
49
|
+
*/
|
|
50
|
+
export class CdpTimeoutError extends UltimateError {
|
|
51
|
+
constructor(input: { readonly method: string; readonly timeoutMs: number }) {
|
|
52
|
+
super({
|
|
53
|
+
code: 'X_CDP_TIMEOUT',
|
|
54
|
+
cause: `${renderCauseValue(input.method)} did not answer inside ${String(input.timeoutMs)}ms`,
|
|
55
|
+
fix: 'raise timeoutMs on installE2eDriver({ timeoutMs }), or find the request the page is still waiting on — a navigation that never settles is an app that never finishes its response',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// One responsibility: start a Chrome in this container and hand back its DevTools endpoint and the
|
|
2
|
+
// way to stop it. The connection is `cdp-connection.ts` and the page surface `cdp-e2e-page.ts`.
|
|
3
|
+
|
|
4
|
+
// why: Bun exposes no recursive-remove and no temp-root primitive, so the throwaway profile
|
|
5
|
+
// directory this launcher must create and delete needs both.
|
|
6
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
7
|
+
// why: Bun exposes no tmpdir(), so only node:os answers the platform temp root.
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
// why: Bun exposes no path-join primitive.
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import type { CdpConnection } from './cdp-connection';
|
|
12
|
+
import { cdpConnectOver } from './cdp-connection';
|
|
13
|
+
import { CdpBrowserMissingError, CdpLaunchFailedError } from './cdp-errors';
|
|
14
|
+
import { pipeTransport } from './cdp-pipe';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where a Chrome is, in the order worth trying. `CHROME_PATH` first because it is the operator's
|
|
18
|
+
* answer and the only one that can be right on a machine none of the rest describes; the two
|
|
19
|
+
* `/usr/bin` names after it are what GitHub-hosted `ubuntu-latest` ships, which is what lets the
|
|
20
|
+
* browser-backed suite run in CI with **no download step and no new dependency**.
|
|
21
|
+
*/
|
|
22
|
+
export const CHROME_PATH_ENV = 'CHROME_PATH';
|
|
23
|
+
export const CHROME_CANDIDATES: readonly string[] = [
|
|
24
|
+
'/usr/bin/google-chrome',
|
|
25
|
+
'/usr/bin/google-chrome-stable',
|
|
26
|
+
'/usr/bin/chromium',
|
|
27
|
+
'/usr/bin/chromium-browser',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** The first candidate that exists, or `undefined`. An absent browser is a SKIP, never a failure. */
|
|
31
|
+
export async function findChrome(
|
|
32
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
33
|
+
): Promise<string | undefined> {
|
|
34
|
+
const declared = env[CHROME_PATH_ENV];
|
|
35
|
+
const candidates = declared === undefined || declared === '' ? CHROME_CANDIDATES : [declared];
|
|
36
|
+
for (const candidate of candidates) {
|
|
37
|
+
if (await Bun.file(candidate).exists()) return candidate;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The two flags a CONTAINER needs, regardless of which process launches Chrome: the sandbox needs
|
|
44
|
+
* privileges CI (and an Ubuntu 23.10+ host with AppArmor's unprivileged-user-namespace restriction
|
|
45
|
+
* — Chrome exits "No usable sandbox" there with neither) does not grant, and `/dev/shm` is 64 MB in
|
|
46
|
+
* a default container, which crashes the renderer on any real page.
|
|
47
|
+
*
|
|
48
|
+
* Exported, and in `chromeLaunchFlags` below, so every launcher carries the SAME two. `x shot` had
|
|
49
|
+
* neither while it launched through `puppeteer-core`, so a box where `x verify`'s e2e gate ran green
|
|
50
|
+
* could not run `x shot` at all; since 22.0.0 it launches through `launchChrome` here.
|
|
51
|
+
*/
|
|
52
|
+
export const CONTAINER_CHROME_ARGS: readonly string[] = ['--no-sandbox', '--disable-dev-shm-usage'];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The flags, and every one of them earns its line.
|
|
56
|
+
*
|
|
57
|
+
* `--headless=new` is Chrome's own headless rather than the retired shim. `--remote-debugging-pipe`
|
|
58
|
+
* is the wire (`cdp-pipe.ts` says why it is not the WebSocket): no port, so two suites on one
|
|
59
|
+
* machine can never collide, and nothing but this process can drive the browser. A throwaway
|
|
60
|
+
* `--user-data-dir` because a run sharing a profile with a real browser inherits its cookies and
|
|
61
|
+
* locks its files.
|
|
62
|
+
*/
|
|
63
|
+
export const chromeLaunchFlags = (profileDir: string): readonly string[] => [
|
|
64
|
+
'--headless=new',
|
|
65
|
+
'--remote-debugging-pipe',
|
|
66
|
+
`--user-data-dir=${profileDir}`,
|
|
67
|
+
...CONTAINER_CHROME_ARGS,
|
|
68
|
+
'--disable-gpu',
|
|
69
|
+
// The cookie store's encryption key comes from the OS keyring, asked over D-Bus on the first
|
|
70
|
+
// cookie access — which is the first navigation. With no keyring answering, Chrome waits out the
|
|
71
|
+
// D-Bus timeout: measured 7-25 s on the first `Page.navigate` of every launch, against a 30 s CDP
|
|
72
|
+
// deadline, which is the intermittent `X_CDP_TIMEOUT` of a full e2e run. A throwaway profile has
|
|
73
|
+
// no secret worth a keyring. `x shot` launches through here too, so it has both.
|
|
74
|
+
'--password-store=basic',
|
|
75
|
+
'--use-mock-keychain',
|
|
76
|
+
// Nothing here should reach the network on its own account, and a first-run bubble or an update
|
|
77
|
+
// check is a page load the test did not ask for.
|
|
78
|
+
'--no-first-run',
|
|
79
|
+
'--no-default-browser-check',
|
|
80
|
+
'--disable-extensions',
|
|
81
|
+
'about:blank',
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
export interface LaunchedBrowser {
|
|
85
|
+
/** The browser's own CDP connection, over its debugging pipe. Already answering. */
|
|
86
|
+
readonly connection: CdpConnection;
|
|
87
|
+
/** Idempotent: closes the connection, kills the process, deletes the profile. */
|
|
88
|
+
close(): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface LaunchOptions {
|
|
92
|
+
readonly executable: string;
|
|
93
|
+
/** How long Chrome has to answer its first call, and every call's deadline after that. */
|
|
94
|
+
readonly timeoutMs: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const STDERR_TAIL_CHARS = 4_000;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read stderr to its end for the life of the process, keeping only a bounded tail. A pipe nobody
|
|
101
|
+
* reads fills, and Chrome's next stderr write then blocks the thread making it — a browser that
|
|
102
|
+
* stops answering mid-run for a reason no log shows. The tail is the launch-failure diagnostics:
|
|
103
|
+
* a missing library, a sandbox refusal and a bad flag are all named there and nowhere else.
|
|
104
|
+
*/
|
|
105
|
+
function stderrTail(stream: ReadableStream<Uint8Array>): {
|
|
106
|
+
readonly text: () => string;
|
|
107
|
+
/** Settles once the stream has ended — every byte the process wrote has been read. */
|
|
108
|
+
readonly drained: Promise<void>;
|
|
109
|
+
} {
|
|
110
|
+
let text = '';
|
|
111
|
+
const drained = (async () => {
|
|
112
|
+
const decoder = new TextDecoder();
|
|
113
|
+
for await (const chunk of stream) {
|
|
114
|
+
text = (text + decoder.decode(chunk, { stream: true })).slice(-STDERR_TAIL_CHARS);
|
|
115
|
+
}
|
|
116
|
+
})().catch(() => undefined);
|
|
117
|
+
return { text: () => text, drained };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* How long a browser that failed its first call gets to finish dying, and its stderr to finish
|
|
122
|
+
* draining, before the tail is read. The pipe ending and the stderr reader reaching the last line
|
|
123
|
+
* are two unordered events; read at the first, the reason a browser died was reported as "printed
|
|
124
|
+
* nothing". Bounded, because a WEDGED browser neither exits nor closes stderr.
|
|
125
|
+
*/
|
|
126
|
+
const FAILURE_DRAIN_MS = 1_000;
|
|
127
|
+
|
|
128
|
+
const within = (ms: number, work: Promise<unknown>): Promise<unknown> =>
|
|
129
|
+
Promise.race([work, Bun.sleep(ms)]);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Start Chrome on a throwaway profile and answer once it has answered one CDP call. With a pipe
|
|
133
|
+
* there is no "DevTools listening" line to wait for — the first reply IS the readiness signal, and
|
|
134
|
+
* a browser that dies or stays silent before it is `X_CDP_LAUNCH_FAILED` carrying its own stderr.
|
|
135
|
+
*/
|
|
136
|
+
export async function launchChrome(options: LaunchOptions): Promise<LaunchedBrowser> {
|
|
137
|
+
const profileDir = mkdtempSync(join(tmpdir(), 'x-e2e-chrome-'));
|
|
138
|
+
const child = Bun.spawn([options.executable, ...chromeLaunchFlags(profileDir)], {
|
|
139
|
+
// Chrome's fd 3 is where it READS commands and fd 4 where it WRITES replies and events.
|
|
140
|
+
stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
|
|
141
|
+
});
|
|
142
|
+
const tail = stderrTail(child.stderr as ReadableStream<Uint8Array>);
|
|
143
|
+
const [, , , toBrowser, fromBrowser] = child.stdio as unknown as readonly number[];
|
|
144
|
+
const sink = Bun.file(toBrowser ?? -1).writer();
|
|
145
|
+
const connection = cdpConnectOver(
|
|
146
|
+
pipeTransport({
|
|
147
|
+
write: (bytes) => {
|
|
148
|
+
sink.write(bytes);
|
|
149
|
+
void sink.flush();
|
|
150
|
+
},
|
|
151
|
+
read: Bun.file(fromBrowser ?? -1).stream(),
|
|
152
|
+
end: () => {
|
|
153
|
+
void Promise.resolve(sink.end()).catch(() => undefined);
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
options.timeoutMs,
|
|
157
|
+
);
|
|
158
|
+
let closed = false;
|
|
159
|
+
const close = (): void => {
|
|
160
|
+
if (closed) return;
|
|
161
|
+
closed = true;
|
|
162
|
+
connection.close();
|
|
163
|
+
child.kill();
|
|
164
|
+
rmSync(profileDir, { recursive: true, force: true });
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
await connection.send('Browser.getVersion');
|
|
168
|
+
return { connection, close };
|
|
169
|
+
} catch {
|
|
170
|
+
await within(FAILURE_DRAIN_MS, child.exited);
|
|
171
|
+
close();
|
|
172
|
+
await within(FAILURE_DRAIN_MS, tail.drained);
|
|
173
|
+
const seen = tail.text().trim();
|
|
174
|
+
throw new CdpLaunchFailedError({
|
|
175
|
+
executable: options.executable,
|
|
176
|
+
detail:
|
|
177
|
+
seen === ''
|
|
178
|
+
? 'it answered no DevTools call and printed nothing before the deadline'
|
|
179
|
+
: seen.split('\n').slice(-3).join(' | '),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `findChrome` then `launchChrome`. Refuses by name when there is no browser to drive. */
|
|
185
|
+
export async function launchFoundChrome(
|
|
186
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
187
|
+
timeoutMs: number,
|
|
188
|
+
): Promise<LaunchedBrowser> {
|
|
189
|
+
const executable = await findChrome(env);
|
|
190
|
+
if (executable === undefined) throw new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
|
|
191
|
+
return launchChrome({ executable, timeoutMs });
|
|
192
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// One responsibility: `navigator.onLine` reads `false` from a document's FIRST script while the
|
|
2
|
+
// session's offline switch is thrown, and reads `true` again — with the `online` event a page
|
|
3
|
+
// reconnects on — when it goes back. `Network.emulateNetworkConditions` cuts the NETWORK for a
|
|
4
|
+
// document created under the switch but, measured on Chrome 150, never tells it so: a reload under
|
|
5
|
+
// the switch read `navigator.onLine === true` at its first script every time, COOP or not, still
|
|
6
|
+
// read `true` a second later, and got no `online` event when the switch went back.
|
|
7
|
+
// `Network.overrideNetworkState` changed nothing. The dummy's page boot asks at its first script
|
|
8
|
+
// and replayed its outbox with a real POST under the cut (`offline-like.e2e.test.ts`, two attempts).
|
|
9
|
+
//
|
|
10
|
+
// The override is an OWN property on `navigator`, so the real getter on `Navigator.prototype` is one
|
|
11
|
+
// `delete` away. A document Chrome DID tell (it saw `offline`) gets Chrome's own `online` event on
|
|
12
|
+
// restore; one it never told gets one from `RESTORE_ONLINE`, so a page reconnects exactly once.
|
|
13
|
+
|
|
14
|
+
import type { CdpResult } from './cdp-connection';
|
|
15
|
+
|
|
16
|
+
type Send = (
|
|
17
|
+
method: string,
|
|
18
|
+
params: Record<string, unknown>,
|
|
19
|
+
session: string,
|
|
20
|
+
) => Promise<CdpResult>;
|
|
21
|
+
|
|
22
|
+
/** Runs before the page's own scripts, in every document a page session creates while cut. */
|
|
23
|
+
export const OFFLINE_FIRST_SCRIPT = `(() => {
|
|
24
|
+
let told = false;
|
|
25
|
+
Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => false });
|
|
26
|
+
addEventListener('offline', () => { told = true; });
|
|
27
|
+
addEventListener('online', () => { delete navigator.onLine; }, { once: true });
|
|
28
|
+
Object.defineProperty(window, '__xRestoreOnLine', { configurable: true, value: () => {
|
|
29
|
+
if (!Object.getOwnPropertyDescriptor(navigator, 'onLine')) return;
|
|
30
|
+
delete navigator.onLine;
|
|
31
|
+
if (!told) dispatchEvent(new Event('online'));
|
|
32
|
+
} });
|
|
33
|
+
})();`;
|
|
34
|
+
|
|
35
|
+
/** Evaluated in every open page when the switch goes back: a no-op in a document never cut. */
|
|
36
|
+
export const RESTORE_ONLINE = `window.__xRestoreOnLine?.()`;
|
|
37
|
+
|
|
38
|
+
export interface OfflineScripts {
|
|
39
|
+
/** Register the script on one page session, once; answers once the browser has it. */
|
|
40
|
+
add(session: string): Promise<unknown>;
|
|
41
|
+
/** Unregister it from one page session, if it holds it, and restore the open document. */
|
|
42
|
+
remove(session: string): Promise<unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const field = (from: unknown, key: string): string | undefined => {
|
|
46
|
+
const value =
|
|
47
|
+
typeof from === 'object' && from !== null ? (from as Record<string, unknown>)[key] : undefined;
|
|
48
|
+
return typeof value === 'string' ? value : undefined;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Per-session registration, keyed by the identifier the browser hands back. */
|
|
52
|
+
export function offlineScripts(send: Send): OfflineScripts {
|
|
53
|
+
const held = new Map<string, string>();
|
|
54
|
+
return {
|
|
55
|
+
async add(session) {
|
|
56
|
+
// Held already: a second `offline(true)` registered a second copy and kept one id, so
|
|
57
|
+
// `online()` removed one and every new page still read `navigator.onLine === false`.
|
|
58
|
+
if (held.has(session)) return undefined;
|
|
59
|
+
const answer = await send(
|
|
60
|
+
'Page.addScriptToEvaluateOnNewDocument',
|
|
61
|
+
{ source: OFFLINE_FIRST_SCRIPT },
|
|
62
|
+
session,
|
|
63
|
+
);
|
|
64
|
+
const identifier = field(answer.result, 'identifier');
|
|
65
|
+
if (identifier !== undefined) held.set(session, identifier);
|
|
66
|
+
return answer;
|
|
67
|
+
},
|
|
68
|
+
async remove(session) {
|
|
69
|
+
const identifier = held.get(session);
|
|
70
|
+
if (identifier === undefined) return undefined;
|
|
71
|
+
held.delete(session);
|
|
72
|
+
await send('Page.removeScriptToEvaluateOnNewDocument', { identifier }, session);
|
|
73
|
+
return send('Runtime.evaluate', { expression: RESTORE_ONLINE }, session);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
package/src/cdp-pipe.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// One responsibility: CDP over the pipe `--remote-debugging-pipe` opens — Chrome reads commands on
|
|
2
|
+
// its fd 3 and writes replies and events on its fd 4, each message one JSON text ended by a NUL
|
|
3
|
+
// byte. This is the e2e driver's wire; the WebSocket in `cdp-connection.ts` is for a remote browser.
|
|
4
|
+
//
|
|
5
|
+
// Why a pipe rather than the WebSocket Chrome also offers: Bun 1.4.0's WebSocket client handed
|
|
6
|
+
// `onmessage` text spliced from several frames under the dummy's `offline-feed` load — 64
|
|
7
|
+
// unparseable frames in one run, one of them a `Runtime.evaluate` reply that then waited out its
|
|
8
|
+
// 30 s deadline. A frame nobody can parse has no `id`, so no layer above can even tell which call
|
|
9
|
+
// it lost. A pipe is bytes and a delimiter, read here, and nothing in between.
|
|
10
|
+
|
|
11
|
+
import type { CdpTransport } from './cdp-connection';
|
|
12
|
+
|
|
13
|
+
/** The two ends a transport needs: a sink for whole messages, and the byte stream Chrome writes. */
|
|
14
|
+
export interface PipeEnds {
|
|
15
|
+
/** Write these bytes to Chrome's fd 3, in order. */
|
|
16
|
+
readonly write: (bytes: Uint8Array) => void;
|
|
17
|
+
/** Chrome's fd 4. */
|
|
18
|
+
readonly read: ReadableStream<Uint8Array>;
|
|
19
|
+
/** Release the write end — Chrome treats its fd 3 closing as the client going away. */
|
|
20
|
+
readonly end: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const NUL = 0;
|
|
24
|
+
|
|
25
|
+
/** Concatenate two byte arrays; the reader only ever holds the unterminated tail. */
|
|
26
|
+
const join = (a: Uint8Array, b: Uint8Array): Uint8Array => {
|
|
27
|
+
if (a.length === 0) return b;
|
|
28
|
+
const out = new Uint8Array(a.length + b.length);
|
|
29
|
+
out.set(a);
|
|
30
|
+
out.set(b, a.length);
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function pipeTransport(ends: PipeEnds): CdpTransport {
|
|
35
|
+
const encoder = new TextEncoder();
|
|
36
|
+
let closed = false;
|
|
37
|
+
const reader = ends.read.getReader();
|
|
38
|
+
return {
|
|
39
|
+
send(text: string): void {
|
|
40
|
+
if (closed) return;
|
|
41
|
+
const body = encoder.encode(text);
|
|
42
|
+
const framed = new Uint8Array(body.length + 1);
|
|
43
|
+
framed.set(body);
|
|
44
|
+
framed[body.length] = NUL;
|
|
45
|
+
ends.write(framed);
|
|
46
|
+
},
|
|
47
|
+
close(): void {
|
|
48
|
+
if (closed) return;
|
|
49
|
+
closed = true;
|
|
50
|
+
ends.end();
|
|
51
|
+
void reader.cancel().catch(() => undefined);
|
|
52
|
+
},
|
|
53
|
+
listen(handlers): void {
|
|
54
|
+
void (async () => {
|
|
55
|
+
// Split on BYTES, decoded per message: a multi-byte character may straddle two reads, and
|
|
56
|
+
// decoding each read on its own would corrupt it — the failure this file exists to end.
|
|
57
|
+
const decoder = new TextDecoder();
|
|
58
|
+
let tail: Uint8Array = new Uint8Array(0);
|
|
59
|
+
try {
|
|
60
|
+
for (;;) {
|
|
61
|
+
const { value, done } = await reader.read();
|
|
62
|
+
if (done) break;
|
|
63
|
+
tail = join(tail, value);
|
|
64
|
+
for (let at = tail.indexOf(NUL); at !== -1; at = tail.indexOf(NUL)) {
|
|
65
|
+
handlers.message(decoder.decode(tail.subarray(0, at)));
|
|
66
|
+
tail = tail.subarray(at + 1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// A read that fails is a pipe that is gone — reported as the close it is, below.
|
|
71
|
+
}
|
|
72
|
+
closed = true;
|
|
73
|
+
handlers.closed('the browser closed the CDP pipe');
|
|
74
|
+
})();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/e2e-app.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The app an e2e suite drives, spawned on a THROWAWAY state directory: its own embedded database,
|
|
2
|
+
// its own disk, its own dev lock, created per call and removed on `stop()`. Never the developer's
|
|
3
|
+
// `.x/pgdata` — resetting that from a test run destroys the data an `x dev` beside it is using.
|
|
4
|
+
// This file is the DATABASE half; spawning, readiness and the restart are `e2e-spawn.ts`'s.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no temp-directory primitive or recursive remove; `tmpdir()` is node:os's alone.
|
|
7
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
8
|
+
// why: Bun exposes no tmpdir() — only node:os answers the platform temp root.
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
// why: Bun exposes no path API — the state dir is joined, not concatenated.
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { finiteCount } from '@ultimat3/core';
|
|
13
|
+
import type { E2eAppMode } from './e2e-spawn';
|
|
14
|
+
import { inherited, refuse, spawnE2eApp, xBin } from './e2e-spawn';
|
|
15
|
+
|
|
16
|
+
export type { E2eAppMode } from './e2e-spawn';
|
|
17
|
+
|
|
18
|
+
export interface StartE2eAppOptions {
|
|
19
|
+
/** The app root — the directory holding `app.config.ts`. */
|
|
20
|
+
readonly root: string;
|
|
21
|
+
readonly mode?: E2eAppMode | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* The arguments after `x db seed`, or `false` for no seeding. Default `['--tier', 'dev']`: every
|
|
24
|
+
* dev-tier seed, which is what a developer's own `x dev` starts from.
|
|
25
|
+
*/
|
|
26
|
+
readonly seed?: readonly string[] | false | undefined;
|
|
27
|
+
/** Extra environment for every process — the reset, the seed and the app. */
|
|
28
|
+
readonly env?: Readonly<Record<string, string>> | undefined;
|
|
29
|
+
/** How long the app may take to answer `/readyz`. */
|
|
30
|
+
readonly readyTimeoutMs?: number | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface E2eApp {
|
|
34
|
+
/** `http://localhost:<port>`, no trailing slash. */
|
|
35
|
+
readonly base: string;
|
|
36
|
+
/** The throwaway `.x` this app runs on — the one directory a test may inspect or corrupt. */
|
|
37
|
+
readonly stateDir: string;
|
|
38
|
+
/** Kill the app and delete its state directory. Idempotent. */
|
|
39
|
+
stop(): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Stop the app and start it again on the SAME port and state directory, with `env` added — a
|
|
42
|
+
* deploy. `{ BUILD_ID: 'b2' }` is a new build the open tabs have not seen (`deploy.newBuild()`).
|
|
43
|
+
*/
|
|
44
|
+
restart(env?: Readonly<Record<string, string>>): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_READY_TIMEOUT_MS = 90_000;
|
|
48
|
+
|
|
49
|
+
function x(bin: string, args: readonly string[], root: string, env: Record<string, string>): void {
|
|
50
|
+
const run = Bun.spawnSync(['bun', bin, ...args], {
|
|
51
|
+
cwd: root,
|
|
52
|
+
env: { ...inherited(), ...env },
|
|
53
|
+
stdout: 'pipe',
|
|
54
|
+
stderr: 'pipe',
|
|
55
|
+
});
|
|
56
|
+
if (run.exitCode !== 0) {
|
|
57
|
+
throw refuse(`x ${args.join(' ')}`, `${run.stdout.toString()}${run.stderr.toString()}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Reset and seed a fresh state directory, then spawn the app on a free port and wait for `/readyz`.
|
|
63
|
+
* The reset runs against the throwaway directory, so it is a first migration, never a data loss.
|
|
64
|
+
*/
|
|
65
|
+
export async function startE2eApp(options: StartE2eAppOptions): Promise<E2eApp> {
|
|
66
|
+
// Screened FIRST, before a directory or a process exists: `waited < NaN` is false, so a NaN budget would never poll and report a dead app.
|
|
67
|
+
const deadline = finiteCount(
|
|
68
|
+
'startE2eApp',
|
|
69
|
+
'readyTimeoutMs',
|
|
70
|
+
options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
|
|
71
|
+
);
|
|
72
|
+
// Before the state directory too: an app that cannot name its `x` leaves nothing behind.
|
|
73
|
+
const bin = await xBin(options.root);
|
|
74
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'ultimate-e2e-'));
|
|
75
|
+
const env: Record<string, string> = { ...options.env, ULTIMATE_STATE_DIR: stateDir };
|
|
76
|
+
const cleanup = (): Promise<void> => rm(stateDir, { recursive: true, force: true });
|
|
77
|
+
try {
|
|
78
|
+
x(bin, ['db', 'reset'], options.root, env);
|
|
79
|
+
const seed = options.seed ?? ['--tier', 'dev'];
|
|
80
|
+
if (seed !== false) x(bin, ['db', 'seed', ...seed], options.root, env);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
await cleanup();
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const spawned = await spawnE2eApp({
|
|
87
|
+
root: options.root,
|
|
88
|
+
mode: options.mode ?? 'dev',
|
|
89
|
+
env,
|
|
90
|
+
bin,
|
|
91
|
+
readyTimeoutMs: deadline,
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
base: spawned.base,
|
|
95
|
+
stateDir,
|
|
96
|
+
restart: (next) => spawned.restart(next),
|
|
97
|
+
async stop(): Promise<void> {
|
|
98
|
+
await spawned.stop();
|
|
99
|
+
await cleanup();
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
} catch (error) {
|
|
103
|
+
await cleanup();
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The browser and the app the e2e step opened, reachable from a test file: `e2eBrowser()` and
|
|
2
|
+
// `e2eApp()`. On `globalThis` under one `Symbol.for` key, because the preload and a test may each
|
|
3
|
+
// hold their own copy of this module — the page-client handle's reason, one runtime over.
|
|
4
|
+
|
|
5
|
+
import type { E2eBrowser } from './cdp-browser';
|
|
6
|
+
import { CdpBrowserMissingError } from './cdp-errors';
|
|
7
|
+
import { CHROME_CANDIDATES } from './cdp-launch';
|
|
8
|
+
import type { E2eApp } from './e2e-app';
|
|
9
|
+
|
|
10
|
+
/** Set by the e2e step to the app root; the preload spawns that app and opens a browser. */
|
|
11
|
+
export const E2E_ROOT_ENV = 'ULTIMATE_E2E_ROOT';
|
|
12
|
+
|
|
13
|
+
interface E2eRun {
|
|
14
|
+
browser: E2eBrowser;
|
|
15
|
+
readonly app: E2eApp;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const KEY = Symbol.for('ultimate.e2e.run');
|
|
19
|
+
|
|
20
|
+
export function publishE2eRun(run: E2eRun): void {
|
|
21
|
+
Object.defineProperty(globalThis, KEY, { value: run, configurable: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const current = (): E2eRun | undefined => Reflect.get(globalThis, KEY) as E2eRun | undefined;
|
|
25
|
+
|
|
26
|
+
/** Swap in a relaunched browser — the app stays; only the dead browser is replaced. */
|
|
27
|
+
export function republishE2eBrowser(browser: E2eBrowser): void {
|
|
28
|
+
const run = current();
|
|
29
|
+
if (run !== undefined) run.browser = browser;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const missing = (): CdpBrowserMissingError =>
|
|
33
|
+
new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The run's browser: `page`, and `session` for a second tab, an init script, the socket and request
|
|
37
|
+
* log and the offline switch for every worker. Refuses by name outside an e2e run that found one.
|
|
38
|
+
*/
|
|
39
|
+
export function e2eBrowser(): E2eBrowser {
|
|
40
|
+
const run = current();
|
|
41
|
+
if (run === undefined) throw missing();
|
|
42
|
+
return run.browser;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The app the run spawned: `base`, `stateDir`, and `restart({ BUILD_ID })` — a deploy. */
|
|
46
|
+
export function e2eApp(): E2eApp {
|
|
47
|
+
const run = current();
|
|
48
|
+
if (run === undefined) throw missing();
|
|
49
|
+
return run.app;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The spawned app's origin, or `undefined` outside an e2e run. */
|
|
53
|
+
export function e2eBaseUrl(): string | undefined {
|
|
54
|
+
return current()?.app.base;
|
|
55
|
+
}
|