Skip to content

Commit f075f89

Browse files
committed
feat(core): add screenshot capability
1 parent ce99453 commit f075f89

File tree

25 files changed

+375
-131
lines changed

25 files changed

+375
-131
lines changed

client/lib/Agent.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { IElementIsolate } from 'awaited-dom/base/interfaces/isolate';
1919
import CSSStyleDeclaration from 'awaited-dom/impl/official-klasses/CSSStyleDeclaration';
2020
import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';
2121
import IAgentMeta from '@secret-agent/core-interfaces/IAgentMeta';
22+
import IScreenshotOptions from '@secret-agent/core-interfaces/IScreenshotOptions';
2223
import WebsocketResource from './WebsocketResource';
2324
import IWaitForResourceFilter from '../interfaces/IWaitForResourceFilter';
2425
import Resource from './Resource';
@@ -256,6 +257,10 @@ export default class Agent extends AwaitedEventTarget<{ close: void }> {
256257
return this.activeTab.isElementVisible(element);
257258
}
258259

260+
public takeScreenshot(options?: IScreenshotOptions): Promise<Buffer> {
261+
return this.activeTab.takeScreenshot(options);
262+
}
263+
259264
public waitForPaintingStable(options?: IWaitForOptions): Promise<void> {
260265
return this.activeTab.waitForPaintingStable(options);
261266
}

client/lib/CoreTab.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import IAttachedState from 'awaited-dom/base/IAttachedState';
1313
import ISetCookieOptions from '@secret-agent/core-interfaces/ISetCookieOptions';
1414
import IConfigureSessionOptions from '@secret-agent/core-interfaces/IConfigureSessionOptions';
1515
import IWaitForOptions from '@secret-agent/core-interfaces/IWaitForOptions';
16+
import IScreenshotOptions from '@secret-agent/core-interfaces/IScreenshotOptions';
1617
import CoreCommandQueue from './CoreCommandQueue';
1718
import CoreEventHeap from './CoreEventHeap';
1819
import IWaitForResourceFilter from '../interfaces/IWaitForResourceFilter';
@@ -117,6 +118,10 @@ export default class CoreTab implements IJsPathEventTarget {
117118
return await this.commandQueue.run('removeCookie', name);
118119
}
119120

121+
public async takeScreenshot(options: IScreenshotOptions): Promise<Buffer> {
122+
return await this.commandQueue.run('takeScreenshot', options);
123+
}
124+
120125
public async isElementVisible(jsPath: IJsPath): Promise<boolean> {
121126
return await this.commandQueue.run('isElementVisible', jsPath);
122127
}

client/lib/Tab.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import IWaitForElementOptions from '@secret-agent/core-interfaces/IWaitForElemen
1919
import Response from 'awaited-dom/impl/official-klasses/Response';
2020
import IWaitForOptions from '@secret-agent/core-interfaces/IWaitForOptions';
2121
import { IElementIsolate } from 'awaited-dom/base/interfaces/isolate';
22+
import IScreenshotOptions from '@secret-agent/core-interfaces/IScreenshotOptions';
2223
import CoreTab from './CoreTab';
2324
import Resource, { createResource } from './Resource';
2425
import IWaitForResourceFilter from '../interfaces/IWaitForResourceFilter';
@@ -161,6 +162,11 @@ export default class Tab extends AwaitedEventTarget<IEventType> {
161162
return coreTab.isElementVisible(awaitedPath.toJSON());
162163
}
163164

