diff --git a/tests/WaitForHelper.test.ts b/tests/WaitForHelper.test.ts
index 00d5e583a..7b6ab7027 100644
--- a/tests/WaitForHelper.test.ts
+++ b/tests/WaitForHelper.test.ts
@@ -7,9 +7,33 @@
import assert from 'node:assert';
import {describe, it} from 'node:test';
+import {getNetworkMultiplierFromString} from '../src/WaitForHelper.js';
+
+import {serverHooks} from './server.js';
import {html, withMcpContext} from './utils.js';
+describe('getNetworkMultiplierFromString', () => {
+ it('maps each predefined network condition to its multiplier', () => {
+ assert.strictEqual(getNetworkMultiplierFromString('Fast 4G'), 1);
+ assert.strictEqual(getNetworkMultiplierFromString('Slow 4G'), 2.5);
+ assert.strictEqual(getNetworkMultiplierFromString('Fast 3G'), 5);
+ assert.strictEqual(getNetworkMultiplierFromString('Slow 3G'), 10);
+ });
+
+ it('falls back to 1 for unknown condition strings', () => {
+ assert.strictEqual(getNetworkMultiplierFromString('2G'), 1);
+ assert.strictEqual(getNetworkMultiplierFromString('No emulation'), 1);
+ assert.strictEqual(getNetworkMultiplierFromString(''), 1);
+ });
+
+ it('falls back to 1 when no condition is set', () => {
+ assert.strictEqual(getNetworkMultiplierFromString(null), 1);
+ });
+});
+
describe('WaitForHelper', () => {
+ const server = serverHooks();
+
it('does not stall when an action opens a dialog without handleDialog', async () => {
await withMcpContext(async (response, context) => {
const mcpPage = context.getSelectedMcpPage();
@@ -43,4 +67,121 @@ describe('WaitForHelper', () => {
assert.throws(() => mcpPage.throwIfDialogOpen());
});
});
+
+ it('reports navigatedToUrl when the action starts a cross-document navigation', async () => {
+ await withMcpContext(async (response, context) => {
+ const mcpPage = context.getSelectedMcpPage();
+ server.addHtmlRoute('/target.html', html`
target
`);
+ const targetUrl = server.getRoute('/target.html');
+
+ const result = await mcpPage.waitForEventsAfterAction(async () => {
+ await mcpPage.pptrPage.evaluate(url => {
+ // Navigate asynchronously so the evaluate call returns before the
+ // execution context is destroyed by the navigation.
+ setTimeout(() => {
+ window.location.href = url;
+ }, 0);
+ }, targetUrl);
+ });
+
+ assert.strictEqual(result.navigatedToUrl, targetUrl);
+ assert.strictEqual(result.dialogHandled, false);
+ assert.strictEqual(mcpPage.pptrPage.url(), targetUrl);
+ });
+ });
+
+ it('resolves quickly without navigatedToUrl when no navigation happens', async () => {
+ await withMcpContext(async (response, context) => {
+ const mcpPage = context.getSelectedMcpPage();
+ await mcpPage.pptrPage.setContent(html``);
+
+ const start = Date.now();
+ const result = await mcpPage.waitForEventsAfterAction(async () => {
+ await mcpPage.pptrPage.evaluate(() => {
+ document.querySelector('#root')!.append('done');
+ });
+ });
+ const elapsed = Date.now() - start;
+
+ assert.strictEqual(result.navigatedToUrl, undefined);
+ assert.strictEqual(result.dialogHandled, false);
+ // Expected wait is ~200ms (#expectNavigationIn 100ms + #stableDomFor
+ // 100ms). Assert we stayed below #stableDomTimeout/#navigationTimeout
+ // (3s each) to prove neither full timeout was consumed.
+ assert.ok(
+ elapsed < 2_000,
+ `expected a fast return without navigation, took ${elapsed}ms`,
+ );
+ });
+ });
+
+ it('swallows navigation timeouts and still resolves with a result', async () => {
+ await withMcpContext(async (response, context) => {
+ const mcpPage = context.getSelectedMcpPage();
+ server.addRoute('/hang.html', () => {
+ // Never respond so the started navigation cannot complete.
+ });
+ const hangUrl = server.getRoute('/hang.html');
+
+ const start = Date.now();
+ const result = await mcpPage.waitForEventsAfterAction(
+ async () => {
+ await mcpPage.pptrPage.evaluate(url => {
+ setTimeout(() => {
+ window.location.href = url;
+ }, 0);
+ }, hangUrl);
+ },
+ {timeout: 500},
+ );
+ const elapsed = Date.now() - start;
+
+ // The navigation started but timed out; the timeout error is logged and
+ // swallowed rather than thrown, and the pending navigation never
+ // committed, so the URL is unchanged and no navigatedToUrl is reported.
+ assert.strictEqual(result.navigatedToUrl, undefined);
+ assert.strictEqual(result.dialogHandled, false);
+ // Current behavior: the total wait is the 500ms navigation timeout plus
+ // the full 3s #stableDomTimeout, because the stable-DOM evaluation does
+ // not settle while the navigation is still pending (~3.5s in total).
+ // Assert an upper bound well below protocolTimeout-scale hangs.
+ assert.ok(
+ elapsed < 8_000,
+ `expected the wait to be bounded by the internal timeouts, took ${elapsed}ms`,
+ );
+
+ });
+ });
+
+ it('reports same-document hash navigations via the URL comparison', async () => {
+ await withMcpContext(async (response, context) => {
+ const mcpPage = context.getSelectedMcpPage();
+ server.addHtmlRoute('/page.html', html`content
`);
+ const pageUrl = server.getRoute('/page.html');
+ await mcpPage.pptrPage.goto(pageUrl);
+
+ const result = await mcpPage.waitForEventsAfterAction(async () => {
+ await mcpPage.pptrPage.evaluate(() => {
+ window.location.hash = '#section';
+ });
+ });
+
+ assert.strictEqual(result.navigatedToUrl, `${pageUrl}#section`);
+ assert.strictEqual(result.dialogHandled, false);
+ });
+ });
+
+ it('rethrows errors from the action', async () => {
+ await withMcpContext(async (response, context) => {
+ const mcpPage = context.getSelectedMcpPage();
+ await mcpPage.pptrPage.setContent(html`content
`);
+
+ await assert.rejects(
+ mcpPage.waitForEventsAfterAction(() =>
+ Promise.reject(new Error('action failed')),
+ ),
+ /action failed/,
+ );
+ });
+ });
});