Skip to content

Commit

Permalink
chore(evaluate): implement non-stalling evaluate
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman committed Apr 28, 2021
1 parent 1b771ed commit 0ca87d3
Show file tree
Hide file tree
Showing 12 changed files with 203 additions and 79 deletions.
13 changes: 12 additions & 1 deletion src/server/chromium/crExecutionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,18 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
this._contextId = contextPayload.id;
}

async rawEvaluate(expression: string): Promise<string> {
async rawEvaluateJSON(expression: string): Promise<any> {
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', {
expression,
contextId: this._contextId,
returnByValue: true,
}).catch(rewriteError);
if (exceptionDetails)
throw new Error('Evaluation failed: ' + getExceptionMessage(exceptionDetails));
return remoteObject.value;
}

async rawEvaluateHandle(expression: string): Promise<js.ObjectId> {
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', {
expression,
contextId: this._contextId,
Expand Down
2 changes: 1 addition & 1 deletion src/server/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export class FrameExecutionContext extends js.ExecutionContext {
);
})();
`;
this._injectedScriptPromise = this._delegate.rawEvaluate(source).then(objectId => new js.JSHandle(this, 'object', objectId));
this._injectedScriptPromise = this._delegate.rawEvaluateHandle(source).then(objectId => new js.JSHandle(this, 'object', objectId));
}
return this._injectedScriptPromise;
}
Expand Down
12 changes: 11 additions & 1 deletion src/server/firefox/ffExecutionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,17 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
this._executionContextId = executionContextId;
}

async rawEvaluate(expression: string): Promise<string> {
async rawEvaluateJSON(expression: string): Promise<any> {
const payload = await this._session.send('Runtime.evaluate', {
expression,
returnByValue: true,
executionContextId: this._executionContextId,
}).catch(rewriteError);
checkException(payload.exceptionDetails);
return payload.result!.value;
}

async rawEvaluateHandle(expression: string): Promise<js.ObjectId> {
const payload = await this._session.send('Runtime.evaluate', {
expression,
returnByValue: false,
Expand Down
78 changes: 59 additions & 19 deletions src/server/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,11 @@ export class FrameManager {
return;
for (const barrier of this._signalBarriers)
barrier.addFrameNavigation(frame);
if (frame._pendingDocument && frame._pendingDocument.documentId === documentId) {
if (frame.pendingDocument() && frame.pendingDocument()!.documentId === documentId) {
// Do not override request with undefined.
return;
}
frame._pendingDocument = { documentId, request: undefined };
frame.setPendingDocument({ documentId, request: undefined });
}

frameCommittedNewDocumentNavigation(frameId: string, url: string, name: string, documentId: string, initial: boolean) {
Expand All @@ -173,24 +173,25 @@ export class FrameManager {
frame._name = name;

let keepPending: DocumentInfo | undefined;
if (frame._pendingDocument) {
if (frame._pendingDocument.documentId === undefined) {
const pendingDocument = frame.pendingDocument();
if (pendingDocument) {
if (pendingDocument.documentId === undefined) {
// Pending with unknown documentId - assume it is the one being committed.
frame._pendingDocument.documentId = documentId;
pendingDocument.documentId = documentId;
}
if (frame._pendingDocument.documentId === documentId) {
if (pendingDocument.documentId === documentId) {
// Committing a pending document.
frame._currentDocument = frame._pendingDocument;
frame._currentDocument = pendingDocument;
} else {
// Sometimes, we already have a new pending when the old one commits.
// An example would be Chromium error page followed by a new navigation request,
// where the error page commit arrives after Network.requestWillBeSent for the
// new navigation.
// We commit, but keep the pending request since it's not done yet.
keepPending = frame._pendingDocument;
keepPending = pendingDocument;
frame._currentDocument = { documentId, request: undefined };
}
frame._pendingDocument = undefined;
frame.setPendingDocument(undefined);
} else {
// No pending - just commit a new document.
frame._currentDocument = { documentId, request: undefined };
Expand All @@ -205,7 +206,7 @@ export class FrameManager {
this._page.frameNavigatedToNewDocument(frame);
}
// Restore pending if any - see comments above about keepPending.
frame._pendingDocument = keepPending;
frame.setPendingDocument(keepPending);
}

frameCommittedSameDocumentNavigation(frameId: string, url: string) {
Expand All @@ -220,17 +221,17 @@ export class FrameManager {

frameAbortedNavigation(frameId: string, errorText: string, documentId?: string) {
const frame = this._frames.get(frameId);
if (!frame || !frame._pendingDocument)
if (!frame || !frame.pendingDocument())
return;
if (documentId !== undefined && frame._pendingDocument.documentId !== documentId)
if (documentId !== undefined && frame.pendingDocument()!.documentId !== documentId)
return;
const navigationEvent: NavigationEvent = {
url: frame._url,
name: frame._name,
newDocument: frame._pendingDocument,
newDocument: frame.pendingDocument(),
error: new Error(errorText),
};
frame._pendingDocument = undefined;
frame.setPendingDocument(undefined);
frame.emit(Frame.Events.Navigation, navigationEvent);
}

Expand All @@ -255,7 +256,7 @@ export class FrameManager {
const frame = request.frame();
this._inflightRequestStarted(request);
if (request._documentId)
frame._pendingDocument = { documentId: request._documentId, request };
frame.setPendingDocument({ documentId: request._documentId, request });
if (request._isFavicon) {
const route = request._route();
if (route)
Expand All @@ -281,11 +282,11 @@ export class FrameManager {
requestFailed(request: network.Request, canceled: boolean) {
const frame = request.frame();
this._inflightRequestFinished(request);
if (frame._pendingDocument && frame._pendingDocument.request === request) {
if (frame.pendingDocument() && frame.pendingDocument()!.request === request) {
let errorText = request.failure()!.errorText;
if (canceled)
errorText += '; maybe frame was detached?';
this.frameAbortedNavigation(frame._id, errorText, frame._pendingDocument.documentId);
this.frameAbortedNavigation(frame._id, errorText, frame.pendingDocument()!.documentId);
}
if (!request._isFavicon)
this._page.emit(Page.Events.RequestFailed, request);
Expand Down Expand Up @@ -399,7 +400,7 @@ export class Frame extends SdkObject {
private _firedLifecycleEvents = new Set<types.LifecycleEvent>();
_subtreeLifecycleEvents = new Set<types.LifecycleEvent>();
_currentDocument: DocumentInfo;
_pendingDocument?: DocumentInfo;
private _pendingDocument: DocumentInfo | undefined;
readonly _page: Page;
private _parentFrame: Frame | null;
_url = '';
Expand All @@ -412,6 +413,7 @@ export class Frame extends SdkObject {
private _setContentCounter = 0;
readonly _detachedPromise: Promise<void>;
private _detachedCallback = () => {};
private _nonStallingEvaluations = new Set<(error: Error) => void>();

constructor(page: Page, id: string, parentFrame: Frame | null) {
super(page, 'frame');
Expand Down Expand Up @@ -451,6 +453,44 @@ export class Frame extends SdkObject {
this._startNetworkIdleTimer();
}

setPendingDocument(documentInfo: DocumentInfo | undefined) {
this._pendingDocument = documentInfo;
if (documentInfo)
this._invalidateNonStallingEvaluations();
}

pendingDocument(): DocumentInfo | undefined {
return this._pendingDocument;
}

private async _invalidateNonStallingEvaluations() {
if (!this._nonStallingEvaluations)
return;
const error = new Error('Navigation interrupted the evaluation');
for (const callback of this._nonStallingEvaluations)
callback(error);
}

async nonStallingRawEvaluateInExistingMainContext(expression: string): Promise<any> {
if (this._pendingDocument)
throw new Error('Frame is currently attempting a navigation');
const context = this._existingMainContext();
if (!context)
throw new Error('Frame does not yet have a main execution context');

let callback = () => {};
const frameInvalidated = new Promise<void>((f, r) => callback = r);
this._nonStallingEvaluations.add(callback);
try {
return await Promise.race([
context.rawEvaluateJSON(expression),
frameInvalidated
]);
} finally {
this._nonStallingEvaluations.delete(callback);
}
}

private _recalculateLifecycle() {
const events = new Set<types.LifecycleEvent>(this._firedLifecycleEvents);
for (const child of this._childFrames) {
Expand Down Expand Up @@ -584,7 +624,7 @@ export class Frame extends SdkObject {
return this._context('main');
}

_existingMainContext(): dom.FrameExecutionContext | null {
private _existingMainContext(): dom.FrameExecutionContext | null {
return this._contextData.get('main')?.context || null;
}

Expand Down
10 changes: 5 additions & 5 deletions src/server/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export type FuncOn<On, Arg2, R> = string | ((on: On, arg2: Unboxed<Arg2>) => R |
export type SmartHandle<T> = T extends Node ? dom.ElementHandle<T> : JSHandle<T>;

export interface ExecutionContextDelegate {
rawEvaluate(expression: string): Promise<ObjectId>;
rawEvaluateJSON(expression: string): Promise<any>;
rawEvaluateHandle(expression: string): Promise<ObjectId>;
rawCallFunctionNoReply(func: Function, ...args: any[]): void;
evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: JSHandle<any>, values: any[], objectIds: ObjectId[]): Promise<any>;
getProperties(context: ExecutionContext, objectId: ObjectId): Promise<Map<string, JSHandle>>;
Expand Down Expand Up @@ -75,7 +76,7 @@ export class ExecutionContext extends SdkObject {
${utilityScriptSource.source}
return new pwExport();
})();`;
this._utilityScriptPromise = this._delegate.rawEvaluate(source).then(objectId => new JSHandle(this, 'object', objectId));
this._utilityScriptPromise = this._delegate.rawEvaluateHandle(source).then(objectId => new JSHandle(this, 'object', objectId));
}
return this._utilityScriptPromise;
}
Expand All @@ -84,9 +85,8 @@ export class ExecutionContext extends SdkObject {
return this._delegate.createHandle(this, remoteObject);
}

