Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/real-cameras-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/stagehand": patch
---

When connecting to a browser session that has zero open tabs, Stagehand now automatically creates an initial `about:blank` tab so the connection can continue.
11 changes: 8 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,14 @@ jobs:
- name: Run Lint
run: pnpm exec turbo run lint

- name: Cancel workflow if lint fails
if: failure()
continue-on-error: true
cancel-after-lint-failure:
name: Cancel after lint failure
runs-on: ubuntu-latest
needs: [run-lint]
if: ${{ always() && needs.run-lint.result == 'failure' }}
continue-on-error: true
steps:
- name: Cancel workflow run
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
Expand Down
39 changes: 34 additions & 5 deletions packages/core/lib/v3/understudy/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ const DEFAULT_FIRST_TOP_LEVEL_PAGE_TIMEOUT_MS = 5000;
const CI_FIRST_TOP_LEVEL_PAGE_TIMEOUT_MS = 30000;
const FIRST_TOP_LEVEL_PAGE_TIMEOUT_ENV =
"STAGEHAND_FIRST_TOP_LEVEL_PAGE_TIMEOUT_MS";
const WAIT_FOR_FIRST_TOP_LEVEL_PAGE_OPERATION =
"waitForFirstTopLevelPage (no top-level Page)";

function getFirstTopLevelPageTimeoutMs(): number {
return (
Expand Down Expand Up @@ -168,7 +170,7 @@ export class V3Context {
opts?.localBrowserLaunchOptions ?? null,
);
await ctx.bootstrap();
await ctx.waitForFirstTopLevelPage(getFirstTopLevelPageTimeoutMs());
await ctx.ensureFirstTopLevelPage(getFirstTopLevelPageTimeoutMs());
return ctx;
};

Expand Down Expand Up @@ -199,6 +201,36 @@ export class V3Context {
return await connectTask();
}

private hasTopLevelPage(): boolean {
for (const [targetId, targetType] of this.typeByTarget) {
if (targetType === "page" && this.pagesByTarget.has(targetId)) {
return true;
}
}
return false;
}

private async ensureFirstTopLevelPage(timeoutMs: number): Promise<void> {
if (this.hasTopLevelPage()) return;

try {
await this.waitForFirstTopLevelPage(timeoutMs);
return;
} catch (err) {
if (!(err instanceof TimeoutError)) {
throw err;
}
v3Logger({
category: "ctx",
message:
"No open browser pages found after connect; creating an initial about:blank page",
level: 1,
});
}

await this.newPage("about:blank");
}

/**
* Wait until at least one top-level Page has been created and registered.
* We poll internal maps that bootstrap/onAttachedToTarget populate.
Expand All @@ -216,10 +248,7 @@ export class V3Context {
}
await new Promise((r) => setTimeout(r, 25));
}
throw new TimeoutError(
"waitForFirstTopLevelPage (no top-level Page)",
timeoutMs,
);
throw new TimeoutError(WAIT_FOR_FIRST_TOP_LEVEL_PAGE_OPERATION, timeoutMs);
}

private async waitForInitialTopLevelTargets(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,54 @@ test.describe("connect to existing Browserbase session", () => {
await closeV3(initialStagehand);
}
});

test("new Stagehand instance initializes when existing browser has zero pages", async () => {
const browserTarget = (
process.env.STAGEHAND_BROWSER_TARGET ?? "local"
).toLowerCase();
const isLocal = browserTarget !== "browserbase";
test.skip(!isLocal, "Requires STAGEHAND_BROWSER_TARGET=local");

const initialStagehand = new V3({
...v3DynamicTestConfig,
disableAPI: true,
env: "LOCAL",
});
await initialStagehand.init();

let resumedStagehand: V3 | null = null;

try {
const ctx = initialStagehand.context;
const pages = ctx.pages();
for (const page of pages) {
await page.close();
}

await expect.poll(() => ctx.pages().length, { timeout: 15_000 }).toBe(0);

const sessionUrl = initialStagehand.connectURL();
resumedStagehand = new V3({
env: "LOCAL",
verbose: 0,
disablePino: true,
disableAPI: true,
logger: v3DynamicTestConfig.logger,
localBrowserLaunchOptions: {
cdpUrl: sessionUrl,
},
});

await resumedStagehand.init();

await expect
.poll(() => resumedStagehand!.context.pages().length, {
timeout: 15_000,
})
.toBeGreaterThan(0);
} finally {
await closeV3(resumedStagehand);
await closeV3(initialStagehand);
}
});
});