@quiesco/web 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +17 -2
- package/dist/expo-server.d.ts +17 -0
- package/dist/expo-server.d.ts.map +1 -0
- package/dist/expo-server.js +81 -0
- package/dist/expo-server.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/provider.d.ts +138 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +618 -0
- package/dist/provider.js.map +1 -0
- package/dist/static-server.d.ts +8 -0
- package/dist/static-server.d.ts.map +1 -0
- package/dist/static-server.js +79 -0
- package/dist/static-server.js.map +1 -0
- package/package.json +57 -3
- package/src/expo-server.ts +96 -0
- package/src/index.ts +4 -0
- package/src/provider.ts +633 -0
- package/src/static-server.ts +85 -0
package/dist/provider.js
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web DeviceProvider (requirements.md §6.9): the Playwright library (never @playwright/test).
|
|
3
|
+
* - boot: launch the browser; install: serve `distDir` (a static export with Quiesco's file server, a
|
|
4
|
+
* server export with `npx expo serve`) or check `url`;
|
|
5
|
+
* - launch: a fresh browser context per app launch (cookies and storage isolated; hard reset = new
|
|
6
|
+
* context), connection parameters injected with addInitScript before any page script runs
|
|
7
|
+
* (docs/spikes/05), then open the app;
|
|
8
|
+
* - injectInput: real mouse / touch / keyboard / wheel input at the coordinates the agent computed
|
|
9
|
+
* (CSS pixels, the same coordinate space as Playwright's mouse; docs/spikes/07).
|
|
10
|
+
* - Phase 6.5: launch arguments in `window.__QUIESCO__.args`; `relaunch({ url })` opens the URL's path
|
|
11
|
+
* (`myapp://todos/1` → `/todos/1`); a relaunch that keeps data carries cookies, localStorage and
|
|
12
|
+
* IndexedDB over to the new context (Playwright storageState). No background state: a page driven by
|
|
13
|
+
* Playwright stays visible whatever is tried (findings 6.5 spike), so sendToBackground is not offered.
|
|
14
|
+
* - Phase 6.7: the page's JS dialogs (alert / confirm / prompt) stay open until the test answers them
|
|
15
|
+
* through app.device.system.* (systemNodes / tapSystemNode); beforeunload is always accepted, or
|
|
16
|
+
* Quiesco's own reloads would hang (findings 6.7 web dialogs).
|
|
17
|
+
*/
|
|
18
|
+
import { LAUNCH_PARAM_PORT, LAUNCH_PARAM_SESSION, WEB_PARAMS_GLOBAL } from '@quiesco/protocol';
|
|
19
|
+
import { LogBuffer, QuiescoError, silentLogger } from '@quiesco/core';
|
|
20
|
+
import { chromium, firefox, webkit } from 'playwright';
|
|
21
|
+
/**
|
|
22
|
+
* Where a launch URL points on the web: paths and http(s) URLs as they are; an app scheme URL
|
|
23
|
+
* (`myapp://todos/1?x=1`) becomes its path (`/todos/1?x=1`), the route expo-router gives it natively.
|
|
24
|
+
*/
|
|
25
|
+
export function webPathFor(url) {
|
|
26
|
+
if (url.startsWith('/') || /^https?:\/\//i.test(url))
|
|
27
|
+
return url;
|
|
28
|
+
const m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)([^?#]*)(.*)$/i.exec(url);
|
|
29
|
+
if (m === null)
|
|
30
|
+
return `/${url.replace(/^\/+/, '')}`;
|
|
31
|
+
const [, host = '', rest = '', tail = ''] = m;
|
|
32
|
+
const pathPart = `/${[host, rest.replace(/^\/+/, '')].filter((x) => x !== '').join('/')}`;
|
|
33
|
+
return `${pathPart}${tail}`;
|
|
34
|
+
}
|
|
35
|
+
import { isServerExport, startExpoServer } from './expo-server.js';
|
|
36
|
+
import { startStaticServer } from './static-server.js';
|
|
37
|
+
const BROWSERS = { chromium, firefox, webkit };
|
|
38
|
+
const WEB_PERMISSIONS = {
|
|
39
|
+
camera: 'camera',
|
|
40
|
+
microphone: 'microphone',
|
|
41
|
+
location: 'geolocation',
|
|
42
|
+
notifications: 'notifications',
|
|
43
|
+
};
|
|
44
|
+
/** Ids of a JS dialog's parts in systemNodes(). */
|
|
45
|
+
export const DIALOG_MESSAGE = 'dialog.message';
|
|
46
|
+
export const DIALOG_ACCEPT = 'dialog.accept';
|
|
47
|
+
export const DIALOG_DISMISS = 'dialog.dismiss';
|
|
48
|
+
/** A JS dialog as system UI: its message, OK and (confirm / prompt) Cancel. No geometry (the browser draws it). */
|
|
49
|
+
export function dialogNodes(type, message) {
|
|
50
|
+
const none = { x1: 0, y1: 0, x2: 0, y2: 0 };
|
|
51
|
+
const nodes = [
|
|
52
|
+
{ text: message, id: DIALOG_MESSAGE, bounds: none },
|
|
53
|
+
{ text: 'OK', id: DIALOG_ACCEPT, bounds: none },
|
|
54
|
+
];
|
|
55
|
+
if (type !== 'alert')
|
|
56
|
+
nodes.push({ text: 'Cancel', id: DIALOG_DISMISS, bounds: none });
|
|
57
|
+
return nodes;
|
|
58
|
+
}
|
|
59
|
+
export class WebProvider {
|
|
60
|
+
options;
|
|
61
|
+
platform = 'web';
|
|
62
|
+
/**
|
|
63
|
+
* react-native-gesture-handler on the web: span slop 2 × 15 px before the scale detector starts, then
|
|
64
|
+
* 15 px more before the pinch activates (resetting its scale); measured about 46 px. Its rotation keeps
|
|
65
|
+
* the angle turned before activating (measured: 90° reported for 90° turned), so no rotation slop.
|
|
66
|
+
*/
|
|
67
|
+
gestureSlop = { pinch: 46, rotation: 0 };
|
|
68
|
+
browser = null;
|
|
69
|
+
context = null;
|
|
70
|
+
page = null;
|
|
71
|
+
server = null;
|
|
72
|
+
baseUrl = null;
|
|
73
|
+
permissions = [];
|
|
74
|
+
/** Cookies / storage of the last context, kept by terminate({ keepData: true }) for the next launch. */
|
|
75
|
+
kept = null;
|
|
76
|
+
/** Device state (Phase 6.6), applied to the open page and to every new context until reset. */
|
|
77
|
+
orientation = null;
|
|
78
|
+
geolocation = null;
|
|
79
|
+
colorScheme = null;
|
|
80
|
+
locale = null;
|
|
81
|
+
/** setNetwork('offline'): requests aborted, and (Chromium) `navigator.onLine` false; see applyNetwork. */
|
|
82
|
+
offline = false;
|
|
83
|
+
networkCdp = null;
|
|
84
|
+
abortRoute = (route) => route.abort('internetdisconnected');
|
|
85
|
+
/** Permissions granted at runtime beyond / revoked from the configured ones. */
|
|
86
|
+
permissionsAtStart = null;
|
|
87
|
+
pageErrors = [];
|
|
88
|
+
/** Phase 6.9 artifacts: the running screencast (segments) and the console log. */
|
|
89
|
+
recording = null;
|
|
90
|
+
logsOn = false;
|
|
91
|
+
logs = new LogBuffer();
|
|
92
|
+
/** The JS dialog the page is blocked on (Phase 6.7). */
|
|
93
|
+
dialog = null;
|
|
94
|
+
dialogListeners = new Set();
|
|
95
|
+
logger;
|
|
96
|
+
constructor(options) {
|
|
97
|
+
this.options = options;
|
|
98
|
+
this.logger = options.logger ?? silentLogger;
|
|
99
|
+
if (options.app.distDir === undefined && options.app.url === undefined) {
|
|
100
|
+
throw new QuiescoError('CONFIG_ERROR', 'apps.web needs distDir (static export) or url (dev server)');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
get device() {
|
|
104
|
+
return this.options.device ?? {};
|
|
105
|
+
}
|
|
106
|
+
async boot() {
|
|
107
|
+
const name = this.device.browser ?? 'chromium';
|
|
108
|
+
this.browser = await BROWSERS[name].launch({ headless: this.device.headless ?? true }).catch((e) => {
|
|
109
|
+
throw new QuiescoError('DEVICE_ERROR', `Cannot launch ${name}: ${e.message}. Install it with \`npx playwright install ${name}\`.`);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async install() {
|
|
113
|
+
const { distDir, url } = this.options.app;
|
|
114
|
+
if (distDir !== undefined) {
|
|
115
|
+
const server = isServerExport(distDir);
|
|
116
|
+
if (server)
|
|
117
|
+
this.logger.info(`${distDir} is a server export (web.output: "server"); starting \`npx expo serve\``);
|
|
118
|
+
this.server = await (server ? startExpoServer(distDir) : startStaticServer(distDir)).catch((e) => {
|
|
119
|
+
throw new QuiescoError('CONFIG_ERROR', e.message);
|
|
120
|
+
});
|
|
121
|
+
this.baseUrl = this.server.url;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const target = url;
|
|
125
|
+
const res = await fetch(target).catch((e) => {
|
|
126
|
+
throw new QuiescoError('DEVICE_ERROR', `${target} is not reachable (${e.message}); is the dev server running with EXPO_PUBLIC_QUIESCO=1?`);
|
|
127
|
+
});
|
|
128
|
+
if (!res.ok)
|
|
129
|
+
throw new QuiescoError('DEVICE_ERROR', `${target} answered ${res.status}`);
|
|
130
|
+
this.baseUrl = target;
|
|
131
|
+
}
|
|
132
|
+
async launch(params) {
|
|
133
|
+
if (this.browser === null || this.baseUrl === null)
|
|
134
|
+
throw new QuiescoError('DEVICE_ERROR', 'web provider not booted / installed');
|
|
135
|
+
if (this.context !== null)
|
|
136
|
+
await this.terminate({ keepData: true });
|
|
137
|
+
const kept = this.kept;
|
|
138
|
+
this.kept = null;
|
|
139
|
+
const vp = this.device.viewport;
|
|
140
|
+
const size = this.viewportSize();
|
|
141
|
+
const context = await this.browser.newContext({
|
|
142
|
+
...(vp !== undefined
|
|
143
|
+
? { viewport: size ?? { width: vp.width, height: vp.height }, deviceScaleFactor: vp.deviceScaleFactor ?? 1, hasTouch: vp.hasTouch ?? false, isMobile: vp.isMobile ?? false }
|
|
144
|
+
: {}),
|
|
145
|
+
permissions: this.permissions,
|
|
146
|
+
...(kept !== null ? { storageState: kept } : {}),
|
|
147
|
+
...(this.geolocation !== null ? { geolocation: this.geolocation } : {}),
|
|
148
|
+
...(this.colorScheme !== null ? { colorScheme: this.colorScheme } : {}),
|
|
149
|
+
...(this.locale !== null ? { locale: this.locale } : {}),
|
|
150
|
+
});
|
|
151
|
+
await context.addInitScript(({ key, port, session, args }) => {
|
|
152
|
+
globalThis[key] = args === null ? { port, session } : { port, session, args };
|
|
153
|
+
}, { key: WEB_PARAMS_GLOBAL, port: params.port, session: params.sessionId, args: (params.argsObject ?? null) });
|
|
154
|
+
const page = await context.newPage();
|
|
155
|
+
page.on('pageerror', (e) => {
|
|
156
|
+
this.pageErrors.push(e.message);
|
|
157
|
+
if (this.pageErrors.length > 20)
|
|
158
|
+
this.pageErrors.shift();
|
|
159
|
+
if (this.logsOn)
|
|
160
|
+
this.logs.push(`pageerror: ${e.stack ?? e.message}`);
|
|
161
|
+
});
|
|
162
|
+
page.on('console', (m) => {
|
|
163
|
+
if (this.logsOn)
|
|
164
|
+
this.logs.push(`${m.type()}: ${m.text()}`);
|
|
165
|
+
});
|
|
166
|
+
page.on('dialog', (d) => this.onDialog(page, d));
|
|
167
|
+
this.context = context;
|
|
168
|
+
this.page = page;
|
|
169
|
+
// A relaunch in the middle of a recorded scenario: the video goes on in a new segment.
|
|
170
|
+
if (this.recording !== null)
|
|
171
|
+
await this.recordSegment(page);
|
|
172
|
+
const target = params.url !== undefined ? webPathFor(params.url) : (this.options.app.path ?? '/');
|
|
173
|
+
await page.goto(new URL(target, this.baseUrl).href);
|
|
174
|
+
// After loading: an installed app starts offline too, a web page could not.
|
|
175
|
+
if (this.offline)
|
|
176
|
+
await this.applyNetwork();
|
|
177
|
+
if (params.url !== undefined)
|
|
178
|
+
return 'delivered';
|
|
179
|
+
}
|
|
180
|
+
async terminate(opts = {}) {
|
|
181
|
+
this.networkCdp = null; // detached with its page
|
|
182
|
+
this.dialog = null; // closed with its page
|
|
183
|
+
// The page's screencast ends with it: finish the segment first.
|
|
184
|
+
if (this.recording !== null && this.page !== null)
|
|
185
|
+
await this.page.screencast.stop().catch(() => undefined);
|
|
186
|
+
const ctx = this.context;
|
|
187
|
+
this.context = null;
|
|
188
|
+
this.page = null;
|
|
189
|
+
if (ctx === null) {
|
|
190
|
+
if (opts.keepData !== true)
|
|
191
|
+
this.kept = null;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
this.kept = opts.keepData === true ? await ctx.storageState({ indexedDB: true }).catch(() => null) : null;
|
|
195
|
+
await ctx.close();
|
|
196
|
+
}
|
|
197
|
+
async grantPermissions(perms) {
|
|
198
|
+
this.permissions = [];
|
|
199
|
+
for (const p of perms) {
|
|
200
|
+
const name = WEB_PERMISSIONS[p];
|
|
201
|
+
if (name === undefined)
|
|
202
|
+
this.logger.warn(`permission "${p}" is not supported on the web; skipped`);
|
|
203
|
+
else
|
|
204
|
+
this.permissions.push(name);
|
|
205
|
+
}
|
|
206
|
+
await this.context?.grantPermissions(this.permissions);
|
|
207
|
+
}
|
|
208
|
+
async screenshot() {
|
|
209
|
+
// The page does not paint while a JS dialog is open: Playwright's screenshot would wait for it.
|
|
210
|
+
const d = this.dialog;
|
|
211
|
+
if (d !== null)
|
|
212
|
+
throw new QuiescoError('DIALOG_OPEN', `no screenshot while the page shows a ${d.type()} dialog (${JSON.stringify(d.message())})`, { message: d.message() });
|
|
213
|
+
return this.requirePage().screenshot();
|
|
214
|
+
}
|
|
215
|
+
async injectInput(input) {
|
|
216
|
+
const page = this.requirePage();
|
|
217
|
+
switch (input.kind) {
|
|
218
|
+
case 'tap': {
|
|
219
|
+
const count = input.count ?? 1;
|
|
220
|
+
const touch = this.device.viewport?.hasTouch === true;
|
|
221
|
+
if (touch && count === 1 && input.holdMs === undefined) {
|
|
222
|
+
await page.touchscreen.tap(input.x, input.y);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
await page.mouse.move(input.x, input.y);
|
|
226
|
+
for (let i = 0; i < count; i++) {
|
|
227
|
+
await page.mouse.down({ clickCount: i + 1 });
|
|
228
|
+
if (input.holdMs !== undefined)
|
|
229
|
+
await new Promise((r) => setTimeout(r, input.holdMs));
|
|
230
|
+
await page.mouse.up({ clickCount: i + 1 });
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
case 'type':
|
|
235
|
+
await page.mouse.click(input.x, input.y);
|
|
236
|
+
if (input.clearFirst === true) {
|
|
237
|
+
await page.keyboard.press('ControlOrMeta+A');
|
|
238
|
+
await page.keyboard.press('Backspace');
|
|
239
|
+
}
|
|
240
|
+
if (input.text !== '')
|
|
241
|
+
await page.keyboard.type(input.text);
|
|
242
|
+
return;
|
|
243
|
+
case 'drag': {
|
|
244
|
+
const steps = Math.max(4, Math.round(input.durationMs / 16));
|
|
245
|
+
await page.mouse.move(input.from.x, input.from.y);
|
|
246
|
+
await page.mouse.down();
|
|
247
|
+
// Press-and-hold first (long-press-activated drags), then move in small steps.
|
|
248
|
+
if (input.holdMs !== undefined && input.holdMs > 0)
|
|
249
|
+
await new Promise((r) => setTimeout(r, input.holdMs));
|
|
250
|
+
await page.mouse.move(input.to.x, input.to.y, { steps });
|
|
251
|
+
await page.mouse.up();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
case 'wheel':
|
|
255
|
+
await page.mouse.move(input.x, input.y);
|
|
256
|
+
await page.mouse.wheel(input.dx, input.dy);
|
|
257
|
+
return;
|
|
258
|
+
case 'key':
|
|
259
|
+
await page.keyboard.press(input.key);
|
|
260
|
+
return;
|
|
261
|
+
case 'touches':
|
|
262
|
+
await this.playTouches(page, input.fingers, input.frames);
|
|
263
|
+
return;
|
|
264
|
+
case 'select':
|
|
265
|
+
case 'fill': {
|
|
266
|
+
// The form control at the point (the agent checked that nothing covers it).
|
|
267
|
+
const handle = await page.evaluateHandle(({ x, y }) => document.elementFromPoint(x, y), { x: input.x, y: input.y });
|
|
268
|
+
const el = handle.asElement();
|
|
269
|
+
try {
|
|
270
|
+
const tag = el === null ? null : await el.evaluate((e) => e.tagName);
|
|
271
|
+
const fits = input.kind === 'select' ? tag === 'SELECT' : tag === 'INPUT' || tag === 'TEXTAREA';
|
|
272
|
+
// Otherwise Playwright would wait for the element to become editable until its own timeout.
|
|
273
|
+
if (el === null || !fits)
|
|
274
|
+
throw new QuiescoError('DEVICE_ERROR', `no ${input.kind === 'select' ? '<select>' : 'form field'} at (${Math.round(input.x)}, ${Math.round(input.y)}) (found ${tag ?? 'nothing'})`);
|
|
275
|
+
if (input.kind === 'select')
|
|
276
|
+
await el.selectOption(input.value);
|
|
277
|
+
else
|
|
278
|
+
await el.fill(input.value);
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
await handle.dispose();
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
case 'navigate':
|
|
286
|
+
await page.goto(new URL(input.url, this.baseUrl ?? undefined).href);
|
|
287
|
+
return;
|
|
288
|
+
case 'back':
|
|
289
|
+
await page.goBack();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// ---- Phase 6.6 device state ----
|
|
294
|
+
/** The configured viewport, turned to `this.orientation`; null without a configured viewport. */
|
|
295
|
+
viewportSize() {
|
|
296
|
+
const vp = this.device.viewport;
|
|
297
|
+
if (vp === undefined)
|
|
298
|
+
return null;
|
|
299
|
+
const long = Math.max(vp.width, vp.height);
|
|
300
|
+
const short = Math.min(vp.width, vp.height);
|
|
301
|
+
if (this.orientation === 'landscape')
|
|
302
|
+
return { width: long, height: short };
|
|
303
|
+
if (this.orientation === 'portrait')
|
|
304
|
+
return { width: short, height: long };
|
|
305
|
+
return { width: vp.width, height: vp.height };
|
|
306
|
+
}
|
|
307
|
+
/** Swaps the viewport's sides (a `resize` for the page; `screen.orientation` follows). */
|
|
308
|
+
async setOrientation(orientation) {
|
|
309
|
+
if (this.device.viewport === undefined) {
|
|
310
|
+
throw new QuiescoError('ACTION_NOT_SUPPORTED', 'setOrientation on the web needs a configured viewport (devices.web.viewport)', { action: 'setOrientation', platform: 'web' });
|
|
311
|
+
}
|
|
312
|
+
this.orientation = orientation;
|
|
313
|
+
await this.requirePage().setViewportSize(this.viewportSize());
|
|
314
|
+
}
|
|
315
|
+
async setLocation(location) {
|
|
316
|
+
this.geolocation = { ...location };
|
|
317
|
+
await this.context?.setGeolocation(this.geolocation);
|
|
318
|
+
}
|
|
319
|
+
async setPermission(permission, grant) {
|
|
320
|
+
const name = WEB_PERMISSIONS[permission];
|
|
321
|
+
if (name === undefined) {
|
|
322
|
+
throw new QuiescoError('ACTION_NOT_SUPPORTED', `the "${permission}" permission does not exist on the web`, { action: 'setPermission', platform: 'web' });
|
|
323
|
+
}
|
|
324
|
+
if (this.permissionsAtStart === null)
|
|
325
|
+
this.permissionsAtStart = [...this.permissions];
|
|
326
|
+
this.permissions = grant ? [...new Set([...this.permissions, name])] : this.permissions.filter((p) => p !== name);
|
|
327
|
+
// Playwright cannot revoke a single permission: clear all, grant the rest again.
|
|
328
|
+
await this.context?.clearPermissions();
|
|
329
|
+
if (this.permissions.length > 0)
|
|
330
|
+
await this.context?.grantPermissions(this.permissions);
|
|
331
|
+
return { relaunch: false };
|
|
332
|
+
}
|
|
333
|
+
/** Not granted is the browser's "prompt" state: a page's next request asks again. */
|
|
334
|
+
resetPermission(permission) {
|
|
335
|
+
return this.setPermission(permission, false);
|
|
336
|
+
}
|
|
337
|
+
async setAppearance(scheme) {
|
|
338
|
+
this.colorScheme = scheme;
|
|
339
|
+
await this.requirePage().emulateMedia({ colorScheme: scheme });
|
|
340
|
+
}
|
|
341
|
+
/** A browser context's locale is fixed when it is created: the app is opened again in a new one (data kept). */
|
|
342
|
+
async setLocale(locale) {
|
|
343
|
+
const changed = this.locale !== locale;
|
|
344
|
+
this.locale = locale;
|
|
345
|
+
return { relaunch: changed };
|
|
346
|
+
}
|
|
347
|
+
/** A browser page has no status bar: nothing to override. */
|
|
348
|
+
async setStatusBar(_opts) { }
|
|
349
|
+
async resetStatusBar() { }
|
|
350
|
+
/**
|
|
351
|
+
* Offline: every request of the page is aborted (`net::ERR_INTERNET_DISCONNECTED`) and, on Chromium,
|
|
352
|
+
* `navigator.onLine` turns false with real `offline` and `navigator.connection` `change` events (CDP
|
|
353
|
+
* `Network.overrideNetworkState`).
|
|
354
|
+
* Not `context.setOffline()`: it also stops Quiesco's own WebSocket (messages no longer arrive; checked
|
|
355
|
+
* in Phase 6.6). Request routes do not see WebSockets, so the connection keeps working; the agent fails
|
|
356
|
+
* the app's new WebSockets.
|
|
357
|
+
*/
|
|
358
|
+
async setNetwork(offline) {
|
|
359
|
+
if (this.offline === offline)
|
|
360
|
+
return;
|
|
361
|
+
this.offline = offline;
|
|
362
|
+
if (this.page !== null)
|
|
363
|
+
await this.applyNetwork();
|
|
364
|
+
}
|
|
365
|
+
async applyNetwork() {
|
|
366
|
+
const page = this.requirePage();
|
|
367
|
+
const ctx = page.context();
|
|
368
|
+
if (this.offline)
|
|
369
|
+
await ctx.route('**/*', this.abortRoute);
|
|
370
|
+
else
|
|
371
|
+
await ctx.unroute('**/*', this.abortRoute);
|
|
372
|
+
if ((this.device.browser ?? 'chromium') !== 'chromium') {
|
|
373
|
+
if (this.offline)
|
|
374
|
+
this.logger.warn(`setNetwork('offline'): ${this.device.browser ?? ''} cannot report navigator.onLine === false (Chromium only); requests fail all the same`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// The override lasts while its CDP session is attached: keep it until online.
|
|
378
|
+
this.networkCdp ??= await ctx.newCDPSession(page);
|
|
379
|
+
// Throughput 0 while offline also changes navigator.connection (downlink 0), so `change` fires there
|
|
380
|
+
// too: NetInfo listens to that event instead of `offline` where the API exists (Chromium).
|
|
381
|
+
const throughput = this.offline ? 0 : -1;
|
|
382
|
+
await this.networkCdp.send('Network.overrideNetworkState', { offline: this.offline, latency: 0, downloadThroughput: throughput, uploadThroughput: throughput });
|
|
383
|
+
if (!this.offline) {
|
|
384
|
+
await this.networkCdp.detach().catch(() => undefined);
|
|
385
|
+
this.networkCdp = null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/** `navigator.clipboard` with the clipboard permissions granted to the page's origin (Chromium; others may refuse). */
|
|
389
|
+
async setClipboard(text) {
|
|
390
|
+
const page = await this.clipboardPage();
|
|
391
|
+
await page.evaluate((t) => navigator.clipboard.writeText(t), text);
|
|
392
|
+
}
|
|
393
|
+
async getClipboard() {
|
|
394
|
+
const page = await this.clipboardPage();
|
|
395
|
+
return page.evaluate(() => navigator.clipboard.readText());
|
|
396
|
+
}
|
|
397
|
+
async clipboardPage() {
|
|
398
|
+
const page = this.requirePage();
|
|
399
|
+
// Again each time: setPermission() clears all permissions.
|
|
400
|
+
await page
|
|
401
|
+
.context()
|
|
402
|
+
.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
|
|
403
|
+
.catch((e) => this.logger.debug(`clipboard permissions: ${e.message}`));
|
|
404
|
+
return page;
|
|
405
|
+
}
|
|
406
|
+
// ---- Phase 6.9 artifacts ----
|
|
407
|
+
/** Playwright's per-page screencast (WebM); a relaunch continues in a new segment. */
|
|
408
|
+
async startVideo(basePath) {
|
|
409
|
+
await this.stopVideo();
|
|
410
|
+
this.recording = { base: basePath, files: [] };
|
|
411
|
+
await this.recordSegment(this.requirePage());
|
|
412
|
+
}
|
|
413
|
+
async recordSegment(page) {
|
|
414
|
+
const rec = this.recording;
|
|
415
|
+
if (rec === null)
|
|
416
|
+
return;
|
|
417
|
+
const file = rec.files.length === 0 ? `${rec.base}.webm` : `${rec.base}-${rec.files.length + 1}.webm`;
|
|
418
|
+
rec.files.push(file);
|
|
419
|
+
await page.screencast.start({ path: file });
|
|
420
|
+
}
|
|
421
|
+
async stopVideo() {
|
|
422
|
+
const rec = this.recording;
|
|
423
|
+
this.recording = null;
|
|
424
|
+
if (rec === null)
|
|
425
|
+
return [];
|
|
426
|
+
await this.page?.screencast.stop().catch(() => undefined);
|
|
427
|
+
return rec.files;
|
|
428
|
+
}
|
|
429
|
+
/** The page's console messages and uncaught errors, from now on. */
|
|
430
|
+
async startDeviceLogs() {
|
|
431
|
+
this.logsOn = true;
|
|
432
|
+
}
|
|
433
|
+
deviceLogs(since) {
|
|
434
|
+
return this.logs.since(since);
|
|
435
|
+
}
|
|
436
|
+
// ---- Phase 6.8 WebView (an <iframe> on the web) ----
|
|
437
|
+
/**
|
|
438
|
+
* Evaluate `script` in the iframe the agent measured at `rect` (page coordinates): Playwright's frames
|
|
439
|
+
* reach cross-origin iframes too. The iframe is picked by position (the agent cannot name DOM nodes).
|
|
440
|
+
*/
|
|
441
|
+
async webviewEval(rect, script) {
|
|
442
|
+
const page = this.requirePage();
|
|
443
|
+
// The one iframe, or the one whose center is nearest (the agent's layout rect can lag a transition).
|
|
444
|
+
const handles = await page.$$('iframe');
|
|
445
|
+
let best = null;
|
|
446
|
+
for (const handle of handles) {
|
|
447
|
+
const box = await handle.boundingBox();
|
|
448
|
+
if (box === null && handles.length > 1)
|
|
449
|
+
continue;
|
|
450
|
+
const d = box === null ? 0 : Math.hypot(box.x + box.width / 2 - (rect.x + rect.width / 2), box.y + box.height / 2 - (rect.y + rect.height / 2));
|
|
451
|
+
if (best === null || d < best.d)
|
|
452
|
+
best = { frame: await handle.contentFrame(), d };
|
|
453
|
+
}
|
|
454
|
+
if (best === null || best.frame === null) {
|
|
455
|
+
throw new QuiescoError('ELEMENT_NOT_FOUND', `no <iframe> at (${Math.round(rect.x)}, ${Math.round(rect.y)}) ${Math.round(rect.width)}x${Math.round(rect.height)}`, {});
|
|
456
|
+
}
|
|
457
|
+
const value = await best.frame.evaluate(script);
|
|
458
|
+
return typeof value === 'string' ? value : JSON.stringify(value ?? null);
|
|
459
|
+
}
|
|
460
|
+
// ---- Phase 6.7 JS dialogs ----
|
|
461
|
+
onDialog(page, d) {
|
|
462
|
+
if (page !== this.page) {
|
|
463
|
+
void d.dismiss().catch(() => undefined);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
// Leaving the page (a reload or relaunch by Quiesco, the app's own navigation): always allowed.
|
|
467
|
+
if (d.type() === 'beforeunload') {
|
|
468
|
+
void d.accept().catch(() => undefined);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
this.dialog = d;
|
|
472
|
+
this.logger.debug(`page opened a dialog (${d.type()}): ${JSON.stringify(d.message())}`);
|
|
473
|
+
for (const l of [...this.dialogListeners])
|
|
474
|
+
l(d.message());
|
|
475
|
+
}
|
|
476
|
+
blockingDialog() {
|
|
477
|
+
return this.dialog?.message() ?? null;
|
|
478
|
+
}
|
|
479
|
+
onBlockingDialog(listener) {
|
|
480
|
+
this.dialogListeners.add(listener);
|
|
481
|
+
return () => this.dialogListeners.delete(listener);
|
|
482
|
+
}
|
|
483
|
+
async systemNodes() {
|
|
484
|
+
const d = this.dialog;
|
|
485
|
+
return d === null ? [] : dialogNodes(d.type(), d.message());
|
|
486
|
+
}
|
|
487
|
+
/** OK accepts (a prompt with its default value), Cancel dismisses; the page goes on at once. */
|
|
488
|
+
async tapSystemNode(node) {
|
|
489
|
+
const d = this.dialog;
|
|
490
|
+
if (d === null)
|
|
491
|
+
throw new QuiescoError('DEVICE_ERROR', 'the dialog is already closed');
|
|
492
|
+
if (node.id !== DIALOG_ACCEPT && node.id !== DIALOG_DISMISS) {
|
|
493
|
+
throw new QuiescoError('ACTION_NOT_SUPPORTED', `${JSON.stringify(node.text)} is the dialog's message, not a button: tap { text: 'OK' } or { text: 'Cancel' }`, { action: 'system.tap', platform: 'web' });
|
|
494
|
+
}
|
|
495
|
+
this.dialog = null;
|
|
496
|
+
// Without text Playwright submits an empty prompt (checked): give it the page's default value.
|
|
497
|
+
if (node.id === DIALOG_ACCEPT)
|
|
498
|
+
await d.accept(d.type() === 'prompt' ? d.defaultValue() : undefined);
|
|
499
|
+
else
|
|
500
|
+
await d.dismiss();
|
|
501
|
+
}
|
|
502
|
+
async resetDeviceState() {
|
|
503
|
+
const left = this.dialog;
|
|
504
|
+
if (left !== null) {
|
|
505
|
+
this.dialog = null;
|
|
506
|
+
this.logger.warn(`the page still showed a ${left.type()} dialog (${JSON.stringify(left.message())}); dismissed it`);
|
|
507
|
+
await left.dismiss().catch(() => undefined);
|
|
508
|
+
}
|
|
509
|
+
if (this.offline)
|
|
510
|
+
await this.setNetwork(false);
|
|
511
|
+
const page = this.page;
|
|
512
|
+
if (this.orientation !== null) {
|
|
513
|
+
this.orientation = null;
|
|
514
|
+
const vp = this.device.viewport;
|
|
515
|
+
if (page !== null && vp !== undefined)
|
|
516
|
+
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
517
|
+
}
|
|
518
|
+
if (this.geolocation !== null) {
|
|
519
|
+
this.geolocation = null;
|
|
520
|
+
await this.context?.setGeolocation(null);
|
|
521
|
+
}
|
|
522
|
+
if (this.colorScheme !== null) {
|
|
523
|
+
this.colorScheme = null;
|
|
524
|
+
await page?.emulateMedia({ colorScheme: null });
|
|
525
|
+
}
|
|
526
|
+
if (this.permissionsAtStart !== null) {
|
|
527
|
+
this.permissions = this.permissionsAtStart;
|
|
528
|
+
this.permissionsAtStart = null;
|
|
529
|
+
}
|
|
530
|
+
const relaunch = this.locale !== null;
|
|
531
|
+
this.locale = null;
|
|
532
|
+
return { relaunch };
|
|
533
|
+
}
|
|
534
|
+
async shutdown() {
|
|
535
|
+
await this.stopVideo().catch(() => undefined);
|
|
536
|
+
await this.terminate().catch(() => undefined);
|
|
537
|
+
await this.browser?.close().catch(() => undefined);
|
|
538
|
+
this.browser = null;
|
|
539
|
+
await this.server?.close();
|
|
540
|
+
this.server = null;
|
|
541
|
+
}
|
|
542
|
+
async diagnose(params) {
|
|
543
|
+
const lines = [`Page: ${this.page?.url() ?? '(none)'}; Quiesco params injected as window.${WEB_PARAMS_GLOBAL} = { port: ${params.port}, session }.`];
|
|
544
|
+
lines.push(`The bundle must be an e2e build (EXPO_PUBLIC_QUIESCO=1) so it reads ${LAUNCH_PARAM_PORT} / ${LAUNCH_PARAM_SESSION} from there.`);
|
|
545
|
+
if (this.pageErrors.length > 0)
|
|
546
|
+
lines.push(`Page errors: ${this.pageErrors.slice(-3).join(' | ')}`);
|
|
547
|
+
return lines;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Multi-finger touches through CDP `Input.dispatchTouchEvent` (Playwright has no multi-touch API):
|
|
551
|
+
* per frame the fingers that went down (touchStart), moved (touchMove) and lifted (touchEnd), each
|
|
552
|
+
* event listing the fingers touching after it, as CDP expects. Touch viewports only: a page on a
|
|
553
|
+
* viewport without touch does not expect touch events (HU 2026-11-09).
|
|
554
|
+
*/
|
|
555
|
+
async playTouches(page, fingers, frames) {
|
|
556
|
+
if (this.device.viewport?.hasTouch !== true) {
|
|
557
|
+
throw new QuiescoError('ACTION_NOT_SUPPORTED', 'multi-finger gestures (pinch, rotate, multiTouch) need a touch viewport on the web', {
|
|
558
|
+
action: 'multiTouch',
|
|
559
|
+
platform: 'web',
|
|
560
|
+
hint: 'run the scenario with a viewport that has hasTouch: true (phone / tablet), or tag it for touch platforms only',
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
const cdp = await page.context().newCDPSession(page);
|
|
564
|
+
const stride = 1 + 3 * fingers;
|
|
565
|
+
const down = new Map();
|
|
566
|
+
const send = (type) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: [...down].map(([id, p]) => ({ id, x: p.x, y: p.y, radiusX: 5, radiusY: 5, force: 1 })) });
|
|
567
|
+
const start = Date.now();
|
|
568
|
+
try {
|
|
569
|
+
for (let k = 0; k + stride <= frames.length; k += stride) {
|
|
570
|
+
const wait = start + frames[k] - Date.now();
|
|
571
|
+
if (wait > 0)
|
|
572
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
573
|
+
const now = new Map();
|
|
574
|
+
for (let i = 0; i < fingers; i++) {
|
|
575
|
+
const o = k + 1 + 3 * i;
|
|
576
|
+
if (frames[o] > 0.5)
|
|
577
|
+
now.set(i, { x: frames[o + 1], y: frames[o + 2] });
|
|
578
|
+
}
|
|
579
|
+
const started = [...now.keys()].filter((i) => !down.has(i));
|
|
580
|
+
const lifted = [...down.keys()].filter((i) => !now.has(i));
|
|
581
|
+
const moved = [...now].some(([i, p]) => down.has(i) && (down.get(i)?.x !== p.x || down.get(i)?.y !== p.y));
|
|
582
|
+
if (started.length > 0) {
|
|
583
|
+
for (const i of started)
|
|
584
|
+
down.set(i, now.get(i));
|
|
585
|
+
await send('touchStart');
|
|
586
|
+
}
|
|
587
|
+
if (moved) {
|
|
588
|
+
for (const [i, p] of now)
|
|
589
|
+
if (down.has(i))
|
|
590
|
+
down.set(i, p);
|
|
591
|
+
await send('touchMove');
|
|
592
|
+
}
|
|
593
|
+
if (lifted.length > 0) {
|
|
594
|
+
for (const i of lifted)
|
|
595
|
+
down.delete(i);
|
|
596
|
+
await send('touchEnd');
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (down.size > 0) {
|
|
600
|
+
down.clear();
|
|
601
|
+
await send('touchEnd');
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
finally {
|
|
605
|
+
await cdp.detach().catch(() => undefined);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
requirePage() {
|
|
609
|
+
if (this.page === null)
|
|
610
|
+
throw new QuiescoError('DEVICE_ERROR', 'no page open (launch() first)');
|
|
611
|
+
return this.page;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
/** Entry used by @quiesco/core's createProvider('web') (loaded at runtime by name). */
|
|
615
|
+
export function createWebProvider(options) {
|
|
616
|
+
return new WebProvider(options);
|
|
617
|
+
}
|
|
618
|
+
//# sourceMappingURL=provider.js.map
|