Skip to content

Commit 27fd21b

Browse files
committed
test: harden local startup and self-heal coverage
1 parent 7a03670 commit 27fd21b

4 files changed

Lines changed: 116 additions & 14 deletions

File tree

packages/core/lib/v3/understudy/cdp.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,41 @@ export class CdpConnection implements CDPSessionLike {
116116

117117
static async connect(
118118
wsUrl: string,
119-
options?: { headers?: Record<string, string> },
119+
options?: { headers?: Record<string, string>; retryForMs?: number },
120+
): Promise<CdpConnection> {
121+
const deadlineMs = options?.retryForMs
122+
? Date.now() + options.retryForMs
123+
: undefined;
124+
let lastError: unknown;
125+
126+
do {
127+
try {
128+
return await CdpConnection.connectOnce(wsUrl, options?.headers);
129+
} catch (error) {
130+
lastError = error;
131+
if (
132+
!deadlineMs ||
133+
Date.now() >= deadlineMs ||
134+
!isTransientConnectError(error)
135+
) {
136+
throw error;
137+
}
138+
await new Promise((resolve) => setTimeout(resolve, 100));
139+
}
140+
} while (deadlineMs && Date.now() < deadlineMs);
141+
142+
throw lastError;
143+
}
144+
145+
private static async connectOnce(
146+
wsUrl: string,
147+
userHeaders?: Record<string, string>,
120148
): Promise<CdpConnection> {
121149
// Include User-Agent header for server-side observability and version tracking
122150
// Merge user-provided headers, letting them override defaults
123151
const headers = {
124152
"User-Agent": `Stagehand/${STAGEHAND_VERSION}`,
125-
...options?.headers,
153+
...userHeaders,
126154
};
127155
const ws = new WebSocket(wsUrl, { headers });
128156
await new Promise<void>((resolve, reject) => {
@@ -510,6 +538,16 @@ export class CdpConnection implements CDPSessionLike {
510538
}
511539
}
512540

541+
function isTransientConnectError(error: unknown): boolean {
542+
const err = error as NodeJS.ErrnoException | undefined;
543+
return (
544+
err?.code === "ECONNREFUSED" ||
545+
err?.code === "ECONNRESET" ||
546+
err?.message?.includes("ECONNREFUSED") === true ||
547+
err?.message?.includes("ECONNRESET") === true
548+
);
549+
}
550+
513551
export class CdpSession implements CDPSessionLike {
514552
constructor(
515553
private readonly root: CdpConnection,

packages/core/lib/v3/understudy/context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export class V3Context {
166166
const connectTask = async () => {
167167
const conn = await CdpConnection.connect(wsUrl, {
168168
headers: opts?.cdpHeaders,
169+
retryForMs: opts?.env === "LOCAL" ? 3_000 : undefined,
169170
});
170171
const ctx = new V3Context(
171172
conn,

packages/core/tests/integration/agent-cache-self-heal.spec.ts

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,53 @@ import { test, expect } from "@playwright/test";
22
import fs from "fs/promises";
33
import path from "path";
44
import { V3 } from "../../lib/v3/v3.js";
5-
import { v3TestConfig } from "./v3.config.js";
5+
import { getV3TestConfig } from "./v3.config.js";
66
import type {
77
AgentReplayActStep,
88
AgentReplayFillFormStep,
99
CachedAgentEntry,
1010
} from "../../lib/v3/types/private/cache.js";
11+
import {
12+
createScriptedAisdkTestLlmClient,
13+
doneToolResponse,
14+
findElementRefForText,
15+
toolCallResponse,
16+
} from "./testUtils.js";
17+
18+
function encodeHtml(html: string): string {
19+
return `data:text/html,${encodeURIComponent(html)}`;
20+
}
21+
22+
function createSelfHealLlmClient() {
23+
return createScriptedAisdkTestLlmClient({
24+
jsonResponses: {
25+
act: [
26+
(options) => ({
27+
action: {
28+
target: findElementRefForText(options, "Launch self-heal"),
29+
description: "launch self-heal button",
30+
method: "click",
31+
button: null,
32+
},
33+
twoStep: false,
34+
}),
35+
(options) => ({
36+
action: {
37+
target: findElementRefForText(options, "Launch self-heal"),
38+
description: "launch self-heal button",
39+
method: "click",
40+
button: null,
41+
},
42+
twoStep: false,
43+
}),
44+
],
45+
},
46+
generateResponses: [
47+
toolCallResponse("act", { action: "click the button" }),
48+
doneToolResponse("Clicked the button successfully.", true),
49+
],
50+
});
51+
}
1152

1253
test.describe("Agent cache self-heal (e2e)", () => {
1354
let v3: V3;
@@ -18,7 +59,10 @@ test.describe("Agent cache self-heal (e2e)", () => {
1859
await fs.mkdir(testInfo.outputDir, { recursive: true });
1960
cacheDir = await fs.mkdtemp(path.join(testInfo.outputDir, "agent-cache-"));
2061
v3 = new V3({
21-
...v3TestConfig,
62+
...getV3TestConfig({
63+
experimental: true,
64+
llmClient: createSelfHealLlmClient(),
65+
}),
2266
cacheDir,
2367
selfHeal: true,
2468
});
@@ -30,19 +74,32 @@ test.describe("Agent cache self-heal (e2e)", () => {
3074
});
3175

3276
test("replays heal corrupted selectors", async () => {
33-
test.setTimeout(120_000);
77+
test.setTimeout(60_000);
3478

35-
const agent = v3.agent({
36-
model: "anthropic/claude-haiku-4-5-20251001",
37-
});
79+
const agent = v3.agent();
3880
const page = v3.context.pages()[0];
39-
const url =
40-
"https://browserbase.github.io/stagehand-eval-sites/sites/shadow-dom/";
81+
const url = encodeHtml(`
82+
<!doctype html>
83+
<html>
84+
<body>
85+
<button
86+
id="launch"
87+
onclick="document.getElementById('status').textContent = 'clicked';"
88+
>
89+
Launch self-heal
90+
</button>
91+
<div id="status">idle</div>
92+
</body>
93+
</html>
94+
`);
4195
const instruction = "click the button";
4296

43-
await page.goto(url, { waitUntil: "networkidle" });
97+
await page.goto(url, { waitUntil: "load" });
4498
const firstResult = await agent.execute({ instruction, maxSteps: 20 });
4599
expect(firstResult.success).toBe(true);
100+
await expect
101+
.poll(async () => page.evaluate(() => document.body.textContent ?? ""))
102+
.toContain("clicked");
46103

47104
const cachePath = await locateAgentCacheFile(cacheDir);
48105
const originalEntry = await readCacheEntry(cachePath);
@@ -62,9 +119,13 @@ test.describe("Agent cache self-heal (e2e)", () => {
62119
);
63120

64121
// Second run should replay from cache, self-heal, and update the file.
65-
await page.goto(url, { waitUntil: "networkidle" });
122+
await page.goto(url, { waitUntil: "load" });
66123
const replayResult = await agent.execute({ instruction, maxSteps: 20 });
67124
expect(replayResult.success).toBe(true);
125+
expect(replayResult.metadata?.cacheHit).toBe(true);
126+
await expect
127+
.poll(async () => page.evaluate(() => document.body.textContent ?? ""))
128+
.toContain("clicked");
68129

69130
const healedEntry = await readCacheEntry(cachePath);
70131
const healedActionStep = findFirstActionStep(healedEntry);

packages/core/tests/integration/multi-instance-logger.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,10 @@ test.describe("V3 Multi-Instance Logger Isolation", () => {
147147
);
148148

149149
try {
150-
// Initialize both instances concurrently
151-
await Promise.all([v3Instance1.init(), v3Instance2.init()]);
150+
// This test is about shared global logger behavior, not concurrent
151+
// startup. Initialize sequentially to avoid local Chromium flake.
152+
await v3Instance1.init();
153+
await v3Instance2.init();
152154

153155
// Both should work fine
154156
expect(v3Instance1.context).toBeDefined();

0 commit comments

Comments
 (0)