mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
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:
parent
22fb7448c3
commit
5033261d27
@ -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
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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) {
|
||||||
|
@ -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)!;
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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>;
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
@ -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');
|
||||||
|
@ -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.
|
||||||
|
@ -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,
|
||||||
|
@ -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>,
|
|
||||||
};
|
|
||||||
|
@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' };
|
const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' };
|
||||||
|
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()),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
@ -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[],
|
|
||||||
};
|
};
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user