@webhands/core 0.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.
Files changed (76) hide show
  1. package/dist/cookies-export.d.ts +56 -0
  2. package/dist/cookies-export.d.ts.map +1 -0
  3. package/dist/cookies-export.js +69 -0
  4. package/dist/cookies-export.js.map +1 -0
  5. package/dist/errors.d.ts +126 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/errors.js +135 -0
  8. package/dist/errors.js.map +1 -0
  9. package/dist/index.d.ts +16 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +15 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/playwright-attach-transport.d.ts +28 -0
  14. package/dist/playwright-attach-transport.d.ts.map +1 -0
  15. package/dist/playwright-attach-transport.js +175 -0
  16. package/dist/playwright-attach-transport.js.map +1 -0
  17. package/dist/playwright-launch-transport.d.ts +90 -0
  18. package/dist/playwright-launch-transport.d.ts.map +1 -0
  19. package/dist/playwright-launch-transport.js +305 -0
  20. package/dist/playwright-launch-transport.js.map +1 -0
  21. package/dist/profile-location.d.ts +61 -0
  22. package/dist/profile-location.d.ts.map +1 -0
  23. package/dist/profile-location.js +61 -0
  24. package/dist/profile-location.js.map +1 -0
  25. package/dist/remote-session.d.ts +22 -0
  26. package/dist/remote-session.d.ts.map +1 -0
  27. package/dist/remote-session.js +57 -0
  28. package/dist/remote-session.js.map +1 -0
  29. package/dist/seam.d.ts +212 -0
  30. package/dist/seam.d.ts.map +1 -0
  31. package/dist/seam.js +25 -0
  32. package/dist/seam.js.map +1 -0
  33. package/dist/session-endpoint.d.ts +53 -0
  34. package/dist/session-endpoint.d.ts.map +1 -0
  35. package/dist/session-endpoint.js +75 -0
  36. package/dist/session-endpoint.js.map +1 -0
  37. package/dist/session-rpc.d.ts +82 -0
  38. package/dist/session-rpc.d.ts.map +1 -0
  39. package/dist/session-rpc.js +107 -0
  40. package/dist/session-rpc.js.map +1 -0
  41. package/dist/session-server.d.ts +79 -0
  42. package/dist/session-server.d.ts.map +1 -0
  43. package/dist/session-server.js +141 -0
  44. package/dist/session-server.js.map +1 -0
  45. package/dist/setup-profile.d.ts +84 -0
  46. package/dist/setup-profile.d.ts.map +1 -0
  47. package/dist/setup-profile.js +52 -0
  48. package/dist/setup-profile.js.map +1 -0
  49. package/dist/stub-transport.d.ts +26 -0
  50. package/dist/stub-transport.d.ts.map +1 -0
  51. package/dist/stub-transport.js +76 -0
  52. package/dist/stub-transport.js.map +1 -0
  53. package/dist/test-fixtures/fixture-pages.d.ts +12 -0
  54. package/dist/test-fixtures/fixture-pages.d.ts.map +1 -0
  55. package/dist/test-fixtures/fixture-pages.js +204 -0
  56. package/dist/test-fixtures/fixture-pages.js.map +1 -0
  57. package/dist/test-fixtures/fixture-server.d.ts +19 -0
  58. package/dist/test-fixtures/fixture-server.d.ts.map +1 -0
  59. package/dist/test-fixtures/fixture-server.js +41 -0
  60. package/dist/test-fixtures/fixture-server.js.map +1 -0
  61. package/package.json +34 -0
  62. package/src/cookies-export.ts +91 -0
  63. package/src/errors.ts +185 -0
  64. package/src/index.ts +89 -0
  65. package/src/playwright-attach-transport.ts +214 -0
  66. package/src/playwright-launch-transport.ts +363 -0
  67. package/src/profile-location.ts +92 -0
  68. package/src/remote-session.ts +66 -0
  69. package/src/seam.ts +222 -0
  70. package/src/session-endpoint.ts +104 -0
  71. package/src/session-rpc.ts +143 -0
  72. package/src/session-server.ts +231 -0
  73. package/src/setup-profile.ts +134 -0
  74. package/src/stub-transport.ts +100 -0
  75. package/src/test-fixtures/fixture-pages.ts +210 -0
  76. package/src/test-fixtures/fixture-server.ts +54 -0
