@push.rocks/smartpuppeteer 2.0.7 → 2.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_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/smartpuppeteer.classes.livebrowsersession.d.ts +97 -0
- package/dist_ts/smartpuppeteer.classes.livebrowsersession.js +1906 -0
- package/dist_ts/smartpuppeteer.classes.smartpuppeteer.d.ts +1 -0
- package/dist_ts/smartpuppeteer.classes.smartpuppeteer.js +19 -7
- package/dist_ts/smartpuppeteer.interfaces.livebrowser.d.ts +191 -0
- package/dist_ts/smartpuppeteer.interfaces.livebrowser.js +2 -0
- package/dist_ts/smartpuppeteer.plugins.d.ts +3 -2
- package/dist_ts/smartpuppeteer.plugins.js +4 -3
- package/package.json +8 -6
- package/readme.hints.md +15 -1
- package/readme.md +92 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +2 -0
- package/ts/smartpuppeteer.classes.livebrowsersession.ts +2298 -0
- package/ts/smartpuppeteer.classes.smartpuppeteer.ts +19 -6
- package/ts/smartpuppeteer.interfaces.livebrowser.ts +235 -0
- package/ts/smartpuppeteer.plugins.ts +3 -2
- package/dist/index.d.ts +0 -3
- package/dist/index.js +0 -18
|
@@ -0,0 +1,2298 @@
|
|
|
1
|
+
import { getEnvAwareBrowserInstance } from './smartpuppeteer.classes.smartpuppeteer.js';
|
|
2
|
+
import type {
|
|
3
|
+
ILiveBrowserClickOptions,
|
|
4
|
+
ILiveBrowserCreateTabOptions,
|
|
5
|
+
ILiveBrowserError,
|
|
6
|
+
ILiveBrowserFillOptions,
|
|
7
|
+
ILiveBrowserFrame,
|
|
8
|
+
ILiveBrowserFrameAcknowledgement,
|
|
9
|
+
ILiveBrowserFrameAcknowledgementRequest,
|
|
10
|
+
ILiveBrowserInsertTextInput,
|
|
11
|
+
ILiveBrowserKeyInput,
|
|
12
|
+
ILiveBrowserModifierState,
|
|
13
|
+
ILiveBrowserMouseInput,
|
|
14
|
+
ILiveBrowserNavigateOptions,
|
|
15
|
+
ILiveBrowserNavigationOptions,
|
|
16
|
+
ILiveBrowserObservation,
|
|
17
|
+
ILiveBrowserObserveOptions,
|
|
18
|
+
ILiveBrowserPressOptions,
|
|
19
|
+
ILiveBrowserSessionOptions,
|
|
20
|
+
ILiveBrowserSnapshot,
|
|
21
|
+
ILiveBrowserSnapshotOptions,
|
|
22
|
+
ILiveBrowserState,
|
|
23
|
+
ILiveBrowserTabState,
|
|
24
|
+
ILiveBrowserViewport,
|
|
25
|
+
ILiveBrowserWheelInput,
|
|
26
|
+
TLiveBrowserEvent,
|
|
27
|
+
TLiveBrowserEventListener,
|
|
28
|
+
TLiveBrowserImageFormat,
|
|
29
|
+
TLiveBrowserWaitUntil,
|
|
30
|
+
} from './smartpuppeteer.interfaces.livebrowser.js';
|
|
31
|
+
import * as plugins from './smartpuppeteer.plugins.js';
|
|
32
|
+
|
|
33
|
+
const defaultViewport: ILiveBrowserViewport = {
|
|
34
|
+
width: 800,
|
|
35
|
+
height: 600,
|
|
36
|
+
deviceScaleFactor: 1,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const maxViewportWidth = 4096;
|
|
40
|
+
const maxViewportHeight = 4096;
|
|
41
|
+
const maxDeviceScaleFactor = 3;
|
|
42
|
+
const maxViewportPixelArea = 8294400;
|
|
43
|
+
const maxSelectorLength = 4096;
|
|
44
|
+
const maxTextLength = 32768;
|
|
45
|
+
const maxUrlLength = 16384;
|
|
46
|
+
const maxTimeoutMs = 60000;
|
|
47
|
+
const maxOutstandingFrames = 3;
|
|
48
|
+
const maxQueuedPublicOperations = 64;
|
|
49
|
+
const maxQueuedInternalOperations = 128;
|
|
50
|
+
|
|
51
|
+
type TScreencastFrameEvent = plugins.puppeteer.Protocol.Page.ScreencastFrameEvent;
|
|
52
|
+
type TScreencastFrameListener = (event: TScreencastFrameEvent) => void;
|
|
53
|
+
type TCdpSessionDetachedListener = (session: plugins.puppeteer.CDPSession) => void;
|
|
54
|
+
|
|
55
|
+
interface IPrivateLiveBrowserTab {
|
|
56
|
+
id: string;
|
|
57
|
+
page: plugins.puppeteer.Page;
|
|
58
|
+
url: string;
|
|
59
|
+
title: string;
|
|
60
|
+
status: 'open' | 'crashed';
|
|
61
|
+
generation: number;
|
|
62
|
+
appliedViewportRevision: number;
|
|
63
|
+
streaming: boolean;
|
|
64
|
+
streamInvalidated: boolean;
|
|
65
|
+
navigationInProgress: boolean;
|
|
66
|
+
closing: boolean;
|
|
67
|
+
stateUpdateQueued: boolean;
|
|
68
|
+
stateUpdatePending: boolean;
|
|
69
|
+
navigationResetPending: boolean;
|
|
70
|
+
cdpSession?: plugins.puppeteer.CDPSession;
|
|
71
|
+
cdpConnection?: plugins.puppeteer.Connection;
|
|
72
|
+
screencastFrameListener?: TScreencastFrameListener;
|
|
73
|
+
cdpSessionDetachedListener?: TCdpSessionDetachedListener;
|
|
74
|
+
removeListeners: Array<() => void>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface IOutstandingFrame {
|
|
78
|
+
tabId: string;
|
|
79
|
+
generation: number;
|
|
80
|
+
viewportRevision: number;
|
|
81
|
+
cdpSessionId: number;
|
|
82
|
+
cdpSession: plugins.puppeteer.CDPSession;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface IImageDimensions {
|
|
86
|
+
width: number;
|
|
87
|
+
height: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type TQueuedOperationKind = 'public' | 'internal' | 'shutdown';
|
|
91
|
+
|
|
92
|
+
interface IQueuedOperation {
|
|
93
|
+
kind: TQueuedOperationKind;
|
|
94
|
+
controller: AbortController;
|
|
95
|
+
run: (signal: AbortSignal) => Promise<unknown>;
|
|
96
|
+
resolve: (value: unknown) => void;
|
|
97
|
+
reject: (error: unknown) => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const validateBoundedString = (
|
|
101
|
+
value: unknown,
|
|
102
|
+
name: string,
|
|
103
|
+
minLength: number,
|
|
104
|
+
maxLength: number,
|
|
105
|
+
): string => {
|
|
106
|
+
if (
|
|
107
|
+
typeof value !== 'string'
|
|
108
|
+
|| value.length < minLength
|
|
109
|
+
|| value.length > maxLength
|
|
110
|
+
) {
|
|
111
|
+
throw new Error(`${name} must contain between ${minLength} and ${maxLength} characters`);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const validateFiniteNumber = (
|
|
117
|
+
value: unknown,
|
|
118
|
+
name: string,
|
|
119
|
+
minimum: number,
|
|
120
|
+
maximum: number,
|
|
121
|
+
): number => {
|
|
122
|
+
if (
|
|
123
|
+
typeof value !== 'number'
|
|
124
|
+
|| !Number.isFinite(value)
|
|
125
|
+
|| value < minimum
|
|
126
|
+
|| value > maximum
|
|
127
|
+
) {
|
|
128
|
+
throw new Error(`${name} must be a finite number between ${minimum} and ${maximum}`);
|
|
129
|
+
}
|
|
130
|
+
return value;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const validateInteger = (
|
|
134
|
+
value: unknown,
|
|
135
|
+
name: string,
|
|
136
|
+
minimum: number,
|
|
137
|
+
maximum: number,
|
|
138
|
+
): number => {
|
|
139
|
+
const validatedValue = validateFiniteNumber(value, name, minimum, maximum);
|
|
140
|
+
if (!Number.isInteger(validatedValue)) {
|
|
141
|
+
throw new Error(`${name} must be an integer`);
|
|
142
|
+
}
|
|
143
|
+
return validatedValue;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const validateOptionalBoolean = (value: unknown, name: string): void => {
|
|
147
|
+
if (value !== undefined && typeof value !== 'boolean') {
|
|
148
|
+
throw new Error(`${name} must be a boolean`);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const truncate = (value: string, maxLength: number): string => {
|
|
153
|
+
if (value.length <= maxLength) {
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const normalizeErrorMessage = (error: unknown): string => {
|
|
160
|
+
if (error instanceof Error) {
|
|
161
|
+
return truncate(error.message, 2048);
|
|
162
|
+
}
|
|
163
|
+
return truncate(String(error), 2048);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const readUint32 = (data: Uint8Array, offset: number): number => {
|
|
167
|
+
return (
|
|
168
|
+
data[offset]! * 0x1000000
|
|
169
|
+
+ data[offset + 1]! * 0x10000
|
|
170
|
+
+ data[offset + 2]! * 0x100
|
|
171
|
+
+ data[offset + 3]!
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const readImageDimensions = (
|
|
176
|
+
data: Uint8Array,
|
|
177
|
+
format: TLiveBrowserImageFormat,
|
|
178
|
+
fallback: IImageDimensions,
|
|
179
|
+
): IImageDimensions => {
|
|
180
|
+
if (
|
|
181
|
+
format === 'png'
|
|
182
|
+
&& data.length >= 24
|
|
183
|
+
&& data[0] === 0x89
|
|
184
|
+
&& data[1] === 0x50
|
|
185
|
+
&& data[2] === 0x4e
|
|
186
|
+
&& data[3] === 0x47
|
|
187
|
+
) {
|
|
188
|
+
return {
|
|
189
|
+
width: readUint32(data, 16),
|
|
190
|
+
height: readUint32(data, 20),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (format === 'jpeg' && data.length >= 4 && data[0] === 0xff && data[1] === 0xd8) {
|
|
195
|
+
let offset = 2;
|
|
196
|
+
while (offset + 8 < data.length) {
|
|
197
|
+
if (data[offset] !== 0xff) {
|
|
198
|
+
offset += 1;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const marker = data[offset + 1]!;
|
|
202
|
+
if (marker === 0xd8 || marker === 0xd9) {
|
|
203
|
+
offset += 2;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const segmentLength = (data[offset + 2]! << 8) + data[offset + 3]!;
|
|
207
|
+
if (segmentLength < 2 || offset + segmentLength + 2 > data.length) {
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
if (
|
|
211
|
+
marker === 0xc0
|
|
212
|
+
|| marker === 0xc1
|
|
213
|
+
|| marker === 0xc2
|
|
214
|
+
|| marker === 0xc3
|
|
215
|
+
|| marker === 0xc5
|
|
216
|
+
|| marker === 0xc6
|
|
217
|
+
|| marker === 0xc7
|
|
218
|
+
|| marker === 0xc9
|
|
219
|
+
|| marker === 0xca
|
|
220
|
+
|| marker === 0xcb
|
|
221
|
+
|| marker === 0xcd
|
|
222
|
+
|| marker === 0xce
|
|
223
|
+
|| marker === 0xcf
|
|
224
|
+
) {
|
|
225
|
+
return {
|
|
226
|
+
height: (data[offset + 5]! << 8) + data[offset + 6]!,
|
|
227
|
+
width: (data[offset + 7]! << 8) + data[offset + 8]!,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
offset += segmentLength + 2;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return fallback;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const normalizeViewport = (viewport: ILiveBrowserViewport): ILiveBrowserViewport => {
|
|
238
|
+
const normalizedViewport = {
|
|
239
|
+
width: validateInteger(viewport.width, 'viewport.width', 1, maxViewportWidth),
|
|
240
|
+
height: validateInteger(viewport.height, 'viewport.height', 1, maxViewportHeight),
|
|
241
|
+
deviceScaleFactor: validateFiniteNumber(
|
|
242
|
+
viewport.deviceScaleFactor,
|
|
243
|
+
'viewport.deviceScaleFactor',
|
|
244
|
+
0.25,
|
|
245
|
+
maxDeviceScaleFactor,
|
|
246
|
+
),
|
|
247
|
+
};
|
|
248
|
+
const physicalWidth = Math.ceil(
|
|
249
|
+
normalizedViewport.width * normalizedViewport.deviceScaleFactor,
|
|
250
|
+
);
|
|
251
|
+
const physicalHeight = Math.ceil(
|
|
252
|
+
normalizedViewport.height * normalizedViewport.deviceScaleFactor,
|
|
253
|
+
);
|
|
254
|
+
if (physicalWidth * physicalHeight > maxViewportPixelArea) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`viewport pixel area must not exceed ${maxViewportPixelArea} physical pixels`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return normalizedViewport;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const createPuppeteerViewport = (
|
|
263
|
+
viewport: ILiveBrowserViewport,
|
|
264
|
+
): plugins.puppeteer.Viewport => ({
|
|
265
|
+
...viewport,
|
|
266
|
+
isMobile: false,
|
|
267
|
+
isLandscape: false,
|
|
268
|
+
hasTouch: false,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
export class LiveBrowserSession {
|
|
272
|
+
private readonly options: ILiveBrowserSessionOptions;
|
|
273
|
+
private readonly eventListeners = new Set<TLiveBrowserEventListener>();
|
|
274
|
+
private readonly tabs = new Map<string, IPrivateLiveBrowserTab>();
|
|
275
|
+
private readonly tabIdsByPage = new WeakMap<plugins.puppeteer.Page, string>();
|
|
276
|
+
private readonly outstandingFrames = new Map<number, IOutstandingFrame>();
|
|
277
|
+
|
|
278
|
+
private browser?: plugins.puppeteer.Browser;
|
|
279
|
+
private browserContext?: plugins.puppeteer.BrowserContext;
|
|
280
|
+
private browserLifetimeController?: AbortController;
|
|
281
|
+
private browserDisconnectedListener?: () => void;
|
|
282
|
+
private readonly operationQueue: IQueuedOperation[] = [];
|
|
283
|
+
private activeOperation?: IQueuedOperation;
|
|
284
|
+
private operationRunning = false;
|
|
285
|
+
private admittedPublicOperations = 0;
|
|
286
|
+
private admittedInternalOperations = 0;
|
|
287
|
+
private shutdownPromise?: Promise<void>;
|
|
288
|
+
private status: 'stopped' | 'starting' | 'running' | 'stopping' = 'stopped';
|
|
289
|
+
private activeTabId: string | null = null;
|
|
290
|
+
private viewport: ILiveBrowserViewport = { ...defaultViewport };
|
|
291
|
+
private viewportRevision = 1;
|
|
292
|
+
private tabSequence = 0;
|
|
293
|
+
private frameSequence = 0;
|
|
294
|
+
private normalStopRequested = false;
|
|
295
|
+
private lastError?: ILiveBrowserError;
|
|
296
|
+
|
|
297
|
+
constructor(optionsArg: ILiveBrowserSessionOptions = {}) {
|
|
298
|
+
if (
|
|
299
|
+
optionsArg.launchOptions?.protocol
|
|
300
|
+
&& optionsArg.launchOptions.protocol !== 'cdp'
|
|
301
|
+
) {
|
|
302
|
+
throw new Error('LiveBrowserSession only supports Puppeteer CDP transport');
|
|
303
|
+
}
|
|
304
|
+
if (
|
|
305
|
+
optionsArg.launchOptions?.browser
|
|
306
|
+
&& optionsArg.launchOptions.browser !== 'chrome'
|
|
307
|
+
) {
|
|
308
|
+
throw new Error('LiveBrowserSession requires Chromium');
|
|
309
|
+
}
|
|
310
|
+
if (optionsArg.launchOptions && 'signal' in optionsArg.launchOptions) {
|
|
311
|
+
throw new Error('LiveBrowserSession owns launch cancellation; launchOptions.signal is unsupported');
|
|
312
|
+
}
|
|
313
|
+
const launchViewport = optionsArg.launchOptions?.defaultViewport;
|
|
314
|
+
const viewport = normalizeViewport(
|
|
315
|
+
optionsArg.viewport
|
|
316
|
+
?? (launchViewport
|
|
317
|
+
? {
|
|
318
|
+
width: launchViewport.width,
|
|
319
|
+
height: launchViewport.height,
|
|
320
|
+
deviceScaleFactor: launchViewport.deviceScaleFactor ?? 1,
|
|
321
|
+
}
|
|
322
|
+
: defaultViewport),
|
|
323
|
+
);
|
|
324
|
+
this.options = {
|
|
325
|
+
...optionsArg,
|
|
326
|
+
launchOptions: {
|
|
327
|
+
...optionsArg.launchOptions,
|
|
328
|
+
args: [...(optionsArg.launchOptions?.args ?? [])],
|
|
329
|
+
defaultViewport: createPuppeteerViewport(viewport),
|
|
330
|
+
},
|
|
331
|
+
viewport,
|
|
332
|
+
screencast: optionsArg.screencast ? { ...optionsArg.screencast } : undefined,
|
|
333
|
+
};
|
|
334
|
+
this.viewport = { ...viewport };
|
|
335
|
+
this.validateScreencastOptions();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
public onEvent(listener: TLiveBrowserEventListener): () => void {
|
|
339
|
+
if (typeof listener !== 'function') {
|
|
340
|
+
throw new Error('listener must be a function');
|
|
341
|
+
}
|
|
342
|
+
this.eventListeners.add(listener);
|
|
343
|
+
return () => {
|
|
344
|
+
this.eventListeners.delete(listener);
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
public getState(): ILiveBrowserState {
|
|
349
|
+
return {
|
|
350
|
+
status: this.status,
|
|
351
|
+
activeTabId: this.activeTabId,
|
|
352
|
+
viewportRevision: this.viewportRevision,
|
|
353
|
+
viewport: { ...this.viewport },
|
|
354
|
+
tabs: [...this.tabs.values()].map((tab) => this.createTabState(tab)),
|
|
355
|
+
...(this.lastError ? { lastError: { ...this.lastError } } : {}),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
public async start(): Promise<void> {
|
|
360
|
+
return this.enqueuePublicOperation(async (signal) => {
|
|
361
|
+
if (this.status === 'running' || this.status === 'starting') {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
this.normalStopRequested = false;
|
|
366
|
+
this.lastError = undefined;
|
|
367
|
+
this.viewportRevision = 1;
|
|
368
|
+
const browserLifetimeController = new AbortController();
|
|
369
|
+
this.browserLifetimeController = browserLifetimeController;
|
|
370
|
+
this.status = 'starting';
|
|
371
|
+
this.emitState();
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
if (signal.aborted) {
|
|
375
|
+
throw signal.reason;
|
|
376
|
+
}
|
|
377
|
+
if (browserLifetimeController.signal.aborted) {
|
|
378
|
+
throw browserLifetimeController.signal.reason;
|
|
379
|
+
}
|
|
380
|
+
this.browser = await getEnvAwareBrowserInstance({
|
|
381
|
+
forceNoSandbox: this.options.forceNoSandbox,
|
|
382
|
+
usePipe: this.options.usePipe,
|
|
383
|
+
launchOptions: {
|
|
384
|
+
...this.options.launchOptions,
|
|
385
|
+
protocol: 'cdp',
|
|
386
|
+
signal: browserLifetimeController.signal,
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
if (signal.aborted) {
|
|
390
|
+
throw signal.reason;
|
|
391
|
+
}
|
|
392
|
+
this.browserContext = this.browser.defaultBrowserContext();
|
|
393
|
+
this.browserDisconnectedListener = () => {
|
|
394
|
+
if (this.normalStopRequested || this.status === 'stopped' || this.status === 'stopping') {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const error: ILiveBrowserError = {
|
|
398
|
+
code: 'browser_disconnected',
|
|
399
|
+
message: 'The Chromium process disconnected',
|
|
400
|
+
fatal: true,
|
|
401
|
+
};
|
|
402
|
+
this.emitError(error);
|
|
403
|
+
void this.requestShutdown(error).catch((cleanupError) => {
|
|
404
|
+
this.emitError({
|
|
405
|
+
code: 'browser_disconnect_cleanup_failed',
|
|
406
|
+
message: normalizeErrorMessage(cleanupError),
|
|
407
|
+
fatal: true,
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
};
|
|
411
|
+
this.browser.on('disconnected', this.browserDisconnectedListener);
|
|
412
|
+
|
|
413
|
+
const initialPages = await this.browserContext.pages();
|
|
414
|
+
if (initialPages.length === 0) {
|
|
415
|
+
initialPages.push(await this.browserContext.newPage());
|
|
416
|
+
}
|
|
417
|
+
const initialPage = initialPages[0]!;
|
|
418
|
+
if (initialPage.url() !== 'about:blank') {
|
|
419
|
+
await initialPage.goto('about:blank');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for (const page of initialPages) {
|
|
423
|
+
await this.registerPage(page);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const firstTab = this.tabs.values().next().value as IPrivateLiveBrowserTab | undefined;
|
|
427
|
+
if (!firstTab) {
|
|
428
|
+
throw new Error('Chromium did not provide an initial page');
|
|
429
|
+
}
|
|
430
|
+
this.activeTabId = firstTab.id;
|
|
431
|
+
await firstTab.page.bringToFront();
|
|
432
|
+
this.status = 'running';
|
|
433
|
+
this.emitState();
|
|
434
|
+
await this.startScreencast(firstTab);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (signal.aborted || this.normalStopRequested) {
|
|
437
|
+
await this.stopInternal();
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
const startError: ILiveBrowserError = {
|
|
441
|
+
code: 'start_failed',
|
|
442
|
+
message: normalizeErrorMessage(error),
|
|
443
|
+
fatal: true,
|
|
444
|
+
};
|
|
445
|
+
this.emitError(startError);
|
|
446
|
+
await this.stopInternal(startError);
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
public async stop(): Promise<void> {
|
|
453
|
+
this.normalStopRequested = true;
|
|
454
|
+
return this.requestShutdown();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
public async acknowledgeFrame(
|
|
458
|
+
acknowledgement: ILiveBrowserFrameAcknowledgementRequest,
|
|
459
|
+
): Promise<ILiveBrowserFrameAcknowledgement> {
|
|
460
|
+
if (!acknowledgement || typeof acknowledgement !== 'object') {
|
|
461
|
+
return { accepted: false };
|
|
462
|
+
}
|
|
463
|
+
const { tabId, sequence, generation, viewportRevision } = acknowledgement;
|
|
464
|
+
if (
|
|
465
|
+
typeof tabId !== 'string'
|
|
466
|
+
|| tabId.length < 1
|
|
467
|
+
|| tabId.length > 128
|
|
468
|
+
|| !Number.isInteger(sequence)
|
|
469
|
+
|| sequence < 1
|
|
470
|
+
|| !Number.isInteger(generation)
|
|
471
|
+
|| generation < 0
|
|
472
|
+
|| !Number.isInteger(viewportRevision)
|
|
473
|
+
|| viewportRevision < 1
|
|
474
|
+
) {
|
|
475
|
+
return { accepted: false };
|
|
476
|
+
}
|
|
477
|
+
const outstandingFrame = this.outstandingFrames.get(sequence);
|
|
478
|
+
if (!outstandingFrame) {
|
|
479
|
+
return { accepted: false };
|
|
480
|
+
}
|
|
481
|
+
if (
|
|
482
|
+
outstandingFrame.tabId !== tabId
|
|
483
|
+
|| outstandingFrame.generation !== generation
|
|
484
|
+
|| outstandingFrame.viewportRevision !== viewportRevision
|
|
485
|
+
) {
|
|
486
|
+
return { accepted: false };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const tab = this.tabs.get(outstandingFrame.tabId);
|
|
490
|
+
if (
|
|
491
|
+
!tab
|
|
492
|
+
|| this.activeTabId !== tab.id
|
|
493
|
+
|| tab.generation !== outstandingFrame.generation
|
|
494
|
+
|| this.viewportRevision !== outstandingFrame.viewportRevision
|
|
495
|
+
|| tab.cdpSession !== outstandingFrame.cdpSession
|
|
496
|
+
|| outstandingFrame.cdpSession.detached
|
|
497
|
+
) {
|
|
498
|
+
this.outstandingFrames.delete(sequence);
|
|
499
|
+
await this.acknowledgeCdpFrame(outstandingFrame);
|
|
500
|
+
return { accepted: false };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
this.outstandingFrames.delete(sequence);
|
|
504
|
+
return {
|
|
505
|
+
accepted: await this.acknowledgeCdpFrame(outstandingFrame),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
public async createTab(
|
|
510
|
+
optionsArg: ILiveBrowserCreateTabOptions = {},
|
|
511
|
+
): Promise<ILiveBrowserTabState> {
|
|
512
|
+
const url = optionsArg.url === undefined ? undefined : this.validateUrl(optionsArg.url);
|
|
513
|
+
const activate = optionsArg.activate ?? true;
|
|
514
|
+
validateOptionalBoolean(optionsArg.activate, 'activate');
|
|
515
|
+
const timeout = this.validateTimeout(optionsArg.timeoutMs, 30000);
|
|
516
|
+
const waitUntil = this.validateWaitUntil(optionsArg.waitUntil);
|
|
517
|
+
return this.enqueuePublicOperation(async (signal) => {
|
|
518
|
+
const context = this.requireBrowserContext();
|
|
519
|
+
const previousActiveTabId = this.activeTabId;
|
|
520
|
+
let page: plugins.puppeteer.Page | undefined;
|
|
521
|
+
let tab: IPrivateLiveBrowserTab | undefined;
|
|
522
|
+
try {
|
|
523
|
+
page = await context.newPage();
|
|
524
|
+
if (signal.aborted) {
|
|
525
|
+
throw signal.reason;
|
|
526
|
+
}
|
|
527
|
+
tab = await this.registerPage(page);
|
|
528
|
+
if (url !== undefined) {
|
|
529
|
+
await this.navigateTab(
|
|
530
|
+
tab,
|
|
531
|
+
async () => {
|
|
532
|
+
await tab!.page.goto(url, { timeout, waitUntil, signal });
|
|
533
|
+
},
|
|
534
|
+
signal,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (activate) {
|
|
539
|
+
await this.activateTabInternal(tab.id);
|
|
540
|
+
} else {
|
|
541
|
+
const previousActiveTab = previousActiveTabId
|
|
542
|
+
? this.tabs.get(previousActiveTabId)
|
|
543
|
+
: undefined;
|
|
544
|
+
if (previousActiveTab && !previousActiveTab.page.isClosed()) {
|
|
545
|
+
await previousActiveTab.page.bringToFront();
|
|
546
|
+
}
|
|
547
|
+
this.emitState();
|
|
548
|
+
}
|
|
549
|
+
return this.createTabState(tab);
|
|
550
|
+
} catch (error) {
|
|
551
|
+
if (page) {
|
|
552
|
+
const rollbackError = await this.rollbackCreatedPage(
|
|
553
|
+
page,
|
|
554
|
+
tab,
|
|
555
|
+
previousActiveTabId,
|
|
556
|
+
);
|
|
557
|
+
if (rollbackError) {
|
|
558
|
+
throw new AggregateError(
|
|
559
|
+
[error, rollbackError],
|
|
560
|
+
'Tab creation failed and its page could not be closed cleanly',
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
public async activateTab(tabId: string): Promise<void> {
|
|
570
|
+
return this.enqueuePublicOperation(async () => {
|
|
571
|
+
await this.activateTabInternal(tabId);
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
public async closeTab(tabId: string): Promise<void> {
|
|
576
|
+
return this.enqueuePublicOperation(async () => {
|
|
577
|
+
const tab = this.requireTab(tabId);
|
|
578
|
+
const wasActive = this.activeTabId === tab.id;
|
|
579
|
+
if (wasActive) {
|
|
580
|
+
await this.stopScreencast(tab);
|
|
581
|
+
} else {
|
|
582
|
+
await this.retireOutstandingFrames((frame) => frame.tabId === tab.id);
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
if (!tab.page.isClosed()) {
|
|
586
|
+
await tab.page.close();
|
|
587
|
+
}
|
|
588
|
+
} catch (error) {
|
|
589
|
+
if (
|
|
590
|
+
wasActive
|
|
591
|
+
&& this.status === 'running'
|
|
592
|
+
&& this.activeTabId === tab.id
|
|
593
|
+
&& tab.status === 'open'
|
|
594
|
+
) {
|
|
595
|
+
await this.startScreencast(tab);
|
|
596
|
+
}
|
|
597
|
+
this.emitState();
|
|
598
|
+
throw error;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
tab.closing = true;
|
|
602
|
+
this.removePageListeners(tab);
|
|
603
|
+
this.tabs.delete(tab.id);
|
|
604
|
+
this.tabIdsByPage.delete(tab.page);
|
|
605
|
+
|
|
606
|
+
if (wasActive) {
|
|
607
|
+
this.activeTabId = null;
|
|
608
|
+
await this.activateReplacementTab();
|
|
609
|
+
} else {
|
|
610
|
+
this.emitState();
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
public async navigate(optionsArg: ILiveBrowserNavigateOptions): Promise<void> {
|
|
616
|
+
const url = this.validateUrl(optionsArg.url);
|
|
617
|
+
return this.enqueuePublicOperation(async (signal) => {
|
|
618
|
+
const tab = this.resolveNavigationTab(optionsArg.tabId);
|
|
619
|
+
await this.navigateTab(
|
|
620
|
+
tab,
|
|
621
|
+
async () => {
|
|
622
|
+
await tab.page.goto(url, this.createPuppeteerNavigationOptions(optionsArg, signal));
|
|
623
|
+
},
|
|
624
|
+
signal,
|
|
625
|
+
);
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
public async back(optionsArg: ILiveBrowserNavigationOptions = {}): Promise<void> {
|
|
630
|
+
return this.enqueuePublicOperation(async (signal) => {
|
|
631
|
+
const tab = this.resolveNavigationTab(optionsArg.tabId);
|
|
632
|
+
await this.navigateTab(
|
|
633
|
+
tab,
|
|
634
|
+
async () => {
|
|
635
|
+
await tab.page.goBack(this.createPuppeteerNavigationOptions(optionsArg, signal));
|
|
636
|
+
},
|
|
637
|
+
signal,
|
|
638
|
+
);
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
public async forward(optionsArg: ILiveBrowserNavigationOptions = {}): Promise<void> {
|
|
643
|
+
return this.enqueuePublicOperation(async (signal) => {
|
|
644
|
+
const tab = this.resolveNavigationTab(optionsArg.tabId);
|
|
645
|
+
await this.navigateTab(
|
|
646
|
+
tab,
|
|
647
|
+
async () => {
|
|
648
|
+
await tab.page.goForward(this.createPuppeteerNavigationOptions(optionsArg, signal));
|
|
649
|
+
},
|
|
650
|
+
signal,
|
|
651
|
+
);
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
public async reload(optionsArg: ILiveBrowserNavigationOptions = {}): Promise<void> {
|
|
656
|
+
return this.enqueuePublicOperation(async (signal) => {
|
|
657
|
+
const tab = this.resolveNavigationTab(optionsArg.tabId);
|
|
658
|
+
await this.navigateTab(
|
|
659
|
+
tab,
|
|
660
|
+
async () => {
|
|
661
|
+
await tab.page.reload(this.createPuppeteerNavigationOptions(optionsArg, signal));
|
|
662
|
+
},
|
|
663
|
+
signal,
|
|
664
|
+
);
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
public async setViewport(viewportArg: ILiveBrowserViewport): Promise<void> {
|
|
669
|
+
const viewport = normalizeViewport(viewportArg);
|
|
670
|
+
return this.enqueuePublicOperation(async (signal) => {
|
|
671
|
+
const tab = this.requireActiveTab();
|
|
672
|
+
await this.stopScreencast(tab);
|
|
673
|
+
try {
|
|
674
|
+
if (signal.aborted) {
|
|
675
|
+
throw signal.reason;
|
|
676
|
+
}
|
|
677
|
+
await tab.page.setViewport(createPuppeteerViewport(viewport));
|
|
678
|
+
this.viewport = { ...viewport };
|
|
679
|
+
this.viewportRevision += 1;
|
|
680
|
+
tab.appliedViewportRevision = this.viewportRevision;
|
|
681
|
+
this.emitState();
|
|
682
|
+
} finally {
|
|
683
|
+
if (this.status === 'running' && this.activeTabId === tab.id && tab.status === 'open') {
|
|
684
|
+
await this.startScreencast(tab);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
public async dispatchMouse(input: ILiveBrowserMouseInput): Promise<void> {
|
|
691
|
+
const { tab, cdpSession } = this.requireRawInputTarget(input);
|
|
692
|
+
this.validateCoordinates(input.x, input.y);
|
|
693
|
+
const type = input.type === 'move'
|
|
694
|
+
? 'mouseMoved'
|
|
695
|
+
: input.type === 'down'
|
|
696
|
+
? 'mousePressed'
|
|
697
|
+
: input.type === 'up'
|
|
698
|
+
? 'mouseReleased'
|
|
699
|
+
: undefined;
|
|
700
|
+
if (!type) {
|
|
701
|
+
throw new Error('mouse input type must be move, down, or up');
|
|
702
|
+
}
|
|
703
|
+
const allowedButtons = ['none', 'left', 'middle', 'right', 'back', 'forward'];
|
|
704
|
+
const button = input.button ?? (type === 'mouseMoved' ? 'none' : 'left');
|
|
705
|
+
if (!allowedButtons.includes(button)) {
|
|
706
|
+
throw new Error('mouse button is invalid');
|
|
707
|
+
}
|
|
708
|
+
const buttons = input.buttons === undefined
|
|
709
|
+
? undefined
|
|
710
|
+
: validateInteger(input.buttons, 'buttons', 0, 31);
|
|
711
|
+
const clickCount = input.clickCount === undefined
|
|
712
|
+
? undefined
|
|
713
|
+
: validateInteger(input.clickCount, 'clickCount', 0, 3);
|
|
714
|
+
|
|
715
|
+
try {
|
|
716
|
+
await cdpSession.send('Input.dispatchMouseEvent', {
|
|
717
|
+
type,
|
|
718
|
+
x: input.x,
|
|
719
|
+
y: input.y,
|
|
720
|
+
button,
|
|
721
|
+
buttons,
|
|
722
|
+
clickCount,
|
|
723
|
+
modifiers: this.createModifierMask(input.modifiers),
|
|
724
|
+
pointerType: 'mouse',
|
|
725
|
+
});
|
|
726
|
+
} catch (error) {
|
|
727
|
+
this.handlePossibleCdpDisconnection(tab, cdpSession, error);
|
|
728
|
+
throw error;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
public async dispatchWheel(input: ILiveBrowserWheelInput): Promise<void> {
|
|
733
|
+
const { tab, cdpSession } = this.requireRawInputTarget(input);
|
|
734
|
+
this.validateCoordinates(input.x, input.y);
|
|
735
|
+
validateFiniteNumber(input.deltaX, 'deltaX', -1000000, 1000000);
|
|
736
|
+
validateFiniteNumber(input.deltaY, 'deltaY', -1000000, 1000000);
|
|
737
|
+
try {
|
|
738
|
+
await cdpSession.send('Input.dispatchMouseEvent', {
|
|
739
|
+
type: 'mouseWheel',
|
|
740
|
+
x: input.x,
|
|
741
|
+
y: input.y,
|
|
742
|
+
deltaX: input.deltaX,
|
|
743
|
+
deltaY: input.deltaY,
|
|
744
|
+
modifiers: this.createModifierMask(input.modifiers),
|
|
745
|
+
pointerType: 'mouse',
|
|
746
|
+
});
|
|
747
|
+
} catch (error) {
|
|
748
|
+
this.handlePossibleCdpDisconnection(tab, cdpSession, error);
|
|
749
|
+
throw error;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
public async dispatchKey(input: ILiveBrowserKeyInput): Promise<void> {
|
|
754
|
+
const { tab, cdpSession } = this.requireRawInputTarget(input);
|
|
755
|
+
const type = input.type === 'down'
|
|
756
|
+
? 'keyDown'
|
|
757
|
+
: input.type === 'up'
|
|
758
|
+
? 'keyUp'
|
|
759
|
+
: undefined;
|
|
760
|
+
if (!type) {
|
|
761
|
+
throw new Error('key input type must be down or up');
|
|
762
|
+
}
|
|
763
|
+
const key = validateBoundedString(input.key, 'key', 1, 64);
|
|
764
|
+
const code = input.code === undefined
|
|
765
|
+
? undefined
|
|
766
|
+
: validateBoundedString(input.code, 'code', 1, 64);
|
|
767
|
+
const text = input.text === undefined
|
|
768
|
+
? undefined
|
|
769
|
+
: validateBoundedString(input.text, 'text', 0, 1024);
|
|
770
|
+
const unmodifiedText = input.unmodifiedText === undefined
|
|
771
|
+
? undefined
|
|
772
|
+
: validateBoundedString(input.unmodifiedText, 'unmodifiedText', 0, 1024);
|
|
773
|
+
const windowsVirtualKeyCode = input.windowsVirtualKeyCode === undefined
|
|
774
|
+
? undefined
|
|
775
|
+
: validateInteger(input.windowsVirtualKeyCode, 'windowsVirtualKeyCode', 0, 65535);
|
|
776
|
+
const nativeVirtualKeyCode = input.nativeVirtualKeyCode === undefined
|
|
777
|
+
? undefined
|
|
778
|
+
: validateInteger(input.nativeVirtualKeyCode, 'nativeVirtualKeyCode', 0, 65535);
|
|
779
|
+
const location = input.location === undefined
|
|
780
|
+
? undefined
|
|
781
|
+
: validateInteger(input.location, 'location', 0, 3);
|
|
782
|
+
validateOptionalBoolean(input.autoRepeat, 'autoRepeat');
|
|
783
|
+
validateOptionalBoolean(input.isKeypad, 'isKeypad');
|
|
784
|
+
|
|
785
|
+
try {
|
|
786
|
+
await cdpSession.send('Input.dispatchKeyEvent', {
|
|
787
|
+
type,
|
|
788
|
+
key,
|
|
789
|
+
code,
|
|
790
|
+
text,
|
|
791
|
+
unmodifiedText,
|
|
792
|
+
windowsVirtualKeyCode,
|
|
793
|
+
nativeVirtualKeyCode,
|
|
794
|
+
autoRepeat: input.autoRepeat,
|
|
795
|
+
isKeypad: input.isKeypad,
|
|
796
|
+
location,
|
|
797
|
+
modifiers: this.createModifierMask(input.modifiers),
|
|
798
|
+
});
|
|
799
|
+
} catch (error) {
|
|
800
|
+
this.handlePossibleCdpDisconnection(tab, cdpSession, error);
|
|
801
|
+
throw error;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
public async insertText(input: ILiveBrowserInsertTextInput): Promise<void> {
|
|
806
|
+
const { tab, cdpSession } = this.requireRawInputTarget(input);
|
|
807
|
+
const text = validateBoundedString(input.text, 'text', 0, maxTextLength);
|
|
808
|
+
try {
|
|
809
|
+
await cdpSession.send('Input.insertText', { text });
|
|
810
|
+
} catch (error) {
|
|
811
|
+
this.handlePossibleCdpDisconnection(tab, cdpSession, error);
|
|
812
|
+
throw error;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
public async captureSnapshot(
|
|
817
|
+
optionsArg: ILiveBrowserSnapshotOptions = {},
|
|
818
|
+
): Promise<ILiveBrowserSnapshot> {
|
|
819
|
+
const format = optionsArg.format ?? 'jpeg';
|
|
820
|
+
if (format !== 'jpeg' && format !== 'png') {
|
|
821
|
+
throw new Error('snapshot format must be jpeg or png');
|
|
822
|
+
}
|
|
823
|
+
const quality = optionsArg.quality === undefined
|
|
824
|
+
? undefined
|
|
825
|
+
: validateInteger(optionsArg.quality, 'quality', 0, 100);
|
|
826
|
+
if (format === 'png' && quality !== undefined) {
|
|
827
|
+
throw new Error('quality is only supported for jpeg snapshots');
|
|
828
|
+
}
|
|
829
|
+
if ('fullPage' in optionsArg) {
|
|
830
|
+
throw new Error('fullPage snapshots are not supported by LiveBrowserSession');
|
|
831
|
+
}
|
|
832
|
+
return this.enqueuePublicOperation(async (signal) => {
|
|
833
|
+
const tab = this.resolveActionTab(optionsArg.tabId);
|
|
834
|
+
await this.ensureTabViewport(tab);
|
|
835
|
+
const capturedTab = this.resolveActionTab(tab.id);
|
|
836
|
+
if (signal.aborted) {
|
|
837
|
+
throw signal.reason;
|
|
838
|
+
}
|
|
839
|
+
const viewport = { ...this.viewport };
|
|
840
|
+
const viewportRevision = this.viewportRevision;
|
|
841
|
+
const data = await capturedTab.page.screenshot({
|
|
842
|
+
type: format,
|
|
843
|
+
...(format === 'jpeg' && quality !== undefined ? { quality } : {}),
|
|
844
|
+
});
|
|
845
|
+
if (signal.aborted) {
|
|
846
|
+
throw signal.reason;
|
|
847
|
+
}
|
|
848
|
+
const dimensions = readImageDimensions(data, format, {
|
|
849
|
+
width: Math.round(viewport.width * viewport.deviceScaleFactor),
|
|
850
|
+
height: Math.round(viewport.height * viewport.deviceScaleFactor),
|
|
851
|
+
});
|
|
852
|
+
return {
|
|
853
|
+
tabId: capturedTab.id,
|
|
854
|
+
viewportRevision,
|
|
855
|
+
viewport,
|
|
856
|
+
format,
|
|
857
|
+
mimeType: format === 'jpeg' ? 'image/jpeg' : 'image/png',
|
|
858
|
+
...dimensions,
|
|
859
|
+
data,
|
|
860
|
+
};
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
public async observe(optionsArg: ILiveBrowserObserveOptions = {}): Promise<ILiveBrowserObservation> {
|
|
865
|
+
const maxCharacters = optionsArg.maxCharacters === undefined
|
|
866
|
+
? 12000
|
|
867
|
+
: validateInteger(optionsArg.maxCharacters, 'maxCharacters', 256, 50000);
|
|
868
|
+
return this.enqueuePublicOperation(async (signal) => {
|
|
869
|
+
const tab = this.resolveActionTab(optionsArg.tabId);
|
|
870
|
+
await this.ensureTabViewport(tab);
|
|
871
|
+
const observedTab = this.resolveActionTab(tab.id);
|
|
872
|
+
if (signal.aborted) {
|
|
873
|
+
throw signal.reason;
|
|
874
|
+
}
|
|
875
|
+
await this.refreshTab(observedTab);
|
|
876
|
+
const accessibilitySnapshot = await observedTab.page.accessibility.snapshot({
|
|
877
|
+
interestingOnly: true,
|
|
878
|
+
});
|
|
879
|
+
if (signal.aborted) {
|
|
880
|
+
throw signal.reason;
|
|
881
|
+
}
|
|
882
|
+
const header = [
|
|
883
|
+
`Tab: ${observedTab.id}`,
|
|
884
|
+
`Status: ${observedTab.status}`,
|
|
885
|
+
`Title: ${observedTab.title}`,
|
|
886
|
+
`URL: ${observedTab.url}`,
|
|
887
|
+
'Accessibility:',
|
|
888
|
+
];
|
|
889
|
+
const lines = [...header];
|
|
890
|
+
let reachedTraversalLimit = false;
|
|
891
|
+
|
|
892
|
+
const appendNode = (
|
|
893
|
+
node: plugins.puppeteer.SerializedAXNode,
|
|
894
|
+
depth: number,
|
|
895
|
+
): void => {
|
|
896
|
+
if (lines.join('\n').length >= maxCharacters) {
|
|
897
|
+
reachedTraversalLimit = true;
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
const details: string[] = [node.role];
|
|
901
|
+
if (node.name) {
|
|
902
|
+
details.push(`"${truncate(node.name, 512)}"`);
|
|
903
|
+
}
|
|
904
|
+
if (node.value !== undefined) {
|
|
905
|
+
details.push(`value="${truncate(String(node.value), 512)}"`);
|
|
906
|
+
}
|
|
907
|
+
for (const property of [
|
|
908
|
+
'disabled',
|
|
909
|
+
'expanded',
|
|
910
|
+
'focused',
|
|
911
|
+
'readonly',
|
|
912
|
+
'required',
|
|
913
|
+
'selected',
|
|
914
|
+
'checked',
|
|
915
|
+
'pressed',
|
|
916
|
+
] as const) {
|
|
917
|
+
const value = node[property];
|
|
918
|
+
if (value !== undefined && value !== false) {
|
|
919
|
+
details.push(`${property}=${String(value)}`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
lines.push(`${' '.repeat(Math.min(depth, 20))}- ${details.join(' ')}`);
|
|
923
|
+
for (const child of node.children ?? []) {
|
|
924
|
+
appendNode(child, depth + 1);
|
|
925
|
+
if (reachedTraversalLimit) {
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
if (accessibilitySnapshot) {
|
|
932
|
+
appendNode(accessibilitySnapshot, 0);
|
|
933
|
+
} else {
|
|
934
|
+
lines.push('- No accessibility nodes');
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const unboundedText = lines.join('\n');
|
|
938
|
+
const truncatedText = unboundedText.length > maxCharacters
|
|
939
|
+
? unboundedText.slice(0, maxCharacters)
|
|
940
|
+
: unboundedText;
|
|
941
|
+
const state = this.getState();
|
|
942
|
+
return {
|
|
943
|
+
tabId: observedTab.id,
|
|
944
|
+
url: observedTab.url,
|
|
945
|
+
title: observedTab.title,
|
|
946
|
+
tab: this.createTabState(observedTab),
|
|
947
|
+
state,
|
|
948
|
+
text: truncatedText,
|
|
949
|
+
truncated: reachedTraversalLimit || unboundedText.length > maxCharacters,
|
|
950
|
+
};
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
public async click(optionsArg: ILiveBrowserClickOptions): Promise<void> {
|
|
955
|
+
const selector = validateBoundedString(
|
|
956
|
+
optionsArg.selector,
|
|
957
|
+
'selector',
|
|
958
|
+
1,
|
|
959
|
+
maxSelectorLength,
|
|
960
|
+
);
|
|
961
|
+
const timeout = this.validateTimeout(optionsArg.timeoutMs);
|
|
962
|
+
const allowedButtons = ['left', 'middle', 'right'];
|
|
963
|
+
if (optionsArg.button !== undefined && !allowedButtons.includes(optionsArg.button)) {
|
|
964
|
+
throw new Error('click button is invalid');
|
|
965
|
+
}
|
|
966
|
+
const clickCount = optionsArg.clickCount === undefined
|
|
967
|
+
? undefined
|
|
968
|
+
: validateInteger(optionsArg.clickCount, 'clickCount', 1, 3);
|
|
969
|
+
return this.enqueuePublicOperation(async (signal) => {
|
|
970
|
+
const tab = this.requireSemanticActionTarget(optionsArg);
|
|
971
|
+
await this.ensureTabViewport(tab);
|
|
972
|
+
const actionTab = this.requireSemanticActionTarget(optionsArg);
|
|
973
|
+
await actionTab.page.locator(selector).setTimeout(timeout).click({
|
|
974
|
+
button: optionsArg.button,
|
|
975
|
+
count: clickCount,
|
|
976
|
+
signal,
|
|
977
|
+
});
|
|
978
|
+
await this.refreshTab(actionTab);
|
|
979
|
+
this.emitState();
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
public async fill(optionsArg: ILiveBrowserFillOptions): Promise<void> {
|
|
984
|
+
const selector = validateBoundedString(
|
|
985
|
+
optionsArg.selector,
|
|
986
|
+
'selector',
|
|
987
|
+
1,
|
|
988
|
+
maxSelectorLength,
|
|
989
|
+
);
|
|
990
|
+
const text = validateBoundedString(optionsArg.text, 'text', 0, maxTextLength);
|
|
991
|
+
const timeout = this.validateTimeout(optionsArg.timeoutMs);
|
|
992
|
+
return this.enqueuePublicOperation(async (signal) => {
|
|
993
|
+
const tab = this.requireSemanticActionTarget(optionsArg);
|
|
994
|
+
await this.ensureTabViewport(tab);
|
|
995
|
+
const actionTab = this.requireSemanticActionTarget(optionsArg);
|
|
996
|
+
await actionTab.page.locator(selector).setTimeout(timeout).fill(text, { signal });
|
|
997
|
+
await this.refreshTab(actionTab);
|
|
998
|
+
this.emitState();
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
public async press(optionsArg: ILiveBrowserPressOptions): Promise<void> {
|
|
1003
|
+
const selector = validateBoundedString(
|
|
1004
|
+
optionsArg.selector,
|
|
1005
|
+
'selector',
|
|
1006
|
+
1,
|
|
1007
|
+
maxSelectorLength,
|
|
1008
|
+
);
|
|
1009
|
+
const key = validateBoundedString(optionsArg.key, 'key', 1, 64);
|
|
1010
|
+
const timeout = this.validateTimeout(optionsArg.timeoutMs);
|
|
1011
|
+
return this.enqueuePublicOperation(async (signal) => {
|
|
1012
|
+
const tab = this.requireSemanticActionTarget(optionsArg);
|
|
1013
|
+
await this.ensureTabViewport(tab);
|
|
1014
|
+
const actionTab = this.requireSemanticActionTarget(optionsArg);
|
|
1015
|
+
const element = await actionTab.page.waitForSelector(selector, {
|
|
1016
|
+
visible: true,
|
|
1017
|
+
timeout,
|
|
1018
|
+
signal,
|
|
1019
|
+
});
|
|
1020
|
+
if (!element) {
|
|
1021
|
+
throw new Error(`No visible element matched selector: ${selector}`);
|
|
1022
|
+
}
|
|
1023
|
+
try {
|
|
1024
|
+
await element.press(key as plugins.puppeteer.KeyInput);
|
|
1025
|
+
} finally {
|
|
1026
|
+
await element.dispose();
|
|
1027
|
+
}
|
|
1028
|
+
await this.refreshTab(actionTab);
|
|
1029
|
+
this.emitState();
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
private enqueuePublicOperation<T>(
|
|
1034
|
+
operation: (signal: AbortSignal) => Promise<T>,
|
|
1035
|
+
): Promise<T> {
|
|
1036
|
+
if (
|
|
1037
|
+
this.status === 'stopping'
|
|
1038
|
+
|| this.normalStopRequested
|
|
1039
|
+
|| (this.status === 'stopped' && this.operationRunning)
|
|
1040
|
+
) {
|
|
1041
|
+
return Promise.reject(new Error('LiveBrowserSession is stopping'));
|
|
1042
|
+
}
|
|
1043
|
+
if (this.admittedPublicOperations >= maxQueuedPublicOperations) {
|
|
1044
|
+
return Promise.reject(new Error('LiveBrowserSession operation queue is full'));
|
|
1045
|
+
}
|
|
1046
|
+
this.admittedPublicOperations += 1;
|
|
1047
|
+
return this.enqueueQueuedOperation('public', operation);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
private enqueueInternalOperation(
|
|
1051
|
+
operation: (signal: AbortSignal) => Promise<void>,
|
|
1052
|
+
priority = false,
|
|
1053
|
+
): Promise<void> {
|
|
1054
|
+
if (this.status === 'stopped' || this.status === 'stopping') {
|
|
1055
|
+
return Promise.reject(new Error('LiveBrowserSession is stopping'));
|
|
1056
|
+
}
|
|
1057
|
+
this.admittedInternalOperations += 1;
|
|
1058
|
+
return this.enqueueQueuedOperation('internal', operation, priority);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
private enqueueQueuedOperation<T>(
|
|
1062
|
+
kind: TQueuedOperationKind,
|
|
1063
|
+
operation: (signal: AbortSignal) => Promise<T>,
|
|
1064
|
+
priority = false,
|
|
1065
|
+
): Promise<T> {
|
|
1066
|
+
return new Promise<T>((resolve, reject) => {
|
|
1067
|
+
const queuedOperation: IQueuedOperation = {
|
|
1068
|
+
kind,
|
|
1069
|
+
controller: new AbortController(),
|
|
1070
|
+
run: operation,
|
|
1071
|
+
resolve: (value) => resolve(value as T),
|
|
1072
|
+
reject,
|
|
1073
|
+
};
|
|
1074
|
+
if (priority) {
|
|
1075
|
+
this.operationQueue.unshift(queuedOperation);
|
|
1076
|
+
} else {
|
|
1077
|
+
this.operationQueue.push(queuedOperation);
|
|
1078
|
+
}
|
|
1079
|
+
this.drainOperationQueue();
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
private drainOperationQueue(): void {
|
|
1084
|
+
if (this.operationRunning) {
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
const queuedOperation = this.operationQueue.shift();
|
|
1088
|
+
if (!queuedOperation) {
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
this.operationRunning = true;
|
|
1092
|
+
this.activeOperation = queuedOperation;
|
|
1093
|
+
void this.executeQueuedOperation(queuedOperation).catch((error) => {
|
|
1094
|
+
this.operationRunning = false;
|
|
1095
|
+
this.activeOperation = undefined;
|
|
1096
|
+
this.emitError({
|
|
1097
|
+
code: 'operation_scheduler_failed',
|
|
1098
|
+
message: normalizeErrorMessage(error),
|
|
1099
|
+
fatal: true,
|
|
1100
|
+
});
|
|
1101
|
+
this.drainOperationQueue();
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
private async executeQueuedOperation(queuedOperation: IQueuedOperation): Promise<void> {
|
|
1106
|
+
try {
|
|
1107
|
+
if (queuedOperation.controller.signal.aborted) {
|
|
1108
|
+
throw queuedOperation.controller.signal.reason;
|
|
1109
|
+
}
|
|
1110
|
+
queuedOperation.resolve(await queuedOperation.run(queuedOperation.controller.signal));
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
queuedOperation.reject(error);
|
|
1113
|
+
} finally {
|
|
1114
|
+
if (queuedOperation.kind === 'public') {
|
|
1115
|
+
this.admittedPublicOperations -= 1;
|
|
1116
|
+
} else if (queuedOperation.kind === 'internal') {
|
|
1117
|
+
this.admittedInternalOperations -= 1;
|
|
1118
|
+
}
|
|
1119
|
+
if (this.activeOperation === queuedOperation) {
|
|
1120
|
+
this.activeOperation = undefined;
|
|
1121
|
+
}
|
|
1122
|
+
this.operationRunning = false;
|
|
1123
|
+
if (
|
|
1124
|
+
this.status === 'stopped'
|
|
1125
|
+
&& !this.operationQueue.some((operation) => operation.kind === 'shutdown')
|
|
1126
|
+
) {
|
|
1127
|
+
this.normalStopRequested = false;
|
|
1128
|
+
}
|
|
1129
|
+
this.drainOperationQueue();
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
private cancelQueuedOperations(error: Error): void {
|
|
1134
|
+
for (const queuedOperation of this.operationQueue.splice(0)) {
|
|
1135
|
+
queuedOperation.controller.abort(error);
|
|
1136
|
+
if (queuedOperation.kind === 'public') {
|
|
1137
|
+
this.admittedPublicOperations -= 1;
|
|
1138
|
+
} else if (queuedOperation.kind === 'internal') {
|
|
1139
|
+
this.admittedInternalOperations -= 1;
|
|
1140
|
+
}
|
|
1141
|
+
queuedOperation.reject(error);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
private beginShutdown(abortActiveOperation: boolean): void {
|
|
1146
|
+
if (this.status !== 'stopped' && this.status !== 'stopping') {
|
|
1147
|
+
this.status = 'stopping';
|
|
1148
|
+
this.emitState();
|
|
1149
|
+
}
|
|
1150
|
+
const shutdownError = new Error('LiveBrowserSession is stopping');
|
|
1151
|
+
if (
|
|
1152
|
+
abortActiveOperation
|
|
1153
|
+
&& this.activeOperation
|
|
1154
|
+
&& this.activeOperation.kind !== 'shutdown'
|
|
1155
|
+
) {
|
|
1156
|
+
this.activeOperation.controller.abort(shutdownError);
|
|
1157
|
+
}
|
|
1158
|
+
if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
|
|
1159
|
+
this.browserLifetimeController.abort(shutdownError);
|
|
1160
|
+
}
|
|
1161
|
+
this.cancelQueuedOperations(shutdownError);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
private requestShutdown(error?: ILiveBrowserError): Promise<void> {
|
|
1165
|
+
if (this.status === 'stopped') {
|
|
1166
|
+
return Promise.resolve();
|
|
1167
|
+
}
|
|
1168
|
+
if (this.shutdownPromise) {
|
|
1169
|
+
return this.shutdownPromise;
|
|
1170
|
+
}
|
|
1171
|
+
this.beginShutdown(true);
|
|
1172
|
+
const shutdownPromise = this.enqueueQueuedOperation(
|
|
1173
|
+
'shutdown',
|
|
1174
|
+
async () => this.stopInternal(error),
|
|
1175
|
+
true,
|
|
1176
|
+
);
|
|
1177
|
+
this.shutdownPromise = shutdownPromise;
|
|
1178
|
+
void shutdownPromise.then(
|
|
1179
|
+
() => {
|
|
1180
|
+
if (this.shutdownPromise === shutdownPromise) {
|
|
1181
|
+
this.shutdownPromise = undefined;
|
|
1182
|
+
}
|
|
1183
|
+
},
|
|
1184
|
+
() => {
|
|
1185
|
+
if (this.shutdownPromise === shutdownPromise) {
|
|
1186
|
+
this.shutdownPromise = undefined;
|
|
1187
|
+
}
|
|
1188
|
+
},
|
|
1189
|
+
);
|
|
1190
|
+
return shutdownPromise;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
private scheduleOperation(
|
|
1194
|
+
operation: (signal: AbortSignal) => Promise<void>,
|
|
1195
|
+
errorCode: string,
|
|
1196
|
+
tabId?: string,
|
|
1197
|
+
priority = false,
|
|
1198
|
+
): boolean {
|
|
1199
|
+
if (this.admittedInternalOperations >= maxQueuedInternalOperations) {
|
|
1200
|
+
const queueError = new Error('LiveBrowserSession internal operation queue is full');
|
|
1201
|
+
const liveBrowserError: ILiveBrowserError = {
|
|
1202
|
+
code: 'internal_operation_queue_full',
|
|
1203
|
+
message: queueError.message,
|
|
1204
|
+
fatal: priority,
|
|
1205
|
+
...(tabId ? { tabId } : {}),
|
|
1206
|
+
};
|
|
1207
|
+
this.emitError(liveBrowserError);
|
|
1208
|
+
if (priority) {
|
|
1209
|
+
void this.requestShutdown(liveBrowserError).catch((error) => {
|
|
1210
|
+
this.emitError({
|
|
1211
|
+
code: 'queue_overflow_shutdown_failed',
|
|
1212
|
+
message: normalizeErrorMessage(error),
|
|
1213
|
+
fatal: true,
|
|
1214
|
+
});
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
return false;
|
|
1218
|
+
}
|
|
1219
|
+
void this.enqueueInternalOperation(operation, priority).catch((error) => {
|
|
1220
|
+
if (
|
|
1221
|
+
this.status === 'stopped'
|
|
1222
|
+
|| this.status === 'stopping'
|
|
1223
|
+
|| this.normalStopRequested
|
|
1224
|
+
) {
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
this.emitError({
|
|
1228
|
+
code: errorCode,
|
|
1229
|
+
message: normalizeErrorMessage(error),
|
|
1230
|
+
fatal: false,
|
|
1231
|
+
...(tabId ? { tabId } : {}),
|
|
1232
|
+
});
|
|
1233
|
+
});
|
|
1234
|
+
return true;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
private emitEvent(event: TLiveBrowserEvent): void {
|
|
1238
|
+
for (const listener of [...this.eventListeners]) {
|
|
1239
|
+
try {
|
|
1240
|
+
listener(event);
|
|
1241
|
+
} catch {
|
|
1242
|
+
// A consumer listener must not interrupt browser lifecycle cleanup.
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
private emitState(): void {
|
|
1248
|
+
this.emitEvent({
|
|
1249
|
+
type: 'state',
|
|
1250
|
+
state: this.getState(),
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
private emitError(error: ILiveBrowserError): void {
|
|
1255
|
+
if (error.fatal) {
|
|
1256
|
+
this.lastError = { ...error };
|
|
1257
|
+
}
|
|
1258
|
+
this.emitEvent({
|
|
1259
|
+
type: 'error',
|
|
1260
|
+
error: { ...error },
|
|
1261
|
+
});
|
|
1262
|
+
this.emitState();
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
private createTabState(tab: IPrivateLiveBrowserTab): ILiveBrowserTabState {
|
|
1266
|
+
return {
|
|
1267
|
+
id: tab.id,
|
|
1268
|
+
url: tab.url,
|
|
1269
|
+
title: tab.title,
|
|
1270
|
+
active: this.activeTabId === tab.id,
|
|
1271
|
+
status: tab.status,
|
|
1272
|
+
generation: tab.generation,
|
|
1273
|
+
appliedViewportRevision: tab.appliedViewportRevision,
|
|
1274
|
+
streaming: tab.streaming,
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
private async stopInternal(error?: ILiveBrowserError): Promise<void> {
|
|
1279
|
+
if (this.status === 'stopped') {
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
this.status = 'stopping';
|
|
1283
|
+
if (error) {
|
|
1284
|
+
this.lastError = { ...error };
|
|
1285
|
+
}
|
|
1286
|
+
this.emitState();
|
|
1287
|
+
if (this.browserLifetimeController && !this.browserLifetimeController.signal.aborted) {
|
|
1288
|
+
this.browserLifetimeController.abort(new Error('LiveBrowserSession is stopping'));
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const browser = this.browser;
|
|
1292
|
+
if (browser && this.browserDisconnectedListener) {
|
|
1293
|
+
browser.off('disconnected', this.browserDisconnectedListener);
|
|
1294
|
+
}
|
|
1295
|
+
this.browserDisconnectedListener = undefined;
|
|
1296
|
+
|
|
1297
|
+
const shutdownErrors: unknown[] = [];
|
|
1298
|
+
for (const tab of [...this.tabs.values()]) {
|
|
1299
|
+
tab.closing = true;
|
|
1300
|
+
try {
|
|
1301
|
+
await this.stopScreencast(tab);
|
|
1302
|
+
} catch (cleanupError) {
|
|
1303
|
+
shutdownErrors.push(cleanupError);
|
|
1304
|
+
}
|
|
1305
|
+
try {
|
|
1306
|
+
this.removePageListeners(tab);
|
|
1307
|
+
} catch (cleanupError) {
|
|
1308
|
+
shutdownErrors.push(cleanupError);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
try {
|
|
1312
|
+
await this.retireOutstandingFrames(() => true);
|
|
1313
|
+
} catch (cleanupError) {
|
|
1314
|
+
shutdownErrors.push(cleanupError);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
if (browser) {
|
|
1318
|
+
try {
|
|
1319
|
+
await browser.close();
|
|
1320
|
+
} catch (closeError) {
|
|
1321
|
+
shutdownErrors.push(closeError);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
for (const tab of this.tabs.values()) {
|
|
1326
|
+
this.tabIdsByPage.delete(tab.page);
|
|
1327
|
+
}
|
|
1328
|
+
this.tabs.clear();
|
|
1329
|
+
this.outstandingFrames.clear();
|
|
1330
|
+
this.browser = undefined;
|
|
1331
|
+
this.browserContext = undefined;
|
|
1332
|
+
this.browserLifetimeController = undefined;
|
|
1333
|
+
this.activeTabId = null;
|
|
1334
|
+
this.status = 'stopped';
|
|
1335
|
+
this.emitState();
|
|
1336
|
+
if (shutdownErrors.length > 0 && !error) {
|
|
1337
|
+
throw new AggregateError(shutdownErrors, 'LiveBrowserSession shutdown was incomplete');
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
private requireBrowserContext(): plugins.puppeteer.BrowserContext {
|
|
1342
|
+
if (this.status !== 'running' || !this.browserContext) {
|
|
1343
|
+
throw new Error('LiveBrowserSession is not running');
|
|
1344
|
+
}
|
|
1345
|
+
return this.browserContext;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
private requireTab(tabId: string): IPrivateLiveBrowserTab {
|
|
1349
|
+
validateBoundedString(tabId, 'tabId', 1, 128);
|
|
1350
|
+
const tab = this.tabs.get(tabId);
|
|
1351
|
+
if (!tab) {
|
|
1352
|
+
throw new Error(`Unknown tab: ${tabId}`);
|
|
1353
|
+
}
|
|
1354
|
+
return tab;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
private requireActiveTab(): IPrivateLiveBrowserTab {
|
|
1358
|
+
if (this.status !== 'running' || !this.activeTabId) {
|
|
1359
|
+
throw new Error('LiveBrowserSession has no active tab');
|
|
1360
|
+
}
|
|
1361
|
+
const tab = this.requireTab(this.activeTabId);
|
|
1362
|
+
if (tab.status !== 'open' || tab.page.isClosed()) {
|
|
1363
|
+
throw new Error(`Active tab is not available: ${tab.id}`);
|
|
1364
|
+
}
|
|
1365
|
+
return tab;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
private resolveActionTab(tabId?: string): IPrivateLiveBrowserTab {
|
|
1369
|
+
if (this.status !== 'running') {
|
|
1370
|
+
throw new Error('LiveBrowserSession is not running');
|
|
1371
|
+
}
|
|
1372
|
+
const tab = tabId ? this.requireTab(tabId) : this.requireActiveTab();
|
|
1373
|
+
if (tab.status !== 'open' || tab.page.isClosed()) {
|
|
1374
|
+
throw new Error(`Tab is not available: ${tab.id}`);
|
|
1375
|
+
}
|
|
1376
|
+
return tab;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
private resolveNavigationTab(tabId?: string): IPrivateLiveBrowserTab {
|
|
1380
|
+
return this.resolveActionTab(tabId);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
private async ensureTabViewport(tab: IPrivateLiveBrowserTab): Promise<void> {
|
|
1384
|
+
if (tab.appliedViewportRevision === this.viewportRevision) {
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
const shouldRestartScreencast = this.activeTabId === tab.id && tab.streaming;
|
|
1388
|
+
if (shouldRestartScreencast) {
|
|
1389
|
+
await this.stopScreencast(tab);
|
|
1390
|
+
}
|
|
1391
|
+
try {
|
|
1392
|
+
await tab.page.setViewport(createPuppeteerViewport(this.viewport));
|
|
1393
|
+
tab.appliedViewportRevision = this.viewportRevision;
|
|
1394
|
+
} finally {
|
|
1395
|
+
if (
|
|
1396
|
+
shouldRestartScreencast
|
|
1397
|
+
&& this.status === 'running'
|
|
1398
|
+
&& this.activeTabId === tab.id
|
|
1399
|
+
&& tab.status === 'open'
|
|
1400
|
+
&& !tab.page.isClosed()
|
|
1401
|
+
) {
|
|
1402
|
+
await this.startScreencast(tab);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
private async registerPage(page: plugins.puppeteer.Page): Promise<IPrivateLiveBrowserTab> {
|
|
1408
|
+
const existingTabId = this.tabIdsByPage.get(page);
|
|
1409
|
+
if (existingTabId) {
|
|
1410
|
+
return this.requireTab(existingTabId);
|
|
1411
|
+
}
|
|
1412
|
+
if (page.isClosed()) {
|
|
1413
|
+
throw new Error('Cannot register a closed page');
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const tab: IPrivateLiveBrowserTab = {
|
|
1417
|
+
id: `tab-${++this.tabSequence}`,
|
|
1418
|
+
page,
|
|
1419
|
+
url: truncate(page.url(), 4096),
|
|
1420
|
+
title: '',
|
|
1421
|
+
status: 'open',
|
|
1422
|
+
generation: 0,
|
|
1423
|
+
appliedViewportRevision: 0,
|
|
1424
|
+
streaming: false,
|
|
1425
|
+
streamInvalidated: true,
|
|
1426
|
+
navigationInProgress: false,
|
|
1427
|
+
closing: false,
|
|
1428
|
+
stateUpdateQueued: false,
|
|
1429
|
+
stateUpdatePending: false,
|
|
1430
|
+
navigationResetPending: false,
|
|
1431
|
+
removeListeners: [],
|
|
1432
|
+
};
|
|
1433
|
+
this.tabs.set(tab.id, tab);
|
|
1434
|
+
this.tabIdsByPage.set(page, tab.id);
|
|
1435
|
+
try {
|
|
1436
|
+
await this.ensureTabViewport(tab);
|
|
1437
|
+
await this.refreshTab(tab);
|
|
1438
|
+
|
|
1439
|
+
const onPopup = (popup: plugins.puppeteer.Page | null): void => {
|
|
1440
|
+
if (
|
|
1441
|
+
!popup
|
|
1442
|
+
|| this.normalStopRequested
|
|
1443
|
+
|| this.status === 'stopped'
|
|
1444
|
+
|| this.status === 'stopping'
|
|
1445
|
+
) {
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
const popupWasScheduled = this.scheduleOperation(async () => {
|
|
1449
|
+
if (popup.isClosed()) {
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const previousActiveTabId = this.activeTabId;
|
|
1453
|
+
let popupTab: IPrivateLiveBrowserTab | undefined;
|
|
1454
|
+
try {
|
|
1455
|
+
popupTab = await this.registerPage(popup);
|
|
1456
|
+
await this.activateTabInternal(popupTab.id);
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
const rollbackError = await this.rollbackCreatedPage(
|
|
1459
|
+
popup,
|
|
1460
|
+
popupTab,
|
|
1461
|
+
previousActiveTabId,
|
|
1462
|
+
);
|
|
1463
|
+
if (rollbackError) {
|
|
1464
|
+
throw new AggregateError([error, rollbackError], 'Popup registration rollback failed');
|
|
1465
|
+
}
|
|
1466
|
+
throw error;
|
|
1467
|
+
}
|
|
1468
|
+
}, 'popup_registration_failed', tab.id);
|
|
1469
|
+
if (!popupWasScheduled) {
|
|
1470
|
+
const popupError: ILiveBrowserError = {
|
|
1471
|
+
code: 'popup_registration_capacity_exceeded',
|
|
1472
|
+
message: 'A popup could not be admitted to the internal operation queue',
|
|
1473
|
+
fatal: true,
|
|
1474
|
+
tabId: tab.id,
|
|
1475
|
+
};
|
|
1476
|
+
this.emitError(popupError);
|
|
1477
|
+
void this.requestShutdown(popupError).catch((shutdownError) => {
|
|
1478
|
+
this.emitError({
|
|
1479
|
+
code: 'untracked_popup_shutdown_failed',
|
|
1480
|
+
message: normalizeErrorMessage(shutdownError),
|
|
1481
|
+
fatal: true,
|
|
1482
|
+
tabId: tab.id,
|
|
1483
|
+
});
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
const onFrameNavigated = (frame: plugins.puppeteer.Frame): void => {
|
|
1488
|
+
if (frame !== page.mainFrame() || tab.closing) {
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
const navigationReset = !tab.navigationInProgress;
|
|
1492
|
+
if (navigationReset) {
|
|
1493
|
+
tab.streamInvalidated = true;
|
|
1494
|
+
this.retireFramesForTabInBackground(tab.id);
|
|
1495
|
+
}
|
|
1496
|
+
this.requestTabStateUpdate(tab, navigationReset);
|
|
1497
|
+
};
|
|
1498
|
+
const onLoad = (): void => {
|
|
1499
|
+
this.requestTabStateUpdate(tab, false);
|
|
1500
|
+
};
|
|
1501
|
+
const onClose = (): void => {
|
|
1502
|
+
tab.streamInvalidated = true;
|
|
1503
|
+
this.retireFramesForTabInBackground(tab.id);
|
|
1504
|
+
if (tab.closing || this.normalStopRequested) {
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
this.scheduleOperation(async () => {
|
|
1508
|
+
await this.handleUnexpectedPageClose(tab);
|
|
1509
|
+
}, 'page_close_cleanup_failed', tab.id, true);
|
|
1510
|
+
};
|
|
1511
|
+
const onCrash = (error: Error): void => {
|
|
1512
|
+
tab.streamInvalidated = true;
|
|
1513
|
+
this.retireFramesForTabInBackground(tab.id);
|
|
1514
|
+
if (tab.closing || this.normalStopRequested) {
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
this.scheduleOperation(async () => {
|
|
1518
|
+
if (!this.tabs.has(tab.id) || tab.status === 'crashed') {
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
await this.stopScreencast(tab);
|
|
1522
|
+
tab.status = 'crashed';
|
|
1523
|
+
const wasActive = this.activeTabId === tab.id;
|
|
1524
|
+
if (wasActive) {
|
|
1525
|
+
this.activeTabId = null;
|
|
1526
|
+
}
|
|
1527
|
+
this.emitError({
|
|
1528
|
+
code: 'page_crashed',
|
|
1529
|
+
message: normalizeErrorMessage(error),
|
|
1530
|
+
fatal: false,
|
|
1531
|
+
tabId: tab.id,
|
|
1532
|
+
});
|
|
1533
|
+
if (wasActive) {
|
|
1534
|
+
await this.activateReplacementTab();
|
|
1535
|
+
}
|
|
1536
|
+
}, 'page_crash_cleanup_failed', tab.id, true);
|
|
1537
|
+
};
|
|
1538
|
+
const onPageError = (error: unknown): void => {
|
|
1539
|
+
if (tab.closing || this.normalStopRequested) {
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
this.emitError({
|
|
1543
|
+
code: 'page_error',
|
|
1544
|
+
message: normalizeErrorMessage(error),
|
|
1545
|
+
fatal: false,
|
|
1546
|
+
tabId: tab.id,
|
|
1547
|
+
});
|
|
1548
|
+
};
|
|
1549
|
+
|
|
1550
|
+
page.on('popup', onPopup);
|
|
1551
|
+
page.on('framenavigated', onFrameNavigated);
|
|
1552
|
+
page.on('load', onLoad);
|
|
1553
|
+
page.on('close', onClose);
|
|
1554
|
+
page.on('error', onCrash);
|
|
1555
|
+
page.on('pageerror', onPageError);
|
|
1556
|
+
tab.removeListeners.push(
|
|
1557
|
+
() => page.off('popup', onPopup),
|
|
1558
|
+
() => page.off('framenavigated', onFrameNavigated),
|
|
1559
|
+
() => page.off('load', onLoad),
|
|
1560
|
+
() => page.off('close', onClose),
|
|
1561
|
+
() => page.off('error', onCrash),
|
|
1562
|
+
() => page.off('pageerror', onPageError),
|
|
1563
|
+
);
|
|
1564
|
+
return tab;
|
|
1565
|
+
} catch (error) {
|
|
1566
|
+
this.removePageListeners(tab);
|
|
1567
|
+
this.tabs.delete(tab.id);
|
|
1568
|
+
this.tabIdsByPage.delete(page);
|
|
1569
|
+
throw error;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
private removePageListeners(tab: IPrivateLiveBrowserTab): void {
|
|
1574
|
+
for (const removeListener of tab.removeListeners.splice(0)) {
|
|
1575
|
+
removeListener();
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
private retireFramesForTabInBackground(tabId: string): void {
|
|
1580
|
+
void this.retireOutstandingFrames((frame) => frame.tabId === tabId).catch((error) => {
|
|
1581
|
+
if (this.status === 'running' && !this.normalStopRequested) {
|
|
1582
|
+
this.emitError({
|
|
1583
|
+
code: 'frame_retirement_failed',
|
|
1584
|
+
message: normalizeErrorMessage(error),
|
|
1585
|
+
fatal: false,
|
|
1586
|
+
tabId,
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
private requestTabStateUpdate(
|
|
1593
|
+
tab: IPrivateLiveBrowserTab,
|
|
1594
|
+
navigationReset: boolean,
|
|
1595
|
+
): void {
|
|
1596
|
+
if (!this.tabs.has(tab.id) || tab.closing) {
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
tab.stateUpdatePending = true;
|
|
1600
|
+
tab.navigationResetPending ||= navigationReset;
|
|
1601
|
+
if (tab.stateUpdateQueued) {
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
tab.stateUpdateQueued = true;
|
|
1605
|
+
const wasScheduled = this.scheduleOperation(async () => {
|
|
1606
|
+
try {
|
|
1607
|
+
const shouldResetNavigation = tab.navigationResetPending;
|
|
1608
|
+
tab.stateUpdatePending = false;
|
|
1609
|
+
tab.navigationResetPending = false;
|
|
1610
|
+
if (!this.tabs.has(tab.id) || tab.closing) {
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (shouldResetNavigation && this.activeTabId === tab.id) {
|
|
1614
|
+
await this.stopScreencast(tab);
|
|
1615
|
+
}
|
|
1616
|
+
await this.refreshTab(tab);
|
|
1617
|
+
this.emitState();
|
|
1618
|
+
if (
|
|
1619
|
+
shouldResetNavigation
|
|
1620
|
+
&& this.activeTabId === tab.id
|
|
1621
|
+
&& this.status === 'running'
|
|
1622
|
+
&& tab.status === 'open'
|
|
1623
|
+
) {
|
|
1624
|
+
await this.startScreencast(tab);
|
|
1625
|
+
}
|
|
1626
|
+
} finally {
|
|
1627
|
+
tab.stateUpdateQueued = false;
|
|
1628
|
+
if (
|
|
1629
|
+
tab.stateUpdatePending
|
|
1630
|
+
&& this.tabs.has(tab.id)
|
|
1631
|
+
&& this.status === 'running'
|
|
1632
|
+
&& !tab.closing
|
|
1633
|
+
) {
|
|
1634
|
+
this.requestTabStateUpdate(tab, false);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
}, 'page_state_update_failed', tab.id);
|
|
1638
|
+
if (!wasScheduled) {
|
|
1639
|
+
tab.stateUpdateQueued = false;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
private async rollbackCreatedPage(
|
|
1644
|
+
page: plugins.puppeteer.Page,
|
|
1645
|
+
tab: IPrivateLiveBrowserTab | undefined,
|
|
1646
|
+
previousActiveTabId: string | null,
|
|
1647
|
+
): Promise<Error | undefined> {
|
|
1648
|
+
const rollbackErrors: unknown[] = [];
|
|
1649
|
+
if (tab) {
|
|
1650
|
+
try {
|
|
1651
|
+
await this.stopScreencast(tab);
|
|
1652
|
+
} catch (error) {
|
|
1653
|
+
rollbackErrors.push(error);
|
|
1654
|
+
}
|
|
1655
|
+
tab.closing = true;
|
|
1656
|
+
}
|
|
1657
|
+
try {
|
|
1658
|
+
if (!page.isClosed()) {
|
|
1659
|
+
await page.close();
|
|
1660
|
+
}
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
rollbackErrors.push(error);
|
|
1663
|
+
}
|
|
1664
|
+
const pageClosed = page.isClosed();
|
|
1665
|
+
if (tab) {
|
|
1666
|
+
if (pageClosed) {
|
|
1667
|
+
this.removePageListeners(tab);
|
|
1668
|
+
this.tabs.delete(tab.id);
|
|
1669
|
+
this.tabIdsByPage.delete(tab.page);
|
|
1670
|
+
} else {
|
|
1671
|
+
tab.closing = false;
|
|
1672
|
+
try {
|
|
1673
|
+
await this.refreshTab(tab);
|
|
1674
|
+
} catch (error) {
|
|
1675
|
+
rollbackErrors.push(error);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
} else {
|
|
1679
|
+
this.tabIdsByPage.delete(page);
|
|
1680
|
+
if (!pageClosed) {
|
|
1681
|
+
const rollbackError: ILiveBrowserError = {
|
|
1682
|
+
code: 'untracked_page_rollback_failed',
|
|
1683
|
+
message: 'A failed page registration could not close its Chromium page',
|
|
1684
|
+
fatal: true,
|
|
1685
|
+
};
|
|
1686
|
+
if (!this.normalStopRequested && this.status === 'running') {
|
|
1687
|
+
this.emitError(rollbackError);
|
|
1688
|
+
}
|
|
1689
|
+
this.normalStopRequested = true;
|
|
1690
|
+
this.beginShutdown(false);
|
|
1691
|
+
try {
|
|
1692
|
+
await this.stopInternal();
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
rollbackErrors.push(error);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
const previousActiveTab = previousActiveTabId
|
|
1700
|
+
? this.tabs.get(previousActiveTabId)
|
|
1701
|
+
: undefined;
|
|
1702
|
+
this.activeTabId = previousActiveTab?.id ?? null;
|
|
1703
|
+
if (
|
|
1704
|
+
previousActiveTab
|
|
1705
|
+
&& previousActiveTab.status === 'open'
|
|
1706
|
+
&& !previousActiveTab.page.isClosed()
|
|
1707
|
+
) {
|
|
1708
|
+
try {
|
|
1709
|
+
await this.ensureTabViewport(previousActiveTab);
|
|
1710
|
+
await previousActiveTab.page.bringToFront();
|
|
1711
|
+
if (this.status === 'running' && !previousActiveTab.streaming) {
|
|
1712
|
+
await this.startScreencast(previousActiveTab);
|
|
1713
|
+
}
|
|
1714
|
+
} catch (error) {
|
|
1715
|
+
rollbackErrors.push(error);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
this.emitState();
|
|
1719
|
+
if (rollbackErrors.length === 0) {
|
|
1720
|
+
return undefined;
|
|
1721
|
+
}
|
|
1722
|
+
return new AggregateError(rollbackErrors, 'Failed to roll back a new tab');
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
private async handleUnexpectedPageClose(tab: IPrivateLiveBrowserTab): Promise<void> {
|
|
1726
|
+
if (!this.tabs.has(tab.id)) {
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
await this.stopScreencast(tab);
|
|
1730
|
+
this.removePageListeners(tab);
|
|
1731
|
+
this.tabs.delete(tab.id);
|
|
1732
|
+
this.tabIdsByPage.delete(tab.page);
|
|
1733
|
+
|
|
1734
|
+
if (this.activeTabId === tab.id) {
|
|
1735
|
+
this.activeTabId = null;
|
|
1736
|
+
await this.activateReplacementTab();
|
|
1737
|
+
} else {
|
|
1738
|
+
this.emitState();
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
private async activateTabInternal(tabId: string): Promise<void> {
|
|
1743
|
+
const tab = this.requireTab(tabId);
|
|
1744
|
+
if (tab.status !== 'open' || tab.page.isClosed()) {
|
|
1745
|
+
throw new Error(`Tab is not available: ${tab.id}`);
|
|
1746
|
+
}
|
|
1747
|
+
if (this.activeTabId === tab.id && tab.streaming) {
|
|
1748
|
+
await tab.page.bringToFront();
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
const currentTab = this.activeTabId ? this.tabs.get(this.activeTabId) : undefined;
|
|
1753
|
+
if (currentTab) {
|
|
1754
|
+
await this.stopScreencast(currentTab);
|
|
1755
|
+
}
|
|
1756
|
+
try {
|
|
1757
|
+
await this.ensureTabViewport(tab);
|
|
1758
|
+
await tab.page.bringToFront();
|
|
1759
|
+
this.activeTabId = tab.id;
|
|
1760
|
+
this.emitState();
|
|
1761
|
+
if (this.status === 'running') {
|
|
1762
|
+
await this.startScreencast(tab);
|
|
1763
|
+
}
|
|
1764
|
+
} catch (error) {
|
|
1765
|
+
if (currentTab && this.tabs.has(currentTab.id) && !currentTab.page.isClosed()) {
|
|
1766
|
+
this.activeTabId = currentTab.id;
|
|
1767
|
+
await this.ensureTabViewport(currentTab);
|
|
1768
|
+
await currentTab.page.bringToFront();
|
|
1769
|
+
if (this.status === 'running' && !currentTab.streaming) {
|
|
1770
|
+
await this.startScreencast(currentTab);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
this.emitState();
|
|
1774
|
+
throw error;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
private async activateReplacementTab(): Promise<void> {
|
|
1779
|
+
const attemptedTabIds = new Set<string>();
|
|
1780
|
+
while (true) {
|
|
1781
|
+
const replacementTab = [...this.tabs.values()].find((tab) => (
|
|
1782
|
+
!attemptedTabIds.has(tab.id)
|
|
1783
|
+
&& tab.status === 'open'
|
|
1784
|
+
&& !tab.page.isClosed()
|
|
1785
|
+
));
|
|
1786
|
+
if (!replacementTab) {
|
|
1787
|
+
this.activeTabId = null;
|
|
1788
|
+
this.normalStopRequested = true;
|
|
1789
|
+
this.beginShutdown(false);
|
|
1790
|
+
await this.stopInternal();
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
attemptedTabIds.add(replacementTab.id);
|
|
1795
|
+
try {
|
|
1796
|
+
await this.activateTabInternal(replacementTab.id);
|
|
1797
|
+
return;
|
|
1798
|
+
} catch (error) {
|
|
1799
|
+
this.activeTabId = null;
|
|
1800
|
+
this.emitError({
|
|
1801
|
+
code: 'replacement_tab_activation_failed',
|
|
1802
|
+
message: normalizeErrorMessage(error),
|
|
1803
|
+
fatal: false,
|
|
1804
|
+
tabId: replacementTab.id,
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
private async navigateTab(
|
|
1811
|
+
tab: IPrivateLiveBrowserTab,
|
|
1812
|
+
navigation: () => Promise<void>,
|
|
1813
|
+
signal: AbortSignal,
|
|
1814
|
+
): Promise<void> {
|
|
1815
|
+
const isActive = this.activeTabId === tab.id;
|
|
1816
|
+
tab.navigationInProgress = true;
|
|
1817
|
+
if (isActive) {
|
|
1818
|
+
await this.stopScreencast(tab);
|
|
1819
|
+
}
|
|
1820
|
+
try {
|
|
1821
|
+
if (signal.aborted) {
|
|
1822
|
+
throw signal.reason;
|
|
1823
|
+
}
|
|
1824
|
+
await navigation();
|
|
1825
|
+
} finally {
|
|
1826
|
+
tab.navigationInProgress = false;
|
|
1827
|
+
if (this.tabs.has(tab.id) && !tab.page.isClosed()) {
|
|
1828
|
+
await this.refreshTab(tab);
|
|
1829
|
+
this.emitState();
|
|
1830
|
+
if (
|
|
1831
|
+
isActive
|
|
1832
|
+
&& this.activeTabId === tab.id
|
|
1833
|
+
&& this.status === 'running'
|
|
1834
|
+
&& tab.status === 'open'
|
|
1835
|
+
&& !signal.aborted
|
|
1836
|
+
) {
|
|
1837
|
+
await this.startScreencast(tab);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
private createPuppeteerNavigationOptions(
|
|
1844
|
+
options: ILiveBrowserNavigationOptions,
|
|
1845
|
+
signal: AbortSignal,
|
|
1846
|
+
): plugins.puppeteer.WaitForOptions {
|
|
1847
|
+
return {
|
|
1848
|
+
timeout: this.validateTimeout(options.timeoutMs, 30000),
|
|
1849
|
+
waitUntil: this.validateWaitUntil(options.waitUntil),
|
|
1850
|
+
signal,
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
private async refreshTab(tab: IPrivateLiveBrowserTab): Promise<void> {
|
|
1855
|
+
if (tab.page.isClosed()) {
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
tab.url = truncate(tab.page.url(), 4096);
|
|
1859
|
+
try {
|
|
1860
|
+
tab.title = truncate(await tab.page.title(), 1024);
|
|
1861
|
+
} catch (error) {
|
|
1862
|
+
if (!tab.page.isClosed() && !tab.closing) {
|
|
1863
|
+
throw error;
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
private async startScreencast(tab: IPrivateLiveBrowserTab): Promise<void> {
|
|
1869
|
+
if (
|
|
1870
|
+
this.status !== 'running'
|
|
1871
|
+
|| this.activeTabId !== tab.id
|
|
1872
|
+
|| tab.status !== 'open'
|
|
1873
|
+
|| tab.page.isClosed()
|
|
1874
|
+
|| tab.streaming
|
|
1875
|
+
) {
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
await this.ensureTabViewport(tab);
|
|
1880
|
+
|
|
1881
|
+
const cdpSession = await tab.page.createCDPSession();
|
|
1882
|
+
const generation = tab.generation + 1;
|
|
1883
|
+
const frameListener: TScreencastFrameListener = (event) => {
|
|
1884
|
+
this.handleScreencastFrame(tab, cdpSession, generation, event);
|
|
1885
|
+
};
|
|
1886
|
+
const cdpConnection = cdpSession.connection();
|
|
1887
|
+
const cdpSessionDetachedListener: TCdpSessionDetachedListener = (detachedSession) => {
|
|
1888
|
+
if (detachedSession !== cdpSession) {
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
this.handlePossibleCdpDisconnection(
|
|
1892
|
+
tab,
|
|
1893
|
+
cdpSession,
|
|
1894
|
+
new Error('The tab CDP session disconnected'),
|
|
1895
|
+
);
|
|
1896
|
+
};
|
|
1897
|
+
tab.cdpSession = cdpSession;
|
|
1898
|
+
tab.cdpConnection = cdpConnection;
|
|
1899
|
+
tab.screencastFrameListener = frameListener;
|
|
1900
|
+
tab.cdpSessionDetachedListener = cdpSessionDetachedListener;
|
|
1901
|
+
tab.generation = generation;
|
|
1902
|
+
tab.streaming = true;
|
|
1903
|
+
tab.streamInvalidated = false;
|
|
1904
|
+
cdpSession.on('Page.screencastFrame', frameListener);
|
|
1905
|
+
cdpConnection?.on(
|
|
1906
|
+
plugins.puppeteer.CDPSessionEvent.SessionDetached,
|
|
1907
|
+
cdpSessionDetachedListener,
|
|
1908
|
+
);
|
|
1909
|
+
|
|
1910
|
+
const format = this.options.screencast?.format ?? 'jpeg';
|
|
1911
|
+
try {
|
|
1912
|
+
await cdpSession.send('Page.startScreencast', {
|
|
1913
|
+
format,
|
|
1914
|
+
quality: this.options.screencast?.quality ?? 80,
|
|
1915
|
+
maxWidth: this.options.screencast?.maxWidth,
|
|
1916
|
+
maxHeight: this.options.screencast?.maxHeight,
|
|
1917
|
+
everyNthFrame: this.options.screencast?.everyNthFrame ?? 1,
|
|
1918
|
+
});
|
|
1919
|
+
this.emitState();
|
|
1920
|
+
} catch (error) {
|
|
1921
|
+
tab.streaming = false;
|
|
1922
|
+
tab.streamInvalidated = true;
|
|
1923
|
+
tab.cdpSession = undefined;
|
|
1924
|
+
tab.cdpConnection = undefined;
|
|
1925
|
+
tab.screencastFrameListener = undefined;
|
|
1926
|
+
tab.cdpSessionDetachedListener = undefined;
|
|
1927
|
+
cdpSession.off('Page.screencastFrame', frameListener);
|
|
1928
|
+
cdpConnection?.off(
|
|
1929
|
+
plugins.puppeteer.CDPSessionEvent.SessionDetached,
|
|
1930
|
+
cdpSessionDetachedListener,
|
|
1931
|
+
);
|
|
1932
|
+
if (!cdpSession.detached) {
|
|
1933
|
+
try {
|
|
1934
|
+
await cdpSession.detach();
|
|
1935
|
+
} catch {
|
|
1936
|
+
// The target may have closed while screencast startup was failing.
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
throw error;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
private async stopScreencast(tab: IPrivateLiveBrowserTab): Promise<void> {
|
|
1944
|
+
const cdpSession = tab.cdpSession;
|
|
1945
|
+
const cdpConnection = tab.cdpConnection;
|
|
1946
|
+
const frameListener = tab.screencastFrameListener;
|
|
1947
|
+
const cdpSessionDetachedListener = tab.cdpSessionDetachedListener;
|
|
1948
|
+
tab.streaming = false;
|
|
1949
|
+
tab.streamInvalidated = true;
|
|
1950
|
+
tab.cdpSession = undefined;
|
|
1951
|
+
tab.cdpConnection = undefined;
|
|
1952
|
+
tab.screencastFrameListener = undefined;
|
|
1953
|
+
tab.cdpSessionDetachedListener = undefined;
|
|
1954
|
+
await this.retireOutstandingFrames((frame) => frame.tabId === tab.id);
|
|
1955
|
+
if (!cdpSession) {
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
if (!cdpSession.detached) {
|
|
1960
|
+
try {
|
|
1961
|
+
await cdpSession.send('Page.stopScreencast');
|
|
1962
|
+
} catch {
|
|
1963
|
+
// Page close and browser disconnect detach the target before cleanup runs.
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
await this.retireOutstandingFrames((frame) => frame.tabId === tab.id);
|
|
1967
|
+
if (frameListener) {
|
|
1968
|
+
cdpSession.off('Page.screencastFrame', frameListener);
|
|
1969
|
+
}
|
|
1970
|
+
if (cdpConnection && cdpSessionDetachedListener) {
|
|
1971
|
+
cdpConnection.off(
|
|
1972
|
+
plugins.puppeteer.CDPSessionEvent.SessionDetached,
|
|
1973
|
+
cdpSessionDetachedListener,
|
|
1974
|
+
);
|
|
1975
|
+
}
|
|
1976
|
+
if (!cdpSession.detached) {
|
|
1977
|
+
try {
|
|
1978
|
+
await cdpSession.detach();
|
|
1979
|
+
} catch {
|
|
1980
|
+
// A concurrent page close can detach the session first.
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
private handleScreencastFrame(
|
|
1986
|
+
tab: IPrivateLiveBrowserTab,
|
|
1987
|
+
cdpSession: plugins.puppeteer.CDPSession,
|
|
1988
|
+
generation: number,
|
|
1989
|
+
event: TScreencastFrameEvent,
|
|
1990
|
+
): void {
|
|
1991
|
+
if (
|
|
1992
|
+
this.status !== 'running'
|
|
1993
|
+
|| this.activeTabId !== tab.id
|
|
1994
|
+
|| tab.status !== 'open'
|
|
1995
|
+
|| tab.streamInvalidated
|
|
1996
|
+
|| !tab.streaming
|
|
1997
|
+
|| tab.generation !== generation
|
|
1998
|
+
|| tab.appliedViewportRevision !== this.viewportRevision
|
|
1999
|
+
|| tab.cdpSession !== cdpSession
|
|
2000
|
+
) {
|
|
2001
|
+
this.acknowledgeCdpFrameInBackground({
|
|
2002
|
+
tabId: tab.id,
|
|
2003
|
+
generation,
|
|
2004
|
+
viewportRevision: this.viewportRevision,
|
|
2005
|
+
cdpSessionId: event.sessionId,
|
|
2006
|
+
cdpSession,
|
|
2007
|
+
});
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
const format = this.options.screencast?.format ?? 'jpeg';
|
|
2012
|
+
const data = new Uint8Array(plugins.Buffer.from(event.data, 'base64'));
|
|
2013
|
+
const dimensions = readImageDimensions(data, format, {
|
|
2014
|
+
width: Math.max(1, Math.round(event.metadata.deviceWidth)),
|
|
2015
|
+
height: Math.max(1, Math.round(event.metadata.deviceHeight)),
|
|
2016
|
+
});
|
|
2017
|
+
const sequence = ++this.frameSequence;
|
|
2018
|
+
const outstandingFrame: IOutstandingFrame = {
|
|
2019
|
+
tabId: tab.id,
|
|
2020
|
+
generation,
|
|
2021
|
+
viewportRevision: this.viewportRevision,
|
|
2022
|
+
cdpSessionId: event.sessionId,
|
|
2023
|
+
cdpSession,
|
|
2024
|
+
};
|
|
2025
|
+
while (this.outstandingFrames.size >= maxOutstandingFrames) {
|
|
2026
|
+
const oldestFrameEntry = this.outstandingFrames.entries().next().value as
|
|
2027
|
+
| [number, IOutstandingFrame]
|
|
2028
|
+
| undefined;
|
|
2029
|
+
if (!oldestFrameEntry) {
|
|
2030
|
+
break;
|
|
2031
|
+
}
|
|
2032
|
+
this.outstandingFrames.delete(oldestFrameEntry[0]);
|
|
2033
|
+
this.acknowledgeCdpFrameInBackground(oldestFrameEntry[1]);
|
|
2034
|
+
}
|
|
2035
|
+
this.outstandingFrames.set(sequence, outstandingFrame);
|
|
2036
|
+
const frame: ILiveBrowserFrame = {
|
|
2037
|
+
tabId: tab.id,
|
|
2038
|
+
sequence,
|
|
2039
|
+
generation,
|
|
2040
|
+
viewportRevision: this.viewportRevision,
|
|
2041
|
+
viewport: { ...this.viewport },
|
|
2042
|
+
format,
|
|
2043
|
+
mimeType: format === 'jpeg' ? 'image/jpeg' : 'image/png',
|
|
2044
|
+
...dimensions,
|
|
2045
|
+
metadata: {
|
|
2046
|
+
offsetTop: event.metadata.offsetTop,
|
|
2047
|
+
pageScaleFactor: event.metadata.pageScaleFactor,
|
|
2048
|
+
deviceWidth: event.metadata.deviceWidth,
|
|
2049
|
+
deviceHeight: event.metadata.deviceHeight,
|
|
2050
|
+
scrollOffsetX: event.metadata.scrollOffsetX,
|
|
2051
|
+
scrollOffsetY: event.metadata.scrollOffsetY,
|
|
2052
|
+
...(event.metadata.timestamp !== undefined
|
|
2053
|
+
? { timestamp: event.metadata.timestamp }
|
|
2054
|
+
: {}),
|
|
2055
|
+
},
|
|
2056
|
+
data,
|
|
2057
|
+
};
|
|
2058
|
+
this.emitEvent({ type: 'frame', frame });
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
private async acknowledgeCdpFrame(frame: IOutstandingFrame): Promise<boolean> {
|
|
2062
|
+
if (frame.cdpSession.detached) {
|
|
2063
|
+
this.handleFrameAcknowledgementFailure(
|
|
2064
|
+
frame,
|
|
2065
|
+
new Error('CDP session detached before frame acknowledgement'),
|
|
2066
|
+
);
|
|
2067
|
+
return false;
|
|
2068
|
+
}
|
|
2069
|
+
try {
|
|
2070
|
+
await frame.cdpSession.send('Page.screencastFrameAck', {
|
|
2071
|
+
sessionId: frame.cdpSessionId,
|
|
2072
|
+
});
|
|
2073
|
+
return true;
|
|
2074
|
+
} catch (error) {
|
|
2075
|
+
this.handleFrameAcknowledgementFailure(frame, error);
|
|
2076
|
+
return false;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
private acknowledgeCdpFrameInBackground(frame: IOutstandingFrame): void {
|
|
2081
|
+
void this.acknowledgeCdpFrame(frame);
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
private handleFrameAcknowledgementFailure(
|
|
2085
|
+
frame: IOutstandingFrame,
|
|
2086
|
+
error: unknown,
|
|
2087
|
+
): void {
|
|
2088
|
+
const tab = this.tabs.get(frame.tabId);
|
|
2089
|
+
const isCurrentStream = Boolean(
|
|
2090
|
+
tab
|
|
2091
|
+
&& tab.cdpSession === frame.cdpSession
|
|
2092
|
+
&& !tab.streamInvalidated,
|
|
2093
|
+
);
|
|
2094
|
+
if (
|
|
2095
|
+
isCurrentStream
|
|
2096
|
+
&& this.status === 'running'
|
|
2097
|
+
&& !this.normalStopRequested
|
|
2098
|
+
) {
|
|
2099
|
+
this.emitError({
|
|
2100
|
+
code: 'frame_acknowledgement_failed',
|
|
2101
|
+
message: normalizeErrorMessage(error),
|
|
2102
|
+
fatal: false,
|
|
2103
|
+
tabId: frame.tabId,
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
if (tab) {
|
|
2107
|
+
this.handlePossibleCdpDisconnection(tab, frame.cdpSession, error);
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
private async retireOutstandingFrames(
|
|
2112
|
+
predicate: (frame: IOutstandingFrame) => boolean,
|
|
2113
|
+
): Promise<void> {
|
|
2114
|
+
const acknowledgements: Array<Promise<boolean>> = [];
|
|
2115
|
+
for (const [sequence, frame] of this.outstandingFrames) {
|
|
2116
|
+
if (!predicate(frame)) {
|
|
2117
|
+
continue;
|
|
2118
|
+
}
|
|
2119
|
+
this.outstandingFrames.delete(sequence);
|
|
2120
|
+
acknowledgements.push(this.acknowledgeCdpFrame(frame));
|
|
2121
|
+
}
|
|
2122
|
+
await Promise.all(acknowledgements);
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
private handlePossibleCdpDisconnection(
|
|
2126
|
+
tab: IPrivateLiveBrowserTab,
|
|
2127
|
+
cdpSession: plugins.puppeteer.CDPSession,
|
|
2128
|
+
error: unknown,
|
|
2129
|
+
): void {
|
|
2130
|
+
if (
|
|
2131
|
+
this.normalStopRequested
|
|
2132
|
+
|| this.status !== 'running'
|
|
2133
|
+
|| tab.cdpSession !== cdpSession
|
|
2134
|
+
|| tab.streamInvalidated
|
|
2135
|
+
|| !cdpSession.detached
|
|
2136
|
+
) {
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
tab.streamInvalidated = true;
|
|
2140
|
+
this.retireFramesForTabInBackground(tab.id);
|
|
2141
|
+
this.scheduleOperation(async () => {
|
|
2142
|
+
if (!this.tabs.has(tab.id) || tab.cdpSession !== cdpSession) {
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
await this.stopScreencast(tab);
|
|
2146
|
+
tab.status = 'crashed';
|
|
2147
|
+
const wasActive = this.activeTabId === tab.id;
|
|
2148
|
+
if (wasActive) {
|
|
2149
|
+
this.activeTabId = null;
|
|
2150
|
+
}
|
|
2151
|
+
this.emitError({
|
|
2152
|
+
code: 'cdp_disconnected',
|
|
2153
|
+
message: normalizeErrorMessage(error),
|
|
2154
|
+
fatal: false,
|
|
2155
|
+
tabId: tab.id,
|
|
2156
|
+
});
|
|
2157
|
+
if (wasActive) {
|
|
2158
|
+
await this.activateReplacementTab();
|
|
2159
|
+
}
|
|
2160
|
+
}, 'cdp_disconnect_cleanup_failed', tab.id, true);
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
private requireRawInputTarget(
|
|
2164
|
+
input: { tabId: string; generation: number; viewportRevision: number },
|
|
2165
|
+
): { tab: IPrivateLiveBrowserTab; cdpSession: plugins.puppeteer.CDPSession } {
|
|
2166
|
+
const tab = this.requireInputTarget(input, true);
|
|
2167
|
+
const cdpSession = tab.cdpSession;
|
|
2168
|
+
if (!tab.streaming || tab.streamInvalidated || !cdpSession || cdpSession.detached) {
|
|
2169
|
+
throw new Error(`Tab input transport is not available: ${tab.id}`);
|
|
2170
|
+
}
|
|
2171
|
+
return { tab, cdpSession };
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
private requireSemanticActionTarget(
|
|
2175
|
+
input: { tabId: string; generation: number; viewportRevision: number },
|
|
2176
|
+
): IPrivateLiveBrowserTab {
|
|
2177
|
+
return this.requireInputTarget(input, false);
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
private requireInputTarget(
|
|
2181
|
+
input: { tabId: string; generation: number; viewportRevision: number },
|
|
2182
|
+
requireActive: boolean,
|
|
2183
|
+
): IPrivateLiveBrowserTab {
|
|
2184
|
+
if (this.status !== 'running') {
|
|
2185
|
+
throw new Error('LiveBrowserSession is not running');
|
|
2186
|
+
}
|
|
2187
|
+
const tab = this.requireTab(input.tabId);
|
|
2188
|
+
if (requireActive && this.activeTabId !== tab.id) {
|
|
2189
|
+
throw new Error(`Tab is not active: ${tab.id}`);
|
|
2190
|
+
}
|
|
2191
|
+
if (!Number.isInteger(input.generation) || input.generation !== tab.generation) {
|
|
2192
|
+
throw new Error(
|
|
2193
|
+
`Stale tab generation ${input.generation}; current generation is ${tab.generation}`,
|
|
2194
|
+
);
|
|
2195
|
+
}
|
|
2196
|
+
if (input.viewportRevision !== this.viewportRevision) {
|
|
2197
|
+
throw new Error(
|
|
2198
|
+
`Stale viewport revision ${input.viewportRevision}; current revision is ${this.viewportRevision}`,
|
|
2199
|
+
);
|
|
2200
|
+
}
|
|
2201
|
+
if (tab.status !== 'open' || tab.page.isClosed()) {
|
|
2202
|
+
throw new Error(`Tab is not available: ${tab.id}`);
|
|
2203
|
+
}
|
|
2204
|
+
return tab;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
private validateCoordinates(x: number, y: number): void {
|
|
2208
|
+
validateFiniteNumber(x, 'x', 0, this.viewport.width);
|
|
2209
|
+
validateFiniteNumber(y, 'y', 0, this.viewport.height);
|
|
2210
|
+
if (x >= this.viewport.width) {
|
|
2211
|
+
throw new Error(`x must be less than viewport width ${this.viewport.width}`);
|
|
2212
|
+
}
|
|
2213
|
+
if (y >= this.viewport.height) {
|
|
2214
|
+
throw new Error(`y must be less than viewport height ${this.viewport.height}`);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
private createModifierMask(modifiers?: ILiveBrowserModifierState): number {
|
|
2219
|
+
if (modifiers === undefined) {
|
|
2220
|
+
return 0;
|
|
2221
|
+
}
|
|
2222
|
+
if (!modifiers || typeof modifiers !== 'object' || Array.isArray(modifiers)) {
|
|
2223
|
+
throw new Error('modifiers must be an object');
|
|
2224
|
+
}
|
|
2225
|
+
const allowedKeys = new Set(['alt', 'control', 'meta', 'shift']);
|
|
2226
|
+
for (const key of Object.keys(modifiers)) {
|
|
2227
|
+
if (!allowedKeys.has(key)) {
|
|
2228
|
+
throw new Error(`Unknown modifier: ${key}`);
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
validateOptionalBoolean(modifiers.alt, 'modifiers.alt');
|
|
2232
|
+
validateOptionalBoolean(modifiers.control, 'modifiers.control');
|
|
2233
|
+
validateOptionalBoolean(modifiers.meta, 'modifiers.meta');
|
|
2234
|
+
validateOptionalBoolean(modifiers.shift, 'modifiers.shift');
|
|
2235
|
+
return (
|
|
2236
|
+
(modifiers.alt ? 1 : 0)
|
|
2237
|
+
| (modifiers.control ? 2 : 0)
|
|
2238
|
+
| (modifiers.meta ? 4 : 0)
|
|
2239
|
+
| (modifiers.shift ? 8 : 0)
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
private validateUrl(url: unknown): string {
|
|
2244
|
+
const validatedUrl = validateBoundedString(url, 'url', 1, maxUrlLength);
|
|
2245
|
+
try {
|
|
2246
|
+
new URL(validatedUrl);
|
|
2247
|
+
} catch {
|
|
2248
|
+
throw new Error('url must be absolute');
|
|
2249
|
+
}
|
|
2250
|
+
return validatedUrl;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
private validateTimeout(timeoutMs?: number, defaultValue = 5000): number {
|
|
2254
|
+
if (timeoutMs === undefined) {
|
|
2255
|
+
return defaultValue;
|
|
2256
|
+
}
|
|
2257
|
+
return validateInteger(timeoutMs, 'timeoutMs', 1, maxTimeoutMs);
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
private validateWaitUntil(waitUntil?: TLiveBrowserWaitUntil): TLiveBrowserWaitUntil {
|
|
2261
|
+
const validatedWaitUntil = waitUntil ?? 'load';
|
|
2262
|
+
if (!['load', 'domcontentloaded', 'networkidle0', 'networkidle2'].includes(validatedWaitUntil)) {
|
|
2263
|
+
throw new Error('waitUntil must be load, domcontentloaded, networkidle0, or networkidle2');
|
|
2264
|
+
}
|
|
2265
|
+
return validatedWaitUntil;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
private validateScreencastOptions(): void {
|
|
2269
|
+
const options = this.options.screencast;
|
|
2270
|
+
if (!options) {
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
if (options.format !== undefined && options.format !== 'jpeg' && options.format !== 'png') {
|
|
2274
|
+
throw new Error('screencast.format must be jpeg or png');
|
|
2275
|
+
}
|
|
2276
|
+
if (options.quality !== undefined) {
|
|
2277
|
+
validateInteger(options.quality, 'screencast.quality', 0, 100);
|
|
2278
|
+
}
|
|
2279
|
+
if (options.maxWidth !== undefined) {
|
|
2280
|
+
validateInteger(options.maxWidth, 'screencast.maxWidth', 1, maxViewportWidth);
|
|
2281
|
+
}
|
|
2282
|
+
if (options.maxHeight !== undefined) {
|
|
2283
|
+
validateInteger(options.maxHeight, 'screencast.maxHeight', 1, maxViewportHeight);
|
|
2284
|
+
}
|
|
2285
|
+
if (
|
|
2286
|
+
options.maxWidth !== undefined
|
|
2287
|
+
&& options.maxHeight !== undefined
|
|
2288
|
+
&& options.maxWidth * options.maxHeight > maxViewportPixelArea
|
|
2289
|
+
) {
|
|
2290
|
+
throw new Error(
|
|
2291
|
+
`screencast pixel area must not exceed ${maxViewportPixelArea} pixels`,
|
|
2292
|
+
);
|
|
2293
|
+
}
|
|
2294
|
+
if (options.everyNthFrame !== undefined) {
|
|
2295
|
+
validateInteger(options.everyNthFrame, 'screencast.everyNthFrame', 1, 100);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
}
|