Skip to content

Commit a30a880

Browse files
authored
fix(expect): account for timeout during the first locator handler check (#32095)
Also considered an alternative to not perform the locator handler check during one-shot, but that would be somewhat against the promise of the locator handler that is supposed to run **before** every expect check. Fixes #32089.
1 parent ba8f94d commit a30a880

File tree

2 files changed

+106
-62
lines changed

2 files changed

+106
-62
lines changed

packages/playwright-core/src/server/frames.ts

Lines changed: 80 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1392,73 +1392,54 @@ export class Frame extends SdkObject {
13921392
}
13931393

13941394
private async _expectImpl(metadata: CallMetadata, selector: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> {
1395-
let timeout = this._page._timeoutSettings.timeout(options);
1396-
const start = timeout > 0 ? monotonicTime() : 0;
13971395
const lastIntermediateResult: { received?: any, isSet: boolean } = { isSet: false };
1398-
const resultOneShot = await this._expectInternal(metadata, selector, options, true, timeout, lastIntermediateResult);
1399-
if (resultOneShot.matches !== options.isNot)
1400-
return resultOneShot;
1401-
if (timeout > 0) {
1402-
const elapsed = monotonicTime() - start;
1403-
timeout -= elapsed;
1404-
}
1405-
if (timeout < 0)
1406-
return { matches: options.isNot, log: metadata.log, timedOut: true, received: lastIntermediateResult.received };
1407-
return await this._expectInternal(metadata, selector, options, false, timeout, lastIntermediateResult);
1408-
}
1396+
try {
1397+
let timeout = this._page._timeoutSettings.timeout(options);
1398+
const start = timeout > 0 ? monotonicTime() : 0;
14091399

1410-
private async _expectInternal(metadata: CallMetadata, selector: string, options: FrameExpectParams, oneShot: boolean, timeout: number, lastIntermediateResult: { received?: any, isSet: boolean }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> {
1411-
const controller = new ProgressController(metadata, this);
1412-
return controller.run(async progress => {
1413-
if (oneShot) {
1400+
// Step 1: perform locator handlers checkpoint with a specified timeout.
1401+
await (new ProgressController(metadata, this)).run(async progress => {
14141402
progress.log(`${metadata.apiName}${timeout ? ` with timeout ${timeout}ms` : ''}`);
14151403
progress.log(`waiting for ${this._asLocator(selector)}`);
1416-
}
1417-
return await this.retryWithProgressAndTimeouts(progress, [100, 250, 500, 1000], async continuePolling => {
14181404
await this._page.performLocatorHandlersCheckpoint(progress);
1405+
}, timeout);
14191406

1420-
const selectorInFrame = await this.selectors.resolveFrameForSelector(selector, { strict: true });
1421-
progress.throwIfAborted();
1422-
1423-
const { frame, info } = selectorInFrame || { frame: this, info: undefined };
1424-
const world = options.expression === 'to.have.property' ? 'main' : (info?.world ?? 'utility');
1425-
const context = await frame._context(world);
1426-
const injected = await context.injectedScript();
1427-
progress.throwIfAborted();
1428-
1429-
const { log, matches, received, missingReceived } = await injected.evaluate(async (injected, { info, options, callId }) => {
1430-
const elements = info ? injected.querySelectorAll(info.parsed, document) : [];
1431-
const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array');
1432-
let log = '';
1433-
if (isArray)
1434-
log = ` locator resolved to ${elements.length} element${elements.length === 1 ? '' : 's'}`;
1435-
else if (elements.length > 1)
1436-
throw injected.strictModeViolationError(info!.parsed, elements);
1437-
else if (elements.length)
1438-
log = ` locator resolved to ${injected.previewNode(elements[0])}`;
1439-
if (callId)
1440-
injected.markTargetElements(new Set(elements), callId);
1441-
return { log, ...await injected.expect(elements[0], options, elements) };
1442-
}, { info, options, callId: metadata.id });
1443-
1444-
if (log)
1445-
progress.log(log);
1446-
// Note: missingReceived avoids `unexpected value "undefined"` when element was not found.
1447-
if (matches === options.isNot) {
1448-
lastIntermediateResult.received = missingReceived ? '<element(s) not found>' : received;
1449-
lastIntermediateResult.isSet = true;
1450-
if (!missingReceived && !Array.isArray(received))
1451-
progress.log(` unexpected value "${renderUnexpectedValue(options.expression, received)}"`);
1452-
}
1453-
if (!oneShot && matches === options.isNot) {
1454-
// Keep waiting in these cases:
1455-
// expect(locator).conditionThatDoesNotMatch
1456-
// expect(locator).not.conditionThatDoesMatch
1457-
return continuePolling;
1458-
}
1459-
return { matches, received };
1460-
});
1461-
}, oneShot ? 0 : timeout).catch(e => {
1407+
// Step 2: perform one-shot expect check without a timeout.
1408+
// Supports the case of `expect(locator).toBeVisible({ timeout: 1 })`
1409+
// that should succeed when the locator is already visible.
1410+
try {
1411+
const resultOneShot = await (new ProgressController(metadata, this)).run(async progress => {
1412+
return await this._expectInternal(progress, selector, options, lastIntermediateResult);
1413+
});
1414+
if (resultOneShot.matches !== options.isNot)
1415+
return resultOneShot;
1416+
} catch (e) {
1417+
if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e))
1418+
throw e;
1419+
// Ignore any other errors from one-shot, we'll handle them during retries.
1420+
}
1421+
if (timeout > 0) {
1422+
const elapsed = monotonicTime() - start;
1423+
timeout -= elapsed;
1424+
}
1425+
if (timeout < 0)
1426+
return { matches: options.isNot, log: metadata.log, timedOut: true, received: lastIntermediateResult.received };
1427+
1428+
// Step 3: auto-retry expect with increasing timeouts. Bounded by the total remaining time.
1429+
return await (new ProgressController(metadata, this)).run(async progress => {
1430+
return await this.retryWithProgressAndTimeouts(progress, [100, 250, 500, 1000], async continuePolling => {
1431+
await this._page.performLocatorHandlersCheckpoint(progress);
1432+
const { matches, received } = await this._expectInternal(progress, selector, options, lastIntermediateResult);
1433+
if (matches === options.isNot) {
1434+
// Keep waiting in these cases:
1435+
// expect(locator).conditionThatDoesNotMatch
1436+
// expect(locator).not.conditionThatDoesMatch
1437+
return continuePolling;
1438+
}
1439+
return { matches, received };
1440+
});
1441+
}, timeout);
1442+
} catch (e) {
14621443
// Q: Why not throw upon isSessionClosedError(e) as in other places?
14631444
// A: We want user to receive a friendly message containing the last intermediate result.
14641445
if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e))
@@ -1469,7 +1450,44 @@ export class Frame extends SdkObject {
14691450
if (e instanceof TimeoutError)
14701451
result.timedOut = true;
14711452
return result;
1472-
});
1453+
}
1454+
}
1455+
1456+
private async _expectInternal(progress: Progress, selector: string, options: FrameExpectParams, lastIntermediateResult: { received?: any, isSet: boolean }) {
1457+
const selectorInFrame = await this.selectors.resolveFrameForSelector(selector, { strict: true });
1458+
progress.throwIfAborted();
1459+
1460+
const { frame, info } = selectorInFrame || { frame: this, info: undefined };
1461+
const world = options.expression === 'to.have.property' ? 'main' : (info?.world ?? 'utility');
1462+
const context = await frame._context(world);
1463+
const injected = await context.injectedScript();
1464+
progress.throwIfAborted();
1465+
1466+
const { log, matches, received, missingReceived } = await injected.evaluate(async (injected, { info, options, callId }) => {
1467+
const elements = info ? injected.querySelectorAll(info.parsed, document) : [];
1468+
const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array');
1469+
let log = '';
1470+
if (isArray)
1471+
log = ` locator resolved to ${elements.length} element${elements.length === 1 ? '' : 's'}`;
1472+
else if (elements.length > 1)
1473+
throw injected.strictModeViolationError(info!.parsed, elements);
1474+
else if (elements.length)
1475+
log = ` locator resolved to ${injected.previewNode(elements[0])}`;
1476+
if (callId)
1477+
injected.markTargetElements(new Set(elements), callId);
1478+
return { log, ...await injected.expect(elements[0], options, elements) };
1479+
}, { info, options, callId: progress.metadata.id });
1480+
1481+
if (log)
1482+
progress.log(log);
1483+
// Note: missingReceived avoids `unexpected value "undefined"` when element was not found.
1484+
if (matches === options.isNot) {
1485+
lastIntermediateResult.received = missingReceived ? '<element(s) not found>' : received;
1486+
lastIntermediateResult.isSet = true;
1487+
if (!missingReceived && !Array.isArray(received))
1488+
progress.log(` unexpected value "${renderUnexpectedValue(options.expression, received)}"`);
1489+
}
1490+
return { matches, received };
14731491
}
14741492