async rawEvaluate(expression: string): Promise<void> {
// Make sure to never return a value.
await this._delegate.rawEvaluate(expression + '; 0');
async rawEvaluateJSON(expression: string): Promise<any> {
return await this._delegate.rawEvaluateJSON(expression);
}

async doSlowMo() {
Expand Down
2 changes: 1 addition & 1 deletion src/server/snapshot/inMemorySnapshotter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot
if (this._frameSnapshots.has(snapshotName))
throw new Error('Duplicate snapshot name: ' + snapshotName);

this._snapshotter.captureSnapshot(page, snapshotName, element);
this._snapshotter.captureSnapshot(page, snapshotName, element).catch(() => {});
return new Promise<SnapshotRenderer>(fulfill => {
const listener = helper.addEventListener(this, 'snapshot', (renderer: SnapshotRenderer) => {
if (renderer.snapshotName === snapshotName) {
Expand Down
77 changes: 40 additions & 37 deletions src/server/snapshot/snapshotter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import * as network from '../network';
import { helper, RegisteredListener } from '../helper';
import { debugLogger } from '../../utils/debugLogger';
import { Frame } from '../frames';
import { SnapshotData, frameSnapshotStreamer } from './snapshotterInjected';
import { frameSnapshotStreamer } from './snapshotterInjected';
import { calculateSha1, createGuid, monotonicTime } from '../../utils/utils';
import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes';
import { ElementHandle } from '../dom';
Expand All @@ -41,7 +41,6 @@ export class Snapshotter {
private _delegate: SnapshotterDelegate;
private _eventListeners: RegisteredListener[] = [];
private _snapshotStreamer: string;
private _snapshotBinding: string;
private _initialized = false;
private _started = false;
private _fetchedResponses = new Map<network.Response, string>();
Expand All @@ -51,7 +50,6 @@ export class Snapshotter {
this._delegate = delegate;
const guid = createGuid();
this._snapshotStreamer = '__playwright_snapshot_streamer_' + guid;
this._snapshotBinding = '__playwright_snapshot_binding_' + guid;
}

async start() {
Expand All @@ -60,7 +58,7 @@ export class Snapshotter {
this._initialized = true;
await this._initialize();
}
this._runInAllFrames(`window["${this._snapshotStreamer}"].reset()`);
await this._runInAllFrames(`window["${this._snapshotStreamer}"].reset()`);

// Replay resources loaded in all pages.
for (const page of this._context.pages()) {
Expand All @@ -80,11 +78,44 @@ export class Snapshotter {
helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)),
];

await this._context.exposeBinding(this._snapshotBinding, false, (source, data: SnapshotData) => {
const initScript = `(${frameSnapshotStreamer})("${this._snapshotStreamer}")`;
await this._context._doAddInitScript(initScript);
await this._runInAllFrames(initScript);
}

private async _runInAllFrames(expression: string) {
const frames = [];
for (const page of this._context.pages())
frames.push(...page.frames());
await Promise.all(frames.map(frame => {
return frame.nonStallingRawEvaluateInExistingMainContext(expression).catch(debugExceptionHandler)
}));
}

dispose() {
helper.removeEventListeners(this._eventListeners);
}

async captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle): Promise<void> {
// Prepare expression synchronously.
const expression = `window["${this._snapshotStreamer}"].captureSnapshot(${JSON.stringify(snapshotName)})`;

// In a best-effort manner, without waiting for it, mark target element.
element?.callFunctionNoReply((element: Element, snapshotName: string) => {
element.setAttribute('__playwright_target__', snapshotName);
}, snapshotName);

// In each frame, in a non-stalling manner, capture the snapshots.
const snapshots = page.frames().map(async frame => {
const data = await frame.nonStallingRawEvaluateInExistingMainContext(expression).catch(debugExceptionHandler);
// Something went wrong -> bail out, our snapshots are best-efforty.
if (!data)
return;

const snapshot: FrameSnapshot = {
snapshotName: data.snapshotName,
pageId: source.page.guid,
frameId: source.frame.guid,
pageId: page.guid,
frameId: frame.guid,
frameUrl: data.url,
doctype: data.doctype,
html: data.html,
Expand All @@ -93,7 +124,7 @@ export class Snapshotter {
pageTimestamp: data.timestamp,
collectionTime: data.collectionTime,
resourceOverrides: [],
isMainFrame: source.page.mainFrame() === source.frame
isMainFrame: page.mainFrame() === frame
};
for (const { url, content } of data.resourceOverrides) {
if (typeof content === 'string') {
Expand All @@ -107,35 +138,7 @@ export class Snapshotter {
}
this._delegate.onFrameSnapshot(snapshot);
});
const initScript = `(${frameSnapshotStreamer})("${this._snapshotStreamer}", "${this._snapshotBinding}")`;
await this._context._doAddInitScript(initScript);
this._runInAllFrames(initScript);
}

private _runInAllFrames(expression: string) {
const frames = [];
for (const page of this._context.pages())
frames.push(...page.frames());
frames.map(frame => {
frame._existingMainContext()?.rawEvaluate(expression).catch(debugExceptionHandler);
});
}

dispose() {
helper.removeEventListeners(this._eventListeners);
}

captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle) {
// This needs to be sync, as in not awaiting for anything before we issue the command.
const expression = `window["${this._snapshotStreamer}"].captureSnapshot(${JSON.stringify(snapshotName)})`;
element?.callFunctionNoReply((element: Element, snapshotName: string) => {
element.setAttribute('__playwright_target__', snapshotName);
}, snapshotName);
const snapshotFrame = (frame: Frame) => {
const context = frame._existingMainContext();
context?.rawEvaluate(expression).catch(debugExceptionHandler);
};
page.frames().map(frame => snapshotFrame(frame));
await Promise.all(snapshots);
}

private _onPage(page: Page) {
Expand Down
Loading

0 comments on commit 0ca87d3

Please sign in to comment.