mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add configurable transport reconnect hook (#1059)
Adds a build-time-configured transport reconnect hook so internal builds can recover the underlying transport (e.g. WARP VPN) before the relay reconnect, while OSS builds stay a pure no-op. The generic "Reconnect to relay" button is transport-agnostic; internal users behind a VPN need the transport re-established first. A build-time env var `BUZZ_BUILD_RELAY_RECONNECT_CMD` (set only in `squareup/buzz-releases`) carries a JSON config validated into a typed Rust struct at compile time, so the OSS binary ships with zero VPN knowledge. - New Tauri command `relay_reconnect_hook` runs structured fixed-argv steps plus a readiness probe, wrapped in `tokio::task::spawn_blocking`; non-fatal end-to-end so any failure falls through to `preconnect()`. - `ReconnectHookConfig` lives in one dep-free source file `include!`'d by both `build.rs` and the runtime command, so the compile-time validation and runtime parse cannot drift. - `useReconnectRelay.ts` invokes the hook before relay preconnect, guarded so a hook rejection cannot abort the reconnect. - When `BUZZ_BUILD_RELAY_RECONNECT_CMD` is unset (OSS), the command compiles to an early-return no-op. Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
2300248d3b
commit
ba776b9959
@@ -38,6 +38,7 @@ export default defineConfig({
|
||||
"**/spoiler.spec.ts",
|
||||
"**/mentions.spec.ts",
|
||||
"**/relay-reconnect.spec.ts",
|
||||
"**/relay-reconnect-screenshots.spec.ts",
|
||||
"**/workflows.spec.ts",
|
||||
"**/identity-archive.spec.ts",
|
||||
"**/identity-archive-hide.spec.ts",
|
||||
|
||||
@@ -21,6 +21,8 @@ default = []
|
||||
mesh-llm = ["dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime"]
|
||||
|
||||
[build-dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Shared schema, included from the same source the runtime command parses with,
|
||||
// so the build-time validation below and the runtime parse cannot drift.
|
||||
include!("src/commands/reconnect_hook_config.rs");
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_RELAY_URL");
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_RELAY_HTTP");
|
||||
@@ -5,6 +9,7 @@ fn main() {
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_ENDPOINT");
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_DATABRICKS_HOST");
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_DATABRICKS_MODEL");
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD");
|
||||
println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)");
|
||||
|
||||
if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") {
|
||||
@@ -23,6 +28,15 @@ fn main() {
|
||||
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_DATABRICKS_MODEL={model}");
|
||||
}
|
||||
|
||||
if let Ok(val) = std::env::var("BUZZ_BUILD_RELAY_RECONNECT_CMD") {
|
||||
let parsed: serde_json::Value = serde_json::from_str(&val)
|
||||
.unwrap_or_else(|e| panic!("BUZZ_BUILD_RELAY_RECONNECT_CMD is not valid JSON: {e}"));
|
||||
serde_json::from_value::<ReconnectHookConfig>(parsed).unwrap_or_else(|e| {
|
||||
panic!("BUZZ_BUILD_RELAY_RECONNECT_CMD doesn't match ReconnectHookConfig: {e}")
|
||||
});
|
||||
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD={val}");
|
||||
}
|
||||
|
||||
let updater_public_key = std::env::var("BUZZ_UPDATER_PUBLIC_KEY")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
|
||||
@@ -22,6 +22,7 @@ mod personas;
|
||||
mod prevent_sleep;
|
||||
mod profile;
|
||||
mod relay_members;
|
||||
mod relay_reconnect;
|
||||
mod social;
|
||||
mod teams;
|
||||
mod workflows;
|
||||
@@ -49,6 +50,7 @@ pub use personas::*;
|
||||
pub use prevent_sleep::*;
|
||||
pub use profile::*;
|
||||
pub use relay_members::*;
|
||||
pub use relay_reconnect::*;
|
||||
pub use social::*;
|
||||
pub use teams::*;
|
||||
pub use workflows::*;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Canonical `ReconnectHookConfig` definition, `include!`d into BOTH
|
||||
// `build.rs` (compile-time validation) and `commands/relay_reconnect.rs`
|
||||
// (runtime deserialization). build scripts cannot import from the crate, so
|
||||
// sharing the source via `include!` is what guarantees the build-time check
|
||||
// and the runtime parse use an identical schema — zero drift surface.
|
||||
//
|
||||
// Keep this file dependency-free: only `serde` derives, no crate-internal
|
||||
// imports. Both consumers have `serde` available.
|
||||
|
||||
/// Typed config carried by the build-time env var `BUZZ_BUILD_RELAY_RECONNECT_CMD`.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(dead_code)] // build.rs only constructs it for validation
|
||||
pub struct ReconnectHookConfig {
|
||||
/// Ordered commands to run. Each inner vec is [program, arg1, arg2, ...].
|
||||
pub steps: Vec<Vec<String>>,
|
||||
/// Command whose stdout is polled for readiness after steps complete.
|
||||
pub ready_probe: Vec<String>,
|
||||
/// RAW SUBSTRING matched in the probe's stdout to consider the transport
|
||||
/// ready — NOT a parsed field. Pick a token unlikely to collide with other
|
||||
/// substrings in the probe output (e.g. `warp-cli -j status` JSON, where
|
||||
/// "Connected" can collide with "Connecting"/"Disconnected").
|
||||
pub ready_match: String,
|
||||
/// Per-process wall-clock cap (ms) for each step and the readiness probe
|
||||
/// phase; non-fatal on expiry — the generic relay reconnect still fires.
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Configurable transport-reconnect hook.
|
||||
//!
|
||||
//! When the build-time env var `BUZZ_BUILD_RELAY_RECONNECT_CMD` is set (internal
|
||||
//! builds), this command runs an ordered sequence of subprocess steps followed by
|
||||
//! a readiness poll before the frontend fires the relay WebSocket reconnect.
|
||||
//!
|
||||
//! OSS builds (env var unset) get a pure no-op — zero WARP knowledge compiled in.
|
||||
|
||||
// Single source of truth for the config schema, shared with build.rs via
|
||||
// `include!`. See reconnect_hook_config.rs for why this is shared, not a module.
|
||||
include!("reconnect_hook_config.rs");
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn relay_reconnect_hook() -> Result<(), String> {
|
||||
let Some(config_str) = option_env!("BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD") else {
|
||||
return Ok(()); // OSS build — no-op
|
||||
};
|
||||
|
||||
// Safe: build.rs already validated this parses correctly against the same schema.
|
||||
let config: ReconnectHookConfig = serde_json::from_str(config_str)
|
||||
.map_err(|e| format!("reconnect hook config parse error: {e}"))?;
|
||||
|
||||
// spawn_blocking because the desktop Tauri crate doesn't enable tokio's
|
||||
// `process` feature; std::process::Command + thread::sleep are synchronous
|
||||
// and must not run on an async worker. The whole hook is non-fatal — a join
|
||||
// failure logs and returns Ok so the frontend's relay reconnect still fires.
|
||||
if let Err(e) = tokio::task::spawn_blocking(move || run_hook(&config)).await {
|
||||
eprintln!("[relay_reconnect_hook] task join failed: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a fixed-argv command (`argv[0]` + `argv[1..]`) with a wall-clock cap.
|
||||
///
|
||||
/// `std::process::Command::output()` blocks until the child exits — a wedged
|
||||
/// `warp-cli` (the exact degraded-transport case this hook targets) would hang
|
||||
/// forever, pinning the blocking-pool thread and leaving the frontend `invoke`
|
||||
/// unresolved. So we spawn, poll `try_wait()` every 500ms, and kill+reap on the
|
||||
/// deadline. Modeled on `media_transcode.rs` `run_ffmpeg_with_timeout`.
|
||||
///
|
||||
/// stdout/stderr are piped and read only after the child exits. The pipe-buffer
|
||||
/// deadlock noted there (a child blocking on write() when the ~64 KiB OS pipe
|
||||
/// fills) does not apply: `warp-cli` emits a few lines, far below the buffer.
|
||||
fn run_with_timeout(
|
||||
argv: &[String],
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<std::process::Output, String> {
|
||||
let mut child = std::process::Command::new(&argv[0])
|
||||
.args(&argv[1..])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("spawn failed: {e}"))?;
|
||||
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let stdout = child.stdout.take().map_or_else(Vec::new, |mut s| {
|
||||
let mut buf = Vec::new();
|
||||
let _ = std::io::Read::read_to_end(&mut s, &mut buf);
|
||||
buf
|
||||
});
|
||||
return Ok(std::process::Output {
|
||||
status,
|
||||
stdout,
|
||||
stderr: Vec::new(),
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
if std::time::Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait(); // reap zombie
|
||||
return Err(format!("timed out after {}ms", timeout.as_millis()));
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
Err(e) => return Err(format!("wait failed: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the configured steps then polls the readiness probe. Every failure is
|
||||
/// logged and swallowed — the caller treats the hook as best-effort.
|
||||
fn run_hook(config: &ReconnectHookConfig) {
|
||||
let cap = std::time::Duration::from_millis(config.timeout_ms);
|
||||
|
||||
// Run each step sequentially (fixed-argv, no shell). Each step is capped at
|
||||
// `timeout_ms` so a hung child can't stall the whole hook — on cap it's
|
||||
// killed, logged, and we move on, same non-fatal contract as a spawn error.
|
||||
for step in &config.steps {
|
||||
if step.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match run_with_timeout(step, cap) {
|
||||
Ok(o) if !o.status.success() => {
|
||||
eprintln!("[relay_reconnect_hook] step {:?} exited {}", step, o.status);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[relay_reconnect_hook] step {:?} failed: {e}", step);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Poll readiness probe until match or timeout.
|
||||
if config.ready_probe.is_empty() {
|
||||
return;
|
||||
}
|
||||
let deadline = std::time::Instant::now() + cap;
|
||||
while std::time::Instant::now() < deadline {
|
||||
// Cap each probe at the time left to the deadline, so one wedged probe
|
||||
// can't push the total probe phase past `timeout_ms`.
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
match run_with_timeout(&config.ready_probe, remaining) {
|
||||
Ok(output) if String::from_utf8_lossy(&output.stdout).contains(&config.ready_match) => {
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[relay_reconnect_hook] probe failed: {e}");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
@@ -838,6 +838,7 @@ pub fn run() {
|
||||
get_active_workspace,
|
||||
set_prevent_sleep_active,
|
||||
get_agent_memory,
|
||||
relay_reconnect_hook,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application");
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
MessageCirclePlus,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
isRelayConnectionDegraded,
|
||||
useRelayConnection,
|
||||
} from "@/shared/api/useRelayConnection";
|
||||
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
|
||||
import {
|
||||
isRelayUnreachableError,
|
||||
@@ -282,6 +286,18 @@ export function AppSidebar({
|
||||
|
||||
const { isPending: isReconnectPending, reconnect } = useReconnectRelay();
|
||||
|
||||
// The sidebar reconnect prompt must surface the moment the relay drops, not
|
||||
// only after `channelsQuery` finally errors (it has a 60s staleTime +
|
||||
// refetchInterval, so the error lags 60-120s behind a dropped socket).
|
||||
// OR-in the live, debounced connection state — same signal that drives
|
||||
// ConnectionBanner — so the prompt flips within ~2s of degradation.
|
||||
const relayConnectionState = useRelayConnection();
|
||||
const hasRelayUnreachableError = errorMessage
|
||||
? isRelayUnreachableError(errorMessage)
|
||||
: false;
|
||||
const isRelayConnectionDegradedNow =
|
||||
hasRelayUnreachableError || isRelayConnectionDegraded(relayConnectionState);
|
||||
|
||||
const [createSectionState, setCreateSectionState] = React.useState<{
|
||||
open: boolean;
|
||||
pendingChannelId: string | null;
|
||||
@@ -722,30 +738,28 @@ export function AppSidebar({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
isRelayUnreachableError(errorMessage) ? (
|
||||
<div
|
||||
className="px-3 py-2 text-sm"
|
||||
data-testid="sidebar-relay-unreachable"
|
||||
{isRelayConnectionDegradedNow ? (
|
||||
<div
|
||||
className="px-3 py-2 text-sm"
|
||||
data-testid="sidebar-relay-unreachable"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{RELAY_UNREACHABLE_SHORT}{" "}
|
||||
</span>
|
||||
<button
|
||||
className="text-primary hover:underline disabled:opacity-50"
|
||||
data-testid="sidebar-reconnect"
|
||||
disabled={isReconnectPending}
|
||||
onClick={() => void reconnect()}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{RELAY_UNREACHABLE_SHORT}{" "}
|
||||
</span>
|
||||
<button
|
||||
className="text-primary hover:underline disabled:opacity-50"
|
||||
data-testid="sidebar-reconnect"
|
||||
disabled={isReconnectPending}
|
||||
onClick={() => void reconnect()}
|
||||
type="button"
|
||||
>
|
||||
{isReconnectPending ? "Reconnecting…" : "Reconnect"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)
|
||||
{isReconnectPending ? "Reconnecting…" : "Reconnect"}
|
||||
</button>
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarContent>
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import type { Workspace } from "@/features/workspaces/types";
|
||||
import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher";
|
||||
import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types";
|
||||
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
|
||||
import {
|
||||
isRelayConnectionDegraded,
|
||||
useRelayConnection,
|
||||
} from "@/shared/api/useRelayConnection";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type SidebarProfileCardProps = {
|
||||
@@ -54,6 +58,12 @@ export function SidebarProfileCard({
|
||||
// workspace-provider and QueryClient safe at this level.
|
||||
const selfProfileCache = useSelfProfileCache();
|
||||
const { isPending, reconnect } = useReconnectRelay();
|
||||
// Only offer reconnect when the relay is actually degraded — keep the item
|
||||
// visible while a reconnect is in flight so it does not vanish mid-click if
|
||||
// the live state briefly flips.
|
||||
const isRelayConnectionDegradedNow = isRelayConnectionDegraded(
|
||||
useRelayConnection(),
|
||||
);
|
||||
|
||||
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
|
||||
const profileCardRef = React.useRef<HTMLDivElement | null>(null);
|
||||
@@ -134,7 +144,11 @@ export function SidebarProfileCard({
|
||||
isStatusPending={isPresencePending}
|
||||
onClearUserStatus={onClearUserStatus}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onReconnect={() => void reconnect()}
|
||||
onReconnect={
|
||||
isRelayConnectionDegradedNow || isPending
|
||||
? () => void reconnect()
|
||||
: undefined
|
||||
}
|
||||
onSetStatus={onSetPresenceStatus ?? (() => {})}
|
||||
onSetUserStatus={onSetUserStatus}
|
||||
triggerContainerRef={profileCardRef}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
@@ -31,6 +32,14 @@ export function useReconnectRelay(): {
|
||||
inFlightRef.current = true;
|
||||
setIsPending(true);
|
||||
try {
|
||||
// Run transport-layer reconnect hook (e.g. WARP VPN re-auth for internal builds).
|
||||
// No-op in OSS builds. Non-fatal — transport failure shouldn't block relay reconnect.
|
||||
try {
|
||||
await invoke("relay_reconnect_hook");
|
||||
} catch (err) {
|
||||
console.warn("[useReconnectRelay] reconnect hook failed:", err);
|
||||
}
|
||||
|
||||
await relayClient.preconnect();
|
||||
await queryClient.invalidateQueries();
|
||||
// No success toast — the banner auto-hides once the connection state
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/relay-reconnect-screenshots";
|
||||
|
||||
async function settle(page: import("@playwright/test").Page) {
|
||||
// Tolerate cancelled animations (skeleton → live swap rejects `.finished`
|
||||
// with AbortError) AND indefinitely-running ones (the degraded-state pulse
|
||||
// never resolves `.finished`): allSettled handles rejection, the timeout
|
||||
// race handles infinite animations so this can never hang the test.
|
||||
await page.evaluate(() =>
|
||||
Promise.race([
|
||||
Promise.allSettled(document.getAnimations().map((a) => a.finished)),
|
||||
new Promise((resolve) => setTimeout(resolve, 1_000)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/** Drive the relay client into a state via the real E2E connection-state seam. */
|
||||
async function driveConnectionState(
|
||||
page: import("@playwright/test").Page,
|
||||
state: "connected" | "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 not installed.");
|
||||
setter(s);
|
||||
}, state);
|
||||
}
|
||||
|
||||
async function scrollSidebarToBottom(page: import("@playwright/test").Page) {
|
||||
// The relay block anchors at the bottom of the sidebar's scroll region and is
|
||||
// painted UNDER the absolute, z-30 profile footer (it sits within the footer's
|
||||
// 68px band when unscrolled — toBeVisible passes since it's in-DOM, but a human
|
||||
// can't see it). The scroll region is specifically [data-sidebar="content"];
|
||||
// scrolling it fully down lifts the block ~96px clear of the footer. Targeting
|
||||
// any old overflowing descendant matches the wrong element (e.g. a menu button),
|
||||
// so the selector must be exact.
|
||||
await page
|
||||
.getByTestId("app-sidebar")
|
||||
.locator('[data-sidebar="content"]')
|
||||
.evaluate((scroller) => {
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
async function openProfilePopover(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-profile-avatar-button").click();
|
||||
// Anchor on a stable popover child so the screenshot captures the open menu.
|
||||
await expect(page.getByTestId("profile-popover-settings")).toBeVisible();
|
||||
// Await the Radix open animation on the [data-state] ancestor — the popper
|
||||
// wrapper carries no animations, so screenshots would otherwise capture a
|
||||
// half-faded popover.
|
||||
await page.getByTestId("profile-popover-settings").evaluate((el) =>
|
||||
Promise.all(
|
||||
el
|
||||
.closest("[data-state]")
|
||||
?.getAnimations()
|
||||
.map((a) => a.finished) ?? [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("relay reconnect affordance screenshots", () => {
|
||||
test("01 — profile popover reconnect item hidden when healthy", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
await openProfilePopover(page);
|
||||
await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0);
|
||||
await settle(page);
|
||||
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/01-profile-popover-healthy.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("02 — profile popover reconnect item shown when degraded", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
await driveConnectionState(page, "disconnected");
|
||||
await openProfilePopover(page);
|
||||
await expect(page.getByTestId("profile-popover-reconnect")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/02-profile-popover-degraded.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("03 — sidebar has no reconnect prompt when healthy", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
|
||||
// The relay block renders at the BOTTOM of the scrollable sidebar content,
|
||||
// below the fold at this viewport. Scroll to the bottom so 03 frames the
|
||||
// same region where 04 will show the block — making the absence legible.
|
||||
await scrollSidebarToBottom(page);
|
||||
await settle(page);
|
||||
|
||||
// Frame the left sidebar directly — the relay-unreachable block lives there,
|
||||
// and a full-window shot makes its presence/absence illegible against the
|
||||
// unrelated top connection banner.
|
||||
await page.getByTestId("app-sidebar").screenshot({
|
||||
path: `${SHOTS}/03-sidebar-healthy.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("04 — sidebar reconnect prompt shown when degraded, channels visible", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
await driveConnectionState(page, "disconnected");
|
||||
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
|
||||
// The cached channel list stays visible alongside the prompt.
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
// Scroll the block clear of the occluding footer (symmetric with 03).
|
||||
await scrollSidebarToBottom(page);
|
||||
await settle(page);
|
||||
|
||||
await page.getByTestId("app-sidebar").screenshot({
|
||||
path: `${SHOTS}/04-sidebar-degraded.png`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,23 @@ async function setMockWebsocketSendsStalled(
|
||||
}, stall);
|
||||
}
|
||||
|
||||
async function driveConnectionDegraded(
|
||||
page: import("@playwright/test").Page,
|
||||
state: "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);
|
||||
});
|
||||
@@ -48,3 +65,53 @@ test("passive relay watchdog does not write while the websocket is half-open", a
|
||||
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, "disconnected");
|
||||
|
||||
// `disconnected` reports immediately (reconnecting/stalled debounce ~2s),
|
||||
// but allow margin for the React render to flush.
|
||||
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
|
||||
timeout: 5_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 reconnect item is hidden when healthy and shown when degraded", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Healthy boot: open the profile popover and wait for it to mount. The
|
||||
// reconnect item must be ABSENT because the relay is connected. Anchoring on
|
||||
// a stable popover item first ensures the count assertion reflects the gate,
|
||||
// not an un-mounted popover. Pre-fix the item rendered unconditionally, so
|
||||
// this fails before the gate is added.
|
||||
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);
|
||||
|
||||
// Drive the live connection degraded. The gate reads `useRelayConnection()`,
|
||||
// so the item surfaces reactively while the popover stays open.
|
||||
await driveConnectionDegraded(page, "disconnected");
|
||||
await expect(page.getByTestId("profile-popover-reconnect")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user