Skip to content
Open
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
84 changes: 84 additions & 0 deletions apps/dev-playground/tests/reconnect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,87 @@ test.describe("Reconnect Route Tests", () => {
await newStreamRequestPromise;
});
});

/**
* RECONNECT-REPLAY scenario.
*
* The happy-path tests above mock `/api/reconnect/stream` (see
* `setupMockAPI`), so they replay a static, all-at-once SSE body and never
* touch the real server. That is why a `Last-Event-ID` replay regression in
* the StreamManager ring buffer could (and did) go undetected for weeks: the
* connection was never actually dropped mid-stream.
*
* These tests run against the REAL dev-playground server (no stream mock) and
* force a mid-stream disconnect, asserting the client recovers ALL messages
* with no duplicates and no gaps — i.e. the server replayed exactly the
* buffered events the client missed.
*/
test.describe("Reconnect Route Tests - Last-Event-ID replay (recovery)", () => {
// Reads the message-count headline ("N / 5 messages received").
const readMessageCount = async (page: import("@playwright/test").Page) => {
const container = page.locator("div").filter({
has: page.getByText("/ 5 messages received"),
});
const text = await container.locator("h2").first().textContent();
return Number.parseInt(text ?? "0", 10);
};

test("recovers all messages with no gaps or duplicates after a mid-stream network drop", async ({
page,
context,
}) => {
// Identify this as the reconnect-replay scenario for triage.
test.info().annotations.push({
type: "scenario",
description: "reconnect-replay: mid-stream drop -> Last-Event-ID replay",
});

// NOTE: intentionally NOT calling setupMockAPI — we exercise the real
// server SSE stream + StreamManager ring buffer.
await page.goto("/reconnect", { waitUntil: "domcontentloaded" });

const messageCount = page.locator("div").filter({
has: page.getByText("/ 5 messages received"),
});

// Wait until SOME (not all) messages have arrived, then drop the network
// mid-stream. The server yields 1 message every ~3s, so >=1 and <5 leaves
// room to interrupt before completion.
await expect
.poll(() => readMessageCount(page), {
timeout: 20000,
message: "expected at least one message before forcing disconnect",
})
.toBeGreaterThanOrEqual(1);

expect(await readMessageCount(page)).toBeLessThan(5);

// Force a client-side disconnect mid-stream: this aborts the in-flight
// fetch with a network error, triggering the hook's reconnect path which
// re-requests the same streamId with a Last-Event-ID header.
await context.setOffline(true);
await page.waitForTimeout(1500);
await context.setOffline(false);

// After reconnection + replay, the stream must complete with all 5
// messages. Web-first assertion polls until the headline reads "5".
await expect(messageCount.locator("h2")).toHaveText("5", {
timeout: 40000,
});

// Status should land on a terminal/healthy state, never "Error".
await expect(
page.locator('[data-slot="badge"]').filter({ hasText: "Error" }),
).toHaveCount(0);

// Verify NO gaps and NO duplicates: the rendered stream must contain
// exactly one "Message k/5" card for each k in 1..5. This is what proves
// Last-Event-ID replay delivered the missed events without re-delivering
// ones the client already had.
for (let k = 1; k <= 5; k++) {
await expect(
page.getByText(`Message ${k}/5`, { exact: true }),
).toHaveCount(1, { timeout: 5000 });
}
});
});
Loading