Files
kandev/apps/web/e2e/tests/session/session-stream-overload-isolation.spec.ts
T

194 lines
6.8 KiB
TypeScript

import { expect } from "@playwright/test";
import { test } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import {
assertNoHorizontalOverflow,
noisyReceivedFrames,
reasoningBurstPrompt,
REASONING_BURST_COUNT,
waitForExactReasoningBurst,
} from "../../helpers/session-stream-overload";
import { attachGatewayTrafficCapture, summarizeGatewayTraffic } from "../../helpers/ws-traffic";
test.describe("Session stream overload isolation", () => {
test("keeps a quiet session usable while another session streams", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const quietCapture = attachGatewayTrafficCapture(testPage);
const noisyTask = await apiClient.createTask(
seedData.workspaceId,
"Noisy reasoning isolation task",
{
agent_profile_id: seedData.agentProfileId,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
const preparedNoisy = await apiClient.launchSession({
task_id: noisyTask.id,
agent_profile_id: seedData.agentProfileId,
executor_profile_id: seedData.worktreeExecutorProfileId,
workflow_step_id: seedData.startStepId,
prompt: "",
intent: "prepare",
launch_workspace: true,
});
const noisySessionId = preparedNoisy.session_id;
await expect
.poll(async () => (await apiClient.getTaskEnvironment(noisyTask.id))?.status ?? null, {
timeout: 60_000,
message: "prepared noisy task environment did not become ready",
})
.toBe("ready");
// Keep a real subscribed browser open for the noisy session while the
// primary page navigates to the quiet task. This exercises the same
// per-client isolation boundary that failed during the incident. The
// session is prepared first so a fast mock run cannot finish before the
// browser has registered its subscription.
const noisyPage = await testPage.context().newPage();
const noisyCapture = attachGatewayTrafficCapture(noisyPage);
await noisyPage.goto(`/t/${noisyTask.id}`);
const noisySession = new SessionPage(noisyPage);
await noisySession.waitForLoad();
await noisySession.waitForChatIdle({ timeout: 60_000 });
await expect
.poll(
() =>
noisyCapture.frames.some(
(frame) =>
frame.direction === "sent" &&
frame.action === "session.subscribe" &&
frame.sessionId === noisySessionId,
),
{ timeout: 10_000, message: "noisy page must subscribe before the burst" },
)
.toBe(true);
await expect
.poll(
() =>
noisyCapture.frames.some(
(frame) =>
frame.direction === "received" &&
frame.action === "session.subscribe" &&
frame.sessionId === noisySessionId,
),
{ timeout: 30_000, message: "noisy page did not acknowledge its session subscription" },
)
.toBe(true);
const quietTask = await apiClient.createTask(
seedData.workspaceId,
"Quiet session isolation task",
{
agent_profile_id: seedData.agentProfileId,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
const preparedQuiet = await apiClient.launchSession({
task_id: quietTask.id,
agent_profile_id: seedData.agentProfileId,
executor_profile_id: seedData.worktreeExecutorProfileId,
workflow_step_id: seedData.startStepId,
prompt: "",
intent: "prepare",
launch_workspace: true,
});
const quietSessionId = preparedQuiet.session_id;
await expect
.poll(async () => (await apiClient.getTaskEnvironment(quietTask.id))?.status ?? null, {
timeout: 60_000,
message: "prepared quiet task environment did not become ready",
})
.toBe("ready");
await testPage.goto(`/t/${quietTask.id}`);
const quietSession = new SessionPage(testPage);
await quietSession.waitForLoad();
await quietSession.waitForChatIdle({ timeout: 60_000 });
await expect
.poll(
() =>
quietCapture.frames.some(
(frame) =>
frame.direction === "sent" &&
frame.action === "session.subscribe" &&
frame.sessionId === quietSessionId,
),
{ timeout: 10_000, message: "quiet page must subscribe before the burst" },
)
.toBe(true);
await expect
.poll(
() =>
quietCapture.frames.some(
(frame) =>
frame.direction === "received" &&
frame.action === "session.subscribe" &&
frame.sessionId === quietSessionId,
),
{ timeout: 30_000, message: "quiet page did not acknowledge its session subscription" },
)
.toBe(true);
noisyCapture.frames.length = 0;
quietCapture.frames.length = 0;
await apiClient.launchSession({
task_id: noisyTask.id,
session_id: noisySessionId,
agent_profile_id: seedData.agentProfileId,
prompt: reasoningBurstPrompt(),
intent: "start_created",
});
await quietSession.sendMessageViaButton("quiet-session-followup");
await quietSession.expectChatResponseVisible("quiet-session-followup", 0, { timeout: 60_000 });
await assertNoHorizontalOverflow(testPage, "session stream overload desktop surface");
const exact = await waitForExactReasoningBurst(apiClient, noisySessionId);
const noisyFrames = noisyReceivedFrames(noisyCapture.frames, noisySessionId);
const quietSessionNoisyFrames = noisyReceivedFrames(quietCapture.frames, noisySessionId);
expect(
noisyFrames.length,
"noisy session must produce observable message frames",
).toBeGreaterThan(0);
expect(noisyFrames.length, "gateway delivery must be below source chunk count").toBeLessThan(
REASONING_BURST_COUNT,
);
expect(
quietSessionNoisyFrames,
"quiet page must not receive noisy-session message frames",
).toHaveLength(0);
const evidence = {
sourceChunks: exact.sourceChunks,
reasoningBytes: exact.reasoningBytes,
gatewayReceivedMessageFrames: noisyFrames.length,
quietSessionResponse: "quiet-session-followup",
quietGateway: summarizeGatewayTraffic(quietCapture.frames),
noisyGateway: summarizeGatewayTraffic(noisyCapture.frames),
};
await test.info().attach("session-stream-overload-evidence.json", {
body: JSON.stringify(evidence, null, 2),
contentType: "application/json",
});
test.info().annotations.push({
type: "stream-overload-evidence",
description: JSON.stringify(evidence),
});
await noisyPage.close();
});
});