fix(waitForSelector): retry when context is gone during node adoption (#6851)

There is a small window after finishing the "rerunnable task" where
we adopt the node to the main world and navigation could destroy the context.
This commit is contained in:
Dmitry Gozman 2021-06-02 20:17:24 -07:00 committed by GitHub
parent 8a68fa1e83
commit 837ee08a53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 75 additions and 27 deletions

View File

@ -109,7 +109,7 @@ function rewriteError(error: Error): Protocol.Runtime.evaluateReturnValue {
if (error.message.includes('Object couldn\'t be returned by value')) if (error.message.includes('Object couldn\'t be returned by value'))
return {result: {type: 'undefined'}}; return {result: {type: 'undefined'}};
if (error.message.endsWith('Cannot find context with specified id') || error.message.endsWith('Inspected target navigated or closed') || error.message.endsWith('Execution context was destroyed.')) if (js.isContextDestroyedError(error) || error.message.endsWith('Inspected target navigated or closed'))
throw new Error('Execution context was destroyed, most likely because of a navigation.'); throw new Error('Execution context was destroyed, most likely because of a navigation.');
if (error instanceof TypeError && error.message.startsWith('Converting circular structure to JSON')) if (error instanceof TypeError && error.message.startsWith('Converting circular structure to JSON'))
rewriteErrorMessage(error, error.message + ' Are you passing a nested JSHandle?'); rewriteErrorMessage(error, error.message + ' Are you passing a nested JSHandle?');

View File

@ -1130,7 +1130,7 @@ class FrameSession {
executionContextId: (to._delegate as CRExecutionContext)._contextId, executionContextId: (to._delegate as CRExecutionContext)._contextId,
}); });
if (!result || result.object.subtype === 'null') if (!result || result.object.subtype === 'null')
throw new Error('Unable to adopt element handle from a different document'); throw new Error(dom.kUnableToAdoptErrorMessage);
return to.createHandle(result.object).asElement()!; return to.createHandle(result.object).asElement()!;
} }
} }

View File

@ -1023,3 +1023,5 @@ export function elementStateTask(selector: SelectorInfo, state: ElementStateWith
}); });
}, { parsed: selector.parsed, state }); }, { parsed: selector.parsed, state });
} }
export const kUnableToAdoptErrorMessage = 'Unable to adopt element handle from a different document';

View File

@ -111,7 +111,7 @@ function checkException(exceptionDetails?: Protocol.Runtime.ExceptionDetails) {
function rewriteError(error: Error): (Protocol.Runtime.evaluateReturnValue | Protocol.Runtime.callFunctionReturnValue) { function rewriteError(error: Error): (Protocol.Runtime.evaluateReturnValue | Protocol.Runtime.callFunctionReturnValue) {
if (error.message.includes('cyclic object value') || error.message.includes('Object is not serializable')) if (error.message.includes('cyclic object value') || error.message.includes('Object is not serializable'))
return {result: {type: 'undefined', value: undefined}}; return {result: {type: 'undefined', value: undefined}};
if (error.message.includes('Failed to find execution context with id') || error.message.includes('Execution context was destroyed!')) if (js.isContextDestroyedError(error))
throw new Error('Execution context was destroyed, most likely because of a navigation.'); throw new Error('Execution context was destroyed, most likely because of a navigation.');
if (error instanceof TypeError && error.message.startsWith('Converting circular structure to JSON')) if (error instanceof TypeError && error.message.startsWith('Converting circular structure to JSON'))
rewriteErrorMessage(error, error.message + ' Are you passing a nested JSHandle?'); rewriteErrorMessage(error, error.message + ' Are you passing a nested JSHandle?');

View File

@ -528,7 +528,7 @@ export class FFPage implements PageDelegate {
executionContextId: (to._delegate as FFExecutionContext)._executionContextId executionContextId: (to._delegate as FFExecutionContext)._executionContextId
}); });
if (!result.remoteObject) if (!result.remoteObject)
throw new Error('Unable to adopt element handle from a different document'); throw new Error(dom.kUnableToAdoptErrorMessage);
return to.createHandle(result.remoteObject) as dom.ElementHandle<T>; return to.createHandle(result.remoteObject) as dom.ElementHandle<T>;
} }

View File

@ -677,13 +677,26 @@ export class Frame extends SdkObject {
const task = dom.waitForSelectorTask(info, state); const task = dom.waitForSelectorTask(info, state);
return controller.run(async progress => { return controller.run(async progress => {
progress.log(`waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`); progress.log(`waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`);
const result = await this._scheduleRerunnableHandleTask(progress, info.world, task); while (progress.isRunning()) {
if (!result.asElement()) { const result = await this._scheduleRerunnableHandleTask(progress, info.world, task);
result.dispose(); if (!result.asElement()) {
return null; result.dispose();
return null;
}
if ((options as any).__testHookBeforeAdoptNode)
await (options as any).__testHookBeforeAdoptNode();
try {
const handle = result.asElement() as dom.ElementHandle<Element>;
const adopted = await handle._adoptTo(await this._mainContext());
return adopted;
} catch (e) {
// Navigated while trying to adopt the node.
if (!js.isContextDestroyedError(e) && !e.message.includes(dom.kUnableToAdoptErrorMessage))
throw e;
result.dispose();
}
} }
const handle = result.asElement() as dom.ElementHandle<Element>; return null;
return handle._adoptTo(await this._mainContext());
}, this._page._timeoutSettings.timeout(options)); }, this._page._timeoutSettings.timeout(options));
} }
@ -1263,16 +1276,9 @@ class RerunnableTask {
this._contextData.rerunnableTasks.delete(this); this._contextData.rerunnableTasks.delete(this);
this._resolve(result); this._resolve(result);
} catch (e) { } catch (e) {
// When the page is navigated, the promise is rejected.
// We will try again in the new execution context. // We will try again in the new execution context.
if (e.message.includes('Execution context was destroyed')) if (js.isContextDestroyedError(e))
return; return;
// We could have tried to evaluate in a context which was already
// destroyed.
if (e.message.includes('Cannot find context with specified id'))
return;
this._contextData.rerunnableTasks.delete(this); this._contextData.rerunnableTasks.delete(this);
this._reject(e); this._reject(e);
} }

