@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,107 @@
|
|
|
1
|
+
import { locator, } from './seam.js';
|
|
2
|
+
/**
|
|
3
|
+
* The wire protocol for driving the long-lived session over HTTP (ADR-0005).
|
|
4
|
+
*
|
|
5
|
+
* The served process holds ONE live {@link Page} in memory; a thin client verb
|
|
6
|
+
* cannot hold a JS reference to it across the process boundary, so each verb
|
|
7
|
+
* call is sent as a small JSON request to the server, which runs it against its
|
|
8
|
+
* live page and returns the result. This module is the SINGLE source of truth
|
|
9
|
+
* for that request/response shape, imported by BOTH the server handler and the
|
|
10
|
+
* client proxy so they cannot drift (mirrors how `serializeCookies` is shared
|
|
11
|
+
* by the cookies verb and its test).
|
|
12
|
+
*
|
|
13
|
+
* It is a thin transport detail, NOT a second verb surface: every request maps
|
|
14
|
+
* 1:1 to a {@link Page} method, and the seam's verb semantics (ADR-0003/0004)
|
|
15
|
+
* are unchanged. The {@link LocatorString} brand and the structured
|
|
16
|
+
* {@link WaitCondition} cross as plain JSON and are re-branded on the server
|
|
17
|
+
* with {@link locator}; no Playwright/CDP type is ever named here.
|
|
18
|
+
*/
|
|
19
|
+
/** The path the session RPC is served under, below the server's base URL. */
|
|
20
|
+
export const SESSION_RPC_PATH = '/session/call';
|
|
21
|
+
/**
|
|
22
|
+
* Run one {@link SessionRpcRequest} against a live {@link Page}, returning the
|
|
23
|
+
* value the wire should carry back. The server's HTTP handler is just this plus
|
|
24
|
+
* JSON framing; keeping the dispatch here (not inline in the handler) means the
|
|
25
|
+
* verb-to-page mapping is in one place and unit-testable without HTTP.
|
|
26
|
+
*
|
|
27
|
+
* The locator string and wait condition arrive as plain JSON; we re-brand the
|
|
28
|
+
* locator with {@link locator} before handing it to the page so the seam's
|
|
29
|
+
* branded-string contract holds.
|
|
30
|
+
*/
|
|
31
|
+
export async function applySessionRpc(page, request) {
|
|
32
|
+
switch (request.verb) {
|
|
33
|
+
case 'navigate':
|
|
34
|
+
await page.navigate(request.url);
|
|
35
|
+
return undefined;
|
|
36
|
+
case 'snapshot':
|
|
37
|
+
return page.snapshot({ full: request.full });
|
|
38
|
+
case 'click':
|
|
39
|
+
await page.click(locator(request.locator));
|
|
40
|
+
return undefined;
|
|
41
|
+
case 'type':
|
|
42
|
+
await page.type(locator(request.locator), request.text);
|
|
43
|
+
return undefined;
|
|
44
|
+
case 'eval':
|
|
45
|
+
return page.eval(request.expression);
|
|
46
|
+
case 'wait':
|
|
47
|
+
await page.wait(rebrandWait(request.condition));
|
|
48
|
+
return undefined;
|
|
49
|
+
case 'cookies':
|
|
50
|
+
return page.cookies();
|
|
51
|
+
case 'setCookies':
|
|
52
|
+
await page.setCookies(request.cookies);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A {@link Page} whose verbs forward to a server via the supplied transport.
|
|
58
|
+
*
|
|
59
|
+
* Used by the client-side proxy (see `remote-session.ts`): each verb builds a
|
|
60
|
+
* {@link SessionRpcRequest}, hands it to `send`, and shapes the reply back into
|
|
61
|
+
* the verb's return type. The `send` function owns the actual HTTP; this keeps
|
|
62
|
+
* the verb-to-request mapping (the other half of {@link applySessionRpc}) in
|
|
63
|
+
* one place so request and response shapes cannot drift between the two sides.
|
|
64
|
+
*/
|
|
65
|
+
export function makeRpcPage(send) {
|
|
66
|
+
return {
|
|
67
|
+
async navigate(url) {
|
|
68
|
+
await send({ verb: 'navigate', url });
|
|
69
|
+
},
|
|
70
|
+
async snapshot(options) {
|
|
71
|
+
return (await send({
|
|
72
|
+
verb: 'snapshot',
|
|
73
|
+
full: options?.full,
|
|
74
|
+
}));
|
|
75
|
+
},
|
|
76
|
+
async click(target) {
|
|
77
|
+
await send({ verb: 'click', locator: target });
|
|
78
|
+
},
|
|
79
|
+
async type(target, text) {
|
|
80
|
+
await send({ verb: 'type', locator: target, text });
|
|
81
|
+
},
|
|
82
|
+
async eval(expression) {
|
|
83
|
+
return send({ verb: 'eval', expression });
|
|
84
|
+
},
|
|
85
|
+
async wait(condition) {
|
|
86
|
+
await send({ verb: 'wait', condition });
|
|
87
|
+
},
|
|
88
|
+
async cookies() {
|
|
89
|
+
return (await send({ verb: 'cookies' }));
|
|
90
|
+
},
|
|
91
|
+
async setCookies(cookies) {
|
|
92
|
+
await send({ verb: 'setCookies', cookies });
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Re-brand a {@link WaitCondition} that arrived as plain JSON: only the
|
|
98
|
+
* `locator` form carries a branded string, which JSON flattens to a plain
|
|
99
|
+
* `string`, so we re-tag it before it reaches the page.
|
|
100
|
+
*/
|
|
101
|
+
function rebrandWait(condition) {
|
|
102
|
+
if (condition.kind === 'locator') {
|
|
103
|
+
return { kind: 'locator', target: locator(condition.target) };
|
|
104
|
+
}
|
|
105
|
+
return condition;
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=session-rpc.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-rpc.js","sourceRoot":"","sources":["../src/session-rpc.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { SessionAlreadyActiveError } from './errors.js';
|
|
2
|
+
import type { ProfileLocationOptions } from './profile-location.js';
|
|
3
|
+
import { type SessionEndpoint } from './session-endpoint.js';
|
|
4
|
+
import type { OpenTarget, Transport } from './seam.js';
|
|
5
|
+
/**
|
|
6
|
+
* The long-lived host that keeps ONE browser session alive between separate CLI
|
|
7
|
+
* invocations (ADR-0005; ADR-0001's control loop made concrete).
|
|
8
|
+
*
|
|
9
|
+
* This IS the controller: it opens the single live {@link Session} ONCE through
|
|
10
|
+
* a {@link Transport} and then serves that already-live page over HTTP, so each
|
|
11
|
+
* `webhands <verb>` thin-client process drives the SAME page state
|
|
12
|
+
* (not just the on-disk profile) and exits. The browser is launched once here,
|
|
13
|
+
* never per verb. It owns three things ADR-0005 calls out:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Single session.** It holds exactly one session; a second {@link open}
|
|
16
|
+
* while one is live is a {@link SessionAlreadyActiveError}, not a second
|
|
17
|
+
* browser.
|
|
18
|
+
* 2. **Discovery.** On start it writes its endpoint (the bound URL + pid) under
|
|
19
|
+
* the config dir so client verbs can find it; on stop it clears that file.
|
|
20
|
+
* 3. **Explicit teardown.** {@link stop} closes the browser and stops the
|
|
21
|
+
* listener; nothing auto-spawns and nothing auto-tears-down.
|
|
22
|
+
*
|
|
23
|
+
* The HTTP surface here is the small session RPC (`/session/call`, see
|
|
24
|
+
* `session-rpc.ts`), deliberately SEPARATE from incur's per-verb commands: a
|
|
25
|
+
* verb command opens-and-closes a session per call, which is exactly what
|
|
26
|
+
* cross-invocation persistence must NOT do. The CLI's `serve` command wraps
|
|
27
|
+
* this server; the CLI's verb commands become thin clients of it.
|
|
28
|
+
*
|
|
29
|
+
* Shared-write isolation: the endpoint file lives under the controller home
|
|
30
|
+
* root, so a {@link SessionServer} created with a temp `root`/`env` (via
|
|
31
|
+
* {@link SessionServerOptions}) writes only there, and tests assert the real
|
|
32
|
+
* `~/.webhands` is untouched.
|
|
33
|
+
*/
|
|
34
|
+
export interface SessionServerOptions extends ProfileLocationOptions {
|
|
35
|
+
/**
|
|
36
|
+
* The transport that opens the single live session (defaults to the caller's
|
|
37
|
+
* choice of launch/attach transport). Injectable so a test drives the server
|
|
38
|
+
* with any seam transport (e.g. a real Playwright launch against the local
|
|
39
|
+
* fixture profile) without the server hard-coding one.
|
|
40
|
+
*/
|
|
41
|
+
readonly transport: Transport;
|
|
42
|
+
/**
|
|
43
|
+
* Host to bind the HTTP listener to. Defaults to loopback (`127.0.0.1`): the
|
|
44
|
+
* server is a LOCAL tool on the user's machine (PRD "Out of Scope": not a
|
|
45
|
+
* hosted service), so it never listens on a public interface.
|
|
46
|
+
*/
|
|
47
|
+
readonly host?: string;
|
|
48
|
+
/** TCP port to bind. Defaults to `0` (an OS-assigned ephemeral port). */
|
|
49
|
+
readonly port?: number;
|
|
50
|
+
}
|
|
51
|
+
/** A running {@link SessionServer}: its advertised endpoint and how to stop it. */
|
|
52
|
+
export interface RunningSessionServer {
|
|
53
|
+
/** The endpoint advertised under the config dir for client discovery. */
|
|
54
|
+
readonly endpoint: SessionEndpoint;
|
|
55
|
+
/**
|
|
56
|
+
* Tear the session down: close the browser, stop the HTTP listener, and clear
|
|
57
|
+
* the endpoint file. Idempotent.
|
|
58
|
+
*/
|
|
59
|
+
stop(): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Start the long-lived session server: open the single session via the
|
|
63
|
+
* transport, bind the HTTP listener, advertise the endpoint, and serve the
|
|
64
|
+
* session RPC. Returns once the server is live and discoverable.
|
|
65
|
+
*
|
|
66
|
+
* Enforces the single-session invariant ACROSS processes via the endpoint file:
|
|
67
|
+
* if a live endpoint is already advertised, this refuses with
|
|
68
|
+
* {@link SessionAlreadyActiveError} rather than opening a second browser. (The
|
|
69
|
+
* caller checks discovery first; this is the last-line guard.)
|
|
70
|
+
*/
|
|
71
|
+
export declare function startSessionServer(target: OpenTarget, options: SessionServerOptions): Promise<RunningSessionServer>;
|
|
72
|
+
/**
|
|
73
|
+
* Guard the single-session invariant against double-open. The mechanism a
|
|
74
|
+
* caller actually relies on is discovery (no endpoint file ⇒ no live server);
|
|
75
|
+
* this is the explicit error a caller raises when it finds one already live and
|
|
76
|
+
* wants to refuse rather than open a second.
|
|
77
|
+
*/
|
|
78
|
+
export declare function sessionAlreadyActive(): SessionAlreadyActiveError;
|
|
79
|
+
//# sourceMappingURL=session-server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-server.d.ts","sourceRoot":"","sources":["../src/session-server.ts"],"names":[],"mappings":"AACA,OAAO,EAAC,yBAAyB,EAAC,MAAM,aAAa,CAAC;AACtD,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,uBAAuB,CAAC;AAClE,OAAO,EAGN,KAAK,eAAe,EACpB,MAAM,uBAAuB,CAAC;AAO/B,OAAO,KAAK,EAAC,UAAU,EAAW,SAAS,EAAC,MAAM,WAAW,CAAC;AAE9D;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,WAAW,oBAAqB,SAAQ,sBAAsB;IACnE;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,yEAAyE;IACzE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,mFAAmF;AACnF,MAAM,WAAW,oBAAoB;IACpC,yEAAyE;IACzE,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC;;;OAGG;IACH,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACtB;AAED;;;;;;;;;GASG;AACH,wBAAsB,kBAAkB,CACvC,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,oBAAoB,GAC3B,OAAO,CAAC,oBAAoB,CAAC,CAiD/B;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,IAAI,yBAAyB,CAEhE"}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { SessionAlreadyActiveError } from './errors.js';
|
|
3
|
+
import { clearSessionEndpoint, writeSessionEndpoint, } from './session-endpoint.js';
|
|
4
|
+
import { applySessionRpc, SESSION_RPC_PATH, } from './session-rpc.js';
|
|
5
|
+
/**
|
|
6
|
+
* Start the long-lived session server: open the single session via the
|
|
7
|
+
* transport, bind the HTTP listener, advertise the endpoint, and serve the
|
|
8
|
+
* session RPC. Returns once the server is live and discoverable.
|
|
9
|
+
*
|
|
10
|
+
* Enforces the single-session invariant ACROSS processes via the endpoint file:
|
|
11
|
+
* if a live endpoint is already advertised, this refuses with
|
|
12
|
+
* {@link SessionAlreadyActiveError} rather than opening a second browser. (The
|
|
13
|
+
* caller checks discovery first; this is the last-line guard.)
|
|
14
|
+
*/
|
|
15
|
+
export async function startSessionServer(target, options) {
|
|
16
|
+
const { transport, host = '127.0.0.1', port = 0, ...location } = options;
|
|
17
|
+
// Open the ONE live session up front: the browser launches here, once.
|
|
18
|
+
const session = await transport.open(target);
|
|
19
|
+
let server;
|
|
20
|
+
try {
|
|
21
|
+
server = createServer((req, res) => {
|
|
22
|
+
handleRequest(session, req, res);
|
|
23
|
+
});
|
|
24
|
+
await listen(server, port, host);
|
|
25
|
+
}
|
|
26
|
+
catch (cause) {
|
|
27
|
+
// Binding failed after we opened the browser; do not leak the session.
|
|
28
|
+
await session.close();
|
|
29
|
+
throw cause;
|
|
30
|
+
}
|
|
31
|
+
const address = server.address();
|
|
32
|
+
if (address === null || typeof address === 'string') {
|
|
33
|
+
await stopServer(server);
|
|
34
|
+
await session.close();
|
|
35
|
+
throw new Error('session server failed to bind to a TCP port');
|
|
36
|
+
}
|
|
37
|
+
const endpoint = {
|
|
38
|
+
url: `http://${host}:${address.port}`,
|
|
39
|
+
pid: process.pid,
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
await writeSessionEndpoint(endpoint, location);
|
|
43
|
+
}
|
|
44
|
+
catch (cause) {
|
|
45
|
+
await stopServer(server);
|
|
46
|
+
await session.close();
|
|
47
|
+
throw cause;
|
|
48
|
+
}
|
|
49
|
+
let stopped = false;
|
|
50
|
+
return {
|
|
51
|
+
endpoint,
|
|
52
|
+
async stop() {
|
|
53
|
+
if (stopped)
|
|
54
|
+
return;
|
|
55
|
+
stopped = true;
|
|
56
|
+
await clearSessionEndpoint(location);
|
|
57
|
+
await stopServer(server);
|
|
58
|
+
await session.close();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Guard the single-session invariant against double-open. The mechanism a
|
|
64
|
+
* caller actually relies on is discovery (no endpoint file ⇒ no live server);
|
|
65
|
+
* this is the explicit error a caller raises when it finds one already live and
|
|
66
|
+
* wants to refuse rather than open a second.
|
|
67
|
+
*/
|
|
68
|
+
export function sessionAlreadyActive() {
|
|
69
|
+
return new SessionAlreadyActiveError();
|
|
70
|
+
}
|
|
71
|
+
/** Handle one session-RPC HTTP request against the live session's page. */
|
|
72
|
+
function handleRequest(session, req, res) {
|
|
73
|
+
const url = req.url ?? '/';
|
|
74
|
+
const path = url.split('?')[0];
|
|
75
|
+
if (req.method !== 'POST' || path !== SESSION_RPC_PATH) {
|
|
76
|
+
writeJson(res, 404, {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: `no route for ${req.method} ${path}`,
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
collectBody(req)
|
|
83
|
+
.then(async (body) => {
|
|
84
|
+
let request;
|
|
85
|
+
try {
|
|
86
|
+
request = JSON.parse(body);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
writeJson(res, 400, { ok: false, error: 'invalid JSON request body' });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const value = await applySessionRpc(session.page, request);
|
|
94
|
+
const reply = { ok: true, value };
|
|
95
|
+
writeJson(res, 200, reply);
|
|
96
|
+
}
|
|
97
|
+
catch (cause) {
|
|
98
|
+
// A verb that throws in the page (or a closed session) maps to an
|
|
99
|
+
// ok:false reply carrying the message; the client re-throws a faithful
|
|
100
|
+
// Error so the seam's "a page throw rejects" contract holds remotely.
|
|
101
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
102
|
+
const reply = { ok: false, error: message };
|
|
103
|
+
writeJson(res, 200, reply);
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
.catch((cause) => {
|
|
107
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
108
|
+
writeJson(res, 500, { ok: false, error: message });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/** Read a request body to a string. */
|
|
112
|
+
function collectBody(req) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const chunks = [];
|
|
115
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
116
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
117
|
+
req.on('error', reject);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** Write a JSON response with a status code. */
|
|
121
|
+
function writeJson(res, status, body) {
|
|
122
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
123
|
+
res.end(JSON.stringify(body));
|
|
124
|
+
}
|
|
125
|
+
/** Promisified `server.listen`. */
|
|
126
|
+
function listen(server, port, host) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
server.once('error', reject);
|
|
129
|
+
server.listen(port, host, () => {
|
|
130
|
+
server.removeListener('error', reject);
|
|
131
|
+
resolve();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/** Promisified `server.close`. */
|
|
136
|
+
function stopServer(server) {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=session-server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-server.js","sourceRoot":"","sources":["../src/session-server.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type ProfileLocation, type ProfileLocationOptions } from './profile-location.js';
|
|
2
|
+
import type { Session, Transport } from './seam.js';
|
|
3
|
+
/**
|
|
4
|
+
* The headed one-time-login flow (PRD User Story 1; CONTEXT `setup-profile`;
|
|
5
|
+
* ADR-0002).
|
|
6
|
+
*
|
|
7
|
+
* `setup-profile` opens the dedicated profile in a VISIBLE (headed) browser so
|
|
8
|
+
* a human logs into a site and/or clears an anti-bot challenge ONCE. The
|
|
9
|
+
* cookies/state the human's session writes persist in the profile dir, so a
|
|
10
|
+
* later `launch --headless` against the SAME profile reuses that logged-in
|
|
11
|
+
* state without re-login.
|
|
12
|
+
*
|
|
13
|
+
* This is the orchestration layer over the launch transport, not a second
|
|
14
|
+
* transport: the launch transport refuses to launch a profile whose dir does
|
|
15
|
+
* not exist (a typed {@link MissingProfileError}, so a `launch` typo cannot
|
|
16
|
+
* spawn a blank profile). CREATING that dir is exactly `setup-profile`'s job,
|
|
17
|
+
* which is why the launch transport defers it here. So this flow:
|
|
18
|
+
*
|
|
19
|
+
* 1. resolves the dedicated profile dir (isolated to a temp root in tests,
|
|
20
|
+
* never the real `~/.webhands`),
|
|
21
|
+
* 2. CREATES it if absent (the one place a profile dir is created), so the
|
|
22
|
+
* profile is now "set up" for later launches,
|
|
23
|
+
* 3. opens it HEADED through the launch transport, and
|
|
24
|
+
* 4. emits a clear, actionable prompt telling the human what to do (log in /
|
|
25
|
+
* clear the challenge, then close the window) and WHICH profile is being set
|
|
26
|
+
* up.
|
|
27
|
+
*
|
|
28
|
+
* The verb only OPENS the window; it never types credentials or touches a
|
|
29
|
+
* credential (ADR-0002: the human does the one-time login, we never bypass it
|
|
30
|
+
* or solve CAPTCHAs). The caller holds the returned {@link Session} open for
|
|
31
|
+
* the interactive login and closes it when the human is done; on close the
|
|
32
|
+
* persistent context flushes the new state to the profile dir. The real
|
|
33
|
+
* third-party login is exercised only in the manual Kayak smoke, not here.
|
|
34
|
+
*/
|
|
35
|
+
/** A function that receives the headed-login prompt (one call, the full text). */
|
|
36
|
+
export type PromptSink = (message: string) => void;
|
|
37
|
+
/** Options for {@link setupProfile}. */
|
|
38
|
+
export interface SetupProfileOptions extends ProfileLocationOptions {
|
|
39
|
+
/** Name of the dedicated profile to set up (e.g. `default`). */
|
|
40
|
+
readonly profile: string;
|
|
41
|
+
/**
|
|
42
|
+
* Where the actionable prompt is delivered. Defaults to STDERR
|
|
43
|
+
* (`console.error`), because STDOUT is reserved for the CLI's structured
|
|
44
|
+
* output envelope; the human-facing instruction is a side-channel message.
|
|
45
|
+
* Tests inject a sink to assert the prompt's content.
|
|
46
|
+
*/
|
|
47
|
+
readonly onPrompt?: PromptSink;
|
|
48
|
+
/**
|
|
49
|
+
* The transport that opens the headed session. Defaults to a
|
|
50
|
+
* {@link PlaywrightLaunchTransport} bound to this flow's profile location.
|
|
51
|
+
* Injectable so the orchestration (dir creation, headed open, prompt) is
|
|
52
|
+
* testable without a real browser, and so the SAME launch transport the PRD
|
|
53
|
+
* mandates is reused rather than a parallel headed-open path.
|
|
54
|
+
*/
|
|
55
|
+
readonly transport?: Transport;
|
|
56
|
+
}
|
|
57
|
+
/** The result of {@link setupProfile}: the live headed session + where it is. */
|
|
58
|
+
export interface SetupProfileResult {
|
|
59
|
+
/**
|
|
60
|
+
* The live headed session, held OPEN for the human's interactive login. The
|
|
61
|
+
* caller closes it when the human is done; closing flushes the session state
|
|
62
|
+
* to the profile dir for a later headless launch.
|
|
63
|
+
*/
|
|
64
|
+
readonly session: Session;
|
|
65
|
+
/** The resolved location of the profile that was set up. */
|
|
66
|
+
readonly location: ProfileLocation;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Run the headed `setup-profile` flow for {@link SetupProfileOptions.profile}.
|
|
70
|
+
*
|
|
71
|
+
* Creates the dedicated profile dir if absent, opens it headed through the
|
|
72
|
+
* launch transport, emits the actionable prompt, and returns the live session
|
|
73
|
+
* for the caller to hold open during the interactive login (see this module's
|
|
74
|
+
* overview). Does NOT close the session: holding it open IS the headed-login
|
|
75
|
+
* window, and the caller owns its lifetime.
|
|
76
|
+
*/
|
|
77
|
+
export declare function setupProfile(options: SetupProfileOptions): Promise<SetupProfileResult>;
|
|
78
|
+
/**
|
|
79
|
+
* Compose the clear, actionable headed-login prompt (PRD acceptance: tell the
|
|
80
|
+
* user what to do AND which profile is being set up). Kept pure so a test can
|
|
81
|
+
* assert its content directly.
|
|
82
|
+
*/
|
|
83
|
+
export declare function buildPrompt(location: ProfileLocation): string;
|
|
84
|
+
//# sourceMappingURL=setup-profile.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"setup-profile.d.ts","sourceRoot":"","sources":["../src/setup-profile.ts"],"names":[],"mappings":"AAEA,OAAO,EAEN,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC3B,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAC,OAAO,EAAE,SAAS,EAAC,MAAM,WAAW,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,kFAAkF;AAClF,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;AAEnD,wCAAwC;AACxC,MAAM,WAAW,mBAAoB,SAAQ,sBAAsB;IAClE,gEAAgE;IAChE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,UAAU,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;CAC/B;AAED,iFAAiF;AACjF,MAAM,WAAW,kBAAkB;IAClC;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,4DAA4D;IAC5D,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;CACnC;AAED;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CACjC,OAAO,EAAE,mBAAmB,GAC1B,OAAO,CAAC,kBAAkB,CAAC,CAsB7B;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,eAAe,GAAG,MAAM,CAe7D"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { PlaywrightLaunchTransport } from './playwright-launch-transport.js';
|
|
3
|
+
import { resolveProfileLocation, } from './profile-location.js';
|
|
4
|
+
/**
|
|
5
|
+
* Run the headed `setup-profile` flow for {@link SetupProfileOptions.profile}.
|
|
6
|
+
*
|
|
7
|
+
* Creates the dedicated profile dir if absent, opens it headed through the
|
|
8
|
+
* launch transport, emits the actionable prompt, and returns the live session
|
|
9
|
+
* for the caller to hold open during the interactive login (see this module's
|
|
10
|
+
* overview). Does NOT close the session: holding it open IS the headed-login
|
|
11
|
+
* window, and the caller owns its lifetime.
|
|
12
|
+
*/
|
|
13
|
+
export async function setupProfile(options) {
|
|
14
|
+
const { profile, onPrompt, transport, ...locationOptions } = options;
|
|
15
|
+
const location = resolveProfileLocation(profile, locationOptions);
|
|
16
|
+
// Create the dedicated profile dir (idempotent). This is the ONE place a
|
|
17
|
+
// profile dir is created: the launch transport refuses a missing one with
|
|
18
|
+
// MissingProfileError precisely so `setup-profile` owns its creation.
|
|
19
|
+
await mkdir(location.profileDir, { recursive: true });
|
|
20
|
+
const driver = transport ?? new PlaywrightLaunchTransport(locationOptions);
|
|
21
|
+
// Open the profile HEADED (visible) so the human can interact with it.
|
|
22
|
+
const session = await driver.open({
|
|
23
|
+
mode: 'launch',
|
|
24
|
+
profile,
|
|
25
|
+
headed: true,
|
|
26
|
+
});
|
|
27
|
+
const sink = onPrompt ?? ((m) => console.error(m));
|
|
28
|
+
sink(buildPrompt(location));
|
|
29
|
+
return { session, location };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Compose the clear, actionable headed-login prompt (PRD acceptance: tell the
|
|
33
|
+
* user what to do AND which profile is being set up). Kept pure so a test can
|
|
34
|
+
* assert its content directly.
|
|
35
|
+
*/
|
|
36
|
+
export function buildPrompt(location) {
|
|
37
|
+
return [
|
|
38
|
+
`Setting up the "${location.profile}" profile for webhands.`,
|
|
39
|
+
`Profile directory: ${location.profileDir}`,
|
|
40
|
+
'',
|
|
41
|
+
'A browser window is now open. In that window:',
|
|
42
|
+
' 1. Log in to the site(s) you want this profile to stay signed in to.',
|
|
43
|
+
' 2. Clear any anti-bot challenge / CAPTCHA if one appears.',
|
|
44
|
+
' 3. When you are done, CLOSE the browser window.',
|
|
45
|
+
'',
|
|
46
|
+
'Your session (cookies, logins, challenge clearance) is saved into the',
|
|
47
|
+
'profile directory above, so a later `launch --headless` against the same',
|
|
48
|
+
'profile reuses it without re-login. Nobody but you types your credentials:',
|
|
49
|
+
'this tool only opens the window.',
|
|
50
|
+
].join('\n');
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=setup-profile.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"setup-profile.js","sourceRoot":"","sources":["../src/setup-profile.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,KAAK,EAAC,MAAM,kBAAkB,CAAC;AACvC,OAAO,EAAC,yBAAyB,EAAC,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EACN,sBAAsB,GAGtB,MAAM,uBAAuB,CAAC;AAwE/B;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CACjC,OAA4B;IAE5B,MAAM,EAAC,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,eAAe,EAAC,GAAG,OAAO,CAAC;IACnE,MAAM,QAAQ,GAAG,sBAAsB,CAAC,OAAO,EAAE,eAAe,CAAC,CAAC;IAElE,yEAAyE;IACzE,0EAA0E;IAC1E,sEAAsE;IACtE,MAAM,KAAK,CAAC,QAAQ,CAAC,UAAU,EAAE,EAAC,SAAS,EAAE,IAAI,EAAC,CAAC,CAAC;IAEpD,MAAM,MAAM,GAAG,SAAS,IAAI,IAAI,yBAAyB,CAAC,eAAe,CAAC,CAAC;IAE3E,uEAAuE;IACvE,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC;QACjC,IAAI,EAAE,QAAQ;QACd,OAAO;QACP,MAAM,EAAE,IAAI;KACZ,CAAC,CAAC;IAEH,MAAM,IAAI,GAAe,QAAQ,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IAC/D,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAC;IAE5B,OAAO,EAAC,OAAO,EAAE,QAAQ,EAAC,CAAC;AAC5B,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,QAAyB;IACpD,OAAO;QACN,mBAAmB,QAAQ,CAAC,OAAO,yBAAyB;QAC5D,sBAAsB,QAAQ,CAAC,UAAU,EAAE;QAC3C,EAAE;QACF,+CAA+C;QAC/C,wEAAwE;QACxE,6DAA6D;QAC7D,mDAAmD;QACnD,EAAE;QACF,uEAAuE;QACvE,0EAA0E;QAC1E,4EAA4E;QAC5E,kCAAkC;KAClC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACd,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { OpenTarget, Page, Session, Transport } from './seam.js';
|
|
2
|
+
/**
|
|
3
|
+
* A record of one verb call against the stub, for assertions in seam tests.
|
|
4
|
+
*/
|
|
5
|
+
export interface StubCall {
|
|
6
|
+
readonly verb: keyof Page;
|
|
7
|
+
readonly args: readonly unknown[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* An in-process, no-op {@link Transport} used to exercise the SEAM SHAPE
|
|
11
|
+
* without a real browser. It is NOT the Playwright transport (that lands in a
|
|
12
|
+
* later task) and implements no real verb behaviour: every verb is a no-op
|
|
13
|
+
* that records the call so a unit test can assert an `open` -> `Session` ->
|
|
14
|
+
* verb round-trip through the `core` `Driver` interface.
|
|
15
|
+
*
|
|
16
|
+
* Session lifetime: a session lives from {@link StubTransport.open} until its
|
|
17
|
+
* {@link Session.close} is called. After `close()` the page rejects further
|
|
18
|
+
* verb calls, mirroring the real "the browser is gone" contract so the seam's
|
|
19
|
+
* lifetime is testable.
|
|
20
|
+
*/
|
|
21
|
+
export declare class StubTransport implements Transport {
|
|
22
|
+
/** Every verb call across every session this transport opened, in order. */
|
|
23
|
+
readonly calls: StubCall[];
|
|
24
|
+
open(target: OpenTarget): Promise<Session>;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=stub-transport.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stub-transport.d.ts","sourceRoot":"","sources":["../src/stub-transport.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEX,UAAU,EACV,IAAI,EACJ,OAAO,EAGP,SAAS,EAET,MAAM,WAAW,CAAC;AAEnB;;GAEG;AACH,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,IAAI,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,SAAS,OAAO,EAAE,CAAC;CAClC;AAED;;;;;;;;;;;GAWG;AACH,qBAAa,aAAc,YAAW,SAAS;IAC9C,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,EAAE,QAAQ,EAAE,CAAM;IAE1B,IAAI,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC;CAgEhD"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-process, no-op {@link Transport} used to exercise the SEAM SHAPE
|
|
3
|
+
* without a real browser. It is NOT the Playwright transport (that lands in a
|
|
4
|
+
* later task) and implements no real verb behaviour: every verb is a no-op
|
|
5
|
+
* that records the call so a unit test can assert an `open` -> `Session` ->
|
|
6
|
+
* verb round-trip through the `core` `Driver` interface.
|
|
7
|
+
*
|
|
8
|
+
* Session lifetime: a session lives from {@link StubTransport.open} until its
|
|
9
|
+
* {@link Session.close} is called. After `close()` the page rejects further
|
|
10
|
+
* verb calls, mirroring the real "the browser is gone" contract so the seam's
|
|
11
|
+
* lifetime is testable.
|
|
12
|
+
*/
|
|
13
|
+
export class StubTransport {
|
|
14
|
+
/** Every verb call across every session this transport opened, in order. */
|
|
15
|
+
calls = [];
|
|
16
|
+
async open(target) {
|
|
17
|
+
const calls = this.calls;
|
|
18
|
+
let closed = false;
|
|
19
|
+
const ensureOpen = () => {
|
|
20
|
+
if (closed) {
|
|
21
|
+
throw new Error('session is closed');
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const url = target.mode === 'launch'
|
|
25
|
+
? `stub://launch/${target.profile}`
|
|
26
|
+
: `stub://attach/${target.endpoint}`;
|
|
27
|
+
const page = {
|
|
28
|
+
async navigate(to) {
|
|
29
|
+
ensureOpen();
|
|
30
|
+
calls.push({ verb: 'navigate', args: [to] });
|
|
31
|
+
},
|
|
32
|
+
async snapshot(options) {
|
|
33
|
+
ensureOpen();
|
|
34
|
+
calls.push({ verb: 'snapshot', args: [options] });
|
|
35
|
+
return {
|
|
36
|
+
url,
|
|
37
|
+
view: options?.full === true ? 'full' : 'accessibility',
|
|
38
|
+
content: '',
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
async click(t) {
|
|
42
|
+
ensureOpen();
|
|
43
|
+
calls.push({ verb: 'click', args: [t] });
|
|
44
|
+
},
|
|
45
|
+
async type(t, text) {
|
|
46
|
+
ensureOpen();
|
|
47
|
+
calls.push({ verb: 'type', args: [t, text] });
|
|
48
|
+
},
|
|
49
|
+
async eval(expression) {
|
|
50
|
+
ensureOpen();
|
|
51
|
+
calls.push({ verb: 'eval', args: [expression] });
|
|
52
|
+
return undefined;
|
|
53
|
+
},
|
|
54
|
+
async wait(condition) {
|
|
55
|
+
ensureOpen();
|
|
56
|
+
calls.push({ verb: 'wait', args: [condition] });
|
|
57
|
+
},
|
|
58
|
+
async cookies() {
|
|
59
|
+
ensureOpen();
|
|
60
|
+
calls.push({ verb: 'cookies', args: [] });
|
|
61
|
+
return [];
|
|
62
|
+
},
|
|
63
|
+
async setCookies(cookies) {
|
|
64
|
+
ensureOpen();
|
|
65
|
+
calls.push({ verb: 'setCookies', args: [cookies] });
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
page,
|
|
70
|
+
async close() {
|
|
71
|
+
closed = true;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=stub-transport.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stub-transport.js","sourceRoot":"","sources":["../src/stub-transport.ts"],"names":[],"mappings":"AAmBA;;;;;;;;;;;GAWG;AACH,MAAM,OAAO,aAAa;IACzB,4EAA4E;IACnE,KAAK,GAAe,EAAE,CAAC;IAEhC,KAAK,CAAC,IAAI,CAAC,MAAkB;QAC5B,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;QACzB,IAAI,MAAM,GAAG,KAAK,CAAC;QAEnB,MAAM,UAAU,GAAG,GAAG,EAAE;YACvB,IAAI,MAAM,EAAE,CAAC;gBACZ,MAAM,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAAC;YACtC,CAAC;QACF,CAAC,CAAC;QAEF,MAAM,GAAG,GACR,MAAM,CAAC,IAAI,KAAK,QAAQ;YACvB,CAAC,CAAC,iBAAiB,MAAM,CAAC,OAAO,EAAE;YACnC,CAAC,CAAC,iBAAiB,MAAM,CAAC,QAAQ,EAAE,CAAC;QAEvC,MAAM,IAAI,GAAS;YAClB,KAAK,CAAC,QAAQ,CAAC,EAAU;gBACxB,UAAU,EAAE,CAAC;gBACb,KAAK,CAAC,IAAI,CAAC,EAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,EAAC,CAAC,CAAC;YAC5C,CAAC;YACD,KAAK,CAAC,QAAQ,CAAC,OAAyB;gBACvC,UAAU,EAAE,CAAC;gBACb,KAAK,CAAC,IAAI,CAAC,EAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,OAAO,CAAC,EAAC,CAAC,CAAC;gBAChD,OAAO;oBACN,GAAG;oBACH,IAAI,EAAE,OAAO,EAAE,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,eAAe;oBACvD,OAAO,EAAE,EAAE;iBACX,CAAC;YACH,CAAC;YACD,KAAK,CAAC,KAAK,CAAC,CAAC;gBACZ,UAAU,EAAE,CAAC;gBACb,KAAK,CAAC,IAAI,CAAC,EAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAC,CAAC,CAAC;YACxC,CAAC;YACD,KAAK,CAAC,IAAI,CAAC,CAAC,EAAE,IAAI;gBACjB,UAAU,EAAE,CAAC;gBACb,KAAK,CAAC,IAAI,CAAC,EAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,IAAI,CAAC,EAAC,CAAC,CAAC;YAC7C,CAAC;YACD,KAAK,CAAC,IAAI,CAAC,UAAkB;gBAC5B,UAAU,EAAE,CAAC;gBACb,KAAK,CAAC,IAAI,CAAC,EAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,UAAU,CAAC,EAAC,CAAC,CAAC;gBAC/C,OAAO,SAAS,CAAC;YAClB,CAAC;YACD,KAAK,CAAC,IAAI,CAAC,SAAwB;gBAClC,UAAU,EAAE,CAAC;gBACb,KAAK,CAAC,IAAI,CAAC,EAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,SAAS,CAAC,EAAC,CAAC,CAAC;YAC/C,CAAC;YACD,KAAK,CAAC,OAAO;gBACZ,UAAU,EAAE,CAAC;gBACb,KAAK,CAAC,IAAI,CAAC,EAAC,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,EAAE,EAAC,CAAC,CAAC;gBACxC,OAAO,EAAE,CAAC;YACX,CAAC;YACD,KAAK,CAAC,UAAU,CAAC,OAAO;gBACvB,UAAU,EAAE,CAAC;gBACb,KAAK,CAAC,IAAI,CAAC,EAAC,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC,OAAO,CAAC,EAAC,CAAC,CAAC;YACnD,CAAC;SACD,CAAC;QAEF,OAAO;YACN,IAAI;YACJ,KAAK,CAAC,KAAK;gBACV,MAAM,GAAG,IAAI,CAAC;YACf,CAAC;SACD,CAAC;IACH,CAAC;CACD"}
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
/** Map of request path (relative to root, no leading slash) to page markup. */
|
|
11
|
+
export declare const FIXTURE_PAGES: Readonly<Record<string, string>>;
|
|
12
|
+
//# sourceMappingURL=fixture-pages.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fixture-pages.d.ts","sourceRoot":"","sources":["../../src/test-fixtures/fixture-pages.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAiMH,+EAA+E;AAC/E,eAAO,MAAM,aAAa,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAO1D,CAAC"}
|