mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
🤖 ## Summary - Keep the relay reconnect notification dismissed across repeated connection retries during one continuous outage. - Re-arm the notification after recovery or relay lifecycle replacement, including switches between communities that use the same relay URL. - Preserve the existing dedicated path for authentication and other application-level errors. ### How an outage is tracked The AppShell-owned relay-card hook treats an outage as one contiguous runtime episode rather than assigning it a persisted ID. A hook-local `outageActiveRef` is armed by the first qualifying unreachable/degraded observation. While it is armed, intermediate retry states (`connecting`, `reconnecting`, `stalled`, and `disconnected`) belong to that same episode, so retry churn cannot clear dismissal or emit another notification. The hook receives the same lifecycle identity used by community initialization: community ID plus `reinitKey`. This distinguishes multiple communities even when they share a relay URL, and it also changes when the active community is explicitly reinitialized. The latch and dismissal are reset when that identity changes or when the relay singleton reports its authoritative `idle` teardown state. A successful `connected` state also closes the episode and re-arms the next outage. These boundaries deliberately bias toward re-notifying rather than suppressing a later outage: recovery, community switch/reinit, or relay teardown cannot leave the hook stuck believing an old outage is still active. No outage state is persisted beyond the mounted hook lifecycle. ### Related issue None found. ### Testing - `pnpm --dir desktop typecheck` - `pnpm --dir desktop test` — 3,769 passed - `pnpm --dir desktop check` - `pnpm --dir desktop build:e2e` - `pnpm --dir desktop exec playwright test tests/e2e/sidebar-relay-card.spec.ts --project=integration` — 11 passed --------- Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
import { expect, type Page, test } from "@playwright/test";
|
|
|
|
import { installMockBridge } from "../helpers/bridge";
|
|
|
|
const CONNECT_ERROR = "relay unreachable: could not connect to relay";
|
|
const PROXY_ERROR =
|
|
"relay unreachable: relay returned an unexpected HTML page (network sign-in?)";
|
|
const ACCESS_ERROR = "relay unreachable: 403 Forbidden";
|
|
const RELAY_AUTH_ERROR = "Relay authentication rejected.";
|
|
|
|
type RelayConnectionState =
|
|
| "connected"
|
|
| "connecting"
|
|
| "disconnected"
|
|
| "idle"
|
|
| "reconnecting"
|
|
| "stalled";
|
|
|
|
async function setChannelsReadError(page: Page, error: string | null) {
|
|
await page.evaluate((nextError) => {
|
|
const testWindow = window as Window & {
|
|
__BUZZ_E2E__?: { mock?: { channelsReadError?: string } };
|
|
};
|
|
|
|
if (!testWindow.__BUZZ_E2E__?.mock) {
|
|
throw new Error("Mock bridge config is not installed.");
|
|
}
|
|
|
|
if (nextError === null) {
|
|
delete testWindow.__BUZZ_E2E__.mock.channelsReadError;
|
|
return;
|
|
}
|
|
|
|
testWindow.__BUZZ_E2E__.mock.channelsReadError = nextError;
|
|
}, error);
|
|
}
|
|
|
|
async function setRelayConnectionState(
|
|
page: Page,
|
|
state: RelayConnectionState,
|
|
) {
|
|
if (state !== "connected") {
|
|
// When driving to a degraded state, wait for the relay to reach "connected"
|
|
// first. Without this guard, the mock relay's async auth handshake (started
|
|
// on page load) can race the override and set state back to "connected"
|
|
// after we've driven it to a degraded value.
|
|
await page.waitForFunction(() => {
|
|
const win = window as Window & {
|
|
__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => string;
|
|
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
|
|
};
|
|
return (
|
|
typeof win.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function" &&
|
|
typeof win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__ === "function" &&
|
|
win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__() === "connected"
|
|
);
|
|
});
|
|
} else {
|
|
// When driving to "connected", just wait for the seam to be installed —
|
|
// no need to gate on the current state since we're overriding it directly.
|
|
await page.waitForFunction(
|
|
() =>
|
|
typeof (
|
|
window as Window & {
|
|
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
|
|
}
|
|
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function",
|
|
);
|
|
}
|
|
await page.evaluate((nextState) => {
|
|
const testWindow = window as Window & {
|
|
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (
|
|
state: RelayConnectionState,
|
|
) => void;
|
|
};
|
|
|
|
const setConnectionState =
|
|
testWindow.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__;
|
|
if (!setConnectionState) {
|
|
throw new Error("Mock relay connection state helper is not installed.");
|
|
}
|
|
|
|
setConnectionState(nextState);
|
|
}, state);
|
|
}
|
|
|
|
async function emitRelayConnectionState(
|
|
page: Page,
|
|
state: RelayConnectionState,
|
|
) {
|
|
await page.evaluate((nextState) => {
|
|
const setConnectionState = (
|
|
window as Window & {
|
|
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (
|
|
state: RelayConnectionState,
|
|
) => void;
|
|
}
|
|
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__;
|
|
if (!setConnectionState) {
|
|
throw new Error("Mock relay connection state helper is not installed.");
|
|
}
|
|
setConnectionState(nextState);
|
|
}, state);
|
|
}
|
|
|
|
async function expectGenericReconnectCard(page: Page) {
|
|
const card = page.getByTestId("sidebar-relay-unreachable");
|
|
await expect(card).toBeVisible();
|
|
await expect(card).toContainText("Can't reach the relay");
|
|
await expect(card).toContainText("Click to connect");
|
|
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
|
|
return card;
|
|
}
|
|
|
|
test("sidebar generic relay failures use the reconnect card", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
|
|
|
|
await page.goto("/");
|
|
|
|
// The card is gated on !isRelayConnectionConnected — a stale channelsReadError
|
|
// alone (with state still "connected") no longer pins the card. Drive the relay
|
|
// into a disconnected state so the card appears immediately (disconnected is
|
|
// reported without debounce, unlike the 2-second delay for reconnecting/stalled).
|
|
await setRelayConnectionState(page, "disconnected");
|
|
|
|
await expectGenericReconnectCard(page);
|
|
});
|
|
|
|
test("relay outage notification stays dismissed through retries and re-arms after recovery", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
|
|
await page.goto("/");
|
|
await setRelayConnectionState(page, "disconnected");
|
|
|
|
const card = await expectGenericReconnectCard(page);
|
|
await card
|
|
.getByRole("button", { name: "Dismiss relay notification" })
|
|
.click({ force: true });
|
|
await expect(card).toBeHidden();
|
|
|
|
// Retry churn is still the same outage: no successful connection occurred.
|
|
await emitRelayConnectionState(page, "connecting");
|
|
await emitRelayConnectionState(page, "disconnected");
|
|
await emitRelayConnectionState(page, "reconnecting");
|
|
await page.waitForTimeout(2_100);
|
|
await expect(card).toBeHidden();
|
|
|
|
// A successful connection ends the episode and re-arms the next outage.
|
|
await setChannelsReadError(page, null);
|
|
await emitRelayConnectionState(page, "connected");
|
|
await setChannelsReadError(page, CONNECT_ERROR);
|
|
await emitRelayConnectionState(page, "disconnected");
|
|
await expectGenericReconnectCard(page);
|
|
});
|
|
|
|
test("relay outage notification re-arms after same-URL lifecycle teardown", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
|
|
await page.goto("/");
|
|
await setRelayConnectionState(page, "disconnected");
|
|
|
|
const card = await expectGenericReconnectCard(page);
|
|
await card
|
|
.getByRole("button", { name: "Dismiss relay notification" })
|
|
.click({ force: true });
|
|
await expect(card).toBeHidden();
|
|
|
|
// Community switches and reconnectCommunity() tear down the singleton to
|
|
// idle before applying the next lifecycle. The next lifecycle may reuse the
|
|
// same relay URL, so URL identity alone must not preserve the old dismissal.
|
|
await emitRelayConnectionState(page, "idle");
|
|
await emitRelayConnectionState(page, "disconnected");
|
|
await expectGenericReconnectCard(page);
|
|
});
|
|
|
|
test("sidebar proxy sign-in failures use the reconnect card", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { channelsReadError: PROXY_ERROR });
|
|
|
|
await page.goto("/");
|
|
|
|
// Drive disconnected so the card appears immediately (connected is now authoritative).
|
|
await setRelayConnectionState(page, "disconnected");
|
|
|
|
await expectGenericReconnectCard(page);
|
|
});
|
|
|
|
test("sidebar access failures use the reconnect card", async ({ page }) => {
|
|
await installMockBridge(page, { channelsReadError: ACCESS_ERROR });
|
|
|
|
await page.goto("/");
|
|
|
|
// Drive disconnected so the card appears immediately (connected is now authoritative).
|
|
await setRelayConnectionState(page, "disconnected");
|
|
|
|
await expectGenericReconnectCard(page);
|
|
});
|
|
|
|
test("collapsed sidebar still shows the reconnect card", async ({ page }) => {
|
|
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
|
|
|
|
await page.goto("/");
|
|
|
|
// Drive degraded state so the card appears.
|
|
await setRelayConnectionState(page, "disconnected");
|
|
|
|
await expectGenericReconnectCard(page);
|
|
|
|
await page
|
|
.getByRole("button", { exact: true, name: "Toggle Sidebar" })
|
|
.click();
|
|
await expect(
|
|
page.locator('[data-state="collapsed"][data-collapsible="offcanvas"]'),
|
|
).toHaveCount(1);
|
|
|
|
// The card remains visible via the fixed overlay even with sidebar collapsed.
|
|
const card = page.getByTestId("sidebar-relay-unreachable");
|
|
await expect(card).toBeVisible();
|
|
await expect(card).toContainText("Can't reach the relay");
|
|
|
|
await setChannelsReadError(page, null);
|
|
await page.getByTestId("sidebar-reconnect").click();
|
|
// Drive connected so the card shows success and auto-dismisses.
|
|
await setRelayConnectionState(page, "connected");
|
|
await expect(card).toContainText("Connected");
|
|
await expect(card).toBeHidden({ timeout: 10_000 });
|
|
});
|
|
|
|
test("sidebar stalled relay state uses the reconnect card", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
|
await setRelayConnectionState(page, "stalled");
|
|
|
|
await expectGenericReconnectCard(page);
|
|
});
|
|
|
|
test("sidebar application auth disconnects stay on the error path", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { channelsReadError: RELAY_AUTH_ERROR });
|
|
|
|
await page.goto("/");
|
|
await setRelayConnectionState(page, "disconnected");
|
|
|
|
await expect(page.getByText(RELAY_AUTH_ERROR)).toBeVisible();
|
|
await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
|
|
});
|
|
|
|
test("sidebar reconnect action shows connected before hiding", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
|
|
|
|
await page.goto("/");
|
|
|
|
// Drive disconnected state so the card appears immediately.
|
|
await setRelayConnectionState(page, "disconnected");
|
|
|
|
const card = await expectGenericReconnectCard(page);
|
|
|
|
await setChannelsReadError(page, null);
|
|
await page.getByTestId("sidebar-reconnect").click();
|
|
// Drive connected to simulate the relay recovering after the reconnect action.
|
|
await setRelayConnectionState(page, "connected");
|
|
|
|
await expect(card).toContainText("Connected");
|
|
await expect(card).not.toContainText("Click to connect");
|
|
|
|
await page.waitForTimeout(3_000);
|
|
await expect(card).toContainText("Connected");
|
|
await expect(card).toBeHidden({ timeout: 5_000 });
|
|
});
|
|
|
|
test("sidebar reconnect action suppresses stale refresh errors after success", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
|
|
|
|
await page.goto("/");
|
|
|
|
// Drive disconnected state so the card appears immediately.
|
|
await setRelayConnectionState(page, "disconnected");
|
|
|
|
const card = await expectGenericReconnectCard(page);
|
|
|
|
await page.getByTestId("sidebar-reconnect").click();
|
|
// Drive connected to simulate the relay recovering after the reconnect action.
|
|
await setRelayConnectionState(page, "connected");
|
|
|
|
await page.waitForTimeout(500);
|
|
await expect(card).toBeVisible();
|
|
await expect(card).toContainText("Connected");
|
|
await expect(card).not.toContainText("Can't reach the relay");
|
|
|
|
await page.waitForTimeout(6_500);
|
|
await expect(card).toBeHidden();
|
|
});
|
|
|
|
test("sidebar connected success clears when relay degrades again", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
|
|
|
|
await page.goto("/");
|
|
|
|
// Drive disconnected state so the card appears immediately.
|
|
await setRelayConnectionState(page, "disconnected");
|
|
|
|
const card = await expectGenericReconnectCard(page);
|
|
|
|
await setChannelsReadError(page, null);
|
|
await page.getByTestId("sidebar-reconnect").click();
|
|
// Drive connected to simulate the relay recovering after the reconnect action.
|
|
await setRelayConnectionState(page, "connected");
|
|
|
|
await expect(card).toContainText("Connected");
|
|
|
|
await setRelayConnectionState(page, "stalled");
|
|
|
|
await expect(card).toContainText("Can't reach the relay", {
|
|
timeout: 10_000,
|
|
});
|
|
await expect(card).toContainText("Click to connect");
|
|
await expect(card).not.toContainText("Connected");
|
|
});
|