fix(desktop): bound startup request bursts and recover quota refusals (#7790)

Prepared by Brain on Wes’s behalf.

## Summary

Reduce startup request amplification and let quota-refused background
work recover without changing relay quotas or bypassing the existing
Send gate.

- Coalesce nest regeneration into one worker plus the latest pending
generation. Keep the stale-write fence; bound the archive read so an old
workspace cannot strand newer work.
- Claim pending channel subscriptions before awaiting setup; cancel
removed/unmounted entries and preserve still-owned subscriptions across
reconnect handoff.
- Recheck extended cooldown deadlines before retry dispatch; distinguish
an explicit zero-second hint from a missing hint.
- Recover native finite/unread queries within their original caller
deadline and a three-retry budget, preserving verified unique events,
complete filters and terminal errors.
- Pace renderer initial/live-CLOSED REQs at one per 250ms: visible
channel and explicitly interactive TTS/read-state/observer owners first,
then live-only auxiliaries, then background history. Publication and
finite queries bypass this queue. Reconnect replay now promotes explicit
interactive owners alongside the visible channel with stable
registration-order ties; its existing capped batches and cooldown remain
intact.

**Scope at `867495426`:** 39 source/test files, including 15 production
files (+665/-235) and 24 test/fixture files. The diagnostic recorder,
HTTP instrumentation, trace launcher and raw captures are deliberately
excluded. No profile reset, grouping, quota increase or new global
scheduler.

## Before / after evidence

These are observations from instrumented, human-operated development
Tauri/Vite runs against a production relay, plus explicitly identified
controlled regressions—not a packaged-release startup benchmark.

| Measurement | Before | After | Evidence boundary |
| --- | --- | --- | --- |
| Archive snapshot requests (kind 13535) | 290 | 3 | Recorder-only
baseline → nest-only intermediate candidate; unequal 67.436s / 39.273s
captures and hydration differences |
| Archive responses: HTTP 200 / 429 | 277 / 13 | 3 / 0 | Same
baseline/nest-only captures; not a claim that all quota pressure
disappeared |
| Successful archive responses’ advertised content-length sum |
21,208,505 bytes | 229,695 bytes | Header values, not actual transferred
bytes |
| 154-channel StrictMode replay | 308 REQs | 154 REQs | Controlled real
React hook → real session → mocked Tauri transport regression |
| Add one channel while 154 setups are pending | 309 REQs | 155 REQs |
Same production-boundary regression; ordinary mount remains 154 → 154 |
| Broad renderer request burst / spacing | 110 broad REQs within
10.766ms in pre-drain capture | 250.061ms minimum / 252.131ms median
between broad REQ writes in final longer capture | Different attended
runs; pacing rather than a universal request-rate cap |
| Native unread query outcomes | Baseline: 8 EOSE / 146 quota CLOSED out
of 154 queries | Final longer run: all 156 observed queries reach EOSE;
eight refused queries each recover after two refusals | Different
runs/workloads; query completion, not badge rendering |

### Timing metrics

| Observation | Measured value |
| --- | ---: |
| Baseline’s two accepted chat EVENT socket writes → positive OK |
173.340ms / 85.556ms |
| Nest-only intermediate candidate’s two accepted writes → positive OK |
277.854ms / 78.775ms |
| Final candidate, short run, accepted chat writes → positive OK |
80.636ms / 77.506ms |
| Final candidate, longer run, accepted chat writes → positive OK |
120.712ms / 86.895ms |
| Last initial EOSE among 163 observed broad channel registrations,
longer run | 64.550s after trace start |
| Last EOSE among 156 observed native unread queries, longer run |
73.972s after trace start |
| Native retry waits after 3s then 0s hints, longer run |
3000.7–3002.8ms, then 2001.5–2002.6ms |
| Renderer finite-query retry after a 2s hint, longer run | 2061.365ms |

Both Sends in each final run were accepted, with no recorded negative
OK. Every quota-refused query in the longer run subsequently completed
the same query. Its two Sends happened early (17.878s and 21.655s), not
near the end. These small samples do not support a percentage latency
improvement.

**Tradeoffs and limits**
- 296 background subscriptions imply about 74s of paced registration
before extra cooldowns/high-priority work. The longer capture observed
163 registrations; it did not independently enumerate the expected
membership set or record a queue-empty marker.
- This is not a global 4-REQ/s cap: finite queries, native requests and
reconnect replay are outside the drain and still share relay quota.
Quota refusals recover rather than disappear.
- Socket-write → OK starts after composer submission and any publisher
gate wait. No click-to-visible, launch-to-usable or badge-paint latency
is claimed.
- Native EOSE is not proof of command return, classification, React
state application or visible unread badges. Existing per-channel kinds,
since floors, limits, h-less routing, unread lease/scope guard and
eight-query concurrency remain intact.
- The attended measurements belong to the instrumented candidate, not a
fresh release run of this fixes-only commit. At the initial packaging
checkpoint (`1bf3d060`), eighteen of twenty files were byte-identical;
the other two removed diagnostic gate calls and isolated the nest test’s
temporary paths. The subsequent interactive priority/reconnect and
readiness fixes have controlled hook/session/browser evidence, not
another attended voice/performance run.

### Evidence provenance (captures retained locally, not uploaded)

| Capture | SHA-256 |
| --- | --- |
| Baseline `STARTUP_20260921T194814Z_98811.jsonl` |
`f4f0a89ad9aea91084bd6a7432510488166de4cbc906600d88c26d557f380994` |
| Nest-only `STARTUP_20260921T202610Z_28642.jsonl` |
`ce517b02a3f717c36d96b36bb8616e8fa80b03b12b356bacfd40475925ea3f3e` |
| Final short `STARTUP_20260922T013628Z_13506.jsonl` |
`bfea6f3cf80cd7b7e4e676632a3df2f16271fade5bfa0edec72b7bcbbe7839ff` |
| Final longer `STARTUP_20260922T014637Z_15846.jsonl` |
`f4abece613d108c4c2fe3cb808e633a0486bdc9993ca92323822dc00c3fae6ec` |

Final instrumented build fingerprint:
`b0b4fc27d793c3e643f4e10289b5afdb4620ee2e13a8b33fa0595dd3417fd5ca`. All
observed dropped-record counters in these final captures were zero.
Source manifests and offline correlation analyses are retained locally;
no raw captures, profile data or agent sessions are part of this PR.

## Validation

Current head: `86749542657a60abdfe82eed5cc576d3e162fe5d`, based on
`77729abfb692b25a0f4ec4a69add86af2e32c0dd`.

Review follow-ups:
- `8842dbd8`: explicit interactive TTS admission using the existing
main-window owner, independent of companion/timeline selection.
Real-hook/session/IPC coverage with 296 cold owners proves initial and
CLOSED priority, unchanged filters/cooldown/pacing, background progress
and deduplicated speech.
- `8cb088106`: reconnect preserves interactive priority even with no
visible channel. Stable ordering among foreground ties; real
hook/session reconnect regressions cover cooldown, capped batching, all
297 owners, deduplication and unmount cancellation. Independent reviewer
mutation restored the prior replay implementation, failed both reconnect
rows, then passed the restored candidate.
- `68e76d7c`: read-state and observer reuse interactive priority. Stop
and ModelPicker wait on the existing observer owner inside send
callbacks, after installing result listeners/deadlines. No new owner,
timer or scheduler; inherited CLOSED/fallback/error semantics remain,
rather than a new strict EOSE guarantee. The real Stop panel browser
probe holds observer IPC across the click and checks
observer-before-control order and correlated outcome. ModelPicker
component ordering is source-reviewed only.
- Mock live admission now replays matching stored history with
per-filter time/limit constraints, deduplication, untagged auxiliary
channel fallback and authoritative explicit h-tags. Kind-specific
fixture admission waits and retry-resolving hover actions fix
startup-dependent test setup without weakening event/badge assertions.
- `48e9c0dad` and `867495426`: steady-state scroll-prepend setup
observes live admission, subsequent head dispatch, successful/idle query
completion and initial bottom geometry. Phase-2 genuine-growth and ≤32px
anchor assertions are unchanged. This isolates steady-state prepending:
it **does not fix or certify production early-scroll/head-reset
behavior**.

Current validation:
- Normal push hooks at `867495426`: **6,582/6,582 renderer tests**, zero
failed/skipped/cancelled; lint/static checks, typecheck and differential
file-size gate passed. Native source is unchanged from the earlier
checkpoint; no new native run is claimed for these renderer/test
follow-ups.
- E2E build passed at `48e9c0dad` plus the exact subsequently committed
test-only delta (spec blob `469be2eed868c677ba80b859cc871208000fee60`).
The completed affected-smoke run passed **153/153** across
agent-control, badge, entity links, messaging, profile status and scroll
history; the full scroll file additionally passed twice (**36/36**). No
full CI-smoke pass is claimed from this six-file local run.
- Pinky independently reviewed production and test follow-ups, including
the final scroll spec blob `469be2eed868c677ba80b859cc871208000fee60`,
with no blocking finding. Review is source evidence, not an independent
test run.
- **Not merge-ready:** new-head required CI must complete, exact-range
security review requires authorization, and GitHub currently reports
REVIEW_REQUIRED; fresh exact-head reviewer approval is still needed. No
approval, dismissal or merge performed. No new packaged/native audio,
launch-to-usable or badge-paint observation is claimed.

Initial fixes-only checkpoint validation
(`1bf3d06024d284a63326fdd353baced0842aeb81`):

- Pre-commit formatting hooks passed; worktree clean.
- Renderer package hooks: 6,570 tests passed, zero
failed/skipped/cancelled; lint/static checks, TypeScript and
differential file-size gate passed.
- Native package hook passed: default and mesh-enabled all-target
Clippy, then the complete native workspace test suite. App library:
3,222 passed / 0 failed / 19 ignored; terminal/integration suites
passed. The real nest trigger/stalled-body test passed on macOS.
- Independent whole-change source review: no blocking findings at this
commit; all 20 file hashes matched the committed blobs and
review/validation manifests.
- Regression coverage includes real hook/session cancellation,
StrictMode/membership re-entry, reconnect ownership, extended/zero
cooldowns, burst pacing, auxiliary readiness (including EOSE before IPC
settles), native socket refusal/retry/deadline/cancellation, unread
fetch/classification and real nest trigger/HTTP/file persistence.
- Earlier isolated renderer mutations that started readiness before
enqueue or removed auxiliary priority each failed the real presence
regression; restored controls passed. No native mutation claim.
- Nest fixture uses explicit app-data/nest paths instead of assuming
Windows known-folder APIs obey HOME/APPDATA. Windows runtime execution
is not claimed; CI/build results remain separate.

Originating conversation:
buzz://message?channel=46ced308-71df-495d-a144-4ebf08334703&id=bc43c3422a3b0b2141268d0fa149b898421c824992b662aed8b73c4eee50404f

No approval or merge recommendation is implied by opening this PR.

---------

Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-09-22 11:31:44 -07:00
committed by GitHub
parent 7881447c19
commit c213d90d5c
49 changed files with 4265 additions and 387 deletions

View File

@@ -171,6 +171,49 @@ test.describe("agent control browser regressions", () => {
test("Stop publishes from a channelId-only panel with no Channel object", async ({
page,
}) => {
// Hold the actual observer IPC send across the click to prove ordering.
await page.addInitScript(() => {
let internals: Record<string, unknown>;
const probe = window as Window & {
__observerOrder?: string[];
__releaseObserver?: () => void;
};
probe.__observerOrder = [];
Object.defineProperty(window, "__TAURI_INTERNALS__", {
configurable: true,
get: () => internals,
set(value) {
internals = value;
let invoke: (command: string, args: unknown) => Promise<unknown>;
Object.defineProperty(value, "invoke", {
configurable: true,
get: () => invoke,
set(original) {
invoke = async (command, args) => {
if (command === "plugin:websocket|send") {
const message = (args as { message: { data: string } })
.message;
const frame = JSON.parse(message.data);
if (
frame[0] === "REQ" &&
frame[1].startsWith("live-") &&
frame[2].kinds?.includes(24200)
) {
await new Promise<void>((resolve) => {
probe.__releaseObserver = resolve;
});
probe.__observerOrder?.push("observer");
}
if (frame[0] === "EVENT" && frame[1].kind === 24200)
probe.__observerOrder?.push("control");
}
return original(command, args);
};
},
});
},
});
});
await installMockBridge(page, {
observerControlResults: [{ type: "cancel_turn", status: "sent" }],
});
@@ -196,7 +239,17 @@ test.describe("agent control browser regressions", () => {
await page.getByTestId("agent-session-settings-menu-trigger").click();
const stop = page.getByTestId("agent-session-stop-turn");
await expect(stop).toBeEnabled();
await page.waitForFunction(
() =>
typeof (window as Window & { __releaseObserver?: unknown })
.__releaseObserver === "function",
);
await stop.click();
await page.evaluate(() =>
(
window as Window & { __releaseObserver?: () => void }
).__releaseObserver?.(),
);
await expect
.poll(() => readControlRequests(page))
.toEqual(
@@ -212,6 +265,12 @@ test.describe("agent control browser regressions", () => {
]),
);
await expect(page.getByText(/Stop signal sent to charlie/)).toBeVisible();
expect(
await page.evaluate(
() =>
(window as Window & { __observerOrder?: string[] }).__observerOrder,
),
).toEqual(["observer", "control"]);
});
test("Stop reports ambiguous_target without claiming success", async ({

View File

@@ -9,29 +9,33 @@ const SHOTS = "test-results/channel-row-decoration-pr";
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
kind?: number,
kind = 40002,
) {
await expect
.poll(async () => {
return page.evaluate(
({ currentChannelName, kind: k }) => {
return (
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
kind?: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: currentChannelName,
kind: k,
}) ?? false
);
},
{ currentChannelName: channelName, kind },
);
})
// Background admission is paced: the 15-channel fixture takes >5s to drain.
.poll(
async () => {
return page.evaluate(
({ currentChannelName, kind: k }) => {
return (
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
kind?: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: currentChannelName,
kind: k,
}) ?? false
);
},
{ currentChannelName: channelName, kind },
);
},
{ timeout: 15_000 },
)
.toBe(true);
}

View File

@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForMockChannelHeadReady } from "../helpers/channelHeadReady";
// Lane 1c regression — the dense-second reachability wall.
//
@@ -19,7 +20,7 @@ import { installMockBridge } from "../helpers/bridge";
// (a) a *continuation* window request fired (cursor != null) — the head load
// always issues `get_channel_window`, so only a cursor-bearing request
// proves keyset paging engaged, and
// (b) every dense-second message becomes reachable (union of rendered rows
// (b) every dense-second message becomes reachable (union of viewport-visible rows
// equals the full seed) — impossible behind a bare-`until` wall.
const DENSE_SECOND = 1_700_000_000;
const DENSE_COUNT = 450; // many multiples of CHANNEL_WINDOW_PAGE_SIZE (50)
@@ -64,6 +65,11 @@ test("dense single second beyond one window page is fully reachable via composit
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockChannelHeadReady(
page,
"general",
"9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
);
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await page.waitForFunction(() => {
@@ -73,15 +79,29 @@ test("dense single second beyond one window page is fully reachable via composit
return element ? element.scrollHeight > element.clientHeight + 500 : false;
});
// Collect the union of dense-second indices ever rendered. Virtualization
// only mounts a window of rows, so we accumulate across scroll passes rather
// than snapshot once.
const renderedDenseIndices = async () =>
// Collect only visible rows intersecting the timeline viewport, not mounted
// overscan rows. Accumulate across overlapping scroll steps so every seeded
// identity must actually become user-reachable.
const visibleDenseIndices = async () =>
timeline.evaluate((element) => {
const found: number[] = [];
const viewport = element.getBoundingClientRect();
for (const row of (
element as HTMLDivElement
).querySelectorAll<HTMLElement>("[data-message-id]")) {
const bounds = row.getBoundingClientRect();
if (
!row.checkVisibility({
checkOpacity: true,
checkVisibilityCSS: true,
}) ||
bounds.bottom <= viewport.top ||
bounds.top >= viewport.bottom ||
bounds.right <= viewport.left ||
bounds.left >= viewport.right
) {
continue;
}
const match = row.textContent?.match(/dense (\d+)/);
if (match) found.push(Number(match[1]));
}
@@ -92,20 +112,26 @@ test("dense single second beyond one window page is fully reachable via composit
// a genuine leave→enter transition (IntersectionObserver), so a raw
// `scrollTop = 0` write on the virtualized container can fail to re-fire.
// A wheel event is what a real user issues and what the observer honors.
const wheelStep = await timeline.evaluate(
(element) => element.clientHeight / 2,
);
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);
// Overlapping viewport steps let the virtualizer actually mount each
// row. A 6,000px jump can skip rows even when every page is reachable.
await page.mouse.wheel(0, -wheelStep);
await page.waitForTimeout(40);
await collectVisible();
}
};
const seen = new Set<number>();
const collectRendered = async () => {
for (const index of await renderedDenseIndices()) {
const collectVisible = async () => {
for (const index of await visibleDenseIndices()) {
seen.add(index);
}
};
@@ -127,7 +153,7 @@ test("dense single second beyond one window page is fully reachable via composit
await expect
.poll(
async () => {
await collectRendered();
await collectVisible();
return seen.size;
},
{ timeout: 4_000 },
@@ -136,7 +162,7 @@ test("dense single second beyond one window page is fully reachable via composit
} catch {
// No growth this pass — count it toward a genuine stall.
}
await collectRendered();
await collectVisible();
if (seen.size > before) {
stallStreak = 0;
} else {
@@ -158,11 +184,9 @@ test("dense single second beyond one window page is fully reachable via composit
);
expect(continuationRequests).toBeGreaterThan(0);
// (b) Reachability parity: the union of paged dense rows crosses far past
// one window page — impossible behind a bare-`until` wall, where paging
// stalls on the newest slice of the dense second. We assert the vast
// majority became reachable; virtualization can drop a few transient rows
// between scroll settles, so we allow a small slack rather than demanding
// an exact 450.
expect(seen.size).toBeGreaterThan(DENSE_COUNT * 0.9);
// (b) Every seeded identity must be reachable: cardinality or a percentage
// threshold can silently accept an interior gap in the dense second.
expect([...seen].sort((a, b) => a - b)).toEqual(
Array.from({ length: DENSE_COUNT }, (_, index) => index),
);
});

View File

@@ -223,26 +223,29 @@ async function waitForMockLiveSubscription(
kind?: number,
) {
await expect
.poll(async () => {
return page.evaluate(
({ currentChannelName, kind }) => {
return (
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
kind?: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: currentChannelName,
kind,
}) ?? false
);
},
{ currentChannelName: channelName, kind },
);
})
.poll(
async () => {
return page.evaluate(
({ currentChannelName, kind }) => {
return (
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
kind?: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: currentChannelName,
kind,
}) ?? false
);
},
{ currentChannelName: channelName, kind },
);
},
{ timeout: 15_000 },
)
.toBe(true);
}
@@ -1647,7 +1650,6 @@ test("create ephemeral stream shows sidebar and header affordances", async ({
throw new Error("Created ephemeral channel is missing its channel id.");
}
await waitForMockLiveSubscription(page, channelName);
await page.getByTestId("channel-general").click();
await page.evaluate(
({ agentPubkey, channelId: activeChannelId }) => {
@@ -1692,6 +1694,9 @@ test("create ephemeral stream shows sidebar and header affordances", async ({
.getByRole("button", { name: "Toggle Sidebar", exact: true })
.click();
// The active channel's window subscription is not the background unread
// listener. Wait for message admission after switching away.
await waitForMockLiveSubscription(page, channelName, 40002);
await page.evaluate(
({ channelName: targetChannelName, mentionPubkey, senderPubkey }) => {
(window as MockFeedWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
@@ -2521,7 +2526,7 @@ test("sidebar shows unread indicator for newly active channels", async ({
await page.goto("/");
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await waitForMockLiveSubscription(page, "random");
await waitForMockLiveSubscription(page, "random", 40002);
// The unread tracker ignores the current user's own messages, so emit as
// alice — simulating a real "another user posted while I was elsewhere".
@@ -2555,7 +2560,7 @@ test("sidebar shows unread indicator for new forum posts", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("channel-unread-watercooler")).toHaveCount(0);
await waitForMockLiveSubscription(page, "watercooler");
await waitForMockLiveSubscription(page, "watercooler", 45001);
// Emit as alice — the unread tracker ignores self-authored messages.
await page.evaluate(
@@ -2585,7 +2590,7 @@ test("sidebar clears unread indicator after opening a DM", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("channel-unread-alice-tyler")).toHaveCount(0);
await waitForMockLiveSubscription(page, "alice-tyler");
await waitForMockLiveSubscription(page, "alice-tyler", 9);
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({

View File

@@ -375,6 +375,12 @@ test("entity tooltip uses project context while relay metadata is delayed", asyn
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("channel-general").click();
await page.waitForFunction(() =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: "general",
kind: 39005,
}),
);
await page.evaluate(() =>
window.__BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__?.(300),
);

View File

@@ -32,6 +32,7 @@ type MockWindow = Window & {
mentionPubkeys?: string[];
id?: string;
kind?: number;
createdAt?: number;
extraTags?: string[][];
}) => RelayEvent;
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
@@ -296,6 +297,14 @@ test.describe("inbox stable-conversation regressions", () => {
await expect(detail).toContainText("Nested anchor");
expect(await getItemParam(page)).toBe(anchor.id);
// This tests steady-state delivery, not paced startup admission.
await page.waitForFunction(() =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: "general",
kind: 9,
}),
);
// Add filler replies to make the detail pane scrollable.
await page.evaluate(
({ senderPubkey, rootId }) => {
@@ -1143,11 +1152,13 @@ test.describe("inbox stable-conversation regressions", () => {
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emit || !push) throw new Error("Bridge helpers not ready");
const start = Math.floor(Date.now() / 1000) - 20;
const fetchRoot = emit({
channelName: "general",
content: "Reaction-drift test root.",
pubkey: senderPubkey,
id: "e0".repeat(32),
createdAt: start,
});
// 10 older replies — in mockMessages, prepended above fetchNewest
@@ -1160,6 +1171,7 @@ test.describe("inbox stable-conversation regressions", () => {
parentEventId: fetchRoot.id,
pubkey: senderPubkey,
id: `e${i.toString(16).padStart(1, "0")}`.repeat(32),
createdAt: start + i,
});
olderReplyIds.push(reply.id);
}
@@ -1172,6 +1184,7 @@ test.describe("inbox stable-conversation regressions", () => {
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "ef".repeat(32),
createdAt: start + 11,
});
// Later replies ensure the selected row has enough content below it to
@@ -1183,6 +1196,7 @@ test.describe("inbox stable-conversation regressions", () => {
parentEventId: fetchRoot.id,
pubkey: senderPubkey,
id: `f${i.toString(16).padStart(1, "0")}`.repeat(32),
createdAt: start + 11 + i,
});
}
@@ -1191,7 +1205,7 @@ test.describe("inbox stable-conversation regressions", () => {
kind: fetchNewest.kind,
pubkey: fetchNewest.pubkey,
content: fetchNewest.content,
created_at: fetchNewest.created_at + 11,
created_at: fetchNewest.created_at,
channel_id: channelId,
channel_name: "general",
tags: fetchNewest.tags,
@@ -1247,6 +1261,9 @@ test.describe("inbox stable-conversation regressions", () => {
);
});
expect(msgCenterOffsetBeforeReactions).not.toBeNull();
expect(Math.abs(msgCenterOffsetBeforeReactions ?? Infinity)).toBeLessThan(
30,
);
expect(await getScrollIntoViewCount(page)).toBe(1);
// ── Emit late reactions targeting messages ABOVE fetchNewest ──────
@@ -1353,11 +1370,13 @@ test.describe("inbox stable-conversation regressions", () => {
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emit || !push) throw new Error("Bridge helpers not ready");
const start = Math.floor(Date.now() / 1000) - 20;
const fetchRoot = emit({
channelName: "general",
content: "Reaction-drift test root.",
pubkey: senderPubkey,
id: "e0".repeat(32),
createdAt: start,
});
// 10 older replies — in mockMessages, prepended above fetchNewest
@@ -1370,6 +1389,7 @@ test.describe("inbox stable-conversation regressions", () => {
parentEventId: fetchRoot.id,
pubkey: senderPubkey,
id: `e${i.toString(16).padStart(1, "0")}`.repeat(32),
createdAt: start + i,
});
olderReplyIds.push(reply.id);
}
@@ -1382,6 +1402,7 @@ test.describe("inbox stable-conversation regressions", () => {
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "ef".repeat(32),
createdAt: start + 11,
});
// Later replies ensure the selected row has enough content below it to
@@ -1394,6 +1415,7 @@ test.describe("inbox stable-conversation regressions", () => {
parentEventId: fetchRoot.id,
pubkey: senderPubkey,
id: `f${i.toString(16).padStart(1, "0")}`.repeat(32),
createdAt: start + 11 + i,
});
}
@@ -1402,7 +1424,7 @@ test.describe("inbox stable-conversation regressions", () => {
kind: fetchNewest.kind,
pubkey: fetchNewest.pubkey,
content: fetchNewest.content,
created_at: fetchNewest.created_at + 11,
created_at: fetchNewest.created_at,
channel_id: channelId,
channel_name: "general",
tags: fetchNewest.tags,
@@ -1440,6 +1462,26 @@ test.describe("inbox stable-conversation regressions", () => {
// Older replies are now rendered (fetch landed).
await expect(detail).toContainText("Reaction-drift older reply 1");
expect(await getScrollIntoViewCount(page)).toBe(1);
// Prove the intended centered state and real downward scroll room before
// testing hold release; do not manufacture either with a setup scroll.
const geometry = await page.evaluate(() => {
const selected = document.querySelector<HTMLElement>(
'[data-testid="home-inbox-selected-message"]',
);
const pane = document.querySelector<HTMLElement>(
'[data-testid="home-inbox-detail"] [aria-busy]',
);
if (!selected || !pane) return null;
const row = selected.getBoundingClientRect();
const bounds = pane.getBoundingClientRect();
return {
offset: row.top + row.height / 2 - (bounds.top + bounds.height / 2),
room: pane.scrollHeight - pane.clientHeight - pane.scrollTop,
};
});
expect(geometry).not.toBeNull();
expect(Math.abs(geometry?.offset ?? Infinity)).toBeLessThan(30);
expect(geometry?.room ?? 0).toBeGreaterThan(0);
// Simulate a scrollbar drag: change the actual scroll container directly
// and dispatch only `scroll`, without wheel/touch/key input. This must

View File

@@ -45,6 +45,7 @@ async function waitForMockLiveSubscription(
(ch) =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: ch,
kind: 39005,
}) ?? false,
channelName,
),

View File

@@ -3946,10 +3946,10 @@ test("closing a thread while editing a reply preserves the typed edit", async ({
const timelineRoot = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.last();
.filter({ hasText: root });
await expect(timelineRoot).toContainText(root);
await waitForAnimations(page);
await timelineRoot.scrollIntoViewIfNeeded();
// Hover auto-scrolls and re-resolves the row if rendering replaces it.
await timelineRoot.hover();
await timelineRoot.getByRole("button", { name: "Reply" }).click();
@@ -4036,10 +4036,10 @@ test("main ArrowUp refuses to replace a dirty thread edit", async ({
const timelineRoot = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.last();
.filter({ hasText: root });
await expect(timelineRoot).toContainText(root);
await waitForAnimations(page);
await timelineRoot.scrollIntoViewIfNeeded();
// Hover auto-scrolls and re-resolves the row if rendering replaces it.
await timelineRoot.hover();
await timelineRoot.getByRole("button", { name: "Reply" }).click();

View File

@@ -197,6 +197,7 @@ test("keeps an open status draft when the saved status expires", async ({
page,
}) => {
await page.goto("/");
await waitForMockGlobalKindSubscription(page, 30315);
const nowSeconds = Math.floor(Date.now() / 1_000);
await seedMockStatus(page, {
text: "Original draft",

View File

@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForMockChannelHeadReady } from "../helpers/channelHeadReady";
// First-pass settle budget for a full channel-history prepend. CI Linux font
// rasterization can leave the restored anchor a subpixel off the local value
@@ -182,17 +183,18 @@ test("preserves user scroll while older channel history loads", async ({
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
// Use the `deep-history` channel: its store is seeded with 600 messages,
// more than CHANNEL_HISTORY_LIMIT (300, hooks.ts), so the cold load windows
// to the newest 300 and leaves ~300 genuinely older messages behind the
// `until` cursor. A shallow seed (store < 300) is fully drained by the cold
// load, so the wheel `fetchOlder` returns only already-cached duplicates that
// dedup to zero net growth -- the anchor never has a real prepend to hold and
// the assertion would measure virtualizer re-measure, not scroll preservation.
// The 600-row seed exceeds the 50-row head window, leaving genuinely older
// pages behind its composite cursor. A fully drained seed would only measure
// virtualizer re-measurement, not scroll preservation across real growth.
await page.getByTestId("channel-deep-history").click();
await expect(page.getByTestId("chat-title")).toHaveText("deep-history");
await waitForMockChannelHeadReady(
page,
"deep-history",
"feedf00d-0000-4000-8000-000000000007",
);
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await page.waitForFunction(() => {
const element = document.querySelector(
'[data-testid="message-timeline"]',
@@ -1316,6 +1318,12 @@ test("fast middle-page scroll settles with continuous mounted coverage", async (
return element && element.scrollHeight > element.clientHeight * 3;
});
await page.waitForFunction(() =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: "general",
kind: 39005,
}),
);
// Land a genuine prepend first. This is what turns `shift` on; subsequent
// ordinary list updates and measurements must happen with it cleared.
const scrollHeightBeforePrepend = (await getTimelineMetrics(page))
@@ -1648,6 +1656,11 @@ test("channel intro stays hidden while paginating past the timeline cap", async
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockChannelHeadReady(
page,
"general",
"9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
);
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
@@ -1785,6 +1798,11 @@ test("older-history fetches never overlap (no concurrent in-flight requests)", a
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockChannelHeadReady(
page,
"general",
"9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
);
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
@@ -1848,6 +1866,11 @@ test("older-history spinner stays visible in viewport while fetching mid-scroll"
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockChannelHeadReady(
page,
"general",
"9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
);
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
@@ -2024,6 +2047,11 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockChannelHeadReady(
page,
"general",
"9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
);
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await page.waitForFunction(() => {

View File

@@ -532,16 +532,18 @@ test("hash mismatch replaces the snapshot with the full live list", async ({
page,
}) => {
await seedSnapshot(page, { hash: "stale-hash" });
await trackSnapshotRows(page);
await installMockBridge(page, {
channelsReadDelayMs: READ_DELAY_MS,
honorChannelsKnownHash: true,
});
await page.goto("/");
await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount(
FULL_SNAPSHOT.length,
{ timeout: 500 },
);
// Observe the boot frame even if live revalidation finishes before the
// test runner gets its next turn; cold-boot coverage separately times paint.
await expect
.poll(() => getTrackedSnapshotRows(page))
.toEqual(FULL_SNAPSHOT.map((channel) => channel.id));
await expect
.poll(() => getChannelsPayloads(page))
.toEqual([{ knownHash: "stale-hash" }]);

View File

@@ -13,20 +13,26 @@ async function waitForMockLiveSubscription(
channelName: string,
) {
await expect
.poll(async () => {
return page.evaluate(
({ ch }) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ??
false,
{ ch: channelName },
);
})
.poll(
async () => {
return page.evaluate(
({ ch }) =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
kind: number;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: ch,
kind: 9,
}) ?? false,
{ ch: channelName },
);
},
{ timeout: 15_000 },
)
.toBe(true);
}

View File

@@ -0,0 +1,75 @@
import { expect, type Page } from "@playwright/test";
// These default mock fixtures have immediate EOSE and no injected reconnect.
// Steady-state pagination begins after live admission and its head refresh,
// which can replace paged tails. This does not test early startup scrolling.
export async function waitForMockChannelHeadReady(
page: Page,
channelName: string,
channelId: string,
) {
await page.waitForFunction(
({ channelId, channelName }) => {
if (
!window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName,
kind: 39005,
})
) {
return false;
}
const commands = window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [];
const liveIndex = commands.findIndex(({ command, payload }) => {
if (command !== "plugin:websocket|send") return false;
const message = (
payload as { message?: { type: string; data: string } }
)?.message;
if (message?.type !== "Text") return false;
const [type, id, ...filters] = JSON.parse(message.data);
return (
type === "REQ" &&
id.startsWith("live-") &&
filters.some(
(filter: { kinds?: number[]; "#h"?: string[] }) =>
filter.kinds?.includes(39005) &&
filter["#h"]?.includes(channelId),
)
);
});
const refreshed = commands
.slice(liveIndex + 1)
.some(({ command, payload }) => {
const args = payload as {
channelId?: string;
cursor?: unknown;
} | null;
return (
command === "get_channel_window" &&
args?.channelId === channelId &&
args.cursor === null
);
});
const state = window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState([
"channel-messages",
channelId,
]);
return (
liveIndex >= 0 &&
refreshed &&
state?.status === "success" &&
state.fetchStatus === "idle"
);
},
{ channelId, channelName },
);
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await expect
.poll(async () => {
return timeline.evaluate(
(element) =>
element.scrollHeight - element.scrollTop - element.clientHeight,
);
})
.toBeLessThanOrEqual(2);
}