165+
public async takeScreenshot(options?: IScreenshotOptions): Promise<Buffer> {
166+
const coreTab = await getCoreTab(this);
167+
return coreTab.takeScreenshot(options);
168+
}
169+
164170
public async waitForPaintingStable(options?: IWaitForOptions): Promise<void> {
165171
const coreTab = await getCoreTab(this);
166172
await coreTab.waitForLoad(LocationStatus.PaintingStable, options);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import IRect from './IRect';
2+
3+
export default interface IScreenshotOptions {
4+
format?: 'jpeg' | 'png';
5+
rectangle?: IRect & { scale: number };
6+
jpegQuality?: number;
7+
}

core/injected-scripts/pageEventsRecorder.ts

Lines changed: 67 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ class PageEventsRecorder {
168168
const time = new Date().toISOString();
169169
const event = [this.commandId, eventType, nodeId, relatedNodeId, time] as IFocusEvent;
170170
this.focusEvents.push(event);
171-
this.checkForPropertyChanges(time);
171+
this.getPropertyChanges(time, this.domChanges);
172172
}
173173

174174
public trackMouse(eventType: MouseEventType, mouseEvent: MouseEvent) {
@@ -206,59 +206,8 @@ class PageEventsRecorder {
206206
this.uploadChanges();
207207
}
208208

209-
public checkForLocationChange(changeTime?: string) {
210-
const timestamp = changeTime || new Date().toISOString();
211-
const currentLocation = window.self.location.href;
212-
if (this.location !== currentLocation) {
213-
this.location = currentLocation;
214-
this.domChanges.push([
215-
this.commandId,
216-
'location',
217-
{ id: -1, textContent: currentLocation },
218-
timestamp,
219-
idx(),
220-
]);
221-
}
222-
}
223-
224-
public checkForStylesheetChanges(changeTime?: string) {
225-
const timestamp = changeTime || new Date().toISOString();
226-
for (const [style, current] of this.stylesheets) {
227-
if (!style.sheet || !style.isConnected) continue;
228-
const sheet = style.sheet as CSSStyleSheet;
229-
const newPropValue = [...sheet.cssRules].map(x => x.cssText);
230-
if (newPropValue.toString() !== current.toString()) {
231-
const nodeId = nodeTracker.getId(style);
232-
this.domChanges.push([
233-
this.commandId,
234-
'property',
235-
{ id: nodeId, properties: { 'sheet.cssRules': newPropValue } },
236-
timestamp,
237-
idx(),
238-
]);
239-
this.stylesheets.set(style, newPropValue);
240-
}
241-
}
242-
}
243-
244-
public checkForPropertyChanges(changeTime?: string) {
245-
const timestamp = changeTime || new Date().toISOString();
246-
for (const [input, propertyMap] of this.propertyTrackingElements) {
247-
for (const [propertyName, value] of propertyMap) {
248-
const newPropValue = input[propertyName];
249-
if (newPropValue !== value) {
250-
const nodeId = nodeTracker.getId(input);
251-
this.domChanges.push([
252-
this.commandId,
253-
'property',
254-
{ id: nodeId, properties: { [propertyName]: newPropValue } },
255-
timestamp,
256-
idx(),
257-
]);
258-
propertyMap.set(propertyName, newPropValue);
259-
}
260-
}
261-
}
209+
public checkForAllPropertyChanges() {
210+
this.getPropertyChanges(new Date().toISOString(), this.domChanges);
262211
}
263212

264213
public get pageResultset(): PageRecorderResultSet {
@@ -291,15 +240,50 @@ class PageEventsRecorder {
291240
}
292241
}
293242

294-
private trackStylesheet(element: HTMLLinkElement | HTMLStyleElement) {
243+
private getLocationChange(changeTime: string, changes: IDomChangeEvent[]) {
244+
const timestamp = changeTime || new Date().toISOString();
245+
const currentLocation = window.self.location.href;
246+
if (this.location !== currentLocation) {
247+
this.location = currentLocation;
248+
changes.push([
249+
this.commandId,
250+
'location',
251+
{ id: -1, textContent: currentLocation },
252+
timestamp,
253+
idx(),
254+
]);
255+
}
256+
}
257+
258+
private getPropertyChanges(changeTime: string, changes: IDomChangeEvent[]) {
259+
const timestamp = changeTime;
260+
for (const [input, propertyMap] of this.propertyTrackingElements) {
261+
for (const [propertyName, value] of propertyMap) {
262+
const newPropValue = input[propertyName];
263+
if (newPropValue !== value) {
264+
const nodeId = nodeTracker.getId(input);
265+
changes.push([
266+
this.commandId,
267+
'property',
268+
{ id: nodeId, properties: { [propertyName]: newPropValue } },
269+
timestamp,
270+
idx(),
271+
]);
272+
propertyMap.set(propertyName, newPropValue);
273+
}
274+
}
275+
}
276+
}
277+
278+
private trackStylesheet(element: HTMLStyleElement) {
295279
if (!element || this.stylesheets.has(element)) return;
296280
if (!element.sheet) return;
297281

298-
const shouldRecordInitialStyle = element.textContent || element instanceof HTMLStyleElement;
282+
const shouldStoreCurrentStyleState = !!element.textContent;
299283
if (element.sheet instanceof CSSStyleSheet) {
300284
try {
301285
// if there's style text, record the current state
302-
const startingStyle = shouldRecordInitialStyle
286+
const startingStyle = shouldStoreCurrentStyleState
303287
? [...element.sheet.cssRules].map(x => x.cssText)
304288
: [];
305289
this.stylesheets.set(element, startingStyle);
@@ -309,6 +293,26 @@ class PageEventsRecorder {
309293
}
310294
}
311295

296+
private checkForStylesheetChanges(changeTime: string, changes: IDomChangeEvent[]) {
297+
const timestamp = changeTime || new Date().toISOString();
298+
for (const [style, current] of this.stylesheets) {
299+
if (!style.sheet || !style.isConnected) continue;
300+
const sheet = style.sheet as CSSStyleSheet;
301+
const newPropValue = [...sheet.cssRules].map(x => x.cssText);
302+
if (newPropValue.toString() !== current.toString()) {
303+
const nodeId = nodeTracker.getId(style);
304+
changes.push([
305+
this.commandId,
306+
'property',
307+
{ id: nodeId, properties: { 'sheet.cssRules': newPropValue } },
308+
timestamp,
309+
idx(),
310+
]);
311+
this.stylesheets.set(style, newPropValue);
312+
}
313+
}
314+
}
315+
312316
private onMutation(mutations: MutationRecord[]) {
313317
const changes = this.convertMutationsToChanges(mutations);
314318
this.domChanges.push(...changes);
@@ -319,8 +323,8 @@ class PageEventsRecorder {
319323
const currentCommandId = this.commandId;
320324
const stamp = new Date().toISOString();
321325

322-
this.checkForLocationChange(stamp);
323-
this.checkForPropertyChanges(stamp);
326+
this.getLocationChange(stamp, changes);
327+
this.getPropertyChanges(stamp, changes);
324328

325329
const addedNodeMap = new Map<Node, INodeData>();
326330
const removedNodes = new Set<Node>();
@@ -411,7 +415,7 @@ class PageEventsRecorder {
411415
}
412416
}
413417

414-
this.checkForStylesheetChanges(stamp);
418+
this.checkForStylesheetChanges(stamp, changes);
415419

416420
return changes;
417421
}
@@ -451,10 +455,6 @@ class PageEventsRecorder {
451455
}
452456
changes.push([currentCommandId, 'added', serial, stamp, idx()]);
453457
addedNodeMap.set(node, serial);
454-
const childRecords = this.serializeChildren(node, addedNodeMap);
455-
for (const childData of childRecords) {
456-
changes.push([currentCommandId, 'added', childData, stamp, idx()]);
457-
}
458458
return serial;
459459
}
460460

@@ -603,17 +603,17 @@ const perfObserver = new PerformanceObserver(() => {
603603
});
604604
perfObserver.observe({ type: 'largest-contentful-paint', buffered: true });
605605

606-
document.addEventListener('input', () => recorder.checkForPropertyChanges(), {
606+
document.addEventListener('input', () => recorder.checkForAllPropertyChanges(), {
607607
capture: true,
608608
passive: true,
609609
});
610610

611-
document.addEventListener('keydown', () => recorder.checkForPropertyChanges(), {
611+
document.addEventListener('keydown', () => recorder.checkForAllPropertyChanges(), {
612612
capture: true,
613613
passive: true,
614614
});
615615

616-
document.addEventListener('change', () => recorder.checkForPropertyChanges(), {
616+
document.addEventListener('change', () => recorder.checkForAllPropertyChanges(), {
617617
capture: true,
618618
passive: true,
619619
});

core/lib/CommandFormatter.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export default class CommandFormatter {
99
if (!command.args) {
1010
return `${command.name}()`;
1111
}
12-
const args = JSON.parse(command.args);
12+
const args = JSON.parse(command.args).filter(x => x !== null);
1313
if (command.name === 'execJsPath') {
1414
return formatJsPath(args[0]);
1515
}
@@ -64,7 +64,7 @@ export default class CommandFormatter {
6464
return `waitForElement( ${formatJsPath(args[0])} )`;
6565
}
6666

67-
return `${command.name}(${args.map(JSON.stringify)})`;
67+
return `${command.name}(${args.map(JSON.stringify).join(', ')})`;
6868
}
6969

7070
public static parseResult(meta: ICommandMeta) {
@@ -106,6 +106,14 @@ export default class CommandFormatter {
106106
}
107107
}
108108

109+
if (result?.type === 'Buffer' && meta.name === 'takeScreenshot') {
110+
const imageType = command.label.includes('jpeg') ? 'jpeg' : 'png';
111+
command.result = `data:image/${imageType}; base64,${Buffer.from(result.data).toString(
112+
'base64',
113+
)}`;
114+
command.resultType = 'image';
115+
}
116+
109117
if (meta.resultType.toLowerCase().includes('error')) {
110118
command.isError = true;
111119
command.result = result.message;

core/lib/SessionState.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ export default class SessionState {
220220
this.db.resources.insert(tabId, resource, null, resourceEvent, error);
221221

222222
const navigations = this.navigationsByTabId[tabId];
223+
if (!navigations) return;
223224

224225
const isNavigationToCurrentUrl =
225226
resource.url === navigations.currentUrl && resourceEvent.request.method !== 'OPTIONS';
@@ -353,7 +354,7 @@ export default class SessionState {
353354
}
354355

355356
public captureError(tabId: string, frameId: string, source: string, error: Error): void {
356-
this.logger.error('Window.error', { source, error });
357+
this.logger.info('Window.error', { source, error });
357358
this.db.pageLogs.insert(tabId, frameId, source, error.stack || String(error), new Date());
358359
}
359360

@@ -364,11 +365,7 @@ export default class SessionState {
364365
message: string,
365366
location?: string,
366367
): void {
367-
if (message.match(/error/gi)) {
368-
this.logger.error('Window.error', { message });
369-
} else {
370-
this.logger.info('Window.console', { message });
371-
}
368+
this.logger.info('Window.console', { message });
372369
this.db.pageLogs.insert(tabId, frameId, consoleType, message, new Date(), location);
373370
}
374371

core/lib/Tab.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { IBoundLog } from '@secret-agent/core-interfaces/ILog';
2929
import IWebsocketResourceMessage from '@secret-agent/core-interfaces/IWebsocketResourceMessage';
3030
import IAttachedState from 'awaited-dom/base/IAttachedState';
3131
import IWaitForOptions from '@secret-agent/core-interfaces/IWaitForOptions';
32+
import IScreenshotOptions from '@secret-agent/core-interfaces/IScreenshotOptions';
3233
import TabNavigations from './TabNavigations';
3334
import SessionState from './SessionState';
3435
import TabNavigationObserver from './TabNavigationsObserver';
@@ -136,6 +137,7 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
136137
this.isElementVisible,
137138
this.removeCookie,
138139
this.setCookie,
140+
this.takeScreenshot,
139141
this.waitForMillis,
140142
this.waitForElement,
141143
this.waitForLoad,
@@ -383,6 +385,10 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
383385
return isVisible.value;
384386
}
385387

388+
public takeScreenshot(options: IScreenshotOptions = {}): Promise<Buffer> {
389+
return this.puppetPage.screenshot(options.format, options.rectangle, options.jpegQuality);
390+
}
391+
386392
public async waitForNewTab(options: IWaitForOptions = {}): Promise<Tab> {
387393
// last command is the one running right now
388394
const startCommandId = options?.sinceCommandId ?? this.lastCommandId - 1;

core/server/ConnectionToReplay.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ export default class ConnectionToReplay {
249249
}
250250

251251
const json = JSON.stringify({ event, data }, (_, value) => {
252-
if (value !== null) return value;
252+
if (value !== undefined) return value;
253253
});
254254

255255
const sendPromise = this.sendMessage(json).catch(err => err);

0 commit comments

Comments
 (0)