@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,204 @@
|
|
|
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
|
+
const INDEX = `<!doctype html>
|
|
11
|
+
<html lang="en">
|
|
12
|
+
<head>
|
|
13
|
+
<meta charset="utf-8" />
|
|
14
|
+
<title>webhands fixture</title>
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<h1 id="heading">Fixture Page</h1>
|
|
18
|
+
<p id="status">ready</p>
|
|
19
|
+
<input id="query" type="text" aria-label="Query" />
|
|
20
|
+
<button id="search" type="button">Search</button>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
|
23
|
+
`;
|
|
24
|
+
/**
|
|
25
|
+
* A page whose content is rendered LATE, client-side: the "Loaded" heading and a
|
|
26
|
+
* marker element are injected by script ~150ms after the `load` event fires, the
|
|
27
|
+
* way an XHR-rendered price or a hydrated result list appears AFTER the document
|
|
28
|
+
* itself has settled. So the `load`-settled `goto` returns BEFORE this content
|
|
29
|
+
* exists, and only `wait({kind: 'locator'})` (PRD story 10) makes a reader block
|
|
30
|
+
* until it does. The delay is deterministic (driven by `setTimeout` against the
|
|
31
|
+
* fixture's own clock, not a network round-trip), so the wait-for-selector test
|
|
32
|
+
* is not flaky.
|
|
33
|
+
*/
|
|
34
|
+
const DELAYED_CONTENT = `<!doctype html>
|
|
35
|
+
<html lang="en">
|
|
36
|
+
<head>
|
|
37
|
+
<meta charset="utf-8" />
|
|
38
|
+
<title>delayed content fixture</title>
|
|
39
|
+
</head>
|
|
40
|
+
<body>
|
|
41
|
+
<h1 id="heading">Loading…</h1>
|
|
42
|
+
<div id="results"></div>
|
|
43
|
+
<script>
|
|
44
|
+
window.setTimeout(function () {
|
|
45
|
+
document.getElementById('heading').textContent = 'Loaded';
|
|
46
|
+
var el = document.createElement('p');
|
|
47
|
+
el.id = 'late';
|
|
48
|
+
el.setAttribute('aria-label', 'Late Content');
|
|
49
|
+
el.textContent = 'late content rendered';
|
|
50
|
+
document.getElementById('results').appendChild(el);
|
|
51
|
+
}, 150);
|
|
52
|
+
</script>
|
|
53
|
+
</body>
|
|
54
|
+
</html>
|
|
55
|
+
`;
|
|
56
|
+
/**
|
|
57
|
+
* A page exercising the `click` and `type` verbs (PRD story 8).
|
|
58
|
+
*
|
|
59
|
+
* - `#search` is a VISIBLE button; clicking it runs its handler, which writes
|
|
60
|
+
* `clicked` into `#status`. A normal `click()` (actionability-checked)
|
|
61
|
+
* handles this path.
|
|
62
|
+
* - `#query` is a VISIBLE text input the `type` verb fills.
|
|
63
|
+
* - `#hidden-toggle` is a HIDDEN custom control (`display:none`), the case the
|
|
64
|
+
* prd calls out: a normal `click()` AUTO-WAITS for the element to become
|
|
65
|
+
* visible/actionable and TIMES OUT, because it never does. The verb's escape
|
|
66
|
+
* path DISPATCHES a click event (no actionability check); the handler then
|
|
67
|
+
* sets `#hidden-state` to `toggled`, so the test can assert the dispatch path
|
|
68
|
+
* actually fired the element's behaviour (not merely that it did not throw).
|
|
69
|
+
*/
|
|
70
|
+
const CLICK_TYPE = `<!doctype html>
|
|
71
|
+
<html lang="en">
|
|
72
|
+
<head>
|
|
73
|
+
<meta charset="utf-8" />
|
|
74
|
+
<title>click + type fixture</title>
|
|
75
|
+
</head>
|
|
76
|
+
<body>
|
|
77
|
+
<h1 id="heading">Click + Type Fixture</h1>
|
|
78
|
+
<p id="status">idle</p>
|
|
79
|
+
<input id="query" type="text" aria-label="Query" />
|
|
80
|
+
<button id="search" type="button">Search</button>
|
|
81
|
+
|
|
82
|
+
<!-- A hidden custom control: a normal click times out (never actionable);
|
|
83
|
+
only a dispatched click fires its handler. -->
|
|
84
|
+
<div id="hidden-toggle" role="button" aria-label="Hidden Toggle" style="display: none"></div>
|
|
85
|
+
<p id="hidden-state">untoggled</p>
|
|
86
|
+
|
|
87
|
+
<script>
|
|
88
|
+
document.getElementById('search').addEventListener('click', function () {
|
|
89
|
+
document.getElementById('status').textContent = 'clicked';
|
|
90
|
+
});
|
|
91
|
+
document
|
|
92
|
+
.getElementById('hidden-toggle')
|
|
93
|
+
.addEventListener('click', function () {
|
|
94
|
+
document.getElementById('hidden-state').textContent = 'toggled';
|
|
95
|
+
});
|
|
96
|
+
</script>
|
|
97
|
+
</body>
|
|
98
|
+
</html>
|
|
99
|
+
`;
|
|
100
|
+
/**
|
|
101
|
+
* A page that NAVIGATES itself to `index.html` ~150ms after load, the way a
|
|
102
|
+
* landing/redirect page bounces to the real destination. `goto` here settles on
|
|
103
|
+
* THIS page's `load`; only `wait({kind: 'navigation'})` (PRD story 10) blocks
|
|
104
|
+
* until the subsequent navigation has settled, after which a reader is on
|
|
105
|
+
* `index.html`. Deterministic (a `setTimeout`-driven `location.assign`), so the
|
|
106
|
+
* wait-for-navigation test is not flaky.
|
|
107
|
+
*/
|
|
108
|
+
const REDIRECTING = `<!doctype html>
|
|
109
|
+
<html lang="en">
|
|
110
|
+
<head>
|
|
111
|
+
<meta charset="utf-8" />
|
|
112
|
+
<title>redirecting fixture</title>
|
|
113
|
+
</head>
|
|
114
|
+
<body>
|
|
115
|
+
<h1 id="heading">Redirecting…</h1>
|
|
116
|
+
<script>
|
|
117
|
+
window.setTimeout(function () {
|
|
118
|
+
window.location.assign('/index.html');
|
|
119
|
+
}, 150);
|
|
120
|
+
</script>
|
|
121
|
+
</body>
|
|
122
|
+
</html>
|
|
123
|
+
`;
|
|
124
|
+
/**
|
|
125
|
+
* A page carrying controlled, deterministic state for the `eval` verb (PRD
|
|
126
|
+
* story 9) to read back. The escape-hatch tests evaluate expressions against
|
|
127
|
+
* THIS fixture's own state and assert the serialized result, never against
|
|
128
|
+
* third-party DOM (PRD "Testing Decisions"):
|
|
129
|
+
*
|
|
130
|
+
* - `#marker` holds a known text the verb can read.
|
|
131
|
+
* - `window.__fixture` is a known object graph (a number, a string, a nested
|
|
132
|
+
* array) so an object result can be asserted by value.
|
|
133
|
+
* - `window.__fixtureAsync()` resolves to a known value after a tick, so the
|
|
134
|
+
* Promise-awaiting behaviour of `eval` is exercised on the fixture's own
|
|
135
|
+
* clock (deterministic, not a network round-trip).
|
|
136
|
+
* - `window.__fixtureCircular` is a circular structure, the controlled case for
|
|
137
|
+
* asserting that the transport's structured clone PRESERVES circular refs (a
|
|
138
|
+
* `[Circular]` marker) rather than throwing, unlike a JSON-based encoding.
|
|
139
|
+
*/
|
|
140
|
+
const EVAL = `<!doctype html>
|
|
141
|
+
<html lang="en">
|
|
142
|
+
<head>
|
|
143
|
+
<meta charset="utf-8" />
|
|
144
|
+
<title>eval fixture</title>
|
|
145
|
+
</head>
|
|
146
|
+
<body>
|
|
147
|
+
<h1 id="heading">Eval Fixture</h1>
|
|
148
|
+
<p id="marker">marker-value</p>
|
|
149
|
+
<script>
|
|
150
|
+
window.__fixture = {
|
|
151
|
+
count: 42,
|
|
152
|
+
label: 'fixture-label',
|
|
153
|
+
nested: [1, 2, 3],
|
|
154
|
+
};
|
|
155
|
+
window.__fixtureAsync = function () {
|
|
156
|
+
return new Promise(function (resolve) {
|
|
157
|
+
window.setTimeout(function () {
|
|
158
|
+
resolve('async-resolved');
|
|
159
|
+
}, 10);
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
var circular = {};
|
|
163
|
+
circular.self = circular;
|
|
164
|
+
window.__fixtureCircular = circular;
|
|
165
|
+
</script>
|
|
166
|
+
</body>
|
|
167
|
+
</html>
|
|
168
|
+
`;
|
|
169
|
+
/**
|
|
170
|
+
* A page that SETS its own cookies client-side on load (PRD story 11), so the
|
|
171
|
+
* `cookies export`/`cookies import` round-trip exports cookies the PAGE
|
|
172
|
+
* created (not only ones seeded through the seam) and re-imports them into a
|
|
173
|
+
* fresh context. Two cookies make the round-trip meaningful: a session-like
|
|
174
|
+
* value and a second name, so the test asserts the whole set crosses, not just
|
|
175
|
+
* one. `document.cookie` writes are visible to the browser context's cookie
|
|
176
|
+
* store, which is exactly what the seam's `cookies()` reads.
|
|
177
|
+
*/
|
|
178
|
+
const COOKIES = `<!doctype html>
|
|
179
|
+
<html lang="en">
|
|
180
|
+
<head>
|
|
181
|
+
<meta charset="utf-8" />
|
|
182
|
+
<title>cookies fixture</title>
|
|
183
|
+
</head>
|
|
184
|
+
<body>
|
|
185
|
+
<h1 id="heading">Cookies Fixture</h1>
|
|
186
|
+
<p id="status">setting cookies</p>
|
|
187
|
+
<script>
|
|
188
|
+
document.cookie = 'mbc_session=session-value-123; path=/';
|
|
189
|
+
document.cookie = 'mbc_pref=dark-mode; path=/';
|
|
190
|
+
document.getElementById('status').textContent = 'cookies set';
|
|
191
|
+
</script>
|
|
192
|
+
</body>
|
|
193
|
+
</html>
|
|
194
|
+
`;
|
|
195
|
+
/** Map of request path (relative to root, no leading slash) to page markup. */
|
|
196
|
+
export const FIXTURE_PAGES = {
|
|
197
|
+
'index.html': INDEX,
|
|
198
|
+
'click-type.html': CLICK_TYPE,
|
|
199
|
+
'delayed.html': DELAYED_CONTENT,
|
|
200
|
+
'redirecting.html': REDIRECTING,
|
|
201
|
+
'eval.html': EVAL,
|
|
202
|
+
'cookies.html': COOKIES,
|
|
203
|
+
};
|
|
204
|
+
//# sourceMappingURL=fixture-pages.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fixture-pages.js","sourceRoot":"","sources":["../../src/test-fixtures/fixture-pages.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,KAAK,GAAG;;;;;;;;;;;;;CAab,CAAC;AAEF;;;;;;;;;GASG;AACH,MAAM,eAAe,GAAG;;;;;;;;;;;;;;;;;;;;;CAqBvB,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6BlB,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,WAAW,GAAG;;;;;;;;;;;;;;;CAenB,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,MAAM,IAAI,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BZ,CAAC;AAEF;;;;;;;;GAQG;AACH,MAAM,OAAO,GAAG;;;;;;;;;;;;;;;;CAgBf,CAAC;AAEF,+EAA+E;AAC/E,MAAM,CAAC,MAAM,aAAa,GAAqC;IAC9D,YAAY,EAAE,KAAK;IACnB,iBAAiB,EAAE,UAAU;IAC7B,cAAc,EAAE,eAAe;IAC/B,kBAAkB,EAAE,WAAW;IAC/B,WAAW,EAAE,IAAI;IACjB,cAAc,EAAE,OAAO;CACvB,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** A running fixture server, with the base URL to point a browser at. */
|
|
2
|
+
export interface FixtureServer {
|
|
3
|
+
/** The base URL, e.g. `http://127.0.0.1:52831`. */
|
|
4
|
+
readonly url: string;
|
|
5
|
+
/** Stop the server and release the port. */
|
|
6
|
+
close(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Start a local HTTP server that serves the controlled static fixture pages
|
|
10
|
+
* from {@link FIXTURE_PAGES}. This is the DETERMINISTIC target for later
|
|
11
|
+
* verb-behaviour tests (navigate / snapshot / click / type / eval / wait /
|
|
12
|
+
* cookies): those tests drive a real browser against this server instead of a
|
|
13
|
+
* third-party site, so they never rot on someone else's DOM.
|
|
14
|
+
*
|
|
15
|
+
* Binds to `127.0.0.1` on an OS-assigned ephemeral port (pass a fixed `port`
|
|
16
|
+
* only if a test needs one). `/` serves `index.html`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function startFixtureServer(port?: number): Promise<FixtureServer>;
|
|
19
|
+
//# sourceMappingURL=fixture-server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fixture-server.d.ts","sourceRoot":"","sources":["../../src/test-fixtures/fixture-server.ts"],"names":[],"mappings":"AAGA,yEAAyE;AACzE,MAAM,WAAW,aAAa;IAC7B,mDAAmD;IACnD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,4CAA4C;IAC5C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAED;;;;;;;;;GASG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,SAAI,GAAG,OAAO,CAAC,aAAa,CAAC,CAgCzE"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { FIXTURE_PAGES } from './fixture-pages.js';
|
|
3
|
+
/**
|
|
4
|
+
* Start a local HTTP server that serves the controlled static fixture pages
|
|
5
|
+
* from {@link FIXTURE_PAGES}. This is the DETERMINISTIC target for later
|
|
6
|
+
* verb-behaviour tests (navigate / snapshot / click / type / eval / wait /
|
|
7
|
+
* cookies): those tests drive a real browser against this server instead of a
|
|
8
|
+
* third-party site, so they never rot on someone else's DOM.
|
|
9
|
+
*
|
|
10
|
+
* Binds to `127.0.0.1` on an OS-assigned ephemeral port (pass a fixed `port`
|
|
11
|
+
* only if a test needs one). `/` serves `index.html`.
|
|
12
|
+
*/
|
|
13
|
+
export async function startFixtureServer(port = 0) {
|
|
14
|
+
const server = createServer((req, res) => {
|
|
15
|
+
const rawPath = (req.url ?? '/').split('?')[0];
|
|
16
|
+
const key = rawPath === '/' ? 'index.html' : rawPath.replace(/^\/+/, '');
|
|
17
|
+
const body = FIXTURE_PAGES[key];
|
|
18
|
+
if (body === undefined) {
|
|
19
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
20
|
+
res.end('not found');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
24
|
+
res.end(body);
|
|
25
|
+
});
|
|
26
|
+
await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve));
|
|
27
|
+
const address = server.address();
|
|
28
|
+
if (address === null || typeof address === 'string') {
|
|
29
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
30
|
+
throw new Error('fixture server failed to bind to a TCP port');
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
url: `http://127.0.0.1:${address.port}`,
|
|
34
|
+
close() {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=fixture-server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fixture-server.js","sourceRoot":"","sources":["../../src/test-fixtures/fixture-server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,YAAY,EAAc,MAAM,WAAW,CAAC;AACpD,OAAO,EAAC,aAAa,EAAC,MAAM,oBAAoB,CAAC;AAUjD;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,IAAI,GAAG,CAAC;IAChD,MAAM,MAAM,GAAW,YAAY,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;QAChD,MAAM,OAAO,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QAC/C,MAAM,GAAG,GAAG,OAAO,KAAK,GAAG,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QACzE,MAAM,IAAI,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;QAChC,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YACxB,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAC,cAAc,EAAE,2BAA2B,EAAC,CAAC,CAAC;YAClE,GAAG,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;YACrB,OAAO;QACR,CAAC;QACD,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAC,cAAc,EAAE,0BAA0B,EAAC,CAAC,CAAC;QACjE,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACf,CAAC,CAAC,CAAC;IAEH,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE,CACnC,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,CAAC,CACzC,CAAC;IAEF,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,EAAE,CAAC;IACjC,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,OAAO,KAAK,QAAQ,EAAE,CAAC;QACrD,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;QACpE,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;IAChE,CAAC;IAED,OAAO;QACN,GAAG,EAAE,oBAAoB,OAAO,CAAC,IAAI,EAAE;QACvC,KAAK;YACJ,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;gBAC5C,MAAM,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;YACxD,CAAC,CAAC,CAAC;QACJ,CAAC;KACD,CAAC;AACH,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@webhands/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"playwright": "1.61.1"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^25.2.0",
|
|
23
|
+
"as-soon": "^0.1.5",
|
|
24
|
+
"tsx": "^4.21.0",
|
|
25
|
+
"typescript": "^5.3.3",
|
|
26
|
+
"vitest": "^4.0.18"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"dev": "as-soon -w src pnpm build",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:watch": "vitest"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `cookies export` / `cookies import` verb's FILE FORMAT (PRD story 11).
|
|
3
|
+
*
|
|
4
|
+
* The seam already carries the transport-neutral cookie primitives:
|
|
5
|
+
* {@link Page.cookies} reads the active context's cookies and
|
|
6
|
+
* {@link Page.setCookies} loads cookies into it. The export/import VERB is built
|
|
7
|
+
* ON TOP of those two methods (the forward-note: refine the existing seam,
|
|
8
|
+
* do NOT add a parallel cookie path). What this module adds is only the
|
|
9
|
+
* SERIALIZATION the verb needs to move a session to/from disk: how a
|
|
10
|
+
* `Cookie[]` is written to (and read back from) an export file.
|
|
11
|
+
*
|
|
12
|
+
* The format is deliberately transport-neutral JSON of the seam's own
|
|
13
|
+
* {@link Cookie} type (no CDP/Playwright type, ADR-0003): a small envelope
|
|
14
|
+
* (`{version, cookies}`) so the file is self-describing and a future format
|
|
15
|
+
* change can be detected rather than silently mis-parsed. Both the CLI verb and
|
|
16
|
+
* the round-trip test share THIS one source of truth for the format, so the
|
|
17
|
+
* thing a user backs up and the thing import reads back can never drift apart.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type {Cookie} from './seam.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The current export-file schema version. Bumped only on a
|
|
24
|
+
* backwards-INCOMPATIBLE format change; {@link deserializeCookies} rejects an
|
|
25
|
+
* unknown version rather than guessing.
|
|
26
|
+
*/
|
|
27
|
+
export const COOKIES_EXPORT_VERSION = 1 as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The on-disk shape of an exported session: a versioned envelope around the
|
|
31
|
+
* transport-neutral {@link Cookie} list. Self-describing so import can verify
|
|
32
|
+
* it is reading a format it understands.
|
|
33
|
+
*/
|
|
34
|
+
export interface CookiesExport {
|
|
35
|
+
/** Format version (see {@link COOKIES_EXPORT_VERSION}). */
|
|
36
|
+
readonly version: typeof COOKIES_EXPORT_VERSION;
|
|
37
|
+
/** The exported cookies, exactly as the seam's {@link Page.cookies} returns them. */
|
|
38
|
+
readonly cookies: readonly Cookie[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Serialize the cookies read from the seam ({@link Page.cookies}) into the
|
|
43
|
+
* export file's text. Pretty-printed JSON so a human can read/diff a backed-up
|
|
44
|
+
* session. This is pure: it does NO disk I/O, so the caller (the CLI verb, a
|
|
45
|
+
* test) owns WHERE the file lands — which is what lets a test keep its export
|
|
46
|
+
* file in its own temp dir.
|
|
47
|
+
*/
|
|
48
|
+
export function serializeCookies(cookies: readonly Cookie[]): string {
|
|
49
|
+
const payload: CookiesExport = {
|
|
50
|
+
version: COOKIES_EXPORT_VERSION,
|
|
51
|
+
cookies,
|
|
52
|
+
};
|
|
53
|
+
return JSON.stringify(payload, null, '\t') + '\n';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse an export file's text back into the cookies to hand to the seam's
|
|
58
|
+
* {@link Page.setCookies} ({@link parse} is pure; the caller does the disk read
|
|
59
|
+
* and the `setCookies` call). Rejects anything that is not a recognised export
|
|
60
|
+
* envelope so a corrupt or wrong-version file surfaces as a clear error rather
|
|
61
|
+
* than silently importing nothing or a half-parsed list.
|
|
62
|
+
*
|
|
63
|
+
* @throws Error if the text is not valid JSON, not the expected envelope shape,
|
|
64
|
+
* or carries an unknown {@link COOKIES_EXPORT_VERSION}.
|
|
65
|
+
*/
|
|
66
|
+
export function deserializeCookies(text: string): readonly Cookie[] {
|
|
67
|
+
let parsed: unknown;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(text);
|
|
70
|
+
} catch (cause) {
|
|
71
|
+
throw new Error('cookies import: file is not valid JSON', {cause});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
75
|
+
throw new Error('cookies import: file is not a cookies export envelope');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const envelope = parsed as {version?: unknown; cookies?: unknown};
|
|
79
|
+
if (envelope.version !== COOKIES_EXPORT_VERSION) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`cookies import: unsupported export version ${String(
|
|
82
|
+
envelope.version,
|
|
83
|
+
)} (expected ${COOKIES_EXPORT_VERSION})`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (!Array.isArray(envelope.cookies)) {
|
|
87
|
+
throw new Error('cookies import: export envelope has no cookies array');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return envelope.cookies as readonly Cookie[];
|
|
91
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed, identifiable `core` error conditions.
|
|
3
|
+
*
|
|
4
|
+
* These are raised by the concrete transports (the v1 Playwright launch
|
|
5
|
+
* transport, and later `attach`/`setup-profile`) so that the `cli` package
|
|
6
|
+
* (`cli-incur-wiring-and-errors`, PRD story 17) can render the EXACT
|
|
7
|
+
* fix-command message without re-detecting the condition. This module OWNS the
|
|
8
|
+
* typed condition; the CLI owns the user-facing message text.
|
|
9
|
+
*
|
|
10
|
+
* The discriminator is the string-literal {@link ControllerError.code}. A
|
|
11
|
+
* caller branches on `code` (a stable, machine-readable tag) rather than
|
|
12
|
+
* matching on a message string, which is presentation and may change. Each
|
|
13
|
+
* error also carries the structured context the CLI needs to compose its fix
|
|
14
|
+
* command (e.g. the profile name, the resolved profile dir) so the CLI never
|
|
15
|
+
* has to re-derive paths.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The closed set of identifiable `core` error conditions. */
|
|
19
|
+
export type ControllerErrorCode =
|
|
20
|
+
| 'missing-browser-binary'
|
|
21
|
+
| 'missing-profile'
|
|
22
|
+
| 'attach-not-chromium'
|
|
23
|
+
| 'attach-no-context'
|
|
24
|
+
| 'no-live-server'
|
|
25
|
+
| 'session-already-active';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Base class for every identifiable `core` error. Branch on {@link code}.
|
|
29
|
+
*
|
|
30
|
+
* Use {@link isControllerError} to narrow an `unknown` caught value to this
|
|
31
|
+
* type across a package/bundle boundary (where `instanceof` can be unreliable);
|
|
32
|
+
* the `code` tag is the contract, not the class identity.
|
|
33
|
+
*/
|
|
34
|
+
export abstract class ControllerError extends Error {
|
|
35
|
+
/** Machine-readable discriminator; stable across versions. */
|
|
36
|
+
abstract readonly code: ControllerErrorCode;
|
|
37
|
+
/** Brand so {@link isControllerError} can narrow across bundle boundaries. */
|
|
38
|
+
readonly isControllerError = true as const;
|
|
39
|
+
|
|
40
|
+
constructor(message: string, options?: {cause?: unknown}) {
|
|
41
|
+
super(message, options);
|
|
42
|
+
// Preserve the concrete subclass name (Error's constructor sets it to
|
|
43
|
+
// `Error` under some transpile targets).
|
|
44
|
+
this.name = new.target.name;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The browser binary Playwright needs is not installed (e.g.
|
|
50
|
+
* `playwright install chromium` was never run). Surfaced so the CLI can tell
|
|
51
|
+
* the user the exact install command.
|
|
52
|
+
*/
|
|
53
|
+
export class MissingBrowserBinaryError extends ControllerError {
|
|
54
|
+
readonly code = 'missing-browser-binary';
|
|
55
|
+
/** The browser whose binary is missing (e.g. `chromium`). */
|
|
56
|
+
readonly browser: string;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
browser: string,
|
|
60
|
+
message: string = `The ${browser} browser binary is not installed.`,
|
|
61
|
+
options?: {cause?: unknown},
|
|
62
|
+
) {
|
|
63
|
+
super(message, options);
|
|
64
|
+
this.browser = browser;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The named profile has not been set up yet: its dedicated profile directory
|
|
70
|
+
* does not exist on disk. A profile is created by the headed `setup-profile`
|
|
71
|
+
* flow; `launch` against a not-yet-set-up profile raises this so the CLI can
|
|
72
|
+
* tell the user to run `setup-profile` first.
|
|
73
|
+
*/
|
|
74
|
+
export class MissingProfileError extends ControllerError {
|
|
75
|
+
readonly code = 'missing-profile';
|
|
76
|
+
/** The name of the profile that is not set up. */
|
|
77
|
+
readonly profile: string;
|
|
78
|
+
/** The dedicated profile directory that was expected to exist. */
|
|
79
|
+
readonly profileDir: string;
|
|
80
|
+
|
|
81
|
+
constructor(
|
|
82
|
+
profile: string,
|
|
83
|
+
profileDir: string,
|
|
84
|
+
message: string = `The "${profile}" profile is not set up (no profile directory at ${profileDir}).`,
|
|
85
|
+
options?: {cause?: unknown},
|
|
86
|
+
) {
|
|
87
|
+
super(message, options);
|
|
88
|
+
this.profile = profile;
|
|
89
|
+
this.profileDir = profileDir;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The `attach` transport connected to a browser that is NOT Chromium. CDP-attach
|
|
95
|
+
* (`connectOverCDP`) is Chromium-only (ADR-0002/0003: Firefox attaches via a
|
|
96
|
+
* different mechanism), so attaching to anything else cannot reuse the live
|
|
97
|
+
* context and is refused. Surfaced as a typed condition so the CLI can tell the
|
|
98
|
+
* user attach is Chromium-only WITHOUT the seam ever naming CDP/Chromium types.
|
|
99
|
+
*/
|
|
100
|
+
export class AttachNotChromiumError extends ControllerError {
|
|
101
|
+
readonly code = 'attach-not-chromium';
|
|
102
|
+
/** The browser engine actually reached at the endpoint (e.g. `firefox`). */
|
|
103
|
+
readonly browser: string;
|
|
104
|
+
|
|
105
|
+
constructor(
|
|
106
|
+
browser: string,
|
|
107
|
+
message: string = `attach is Chromium-only; the endpoint exposes a "${browser}" browser. Start Chromium/Chrome with --remote-debugging-port and attach to that.`,
|
|
108
|
+
options?: {cause?: unknown},
|
|
109
|
+
) {
|
|
110
|
+
super(message, options);
|
|
111
|
+
this.browser = browser;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The browser reached at the attach endpoint exposes no browser context to
|
|
117
|
+
* reuse. attach deliberately reuses the user's EXISTING authenticated context
|
|
118
|
+
* (`contexts()[0]`) and never opens a fresh one (ADR-0002), so a browser with
|
|
119
|
+
* zero contexts is a refusal, not a silent `newContext()`.
|
|
120
|
+
*/
|
|
121
|
+
export class AttachNoContextError extends ControllerError {
|
|
122
|
+
readonly code = 'attach-no-context';
|
|
123
|
+
/** The endpoint that exposed no reusable context. */
|
|
124
|
+
readonly endpoint: string;
|
|
125
|
+
|
|
126
|
+
constructor(
|
|
127
|
+
endpoint: string,
|
|
128
|
+
message: string = `attach found no existing browser context at ${endpoint} to reuse. Open a window/tab in the browser before attaching.`,
|
|
129
|
+
options?: {cause?: unknown},
|
|
130
|
+
) {
|
|
131
|
+
super(message, options);
|
|
132
|
+
this.endpoint = endpoint;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* No long-lived served session is running (no endpoint file under the config
|
|
138
|
+
* dir), so a thin-client verb has nothing to drive (ADR-0005). Surfaced so the
|
|
139
|
+
* CLI can tell the user to run `serve` first rather than auto-spawning a browser
|
|
140
|
+
* (lifecycle is EXPLICIT in v1). This is the cross-invocation analogue of
|
|
141
|
+
* {@link MissingProfileError}: a precondition the user resolves with one named
|
|
142
|
+
* command.
|
|
143
|
+
*/
|
|
144
|
+
export class NoLiveServerError extends ControllerError {
|
|
145
|
+
readonly code = 'no-live-server';
|
|
146
|
+
|
|
147
|
+
constructor(
|
|
148
|
+
message: string = 'No live webhands session server is running. Start one with `serve` first.',
|
|
149
|
+
options?: {cause?: unknown},
|
|
150
|
+
) {
|
|
151
|
+
super(message, options);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A second `serve`/`launch`/`attach` was requested while one session is already
|
|
157
|
+
* live. v1 holds EXACTLY ONE session (ADR-0005, single session); a concurrent
|
|
158
|
+
* open is a clear refusal, not a second browser. Surfaced so the CLI can tell
|
|
159
|
+
* the user to stop the active session first.
|
|
160
|
+
*/
|
|
161
|
+
export class SessionAlreadyActiveError extends ControllerError {
|
|
162
|
+
readonly code = 'session-already-active';
|
|
163
|
+
|
|
164
|
+
constructor(
|
|
165
|
+
message: string = 'A session is already active; stop it first (run `stop`).',
|
|
166
|
+
options?: {cause?: unknown},
|
|
167
|
+
) {
|
|
168
|
+
super(message, options);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Narrow an unknown caught value to a {@link ControllerError}. Prefer this over
|
|
174
|
+
* `instanceof` at package boundaries: it checks the {@link ControllerError.isControllerError}
|
|
175
|
+
* brand and a known {@link ControllerErrorCode}, so it survives duplicate
|
|
176
|
+
* copies of this module in different bundles.
|
|
177
|
+
*/
|
|
178
|
+
export function isControllerError(value: unknown): value is ControllerError {
|
|
179
|
+
return (
|
|
180
|
+
typeof value === 'object' &&
|
|
181
|
+
value !== null &&
|
|
182
|
+
(value as {isControllerError?: unknown}).isControllerError === true &&
|
|
183
|
+
typeof (value as {code?: unknown}).code === 'string'
|
|
184
|
+
);
|
|
185
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
Cookie,
|
|
3
|
+
Driver,
|
|
4
|
+
LocatorString,
|
|
5
|
+
OpenTarget,
|
|
6
|
+
Page,
|
|
7
|
+
Session,
|
|
8
|
+
Snapshot,
|
|
9
|
+
SnapshotOptions,
|
|
10
|
+
SnapshotView,
|
|
11
|
+
Transport,
|
|
12
|
+
WaitCondition,
|
|
13
|
+
} from './seam.js';
|
|
14
|
+
export {locator} from './seam.js';
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
serializeCookies,
|
|
18
|
+
deserializeCookies,
|
|
19
|
+
COOKIES_EXPORT_VERSION,
|
|
20
|
+
type CookiesExport,
|
|
21
|
+
} from './cookies-export.js';
|
|
22
|
+
|
|
23
|
+
export {StubTransport, type StubCall} from './stub-transport.js';
|
|
24
|
+
|
|
25
|
+
export {PlaywrightLaunchTransport} from './playwright-launch-transport.js';
|
|
26
|
+
|
|
27
|
+
export {PlaywrightAttachTransport} from './playwright-attach-transport.js';
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
setupProfile,
|
|
31
|
+
buildPrompt,
|
|
32
|
+
type PromptSink,
|
|
33
|
+
type SetupProfileOptions,
|
|
34
|
+
type SetupProfileResult,
|
|
35
|
+
} from './setup-profile.js';
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
ControllerError,
|
|
39
|
+
MissingBrowserBinaryError,
|
|
40
|
+
MissingProfileError,
|
|
41
|
+
AttachNotChromiumError,
|
|
42
|
+
AttachNoContextError,
|
|
43
|
+
NoLiveServerError,
|
|
44
|
+
SessionAlreadyActiveError,
|
|
45
|
+
isControllerError,
|
|
46
|
+
type ControllerErrorCode,
|
|
47
|
+
} from './errors.js';
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
resolveSessionEndpointPath,
|
|
51
|
+
writeSessionEndpoint,
|
|
52
|
+
readSessionEndpoint,
|
|
53
|
+
clearSessionEndpoint,
|
|
54
|
+
SESSION_ENDPOINT_FILENAME,
|
|
55
|
+
type SessionEndpoint,
|
|
56
|
+
} from './session-endpoint.js';
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
startSessionServer,
|
|
60
|
+
sessionAlreadyActive,
|
|
61
|
+
type SessionServerOptions,
|
|
62
|
+
type RunningSessionServer,
|
|
63
|
+
} from './session-server.js';
|
|
64
|
+
|
|
65
|
+
export {connectRemoteSession} from './remote-session.js';
|
|
66
|
+
|
|
67
|
+
export {
|
|
68
|
+
SESSION_RPC_PATH,
|
|
69
|
+
applySessionRpc,
|
|
70
|
+
makeRpcPage,
|
|
71
|
+
type SessionRpcRequest,
|
|
72
|
+
type SessionRpcResponse,
|
|
73
|
+
} from './session-rpc.js';
|
|
74
|
+
|
|
75
|
+
export {
|
|
76
|
+
resolveHomeRoot,
|
|
77
|
+
resolveProfileLocation,
|
|
78
|
+
CONTROLLER_HOME_ENV,
|
|
79
|
+
DEFAULT_HOME_DIRNAME,
|
|
80
|
+
PROFILES_DIRNAME,
|
|
81
|
+
type ProfileLocation,
|
|
82
|
+
type ProfileLocationOptions,
|
|
83
|
+
} from './profile-location.js';
|
|
84
|
+
|
|
85
|
+
export {
|
|
86
|
+
startFixtureServer,
|
|
87
|
+
type FixtureServer,
|
|
88
|
+
} from './test-fixtures/fixture-server.js';
|
|
89
|
+
export {FIXTURE_PAGES} from './test-fixtures/fixture-pages.js';
|