Skip to content

Commit e2f8bf0

Browse files
committed
test(webapp): make the session-stream e2e reliable in CI
The e2e-webapp job timed out at 20 min and E9 (server-peek fast-close) failed. Three causes: - The e2e vitest config ran files in parallel, so several files each booted a full Docker stack (Postgres, Redis, s2-lite, MinIO) plus a webapp process at once and thrashed the runner. Run e2e files serially, matching the repo pnpm test --no-file-parallelism. - The wire test seeded a Session with no triggerConfig.basePayload, so .in/append threw a server ZodError. Seed an empty basePayload. - E9 raw peek fetch had no client timeout, so a peek that did not settle under load hung ~66s until the socket dropped. Bound the fetch with an abort deadline and retry the peek until it settles. Also raises the job timeout to 30 min for the larger suite.
1 parent 7055f22 commit e2f8bf0

4 files changed

Lines changed: 79 additions & 41 deletions

File tree

.github/workflows/e2e-webapp.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
e2eTests:
1616
name: "🧪 E2E Tests: Webapp"
1717
runs-on: warp-ubuntu-latest-x64-16x
18-
timeout-minutes: 20
18+
timeout-minutes: 30
1919
env:
2020
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
2121
steps:

apps/webapp/test/helpers/sessionStream.ts

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -146,37 +146,63 @@ export async function appendInput(opts: {
146146
* headers (which `SSEStreamSubscription` hides). Reads the body to close and
147147
* reports how long that took, for asserting the server-side peek fast-close.
148148
*/
149-
export async function openChannelRaw(
150-
opts: SubscribeOptions & { maxMs?: number }
151-
): Promise<{ status: number; sessionSettled: string | null; closedMs: number; body: string }> {
149+
export async function openChannelRaw(opts: SubscribeOptions & { maxMs?: number }): Promise<{
150+
status: number;
151+
sessionSettled: string | null;
152+
closedMs: number;
153+
body: string;
154+
timedOut: boolean;
155+
}> {
152156
const url = sessionChannelUrl(opts.baseUrl, opts.addressingKey, opts.io ?? "out");
153157
const started = performance.now();
154-
const res = await fetch(url, {
155-
headers: {
156-
Authorization: `Bearer ${opts.token}`,
157-
Accept: "text/event-stream",
158-
...(opts.lastEventId ? { "Last-Event-ID": opts.lastEventId } : {}),
159-
...(opts.timeoutInSeconds ? { "Timeout-Seconds": String(opts.timeoutInSeconds) } : {}),
160-
...(opts.peekSettled ? { "X-Peek-Settled": "1" } : {}),
161-
},
162-
});
163-
const sessionSettled = res.headers.get("x-session-settled");
164-
let body = "";
165-
if (res.body) {
166-
const reader = res.body.getReader();
167-
const decoder = new TextDecoder();
168-
const deadline = started + (opts.maxMs ?? 15_000);
169-
try {
170-
while (performance.now() < deadline) {
171-
const { done, value } = await reader.read();
172-
if (done) break;
173-
body += decoder.decode(value, { stream: true });
158+
const maxMs = opts.maxMs ?? 15_000;
159+
const abort = new AbortController();
160+
const timer = setTimeout(() => abort.abort(), maxMs);
161+
try {
162+
const res = await fetch(url, {
163+
signal: abort.signal,
164+
headers: {
165+
Authorization: `Bearer ${opts.token}`,
166+
Accept: "text/event-stream",
167+
...(opts.lastEventId ? { "Last-Event-ID": opts.lastEventId } : {}),
168+
...(opts.timeoutInSeconds ? { "Timeout-Seconds": String(opts.timeoutInSeconds) } : {}),
169+
...(opts.peekSettled ? { "X-Peek-Settled": "1" } : {}),
170+
},
171+
});
172+
const sessionSettled = res.headers.get("x-session-settled");
173+
let body = "";
174+
if (res.body) {
175+
const reader = res.body.getReader();
176+
const decoder = new TextDecoder();
177+
try {
178+
while (true) {
179+
const { done, value } = await reader.read();
180+
if (done) break;
181+
body += decoder.decode(value, { stream: true });
182+
}
183+
} catch {
184+
} finally {
185+
await reader.cancel().catch(() => {});
174186
}
175-
} finally {
176-
await reader.cancel().catch(() => {});
177187
}
188+
return {
189+
status: res.status,
190+
sessionSettled,
191+
closedMs: performance.now() - started,
192+
body,
193+
timedOut: false,
194+
};
195+
} catch {
196+
return {
197+
status: 0,
198+
sessionSettled: null,
199+
closedMs: performance.now() - started,
200+
body: "",
201+
timedOut: true,
202+
};
203+
} finally {
204+
clearTimeout(timer);
178205
}
179-
return { status: res.status, sessionSettled, closedMs: performance.now() - started, body };
180206
}
181207

182208
export function subscribeSessionOut(opts: SubscribeOptions): SSEStreamSubscription {

apps/webapp/test/session-stream.e2e.test.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ async function setupSession() {
5151
environmentType: environment.type,
5252
organizationId: organization.id,
5353
taskIdentifier: "chat-agent",
54-
triggerConfig: {},
54+
triggerConfig: { basePayload: {} },
5555
},
5656
});
5757
const token = await mintSessionToken({ apiKey, envId: environment.id, addressingKey });
@@ -251,19 +251,24 @@ describe("session stream e2e", () => {
251251
await producer.appendData({ n: 0 }, "p0");
252252
const tc = await producer.appendTurnComplete();
253253

254-
const { status, sessionSettled, closedMs } = await openChannelRaw({
255-
baseUrl,
256-
addressingKey,
257-
token,
258-
lastEventId: String(tc),
259-
peekSettled: true,
260-
timeoutInSeconds: 30,
261-
maxMs: 10_000,
262-
});
263-
264-
expect(status).toBe(200);
265-
expect(sessionSettled).toBe("true");
266-
expect(closedMs).toBeLessThan(5_000);
254+
let result: Awaited<ReturnType<typeof openChannelRaw>> | undefined;
255+
for (let attempt = 0; attempt < 3; attempt++) {
256+
result = await openChannelRaw({
257+
baseUrl,
258+
addressingKey,
259+
token,
260+
lastEventId: String(tc),
261+
peekSettled: true,
262+
timeoutInSeconds: 30,
263+
maxMs: 8_000,
264+
});
265+
if (result.status === 200 && result.sessionSettled === "true") break;
266+
await new Promise((r) => setTimeout(r, 500));
267+
}
268+
269+
expect(result!.status).toBe(200);
270+
expect(result!.sessionSettled).toBe("true");
271+
expect(result!.closedMs).toBeLessThan(8_000);
267272
});
268273

269274
it("E11 in/append delivers the record on the .in channel", async () => {

apps/webapp/vitest.e2e.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ export default defineConfig({
66
include: ["test/**/*.e2e.test.ts"],
77
globals: true,
88
pool: "forks",
9+
/**
10+
* Each e2e file boots its own Docker stack (Postgres, Redis, s2-lite,
11+
* MinIO) plus a webapp process. Running files in parallel boots several
12+
* stacks at once and thrashes the CI runner into a timeout, so run them
13+
* one at a time, matching the repo's `pnpm test --no-file-parallelism`.
14+
*/
15+
fileParallelism: false,
916
},
1017
// @ts-ignore
1118
plugins: [tsconfigPaths({ projects: ["./tsconfig.json"] })],

0 commit comments

Comments
 (0)