feat(trace): streaming snapshots (#5133)

- Instead of capturing snapshots on demand, we now stream them
  from each frame every 100ms.
- Certain actions can also force snapshots at particular moment using
  "checkpoints".
- Trace viewer is able to show the page snapshot at a particular
  timestamp, or using a "checkpoint" snapshot.
- Small optimization to not process stylesheets if CSSOM was not used.
  There still is a lot of room for improvement.
This commit is contained in:
Dmitry Gozman 2021-01-25 18:44:46 -08:00 committed by GitHub
parent 22fb7448c3
commit 5033261d27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 646 additions and 505 deletions

View File

@ -202,6 +202,8 @@ async function launchContext(options: Options, headless: boolean): Promise<{ bro
if (contextOptions.isMobile && browserType.name() === 'firefox') if (contextOptions.isMobile && browserType.name() === 'firefox')
contextOptions.isMobile = undefined; contextOptions.isMobile = undefined;
if (process.env.PWTRACE)
(contextOptions as any)._traceDir = path.join(process.cwd(), '.trace');
// Proxy // Proxy

View File

@ -19,8 +19,7 @@ import * as path from 'path';
import * as playwright from '../../..'; import * as playwright from '../../..';
import * as util from 'util'; import * as util from 'util';
import { SnapshotRouter } from './snapshotRouter'; import { SnapshotRouter } from './snapshotRouter';
import { actionById, ActionEntry, ContextEntry, TraceModel } from './traceModel'; import { actionById, ActionEntry, ContextEntry, PageEntry, TraceModel } from './traceModel';
import type { PageSnapshot } from '../../trace/traceTypes';
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs)); const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
@ -39,11 +38,9 @@ export class ScreenshotGenerator {
} }
generateScreenshot(actionId: string): Promise<Buffer | undefined> { generateScreenshot(actionId: string): Promise<Buffer | undefined> {
const { context, action } = actionById(this._traceModel, actionId); const { context, action, page } = actionById(this._traceModel, actionId);
if (!action.action.snapshot)
return Promise.resolve(undefined);
if (!this._rendering.has(action)) { if (!this._rendering.has(action)) {
this._rendering.set(action, this._render(context, action).then(body => { this._rendering.set(action, this._render(context, page, action).then(body => {
this._rendering.delete(action); this._rendering.delete(action);
return body; return body;
})); }));
@ -51,8 +48,8 @@ export class ScreenshotGenerator {
return this._rendering.get(action)!; return this._rendering.get(action)!;
} }
private async _render(contextEntry: ContextEntry, actionEntry: ActionEntry): Promise<Buffer | undefined> { private async _render(contextEntry: ContextEntry, pageEntry: PageEntry, actionEntry: ActionEntry): Promise<Buffer | undefined> {
const imageFileName = path.join(this._traceStorageDir, actionEntry.action.snapshot!.sha1 + '-screenshot.png'); const imageFileName = path.join(this._traceStorageDir, actionEntry.action.timestamp + '-screenshot.png');
try { try {
return await fsReadFileAsync(imageFileName); return await fsReadFileAsync(imageFileName);
} catch (e) { } catch (e) {
@ -70,27 +67,24 @@ export class ScreenshotGenerator {
}); });
try { try {
const snapshotPath = path.join(this._traceStorageDir, action.snapshot!.sha1);
let snapshot;
try {
snapshot = await fsReadFileAsync(snapshotPath, 'utf8');
} catch (e) {
console.log(`Unable to read snapshot at ${snapshotPath}`); // eslint-disable-line no-console
return;
}
const snapshotObject = JSON.parse(snapshot) as PageSnapshot;
const snapshotRouter = new SnapshotRouter(this._traceStorageDir); const snapshotRouter = new SnapshotRouter(this._traceStorageDir);
snapshotRouter.selectSnapshot(snapshotObject, contextEntry); const snapshots = action.snapshots || [];
const snapshotId = snapshots.length ? snapshots[0].snapshotId : undefined;
const snapshotTimestamp = action.startTime;
const pageUrl = await snapshotRouter.selectSnapshot(contextEntry, pageEntry, snapshotId, snapshotTimestamp);
page.route('**/*', route => snapshotRouter.route(route)); page.route('**/*', route => snapshotRouter.route(route));
const url = snapshotObject.frames[0].url; console.log('Generating screenshot for ' + action.action, pageUrl); // eslint-disable-line no-console
console.log('Generating screenshot for ' + action.action, snapshotObject.frames[0].url); // eslint-disable-line no-console await page.goto(pageUrl);
await page.goto(url);
const element = await page.$(action.selector || '*[__playwright_target__]'); try {
if (element) { const element = await page.$(action.selector || '*[__playwright_target__]');
await element.evaluate(e => { if (element) {
e.style.backgroundColor = '#ff69b460'; await element.evaluate(e => {
}); e.style.backgroundColor = '#ff69b460';
});
}
} catch (e) {
console.log(e); // eslint-disable-line no-console
} }
const imageData = await page.screenshot(); const imageData = await page.screenshot();
await fsWriteFileAsync(imageFileName, imageData); await fsWriteFileAsync(imageFileName, imageData);

View File

@ -17,55 +17,117 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as util from 'util'; import * as util from 'util';
import type { Route } from '../../..'; import type { Frame, Route } from '../../..';
import { parsedURL } from '../../client/clientHelper'; import { parsedURL } from '../../client/clientHelper';
import type { FrameSnapshot, NetworkResourceTraceEvent, PageSnapshot } from '../../trace/traceTypes'; import { ContextEntry, PageEntry, trace } from './traceModel';
import { ContextEntry } from './traceModel';
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
export class SnapshotRouter { export class SnapshotRouter {
private _contextEntry: ContextEntry | undefined; private _contextEntry: ContextEntry | undefined;
private _unknownUrls = new Set<string>(); private _unknownUrls = new Set<string>();
private _traceStorageDir: string; private _resourcesDir: string;
private _frameBySrc = new Map<string, FrameSnapshot>(); private _snapshotFrameIdToSnapshot = new Map<string, trace.FrameSnapshot>();
private _pageUrl = '';
private _frameToSnapshotFrameId = new Map<Frame, string>();
constructor(traceStorageDir: string) { constructor(resourcesDir: string) {
this._traceStorageDir = traceStorageDir; this._resourcesDir = resourcesDir;
} }
selectSnapshot(snapshot: PageSnapshot, contextEntry: ContextEntry) { // Returns the url to navigate to.
this._frameBySrc.clear(); async selectSnapshot(contextEntry: ContextEntry, pageEntry: PageEntry, snapshotId?: string, timestamp?: number): Promise<string> {
this._contextEntry = contextEntry; this._contextEntry = contextEntry;
for (const frameSnapshot of snapshot.frames) if (!snapshotId && !timestamp)
this._frameBySrc.set(frameSnapshot.url, frameSnapshot); return 'data:text/html,Snapshot is not available';
const lastSnapshotEvent = new Map<string, trace.FrameSnapshotTraceEvent>();
for (const [frameId, snapshots] of pageEntry.snapshotsByFrameId) {
for (const snapshot of snapshots) {
const current = lastSnapshotEvent.get(frameId);
// Prefer snapshot with exact id.
const exactMatch = snapshotId && snapshot.snapshotId === snapshotId;
const currentExactMatch = current && snapshotId && current.snapshotId === snapshotId;
// If not available, prefer the latest snapshot before the timestamp.
const timestampMatch = timestamp && snapshot.timestamp <= timestamp;
if (exactMatch || (timestampMatch && !currentExactMatch))
lastSnapshotEvent.set(frameId, snapshot);
}
}
this._snapshotFrameIdToSnapshot.clear();
for (const [frameId, event] of lastSnapshotEvent) {
const buffer = await this._readSha1(event.sha1);
if (!buffer)
continue;
try {
const snapshot = JSON.parse(buffer.toString('utf8')) as trace.FrameSnapshot;
// Request url could come lower case, so we always normalize to lower case.
this._snapshotFrameIdToSnapshot.set(frameId.toLowerCase(), snapshot);
} catch (e) {
}
}
const mainFrameSnapshot = lastSnapshotEvent.get('');
if (!mainFrameSnapshot)
return 'data:text/html,Snapshot is not available';
if (!mainFrameSnapshot.frameUrl.startsWith('http'))
this._pageUrl = 'http://playwright.snapshot/';
else
this._pageUrl = mainFrameSnapshot.frameUrl;
return this._pageUrl;
} }
async route(route: Route) { async route(route: Route) {
const url = route.request().url(); const url = route.request().url();
if (this._frameBySrc.has(url)) { const frame = route.request().frame();
const frameSnapshot = this._frameBySrc.get(url)!;
if (route.request().isNavigationRequest()) {
let snapshotFrameId: string | undefined;
if (url === this._pageUrl) {
snapshotFrameId = '';
} else {
snapshotFrameId = url.substring(url.indexOf('://') + 3);
if (snapshotFrameId.endsWith('/'))
snapshotFrameId = snapshotFrameId.substring(0, snapshotFrameId.length - 1);
// Request url could come lower case, so we always normalize to lower case.
snapshotFrameId = snapshotFrameId.toLowerCase();
}
const snapshot = this._snapshotFrameIdToSnapshot.get(snapshotFrameId);
if (!snapshot) {
route.fulfill({
contentType: 'text/html',
body: 'data:text/html,Snapshot is not available',
});
return;
}
this._frameToSnapshotFrameId.set(frame, snapshotFrameId);
route.fulfill({ route.fulfill({
contentType: 'text/html', contentType: 'text/html',
body: Buffer.from(frameSnapshot.html), body: snapshot.html,
}); });
return; return;
} }
const frameSrc = route.request().frame().url(); const snapshotFrameId = this._frameToSnapshotFrameId.get(frame);
const frameSnapshot = this._frameBySrc.get(frameSrc); if (snapshotFrameId === undefined)
if (!frameSnapshot) return this._routeUnknown(route);
const snapshot = this._snapshotFrameIdToSnapshot.get(snapshotFrameId);
if (!snapshot)
return this._routeUnknown(route); return this._routeUnknown(route);
// Find a matching resource from the same context, preferrably from the same frame. // Find a matching resource from the same context, preferrably from the same frame.
// Note: resources are stored without hash, but page may reference them with hash. // Note: resources are stored without hash, but page may reference them with hash.
let resource: NetworkResourceTraceEvent | null = null; let resource: trace.NetworkResourceTraceEvent | null = null;
const resourcesWithUrl = this._contextEntry!.resourcesByUrl.get(removeHash(url)) || []; const resourcesWithUrl = this._contextEntry!.resourcesByUrl.get(removeHash(url)) || [];
for (const resourceEvent of resourcesWithUrl) { for (const resourceEvent of resourcesWithUrl) {
if (resource && resourceEvent.frameId !== frameSnapshot.frameId) if (resource && resourceEvent.frameId !== snapshotFrameId)
continue; continue;
resource = resourceEvent; resource = resourceEvent;
if (resourceEvent.frameId === frameSnapshot.frameId) if (resourceEvent.frameId === snapshotFrameId)
break; break;
} }
if (!resource) if (!resource)
@ -73,7 +135,7 @@ export class SnapshotRouter {
// This particular frame might have a resource content override, for example when // This particular frame might have a resource content override, for example when
// stylesheet is modified using CSSOM. // stylesheet is modified using CSSOM.
const resourceOverride = frameSnapshot.resourceOverrides.find(o => o.url === url); const resourceOverride = snapshot.resourceOverrides.find(o => o.url === url);
const overrideSha1 = resourceOverride ? resourceOverride.sha1 : undefined; const overrideSha1 = resourceOverride ? resourceOverride.sha1 : undefined;
const resourceData = await this._readResource(resource, overrideSha1); const resourceData = await this._readResource(resource, overrideSha1);
if (!resourceData) if (!resourceData)
@ -98,18 +160,24 @@ export class SnapshotRouter {
route.abort(); route.abort();
} }
private async _readResource(event: NetworkResourceTraceEvent, overrideSha1: string | undefined) { private async _readSha1(sha1: string) {
try { try {
const body = await fsReadFileAsync(path.join(this._traceStorageDir, overrideSha1 || event.sha1)); return await fsReadFileAsync(path.join(this._resourcesDir, sha1));
return {
contentType: event.contentType,
body,
headers: event.responseHeaders,
};
} catch (e) { } catch (e) {
return undefined; return undefined;
} }
} }
private async _readResource(event: trace.NetworkResourceTraceEvent, overrideSha1: string | undefined) {
const body = await this._readSha1(overrideSha1 || event.sha1);
if (!body)
return;
return {
contentType: event.contentType,
body,
headers: event.responseHeaders,
};
}
} }
function removeHash(url: string) { function removeHash(url: string) {

View File

@ -46,6 +46,7 @@ export type PageEntry = {
actions: ActionEntry[]; actions: ActionEntry[];
interestingEvents: InterestingPageEvent[]; interestingEvents: InterestingPageEvent[];
resources: trace.NetworkResourceTraceEvent[]; resources: trace.NetworkResourceTraceEvent[];
snapshotsByFrameId: Map<string, trace.FrameSnapshotTraceEvent[]>;
} }
export type ActionEntry = { export type ActionEntry = {
@ -93,6 +94,7 @@ export function readTraceFile(events: trace.TraceEvent[], traceModel: TraceModel
actions: [], actions: [],
resources: [], resources: [],
interestingEvents: [], interestingEvents: [],
snapshotsByFrameId: new Map(),
}; };
pageEntries.set(event.pageId, pageEntry); pageEntries.set(event.pageId, pageEntry);
contextEntries.get(event.contextId)!.pages.push(pageEntry); contextEntries.get(event.contextId)!.pages.push(pageEntry);
@ -144,6 +146,13 @@ export function readTraceFile(events: trace.TraceEvent[], traceModel: TraceModel
pageEntry.interestingEvents.push(event); pageEntry.interestingEvents.push(event);
break; break;
} }
case 'snapshot': {
const pageEntry = pageEntries.get(event.pageId!)!;
if (!pageEntry.snapshotsByFrameId.has(event.frameId))
pageEntry.snapshotsByFrameId.set(event.frameId, []);
pageEntry.snapshotsByFrameId.get(event.frameId)!.push(event);
break;
}
} }
const contextEntry = contextEntries.get(event.contextId)!; const contextEntry = contextEntries.get(event.contextId)!;

View File

@ -21,7 +21,7 @@ import * as util from 'util';
import { ScreenshotGenerator } from './screenshotGenerator'; import { ScreenshotGenerator } from './screenshotGenerator';
import { SnapshotRouter } from './snapshotRouter'; import { SnapshotRouter } from './snapshotRouter';
import { readTraceFile, TraceModel } from './traceModel'; import { readTraceFile, TraceModel } from './traceModel';
import type { ActionTraceEvent, PageSnapshot, TraceEvent } from '../../trace/traceTypes'; import type { ActionTraceEvent, TraceEvent } from '../../trace/traceTypes';
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
@ -92,25 +92,20 @@ class TraceViewer {
await uiPage.exposeBinding('readFile', async (_, path: string) => { await uiPage.exposeBinding('readFile', async (_, path: string) => {
return fs.readFileSync(path).toString(); return fs.readFileSync(path).toString();
}); });
await uiPage.exposeBinding('renderSnapshot', async (_, action: ActionTraceEvent) => { await uiPage.exposeBinding('renderSnapshot', async (_, arg: { action: ActionTraceEvent, snapshot: { name: string, snapshotId?: string } }) => {
const { action, snapshot } = arg;
if (!this._document) if (!this._document)
return; return;
try { try {
if (!action.snapshot) {
const snapshotFrame = uiPage.frames()[1];
await snapshotFrame.goto('data:text/html,No snapshot available');
return;
}
const snapshot = await fsReadFileAsync(path.join(this._document.resourcesDir, action.snapshot!.sha1), 'utf8');
const snapshotObject = JSON.parse(snapshot) as PageSnapshot;
const contextEntry = this._document.model.contexts.find(entry => entry.created.contextId === action.contextId)!; const contextEntry = this._document.model.contexts.find(entry => entry.created.contextId === action.contextId)!;
this._document.snapshotRouter.selectSnapshot(snapshotObject, contextEntry); const pageEntry = contextEntry.pages.find(entry => entry.created.pageId === action.pageId)!;
const snapshotTime = snapshot.name === 'before' ? action.startTime : (snapshot.name === 'after' ? action.endTime : undefined);
const pageUrl = await this._document.snapshotRouter.selectSnapshot(contextEntry, pageEntry, snapshot.snapshotId, snapshotTime);
// TODO: fix Playwright bug where frame.name is lost (empty). // TODO: fix Playwright bug where frame.name is lost (empty).
const snapshotFrame = uiPage.frames()[1]; const snapshotFrame = uiPage.frames()[1];
try { try {
await snapshotFrame.goto(snapshotObject.frames[0].url); await snapshotFrame.goto(pageUrl);
} catch (e) { } catch (e) {
if (!e.message.includes('frame was detached')) if (!e.message.includes('frame was detached'))
console.error(e); console.error(e);

View File

@ -25,7 +25,7 @@ declare global {
interface Window { interface Window {
getTraceModel(): Promise<TraceModel>; getTraceModel(): Promise<TraceModel>;
readFile(filePath: string): Promise<string>; readFile(filePath: string): Promise<string>;
renderSnapshot(action: trace.ActionTraceEvent): void; renderSnapshot(arg: { action: trace.ActionTraceEvent, snapshot: { name: string, snapshotId?: string } }): void;
} }
} }

View File

@ -68,6 +68,28 @@
font-weight: 600; font-weight: 600;
} }
.snapshot-tab {
display: flex;
flex-direction: column;
align-items: stretch;
}
.snapshot-controls {
flex: 0 0 24px;
display: flex;
flex-direction: row;
align-items: center;
}
.snapshot-toggle {
padding: 5px 10px;
cursor: pointer;
}
.snapshot-toggle.toggled {
background: var(--inactive-focus-ring);
}
.snapshot-wrapper { .snapshot-wrapper {
flex: auto; flex: auto;
margin: 1px; margin: 1px;

View File

@ -72,6 +72,16 @@ const SnapshotTab: React.FunctionComponent<{
}> = ({ actionEntry, snapshotSize }) => { }> = ({ actionEntry, snapshotSize }) => {
const [measure, ref] = useMeasure<HTMLDivElement>(); const [measure, ref] = useMeasure<HTMLDivElement>();
let snapshots: { name: string, snapshotId?: string }[] = [];
snapshots = (actionEntry ? (actionEntry.action.snapshots || []) : []).slice();
if (!snapshots.length || snapshots[0].name !== 'before')
snapshots.unshift({ name: 'before', snapshotId: undefined });
if (snapshots[snapshots.length - 1].name !== 'after')
snapshots.push({ name: 'after', snapshotId: undefined });
const [snapshotIndex, setSnapshotIndex] = React.useState(0);
const iframeRef = React.createRef<HTMLIFrameElement>(); const iframeRef = React.createRef<HTMLIFrameElement>();
React.useEffect(() => { React.useEffect(() => {
if (iframeRef.current && !actionEntry) if (iframeRef.current && !actionEntry)
@ -80,17 +90,29 @@ const SnapshotTab: React.FunctionComponent<{
React.useEffect(() => { React.useEffect(() => {
if (actionEntry) if (actionEntry)
(window as any).renderSnapshot(actionEntry.action); (window as any).renderSnapshot({ action: actionEntry.action, snapshot: snapshots[snapshotIndex] });
}, [actionEntry]); }, [actionEntry, snapshotIndex]);
const scale = Math.min(measure.width / snapshotSize.width, measure.height / snapshotSize.height); const scale = Math.min(measure.width / snapshotSize.width, measure.height / snapshotSize.height);
return <div ref={ref} className='snapshot-wrapper'> return <div className='snapshot-tab'>
<div className='snapshot-container' style={{ <div className='snapshot-controls'>{
width: snapshotSize.width + 'px', snapshots.map((snapshot, index) => {
height: snapshotSize.height + 'px', return <div
transform: `translate(${-snapshotSize.width * (1 - scale) / 2}px, ${-snapshotSize.height * (1 - scale) / 2}px) scale(${scale})`, key={snapshot.name}
}}> className={'snapshot-toggle' + (snapshotIndex === index ? ' toggled' : '')}
<iframe ref={iframeRef} id='snapshot' name='snapshot'></iframe> onClick={() => setSnapshotIndex(index)}>
{snapshot.name}
</div>
})
}</div>
<div ref={ref} className='snapshot-wrapper'>
<div className='snapshot-container' style={{
width: snapshotSize.width + 'px',
height: snapshotSize.height + 'px',
transform: `translate(${-snapshotSize.width * (1 - scale) / 2}px, ${-snapshotSize.height * (1 - scale) / 2}px) scale(${scale})`,
}}>
<iframe ref={iframeRef} id='snapshot' name='snapshot'></iframe>
</div>
</div> </div>
</div>; </div>;
}; };

View File

@ -67,14 +67,22 @@ export type ActionMetadata = {
}; };
export interface ActionListener { export interface ActionListener {
onActionCheckpoint(name: string, metadata: ActionMetadata): Promise<void>;
onAfterAction(result: ProgressResult, metadata: ActionMetadata): Promise<void>; onAfterAction(result: ProgressResult, metadata: ActionMetadata): Promise<void>;
} }
export async function runAction<T>(task: (controller: ProgressController) => Promise<T>, metadata: ActionMetadata): Promise<T> { export async function runAction<T>(task: (controller: ProgressController) => Promise<T>, metadata: ActionMetadata): Promise<T> {
const controller = new ProgressController(); const controller = new ProgressController();
controller.setListener(async result => { controller.setListener({
for (const listener of metadata.page._browserContext._actionListeners) onProgressCheckpoint: async (name: string): Promise<void> => {
await listener.onAfterAction(result, metadata); for (const listener of metadata.page._browserContext._actionListeners)
await listener.onActionCheckpoint(name, metadata);
},
onProgressDone: async (result: ProgressResult): Promise<void> => {
for (const listener of metadata.page._browserContext._actionListeners)
await listener.onAfterAction(result, metadata);
},
}); });
const result = await task(controller); const result = await task(controller);
return result; return result;

View File

@ -378,6 +378,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (options && options.modifiers) if (options && options.modifiers)
restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers); restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers);
progress.log(` performing ${actionName} action`); progress.log(` performing ${actionName} action`);
await progress.checkpoint('before');
await action(point); await action(point);
progress.log(` ${actionName} action done`); progress.log(` ${actionName} action done`);
progress.log(' waiting for scheduled navigations to finish'); progress.log(' waiting for scheduled navigations to finish');
@ -447,6 +448,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
progress.throwIfAborted(); // Avoid action that has side-effects. progress.throwIfAborted(); // Avoid action that has side-effects.
progress.log(' selecting specified option(s)'); progress.log(' selecting specified option(s)');
await progress.checkpoint('before');
const poll = await this._evaluateHandleInUtility(([injected, node, selectOptions]) => injected.waitForOptionsAndSelect(node, selectOptions), selectOptions); const poll = await this._evaluateHandleInUtility(([injected, node, selectOptions]) => injected.waitForOptionsAndSelect(node, selectOptions), selectOptions);
const pollHandler = new InjectedScriptPollHandler(progress, poll); const pollHandler = new InjectedScriptPollHandler(progress, poll);
const result = throwFatalDOMError(await pollHandler.finish()); const result = throwFatalDOMError(await pollHandler.finish());
@ -475,6 +477,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (filled === 'error:notconnected') if (filled === 'error:notconnected')
return filled; return filled;
progress.log(' element is visible, enabled and editable'); progress.log(' element is visible, enabled and editable');
await progress.checkpoint('before');
if (filled === 'needsinput') { if (filled === 'needsinput') {
progress.throwIfAborted(); // Avoid action that has side-effects. progress.throwIfAborted(); // Avoid action that has side-effects.
if (value) if (value)
@ -521,6 +524,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
assert(multiple || files.length <= 1, 'Non-multiple file input can only accept single file!'); assert(multiple || files.length <= 1, 'Non-multiple file input can only accept single file!');
await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
progress.throwIfAborted(); // Avoid action that has side-effects. progress.throwIfAborted(); // Avoid action that has side-effects.
await progress.checkpoint('before');
await this._page._delegate.setInputFiles(this as any as ElementHandle<HTMLInputElement>, files); await this._page._delegate.setInputFiles(this as any as ElementHandle<HTMLInputElement>, files);
}); });
await this._page._doSlowMo(); await this._page._doSlowMo();
@ -555,6 +559,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (result !== 'done') if (result !== 'done')
return result; return result;
progress.throwIfAborted(); // Avoid action that has side-effects. progress.throwIfAborted(); // Avoid action that has side-effects.
await progress.checkpoint('before');
await this._page.keyboard.type(text, options); await this._page.keyboard.type(text, options);
return 'done'; return 'done';
}, 'input'); }, 'input');
@ -574,6 +579,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (result !== 'done') if (result !== 'done')
return result; return result;
progress.throwIfAborted(); // Avoid action that has side-effects. progress.throwIfAborted(); // Avoid action that has side-effects.
await progress.checkpoint('before');
await this._page.keyboard.press(key, options); await this._page.keyboard.press(key, options);
return 'done'; return 'done';
}, 'input'); }, 'input');

View File

@ -131,8 +131,11 @@ export class FrameManager {
if (progress) if (progress)
progress.cleanupWhenAborted(() => this._signalBarriers.delete(barrier)); progress.cleanupWhenAborted(() => this._signalBarriers.delete(barrier));
const result = await action(); const result = await action();
if (source === 'input') if (source === 'input') {
await this._page._delegate.inputActionEpilogue(); await this._page._delegate.inputActionEpilogue();
if (progress)
await progress.checkpoint('after');
}
await barrier.waitFor(); await barrier.waitFor();
this._signalBarriers.delete(barrier); this._signalBarriers.delete(barrier);
// Resolve in the next task, after all waitForNavigations. // Resolve in the next task, after all waitForNavigations.

View File

@ -33,6 +33,12 @@ export interface Progress {
isRunning(): boolean; isRunning(): boolean;
cleanupWhenAborted(cleanup: () => any): void; cleanupWhenAborted(cleanup: () => any): void;
throwIfAborted(): void; throwIfAborted(): void;
checkpoint(name: string): Promise<void>;
}
export interface ProgressListener {
onProgressCheckpoint(name: string): Promise<void>;
onProgressDone(result: ProgressResult): Promise<void>;
} }
export async function runAbortableTask<T>(task: (progress: Progress) => Promise<T>, timeout: number): Promise<T> { export async function runAbortableTask<T>(task: (progress: Progress) => Promise<T>, timeout: number): Promise<T> {
@ -59,7 +65,7 @@ export class ProgressController {
private _deadline: number = 0; private _deadline: number = 0;
private _timeout: number = 0; private _timeout: number = 0;
private _logRecording: string[] = []; private _logRecording: string[] = [];
private _listener?: (result: ProgressResult) => Promise<void>; private _listener?: ProgressListener;
constructor() { constructor() {
this._forceAbortPromise = new Promise((resolve, reject) => this._forceAbort = reject); this._forceAbortPromise = new Promise((resolve, reject) => this._forceAbort = reject);
@ -71,7 +77,7 @@ export class ProgressController {
this._logName = logName; this._logName = logName;
} }
setListener(listener: (result: ProgressResult) => Promise<void>) { setListener(listener: ProgressListener) {
this._listener = listener; this._listener = listener;
} }
@ -103,6 +109,10 @@ export class ProgressController {
if (this._state === 'aborted') if (this._state === 'aborted')
throw new AbortedError(); throw new AbortedError();
}, },
checkpoint: async (name: string) => {
if (this._listener)
await this._listener.onProgressCheckpoint(name);
},
}; };
const timeoutError = new TimeoutError(`Timeout ${this._timeout}ms exceeded.`); const timeoutError = new TimeoutError(`Timeout ${this._timeout}ms exceeded.`);
@ -114,7 +124,7 @@ export class ProgressController {
clearTimeout(timer); clearTimeout(timer);
this._state = 'finished'; this._state = 'finished';
if (this._listener) { if (this._listener) {
await this._listener({ await this._listener.onProgressDone({
startTime, startTime,
endTime: monotonicTime(), endTime: monotonicTime(),
logs: this._logRecording, logs: this._logRecording,
@ -128,7 +138,7 @@ export class ProgressController {
this._state = 'aborted'; this._state = 'aborted';
await Promise.all(this._cleanups.splice(0).map(cleanup => runCleanup(cleanup))); await Promise.all(this._cleanups.splice(0).map(cleanup => runCleanup(cleanup)));
if (this._listener) { if (this._listener) {
await this._listener({ await this._listener.onProgressDone({
startTime, startTime,
endTime: monotonicTime(), endTime: monotonicTime(),
logs: this._logRecording, logs: this._logRecording,

View File

@ -18,16 +18,11 @@ import { BrowserContext } from '../server/browserContext';
import { Page } from '../server/page'; import { Page } from '../server/page';
import * as network from '../server/network'; import * as network from '../server/network';
import { helper, RegisteredListener } from '../server/helper'; import { helper, RegisteredListener } from '../server/helper';
import { stripFragmentFromUrl } from '../server/network';
import { Progress, runAbortableTask } from '../server/progress';
import { debugLogger } from '../utils/debugLogger'; import { debugLogger } from '../utils/debugLogger';
import { Frame } from '../server/frames'; import { Frame } from '../server/frames';
import * as js from '../server/javascript'; import { SnapshotData, frameSnapshotStreamer, kSnapshotBinding, kSnapshotStreamer } from './snapshotterInjected';
import * as types from '../server/types'; import { calculateSha1 } from '../utils/utils';
import { SnapshotData, takeSnapshotInFrame } from './snapshotterInjected'; import { FrameSnapshot } from './traceTypes';
import { assert, calculateSha1, createGuid } from '../utils/utils';
import { ElementHandle } from '../server/dom';
import { FrameSnapshot, PageSnapshot } from './traceTypes';
export type SnapshotterResource = { export type SnapshotterResource = {
pageId: string, pageId: string,
@ -46,6 +41,7 @@ export type SnapshotterBlob = {
export interface SnapshotterDelegate { export interface SnapshotterDelegate {
onBlob(blob: SnapshotterBlob): void; onBlob(blob: SnapshotterBlob): void;
onResource(resource: SnapshotterResource): void; onResource(resource: SnapshotterResource): void;
onFrameSnapshot(frame: Frame, snapshot: FrameSnapshot, snapshotId?: string): void;
pageId(page: Page): string; pageId(page: Page): string;
} }
@ -60,16 +56,63 @@ export class Snapshotter {
this._eventListeners = [ this._eventListeners = [
helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)), helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)),
]; ];
this._context.exposeBinding(kSnapshotBinding, false, (source, data: SnapshotData) => {
const snapshot: FrameSnapshot = {
html: data.html,
viewport: data.viewport,
resourceOverrides: [],
url: data.url,
};
for (const { url, content } of data.resourceOverrides) {
const buffer = Buffer.from(content);
const sha1 = calculateSha1(buffer);
this._delegate.onBlob({ sha1, buffer });
snapshot.resourceOverrides.push({ url, sha1 });
}
this._delegate.onFrameSnapshot(source.frame, snapshot, data.snapshotId);
});
this._context._doAddInitScript('(' + frameSnapshotStreamer.toString() + ')()');
} }
dispose() { dispose() {
helper.removeEventListeners(this._eventListeners); helper.removeEventListeners(this._eventListeners);
} }
async forceSnapshot(page: Page, snapshotId: string) {
await Promise.all([
page.frames().forEach(async frame => {
try {
const context = await frame._mainContext();
await context.evaluateInternal(({ kSnapshotStreamer, snapshotId }) => {
// Do not block action execution on the actual snapshot.
Promise.resolve().then(() => (window as any)[kSnapshotStreamer].forceSnapshot(snapshotId));
return undefined;
}, { kSnapshotStreamer, snapshotId });
} catch (e) {
}
})
]);
}
private _onPage(page: Page) { private _onPage(page: Page) {
this._eventListeners.push(helper.addEventListener(page, Page.Events.Response, (response: network.Response) => { this._eventListeners.push(helper.addEventListener(page, Page.Events.Response, (response: network.Response) => {
this._saveResource(page, response).catch(e => debugLogger.log('error', e)); this._saveResource(page, response).catch(e => debugLogger.log('error', e));
})); }));
this._eventListeners.push(helper.addEventListener(page, Page.Events.FrameAttached, async (frame: Frame) => {
try {
const frameElement = await frame.frameElement();
const parent = frame.parentFrame();
if (!parent)
return;
const context = await parent._mainContext();
await context.evaluateInternal(({ kSnapshotStreamer, frameElement, frameId }) => {
(window as any)[kSnapshotStreamer].markIframe(frameElement, frameId);
}, { kSnapshotStreamer, frameElement, frameId: frame._id });
frameElement.dispose();
} catch (e) {
// Ignore
}
}));
} }
private async _saveResource(page: Page, response: network.Response) { private async _saveResource(page: Page, response: network.Response) {
@ -103,121 +146,4 @@ export class Snapshotter {
if (body) if (body)
this._delegate.onBlob({ sha1, buffer: body }); this._delegate.onBlob({ sha1, buffer: body });
} }
async takeSnapshot(page: Page, target: ElementHandle | undefined, timeout: number): Promise<PageSnapshot | null> {
assert(page.context() === this._context);
const frames = page.frames();
const frameSnapshotPromises = frames.map(async frame => {
// TODO: use different timeout depending on the frame depth/origin
// to avoid waiting for too long for some useless frame.
const frameResult = await runAbortableTask(progress => this._snapshotFrame(progress, target, frame), timeout).catch(e => null);
if (frameResult)
return frameResult;
const frameSnapshot = {
frameId: frame._id,
url: stripFragmentFromUrl(frame.url()),
html: '<body>Snapshot is not available</body>',
resourceOverrides: [],
};
return { snapshot: frameSnapshot, mapping: new Map<Frame, string>() };
});
const viewportSize = await this._getViewportSize(page, timeout);
const results = await Promise.all(frameSnapshotPromises);
if (!viewportSize)
return null;
const mainFrame = results[0];
if (!mainFrame.snapshot.url.startsWith('http'))
mainFrame.snapshot.url = 'http://playwright.snapshot/';
const mapping = new Map<Frame, string>();
for (const result of results) {
for (const [key, value] of result.mapping)
mapping.set(key, value);
}
const childFrames: FrameSnapshot[] = [];
for (let i = 1; i < results.length; i++) {
const result = results[i];
const frame = frames[i];
if (!mapping.has(frame))
continue;
const frameSnapshot = result.snapshot;
frameSnapshot.url = mapping.get(frame)!;
childFrames.push(frameSnapshot);
}
return {
viewportSize,
frames: [mainFrame.snapshot, ...childFrames],
};
}
private async _getViewportSize(page: Page, timeout: number): Promise<types.Size | null> {
return runAbortableTask(async progress => {
const viewportSize = page.viewportSize();
if (viewportSize)
return viewportSize;
const context = await page.mainFrame()._utilityContext();
return context.evaluateInternal(() => {
return {
width: Math.max(document.body.offsetWidth, document.documentElement.offsetWidth),
height: Math.max(document.body.offsetHeight, document.documentElement.offsetHeight),
};
});
}, timeout).catch(e => null);
}
private async _snapshotFrame(progress: Progress, target: ElementHandle | undefined, frame: Frame): Promise<FrameSnapshotAndMapping | null> {
if (!progress.isRunning())
return null;
if (target && (await target.ownerFrame()) !== frame)
target = undefined;
const context = await frame._utilityContext();
const guid = createGuid();
const removeNoScript = !frame._page.context()._options.javaScriptEnabled;
const result = await js.evaluate(context, false /* returnByValue */, takeSnapshotInFrame, guid, removeNoScript, target) as js.JSHandle;
if (!progress.isRunning())
return null;
const properties = await result.getProperties();
const data = await properties.get('data')!.jsonValue() as SnapshotData;
const frameElements = await properties.get('frameElements')!.getProperties();
result.dispose();
const snapshot: FrameSnapshot = {
frameId: frame._id,
url: stripFragmentFromUrl(frame.url()),
html: data.html,
resourceOverrides: [],
};
const mapping = new Map<Frame, string>();
for (const { url, content } of data.resourceOverrides) {
const buffer = Buffer.from(content);
const sha1 = calculateSha1(buffer);
this._delegate.onBlob({ sha1, buffer });
snapshot.resourceOverrides.push({ url, sha1 });
}
for (let i = 0; i < data.frameUrls.length; i++) {
const element = frameElements.get(String(i))!.asElement();
if (!element)
continue;
const frame = await element.contentFrame().catch(e => null);
if (frame)
mapping.set(frame, data.frameUrls[i]);
}
return { snapshot, mapping };
}
} }
type FrameSnapshotAndMapping = {
snapshot: FrameSnapshot,
mapping: Map<Frame, string>,
};

View File

@ -17,276 +17,334 @@
export type SnapshotData = { export type SnapshotData = {
html: string, html: string,
resourceOverrides: { url: string, content: string }[], resourceOverrides: { url: string, content: string }[],
frameUrls: string[], viewport: { width: number, height: number },
url: string,
snapshotId?: string,
}; };
type SnapshotResult = { export const kSnapshotStreamer = '__playwright_snapshot_streamer_';
data: SnapshotData, export const kSnapshotFrameIdAttribute = '__playwright_snapshot_frameid_';
frameElements: Element[], export const kSnapshotBinding = '__playwright_snapshot_binding_';
};
export function takeSnapshotInFrame(guid: string, removeNoScript: boolean, target: Node | undefined): SnapshotResult { export function frameSnapshotStreamer() {
const shadowAttribute = 'playwright-shadow-root'; const kSnapshotStreamer = '__playwright_snapshot_streamer_';
const win = window; const kSnapshotFrameIdAttribute = '__playwright_snapshot_frameid_';
const doc = win.document; const kSnapshotBinding = '__playwright_snapshot_binding_';
const kShadowAttribute = '__playwright_shadow_root_';
const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
const escaped = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' }; const escaped = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' };
const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
const escapeAttribute = (s: string): string => { class Streamer {
return s.replace(/[&<>"']/ug, char => (escaped as any)[char]); private _removeNoScript = true;
}; private _needStyleOverrides = false;
const escapeText = (s: string): string => { private _timer: NodeJS.Timeout | undefined;
return s.replace(/[&<]/ug, char => (escaped as any)[char]);
};
const escapeScriptString = (s: string): string => {
return s.replace(/'/g, '\\\'');
};
const chunks = new Map<string, string>(); constructor() {
const frameUrlToFrameElement = new Map<string, Element>(); this._streamSnapshot();
const styleNodeToStyleSheetText = new Map<Node, string>(); this._interceptCSSOM(window.CSSStyleSheet.prototype, 'insertRule');
const styleSheetUrlToContentOverride = new Map<string, string>(); this._interceptCSSOM(window.CSSStyleSheet.prototype, 'deleteRule');
this._interceptCSSOM(window.CSSStyleSheet.prototype, 'addRule');
this._interceptCSSOM(window.CSSStyleSheet.prototype, 'removeRule');
// TODO: should we also intercept setters like CSSRule.cssText and CSSStyleRule.selectorText?
}
let counter = 0; private _interceptCSSOM(obj: any, method: string) {
const nextId = (): string => { const self = this;
return guid + (++counter); const native = obj[method] as Function;
}; if (!native)
return;
obj[method] = function(...args: any[]) {
self._needStyleOverrides = true;
native.call(this, ...args);
};
}
const resolve = (base: string, url: string): string => { markIframe(iframeElement: HTMLIFrameElement | HTMLFrameElement, frameId: string) {
if (url === '') iframeElement.setAttribute(kSnapshotFrameIdAttribute, frameId);
return ''; }
try {
return new URL(url, base).href; forceSnapshot(snapshotId: string) {
} catch (e) { this._streamSnapshot(snapshotId);
}
private _streamSnapshot(snapshotId?: string) {
if (this._timer) {
clearTimeout(this._timer);
this._timer = undefined;
}
const snapshot = this._captureSnapshot(snapshotId);
(window as any)[kSnapshotBinding](snapshot).catch((e: any) => {});
this._timer = setTimeout(() => this._streamSnapshot(), 100);
}
private _escapeAttribute(s: string): string {
return s.replace(/[&<>"']/ug, char => (escaped as any)[char]);
}
private _escapeText(s: string): string {
return s.replace(/[&<]/ug, char => (escaped as any)[char]);
}
private _sanitizeUrl(url: string): string {
if (url.startsWith('javascript:'))
return '';
return url; return url;
} }
};
const sanitizeUrl = (url: string): string => { private _sanitizeSrcSet(srcset: string): string {
if (url.startsWith('javascript:')) return srcset.split(',').map(src => {
return ''; src = src.trim();
return url; const spaceIndex = src.lastIndexOf(' ');
}; if (spaceIndex === -1)
return this._sanitizeUrl(src);
return this._sanitizeUrl(src.substring(0, spaceIndex).trim()) + src.substring(spaceIndex);
}).join(',');
}
const sanitizeSrcSet = (srcset: string): string => { private _resolveUrl(base: string, url: string): string {
return srcset.split(',').map(src => { if (url === '')
src = src.trim(); return '';
const spaceIndex = src.lastIndexOf(' '); try {
if (spaceIndex === -1) return new URL(url, base).href;
return sanitizeUrl(src); } catch (e) {
return sanitizeUrl(src.substring(0, spaceIndex).trim()) + src.substring(spaceIndex); return url;
}).join(',');
};
const getSheetBase = (sheet: CSSStyleSheet): string => {
let rootSheet = sheet;
while (rootSheet.parentStyleSheet)
rootSheet = rootSheet.parentStyleSheet;
if (rootSheet.ownerNode)
return rootSheet.ownerNode.baseURI;
return document.baseURI;
};
const getSheetText = (sheet: CSSStyleSheet): string => {
const rules: string[] = [];
for (const rule of sheet.cssRules)
rules.push(rule.cssText);
return rules.join('\n');
};
const visitStyleSheet = (sheet: CSSStyleSheet) => {
try {
for (const rule of sheet.cssRules) {
if ((rule as CSSImportRule).styleSheet)
visitStyleSheet((rule as CSSImportRule).styleSheet);
} }
const cssText = getSheetText(sheet);
if (sheet.ownerNode && sheet.ownerNode.nodeName === 'STYLE') {
// Stylesheets with owner STYLE nodes will be rewritten.
styleNodeToStyleSheetText.set(sheet.ownerNode, cssText);
} else if (sheet.href !== null) {
// Other stylesheets will have resource overrides.
const base = getSheetBase(sheet);
const url = resolve(base, sheet.href);
styleSheetUrlToContentOverride.set(url, cssText);
}
} catch (e) {
// Sometimes we cannot access cross-origin stylesheets.
}
};
const visit = (node: Node | ShadowRoot, builder: string[]) => {
const nodeName = node.nodeName;
const nodeType = node.nodeType;
if (nodeType === Node.DOCUMENT_TYPE_NODE) {
const docType = node as DocumentType;
builder.push(`<!DOCTYPE ${docType.name}>`);
return;
} }
if (nodeType === Node.TEXT_NODE) { private _getSheetBase(sheet: CSSStyleSheet): string {
builder.push(escapeText(node.nodeValue || '')); let rootSheet = sheet;
return; while (rootSheet.parentStyleSheet)
rootSheet = rootSheet.parentStyleSheet;
if (rootSheet.ownerNode)
return rootSheet.ownerNode.baseURI;
return document.baseURI;
} }
if (nodeType !== Node.ELEMENT_NODE && private _getSheetText(sheet: CSSStyleSheet): string {
nodeType !== Node.DOCUMENT_NODE && const rules: string[] = [];
nodeType !== Node.DOCUMENT_FRAGMENT_NODE) for (const rule of sheet.cssRules)
return; rules.push(rule.cssText);
return rules.join('\n');
if (nodeType === Node.DOCUMENT_NODE || nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
const documentOrShadowRoot = node as DocumentOrShadowRoot;
for (const sheet of documentOrShadowRoot.styleSheets)
visitStyleSheet(sheet);
} }
if (nodeName === 'SCRIPT' || nodeName === 'BASE') private _captureSnapshot(snapshotId?: string): SnapshotData {
return; const win = window;
const doc = win.document;
if (removeNoScript && nodeName === 'NOSCRIPT') const shadowChunks: string[] = [];
return; const styleNodeToStyleSheetText = new Map<Node, string>();
const styleSheetUrlToContentOverride = new Map<string, string>();
if (nodeName === 'STYLE') { const visitStyleSheet = (sheet: CSSStyleSheet) => {
const cssText = styleNodeToStyleSheetText.get(node) || node.textContent || ''; // TODO: recalculate these upon changes, and only send them once.
builder.push('<style>'); if (!this._needStyleOverrides)
builder.push(cssText); return;
builder.push('</style>');
return;
}
if (nodeType === Node.ELEMENT_NODE) { try {
const element = node as Element; for (const rule of sheet.cssRules) {
builder.push('<'); if ((rule as CSSImportRule).styleSheet)
builder.push(nodeName); visitStyleSheet((rule as CSSImportRule).styleSheet);
if (node === target) }
builder.push(' __playwright_target__="true"');
for (let i = 0; i < element.attributes.length; i++) { const cssText = this._getSheetText(sheet);
const name = element.attributes[i].name; if (sheet.ownerNode && sheet.ownerNode.nodeName === 'STYLE') {
let value = element.attributes[i].value; // Stylesheets with owner STYLE nodes will be rewritten.
if (name === 'value' && (nodeName === 'INPUT' || nodeName === 'TEXTAREA')) styleNodeToStyleSheetText.set(sheet.ownerNode, cssText);
continue; } else if (sheet.href !== null) {
if (name === 'checked' || name === 'disabled' || name === 'checked') // Other stylesheets will have resource overrides.
continue; const base = this._getSheetBase(sheet);
if (nodeName === 'LINK' && name === 'integrity') const url = this._resolveUrl(base, sheet.href);
continue; styleSheetUrlToContentOverride.set(url, cssText);
if (name === 'src' && (nodeName === 'IFRAME' || nodeName === 'FRAME')) { }
// TODO: handle srcdoc? } catch (e) {
let protocol = win.location.protocol; // Sometimes we cannot access cross-origin stylesheets.
if (!protocol.startsWith('http'))
protocol = 'http:';
value = protocol + '//' + nextId() + '/';
frameUrlToFrameElement.set(value, element);
} else if (name === 'src' && (nodeName === 'IMG')) {
value = sanitizeUrl(value);
} else if (name === 'srcset' && (nodeName === 'IMG')) {
value = sanitizeSrcSet(value);
} else if (name === 'srcset' && (nodeName === 'SOURCE')) {
value = sanitizeSrcSet(value);
} else if (name === 'href' && (nodeName === 'LINK')) {
value = sanitizeUrl(value);
} else if (name.startsWith('on')) {
value = '';
} }
builder.push(' '); };
builder.push(name);
builder.push('="');
builder.push(escapeAttribute(value));
builder.push('"');
}
if (nodeName === 'INPUT') {
builder.push(' value="');
builder.push(escapeAttribute((element as HTMLInputElement).value));
builder.push('"');
}
if ((element as any).checked)
builder.push(' checked');
if ((element as any).disabled)
builder.push(' disabled');
if ((element as any).readOnly)
builder.push(' readonly');
if (element.shadowRoot) {
const b: string[] = [];
visit(element.shadowRoot, b);
const chunkId = nextId();
chunks.set(chunkId, b.join(''));
builder.push(' ');
builder.push(shadowAttribute);
builder.push('="');
builder.push(chunkId);
builder.push('"');
}
builder.push('>');
}
if (nodeName === 'HEAD') {
let baseHref = document.baseURI;
let baseTarget: string | undefined;
for (let child = node.firstChild; child; child = child.nextSibling) {
if (child.nodeName === 'BASE') {
baseHref = (child as HTMLBaseElement).href;
baseTarget = (child as HTMLBaseElement).target;
}
}
builder.push('<base href="');
builder.push(escapeAttribute(baseHref));
builder.push('"');
if (baseTarget) {
builder.push(' target="');
builder.push(escapeAttribute(baseTarget));
builder.push('"');
}
builder.push('>');
}
if (nodeName === 'TEXTAREA') {
builder.push(escapeText((node as HTMLTextAreaElement).value));
} else {
for (let child = node.firstChild; child; child = child.nextSibling)
visit(child, builder);
}
if (node.nodeName === 'BODY' && chunks.size) {
builder.push('<script>');
const shadowChunks = Array.from(chunks).map(([chunkId, html]) => {
return ` ['${chunkId}', '${escapeScriptString(html)}']`;
}).join(',\n');
const scriptContent = `\n(${applyShadowsInPage.toString()})('${shadowAttribute}', new Map([\n${shadowChunks}\n]))\n`;
builder.push(scriptContent);
builder.push('</script>');
}
if (nodeType === Node.ELEMENT_NODE && !autoClosing.has(nodeName)) {
builder.push('</');
builder.push(nodeName);
builder.push('>');
}
};
function applyShadowsInPage(shadowAttribute: string, shadowContent: Map<string, string>) { const visit = (node: Node | ShadowRoot, builder: string[]) => {
const visitShadows = (root: Document | ShadowRoot) => { const nodeName = node.nodeName;
const elements = root.querySelectorAll(`[${shadowAttribute}]`); const nodeType = node.nodeType;
for (let i = 0; i < elements.length; i++) {
const host = elements[i]; if (nodeType === Node.DOCUMENT_TYPE_NODE) {
const chunkId = host.getAttribute(shadowAttribute)!; const docType = node as DocumentType;
host.removeAttribute(shadowAttribute); builder.push(`<!DOCTYPE ${docType.name}>`);
const shadow = host.attachShadow({ mode: 'open' }); return;
const html = shadowContent.get(chunkId);
if (html) {
shadow.innerHTML = html;
visitShadows(shadow);
} }
if (nodeType === Node.TEXT_NODE) {
builder.push(this._escapeText(node.nodeValue || ''));
return;
}
if (nodeType !== Node.ELEMENT_NODE &&
nodeType !== Node.DOCUMENT_NODE &&
nodeType !== Node.DOCUMENT_FRAGMENT_NODE)
return;
if (nodeType === Node.DOCUMENT_NODE || nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
const documentOrShadowRoot = node as DocumentOrShadowRoot;
for (const sheet of documentOrShadowRoot.styleSheets)
visitStyleSheet(sheet);
}
if (nodeName === 'SCRIPT' || nodeName === 'BASE')
return;
if (this._removeNoScript && nodeName === 'NOSCRIPT')
return;
if (nodeName === 'STYLE') {
const cssText = styleNodeToStyleSheetText.get(node) || node.textContent || '';
builder.push('<style>');
builder.push(cssText);
builder.push('</style>');
return;
}
if (nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
builder.push('<');
builder.push(nodeName);
// if (node === target)
// builder.push(' __playwright_target__="true"');
for (let i = 0; i < element.attributes.length; i++) {
const name = element.attributes[i].name;
if (name === kSnapshotFrameIdAttribute)
continue;
let value = element.attributes[i].value;
if (name === 'value' && (nodeName === 'INPUT' || nodeName === 'TEXTAREA'))
continue;
if (name === 'checked' || name === 'disabled' || name === 'checked')
continue;
if (nodeName === 'LINK' && name === 'integrity')
continue;
if (name === 'src' && (nodeName === 'IFRAME' || nodeName === 'FRAME')) {
// TODO: handle srcdoc?
const frameId = element.getAttribute(kSnapshotFrameIdAttribute);
if (frameId) {
let protocol = win.location.protocol;
if (!protocol.startsWith('http'))
protocol = 'http:';
value = protocol + '//' + frameId + '/';
} else {
value = 'data:text/html,<body>Snapshot is not available</body>';
}
} else if (name === 'src' && (nodeName === 'IMG')) {
value = this._sanitizeUrl(value);
} else if (name === 'srcset' && (nodeName === 'IMG')) {
value = this._sanitizeSrcSet(value);
} else if (name === 'srcset' && (nodeName === 'SOURCE')) {
value = this._sanitizeSrcSet(value);
} else if (name === 'href' && (nodeName === 'LINK')) {
value = this._sanitizeUrl(value);
} else if (name.startsWith('on')) {
value = '';
}
builder.push(' ');
builder.push(name);
builder.push('="');
builder.push(this._escapeAttribute(value));
builder.push('"');
}
if (nodeName === 'INPUT') {
builder.push(' value="');
builder.push(this._escapeAttribute((element as HTMLInputElement).value));
builder.push('"');
}
if ((element as any).checked)
builder.push(' checked');
if ((element as any).disabled)
builder.push(' disabled');
if ((element as any).readOnly)
builder.push(' readonly');
if (element.shadowRoot) {
const b: string[] = [];
visit(element.shadowRoot, b);
const chunkId = shadowChunks.length;
shadowChunks.push(b.join(''));
builder.push(' ');
builder.push(kShadowAttribute);
builder.push('="');
builder.push('' + chunkId);
builder.push('"');
}
builder.push('>');
}
if (nodeName === 'HEAD') {
let baseHref = document.baseURI;
let baseTarget: string | undefined;
for (let child = node.firstChild; child; child = child.nextSibling) {
if (child.nodeName === 'BASE') {
baseHref = (child as HTMLBaseElement).href;
baseTarget = (child as HTMLBaseElement).target;
}
}
builder.push('<base href="');
builder.push(this._escapeAttribute(baseHref));
builder.push('"');
if (baseTarget) {
builder.push(' target="');
builder.push(this._escapeAttribute(baseTarget));
builder.push('"');
}
builder.push('>');
}
if (nodeName === 'TEXTAREA') {
builder.push(this._escapeText((node as HTMLTextAreaElement).value));
} else {
for (let child = node.firstChild; child; child = child.nextSibling)
visit(child, builder);
}
if (node.nodeName === 'BODY' && shadowChunks.length) {
builder.push('<script>');
const chunks = shadowChunks.map(html => {
return '`' + html.replace(/`/g, '\\\`') + '`';
}).join(',\n');
const scriptContent = `\n(${applyShadowsInPage.toString()})('${kShadowAttribute}', [\n${chunks}\n])\n`;
builder.push(scriptContent);
builder.push('</script>');
}
if (nodeType === Node.ELEMENT_NODE && !autoClosing.has(nodeName)) {
builder.push('</');
builder.push(nodeName);
builder.push('>');
}
};
function applyShadowsInPage(shadowAttribute: string, shadowContent: string[]) {
const visitShadows = (root: Document | ShadowRoot) => {
const elements = root.querySelectorAll(`[${shadowAttribute}]`);
for (let i = 0; i < elements.length; i++) {
const host = elements[i];
const chunkId = host.getAttribute(shadowAttribute)!;
host.removeAttribute(shadowAttribute);
const shadow = host.attachShadow({ mode: 'open' });
const html = shadowContent[+chunkId];
if (html) {
shadow.innerHTML = html;
visitShadows(shadow);
}
}
};
visitShadows(document);
} }
};
visitShadows(document); const root: string[] = [];
visit(doc, root);
return {
html: root.join(''),
resourceOverrides: Array.from(styleSheetUrlToContentOverride).map(([url, content]) => ({ url, content })),
viewport: {
width: Math.max(doc.body ? doc.body.offsetWidth : 0, doc.documentElement ? doc.documentElement.offsetWidth : 0),
height: Math.max(doc.body ? doc.body.offsetHeight : 0, doc.documentElement ? doc.documentElement.offsetHeight : 0),
},
url: location.href,
snapshotId,
};
}
} }
const root: string[] = []; (window as any)[kSnapshotStreamer] = new Streamer();
visit(doc, root);
return {
data: {
html: root.join(''),
frameUrls: Array.from(frameUrlToFrameElement.keys()),
resourceOverrides: Array.from(styleSheetUrlToContentOverride).map(([url, content]) => ({ url, content })),
},
frameElements: Array.from(frameUrlToFrameElement.values()),
};
} }

View File

@ -76,12 +76,9 @@ export type ActionTraceEvent = {
startTime: number, startTime: number,
endTime: number, endTime: number,
logs?: string[], logs?: string[],
snapshot?: {
sha1: string,
duration: number,
},
stack?: string, stack?: string,
error?: string, error?: string,
snapshots?: { name: string, snapshotId: string }[],
}; };
export type DialogOpenedEvent = { export type DialogOpenedEvent = {
@ -117,6 +114,17 @@ export type LoadEvent = {
pageId: string, pageId: string,
}; };
export type FrameSnapshotTraceEvent = {
timestamp: number,
type: 'snapshot',
contextId: string,
pageId: string,
frameId: string, // Empty means main frame.
sha1: string,
frameUrl: string,
snapshotId?: string,
};
export type TraceEvent = export type TraceEvent =
ContextCreatedTraceEvent | ContextCreatedTraceEvent |
ContextDestroyedTraceEvent | ContextDestroyedTraceEvent |
@ -128,18 +136,13 @@ export type TraceEvent =
DialogOpenedEvent | DialogOpenedEvent |
DialogClosedEvent | DialogClosedEvent |
NavigationEvent | NavigationEvent |
LoadEvent; LoadEvent |
FrameSnapshotTraceEvent;
export type FrameSnapshot = { export type FrameSnapshot = {
frameId: string,
url: string,
html: string, html: string,
resourceOverrides: { url: string, sha1: string }[], resourceOverrides: { url: string, sha1: string }[],
}; viewport: { width: number, height: number },
url: string,
export type PageSnapshot = {
viewportSize?: { width: number, height: number },
// First frame is the main frame.
frames: FrameSnapshot[],
}; };

View File

@ -23,9 +23,7 @@ import * as fs from 'fs';
import { calculateSha1, createGuid, getFromENV, mkdirIfNeeded, monotonicTime } from '../utils/utils'; import { calculateSha1, createGuid, getFromENV, mkdirIfNeeded, monotonicTime } from '../utils/utils';
import { Page } from '../server/page'; import { Page } from '../server/page';
import { Snapshotter } from './snapshotter'; import { Snapshotter } from './snapshotter';
import { ElementHandle } from '../server/dom';
import { helper, RegisteredListener } from '../server/helper'; import { helper, RegisteredListener } from '../server/helper';
import { DEFAULT_TIMEOUT } from '../utils/timeoutSettings';
import { ProgressResult } from '../server/progress'; import { ProgressResult } from '../server/progress';
import { Dialog } from '../server/dialog'; import { Dialog } from '../server/dialog';
import { Frame, NavigationEvent } from '../server/frames'; import { Frame, NavigationEvent } from '../server/frames';
@ -64,6 +62,14 @@ class Tracer implements ContextListener {
} }
const pageIdSymbol = Symbol('pageId'); const pageIdSymbol = Symbol('pageId');
const snapshotsSymbol = Symbol('snapshots');
// TODO: this is a hacky way to pass snapshots between onActionCheckpoint and onAfterAction.
function snapshotsForMetadata(metadata: ActionMetadata): { name: string, snapshotId: string }[] {
if (!(metadata as any)[snapshotsSymbol])
(metadata as any)[snapshotsSymbol] = [];
return (metadata as any)[snapshotsSymbol];
}
class ContextTracer implements SnapshotterDelegate, ActionListener { class ContextTracer implements SnapshotterDelegate, ActionListener {
private _context: BrowserContext; private _context: BrowserContext;
@ -119,31 +125,50 @@ class ContextTracer implements SnapshotterDelegate, ActionListener {
this._appendTraceEvent(event); this._appendTraceEvent(event);
} }
onFrameSnapshot(frame: Frame, snapshot: trace.FrameSnapshot, snapshotId?: string): void {
const buffer = Buffer.from(JSON.stringify(snapshot));
const sha1 = calculateSha1(buffer);
this._writeArtifact(sha1, buffer);
const event: trace.FrameSnapshotTraceEvent = {
timestamp: monotonicTime(),
type: 'snapshot',
contextId: this._contextId,
pageId: this.pageId(frame._page),
frameId: frame._page.mainFrame() === frame ? '' : frame._id,
sha1,
frameUrl: snapshot.url,
snapshotId,
};
this._appendTraceEvent(event);
}
pageId(page: Page): string { pageId(page: Page): string {
return (page as any)[pageIdSymbol]; return (page as any)[pageIdSymbol];
} }
async onActionCheckpoint(name: string, metadata: ActionMetadata): Promise<void> {
const snapshotId = createGuid();
snapshotsForMetadata(metadata).push({ name, snapshotId });
await this._snapshotter.forceSnapshot(metadata.page, snapshotId);
}
async onAfterAction(result: ProgressResult, metadata: ActionMetadata): Promise<void> { async onAfterAction(result: ProgressResult, metadata: ActionMetadata): Promise<void> {
try { const event: trace.ActionTraceEvent = {
const snapshot = await this._takeSnapshot(metadata.page, typeof metadata.target === 'string' ? undefined : metadata.target); timestamp: monotonicTime(),
const event: trace.ActionTraceEvent = { type: 'action',
timestamp: monotonicTime(), contextId: this._contextId,
type: 'action', pageId: this.pageId(metadata.page),
contextId: this._contextId, action: metadata.type,
pageId: this.pageId(metadata.page), selector: typeof metadata.target === 'string' ? metadata.target : undefined,
action: metadata.type, value: metadata.value,
selector: typeof metadata.target === 'string' ? metadata.target : undefined, startTime: result.startTime,
value: metadata.value, endTime: result.endTime,
snapshot, stack: metadata.stack,
startTime: result.startTime, logs: result.logs.slice(),
endTime: result.endTime, error: result.error ? result.error.stack : undefined,
stack: metadata.stack, snapshots: snapshotsForMetadata(metadata),
logs: result.logs.slice(), };
error: result.error ? result.error.stack : undefined, this._appendTraceEvent(event);
};
this._appendTraceEvent(event);
} catch (e) {
}
} }
private _onPage(page: Page) { private _onPage(page: Page) {
@ -237,22 +262,6 @@ class ContextTracer implements SnapshotterDelegate, ActionListener {
}); });
} }
private async _takeSnapshot(page: Page, target: ElementHandle | undefined, timeout: number = 0): Promise<{ sha1: string, duration: number } | undefined> {
if (!timeout) {
// Never use zero timeout to avoid stalling because of snapshot.
// Use 20% of the default timeout.
timeout = (page._timeoutSettings.timeout({}) || DEFAULT_TIMEOUT) / 5;
}
const startTime = monotonicTime();
const snapshot = await this._snapshotter.takeSnapshot(page, target, timeout);
if (!snapshot)
return;
const buffer = Buffer.from(JSON.stringify(snapshot));
const sha1 = calculateSha1(buffer);
this._writeArtifact(sha1, buffer);
return { sha1, duration: monotonicTime() - startTime };
}
async dispose() { async dispose() {
this._disposed = true; this._disposed = true;
this._context._actionListeners.delete(this); this._context._actionListeners.delete(this);

View File

@ -25,6 +25,7 @@ it('should record trace', test => test.fixme(), async ({browser, testInfo, serve
const page = await context.newPage(); const page = await context.newPage();
const url = server.PREFIX + '/snapshot/snapshot-with-css.html'; const url = server.PREFIX + '/snapshot/snapshot-with-css.html';
await page.goto(url); await page.goto(url);
await page.click('textarea');
await context.close(); await context.close();
const tracePath = path.join(traceDir, fs.readdirSync(traceDir).find(n => n.endsWith('.trace'))); const tracePath = path.join(traceDir, fs.readdirSync(traceDir).find(n => n.endsWith('.trace')));
const traceFileContent = await fs.promises.readFile(tracePath, 'utf8'); const traceFileContent = await fs.promises.readFile(tracePath, 'utf8');
@ -45,6 +46,11 @@ it('should record trace', test => test.fixme(), async ({browser, testInfo, serve
expect(gotoEvent.pageId).toBe(pageId); expect(gotoEvent.pageId).toBe(pageId);
expect(gotoEvent.value).toBe(url); expect(gotoEvent.value).toBe(url);
expect(gotoEvent.snapshot).toBeTruthy(); const clickEvent = traceEvents.find(event => event.type === 'action' && event.action === 'click') as trace.ActionTraceEvent;
expect(fs.existsSync(path.join(traceDir, 'resources', gotoEvent.snapshot!.sha1))).toBe(true); expect(clickEvent).toBeTruthy();
expect(clickEvent.snapshots.length).toBe(2);
const snapshotId = clickEvent.snapshots[0].snapshotId;
const snapshotEvent = traceEvents.find(event => event.type === 'snapshot' && event.snapshotId === snapshotId) as trace.FrameSnapshotTraceEvent;
expect(fs.existsSync(path.join(traceDir, 'resources', snapshotEvent.sha1))).toBe(true);
}); });