Files
buzz/desktop/tests/e2e/relay-reconnect.spec.ts
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

662 lines
22 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
async function setMockWebsocketSendsStalled(
page: import("@playwright/test").Page,
stall: boolean,
) {
await page.evaluate((shouldStall) => {
const setter = (
window as Window & {
__BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void;
}
).__BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__;
if (!setter) {
throw new Error("E2E websocket stall setter is not installed.");
}
setter(shouldStall);
}, stall);
}
async function disconnectMockWebsockets(page: import("@playwright/test").Page) {
const disconnected = await page.evaluate(() => {
const disconnect = (
window as Window & {
__BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__?: () => number;
}
).__BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__;
if (!disconnect) {
throw new Error("E2E mock websocket disconnect seam is not installed.");
}
return disconnect();
});
expect(disconnected).toBeGreaterThan(0);
}
async function restartMockWebsockets(page: import("@playwright/test").Page) {
const restarted = await page.evaluate(() => {
const restart = (
window as Window & {
__BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__?: () => number;
}
).__BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__;
if (!restart)
throw new Error("E2E websocket restart seam is not installed.");
return restart();
});
expect(restarted).toBeGreaterThan(0);
}
async function setMockWebsocketUnavailable(
page: import("@playwright/test").Page,
unavailable: boolean,
) {
await page.evaluate((value) => {
const setUnavailable = window.__BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__;
if (!setUnavailable) {
throw new Error("E2E websocket availability seam is not installed.");
}
setUnavailable(value);
}, unavailable);
}
async function activateRelayRateLimit(
page: import("@playwright/test").Page,
seconds: number,
) {
await page.evaluate((duration) => {
const activate = window.__BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__;
if (!activate) {
throw new Error("E2E relay rate-limit seam is not installed.");
}
activate(duration);
}, seconds);
}
async function getMockWebsocketConnectAttempts(
page: import("@playwright/test").Page,
) {
return page.evaluate(() => {
const getAttempts = window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__;
if (!getAttempts) {
throw new Error("E2E websocket attempt seam is not installed.");
}
return getAttempts();
});
}
async function emitMockMessages(
page: import("@playwright/test").Page,
messages: Array<{ content: string; createdAt: number }>,
) {
await page.evaluate((items) => {
const emit = (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
createdAt: number;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) {
throw new Error("E2E mock message emitter is not installed.");
}
for (const item of items) {
emit({ channelName: "general", ...item });
}
}, messages);
}
async function queueAuthResponses(
page: import("@playwright/test").Page,
responses: Array<{ success: boolean; message: string }>,
) {
await page.evaluate((queued) => {
const queue = window.__BUZZ_E2E_QUEUE_AUTH_RESPONSES__;
if (!queue) throw new Error("E2E AUTH response seam is not installed.");
queue(queued);
}, responses);
}
async function closeLiveSubscriptions(
page: import("@playwright/test").Page,
reason: string,
) {
const closed = await page.evaluate((message) => {
const close = window.__BUZZ_E2E_CLOSE_LIVE_SUBSCRIPTIONS__;
if (!close) throw new Error("E2E live CLOSED seam is not installed.");
return close(message);
}, reason);
expect(closed).toBeGreaterThan(0);
}
async function queueChannelHistoryCloses(
page: import("@playwright/test").Page,
reasons: string[],
) {
await page.evaluate((queued) => {
const queue = window.__BUZZ_E2E_QUEUE_CHANNEL_HISTORY_CLOSES__;
if (!queue) {
throw new Error("E2E channel history CLOSED seam is not installed.");
}
queue(queued);
}, reasons);
}
async function driveConnectionDegraded(
page: import("@playwright/test").Page,
state: "connected" | "reconnecting" | "stalled" | "disconnected",
) {
await page.evaluate((s) => {
const setter = (
window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (state: string) => void;
}
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__;
if (!setter) {
throw new Error("E2E relay state setter is not installed.");
}
setter(s);
}, state);
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("failed initial relay dial retries automatically", async ({ page }) => {
await installMockBridge(page, {
websocketConnectErrors: ["mock relay pod unavailable"],
});
await page.goto("/");
// App-shell preconnect owns a keep-alive request. The first native dial is
// rejected before a socket ID exists; the session must still enter its
// backoff loop and recover without a click, query, or reload.
await expect
.poll(
() =>
page.evaluate(() => {
const getState = (
window as Window & {
__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => string;
}
).__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__;
if (!getState) throw new Error("Relay state seam is not installed.");
return getState();
}),
{ timeout: 10_000 },
)
.toBe("connected");
await expect(page.getByTestId("channel-general")).toBeVisible();
});
test("routine traffic cannot bypass outage backoff and recovery stays automatic", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("channel-general")).toBeVisible();
await setMockWebsocketUnavailable(page, true);
await disconnectMockWebsockets(page);
// Exercise the production query path throughout the outage. Before the
// coordinator fix, each rejected query called ensureConnected(), cancelled
// the scheduled timer, and dialed immediately. The fixed session keeps these
// callers behind its single jittered exponential-backoff attempt.
await page.evaluate(async () => {
const deadline = Date.now() + 4_200;
while (Date.now() < deadline) {
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
queryKey: ["channels"],
});
await new Promise((resolve) => window.setTimeout(resolve, 100));
}
});
const attempts = await getMockWebsocketConnectAttempts(page);
expect(attempts.length).toBeGreaterThanOrEqual(2);
expect(attempts.length).toBeLessThanOrEqual(3);
for (let index = 1; index < attempts.length; index += 1) {
expect(attempts[index] - attempts[index - 1]).toBeGreaterThanOrEqual(700);
}
await setMockWebsocketUnavailable(page, false);
await expect
.poll(
() =>
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
{ timeout: 10_000 },
)
.toBe("connected");
const afterRecovery = `automatic outage recovery ${Date.now()}`;
await emitMockMessages(page, [
{ content: afterRecovery, createdAt: Math.floor(Date.now() / 1_000) },
]);
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("message-timeline")).toContainText(
afterRecovery,
);
});
test("authenticated reconnect reports connected while replay is rate-limited", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("channel-general")).toBeVisible();
await activateRelayRateLimit(page, 5);
await disconnectMockWebsockets(page);
// Replay remains intentionally blocked behind admission control, but socket
// open + successful AUTH is already a healthy connection. The UI must not
// claim the relay is unreachable for the rest of the gate window.
await expect
.poll(
() =>
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
{ timeout: 3_000 },
)
.toBe("connected");
await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
});
test("rate-limited reconnect backfill does not tear down the authenticated socket", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Give the channel live subscription a replay cursor. On reconnect this
// causes a paged channel-history REQ in addition to restoring the live REQ.
await emitMockMessages(page, [
{
content: `replay cursor ${Date.now()}`,
createdAt: Math.floor(Date.now() / 1_000),
},
]);
const attemptsBeforeReconnect = (await getMockWebsocketConnectAttempts(page))
.length;
// Inject back-pressure from the history REQ itself. This differs from a
// pre-armed gate and from CLOSED on the live subscription: AUTH has already
// succeeded and the socket is healthy when replay backfill is rejected.
await queueChannelHistoryCloses(page, [
"rate-limited: quota exceeded; retry in 1s",
]);
await disconnectMockWebsockets(page);
await expect
.poll(
async () =>
(await getMockWebsocketConnectAttempts(page)).length -
attemptsBeforeReconnect,
{ timeout: 3_000 },
)
.toBe(1);
await expect
.poll(() =>
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
)
.toBe("connected");
// Hold through the rate-limit hint plus the next base-backoff window. The
// authenticated socket must remain the only reconnect attempt, rather than
// flashing connected and redialing after replay rejects.
await page.waitForTimeout(2_500);
expect(
(await getMockWebsocketConnectAttempts(page)).length -
attemptsBeforeReconnect,
).toBe(1);
expect(
await page.evaluate(() =>
window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.(),
),
).toBe("connected");
});
test("service restart close resets accumulated backoff", async ({ page }) => {
await installMockBridge(page, {
websocketConnectErrors: ["down 1", "down 2", "down 3"],
});
await page.goto("/");
await expect(page.getByTestId("channel-general")).toBeVisible({
timeout: 15_000,
});
const startedAt = Date.now();
await restartMockWebsockets(page);
await expect
.poll(
() =>
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
{ timeout: 2_500 },
)
.toBe("connected");
expect(Date.now() - startedAt).toBeLessThan(2_500);
});
test("passive relay watchdog does not write while the websocket is half-open", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-timeline")).toContainText(
"Welcome to #general",
);
await setMockWebsocketSendsStalled(page, true);
// Wait longer than the old active-probe interval. If the watchdog still
// writes probes, the mocked plugin send would never resolve and mark the
// mock plugin mutex as wedged. Future reconnects would then be unable to
// register, matching the tauri-plugin-websocket failure mode. The passive
// watchdog should perform no writes of its own during this window.
await page.waitForTimeout(22_000);
await setMockWebsocketSendsStalled(page, false);
const message = `recovered after passive idle ${Date.now()}`;
await page.getByTestId("message-input").fill(message);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
});
test("sidebar reconnect prompt flips on live relay degradation without a query error", async ({
page,
}) => {
await page.goto("/");
// Healthy boot: channels render from the mock bridge and the reconnect
// prompt is absent (no query error, connection healthy).
await expect(page.getByTestId("channel-general")).toBeVisible();
await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
// Drive ONLY the live connection state degraded — no channelsQuery error is
// set. Pre-fix the block keyed off `channelsQuery.error` alone, so it stays
// absent here; post-fix the dual signal surfaces it.
await driveConnectionDegraded(page, "stalled");
// `stalled` is debounced before surfacing, then React needs a render tick.
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
// The cached channel list stays visible alongside the prompt (layout intent:
// surface the affordance, don't yank context).
await expect(page.getByTestId("channel-general")).toBeVisible();
});
test("profile popover does not show relay reconnect controls", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("channel-general")).toBeVisible();
await page.getByTestId("sidebar-profile-avatar-button").click();
await expect(page.getByTestId("profile-popover-settings")).toBeVisible();
await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0);
// The sidebar owns the relay reconnect affordance; the profile popover stays
// focused on profile/settings/community actions even while the relay is down.
await driveConnectionDegraded(page, "stalled");
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0);
});
test("resume event short-circuits accumulated reconnect backoff", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("channel-general")).toBeVisible();
await setMockWebsocketUnavailable(page, true);
await disconnectMockWebsockets(page);
await expect
.poll(() => getMockWebsocketConnectAttempts(page), { timeout: 10_000 })
.toHaveLength(3);
await setMockWebsocketUnavailable(page, false);
const resumedAt = Date.now();
await page.evaluate(() => window.dispatchEvent(new Event("online")));
await expect
.poll(
() =>
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
{
timeout: 2_000,
},
)
.toBe("connected");
expect(Date.now() - resumedAt).toBeLessThan(2_000);
});
test("resume events during repeated AUTH rejection cannot defeat the terminal cap", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Three consecutive rejections must latch terminal even when resume
// events interleave with the handshakes: resume attempts bypass backoff
// but must preserve the AUTH rejection streak (review finding on
// PR #4737 — preconnect()'s streak reset previously made the cap
// unreachable under focus/online bursts).
//
// Queue MORE rejections than the cap: superseded connection attempts
// (resume racing the backoff timer) consume queue entries on handshakes
// whose frames the client drops as stale, exactly like a real relay that
// rejects every attempt. A queue of exactly 3 can be silently eaten and
// a default-success AUTH would then reset the streak.
await queueAuthResponses(
page,
Array.from({ length: 12 }, () => ({
success: false,
message: "auth-required: verification failed",
})),
);
await disconnectMockWebsockets(page);
// Fire resume events while the rejection sequence plays out. Repeated
// dispatch (post-rate-limit spacing is irrelevant here: each poll tick
// dispatches both events) guarantees at least one lands between
// handshakes.
await expect
.poll(
async () => {
await page.evaluate(() => {
window.dispatchEvent(new Event("online"));
window.dispatchEvent(new Event("focus"));
});
return page.evaluate(() =>
window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.(),
);
},
{ intervals: [500], timeout: 20_000 },
)
.toBe("disconnected");
// Terminal is user-owned: further resume events must not revive the
// session.
await page.evaluate(() => {
window.dispatchEvent(new Event("online"));
window.dispatchEvent(new Event("focus"));
});
await page.waitForTimeout(1_000);
expect(
await page.evaluate(() =>
window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.(),
),
).toBe("disconnected");
});
test("sub-2s degraded flap invalidates relay queries on recovery", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("channel-general")).toBeVisible();
await page.evaluate(() => {
const queryClient = window.__BUZZ_E2E_QUERY_CLIENT__ as
| {
invalidateQueries: (...args: unknown[]) => unknown;
__rawHealInvalidations?: number;
}
| undefined;
if (!queryClient)
throw new Error("E2E query client seam is not installed.");
const original = queryClient.invalidateQueries.bind(queryClient);
queryClient.__rawHealInvalidations = 0;
queryClient.invalidateQueries = (...args: unknown[]) => {
queryClient.__rawHealInvalidations =
(queryClient.__rawHealInvalidations ?? 0) + 1;
return original(...args);
};
});
await driveConnectionDegraded(page, "reconnecting");
await page.waitForTimeout(100);
await driveConnectionDegraded(page, "connected");
await expect
.poll(() =>
page.evaluate(
() =>
(
window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as {
__rawHealInvalidations?: number;
}
)?.__rawHealInvalidations ?? 0,
),
)
.toBeGreaterThan(0);
});
test("transient AUTH rejection reconnects and restores live traffic", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await queueAuthResponses(page, [
{ success: false, message: "auth-required: verification failed" },
{ success: true, message: "" },
]);
await disconnectMockWebsockets(page);
await expect
.poll(
() =>
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
{
timeout: 10_000,
},
)
.toBe("connected");
const recovered = `live after transient AUTH rejection ${Date.now()}`;
await emitMockMessages(page, [
{ content: recovered, createdAt: Math.floor(Date.now() / 1_000) },
]);
await expect(page.getByTestId("message-timeline")).toContainText(recovered);
const sent = `send after transient AUTH rejection ${Date.now()}`;
await page.getByTestId("message-input").fill(sent);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(sent);
});
test("auth-required CLOSED restores the active live subscription", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await closeLiveSubscriptions(page, "auth-required: not authenticated");
const recovered = `live after auth-required CLOSED ${Date.now()}`;
await expect
.poll(
async () => {
await emitMockMessages(page, [
{ content: recovered, createdAt: Math.floor(Date.now() / 1_000) },
]);
return page
.getByTestId("message-timeline")
.evaluate(
(element, content) => (element.textContent ?? "").includes(content),
recovered,
);
},
{ intervals: [1_100], timeout: 5_000 },
)
.toBe(true);
});
test("reconnect backfills more missed channel messages than the live subscription limit", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const baseCreatedAt = Math.floor(Date.now() / 1_000) - 300;
const seenBeforeDisconnect = "reconnect e2e seen before disconnect";
await emitMockMessages(page, [
{ content: seenBeforeDisconnect, createdAt: baseCreatedAt },
]);
await expect(page.getByTestId("message-timeline")).toContainText(
seenBeforeDisconnect,
);
await disconnectMockWebsockets(page);
const missedMessages = Array.from({ length: 260 }, (_, index) => ({
content: `reconnect e2e missed ${String(index + 1).padStart(3, "0")}`,
createdAt: baseCreatedAt + index + 1,
}));
await emitMockMessages(page, missedMessages);
// The newest backfilled message renders at the bottom once the reconnect
// catch-up settles.
const timeline = page.getByTestId("message-timeline");
await expect(timeline).toContainText("reconnect e2e missed 260", {
timeout: 15_000,
});
// The virtualized timeline windows the oldest backfilled rows out of the DOM
// while the user sits at the bottom. Walk upward with real wheel input: the
// older-history sentinel arms on a genuine leave→enter transition, and each
// prepend restores the visible anchor away from the top. Reaching "missed
// 001" proves reconnect backfilled past the live-subscription limit.
await timeline.hover();
await expect
.poll(
async () => {
// Compact same-author rows can leave the restored viewport inside the
// sentinel band. Move down first so the next upward gesture creates the
// leave→enter transition that arms another bounded page.
await page.mouse.wheel(0, 1200);
await page.waitForTimeout(40);
await page.mouse.wheel(0, -6000);
return timeline.evaluate((element) =>
(element.textContent ?? "").includes("reconnect e2e missed 001"),
);
},
{ intervals: [100, 150, 250], timeout: 15_000 },
)
.toBe(true);
});