Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/browser/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,45 @@ describe('Page.evaluate', () => {
expect(sendCommandMock).toHaveBeenCalledTimes(2);
});

it('rebinds before retrying when the active page identity goes stale', async () => {
sendCommandFullMock
.mockResolvedValueOnce({ data: { url: 'https://example.com/jobs' }, page: 'stale-page' })
.mockResolvedValueOnce({ data: { url: 'https://example.com/jobs' }, page: 'fresh-page' });
sendCommandMock
.mockResolvedValueOnce(null)
.mockRejectedValueOnce(new Error('Page not found: stale-page — stale page identity'))
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(42);

const page = new Page('site:example', undefined, undefined, undefined, 'adapter', 'persistent');
await page.goto('https://example.com/jobs');

await expect(page.evaluate('21 + 21')).resolves.toBe(42);
expect(page.getActivePage()).toBe('fresh-page');
expect(sendCommandFullMock).toHaveBeenCalledTimes(2);
expect(sendCommandFullMock.mock.calls[1]).toEqual([
'navigate',
expect.not.objectContaining({ page: expect.anything() }),
]);
expect(sendCommandMock).toHaveBeenLastCalledWith('exec', expect.objectContaining({
code: '21 + 21',
page: 'fresh-page',
}));
});

it('does not guess a replacement for an explicitly selected stale page', async () => {
sendCommandMock.mockRejectedValueOnce(
new Error('Page not found: selected-page — stale page identity'),
);

const page = new Page('default');
page.setActivePage('selected-page');

await expect(page.evaluate('21 + 21')).rejects.toThrow('stale page identity');
expect(sendCommandFullMock).not.toHaveBeenCalled();
expect(sendCommandMock).toHaveBeenCalledTimes(1);
});

it('serializes function-form evaluate calls with JSON args', async () => {
sendCommandMock.mockResolvedValueOnce('/opencli');

Expand Down Expand Up @@ -323,6 +362,29 @@ describe('Page active target tracking', () => {
expect(retryCall[1]).not.toHaveProperty('page');
});

it('rebinds when the settle retry exposes a stale page identity', async () => {
sendCommandFullMock
.mockResolvedValueOnce({ data: { url: 'https://example.com/jobs' }, page: 'stale-page' })
.mockResolvedValueOnce({ data: { url: 'https://example.com/jobs' }, page: 'fresh-page' });
sendCommandMock
.mockRejectedValueOnce(new Error('Inspected target navigated or closed'))
.mockRejectedValueOnce(new Error('Page not found: stale-page — stale page identity'))
.mockResolvedValueOnce(null);

const page = new Page('site:boss', undefined, undefined, undefined, 'adapter', 'persistent');

await expect(page.goto('https://example.com/jobs')).resolves.toBeUndefined();
expect(page.getActivePage()).toBe('fresh-page');
expect(sendCommandFullMock).toHaveBeenCalledTimes(2);
expect(sendCommandFullMock.mock.calls[1]).toEqual([
'navigate',
expect.not.objectContaining({ page: expect.anything() }),
]);
expect(sendCommandMock).toHaveBeenLastCalledWith('exec', expect.objectContaining({
page: 'fresh-page',
}));
});

it('retries on a bare "Page not found:" error without the stale-identity suffix', async () => {
// Under concurrent adapter calls the extension can reject with just
// "Page not found: <id>" (no "— stale page identity" suffix) when the cached
Expand Down
31 changes: 29 additions & 2 deletions src/browser/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ function isUnsupportedNetworkCaptureError(err: unknown): boolean {
// The extension throws "Page not found: <id> — stale page identity" when our cached
// `_page` targetId no longer maps to a live tab — e.g. the user closed the automation
// window, or a long-running script left the cache pointing at an evicted target.
// Detect that signature so goto() can drop the stale id and let resolveTab fall back
// to the session lease (or create a fresh tab).
// Detect that signature so page-scoped operations can drop the stale id and let
// resolveTab fall back to the session lease (or create a fresh tab).
function isStalePageIdentityError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return message.includes('stale page identity') || /^Page not found:\s*\S+\s*$/.test(message);
Expand Down Expand Up @@ -122,9 +122,26 @@ export class Page extends BasePage {
code: combinedCode,
...this._cmdOpts(),
};
const recoverStalePageIdentity = async (err: unknown): Promise<boolean> => {
if (!isStalePageIdentityError(err) || this._page === undefined) return false;

this._page = undefined;
const rebound = await sendCommandFull('navigate', {
url,
...this._cmdOpts(),
});
if (rebound.page) this._page = rebound.page;
this._lastUrl = url;
await sendCommand('exec', {
code: combinedCode,
...this._cmdOpts(),
});
return true;
};
try {
await sendCommand('exec', combinedOpts);
} catch (err) {
if (await recoverStalePageIdentity(err)) return;
const advice = classifyBrowserError(err);
// Only settle-retry on target navigation (SPA client-side redirects).
// Extension/daemon errors are already retried by sendCommandRaw —
Expand All @@ -134,6 +151,7 @@ export class Page extends BasePage {
await new Promise((r) => setTimeout(r, advice.delayMs));
await sendCommand('exec', combinedOpts);
} catch (retryErr) {
if (await recoverStalePageIdentity(retryErr)) return;
if (classifyBrowserError(retryErr).kind !== 'target-navigation') throw retryErr;
}
}
Expand Down Expand Up @@ -177,6 +195,15 @@ export class Page extends BasePage {
try {
return await sendCommand('exec', { code, ...this._cmdOpts() });
} catch (err) {
// resolveTabId rejects a stale target before page code can run, so one retry is
// safe. Revisit the last known URL without the dead identity first; retrying the
// same exec immediately would only send the stale target again.
if (isStalePageIdentityError(err) && this._page !== undefined && this._lastUrl !== null) {
const lastUrl = this._lastUrl;
this._page = undefined;
await this.goto(lastUrl);
return sendCommand('exec', { code, ...this._cmdOpts() });
}
const advice = classifyBrowserError(err);
if (advice.kind !== 'target-navigation') throw err;
await new Promise((resolve) => setTimeout(resolve, advice.delayMs));
Expand Down