View File

@ -277,3 +277,27 @@ export function normalizeEvaluationExpression(expression: string, isFunction: bo
expression = '(' + expression + ')'; expression = '(' + expression + ')';
return expression; return expression;
} }
export const kSwappedOutErrorMessage = 'Target was swapped out.';
export function isContextDestroyedError(e: any) {
if (!e || typeof e !== 'object' || typeof e.message !== 'string')
return false;
// Evaluating in a context which was already destroyed.
if (e.message.includes('Cannot find context with specified id')
|| e.message.includes('Failed to find execution context with id')
|| e.message.includes('Missing injected script for given')
|| e.message.includes('Cannot find object with id'))
return true;
// Evaluation promise is rejected when context is gone.
if (e.message.includes('Execution context was destroyed'))
return true;
// WebKit target swap.
if (e.message.includes(kSwappedOutErrorMessage))
return true;
return false;
}

View File

@ -185,7 +185,3 @@ export function createProtocolError(error: Error, method: string, protocolError:
message += ` ${JSON.stringify(protocolError.data)}`; message += ` ${JSON.stringify(protocolError.data)}`;
return rewriteErrorMessage(error, message); return rewriteErrorMessage(error, message);
} }
export function isSwappedOutError(e: Error) {
return e.message.includes('Target was swapped out.');
}

View File

@ -15,7 +15,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { WKSession, isSwappedOutError } from './wkConnection'; import { WKSession } from './wkConnection';
import { Protocol } from './protocol'; import { Protocol } from './protocol';
import * as js from '../javascript'; import * as js from '../javascript';
import { parseEvaluationResultValue } from '../common/utilityScriptSerializers'; import { parseEvaluationResultValue } from '../common/utilityScriptSerializers';
@ -143,7 +143,7 @@ function potentiallyUnserializableValue(remoteObject: Protocol.Runtime.RemoteObj
} }
function rewriteError(error: Error): Error { function rewriteError(error: Error): Error {
if (isSwappedOutError(error) || error.message.includes('Missing injected script for given')) if (js.isContextDestroyedError(error))
return new Error('Execution context was destroyed, most likely because of a navigation.'); return new Error('Execution context was destroyed, most likely because of a navigation.');
return error; return error;
} }

View File

@ -26,7 +26,7 @@ import * as dialog from '../dialog';
import * as dom from '../dom'; import * as dom from '../dom';
import * as frames from '../frames'; import * as frames from '../frames';
import { helper, RegisteredListener } from '../helper'; import { helper, RegisteredListener } from '../helper';
import { JSHandle } from '../javascript'; import { JSHandle, kSwappedOutErrorMessage } from '../javascript';
import * as network from '../network'; import * as network from '../network';
import { Page, PageBinding, PageDelegate } from '../page'; import { Page, PageBinding, PageDelegate } from '../page';
import { Progress } from '../progress'; import { Progress } from '../progress';
@ -213,7 +213,7 @@ export class WKPage implements PageDelegate {
assert(this._provisionalPage); assert(this._provisionalPage);
assert(this._provisionalPage._session.sessionId === newTargetId, 'Unknown new target: ' + newTargetId); assert(this._provisionalPage._session.sessionId === newTargetId, 'Unknown new target: ' + newTargetId);
assert(this._session.sessionId === oldTargetId, 'Unknown old target: ' + oldTargetId); assert(this._session.sessionId === oldTargetId, 'Unknown old target: ' + oldTargetId);
this._session.errorText = 'Target was swapped out.'; this._session.errorText = kSwappedOutErrorMessage;
const newSession = this._provisionalPage._session; const newSession = this._provisionalPage._session;
this._provisionalPage.commit(); this._provisionalPage.commit();
this._provisionalPage.dispose(); this._provisionalPage.dispose();
@ -896,7 +896,7 @@ export class WKPage implements PageDelegate {
executionContextId: (to._delegate as WKExecutionContext)._contextId executionContextId: (to._delegate as WKExecutionContext)._contextId
}); });
if (!result || result.object.subtype === 'null') if (!result || result.object.subtype === 'null')
throw new Error('Unable to adopt element handle from a different document'); throw new Error(dom.kUnableToAdoptErrorMessage);
return to.createHandle(result.object) as dom.ElementHandle<T>; return to.createHandle(result.object) as dom.ElementHandle<T>;
} }

View File

@ -268,3 +268,23 @@ it('should correctly handle hidden shadow host', async ({page, server}) => {
expect(await page.textContent('div')).toBe('Find me'); expect(await page.textContent('div')).toBe('Find me');
await page.waitForSelector('div', { state: 'hidden' }); await page.waitForSelector('div', { state: 'hidden' });
}); });
it('should work when navigating before node adoption', async ({page, mode, server}) => {
it.skip(mode !== 'default');
await page.goto(server.EMPTY_PAGE);
await page.setContent(`<div>Hello</div>`);
let navigatedOnce = false;
const __testHookBeforeAdoptNode = async () => {
if (!navigatedOnce) {
navigatedOnce = true;
await page.goto(server.PREFIX + '/one-style.html');
}
};
const div = await page.waitForSelector('div', { __testHookBeforeAdoptNode } as any);
// This text is coming from /one-style.html
expect(await div.textContent()).toBe('hello, world!');
});