@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.
- package/dist/cookies-export.d.ts +56 -0
- package/dist/cookies-export.d.ts.map +1 -0
- package/dist/cookies-export.js +69 -0
- package/dist/cookies-export.js.map +1 -0
- package/dist/errors.d.ts +126 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +135 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/playwright-attach-transport.d.ts +28 -0
- package/dist/playwright-attach-transport.d.ts.map +1 -0
- package/dist/playwright-attach-transport.js +175 -0
- package/dist/playwright-attach-transport.js.map +1 -0
- package/dist/playwright-launch-transport.d.ts +90 -0
- package/dist/playwright-launch-transport.d.ts.map +1 -0
- package/dist/playwright-launch-transport.js +305 -0
- package/dist/playwright-launch-transport.js.map +1 -0
- package/dist/profile-location.d.ts +61 -0
- package/dist/profile-location.d.ts.map +1 -0
- package/dist/profile-location.js +61 -0
- package/dist/profile-location.js.map +1 -0
- package/dist/remote-session.d.ts +22 -0
- package/dist/remote-session.d.ts.map +1 -0
- package/dist/remote-session.js +57 -0
- package/dist/remote-session.js.map +1 -0
- package/dist/seam.d.ts +212 -0
- package/dist/seam.d.ts.map +1 -0
- package/dist/seam.js +25 -0
- package/dist/seam.js.map +1 -0
- package/dist/session-endpoint.d.ts +53 -0
- package/dist/session-endpoint.d.ts.map +1 -0
- package/dist/session-endpoint.js +75 -0
- package/dist/session-endpoint.js.map +1 -0
- package/dist/session-rpc.d.ts +82 -0
- package/dist/session-rpc.d.ts.map +1 -0
- package/dist/session-rpc.js +107 -0
- package/dist/session-rpc.js.map +1 -0
- package/dist/session-server.d.ts +79 -0
- package/dist/session-server.d.ts.map +1 -0
- package/dist/session-server.js +141 -0
- package/dist/session-server.js.map +1 -0
- package/dist/setup-profile.d.ts +84 -0
- package/dist/setup-profile.d.ts.map +1 -0
- package/dist/setup-profile.js +52 -0
- package/dist/setup-profile.js.map +1 -0
- package/dist/stub-transport.d.ts +26 -0
- package/dist/stub-transport.d.ts.map +1 -0
- package/dist/stub-transport.js +76 -0
- package/dist/stub-transport.js.map +1 -0
- package/dist/test-fixtures/fixture-pages.d.ts +12 -0
- package/dist/test-fixtures/fixture-pages.d.ts.map +1 -0
- package/dist/test-fixtures/fixture-pages.js +204 -0
- package/dist/test-fixtures/fixture-pages.js.map +1 -0
- package/dist/test-fixtures/fixture-server.d.ts +19 -0
- package/dist/test-fixtures/fixture-server.d.ts.map +1 -0
- package/dist/test-fixtures/fixture-server.js +41 -0
- package/dist/test-fixtures/fixture-server.js.map +1 -0
- package/package.json +34 -0
- package/src/cookies-export.ts +91 -0
- package/src/errors.ts +185 -0
- package/src/index.ts +89 -0
- package/src/playwright-attach-transport.ts +214 -0
- package/src/playwright-launch-transport.ts +363 -0
- package/src/profile-location.ts +92 -0
- package/src/remote-session.ts +66 -0
- package/src/seam.ts +222 -0
- package/src/session-endpoint.ts +104 -0
- package/src/session-rpc.ts +143 -0
- package/src/session-server.ts +231 -0
- package/src/setup-profile.ts +134 -0
- package/src/stub-transport.ts +100 -0
- package/src/test-fixtures/fixture-pages.ts +210 -0
- 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
|
+
};
|