14751493
async _waitForFunctionExpression<R>(metadata: CallMetadata, expression: string, isFunction: boolean | undefined, arg: any, options: types.WaitForFunctionOptions, world: types.World = 'main'): Promise<js.SmartHandle<R>> {

tests/page/expect-timeout.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,29 @@ test('should have timeout error name', async ({ page }) => {
5555
const error = await page.waitForSelector('#not-found', { timeout: 1 }).catch(e => e);
5656
expect(error.name).toBe('TimeoutError');
5757
});
58+
59+
test('should not throw when navigating during one-shot check', async ({ page, server }) => {
60+
await page.setContent(`<div>hello</div>`);
61+
const promise = expect(page.locator('div')).toHaveText('bye');
62+
await page.goto(server.EMPTY_PAGE);
63+
await page.setContent(`<div>bye</div>`);
64+
await promise;
65+
});
66+
67+
test('should not throw when navigating during first locator handler check', async ({ page, server }) => {
68+
await page.addLocatorHandler(page.locator('span'), async locator => {});
69+
await page.setContent(`<div>hello</div>`);
70+
const promise = expect(page.locator('div')).toHaveText('bye');
71+
await page.goto(server.EMPTY_PAGE);
72+
await page.setContent(`<div>bye</div>`);
73+
await promise;
74+
});
75+
76+
test('should timeout during first locator handler check', async ({ page, server }) => {
77+
await page.addLocatorHandler(page.locator('div'), async locator => {});
78+
await page.setContent(`<div>hello</div><span>bye</span>`);
79+
const error = await expect(page.locator('span')).toHaveText('bye', { timeout: 3000 }).catch(e => e);
80+
expect(error.message).toContain('Timed out 3000ms waiting for');
81+
expect(error.message).toContain(`locator handler has finished, waiting for locator('div') to be hidden`);
82+
expect(error.message).toContain(`locator resolved to visible <div>hello</div>`);
83+
});

0 commit comments

Comments
 (0)