Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
50 changes: 50 additions & 0 deletions packages/stagehand-crawler/src/internals/stagehand-controller.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { setTimeout } from 'node:timers/promises';
Comment thread
B4nan marked this conversation as resolved.
Outdated

import type { Stagehand } from '@browserbasehq/stagehand';
import { BrowserController } from '@crawlee/browser-pool';
import type { Cookie } from '@crawlee/types';
Expand Down Expand Up @@ -62,6 +64,13 @@ export class StagehandController extends BrowserController<BrowserType, LaunchOp
this.activePages--;
});

try {
await this.waitForStagehandToRegisterPage(page);
} catch (error) {
await page.close().catch(() => {});
throw error;
}

return page;
} catch (error) {
throw new Error(`Failed to create new page: ${error instanceof Error ? error.message : String(error)}`, {
Expand All @@ -70,6 +79,47 @@ export class StagehandController extends BrowserController<BrowserType, LaunchOp
}
}

/**
* Waits until Stagehand knows about the page, so that `act()`/`extract()`/`observe()` can resolve it.
*
* Stagehand only learns about pages from CDP `Target.attachedToTarget` events on its own connection,
* and it maps them by main frame id. We create pages through a separate `connectOverCDP()` handle, so
* `context.newPage()` resolves before Stagehand has processed that event — the page is unresolvable for
* a short window, and the AI methods fail with 'Failed to resolve V3 Page from Playwright page'.
* Stagehand's own `newPage()` polls for the same reason.
*/
private async waitForStagehandToRegisterPage(page: Page, timeoutMs = 10_000): Promise<void> {
const stagehand = this.getStagehand();
const mainFrameId = await this.getMainFrameId(page);
const deadline = Date.now() + timeoutMs;

while (Date.now() < deadline) {
if (stagehand.context.resolvePageByMainFrameId(mainFrameId)) {
return;
}

await setTimeout(25);
}

// Stagehand skips registration entirely when it cannot install its helper script into the page's CDP
// session, so this is not always just a slow attach - it can never resolve.
throw new Error(`Stagehand did not register the page within ${timeoutMs}ms`);
}

/**
* Reads the page's main frame id, which is the key Stagehand resolves pages by.
*/
private async getMainFrameId(page: Page): Promise<string> {
const cdpSession = await page.context().newCDPSession(page);

try {
const { frameTree } = await cdpSession.send('Page.getFrameTree');
return frameTree.frame.id;
} finally {
await cdpSession.detach().catch(() => {});
}
}

/**
* Normalizes proxy options for Playwright.
*/
Expand Down
72 changes: 72 additions & 0 deletions test/stagehand-crawler/stagehand-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,78 @@ describe('StagehandController', () => {
await expect((controller as any)._kill()).resolves.toBeUndefined();
});

describe('_newPage', () => {
let mockCdpSession: any;
let mockPage: any;

beforeEach(() => {
mockCdpSession = {
send: vi.fn().mockResolvedValue({ frameTree: { frame: { id: 'MAIN_FRAME_1' } } }),
detach: vi.fn().mockResolvedValue(undefined),
};

mockPage = {
once: vi.fn(),
close: vi.fn().mockResolvedValue(undefined),
context: vi.fn().mockReturnValue({
newCDPSession: vi.fn().mockResolvedValue(mockCdpSession),
}),
};

mockBrowser.contexts = vi.fn().mockReturnValue([{ newPage: vi.fn().mockResolvedValue(mockPage) }]);
mockStagehand.context.resolvePageByMainFrameId = vi.fn().mockReturnValue(undefined);
});

const createController = () => {
const controller = new StagehandController(mockPlugin, stagehandInstances);
(controller as any).browser = mockBrowser;

return controller;
};

test('should not return the page before Stagehand registers it', async () => {
// Stagehand registers pages from a CDP event, so the page is unresolvable for a short while.
let attempts = 0;
mockStagehand.context.resolvePageByMainFrameId = vi.fn(() =>
++attempts < 3 ? undefined : { v3Page: true },
);

const page = await (createController() as any)._newPage();

expect(page).toBe(mockPage);
expect(attempts).toBe(3);
expect(mockStagehand.context.resolvePageByMainFrameId).toHaveBeenCalledWith('MAIN_FRAME_1');
expect(mockPage.close).not.toHaveBeenCalled();
});

test('should detach the CDP session used to read the main frame id', async () => {
mockStagehand.context.resolvePageByMainFrameId = vi.fn().mockReturnValue({ v3Page: true });

await (createController() as any)._newPage();

expect(mockCdpSession.send).toHaveBeenCalledWith('Page.getFrameTree');
expect(mockCdpSession.detach).toHaveBeenCalled();
});

test('should throw when Stagehand never registers the page', async () => {
const controller = createController();

await expect((controller as any).waitForStagehandToRegisterPage(mockPage, 100)).rejects.toThrow(
'Stagehand did not register the page within 100ms',
);
});

test('should close the page when Stagehand never registers it', async () => {
const controller = createController();
vi.spyOn(controller as any, 'waitForStagehandToRegisterPage').mockRejectedValue(
new Error('not registered'),
);

await expect((controller as any)._newPage()).rejects.toThrow('Failed to create new page: not registered');
expect(mockPage.close).toHaveBeenCalled();
});
});

// Note: Proxy authentication is now handled at the plugin level using anonymizeProxySugar
// from proxy-chain, which creates a local proxy that handles auth transparently.
// See stagehand-plugin.ts for the implementation.
Expand Down
Loading