Files
buzz/desktop/tests/e2e/relay-restart.live.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

241 lines
7.9 KiB
TypeScript

import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { expect, test, type Page } from "@playwright/test";
import { installBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { TwoRelayHarness, type RelaySpec } from "./helpers/twoRelayHarness";
const exec = promisify(execFile);
// Live gate: boots a REAL buzz-relay process, points the app at it, SIGTERMs
// the relay mid-session, restarts it on the same port, and asserts the client
// converges back to "connected". This proves the full restart story end to
// end: the relay's graceful-drain 1012 close broadcast (server side) and the
// client's dial-failure retry + 1012 fast-reconnect (desktop side) — the two
// halves that synthetic mock-websocket specs cannot compose.
//
// Requires: BUZZ_E2E_RELAY_RESTART=1, BUZZ_E2E_RELAY_BIN, and
// BUZZ_E2E_DATABASE_URL (plus reachable Redis and media object store, same
// infra as the agents-everywhere live gate).
const enabled = process.env.BUZZ_E2E_RELAY_RESTART === "1";
function required(name: string, value: string | undefined): string {
if (!value) throw new Error(`${name} is required for the live gate`);
return value;
}
async function runCli(args: string[], relayUrl: string, privateKey: string) {
const binary = required("BUZZ_E2E_CLI_BIN", process.env.BUZZ_E2E_CLI_BIN);
const { stdout } = await exec(binary, args, {
cwd: "..",
env: {
...process.env,
BUZZ_AUTH_TAG: "",
BUZZ_PRIVATE_KEY: privateKey,
BUZZ_RELAY_URL: relayUrl,
},
});
return stdout;
}
async function seedLiveChannel(relayUrl: string) {
const name = `reconnect-live-${process.pid}`;
const created = JSON.parse(
await runCli(
[
"channels",
"create",
"--name",
name,
"--type",
"stream",
"--visibility",
"open",
],
relayUrl,
TEST_IDENTITIES.alice.privateKey,
),
) as { channel_id: string };
await runCli(
[
"channels",
"add-member",
"--channel",
created.channel_id,
"--pubkey",
TEST_IDENTITIES.tyler.pubkey,
"--role",
"member",
],
relayUrl,
TEST_IDENTITIES.alice.privateKey,
);
return { id: created.channel_id, name };
}
async function connectionState(page: Page): Promise<string> {
return page.evaluate(() => {
const win = window as Window & {
__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => string;
};
return win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.() ?? "uninstalled";
});
}
async function exerciseBackgroundTraffic(page: Page, durationMs: number) {
await page.evaluate(async (duration) => {
const deadline = Date.now() + duration;
while (Date.now() < deadline) {
void window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
queryKey: ["channels"],
});
await new Promise((resolve) => window.setTimeout(resolve, 100));
}
}, durationMs);
}
async function resetConnectAttempts(page: Page) {
await page.evaluate(() => {
window.__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__?.();
});
}
async function assertConnectAttemptsArePaced(page: Page) {
const attempts = await page.evaluate(
() => window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__?.() ?? [],
);
expect(attempts.length).toBeGreaterThanOrEqual(2);
expect(attempts.length).toBeLessThanOrEqual(4);
for (let index = 1; index < attempts.length; index += 1) {
expect(attempts[index] - attempts[index - 1]).toBeGreaterThanOrEqual(700);
}
}
async function proveLiveDelivery(
page: Page,
relayUrl: string,
channel: { id: string; name: string },
label: string,
) {
await page.getByTestId(`channel-${channel.name}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channel.name);
const message = `${label} ${Date.now()}`;
await runCli(
["messages", "send", "--channel", channel.id, "--content", message],
relayUrl,
TEST_IDENTITIES.alice.privateKey,
);
await expect(page.getByTestId("message-timeline")).toContainText(message, {
timeout: 30_000,
});
}
test.describe("relay restart live gate", () => {
test.skip(!enabled, "set BUZZ_E2E_RELAY_RESTART=1 to run live gate");
test("client reconnects after the relay is SIGTERMed and restarted", async ({
page,
}) => {
test.setTimeout(240_000);
const portBase = 26_000 + (process.pid % 3_000);
const spec: RelaySpec = {
name: "relay-restart",
ports: {
main: portBase,
health: portBase + 3_000,
metrics: portBase + 6_000,
},
databaseUrl: required(
"BUZZ_E2E_DATABASE_URL",
process.env.BUZZ_E2E_DATABASE_URL,
),
redisUrl:
process.env.BUZZ_E2E_REDIS_RESTART ?? "redis://127.0.0.1:6379/13",
};
const harness = await TwoRelayHarness.create([spec]);
try {
await harness.startRelays();
const relayHttpUrl = `http://127.0.0.1:${spec.ports.main}`;
const channel = await test.step("seed live channel and membership", () =>
seedLiveChannel(relayHttpUrl));
await installBridge(page, {
mode: "relay",
user: "tyler",
relayHttpUrl,
relayWsUrl: `ws://127.0.0.1:${spec.ports.main}`,
});
await page.goto("/");
// Baseline: the app converges to a live authenticated session and sees
// the channel created for this fresh database.
await test.step("wait for initial authenticated connection", async () => {
await expect
.poll(() => connectionState(page), { timeout: 60_000 })
.toBe("connected");
await expect(page.getByTestId(`channel-${channel.name}`)).toBeVisible({
timeout: 30_000,
});
});
// Roll the pod. Graceful drain: readiness 503 → 5s grace → 1012 close
// broadcast → process exit. The client must observe the close (not a
// silent stall) and start retrying.
await resetConnectAttempts(page);
await harness.terminateRelayGracefully(spec.name);
await expect
.poll(() => connectionState(page), { timeout: 30_000 })
.not.toBe("connected");
// Keep the real relay unavailable across several reconnect windows while
// ordinary app traffic continues. This is the production-shaped race:
// background queries must not bypass the session coordinator's backoff.
await exerciseBackgroundTraffic(page, 8_000);
await expect.poll(() => connectionState(page)).not.toBe("connected");
await assertConnectAttemptsArePaced(page);
// Bring the "new pod" up on the same address, exactly like a k8s
// restart behind a stable service endpoint.
await harness.restartRelay(spec.name);
// The client's retry loop must find the fresh relay and converge back
// to connected without any user interaction, then prove that AUTH and
// live-subscription replay finished by receiving an event published by
// a second identity through the real CLI/relay boundary.
await expect
.poll(() => connectionState(page), { timeout: 60_000 })
.toBe("connected");
await proveLiveDelivery(
page,
relayHttpUrl,
channel,
"first automatic recovery",
);
// Flap the fresh pod once more. A second recovery catches stale timer,
// waiter, generation, and subscription state that a single cycle cannot.
await harness.terminateRelayGracefully(spec.name);
await expect
.poll(() => connectionState(page), { timeout: 30_000 })
.not.toBe("connected");
await exerciseBackgroundTraffic(page, 4_000);
await harness.restartRelay(spec.name);
await expect
.poll(() => connectionState(page), { timeout: 60_000 })
.toBe("connected");
await proveLiveDelivery(
page,
relayHttpUrl,
channel,
"second automatic recovery",
);
} catch (error) {
console.error(await harness.logs());
throw error;
} finally {
await harness.stop();
}
});
});