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
Signed-off-by: cls_宁波本机 <908705107@qq.com>
241 lines
7.9 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|