mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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>
996 lines
31 KiB
TypeScript
996 lines
31 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { waitForAnimations } from "../helpers/animations";
|
|
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
|
|
|
|
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
|
|
const SHOTS = "test-results/channel-row-decoration-pr";
|
|
|
|
async function waitForMockLiveSubscription(
|
|
page: import("@playwright/test").Page,
|
|
channelName: string,
|
|
kind = 40002,
|
|
) {
|
|
await expect
|
|
// 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);
|
|
}
|
|
|
|
async function getBadgeState(page: import("@playwright/test").Page) {
|
|
return page.evaluate(() => {
|
|
const w = window as Window & {
|
|
__BUZZ_E2E_APP_BADGE_STATE__?: string;
|
|
__BUZZ_E2E_APP_BADGE_COUNT__?: number;
|
|
};
|
|
return {
|
|
state: w.__BUZZ_E2E_APP_BADGE_STATE__ ?? "none",
|
|
count: w.__BUZZ_E2E_APP_BADGE_COUNT__ ?? 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function waitForBadgeState(
|
|
page: import("@playwright/test").Page,
|
|
expected: { state: string; count?: number },
|
|
) {
|
|
await expect
|
|
.poll(async () => getBadgeState(page), { timeout: 5_000 })
|
|
.toEqual(
|
|
expect.objectContaining({
|
|
state: expected.state,
|
|
...(expected.count !== undefined ? { count: expected.count } : {}),
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function getSettledBadgeState(page: import("@playwright/test").Page) {
|
|
// The mock bridge seeds a couple of unread items during app startup. Let
|
|
// those settle before asserting deltas from newly emitted messages.
|
|
await page.waitForTimeout(2000);
|
|
return getBadgeState(page);
|
|
}
|
|
|
|
async function getSidebarHomeBadgeText(page: import("@playwright/test").Page) {
|
|
return page
|
|
.getByTestId("sidebar-home-count")
|
|
.allTextContents()
|
|
.then((texts) => texts[0] ?? null);
|
|
}
|
|
|
|
function withAdditionalBadgeCount(baseline: { count: number }, count: number) {
|
|
return { state: "count", count: baseline.count + count };
|
|
}
|
|
|
|
function withDotOnlyBadge(baseline: { state: string; count: number }) {
|
|
return baseline.count > 0 ? baseline : { state: "dot", count: 0 };
|
|
}
|
|
|
|
async function getUnreadPillComposition(
|
|
pill: import("@playwright/test").Locator,
|
|
) {
|
|
return pill.evaluate((element) => {
|
|
const style = getComputedStyle(element);
|
|
const icon = element.querySelector("svg")?.getBoundingClientRect();
|
|
return {
|
|
fontSize: style.fontSize,
|
|
gap: style.gap,
|
|
height: element.getBoundingClientRect().height,
|
|
iconHeight: icon?.height,
|
|
iconWidth: icon?.width,
|
|
letterSpacing: style.letterSpacing,
|
|
paddingBlock: `${style.paddingTop} ${style.paddingBottom}`,
|
|
paddingInline: `${style.paddingLeft} ${style.paddingRight}`,
|
|
};
|
|
});
|
|
}
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await installMockBridge(page);
|
|
});
|
|
|
|
test("selected Inbox and Agents rows keep their highlight without bold text", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const inbox = page
|
|
.getByTestId("sidebar-primary-menu")
|
|
.getByRole("button", { name: "Inbox", exact: true });
|
|
await expect(inbox).toHaveAttribute("data-active", "true");
|
|
await expect(inbox).toHaveCSS("font-weight", "400");
|
|
|
|
const agents = page.getByTestId("open-agents-view");
|
|
await agents.click();
|
|
await expect(agents).toHaveAttribute("data-active", "true");
|
|
await expect(agents).toHaveCSS("font-weight", "400");
|
|
});
|
|
|
|
test("primary navigation rows share the same inactive emphasis", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
|
|
const primaryMenu = page.getByTestId("sidebar-primary-menu");
|
|
const inactiveRows = [
|
|
primaryMenu.getByRole("button", { name: "Inbox", exact: true }),
|
|
page.getByTestId("open-pulse-view"),
|
|
page.getByTestId("open-projects-view"),
|
|
page.getByTestId("open-agents-view"),
|
|
page.getByTestId("open-workflows-view"),
|
|
];
|
|
|
|
for (const row of inactiveRows) {
|
|
await expect(row).toHaveAttribute("data-active", "false");
|
|
await expect(row.locator("[data-sidebar=menu-label]")).toHaveCSS(
|
|
"opacity",
|
|
"0.8",
|
|
);
|
|
await expect(row.locator("svg")).toHaveCSS("opacity", "0.8");
|
|
}
|
|
|
|
const pulse = page.getByTestId("open-pulse-view");
|
|
await pulse.click();
|
|
await expect(pulse).toHaveAttribute("data-active", "true");
|
|
await expect(pulse.locator("[data-sidebar=menu-label]")).toHaveCSS(
|
|
"opacity",
|
|
"1",
|
|
);
|
|
await expect(pulse.locator("svg")).toHaveCSS("opacity", "1");
|
|
});
|
|
|
|
test("hovering a channel keeps its text color", async ({ page }) => {
|
|
await page.goto("/");
|
|
const channel = page.getByTestId("channel-engineering");
|
|
const initialColor = await channel.evaluate(
|
|
(element) => getComputedStyle(element).color,
|
|
);
|
|
|
|
await channel.hover();
|
|
await expect(channel).toHaveCSS("color", initialColor);
|
|
});
|
|
|
|
test("direct-message rows become prominent only when unread", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
window.localStorage.setItem("buzz-theme", "buzz-dark");
|
|
});
|
|
await page.goto("/");
|
|
const directMessage = page.getByTestId("channel-alice-tyler");
|
|
|
|
await directMessage.click();
|
|
await waitForMockLiveSubscription(page, "alice-tyler");
|
|
await page.getByTestId("channel-general").click();
|
|
|
|
const label = directMessage.locator("[data-sidebar-row-label]");
|
|
await expect(directMessage).toHaveCSS("opacity", "1");
|
|
await expect(label).toHaveCSS("opacity", "0.8");
|
|
await page.evaluate((pubkey) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "alice-tyler",
|
|
content: "An unread direct message",
|
|
kind: 40002,
|
|
pubkey,
|
|
});
|
|
}, TEST_IDENTITIES.alice.pubkey);
|
|
|
|
await expect(label).toHaveCSS("opacity", "1");
|
|
await expect(directMessage).toHaveCSS("font-weight", "700");
|
|
});
|
|
|
|
test("light mode reserves full opacity for unread text and avatars", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const directMessage = page.getByTestId("channel-alice-tyler");
|
|
await directMessage.click();
|
|
await waitForMockLiveSubscription(page, "alice-tyler");
|
|
await page.getByTestId("channel-general").click();
|
|
|
|
const inbox = page
|
|
.getByTestId("sidebar-primary-menu")
|
|
.getByRole("button", { name: "Inbox", exact: true });
|
|
await expect(inbox).toHaveCSS("opacity", "1");
|
|
await expect(inbox.locator("[data-sidebar=menu-label]")).toHaveCSS(
|
|
"opacity",
|
|
"0.8",
|
|
);
|
|
await expect(inbox.locator("svg")).toHaveCSS("opacity", "0.8");
|
|
await expect(directMessage).toHaveCSS("opacity", "1");
|
|
await expect(directMessage.locator("[data-sidebar-row-label]")).toHaveCSS(
|
|
"opacity",
|
|
"0.8",
|
|
);
|
|
|
|
await page.evaluate((pubkey) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "alice-tyler",
|
|
content: "An unread direct message in light mode",
|
|
kind: 40002,
|
|
pubkey,
|
|
});
|
|
}, TEST_IDENTITIES.alice.pubkey);
|
|
|
|
await expect(directMessage.locator("[data-sidebar-row-label]")).toHaveCSS(
|
|
"opacity",
|
|
"1",
|
|
);
|
|
await expect(directMessage).toHaveCSS("font-weight", "700");
|
|
});
|
|
|
|
test("dark mode keeps selected labels regular and channel-level unread labels bold", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
window.localStorage.setItem("buzz-theme", "buzz-dark");
|
|
});
|
|
await page.goto("/");
|
|
|
|
await expect(page.locator("html")).toHaveClass(/dark/);
|
|
const inbox = page
|
|
.getByTestId("sidebar-primary-menu")
|
|
.getByRole("button", { name: "Inbox", exact: true });
|
|
await expect(inbox).toHaveAttribute("data-active", "true");
|
|
await expect(inbox).toHaveCSS("font-weight", "400");
|
|
await expect(page.getByTestId("open-agents-view")).toHaveCSS("opacity", "1");
|
|
await expect(
|
|
page.getByTestId("open-agents-view").locator("[data-sidebar=menu-label]"),
|
|
).toHaveCSS("opacity", "0.8");
|
|
await expect(page.getByTestId("open-agents-view").locator("svg")).toHaveCSS(
|
|
"opacity",
|
|
"0.8",
|
|
);
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(inbox).toHaveCSS("opacity", "1");
|
|
await expect(inbox.locator("[data-sidebar=menu-label]")).toHaveCSS(
|
|
"opacity",
|
|
"0.8",
|
|
);
|
|
await waitForMockLiveSubscription(page, "random");
|
|
await page.evaluate((pubkey) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "A dark-mode channel-level unread message",
|
|
kind: 40002,
|
|
pubkey,
|
|
});
|
|
}, TEST_IDENTITIES.alice.pubkey);
|
|
|
|
const unreadChannel = page.getByTestId("channel-random");
|
|
const engineeringLabel = page
|
|
.getByTestId("channel-engineering")
|
|
.locator("[data-sidebar-row-label]");
|
|
await expect(engineeringLabel).toHaveCSS("opacity", "0.8");
|
|
await expect(
|
|
page.getByTestId("channel-engineering").locator("svg"),
|
|
).toHaveCSS("opacity", "0.8");
|
|
await expect(unreadChannel.locator("[data-sidebar-row-label]")).toHaveCSS(
|
|
"opacity",
|
|
"1",
|
|
);
|
|
await expect(unreadChannel).toHaveCSS("font-weight", "700");
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
path: `${SHOTS}/sidebar-dark-unread.png`,
|
|
clip: { x: 0, y: 0, width: 320, height: 720 },
|
|
});
|
|
});
|
|
|
|
test("offscreen unread counts destinations and promotes without incrementing", async ({
|
|
page,
|
|
}) => {
|
|
await page.setViewportSize({ width: 1280, height: 360 });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-random").click();
|
|
await waitForMockLiveSubscription(page, "random");
|
|
await page.getByTestId("channel-general").click();
|
|
|
|
const sidebarScroller = page
|
|
.getByTestId("app-sidebar")
|
|
.locator('[data-sidebar="content"]');
|
|
await sidebarScroller.evaluate((element) => {
|
|
const random = element.querySelector<HTMLElement>(
|
|
'[data-testid="channel-random"]',
|
|
);
|
|
if (!random) throw new Error("Could not find #random in the sidebar");
|
|
|
|
// Keep #random just above the viewport so it is the next unread row.
|
|
element.scrollTop +=
|
|
random.getBoundingClientRect().bottom -
|
|
element.getBoundingClientRect().top +
|
|
1;
|
|
});
|
|
await expect(page.getByTestId("channel-random")).not.toBeInViewport();
|
|
|
|
await page.evaluate(
|
|
({ pubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "A regular channel message",
|
|
kind: 40002,
|
|
pubkey,
|
|
});
|
|
},
|
|
{ pubkey: TEST_IDENTITIES.alice.pubkey },
|
|
);
|
|
|
|
const activityArrow = page.getByTestId("sidebar-more-unread-above");
|
|
await expect(activityArrow).toBeVisible();
|
|
await expect(activityArrow).toContainText("1 unread");
|
|
await expect(activityArrow).not.toHaveClass(/bg-primary/);
|
|
await expect(activityArrow).toHaveCSS("font-size", "12px");
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
path: `${SHOTS}/sidebar-unread-overflow-default.png`,
|
|
clip: { x: 0, y: 0, width: 320, height: 360 },
|
|
});
|
|
|
|
const defaultComposition = await getUnreadPillComposition(activityArrow);
|
|
|
|
await page.evaluate(
|
|
({ pubkey, mentionPubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "A priority mention for @tyler",
|
|
kind: 40002,
|
|
pubkey,
|
|
mentionPubkeys: [mentionPubkey],
|
|
});
|
|
},
|
|
{
|
|
pubkey: TEST_IDENTITIES.alice.pubkey,
|
|
mentionPubkey: DEFAULT_MOCK_PUBKEY,
|
|
},
|
|
);
|
|
|
|
// A second message in the same destination promotes the pill but does not
|
|
// increase the number of places awaiting review.
|
|
await expect(activityArrow).toContainText("1 unread");
|
|
await expect(activityArrow).toHaveClass(/bg-primary/);
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
path: `${SHOTS}/sidebar-unread-overflow-primary.png`,
|
|
clip: { x: 0, y: 0, width: 320, height: 360 },
|
|
});
|
|
const primaryComposition = await getUnreadPillComposition(activityArrow);
|
|
expect(primaryComposition).toEqual(defaultComposition);
|
|
|
|
await activityArrow.click();
|
|
await expect(page.getByTestId("channel-random")).toBeInViewport();
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
path: `${SHOTS}/sidebar-top-level-unread-arrow.png`,
|
|
clip: { x: 0, y: 0, width: 320, height: 360 },
|
|
});
|
|
});
|
|
|
|
test("offscreen unread DM shows the primary sidebar arrow", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-alice-tyler").click();
|
|
await waitForMockLiveSubscription(page, "alice-tyler");
|
|
await page.getByTestId("channel-general").click();
|
|
await page.setViewportSize({ width: 1280, height: 360 });
|
|
|
|
const sidebarScroller = page
|
|
.getByTestId("app-sidebar")
|
|
.locator('[data-sidebar="content"]');
|
|
await sidebarScroller.evaluate((element) => {
|
|
element.scrollTop = 0;
|
|
});
|
|
await expect(page.getByTestId("channel-alice-tyler")).not.toBeInViewport();
|
|
|
|
await page.evaluate((pubkey) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "alice-tyler",
|
|
content: "An unread direct message",
|
|
kind: 40002,
|
|
pubkey,
|
|
});
|
|
}, TEST_IDENTITIES.alice.pubkey);
|
|
|
|
const activityArrow = page.getByTestId("sidebar-more-unread-below");
|
|
await expect(activityArrow).toBeVisible();
|
|
await expect(activityArrow).toContainText("1 unread");
|
|
await expect(activityArrow).toHaveClass(/bg-primary/);
|
|
});
|
|
|
|
test("thread-only activity in an offscreen DM stays primary", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-alice-tyler").click();
|
|
await waitForMockLiveSubscription(page, "alice-tyler");
|
|
await page.setViewportSize({ width: 1280, height: 360 });
|
|
|
|
const sidebarScroller = page
|
|
.getByTestId("app-sidebar")
|
|
.locator('[data-sidebar="content"]');
|
|
await sidebarScroller.evaluate((element) => {
|
|
element.scrollTop = 0;
|
|
});
|
|
await expect(page.getByTestId("channel-alice-tyler")).not.toBeInViewport();
|
|
|
|
const initialReplyAt = Math.floor(Date.now() / 1000) - 10;
|
|
const rootEventId = await page.evaluate((pubkey) => {
|
|
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "alice-tyler",
|
|
content: "A DM thread I started",
|
|
kind: 40002,
|
|
pubkey,
|
|
});
|
|
return root?.id;
|
|
}, DEFAULT_MOCK_PUBKEY);
|
|
if (!rootEventId) throw new Error("Mock message emitter is unavailable");
|
|
|
|
await page.evaluate(
|
|
({ createdAt, parentEventId, pubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "alice-tyler",
|
|
content: "Initial DM thread reply",
|
|
createdAt,
|
|
kind: 40002,
|
|
parentEventId,
|
|
pubkey,
|
|
});
|
|
},
|
|
{
|
|
createdAt: initialReplyAt,
|
|
parentEventId: rootEventId,
|
|
pubkey: TEST_IDENTITIES.alice.pubkey,
|
|
},
|
|
);
|
|
|
|
const threadSummary = page.getByTestId("message-thread-summary").first();
|
|
await expect(threadSummary).toBeVisible();
|
|
await threadSummary.click();
|
|
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
|
|
await page.getByTestId("auxiliary-panel-close").click();
|
|
const threadReadSecond = await page.evaluate(() =>
|
|
Math.floor(Date.now() / 1000),
|
|
);
|
|
await expect
|
|
.poll(() => page.evaluate(() => Math.floor(Date.now() / 1000)))
|
|
.toBeGreaterThan(threadReadSecond);
|
|
|
|
await page.evaluate(
|
|
({ parentEventId, pubkey }) => {
|
|
const reply = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "alice-tyler",
|
|
content: "A non-mention reply in the active DM thread",
|
|
kind: 40002,
|
|
parentEventId,
|
|
pubkey,
|
|
});
|
|
if (!reply) throw new Error("Mock message emitter is unavailable");
|
|
window.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?.({
|
|
category: "activity",
|
|
channel_id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8",
|
|
channel_name: "alice-tyler",
|
|
channel_type: "dm",
|
|
content: reply.content,
|
|
created_at: reply.created_at,
|
|
id: reply.id,
|
|
kind: reply.kind,
|
|
pubkey: reply.pubkey,
|
|
tags: reply.tags,
|
|
});
|
|
},
|
|
{ parentEventId: rootEventId, pubkey: TEST_IDENTITIES.alice.pubkey },
|
|
);
|
|
|
|
const activityArrow = page.getByTestId("sidebar-more-unread-below");
|
|
await expect(activityArrow).toBeVisible();
|
|
await expect(activityArrow).toContainText("1 unread");
|
|
await expect(activityArrow).toHaveClass(/bg-primary/);
|
|
await waitForAnimations(page);
|
|
await activityArrow.screenshot({
|
|
path: `${SHOTS}/sidebar-dm-thread-overflow-primary.png`,
|
|
});
|
|
});
|
|
|
|
test("regular message bolds inactive channel without numeric badge", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "random");
|
|
const baselineBadge = await getSettledBadgeState(page);
|
|
|
|
await page.evaluate(
|
|
({ pubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "Regular message, no mention",
|
|
kind: 40002,
|
|
pubkey,
|
|
});
|
|
},
|
|
{ pubkey: TEST_IDENTITIES.alice.pubkey },
|
|
);
|
|
|
|
const unreadChannel = page.getByTestId("channel-random");
|
|
await expect(unreadChannel).toHaveCSS("font-weight", "700");
|
|
await expect(unreadChannel.locator("[data-sidebar-row-label]")).toHaveCSS(
|
|
"opacity",
|
|
"1",
|
|
);
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
|
|
await waitForBadgeState(page, withDotOnlyBadge(baselineBadge));
|
|
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("channel-random")).toHaveAttribute(
|
|
"data-active",
|
|
"true",
|
|
);
|
|
await expect(page.getByTestId("channel-random")).toHaveCSS(
|
|
"font-weight",
|
|
"400",
|
|
);
|
|
});
|
|
|
|
test("top-level @mention bolds its channel without a trailing numeral", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "random");
|
|
const baselineBadge = await getSettledBadgeState(page);
|
|
|
|
await page.evaluate(
|
|
({ pubkey, mentionPubkey }) => {
|
|
for (const content of [
|
|
"Hey @tyler check this out",
|
|
"One more for @tyler",
|
|
]) {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content,
|
|
kind: 40002,
|
|
pubkey,
|
|
mentionPubkeys: [mentionPubkey],
|
|
});
|
|
}
|
|
},
|
|
{
|
|
pubkey: TEST_IDENTITIES.alice.pubkey,
|
|
mentionPubkey: DEFAULT_MOCK_PUBKEY,
|
|
},
|
|
);
|
|
|
|
await expect(page.getByTestId("channel-random")).toHaveCSS(
|
|
"font-weight",
|
|
"700",
|
|
);
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
|
|
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 2));
|
|
});
|
|
|
|
test("@mention inside a thread bolds the room and keeps hover-to-preview", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "random");
|
|
|
|
const rootEventId = await page.evaluate(() => {
|
|
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "Thread root from someone else",
|
|
kind: 40002,
|
|
pubkey: "deadbeef".repeat(8),
|
|
});
|
|
return root?.id;
|
|
});
|
|
|
|
await page.evaluate(
|
|
({ parentEventId, pubkey, mentionPubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "In-thread ping for @tyler",
|
|
kind: 40002,
|
|
parentEventId,
|
|
pubkey,
|
|
mentionPubkeys: [mentionPubkey],
|
|
});
|
|
},
|
|
{
|
|
parentEventId: rootEventId,
|
|
pubkey: TEST_IDENTITIES.alice.pubkey,
|
|
mentionPubkey: DEFAULT_MOCK_PUBKEY,
|
|
},
|
|
);
|
|
|
|
await expect(page.getByTestId("channel-random")).toHaveCSS(
|
|
"font-weight",
|
|
"700",
|
|
);
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
await expect(page.getByTestId("channel-unread-dot-random")).toBeVisible();
|
|
|
|
// Hover-to-preview is owned by the thread dot, not a trailing numeral.
|
|
await page.getByTestId("channel-random").hover();
|
|
const popover = page.getByTestId("channel-activity-popover-random");
|
|
await expect(popover).toBeVisible();
|
|
await expect(
|
|
popover.getByTestId(`channel-activity-item-${rootEventId}`),
|
|
).toBeVisible();
|
|
await expect(popover).toContainText("In-thread ping for");
|
|
});
|
|
|
|
test("numeric badge increments for DM message", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "alice-tyler");
|
|
const baselineBadge = await getSettledBadgeState(page);
|
|
|
|
await page.evaluate((pubkey) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "alice-tyler",
|
|
content: "Hey, got a minute?",
|
|
pubkey,
|
|
});
|
|
}, TEST_IDENTITIES.alice.pubkey);
|
|
|
|
await expect(page.getByTestId("channel-unread-alice-tyler")).toBeVisible();
|
|
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
|
|
});
|
|
|
|
test("interested thread reply shows the channel preview dot without incrementing Inbox", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "random");
|
|
const baselineBadge = await getSettledBadgeState(page);
|
|
const baselineHomeBadge = await getSidebarHomeBadgeText(page);
|
|
|
|
const rootEventId = await page.evaluate(() => {
|
|
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "Conversation I started",
|
|
kind: 40002,
|
|
pubkey: "deadbeef".repeat(8),
|
|
});
|
|
return root?.id;
|
|
});
|
|
|
|
await page.evaluate(
|
|
({ parentEventId, pubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "Thread reply to a followed conversation",
|
|
kind: 40002,
|
|
parentEventId,
|
|
pubkey,
|
|
});
|
|
},
|
|
{ parentEventId: rootEventId, pubkey: TEST_IDENTITIES.alice.pubkey },
|
|
);
|
|
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
await expect(page.getByTestId("channel-unread-dot-random")).toBeVisible();
|
|
await expect
|
|
.poll(() => getSidebarHomeBadgeText(page))
|
|
.toBe(baselineHomeBadge);
|
|
await waitForBadgeState(page, baselineBadge);
|
|
});
|
|
|
|
test("broadcast reply bolds its channel without a trailing numeral", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "random");
|
|
const baselineBadge = await getSettledBadgeState(page);
|
|
|
|
await page.evaluate(
|
|
({ pubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "Broadcast reply to the channel",
|
|
kind: 40002,
|
|
pubkey,
|
|
extraTags: [
|
|
["broadcast", "1"],
|
|
["e", "some-root-event-id"],
|
|
],
|
|
});
|
|
},
|
|
{ pubkey: TEST_IDENTITIES.alice.pubkey },
|
|
);
|
|
|
|
await expect(page.getByTestId("channel-random")).toHaveCSS(
|
|
"font-weight",
|
|
"700",
|
|
);
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
|
|
});
|
|
|
|
test("mark-as-read via context menu clears channel unread indicator", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await waitForMockLiveSubscription(page, "random");
|
|
|
|
// Wait for catch-up to settle, then record baseline badge state
|
|
// (other mock channels may have pre-existing unreads from seeded history)
|
|
await page.waitForTimeout(2000);
|
|
const baselineBadge = await getBadgeState(page);
|
|
|
|
await page.evaluate(
|
|
({ pubkey }) => {
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "Message to be marked read",
|
|
kind: 40002,
|
|
pubkey,
|
|
});
|
|
},
|
|
{ pubkey: TEST_IDENTITIES.alice.pubkey },
|
|
);
|
|
|
|
await expect(page.getByTestId("channel-random")).toHaveCSS(
|
|
"font-weight",
|
|
"700",
|
|
);
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
|
|
await page.getByTestId("channel-random").click({ button: "right" });
|
|
await page.getByText("Mark as read").click();
|
|
|
|
await expect(page.getByTestId("channel-random")).not.toHaveCSS(
|
|
"font-weight",
|
|
"700",
|
|
);
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
await waitForBadgeState(page, baselineBadge);
|
|
});
|
|
|
|
test("mark-as-unread via context menu bolds the channel", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
const baselineBadge = await getSettledBadgeState(page);
|
|
|
|
await page.getByTestId("channel-random").click({ button: "right" });
|
|
await page.getByText("Mark unread").click();
|
|
|
|
await expect(page.getByTestId("channel-random")).toHaveCSS(
|
|
"font-weight",
|
|
"700",
|
|
);
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
|
|
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
|
|
});
|
|
|
|
test("marking a message unread bolds its channel after leaving", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-random").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
|
await waitForMockLiveSubscription(page, "random");
|
|
|
|
const message = await page.evaluate(
|
|
({ pubkey }) =>
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
|
channelName: "random",
|
|
content: "Keep this channel message unread",
|
|
kind: 40002,
|
|
pubkey,
|
|
}),
|
|
{ pubkey: TEST_IDENTITIES.alice.pubkey },
|
|
);
|
|
if (!message) {
|
|
throw new Error("Mock message emitter is unavailable");
|
|
}
|
|
|
|
const messageRow = page
|
|
.getByTestId("message-row")
|
|
.filter({ hasText: "Keep this channel message unread" });
|
|
await expect(messageRow).toBeVisible();
|
|
await messageRow.hover();
|
|
await page.getByTestId(`more-actions-${message.id}`).click();
|
|
const toggle = page.getByTestId(`mark-read-toggle-${message.id}`);
|
|
await expect(toggle).toHaveText("Mark unread");
|
|
await toggle.click();
|
|
|
|
await expect(page.getByTestId("channel-random")).toHaveCSS(
|
|
"font-weight",
|
|
"700",
|
|
);
|
|
await waitForAnimations(page);
|
|
await page.screenshot({
|
|
path: `${SHOTS}/sidebar-active-manual-unread.png`,
|
|
clip: { x: 0, y: 0, width: 320, height: 720 },
|
|
});
|
|
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("channel-random")).toHaveCSS(
|
|
"font-weight",
|
|
"700",
|
|
);
|
|
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
|
|
});
|
|
|
|
test("remote read-state rollback is ignored while local mark-unread still increments badge", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
|
|
// Baseline: random has no unread dot
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
const baselineBadge = await getSettledBadgeState(page);
|
|
|
|
// Wait for ReadStateManager's live subscription (kind:30078) to be
|
|
// established before injecting events.
|
|
await expect
|
|
.poll(async () => {
|
|
return page.evaluate(() => {
|
|
return (
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
|
|
channelName: string;
|
|
kind?: number;
|
|
}) => boolean;
|
|
}
|
|
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
|
channelName: "general",
|
|
kind: 30078,
|
|
}) ?? false
|
|
);
|
|
});
|
|
})
|
|
.toBe(true);
|
|
|
|
const REMOTE_CLIENT_ID = "other-device-client-id";
|
|
const REMOTE_SLOT_ID = "e2e00000000000000000000000000000";
|
|
const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
// Step 1: seed a "read at now" state from the remote device so the
|
|
// local manager has a baseline value for this channel context.
|
|
await page.evaluate(
|
|
({ clientId, slotId, channelId, ts }) => {
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: {
|
|
clientId: string;
|
|
contexts: Record<string, number>;
|
|
createdAt: number;
|
|
slotId: string;
|
|
}) => unknown;
|
|
}
|
|
).__BUZZ_E2E_EMIT_MOCK_READ_STATE__?.({
|
|
clientId,
|
|
slotId,
|
|
contexts: { [channelId]: ts },
|
|
createdAt: ts,
|
|
});
|
|
},
|
|
{
|
|
clientId: REMOTE_CLIENT_ID,
|
|
slotId: REMOTE_SLOT_ID,
|
|
channelId: RANDOM_CHANNEL_ID,
|
|
ts: now,
|
|
},
|
|
);
|
|
|
|
// Step 2: a remote rollback carries an older read timestamp in a newer
|
|
// event. NIP-RS read markers are monotonic, so this must be ignored.
|
|
await page.evaluate(
|
|
({ clientId, slotId, channelId, ts, createdAt }) => {
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: {
|
|
clientId: string;
|
|
contexts: Record<string, number>;
|
|
createdAt: number;
|
|
slotId: string;
|
|
}) => unknown;
|
|
}
|
|
).__BUZZ_E2E_EMIT_MOCK_READ_STATE__?.({
|
|
clientId,
|
|
slotId,
|
|
contexts: { [channelId]: ts },
|
|
createdAt,
|
|
});
|
|
},
|
|
{
|
|
clientId: REMOTE_CLIENT_ID,
|
|
slotId: REMOTE_SLOT_ID,
|
|
channelId: RANDOM_CHANNEL_ID,
|
|
ts: now - 100,
|
|
createdAt: now + 5,
|
|
},
|
|
);
|
|
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
|
|
// Local mark-unread remains an in-session affordance and should still bold
|
|
// the channel immediately without publishing a lower read timestamp.
|
|
await page.getByTestId("channel-random").click({ button: "right" });
|
|
await page.getByText("Mark unread").click();
|
|
await expect(page.getByTestId("channel-random")).toHaveCSS(
|
|
"font-weight",
|
|
"700",
|
|
);
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
|
|
|
|
// Step 3: remote advance clears the local forced-unread dot.
|
|
await page.evaluate(
|
|
({ clientId, slotId, channelId, ts, createdAt }) => {
|
|
(
|
|
window as Window & {
|
|
__BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: {
|
|
clientId: string;
|
|
contexts: Record<string, number>;
|
|
createdAt: number;
|
|
slotId: string;
|
|
}) => unknown;
|
|
}
|
|
).__BUZZ_E2E_EMIT_MOCK_READ_STATE__?.({
|
|
clientId,
|
|
slotId,
|
|
contexts: { [channelId]: ts },
|
|
createdAt,
|
|
});
|
|
},
|
|
{
|
|
clientId: REMOTE_CLIENT_ID,
|
|
slotId: REMOTE_SLOT_ID,
|
|
channelId: RANDOM_CHANNEL_ID,
|
|
ts: now + 10,
|
|
createdAt: now + 10,
|
|
},
|
|
);
|
|
|
|
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
|
});
|