Files
buzz/desktop/tests/e2e/parity-ancestor-island.spec.ts
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

162 lines
6.8 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { installRelayBridge } from "../helpers/bridge";
import { assertRelaySeeded } from "../helpers/seed";
import { ancestorIsland, seedScenario } from "../helpers/seedRelay";
// =============================================================================
// LIVE-RELAY parity — ancestor-island cursor poisoning (GUI read-model overhaul)
// =============================================================================
//
// The deterministic current-main RED (Dawn's Jul-2 root cause, Wren's Jul-3
// proven repro `239cc161`). The dense-second wall is NOT the disease — main
// already drains a tied second via the PR #1418 keyset fallback. THIS is the
// flaw the server-assembled windowed read model deletes:
//
// 1. Cold channel load paints the newest CHANNEL_HISTORY_LIMIT (60) rows.
// 2. One of those rows is a reply whose root/parent is OUTSIDE that window.
// 3. useLoadMissingAncestors fetches that old root by id and merges it into
// the SAME channel cache — a non-contiguous "island" days older than the
// contiguous window.
// 4. pageOlderMessages anchors `oldestTimestamp = baseline[0].created_at` on
// the island, so every scroll-up pages backward FROM the island and
// permanently skips the real history between it and the true frontier.
// 5. CLI `/query` returns those `gap-*` rows; the GUI never requests them.
//
// Contract: every top-level `gap-*` row the relay holds must be reachable in the
// timeline. RED on main (the pager anchors on the island and skips the gap
// entirely — `gap` rows reached === 0); GREEN on the windowed read model, whose
// relay-owned cursor cannot be moved by an out-of-band ancestor merge.
const RELAY_HTTP = process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000";
// uuid5(NAMESPACE_DNS, "buzz.channel.general") — the seeded `general` channel.
const GENERAL_CHANNEL_ID = "9f28288a-d724-587a-9709-92dc7f967110";
// >60 newest rows so the cold window (CHANNEL_HISTORY_LIMIT=60) does NOT reach
// the gap; the gap sits below the frontier, the old root below the gap.
const GAP_COUNT = 100;
const NEWEST_COUNT = 70;
test.beforeAll(async () => {
test.setTimeout(90_000);
await assertRelaySeeded();
});
test("live relay: an ancestor island does not strand the history frontier", async ({
page,
}, testInfo) => {
testInfo.setTimeout(180_000);
// Per-run isolation: the suite seeds into shared `general`, which accumulates
// rows across runs. A generic `gap \d+` match lets a prior run's rows inflate
// the reachable set and false-green the parity assertion (observed: a
// contaminated relay "passed" in 3.3s while a clean channel is RED with
// seen.size === 0). Tag this run's rows and assert only against them.
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const scenario = ancestorIsland({
channelId: GENERAL_CHANNEL_ID,
gapCount: GAP_COUNT,
newestCount: NEWEST_COUNT,
nonce,
});
const expected = await seedScenario(scenario, { relayHttpUrl: RELAY_HTTP });
const expectedGap = new Set(expected.map((e) => e.content));
expect(expectedGap.size).toBe(GAP_COUNT);
await installRelayBridge(page, "tyler");
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
// Establish the poison precondition on the diseased client: on main,
// useLoadMissingAncestors fetches the old root by id and merges it into the
// channel cache, and "island root" appears in the timeline — that injected
// island is what strands the pager. The overhaul DELETES useLoadMissingAncestors
// (the relay-owned window cursor can't be moved by an out-of-band merge), so
// the island is never injected and this row never appears. Best-effort, not a
// hard gate: on main it settles the poison before we scroll; on the overhaul
// it just times out harmlessly and we proceed to the reachability invariant —
// which is the assertion that actually flips 0/100 (stranded) → 100/100.
await timeline
.getByText(`island root ${nonce}`, { exact: false })
.first()
.waitFor({ state: "visible", timeout: 15_000 })
.catch(() => {
/* cured client: no ancestor fetch, no island — expected on the overhaul */
});
// Union of THIS run's `gap` contents ever rendered. Virtualization only
// mounts a window, so accumulate across scroll passes rather than snapshot
// once. Match on the run nonce so a prior run's rows can't inflate the set.
const gapPattern = new RegExp(`gap ${nonce} \\d+`);
const renderedGapContents = async () =>
timeline.evaluate((element, pattern: string) => {
const re = new RegExp(pattern);
const found: string[] = [];
for (const row of (
element as HTMLDivElement
).querySelectorAll<HTMLElement>("[data-message-id]")) {
const match = row.textContent?.match(re);
if (match) found.push(match[0]);
}
return found;
}, gapPattern.source);
// A real wheel-up gesture per pass: the older-history sentinel arms on a
// genuine leave→enter transition (IntersectionObserver), so a raw scrollTop=0
// write can fail to re-fire. A wheel event is what a real user issues.
const wheelToTop = async () => {
for (let step = 0; step < 12; step += 1) {
const atTop = await timeline.evaluate(
(element) => (element as HTMLDivElement).scrollTop <= 1,
);
if (atTop) break;
await page.mouse.wheel(0, -6000);
await page.waitForTimeout(40);
}
};
const seen = new Set<string>();
const collect = async () => {
for (const content of await renderedGapContents()) seen.add(content);
};
await timeline.hover();
let stallStreak = 0;
for (let attempt = 0; attempt < 200 && seen.size < GAP_COUNT; attempt += 1) {
const before = seen.size;
await wheelToTop();
try {
await expect
.poll(
async () => {
await collect();
return seen.size;
},
{ timeout: 4_000 },
)
.toBeGreaterThan(before);
} catch {
// No growth this pass — count toward a genuine stall.
}
await collect();
if (seen.size > before) {
stallStreak = 0;
} else {
stallStreak += 1;
if (stallStreak > 8) break;
}
}
// Parity: every gap row the relay holds must be reachable. RED on main — the
// pager anchors on the injected island (old root) and pages backward from it,
// skipping the entire gap span (reaches ~0 of GAP_COUNT). GREEN on the
// windowed read model, whose relay-owned cursor an ancestor merge can't move.
expect(seen.size).toBeGreaterThan(GAP_COUNT * 0.9);
});