diff --git a/core/dbs/SessionDb.ts b/core/dbs/SessionDb.ts index c0cf26ee7..f3179b5a7 100644 --- a/core/dbs/SessionDb.ts +++ b/core/dbs/SessionDb.ts @@ -138,7 +138,10 @@ export default class SessionDb { try { this.batchInsert.immediate(); } catch (error) { - if (String(error).match(/attempt to write a readonly database/)) { + if ( + String(error).match(/attempt to write a readonly database/) || + String(error).match(/database is locked/) + ) { clearInterval(this.saveInterval); } throw error; diff --git a/core/injected-scripts/NodeTracker.ts b/core/injected-scripts/NodeTracker.ts index 4a483bbd9..f500588ec 100644 --- a/core/injected-scripts/NodeTracker.ts +++ b/core/injected-scripts/NodeTracker.ts @@ -52,7 +52,7 @@ class NodeTracker { public static restore(id: number, node: Node): void { node[this.nodeIdSymbol] = id; this.watchedNodesById.set(id, node); - if (id > this.nextId) this.nextId = id; + if (id > this.nextId) this.nextId = id + 1; } } diff --git a/core/injected-scripts/domReplayer.ts b/core/injected-scripts/domReplayer.ts index 8a83b2355..b927baac6 100644 --- a/core/injected-scripts/domReplayer.ts +++ b/core/injected-scripts/domReplayer.ts @@ -4,7 +4,8 @@ import type { IFrontendDomChangeEvent } from '@ulixee/hero-core/models/DomChange declare global { interface Window { - replayDomChanges(...args: any[]); + loadPaintEvents(paintEvents: IFrontendDomChangeEvent[][]); + setPaintIndexRange(startIndex: number, endIndex: number); replayInteractions(...args: any[]); getIsMainFrame?: () => boolean; debugLogs: any[]; @@ -25,133 +26,161 @@ enum DomActionType { property = 6, } -const SHADOW_NODE_TYPE = 40; +class DomReplayer { + private paintEvents: IFrontendDomChangeEvent[][] = []; + private loadedIndex = -1; -const domChangeList = []; + private pendingDelegatedEventsByPath: { [frameIdPath: string]: IFrontendDomChangeEvent[] } = {}; + private pendingDomChanges: IFrontendDomChangeEvent[] = []; -if (!window.debugLogs) window.debugLogs = []; + public loadPaintEvents(newPaintEvents: IFrontendDomChangeEvent[][]): void { + this.paintEvents = newPaintEvents; + debugLog('Loaded PaintEvents', newPaintEvents); + } -function isMainFrame() { - if ('isMainFrame' in window) return (window as any).isMainFrame; - if ('getIsMainFrame' in window) return window.getIsMainFrame(); - return true; -} + public setPaintIndexRange(startIndex: number, endIndex: number): void { + if (endIndex === this.loadedIndex) return; + debugLog('Setting paint index range', startIndex, endIndex, document.readyState); -function debugLog(message: string, ...args: any[]) { - if (window.debugToConsole) { - // eslint-disable-next-line prefer-rest-params,no-console - console.log(...arguments); + for (let i = startIndex; i <= endIndex; i += 1) { + this.applyDomChanges(this.paintEvents[i]); + } + + this.loadedIndex = endIndex; } - window.debugLogs.push({ message, args }); -} -window.replayDomChanges = function replayDomChanges(changeEvents: IFrontendDomChangeEvent[]) { - if (changeEvents) applyDomChanges(changeEvents); -}; + private applyDomChanges(changeEvents: IFrontendDomChangeEvent[]): void { + this.pendingDomChanges.push(...changeEvents); + if (document.readyState !== 'complete') { + document.onreadystatechange = () => this.applyDomChanges([]); + return; + } -window.addEventListener('message', ev => { - if (ev.data.action !== 'replayDomChanges') return; - if (ev.data.recipientFrameIdPath && !window.selfFrameIdPath) { - window.selfFrameIdPath = ev.data.recipientFrameIdPath; + document.onreadystatechange = null; + for (const changeEvent of this.pendingDomChanges) { + try { + this.replayDomEvent(changeEvent); + } catch (err) { + debugLog('ERROR applying change', changeEvent, err); + } + } + this.pendingDomChanges.length = 0; } - domChangeList.push(ev.data.event); - if (document.readyState !== 'loading') applyDomChanges([]); -}); -function applyDomChanges(changeEvents: IFrontendDomChangeEvent[]) { - const toProcess = domChangeList.concat(changeEvents); - domChangeList.length = 0; + private replayDomEvent(event: IFrontendDomChangeEvent): void { + if (!window.selfFrameIdPath && isMainFrame()) { + window.selfFrameIdPath = 'main'; + } - for (const changeEvent of toProcess) { - try { - replayDomEvent(changeEvent); - } catch (err) { - debugLog('ERROR applying change', changeEvent, err); + const { action, frameIdPath } = event; + if (frameIdPath && frameIdPath !== window.selfFrameIdPath) { + this.delegateToSubframe(event); + return; } - } -} -/////// DOM REPLAYER /////////////////////////////////////////////////////////////////////////////////////////////////// + if (action === DomActionType.newDocument) return onNewDocument(event); + if (action === DomActionType.location) return onLocation(event); -function replayDomEvent(event: IFrontendDomChangeEvent) { - if (!window.selfFrameIdPath && isMainFrame()) { - window.selfFrameIdPath = 'main'; - } + if (isPreservedElement(event)) return; - const { action, textContent, frameIdPath } = event; - if (frameIdPath && frameIdPath !== window.selfFrameIdPath) { - delegateToSubframe(event); - return; + let node: Node; + let parentNode: Node; + try { + parentNode = getNode(event.parentNodeId); + node = deserializeNode(event, parentNode as Element); + + if (action === DomActionType.added) onNodeAdded(node, parentNode, event); + if (action === DomActionType.removed) onNodeRemoved(node, parentNode, event); + if (action === DomActionType.attribute) setNodeAttributes(node as Element, event); + if (action === DomActionType.property) setNodeProperties(node as Element, event); + if (action === DomActionType.text) node.textContent = event.textContent; + } catch (error) { + // eslint-disable-next-line no-console + console.error('ERROR: applying action', error.stack, { parentNode, node, event }); + } } - if (action === DomActionType.newDocument) { - onNewDocument(event); - return; - } + private delegateToSubframe(event: IFrontendDomChangeEvent) { + const { node, frameIdPath } = getDelegatedFrameRecipient(event.frameIdPath); + if (!node) { + // queue for pending events + this.pendingDelegatedEventsByPath[frameIdPath] ??= []; + this.pendingDelegatedEventsByPath[frameIdPath].push(event); + debugLog('Frame: not loaded yet, queuing pending', frameIdPath); + return; + } - if (action === DomActionType.location) { - debugLog('Location: href=%s', event.textContent); - window.history.replaceState({}, 'Replay', textContent); - return; - } + const frame = this.getDelegatableFrame(event.action, node); + if (!frame) return; - if (isPreservedElement(event)) return; - const { parentNodeId } = event; + const events = this.pendingDelegatedEventsByPath[frameIdPath] ?? []; + events.push(event); + delete this.pendingDelegatedEventsByPath[frameIdPath]; - let node: Node; - let parentNode: Node; - try { - parentNode = getNode(parentNodeId); - node = deserializeNode(event, parentNode as Element); + frame.contentWindow.postMessage({ recipientFrameIdPath: frameIdPath, events }, '*'); + } + + private getDelegatableFrame( + action: IFrontendDomChangeEvent['action'], + node: Node, + ): HTMLIFrameElement { + const isNavigation = action === DomActionType.location || action === DomActionType.newDocument; - if (!parentNode && (action === DomActionType.added || action === DomActionType.removed)) { - debugLog('WARN: parent node id not found', event); + if (isNavigation && node instanceof HTMLObjectElement) { return; } - switch (action) { - case DomActionType.added: - if (!event.previousSiblingId) { - (parentNode as Element).prepend(node); - } else if (getNode(event.previousSiblingId)) { - const next = getNode(event.previousSiblingId).nextSibling; + const frame = node as HTMLIFrameElement; + if (!frame.contentWindow) { + debugLog('Frame: without window', frame); + return; + } + return frame; + } - if (next) parentNode.insertBefore(node, next); - else parentNode.appendChild(node); - } + static load() { + const replayer = new DomReplayer(); + window.loadPaintEvents = replayer.loadPaintEvents.bind(replayer); + window.setPaintIndexRange = replayer.setPaintIndexRange.bind(replayer); - break; - case DomActionType.removed: - if (parentNode.contains(node)) parentNode.removeChild(node); - break; - case DomActionType.attribute: - setNodeAttributes(node as Element, event); - break; - case DomActionType.property: - setNodeProperties(node as Element, event); - break; - case DomActionType.text: - node.textContent = textContent; - break; - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('ERROR: applying action', error.stack, parentNode, node, event); + window.addEventListener('message', ev => { + if (ev.data.recipientFrameIdPath && !window.selfFrameIdPath) { + window.selfFrameIdPath = ev.data.recipientFrameIdPath; + } + replayer.applyDomChanges(ev.data.events); + }); } } +DomReplayer.load(); + +/////// DELEGATION BETWEEN FRAMES //////////////////////////////////////////////////////////////////////////////////// + +function getDelegatedFrameRecipient(eventFrameIdPath: string): { node: Node; frameIdPath: string } { + const childPath = eventFrameIdPath + .replace(window.selfFrameIdPath, '') + .split('_') + .filter(Boolean) + .map(Number); + + const childId = childPath.shift(); + const frameIdPath = `${window.selfFrameIdPath}_${childId}`; + const node = getNode(childId); + return { frameIdPath, node }; +} + /////// PRESERVE HTML, BODY, HEAD ELEMS //////////////////////////////////////////////////////////////////////////////// const preserveElements = new Set(['HTML', 'HEAD', 'BODY']); function isPreservedElement(event: IFrontendDomChangeEvent) { const { action, nodeId, nodeType } = event; - if (nodeType === document.DOCUMENT_NODE) { + if (nodeId && nodeType === document.DOCUMENT_NODE) { NodeTracker.restore(nodeId, document); return true; } - if (nodeType === document.DOCUMENT_TYPE_NODE) { + if (nodeId && nodeType === document.DOCUMENT_TYPE_NODE) { NodeTracker.restore(nodeId, document.doctype); return true; } @@ -192,61 +221,38 @@ function isPreservedElement(event: IFrontendDomChangeEvent) { return true; } -/////// DELEGATION BETWEEN FRAMES //////////////////////////////////////////////////////////////////////////////////// +/////// APPLY PAINT CHANGES ////////////////////////////////////////////////////////////////////////////////////////// -const pendingFrameCreationEvents = new Map< - string, - { recipientFrameIdPath: string; event: IFrontendDomChangeEvent; action: string }[] ->(); -(window as any).pendingFrameCreationEvents = pendingFrameCreationEvents; -function delegateToSubframe(event: IFrontendDomChangeEvent) { - const childPath = event.frameIdPath - .replace(window.selfFrameIdPath, '') - .split('_') - .filter(Boolean) - .map(Number); - - const childId = childPath.shift(); - const recipientFrameIdPath = `${window.selfFrameIdPath}_${childId}`; - - const node = getNode(childId); - if (!node) { - if (!pendingFrameCreationEvents.has(recipientFrameIdPath)) { - pendingFrameCreationEvents.set(recipientFrameIdPath, []); - } - // queue for pending events - pendingFrameCreationEvents - .get(recipientFrameIdPath) - .push({ recipientFrameIdPath, event, action: 'replayDomChanges' }); - debugLog('Frame: not loaded yet, queuing pending', recipientFrameIdPath); +function onNodeAdded(node: Node, parentNode: Node, event: IFrontendDomChangeEvent) { + if (!parentNode) { + debugLog('WARN: parent node id not found', event); return; } - if ( - (event.action === DomActionType.location || event.action === DomActionType.newDocument) && - node instanceof HTMLObjectElement - ) { - return; - } + if (!event.previousSiblingId) { + (parentNode as Element).prepend(node); + } else { + const previous = getNode(event.previousSiblingId); + if (previous) { + const next = previous.nextSibling; - const frame = node as HTMLIFrameElement; - if (!frame.contentWindow) { - debugLog('Frame: without window', frame); - return; - } - const events = [{ recipientFrameIdPath, event, action: 'replayDomChanges' }]; - - if (pendingFrameCreationEvents.has(recipientFrameIdPath)) { - events.unshift(...pendingFrameCreationEvents.get(recipientFrameIdPath)); - pendingFrameCreationEvents.delete(recipientFrameIdPath); + if (next) parentNode.insertBefore(node, next); + else parentNode.appendChild(node); + } } +} - for (const message of events) { - frame.contentWindow.postMessage(message, '*'); +function onNodeRemoved(node: Node, parentNode: Node, event: IFrontendDomChangeEvent) { + if (!parentNode) { + debugLog('WARN: parent node id not found', event); + return; } + if (parentNode.contains(node)) parentNode.removeChild(node); } function onNewDocument(event: IFrontendDomChangeEvent) { + if (isMainFrame()) return; + const { textContent } = event; const href = textContent; const newUrl = new URL(href); @@ -281,6 +287,11 @@ function onNewDocument(event: IFrontendDomChangeEvent) { } } +function onLocation(event: IFrontendDomChangeEvent) { + debugLog('Location: href=%s', event.textContent); + window.history.replaceState({}, 'Replay', event.textContent); +} + function getNode(id: number) { if (id === null || id === undefined) return null; return NodeTracker.getWatchedNodeWithId(id, false); @@ -349,6 +360,7 @@ function deserializeNode(data: IFrontendDomChangeEvent, parent: Element): Node { return node; } + const SHADOW_NODE_TYPE = 40; if (parent && typeof parent.attachShadow === 'function' && data.nodeType === SHADOW_NODE_TYPE) { // NOTE: we just make all shadows open in replay node = parent.attachShadow({ mode: 'open' }); @@ -403,3 +415,20 @@ function deserializeNode(data: IFrontendDomChangeEvent, parent: Element): Node { return node; } + +function isMainFrame() { + if ('isMainFrame' in window) return (window as any).isMainFrame; + if ('getIsMainFrame' in window) return window.getIsMainFrame(); + return true; +} + +/////// DEBUG LOGS /////////////////////////////////////////////////////////////////////////////////////////////////// + +function debugLog(message: string, ...args: any[]) { + if (window.debugToConsole) { + // eslint-disable-next-line prefer-rest-params,no-console + console.log(...arguments); + } + window.debugLogs ??= []; + window.debugLogs.push({ message, args }); +} diff --git a/core/lib/InjectedScripts.ts b/core/lib/InjectedScripts.ts index 525115974..6c4b86343 100644 --- a/core/lib/InjectedScripts.ts +++ b/core/lib/InjectedScripts.ts @@ -8,6 +8,7 @@ import { IFrontendScrollEvent, IHighlightedNodes, } from '../injected-scripts/interactReplayer'; +import { IPaintEvent } from '../apis/Session.ticks'; const pageScripts = { domStorage: fs.readFileSync(`${__dirname}/../injected-scripts/domStorage.js`, 'utf8'), @@ -114,8 +115,32 @@ export default class InjectedScripts { public static async restoreDom( puppetPage: IPuppetPage, - domChanges: IFrontendDomChangeEvent[], + changeEvents: IFrontendDomChangeEvent[], ): Promise { + await InjectedScripts.injectPaintEvents(puppetPage, [{ changeEvents } as any]); + await InjectedScripts.setPaintIndexRange(puppetPage, 0, 0); + } + + public static async setPaintIndexRange( + puppetPage: IPuppetPage, + startIndex: number, + endIndex: number, + ): Promise { + await puppetPage.mainFrame.evaluate( + `window.setPaintIndexRange(${startIndex}, ${endIndex});`, + true, + ); + } + + public static async injectPaintEvents( + puppetPage: IPuppetPage, + paintEvents: IPaintEvent[], + domNodePathByFrameId?: { [frameId: number]: string }, + ): Promise { + if (!puppetPage[installedSymbol]) { + await this.installDetachedScripts(puppetPage); + } + const columns = [ 'action', 'nodeId', @@ -128,11 +153,30 @@ export default class InjectedScripts { 'attributeNamespaces', 'attributes', 'properties', - 'frameIdPath', - ]; - const records = domChanges.map(x => columns.map(col => x[col])); - if (!puppetPage[installedSymbol]) { - await this.installDetachedScripts(puppetPage); + 'frameId', + ] as const; + + const tagNames: { [tagName: string]: number } = {}; + const tagNamesById: { [tagId: number]: string } = {}; + let tagCounter = 0; + + const events: IFrontendDomChangeEvent[][] = []; + for (const event of paintEvents) { + const changes = []; + events.push(changes); + for (const change of event.changeEvents) { + if (change.tagName && tagNames[change.tagName] === undefined) { + tagCounter += 1; + tagNamesById[tagCounter] = change.tagName; + tagNames[change.tagName] = tagCounter; + } + const record = columns.map(col => { + const prop = change[col]; + if (col === 'tagName') return tagNames[prop as string]; + return prop; + }); + changes.push(record); + } } await puppetPage.mainFrame.evaluate( @@ -140,14 +184,23 @@ export default class InjectedScripts { const exports = {}; window.isMainFrame = true; - const records = ${JSON.stringify(records).replace(/,null/g, ',')}; - const events = []; - for (const [${columns.join(',')}] of records) { - const event = {${columns.join(',')}}; - events.push(event); + const records = ${JSON.stringify(events).replace(/,null/g, ',')}; + const tagNamesById = ${JSON.stringify(tagNamesById)}; + const domNodePathsByFrameId = ${JSON.stringify(domNodePathByFrameId ?? {})}; + window.records= { records,tagNamesById, domNodePathsByFrameId}; + const paintEvents = []; + for (const event of records) { + const changeEvents = []; + paintEvents.push(changeEvents); + for (const [${columns.join(',')}] of event) { + const event = { ${columns.join(',')} }; + event.frameIdPath = domNodePathsByFrameId[frameId]; + if (event.tagName !== undefined) event.tagName = tagNamesById[event.tagName]; + changeEvents.push(event); + } } - window.replayDomChanges(events); + window.loadPaintEvents(paintEvents); })() //# sourceURL=${injectedSourceUrl}`, true, diff --git a/core/lib/Session.ts b/core/lib/Session.ts index cbc10b33a..0ff80befa 100644 --- a/core/lib/Session.ts +++ b/core/lib/Session.ts @@ -620,6 +620,8 @@ ${data}`, options.userProfile ??= record.createSessionOptions?.userProfile; options.userProfile ??= {}; options.userProfile.deviceProfile ??= record.deviceProfile; + options.browserEmulatorId = record.browserEmulatorId; + options.humanEmulatorId = record.humanEmulatorId; return options; } diff --git a/core/lib/SessionReplay.ts b/core/lib/SessionReplay.ts index 04b07f142..a3f34e431 100644 --- a/core/lib/SessionReplay.ts +++ b/core/lib/SessionReplay.ts @@ -1,11 +1,13 @@ import { IPuppetPage } from '@ulixee/hero-interfaces/IPuppetPage'; import Log from '@ulixee/commons/lib/Logger'; import IPuppetContext from '@ulixee/hero-interfaces/IPuppetContext'; +import { Protocol } from '@ulixee/hero-interfaces/IDevtoolsSession'; import decodeBuffer from '@ulixee/commons/lib/decodeBuffer'; -import * as util from 'util'; +import ICorePlugin, { ISessionSummary } from '@ulixee/hero-interfaces/ICorePlugin'; +import ISessionCreateOptions from '@ulixee/hero-interfaces/ISessionCreateOptions'; +import { TypedEventEmitter } from '@ulixee/commons/lib/eventUtils'; import SessionReplayTab from './SessionReplayTab'; import ConnectionToCoreApi from '../connections/ConnectionToCoreApi'; -import { ISessionFindArgs } from '../dbs/SessionDb'; import { IDocument } from '../apis/Session.ticks'; import { ISessionResource } from '../apis/Session.resources'; import { ISessionResourceDetails } from '../apis/Session.resource'; @@ -13,150 +15,155 @@ import CorePlugins from './CorePlugins'; import { Session } from '../index'; import GlobalPool from './GlobalPool'; import InjectedScripts from './InjectedScripts'; -import { ISessionRecord } from '../models/SessionTable'; +import Fetch = Protocol.Fetch; const { log } = Log(module); -export default class SessionReplay { +export default class SessionReplay extends TypedEventEmitter<{ 'all-tabs-closed': void }> { public tabsById = new Map(); - public session: ISessionRecord; - public startTab: SessionReplayTab; - private readonly resourceLookup: { [method_url: string]: ISessionResource[] } = {}; + public get isOpen(): boolean { + for (const tab of this.tabsById.values()) { + if (tab.isOpen) return true; + } + return false; + } + private resourceLookup: { [method_url: string]: ISessionResource[] } = {}; private readonly documents: IDocument[] = []; - private get sessionArgs(): { sessionId: string } { - return { - sessionId: this.session.id, - }; + private readonly sessionOptions: ISessionCreateOptions; + private browserContext: IPuppetContext; + private isReady: Promise; + + private pageIds = new Set(); + + constructor( + readonly sessionId: string, + readonly connection: ConnectionToCoreApi, + readonly plugins: ICorePlugin[] = [], + readonly debugLogging = false, + ) { + super(); + this.sessionOptions = + Session.get(sessionId)?.options ?? Session.restoreOptionsFromSessionRecord({}, sessionId); } - private browserContext: IPuppetContext; + public async open( + browserContext: IPuppetContext, + sessionOffsetPercent?: number, + ): Promise { + this.browserContext = browserContext; + this.isReady ??= this.load(); + return await this.goto(sessionOffsetPercent); + } - constructor(readonly connection: ConnectionToCoreApi, readonly debugLogging = false) {} + public isReplayPage(pageId: string): boolean { + return this.pageIds.has(pageId); + } - public async load(args: ISessionFindArgs): Promise { - const { session } = await this.connection.run({ api: 'Session.find', args }); - this.session = session; + public async goto(sessionOffsetPercent: number): Promise { + await this.isReady; - const ticksResult = await this.connection.run({ - api: 'Session.ticks', - args: { - ...this.sessionArgs, - includeCommands: true, - includeInteractionEvents: true, - includePaintEvents: true, - }, - }); - if (this.debugLogging) { - // eslint-disable-next-line no-console - console.log(util.inspect(ticksResult.tabDetails, true, null, true)); - } - for (const tabDetails of ticksResult.tabDetails) { - const tab = new SessionReplayTab(tabDetails, this); - this.tabsById.set(tabDetails.tab.id, tab); - this.startTab ??= tab; - this.documents.push(...tabDetails.documents); + /** + * TODO: eventually this playbar needs to know which tab is active in the timeline at this offset + * If 1 tab is active, switch to it, otherwise, need to show the multi-timeline view and pick one tab to show + */ + const tab = [...this.tabsById.values()][0]; + if (!tab.isOpen) { + await tab.open(); } - const resourcesResult = await this.connection.run({ - api: 'Session.resources', - args: { ...this.sessionArgs, omitWithoutResponse: true, omitNonHttpGet: true }, - }); - for (const resource of resourcesResult.resources) { - const key = `${resource.method}_${resource.url}`; - this.resourceLookup[key] ??= []; - this.resourceLookup[key].push(resource); + if (sessionOffsetPercent !== undefined) { + await tab.setPlaybarOffset(sessionOffsetPercent); + } else { + await tab.loadEndState(); } + return tab; + } - const options = Session.restoreOptionsFromSessionRecord({}, this.session.id); - options.sessionResume = null; - options.showBrowserInteractions = true; - options.showBrowser = true; - options.allowManualBrowserInteraction = false; + public async close(closeContext = false): Promise { + this.isReady = null; + if (!closeContext) { + for (const tab of this.tabsById.values()) { + await tab.close(); + } + } else { + await this.browserContext?.close(); + this.browserContext = null; + } + this.tabsById.clear(); + this.resourceLookup = {}; + this.documents.length = 0; + } - const plugins = new CorePlugins( - { - humanEmulatorId: this.session.humanEmulatorId, - browserEmulatorId: this.session.browserEmulatorId, - userAgentSelector: options.userAgent, - deviceProfile: options?.userProfile?.deviceProfile, - getSessionSummary() { - return { - id: session.id, - options, - }; - }, - }, - log, - ); - plugins.browserEngine.isHeaded = true; - plugins.configure(options); + public async mockNetworkRequests( + request: Fetch.RequestPausedEvent, + ): Promise { + const { url, method } = request.request; + if (request.resourceType === 'Document') { + const doctype = this.documents.find(x => x.url === url)?.doctype ?? ''; + return { + requestId: request.requestId, + responseCode: 200, + responseHeaders: [{ name: 'Content-Type', value: 'text/html; charset=utf-8' }], + body: Buffer.from(`${doctype}`).toString('base64'), + }; + } - const puppet = await GlobalPool.getPuppet(plugins); - this.browserContext = await puppet.newContext(plugins, log); + const matches = this.resourceLookup[`${method}_${url}`]; + if (!matches?.length) { + return { + requestId: request.requestId, + responseCode: 404, + body: Buffer.from(`Not Found`).toString('base64'), + }; + } - this.browserContext.defaultPageInitializationFn = page => - InjectedScripts.installDetachedScripts(page, true); + const { resource } = await this.connection.run({ + api: 'Session.resource', + args: { + sessionId: this.sessionId, + resourceId: matches[0].id, + }, + }); - await this.startTab.openTab(); - } + const { headers, contentEncoding } = this.getMockHeaders(resource); + let body = resource.body; - public async close(): Promise { - await this.browserContext.close(); + // Chrome Devtools has an upstream issue that gzipped responses don't work, so we have to do it.. :( + // https://bugs.chromium.org/p/chromium/issues/detail?id=1138839 + if (contentEncoding) { + body = await decodeBuffer(resource.body, contentEncoding); + headers.splice( + headers.findIndex(x => x.name === 'content-encoding'), + 1, + ); + } + return { + requestId: request.requestId, + body: body.toString('base64'), + responseHeaders: headers, + responseCode: resource.statusCode, + }; } - public async createNewPage(): Promise { - const page = await this.browserContext.newPage(); + private async createNewPage(): Promise { + const page = await this.browserContext.newPage({ runPageScripts: false }); + this.pageIds.add(page.id); + page.once('close', this.checkAllPagesClosed.bind(this)); + const sessionSummary = { + id: this.sessionId, + options: this.sessionOptions, + }; await Promise.all([ + this.plugins.filter(x => x.onNewPuppetPage).map(x => x.onNewPuppetPage(page, sessionSummary)), + InjectedScripts.installDetachedScripts(page, true), page.setNetworkRequestInterceptor(this.mockNetworkRequests.bind(this)), page.setJavaScriptEnabled(false), ]); return page; } - public mockNetworkRequests: Parameters[0] = - async request => { - const { url, method } = request.request; - if (request.resourceType === 'Document') { - const doctype = this.documents.find(x => x.url === url)?.doctype ?? ''; - return { - requestId: request.requestId, - responseCode: 200, - responseHeaders: [{ name: 'Content-Type', value: 'text/html; charset=utf-8' }], - body: Buffer.from(`${doctype}`).toString('base64'), - }; - } - - const matches = this.resourceLookup[`${method}_${url}`]; - if (!matches?.length) return null; - - const { resource } = await this.connection.run({ - api: 'Session.resource', - args: { - ...this.sessionArgs, - resourceId: matches[0].id, - }, - }); - - const { headers, contentEncoding } = this.getMockHeaders(resource); - let body = resource.body; - if (contentEncoding) { - // TODO: can't send compressed content to devtools for some reason - body = await decodeBuffer(resource.body, contentEncoding); - headers.splice( - headers.findIndex(x => x.name === 'content-encoding'), - 1, - ); - } - return { - requestId: request.requestId, - body: body.toString('base64'), - responseHeaders: headers, - responseCode: resource.statusCode, - }; - }; - private getMockHeaders(resource: ISessionResourceDetails): { isJavascript: boolean; hasChunkedTransfer: boolean; @@ -176,6 +183,7 @@ export default class SessionReplay { } if (name === 'transfer-encoding' && header === 'chunked') { + // node has stripped this out by the time we have the body hasChunkedTransfer = true; continue; } @@ -195,4 +203,89 @@ export default class SessionReplay { } return { headers, isJavascript, contentEncoding, hasChunkedTransfer }; } + + private async checkAllPagesClosed(): Promise { + await new Promise(setImmediate); + for (const tab of this.tabsById.values()) { + if (tab.isOpen) return; + } + this.emit('all-tabs-closed'); + } + + private async load(): Promise { + await this.loadTicks(); + await this.loadResources(); + } + + private async loadTicks(): Promise { + const ticksResult = await this.connection.run({ + api: 'Session.ticks', + args: { + sessionId: this.sessionId, + includeCommands: true, + includeInteractionEvents: true, + includePaintEvents: true, + }, + }); + if (this.debugLogging) { + log.info('Replay Tab State', { + sessionId: this.sessionId, + tabDetails: ticksResult.tabDetails, + }); + } + for (const tabDetails of ticksResult.tabDetails) { + const tab = new SessionReplayTab( + tabDetails, + () => this.createNewPage(), + this.sessionId, + this.debugLogging, + ); + this.tabsById.set(tabDetails.tab.id, tab); + this.documents.push(...tabDetails.documents); + } + } + + private async loadResources(): Promise { + const resourcesResult = await this.connection.run({ + api: 'Session.resources', + args: { sessionId: this.sessionId, omitWithoutResponse: true, omitNonHttpGet: true }, + }); + for (const resource of resourcesResult.resources) { + const key = `${resource.method}_${resource.url}`; + this.resourceLookup[key] ??= []; + this.resourceLookup[key].push(resource); + } + } + + public static async recreateBrowserContextForSession( + sessionId: string, + headed = true, + ): Promise { + const options = Session.restoreOptionsFromSessionRecord({}, sessionId); + options.sessionResume = null; + options.showBrowserInteractions = headed; + options.showBrowser = headed; + options.allowManualBrowserInteraction = false; + + const plugins = new CorePlugins( + { + humanEmulatorId: options.humanEmulatorId, + browserEmulatorId: options.browserEmulatorId, + userAgentSelector: options.userAgent, + deviceProfile: options?.userProfile?.deviceProfile, + getSessionSummary() { + return { + id: this.sessionId, + options, + }; + }, + }, + log, + ); + plugins.browserEngine.isHeaded = true; + plugins.configure(options); + + const puppet = await GlobalPool.getPuppet(plugins); + return await puppet.newContext(plugins, log); + } } diff --git a/core/lib/SessionReplayTab.ts b/core/lib/SessionReplayTab.ts index 1ded54d99..4961ac8ee 100644 --- a/core/lib/SessionReplayTab.ts +++ b/core/lib/SessionReplayTab.ts @@ -1,11 +1,12 @@ import { DomActionType } from '@ulixee/hero-interfaces/IDomChangeEvent'; import { IPuppetPage } from '@ulixee/hero-interfaces/IPuppetPage'; -import { IScrollRecord } from '../models/ScrollEventsTable'; -import { IMouseEventRecord } from '../models/MouseEventsTable'; +import Log from '@ulixee/commons/lib/Logger'; +import { CanceledPromiseError } from '@ulixee/commons/interfaces/IPendingWaitEvent'; import ICommandWithResult from '../interfaces/ICommandWithResult'; import { IPaintEvent, ITabDetails, ITick } from '../apis/Session.ticks'; import InjectedScripts from './InjectedScripts'; -import SessionReplay from './SessionReplay'; + +const { log } = Log(module); export default class SessionReplayTab { public get ticks(): ITick[] { @@ -38,33 +39,47 @@ export default class SessionReplayTab { return this.ticks[this.currentTickIndex + 1]; } + public get isOpen(): boolean { + return !!this.page && !!this.pageId; + } + public readonly commandsById = new Map(); - public currentUrl: string; public currentPlaybarOffsetPct = 0; public isPlaying = false; public currentTickIndex = -1; + + private pageId: string; + private page: Promise; // put in placeholder private paintEventsLoadedIdx = -1; - private page: IPuppetPage; - private domNodePathByFrameId = new Map(); + private domNodePathByFrameId: { [frameId: number]: string } = {}; constructor( private readonly tabDetails: ITabDetails, - private readonly sessionReplay: SessionReplay, + private readonly pageCreator: () => Promise, + private readonly sessionId: string, + private readonly debugLogging: boolean = false, ) { for (const command of tabDetails.commands) { this.commandsById.set(command.id, command); } for (const frame of this.tabDetails.tab.frames) { if (frame.isMainFrame) this.mainFrameId = frame.id; - this.domNodePathByFrameId.set(frame.id, frame.domNodePath); + this.domNodePathByFrameId[frame.id] = frame.domNodePath; } } - public async openTab(): Promise { - this.page = await this.sessionReplay.createNewPage(); - await this.goto(this.tabDetails.tab.startUrl); + public async open(): Promise { + if (!this.page) { + this.page = this.pageCreator(); + const page = await this.page; + page.on('close', () => { + this.page = null; + this.pageId = null; + }); + this.pageId = page.id; + } } public async play(onTick?: (tick: ITick) => void): Promise { @@ -111,28 +126,11 @@ export default class SessionReplayTab { } public async close(): Promise { - await this.page.close(); + await this.page?.then(x => x.close()); } - public getTickState(): { - currentPlaybarOffsetPct: number; - currentTickIndex: number; - ticks: number[]; - } { - return { - currentPlaybarOffsetPct: this.currentPlaybarOffsetPct, - currentTickIndex: this.currentTickIndex, - ticks: this.ticks.filter(x => x.isMajor).map(x => x.playbarOffsetPercent), - }; - } - - public async setPlaybarOffset(playbarOffset: number, isReset = false): Promise { + public async setPlaybarOffset(playbarOffset: number): Promise { const ticks = this.ticks; - if (isReset) { - this.currentPlaybarOffsetPct = 0; - this.currentTickIndex = -1; - this.paintEventsLoadedIdx = -1; - } if (!ticks.length || this.currentPlaybarOffsetPct === playbarOffset) return; let newTickIdx = this.currentTickIndex; @@ -153,6 +151,10 @@ export default class SessionReplayTab { await this.loadTick(newTickIdx, playbarOffset); } + public async loadEndState(): Promise { + await this.loadTick(this.ticks.length - 1); + } + public async loadTick(newTickIdx: number, specificPlaybarOffset?: number): Promise { if (newTickIdx === this.currentTickIndex) { return; @@ -164,19 +166,40 @@ export default class SessionReplayTab { return; } - const playbarOffset = specificPlaybarOffset ?? newTick.playbarOffsetPercent; this.currentTickIndex = newTickIdx; - this.currentPlaybarOffsetPct = playbarOffset; + this.currentPlaybarOffsetPct = specificPlaybarOffset ?? newTick.playbarOffsetPercent; + + const newPaintIndex = newTick.paintEventIndex; + if (newPaintIndex !== undefined && newPaintIndex !== this.paintEventsLoadedIdx) { + const isBackwards = newPaintIndex < this.paintEventsLoadedIdx; + + let startIndex = newTick.documentLoadPaintIndex; + if ((isBackwards && newPaintIndex === -1) || newTick.eventType === 'init') { + await this.goto(this.firstDocumentUrl); + return; + } + + // if going forward and past document load, start at currently loaded index + if (!isBackwards && this.paintEventsLoadedIdx > newTick.documentLoadPaintIndex) { + startIndex = this.paintEventsLoadedIdx + 1; + } + + this.paintEventsLoadedIdx = newPaintIndex; + await this.loadPaintEvents([startIndex, newPaintIndex]); + } - const paintEvents = this.getPaintEventsForNewTick(newTick); const mouseEvent = this.tabDetails.mouse[newTick.mouseEventIndex]; const scrollEvent = this.tabDetails.scroll[newTick.scrollEventIndex]; const nodesToHighlight = newTick.highlightNodeIds; - this.currentUrl = newTick.documentUrl; - this.paintEventsLoadedIdx = newTick.paintEventIndex; - - await this.loadDomState(paintEvents, nodesToHighlight, mouseEvent, scrollEvent); + if (nodesToHighlight || mouseEvent || scrollEvent) { + await InjectedScripts.replayInteractions( + await this.page, + this.applyFrameNodePath(nodesToHighlight), + this.applyFrameNodePath(mouseEvent), + this.applyFrameNodePath(scrollEvent), + ); + } } public loadDetachedState( @@ -201,98 +224,64 @@ export default class SessionReplayTab { this.ticks.unshift(tick); } - private getPaintEventsForNewTick(newTick: ITick): IPaintEvent['changeEvents'] { - if ( - newTick.paintEventIndex === this.paintEventsLoadedIdx || - newTick.paintEventIndex === undefined - ) { - return; - } - - const isBackwards = newTick.paintEventIndex < this.paintEventsLoadedIdx; + private async loadPaintEvents(paintIndexRange: [number, number]): Promise { + const page = await this.page; + const startIndex = paintIndexRange[0]; + let loadUrl: string = null; + const { action, frameId, textContent } = + this.tabDetails.paintEvents[startIndex].changeEvents[0] ?? {}; - let startIndex = this.paintEventsLoadedIdx + 1; - if (isBackwards) { - startIndex = newTick.documentLoadPaintIndex; + if (action === DomActionType.newDocument && frameId === this.mainFrameId) { + loadUrl = textContent; } - const changeEvents: IPaintEvent['changeEvents'] = []; - if ((newTick.paintEventIndex === -1 && isBackwards) || newTick.eventType === 'init') { - startIndex = -1; - changeEvents.push({ - action: DomActionType.newDocument, - textContent: this.firstDocumentUrl, - commandId: newTick.commandId, - } as any); - } else { - for (let i = startIndex; i <= newTick.paintEventIndex; i += 1) { - const paints = this.tabDetails.paintEvents[i]; - const first = paints.changeEvents[0]; - // find last newDocument change - if (first.frameId === this.mainFrameId && first.action === DomActionType.newDocument) { - changeEvents.length = 0; - } - changeEvents.push(...paints.changeEvents); - } - } - - if (this.sessionReplay.debugLogging) { - // eslint-disable-next-line no-console - console.log( - 'Paint load. Current Idx=%s, Loading [%s->%s] (paints: %s, back? %s)', - this.paintEventsLoadedIdx, - startIndex, - newTick.paintEventIndex, - changeEvents.length, - isBackwards, - ); + if (loadUrl && loadUrl !== page.mainFrame.url) { + await this.goto(loadUrl); } - return changeEvents; - } - - private async loadDomState( - domChanges: IPaintEvent['changeEvents'], - highlightedNodes?: { frameId: number; nodeIds: number[] }, - mouse?: IMouseEventRecord, - scroll?: IScrollRecord, - ): Promise { - if (domChanges?.length) { - const { action, frameId } = domChanges[0]; - const hasNewUrlToLoad = action === DomActionType.newDocument && frameId === this.mainFrameId; - if (hasNewUrlToLoad) { - const nav = domChanges.shift(); - await this.goto(nav.textContent); + if (startIndex >= 0) { + if (this.debugLogging) { + log.info('Replay.loadPaintEvents', { + sessionId: this.sessionId, + paintIndexRange, + }); } - await InjectedScripts.restoreDom( - this.page, - domChanges.map(this.applyFrameNodePath.bind(this)), - ); - } + try { + await InjectedScripts.setPaintIndexRange(page, startIndex, paintIndexRange[1]); + } catch (err) { + // -32000 means ContextNotFound. ie, page has navigated + if (err.code === -32000) throw new CanceledPromiseError('Context not found'); - if (highlightedNodes || mouse || scroll) { - await InjectedScripts.replayInteractions( - this.page, - this.applyFrameNodePath(highlightedNodes), - this.applyFrameNodePath(mouse), - this.applyFrameNodePath(scroll), - ); + throw err; + } } } private applyFrameNodePath(item: T): T & { frameIdPath: string } { if (!item) return undefined; const result = item as T & { frameIdPath: string }; - result.frameIdPath = this.domNodePathByFrameId.get(item.frameId); + result.frameIdPath = this.domNodePathByFrameId[item.frameId]; return result; } private async goto(url: string): Promise { - const page = this.page; + if (this.debugLogging) { + log.info('Replay.goto', { + sessionId: this.sessionId, + url, + }); + } + + const page = await this.page; const loader = await page.navigate(url); await Promise.all([ page.mainFrame.waitForLoader(loader.loaderId), page.mainFrame.waitForLoad('DOMContentLoaded'), ]); + await InjectedScripts.injectPaintEvents( + page, + this.tabDetails.paintEvents, + this.domNodePathByFrameId, + ); } } diff --git a/core/models/DevtoolsMessagesTable.ts b/core/models/DevtoolsMessagesTable.ts index ef6ca8b0e..2cf5527d5 100644 --- a/core/models/DevtoolsMessagesTable.ts +++ b/core/models/DevtoolsMessagesTable.ts @@ -10,12 +10,13 @@ export default class DevtoolsMessagesTable extends SqliteTable x.id === event.id && x.sessionId === event.sessionId, - ); - if (match) { - this.sentMessageIds.splice(this.sentMessageIds.indexOf(match), 1); - if (!frameId) frameId = match.frameId; - if (!requestId) requestId = match.requestId; + let result = event.result; + + if (result) { + if (method === 'Page.captureScreenshot') { + result = { data: `[truncated ${result.data.length} chars]` }; + } + if (method === 'Network.getResponseBody') { + result = { ...result, body: '[truncated]' }; } } function paramsStringifyFilter(key: string, value: any): any { if ( key === 'payload' && - event.method === 'Runtime.bindingCalled' && + method === 'Runtime.bindingCalled' && params.name === '__heroPageListenerCallback' && value?.length > 250 ) { @@ -86,7 +97,7 @@ export default class DevtoolsMessagesTable extends SqliteTable 50 ) { return `${value.substr(0, 50)}... [truncated ${value.length - 50} chars]`; @@ -111,7 +122,7 @@ export default class DevtoolsMessagesTable extends SqliteTable { ['tabId', 'INTEGER'], ['timestamp', 'INTEGER'], ]); - this.defaultSortOrder = 'timestamp ASC'; + this.defaultSortOrder = 'timestamp ASC,eventIndex ASC'; } public insert(tabId: number, frameId: number, commandId: number, change: IDomChangeEvent): void { diff --git a/examples/sessionReplay.ts b/examples/sessionReplay.ts index 0f61c1c95..6b9dca916 100644 --- a/examples/sessionReplay.ts +++ b/examples/sessionReplay.ts @@ -18,10 +18,19 @@ inspect.defaultOptions.depth = null; scriptEntrypoint = Path.resolve(process.cwd(), scriptEntrypoint); } const connectionToCoreApi = new DirectConnectionToCoreApi(); - const sessionReplay = new SessionReplay(connectionToCoreApi); - await sessionReplay.load({ - scriptEntrypoint, + + const { session } = await connectionToCoreApi.run({ + api: 'Session.find', + args: { + scriptEntrypoint, + }, }); + + const context = await SessionReplay.recreateBrowserContextForSession(session.id, true); + + const sessionReplay = new SessionReplay(session.id, connectionToCoreApi); + const startTab = await sessionReplay.open(context); + readline.createInterface({ input: process.stdin, output: process.stdout, @@ -35,15 +44,12 @@ inspect.defaultOptions.depth = null; readline.cursorTo(process.stdout, 0, 0); readline.clearScreenDown(process.stdout); - const currentLabel = - sessionReplay.startTab.currentTick?.label ?? - sessionReplay.startTab.currentTick?.eventType ?? - 'Start'; + const currentLabel = startTab.currentTick?.label ?? startTab.currentTick?.eventType ?? 'Start'; - const input = `${sessionReplay.startTab.currentTickIndex} / ${sessionReplay.startTab.ticks.length}: ${currentLabel}`; + const input = `${startTab.currentTickIndex} / ${startTab.ticks.length}: ${currentLabel}`; const prompt = `Interactive commands: P=${ - sessionReplay.startTab.isPlaying ? 'pause' : 'play' + startTab.isPlaying ? 'pause' : 'play' }, LeftArrow=Back, RightArrow=Forward, C=Close\n\n${input}`; process.stdout.write(prompt); @@ -51,7 +57,7 @@ inspect.defaultOptions.depth = null; async function close() { await sessionReplay.connection.disconnect(); - await sessionReplay.close(); + await sessionReplay.close(true); await Core.shutdown(); } @@ -64,19 +70,19 @@ inspect.defaultOptions.depth = null; } if (key.name === 'p') { - if (sessionReplay.startTab.isPlaying) { - sessionReplay.startTab.pause(); + if (startTab.isPlaying) { + startTab.pause(); } else { - await sessionReplay.startTab.play(() => render()); + await startTab.play(() => render()); } } if (key.name === 'right') { - sessionReplay.startTab.pause(); - await sessionReplay.startTab.goForward(); + startTab.pause(); + await startTab.goForward(); } if (key.name === 'left') { - sessionReplay.startTab.pause(); - await sessionReplay.startTab.goBack(); + startTab.pause(); + await startTab.goBack(); } render(); }); diff --git a/plugins/default-browser-emulator/lib/helpers/setUserAgent.ts b/plugins/default-browser-emulator/lib/helpers/setUserAgent.ts index 1f750c6d2..5eb1c0c58 100644 --- a/plugins/default-browser-emulator/lib/helpers/setUserAgent.ts +++ b/plugins/default-browser-emulator/lib/helpers/setUserAgent.ts @@ -2,9 +2,69 @@ import IDevtoolsSession from '@ulixee/hero-interfaces/IDevtoolsSession'; import BrowserEmulator from '../../index'; export default async function setUserAgent(emulator: BrowserEmulator, devtools: IDevtoolsSession) { + // Determine the full user agent string, strip the "Headless" part + const ua = emulator.userAgentString; + + // Full version number from Chrome + const chromeVersion = ua.match(/Chrome\/([\d|.]+)/)[1]; + + let platform = ''; + let platformVersion = ''; + + if (emulator.operatingSystemName === 'mac-os') { + platform = 'Mac OS X'; + platformVersion = ua.match(/Mac OS X ([^)]+)/)[1]; + } else if (emulator.operatingSystemName === 'windows') { + platform = 'Windows'; + platformVersion = ua.match(/Windows .*?([\d|.]+);/)[1]; + } + await devtools.send('Network.setUserAgentOverride', { userAgent: emulator.userAgentString, acceptLanguage: emulator.locale, platform: emulator.operatingSystemPlatform, + userAgentMetadata: { + brands: createBrands(emulator.browserVersion.major), + fullVersion: chromeVersion, + platform, + platformVersion, + architecture: 'x86', + model: '', + mobile: false, + }, }); } + +function createBrands(majorBrowserVersion: string) { + // Source in C++: https://source.chromium.org/chromium/chromium/src/+/master:components/embedder_support/user_agent_utils.cc;l=55-100 + const seed = majorBrowserVersion; + + const order = [ + [0, 1, 2], + [0, 2, 1], + [1, 0, 2], + [1, 2, 0], + [2, 0, 1], + [2, 1, 0], + ][Number(seed) % 6]; + const escapedChars = [' ', ' ', ';']; + + const greaseyBrand = `${escapedChars[order[0]]}Not${escapedChars[order[1]]}A${ + escapedChars[order[2]] + }Brand`; + + const brands = []; + brands[order[0]] = { + brand: greaseyBrand, + version: '99', + }; + brands[order[1]] = { + brand: 'Chromium', + version: seed, + }; + brands[order[2]] = { + brand: 'Google Chrome', + version: seed, + }; + return brands; +}