@@ -0,0 +1,231 @@
1
+ import {createServer, type Server} from 'node:http';
2
+ import {SessionAlreadyActiveError} from './errors.js';
3
+ import type {ProfileLocationOptions} from './profile-location.js';
4
+ import {
5
+ clearSessionEndpoint,
6
+ writeSessionEndpoint,
7
+ type SessionEndpoint,
8
+ } from './session-endpoint.js';
9
+ import {
10
+ applySessionRpc,
11
+ SESSION_RPC_PATH,
12
+ type SessionRpcRequest,
13
+ type SessionRpcResponse,
14
+ } from './session-rpc.js';
15
+ import type {OpenTarget, Session, Transport} from './seam.js';
16
+
17
+ /**
18
+ * The long-lived host that keeps ONE browser session alive between separate CLI
19
+ * invocations (ADR-0005; ADR-0001's control loop made concrete).
20
+ *
21
+ * This IS the controller: it opens the single live {@link Session} ONCE through
22
+ * a {@link Transport} and then serves that already-live page over HTTP, so each
23
+ * `webhands <verb>` thin-client process drives the SAME page state
24
+ * (not just the on-disk profile) and exits. The browser is launched once here,
25
+ * never per verb. It owns three things ADR-0005 calls out:
26
+ *
27
+ * 1. **Single session.** It holds exactly one session; a second {@link open}
28
+ * while one is live is a {@link SessionAlreadyActiveError}, not a second
29
+ * browser.
30
+ * 2. **Discovery.** On start it writes its endpoint (the bound URL + pid) under
31
+ * the config dir so client verbs can find it; on stop it clears that file.
32
+ * 3. **Explicit teardown.** {@link stop} closes the browser and stops the
33
+ * listener; nothing auto-spawns and nothing auto-tears-down.
34
+ *
35
+ * The HTTP surface here is the small session RPC (`/session/call`, see
36
+ * `session-rpc.ts`), deliberately SEPARATE from incur's per-verb commands: a
37
+ * verb command opens-and-closes a session per call, which is exactly what
38
+ * cross-invocation persistence must NOT do. The CLI's `serve` command wraps
39
+ * this server; the CLI's verb commands become thin clients of it.
40
+ *
41
+ * Shared-write isolation: the endpoint file lives under the controller home
42
+ * root, so a {@link SessionServer} created with a temp `root`/`env` (via
43
+ * {@link SessionServerOptions}) writes only there, and tests assert the real
44
+ * `~/.webhands` is untouched.
45
+ */
46
+ export interface SessionServerOptions extends ProfileLocationOptions {
47
+ /**
48
+ * The transport that opens the single live session (defaults to the caller's
49
+ * choice of launch/attach transport). Injectable so a test drives the server
50
+ * with any seam transport (e.g. a real Playwright launch against the local
51
+ * fixture profile) without the server hard-coding one.
52
+ */
53
+ readonly transport: Transport;
54
+ /**
55
+ * Host to bind the HTTP listener to. Defaults to loopback (`127.0.0.1`): the
56
+ * server is a LOCAL tool on the user's machine (PRD "Out of Scope": not a
57
+ * hosted service), so it never listens on a public interface.
58
+ */
59
+ readonly host?: string;
60
+ /** TCP port to bind. Defaults to `0` (an OS-assigned ephemeral port). */
61
+ readonly port?: number;
62
+ }
63
+
64
+ /** A running {@link SessionServer}: its advertised endpoint and how to stop it. */
65
+ export interface RunningSessionServer {
66
+ /** The endpoint advertised under the config dir for client discovery. */
67
+ readonly endpoint: SessionEndpoint;
68
+ /**
69
+ * Tear the session down: close the browser, stop the HTTP listener, and clear
70
+ * the endpoint file. Idempotent.
71
+ */
72
+ stop(): Promise<void>;
73
+ }
74
+
75
+ /**
76
+ * Start the long-lived session server: open the single session via the
77
+ * transport, bind the HTTP listener, advertise the endpoint, and serve the
78
+ * session RPC. Returns once the server is live and discoverable.
79
+ *
80
+ * Enforces the single-session invariant ACROSS processes via the endpoint file:
81
+ * if a live endpoint is already advertised, this refuses with
82
+ * {@link SessionAlreadyActiveError} rather than opening a second browser. (The
83
+ * caller checks discovery first; this is the last-line guard.)
84
+ */
85
+ export async function startSessionServer(
86
+ target: OpenTarget,
87
+ options: SessionServerOptions,
88
+ ): Promise<RunningSessionServer> {
89
+ const {transport, host = '127.0.0.1', port = 0, ...location} = options;
90
+
91
+ // Open the ONE live session up front: the browser launches here, once.
92
+ const session: Session = await transport.open(target);
93
+
94
+ let server: Server;
95
+ try {
96
+ server = createServer((req, res) => {
97
+ handleRequest(session, req, res);
98
+ });
99
+ await listen(server, port, host);
100
+ } catch (cause) {
101
+ // Binding failed after we opened the browser; do not leak the session.
102
+ await session.close();
103
+ throw cause;
104
+ }
105
+
106
+ const address = server.address();
107
+ if (address === null || typeof address === 'string') {
108
+ await stopServer(server);
109
+ await session.close();
110
+ throw new Error('session server failed to bind to a TCP port');
111
+ }
112
+
113
+ const endpoint: SessionEndpoint = {
114
+ url: `http://${host}:${address.port}`,
115
+ pid: process.pid,
116
+ };
117
+
118
+ try {
119
+ await writeSessionEndpoint(endpoint, location);
120
+ } catch (cause) {
121
+ await stopServer(server);
122
+ await session.close();
123
+ throw cause;
124
+ }
125
+
126
+ let stopped = false;
127
+ return {
128
+ endpoint,
129
+ async stop() {
130
+ if (stopped) return;
131
+ stopped = true;
132
+ await clearSessionEndpoint(location);
133
+ await stopServer(server);
134
+ await session.close();
135
+ },
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Guard the single-session invariant against double-open. The mechanism a
141
+ * caller actually relies on is discovery (no endpoint file ⇒ no live server);
142
+ * this is the explicit error a caller raises when it finds one already live and
143
+ * wants to refuse rather than open a second.
144
+ */
145
+ export function sessionAlreadyActive(): SessionAlreadyActiveError {
146
+ return new SessionAlreadyActiveError();
147
+ }
148
+
149
+ /** Handle one session-RPC HTTP request against the live session's page. */
150
+ function handleRequest(
151
+ session: Session,
152
+ req: import('node:http').IncomingMessage,
153
+ res: import('node:http').ServerResponse,
154
+ ): void {
155
+ const url = req.url ?? '/';
156
+ const path = url.split('?')[0];
157
+ if (req.method !== 'POST' || path !== SESSION_RPC_PATH) {
158
+ writeJson(res, 404, {
159
+ ok: false,
160
+ error: `no route for ${req.method} ${path}`,
161
+ });
162
+ return;
163
+ }
164
+
165
+ collectBody(req)
166
+ .then(async (body) => {
167
+ let request: SessionRpcRequest;
168
+ try {
169
+ request = JSON.parse(body) as SessionRpcRequest;
170
+ } catch {
171
+ writeJson(res, 400, {ok: false, error: 'invalid JSON request body'});
172
+ return;
173
+ }
174
+ try {
175
+ const value = await applySessionRpc(session.page, request);
176
+ const reply: SessionRpcResponse = {ok: true, value};
177
+ writeJson(res, 200, reply);
178
+ } catch (cause) {
179
+ // A verb that throws in the page (or a closed session) maps to an
180
+ // ok:false reply carrying the message; the client re-throws a faithful
181
+ // Error so the seam's "a page throw rejects" contract holds remotely.
182
+ const message = cause instanceof Error ? cause.message : String(cause);
183
+ const reply: SessionRpcResponse = {ok: false, error: message};
184
+ writeJson(res, 200, reply);
185
+ }
186
+ })
187
+ .catch((cause: unknown) => {
188
+ const message = cause instanceof Error ? cause.message : String(cause);
189
+ writeJson(res, 500, {ok: false, error: message});
190
+ });
191
+ }
192
+
193
+ /** Read a request body to a string. */
194
+ function collectBody(
195
+ req: import('node:http').IncomingMessage,
196
+ ): Promise<string> {
197
+ return new Promise((resolve, reject) => {
198
+ const chunks: Buffer[] = [];
199
+ req.on('data', (chunk: Buffer) => chunks.push(chunk));
200
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
201
+ req.on('error', reject);
202
+ });
203
+ }
204
+
205
+ /** Write a JSON response with a status code. */
206
+ function writeJson(
207
+ res: import('node:http').ServerResponse,
208
+ status: number,
209
+ body: SessionRpcResponse,
210
+ ): void {
211
+ res.writeHead(status, {'content-type': 'application/json; charset=utf-8'});
212
+ res.end(JSON.stringify(body));
213
+ }
214
+
215
+ /** Promisified `server.listen`. */
216
+ function listen(server: Server, port: number, host: string): Promise<void> {
217
+ return new Promise((resolve, reject) => {
218
+ server.once('error', reject);
219
+ server.listen(port, host, () => {
220
+ server.removeListener('error', reject);
221
+ resolve();
222
+ });
223
+ });
224
+ }
225
+
226
+ /** Promisified `server.close`. */
227
+ function stopServer(server: Server): Promise<void> {
228
+ return new Promise((resolve, reject) => {
229
+ server.close((err) => (err ? reject(err) : resolve()));
230
+ });
231
+ }
@@ -0,0 +1,134 @@
1
+ import {mkdir} from 'node:fs/promises';
2
+ import {PlaywrightLaunchTransport} from './playwright-launch-transport.js';
3
+ import {
4
+ resolveProfileLocation,
5
+ type ProfileLocation,
6
+ type ProfileLocationOptions,
7
+ } from './profile-location.js';
8
+ import type {Session, Transport} from './seam.js';
9
+
10
+ /**
11
+ * The headed one-time-login flow (PRD User Story 1; CONTEXT `setup-profile`;
12
+ * ADR-0002).
13
+ *
14
+ * `setup-profile` opens the dedicated profile in a VISIBLE (headed) browser so
15
+ * a human logs into a site and/or clears an anti-bot challenge ONCE. The
16
+ * cookies/state the human's session writes persist in the profile dir, so a
17
+ * later `launch --headless` against the SAME profile reuses that logged-in
18
+ * state without re-login.
19
+ *
20
+ * This is the orchestration layer over the launch transport, not a second
21
+ * transport: the launch transport refuses to launch a profile whose dir does
22
+ * not exist (a typed {@link MissingProfileError}, so a `launch` typo cannot
23
+ * spawn a blank profile). CREATING that dir is exactly `setup-profile`'s job,
24
+ * which is why the launch transport defers it here. So this flow:
25
+ *
26
+ * 1. resolves the dedicated profile dir (isolated to a temp root in tests,
27
+ * never the real `~/.webhands`),
28
+ * 2. CREATES it if absent (the one place a profile dir is created), so the
29
+ * profile is now "set up" for later launches,
30
+ * 3. opens it HEADED through the launch transport, and
31
+ * 4. emits a clear, actionable prompt telling the human what to do (log in /
32
+ * clear the challenge, then close the window) and WHICH profile is being set
33
+ * up.
34
+ *
35
+ * The verb only OPENS the window; it never types credentials or touches a
36
+ * credential (ADR-0002: the human does the one-time login, we never bypass it
37
+ * or solve CAPTCHAs). The caller holds the returned {@link Session} open for
38
+ * the interactive login and closes it when the human is done; on close the
39
+ * persistent context flushes the new state to the profile dir. The real
40
+ * third-party login is exercised only in the manual Kayak smoke, not here.
41
+ */
42
+
43
+ /** A function that receives the headed-login prompt (one call, the full text). */
44
+ export type PromptSink = (message: string) => void;
45
+
46
+ /** Options for {@link setupProfile}. */
47
+ export interface SetupProfileOptions extends ProfileLocationOptions {
48
+ /** Name of the dedicated profile to set up (e.g. `default`). */
49
+ readonly profile: string;
50
+ /**
51
+ * Where the actionable prompt is delivered. Defaults to STDERR
52
+ * (`console.error`), because STDOUT is reserved for the CLI's structured
53
+ * output envelope; the human-facing instruction is a side-channel message.
54
+ * Tests inject a sink to assert the prompt's content.
55
+ */
56
+ readonly onPrompt?: PromptSink;
57
+ /**
58
+ * The transport that opens the headed session. Defaults to a
59
+ * {@link PlaywrightLaunchTransport} bound to this flow's profile location.
60
+ * Injectable so the orchestration (dir creation, headed open, prompt) is
61
+ * testable without a real browser, and so the SAME launch transport the PRD
62
+ * mandates is reused rather than a parallel headed-open path.
63
+ */
64
+ readonly transport?: Transport;
65
+ }
66
+
67
+ /** The result of {@link setupProfile}: the live headed session + where it is. */
68
+ export interface SetupProfileResult {
69
+ /**
70
+ * The live headed session, held OPEN for the human's interactive login. The
71
+ * caller closes it when the human is done; closing flushes the session state
72
+ * to the profile dir for a later headless launch.
73
+ */
74
+ readonly session: Session;
75
+ /** The resolved location of the profile that was set up. */
76
+ readonly location: ProfileLocation;
77
+ }
78
+
79
+ /**
80
+ * Run the headed `setup-profile` flow for {@link SetupProfileOptions.profile}.
81
+ *
82
+ * Creates the dedicated profile dir if absent, opens it headed through the
83
+ * launch transport, emits the actionable prompt, and returns the live session
84
+ * for the caller to hold open during the interactive login (see this module's
85
+ * overview). Does NOT close the session: holding it open IS the headed-login
86
+ * window, and the caller owns its lifetime.
87
+ */
88
+ export async function setupProfile(
89
+ options: SetupProfileOptions,
90
+ ): Promise<SetupProfileResult> {
91
+ const {profile, onPrompt, transport, ...locationOptions} = options;
92
+ const location = resolveProfileLocation(profile, locationOptions);
93
+
94
+ // Create the dedicated profile dir (idempotent). This is the ONE place a
95
+ // profile dir is created: the launch transport refuses a missing one with
96
+ // MissingProfileError precisely so `setup-profile` owns its creation.
97
+ await mkdir(location.profileDir, {recursive: true});
98
+
99
+ const driver = transport ?? new PlaywrightLaunchTransport(locationOptions);
100
+
101
+ // Open the profile HEADED (visible) so the human can interact with it.
102
+ const session = await driver.open({
103
+ mode: 'launch',
104
+ profile,
105
+ headed: true,
106
+ });
107
+
108
+ const sink: PromptSink = onPrompt ?? ((m) => console.error(m));
109
+ sink(buildPrompt(location));
110
+
111
+ return {session, location};
112
+ }
113
+
114
+ /**
115
+ * Compose the clear, actionable headed-login prompt (PRD acceptance: tell the
116
+ * user what to do AND which profile is being set up). Kept pure so a test can
117
+ * assert its content directly.
118
+ */
119
+ export function buildPrompt(location: ProfileLocation): string {
120
+ return [
121
+ `Setting up the "${location.profile}" profile for webhands.`,
122
+ `Profile directory: ${location.profileDir}`,
123
+ '',
124
+ 'A browser window is now open. In that window:',
125
+ ' 1. Log in to the site(s) you want this profile to stay signed in to.',
126
+ ' 2. Clear any anti-bot challenge / CAPTCHA if one appears.',
127
+ ' 3. When you are done, CLOSE the browser window.',
128
+ '',
129
+ 'Your session (cookies, logins, challenge clearance) is saved into the',
130
+ 'profile directory above, so a later `launch --headless` against the same',
131
+ 'profile reuses it without re-login. Nobody but you types your credentials:',
132
+ 'this tool only opens the window.',
133
+ ].join('\n');
134
+ }
@@ -0,0 +1,100 @@
1
+ import type {
2
+ Cookie,
3
+ OpenTarget,
4
+ Page,
5
+ Session,
6
+ Snapshot,
7
+ SnapshotOptions,
8
+ Transport,
9
+ WaitCondition,
10
+ } from './seam.js';
11
+
12
+ /**
13
+ * A record of one verb call against the stub, for assertions in seam tests.
14
+ */
15
+ export interface StubCall {
16
+ readonly verb: keyof Page;
17
+ readonly args: readonly unknown[];
18
+ }
19
+
20
+ /**
21
+ * An in-process, no-op {@link Transport} used to exercise the SEAM SHAPE
22
+ * without a real browser. It is NOT the Playwright transport (that lands in a
23
+ * later task) and implements no real verb behaviour: every verb is a no-op
24
+ * that records the call so a unit test can assert an `open` -> `Session` ->
25
+ * verb round-trip through the `core` `Driver` interface.
26
+ *
27
+ * Session lifetime: a session lives from {@link StubTransport.open} until its
28
+ * {@link Session.close} is called. After `close()` the page rejects further
29
+ * verb calls, mirroring the real "the browser is gone" contract so the seam's
30
+ * lifetime is testable.
31
+ */
32
+ export class StubTransport implements Transport {
33
+ /** Every verb call across every session this transport opened, in order. */
34
+ readonly calls: StubCall[] = [];
35
+
36
+ async open(target: OpenTarget): Promise<Session> {
37
+ const calls = this.calls;
38
+ let closed = false;
39
+
40
+ const ensureOpen = () => {
41
+ if (closed) {
42
+ throw new Error('session is closed');
43
+ }
44
+ };
45
+
46
+ const url =
47
+ target.mode === 'launch'
48
+ ? `stub://launch/${target.profile}`
49
+ : `stub://attach/${target.endpoint}`;
50
+
51
+ const page: Page = {
52
+ async navigate(to: string): Promise<void> {
53
+ ensureOpen();
54
+ calls.push({verb: 'navigate', args: [to]});
55
+ },
56
+ async snapshot(options?: SnapshotOptions): Promise<Snapshot> {
57
+ ensureOpen();
58
+ calls.push({verb: 'snapshot', args: [options]});
59
+ return {
60
+ url,
61
+ view: options?.full === true ? 'full' : 'accessibility',
62
+ content: '',
63
+ };
64
+ },
65
+ async click(t): Promise<void> {
66
+ ensureOpen();
67
+ calls.push({verb: 'click', args: [t]});
68
+ },
69
+ async type(t, text): Promise<void> {
70
+ ensureOpen();
71
+ calls.push({verb: 'type', args: [t, text]});
72
+ },
73
+ async eval(expression: string): Promise<unknown> {
74
+ ensureOpen();
75
+ calls.push({verb: 'eval', args: [expression]});
76
+ return undefined;
77
+ },
78
+ async wait(condition: WaitCondition): Promise<void> {
79
+ ensureOpen();
80
+ calls.push({verb: 'wait', args: [condition]});
81
+ },
82
+ async cookies(): Promise<readonly Cookie[]> {
83
+ ensureOpen();
84
+ calls.push({verb: 'cookies', args: []});
85
+ return [];
86
+ },
87
+ async setCookies(cookies): Promise<void> {
88
+ ensureOpen();
89
+ calls.push({verb: 'setCookies', args: [cookies]});
90
+ },
91
+ };
92
+
93
+ return {
94
+ page,
95
+ async close(): Promise<void> {
96
+ closed = true;
97
+ },
98
+ };
99
+ }
100
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The controlled static fixture pages served by {@link startFixtureServer}.
3
+ *
4
+ * Kept as in-module strings (rather than separate `.html` assets) so they
5
+ * survive `tsc` compilation into `dist` without a copy step, and so the
6
+ * deterministic verb tests have a single source of truth for the markup they
7
+ * assert against. Later verb-behaviour tasks extend these pages with whatever
8
+ * controlled elements they need; the seam scaffold ships a minimal index page.
9
+ */
10
+
11
+ const INDEX = `<!doctype html>
12
+ <html lang="en">
13
+ <head>
14
+ <meta charset="utf-8" />
15
+ <title>webhands fixture</title>
16
+ </head>
17
+ <body>
18
+ <h1 id="heading">Fixture Page</h1>
19
+ <p id="status">ready</p>
20
+ <input id="query" type="text" aria-label="Query" />
21
+ <button id="search" type="button">Search</button>
22
+ </body>
23
+ </html>
24
+ `;
25
+
26
+ /**
27
+ * A page whose content is rendered LATE, client-side: the "Loaded" heading and a
28
+ * marker element are injected by script ~150ms after the `load` event fires, the
29
+ * way an XHR-rendered price or a hydrated result list appears AFTER the document
30
+ * itself has settled. So the `load`-settled `goto` returns BEFORE this content
31
+ * exists, and only `wait({kind: 'locator'})` (PRD story 10) makes a reader block
32
+ * until it does. The delay is deterministic (driven by `setTimeout` against the
33
+ * fixture's own clock, not a network round-trip), so the wait-for-selector test
34
+ * is not flaky.
35
+ */
36
+ const DELAYED_CONTENT = `<!doctype html>
37
+ <html lang="en">
38
+ <head>
39
+ <meta charset="utf-8" />
40
+ <title>delayed content fixture</title>
41
+ </head>
42
+ <body>
43
+ <h1 id="heading">Loading…</h1>
44
+ <div id="results"></div>
45
+ <script>
46
+ window.setTimeout(function () {
47
+ document.getElementById('heading').textContent = 'Loaded';
48
+ var el = document.createElement('p');
49
+ el.id = 'late';
50
+ el.setAttribute('aria-label', 'Late Content');
51
+ el.textContent = 'late content rendered';
52
+ document.getElementById('results').appendChild(el);
53
+ }, 150);
54
+ </script>
55
+ </body>
56
+ </html>
57
+ `;
58
+
59
+ /**
60
+ * A page exercising the `click` and `type` verbs (PRD story 8).
61
+ *
62
+ * - `#search` is a VISIBLE button; clicking it runs its handler, which writes
63
+ * `clicked` into `#status`. A normal `click()` (actionability-checked)
64
+ * handles this path.
65
+ * - `#query` is a VISIBLE text input the `type` verb fills.
66
+ * - `#hidden-toggle` is a HIDDEN custom control (`display:none`), the case the
67
+ * prd calls out: a normal `click()` AUTO-WAITS for the element to become
68
+ * visible/actionable and TIMES OUT, because it never does. The verb's escape
69
+ * path DISPATCHES a click event (no actionability check); the handler then
70
+ * sets `#hidden-state` to `toggled`, so the test can assert the dispatch path
71
+ * actually fired the element's behaviour (not merely that it did not throw).
72
+ */
73
+ const CLICK_TYPE = `<!doctype html>
74
+ <html lang="en">
75
+ <head>
76
+ <meta charset="utf-8" />
77
+ <title>click + type fixture</title>
78
+ </head>
79
+ <body>
80
+ <h1 id="heading">Click + Type Fixture</h1>
81
+ <p id="status">idle</p>
82
+ <input id="query" type="text" aria-label="Query" />
83
+ <button id="search" type="button">Search</button>
84
+
85
+ <!-- A hidden custom control: a normal click times out (never actionable);
86
+ only a dispatched click fires its handler. -->
87
+ <div id="hidden-toggle" role="button" aria-label="Hidden Toggle" style="display: none"></div>
88
+ <p id="hidden-state">untoggled</p>
89
+
90
+ <script>
91
+ document.getElementById('search').addEventListener('click', function () {
92
+ document.getElementById('status').textContent = 'clicked';
93
+ });
94
+ document
95
+ .getElementById('hidden-toggle')
96
+ .addEventListener('click', function () {
97
+ document.getElementById('hidden-state').textContent = 'toggled';
98
+ });
99
+ </script>
100
+ </body>
101
+ </html>
102
+ `;
103
+
104
+ /**
105
+ * A page that NAVIGATES itself to `index.html` ~150ms after load, the way a
106
+ * landing/redirect page bounces to the real destination. `goto` here settles on
107
+ * THIS page's `load`; only `wait({kind: 'navigation'})` (PRD story 10) blocks
108
+ * until the subsequent navigation has settled, after which a reader is on
109
+ * `index.html`. Deterministic (a `setTimeout`-driven `location.assign`), so the
110
+ * wait-for-navigation test is not flaky.
111
+ */
112
+ const REDIRECTING = `<!doctype html>
113
+ <html lang="en">
114
+ <head>
115
+ <meta charset="utf-8" />
116
+ <title>redirecting fixture</title>
117
+ </head>
118
+ <body>
119
+ <h1 id="heading">Redirecting…</h1>
120
+ <script>
121
+ window.setTimeout(function () {
122
+ window.location.assign('/index.html');
123
+ }, 150);
124
+ </script>
125
+ </body>
126
+ </html>
127
+ `;
128
+
129
+ /**
130
+ * A page carrying controlled, deterministic state for the `eval` verb (PRD
131
+ * story 9) to read back. The escape-hatch tests evaluate expressions against
132
+ * THIS fixture's own state and assert the serialized result, never against
133
+ * third-party DOM (PRD "Testing Decisions"):
134
+ *
135
+ * - `#marker` holds a known text the verb can read.
136
+ * - `window.__fixture` is a known object graph (a number, a string, a nested
137
+ * array) so an object result can be asserted by value.
138
+ * - `window.__fixtureAsync()` resolves to a known value after a tick, so the
139
+ * Promise-awaiting behaviour of `eval` is exercised on the fixture's own
140
+ * clock (deterministic, not a network round-trip).
141
+ * - `window.__fixtureCircular` is a circular structure, the controlled case for
142
+ * asserting that the transport's structured clone PRESERVES circular refs (a
143
+ * `[Circular]` marker) rather than throwing, unlike a JSON-based encoding.
144
+ */
145
+ const EVAL = `<!doctype html>
146
+ <html lang="en">
147
+ <head>
148
+ <meta charset="utf-8" />
149
+ <title>eval fixture</title>
150
+ </head>
151
+ <body>
152
+ <h1 id="heading">Eval Fixture</h1>
153
+ <p id="marker">marker-value</p>
154
+ <script>
155
+ window.__fixture = {
156
+ count: 42,
157
+ label: 'fixture-label',
158
+ nested: [1, 2, 3],
159
+ };
160
+ window.__fixtureAsync = function () {
161
+ return new Promise(function (resolve) {
162
+ window.setTimeout(function () {
163
+ resolve('async-resolved');
164
+ }, 10);
165
+ });
166
+ };
167
+ var circular = {};
168
+ circular.self = circular;
169
+ window.__fixtureCircular = circular;
170
+ </script>
171
+ </body>
172
+ </html>
173
+ `;
174
+
175
+ /**
176
+ * A page that SETS its own cookies client-side on load (PRD story 11), so the
177
+ * `cookies export`/`cookies import` round-trip exports cookies the PAGE
178
+ * created (not only ones seeded through the seam) and re-imports them into a
179
+ * fresh context. Two cookies make the round-trip meaningful: a session-like
180
+ * value and a second name, so the test asserts the whole set crosses, not just
181
+ * one. `document.cookie` writes are visible to the browser context's cookie
182
+ * store, which is exactly what the seam's `cookies()` reads.
183
+ */
184
+ const COOKIES = `<!doctype html>
185
+ <html lang="en">
186
+ <head>
187
+ <meta charset="utf-8" />
188
+ <title>cookies fixture</title>
189
+ </head>
190
+ <body>
191
+ <h1 id="heading">Cookies Fixture</h1>
192
+ <p id="status">setting cookies</p>
193
+ <script>
194
+ document.cookie = 'mbc_session=session-value-123; path=/';
195
+ document.cookie = 'mbc_pref=dark-mode; path=/';
196
+ document.getElementById('status').textContent = 'cookies set';
197
+ </script>
198
+ </body>
199
+ </html>
200
+ `;
201
+
202
+ /** Map of request path (relative to root, no leading slash) to page markup. */
203
+ export const FIXTURE_PAGES: Readonly<Record<string, string>> = {
204
+ 'index.html': INDEX,
205
+ 'click-type.html': CLICK_TYPE,
206
+ 'delayed.html': DELAYED_CONTENT,
207
+ 'redirecting.html': REDIRECTING,
208
+ 'eval.html': EVAL,
209
+ 'cookies.html': COOKIES,
210
+ };