diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 76f27b6176d..813b7b968a8 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/mock-subscription-readiness.spec.ts", "**/owned-agent-discovery.spec.ts", "**/thread-head-stale-edit.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b50de75ea82..712b3694b39 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -30,8 +30,13 @@ import { subscribeControlResults, } from "@/features/agents/observerRelayStore"; import { switchManagedAgentModel } from "@/shared/api/agentControl"; +import { getAudioMediaLoadSchedulerSnapshot } from "@/features/messages/lib/audioMediaLoadScheduler"; import { mockSearchHitMatches } from "./e2eBridgeSearch.ts"; import { selectMockHistory } from "./e2eBridgeHistory.ts"; +import { + createMockSubscription, + hasMockSubscription, +} from "./e2eBridgeSubscriptions.ts"; export { mockSearchHitMatches }; import type { ConnectionState } from "@/shared/api/relayClientShared"; import type { @@ -1085,14 +1090,7 @@ type MockManagedAgentRuntimeRow = { type WsHandler = (message: unknown) => void; const GLOBAL_MOCK_SUBSCRIPTION = "*"; -type MockSubscription = { - channelIds: string[]; - kinds: number[] | null; - /** `#p` values from the REQ filters, if any — lets specs assert an - * owner-scoped live subscription (e.g. the observer-archive `24200` - * reconciliation gate) independently of channel-scoped ones. */ - ownerPubkeys: string[]; -}; +type MockSubscription = ReturnType; type MockFilter = { "#a"?: string[]; @@ -1275,6 +1273,7 @@ declare global { __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { channelName: string; kind?: number; + exactChannel?: boolean; }) => boolean; __BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: { ownerPubkey: string; @@ -1609,6 +1608,8 @@ declare global { __BUZZ_E2E_LINK_PREVIEW_UPLOAD_STARTS__?: number; /** Hold renderer-owned media fetches until their cancellation command. */ __BUZZ_E2E_HOLD_MEDIA_FETCHES__?: boolean; + /** Real scheduler ownership, including work not yet admitted to native fetch. */ + __BUZZ_E2E_AUDIO_LOAD_STATE__?: typeof getAudioMediaLoadSchedulerSnapshot; /** Exact active/peak native media-fetch ownership for scheduler tests. */ __BUZZ_E2E_MEDIA_FETCH_STATE__?: { active: number; peak: number }; /** Object-URL lifecycle counters installed by the audio E2E regression. */ @@ -5040,22 +5041,19 @@ function emitMockGlobalEvent(event: RelayEvent) { } } -function hasMockLiveSubscription(channelId: string, kind?: number) { - for (const socket of mockSockets.values()) { - for (const subscription of socket.subscriptions.values()) { - if ( - (subscription.channelIds.includes(channelId) || - subscription.channelIds.includes(GLOBAL_MOCK_SUBSCRIPTION)) && - (kind === undefined || - !subscription.kinds || - subscription.kinds.includes(kind)) - ) { - return true; - } - } - } - - return false; +function hasMockLiveSubscription( + channelId: string, + kind?: number, + exactChannel = false, +) { + return [...mockSockets.values()].some((socket) => + hasMockSubscription( + socket.subscriptions.values(), + channelId, + kind, + exactChannel, + ), + ); } /** @@ -10862,43 +10860,29 @@ function sendToMockSocket(args: { } if (subId.startsWith("live-")) { - // Collect channel IDs from all filters in the REQ - const channelIds = new Set(); - const kinds = new Set(); - const ownerPubkeys = new Set(); - for (const f of filters) { - for (const channelId of f["#h"] ?? []) channelIds.add(channelId); - for (const kind of f.kinds ?? []) { - kinds.add(kind); - } - for (const p of f["#p"] ?? []) { - ownerPubkeys.add(p); - } - } + const subscription = createMockSubscription(filters); const onlyChannelId = - channelIds.size === 1 - ? (channelIds.values().next().value as string) + subscription.channelIds.length === 1 && + subscription.channelIds[0] !== GLOBAL_MOCK_SUBSCRIPTION + ? subscription.channelIds[0] : undefined; if ( getConfig()?.mock?.closeChannelLiveSubscriptionOnce && !mockClosedChannelLiveSubscription && onlyChannelId && - kinds.has(KIND_CHANNEL_THREAD_SUMMARY) + subscription.kinds?.includes(KIND_CHANNEL_THREAD_SUMMARY) ) { mockClosedChannelLiveSubscription = true; sendWsText(socket.handler, ["CLOSED", subId, "rate-limited"]); return; } - socket.subscriptions.set(subId, { - channelIds: - channelIds.size > 0 ? [...channelIds] : [GLOBAL_MOCK_SUBSCRIPTION], - kinds: kinds.size > 0 ? [...kinds] : null, - ownerPubkeys: [...ownerPubkeys], - }); + socket.subscriptions.set(subId, subscription); // Live requests still replay stored matches; pacing can admit them after // a publish. Ephemeral/global fixtures are not channel history. const history = new Map( - [...channelIds].map((id) => [id, getMockMessageStore(id)]), + subscription.channelIds + .filter((id) => id !== GLOBAL_MOCK_SUBSCRIPTION) + .map((id) => [id, getMockMessageStore(id)]), ); for (const event of selectMockHistory(history, filters)) { sendWsText(socket.handler, ["EVENT", subId, event]); @@ -11362,6 +11346,7 @@ export function maybeInstallE2eTauriMocks() { cancelledMediaFetchIds = new Set(); mockMediaFetchControllers = new Map(); window.__BUZZ_E2E_LINK_PREVIEW_UPLOAD_STARTS__ = 0; + window.__BUZZ_E2E_AUDIO_LOAD_STATE__ = getAudioMediaLoadSchedulerSnapshot; window.__BUZZ_E2E_MEDIA_FETCH_STATE__ = { active: 0, peak: 0 }; window.__BUZZ_E2E_RELEASE_LINK_PREVIEW_METADATA__ = () => { const queued = deferredLinkPreviewMetadataQueue.splice(0); @@ -11618,7 +11603,11 @@ export function maybeInstallE2eTauriMocks() { createdAt, ); }; - window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__ = ({ channelName, kind }) => { + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__ = ({ + channelName, + kind, + exactChannel, + }) => { const channel = mockChannels.find( (candidate) => candidate.name === channelName, ); @@ -11626,7 +11615,7 @@ export function maybeInstallE2eTauriMocks() { throw new Error(`Mock channel ${channelName} not found.`); } - return hasMockLiveSubscription(channel.id, kind); + return hasMockLiveSubscription(channel.id, kind, exactChannel); }; window.__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__ = ({ ownerPubkey, diff --git a/desktop/src/testing/e2eBridgeSubscriptions.test.mjs b/desktop/src/testing/e2eBridgeSubscriptions.test.mjs new file mode 100644 index 00000000000..1da8157cf6b --- /dev/null +++ b/desktop/src/testing/e2eBridgeSubscriptions.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createMockSubscription, + hasMockSubscription, +} from "./e2eBridgeSubscriptions.ts"; + +const ready = (filters, channel = "channel", kind = 9, exact = true) => + hasMockSubscription([createMockSubscription(filters)], channel, kind, exact); + +test("channel readiness rejects global-only, wrong-channel and wrong-kind REQs", () => { + for (const filter of [ + { kinds: [9] }, + { "#h": [], kinds: [9] }, + { "#h": ["other"], kinds: [9] }, + { "#h": ["channel"], kinds: [30078] }, + ]) + assert.equal(ready([filter]), false); + assert.equal(hasMockSubscription([], "channel", 9, true), false); + for (const kinds of [[9], [7, 9], undefined]) { + assert.equal(ready([{ "#h": ["channel"], kinds }]), true); + } + const emptyKinds = [ + createMockSubscription([{ "#h": ["channel"], kinds: [] }]), + ]; + assert.equal(hasMockSubscription(emptyKinds, "channel", 9, true), false); + assert.equal( + hasMockSubscription(emptyKinds, "channel", undefined, true), + false, + ); + assert.equal( + ready([ + { "#h": ["channel"], kinds: [] }, + { "#h": ["channel"], kinds: [9] }, + ]), + true, + ); +}); + +test("REQ storage preserves channel/kind correlation across OR filters", () => { + for (const unrelated of [{ kinds: [9] }, { "#h": ["other"], kinds: [9] }]) { + const filters = [{ "#h": ["channel"], kinds: [30078] }, unrelated]; + const stored = createMockSubscription(filters); + assert.deepEqual(stored.filters, filters); + assert.equal(hasMockSubscription([stored], "channel", 9, true), false); + assert.equal(hasMockSubscription([stored], "channel", 30078, true), true); + assert.equal(ready([...filters, { "#h": ["channel"], kinds: [9] }]), true); + } +}); + +test("legacy readiness and explicit global queries retain their semantics", () => { + const global = [createMockSubscription([{ kinds: [30078] }])]; + assert.equal(hasMockSubscription(global, "channel"), true); + assert.equal(hasMockSubscription(global, "channel", 9), false); + assert.equal(hasMockSubscription(global, "channel", 30078), true); + assert.equal(hasMockSubscription(global, "*", 30078), true); + assert.equal(hasMockSubscription(global, "*", 9), false); + assert.equal(hasMockSubscription(global, "channel", 30078, true), false); + assert.equal( + ready( + [{ "#h": ["channel"], kinds: [30078] }, { kinds: [9] }], + "channel", + 9, + false, + ), + true, + ); +}); diff --git a/desktop/src/testing/e2eBridgeSubscriptions.ts b/desktop/src/testing/e2eBridgeSubscriptions.ts new file mode 100644 index 00000000000..41eda44e15f --- /dev/null +++ b/desktop/src/testing/e2eBridgeSubscriptions.ts @@ -0,0 +1,50 @@ +type SubscriptionFilter = { + "#h"?: readonly string[]; + "#p"?: readonly string[]; + kinds?: readonly number[]; +}; + +/** Preserve raw REQ correlation alongside legacy mock delivery projections. */ +export function createMockSubscription(filters: readonly SubscriptionFilter[]) { + const channelIds = [ + ...new Set(filters.flatMap((filter) => filter["#h"] ?? [])), + ]; + const kinds = [...new Set(filters.flatMap((filter) => filter.kinds ?? []))]; + return { + filters, + channelIds: channelIds.length ? channelIds : ["*"], + kinds: kinds.length ? kinds : null, + ownerPubkeys: [...new Set(filters.flatMap((filter) => filter["#p"] ?? []))], + }; +} + +/** Test readiness must distinguish a channel consumer from unrelated global REQs. */ +export function hasMockSubscription( + subscriptions: Iterable>, + channelId: string, + kind?: number, + exactChannel = false, +): boolean { + for (const subscription of subscriptions) { + if (exactChannel) { + if ( + subscription.filters.some( + (filter) => + filter["#h"]?.includes(channelId) && + (filter.kinds === undefined || + (filter.kinds.length > 0 && + (kind === undefined || filter.kinds.includes(kind)))), + ) + ) + return true; + } else if ( + (subscription.channelIds.includes(channelId) || + subscription.channelIds.includes("*")) && + (kind === undefined || + !subscription.kinds || + subscription.kinds.includes(kind)) + ) + return true; + } + return false; +} diff --git a/desktop/tests/e2e/channel-activity-popover.spec.ts b/desktop/tests/e2e/channel-activity-popover.spec.ts index 59d46804615..80279ad5016 100644 --- a/desktop/tests/e2e/channel-activity-popover.spec.ts +++ b/desktop/tests/e2e/channel-activity-popover.spec.ts @@ -28,10 +28,14 @@ async function waitForMockLiveSubscription(page: Page, channelName: string) { window as Window & { __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { channelName: string; + kind: number; + exactChannel: boolean; }) => boolean; } ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: name, + kind: 9, + exactChannel: true, }) ?? false, channelName, ), @@ -184,6 +188,7 @@ async function seedChannelActivity( await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); + await waitForMockLiveSubscription(page, "general"); const unreadAt = Math.floor(Date.now() / 1000) + 60; await emitMockMessage( page, diff --git a/desktop/tests/e2e/channel-mute.spec.ts b/desktop/tests/e2e/channel-mute.spec.ts index 4d181e0033e..06ae69dc588 100644 --- a/desktop/tests/e2e/channel-mute.spec.ts +++ b/desktop/tests/e2e/channel-mute.spec.ts @@ -38,10 +38,15 @@ async function waitForMockLiveSubscription( window as Window & { __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { channelName: string; + kind: number; + exactChannel: boolean; }) => boolean; } - ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ?? - false, + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: ch, + kind: 9, + exactChannel: true, + }) ?? false, { ch: channelName }, ); }) @@ -124,6 +129,7 @@ test.describe("channel muting", () => { await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); + await waitForMockLiveSubscription(page, "engineering"); await page.evaluate( ({ pubkey, mockPubkey }) => { diff --git a/desktop/tests/e2e/inbox-reactions.spec.ts b/desktop/tests/e2e/inbox-reactions.spec.ts index 2385be0f6c3..54bac31a5b3 100644 --- a/desktop/tests/e2e/inbox-reactions.spec.ts +++ b/desktop/tests/e2e/inbox-reactions.spec.ts @@ -122,6 +122,16 @@ test("inbox reaction on a thread-reply mention persists after refetch", async ({ const selectedMessage = page.getByTestId("home-inbox-selected-message"); + // Detail renders from history before the paced background consumer is ready. + // Exercise live delivery only once this channel actually requests kind 7. + await page.waitForFunction(() => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + kind: 7, + exactChannel: true, + }), + ); + // Deliver a live reaction with the real add_reaction wire shape: `e` target, // no `h` channel tag. The Inbox must render it without waiting for another // message or a context refetch. diff --git a/desktop/tests/e2e/mock-subscription-readiness.spec.ts b/desktop/tests/e2e/mock-subscription-readiness.spec.ts new file mode 100644 index 00000000000..5ca5ea7f5b0 --- /dev/null +++ b/desktop/tests/e2e/mock-subscription-readiness.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +test("mock socket REQs preserve exact readiness semantics", async ({ + page, +}) => { + // Keep app-owned channel subscriptions out of the readiness result. The + // bridge is installed, but locked boot never mounts the channel consumers. + await installMockBridge(page, { identityLocked: true }); + await page.goto("/"); + await expect(page.getByTestId("keyring-locked")).toBeVisible(); + + const observed = await page.evaluate(async () => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + const ready = window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__; + if (!invoke || !ready) throw new Error("Mock socket bridge is unavailable"); + const channel = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + const owner = "deadbeef".repeat(8); + const frames: unknown[][] = []; + const id = await invoke("plugin:websocket|connect", { + onMessage: (batch: Array<{ type: string; data?: string }>) => { + for (const frame of batch) { + if (frame.type === "Text" && frame.data) { + frames.push(JSON.parse(frame.data)); + } + } + }, + }); + const subId = "live-readiness-regression"; + const check = (kind: number | undefined = 9, exactChannel = true) => + ready({ channelName: "general", kind, exactChannel }); + const send = (message: unknown[]) => + invoke("plugin:websocket|send", { + id, + message: { type: "Text", data: JSON.stringify(message) }, + }); + const req = async (filters: Array>) => { + frames.length = 0; + await send(["REQ", subId, ...filters]); + if (!frames.some((frame) => frame[0] === "EOSE" && frame[1] === subId)) { + throw new Error(`REQ was not accepted: ${JSON.stringify(frames)}`); + } + return check(); + }; + + try { + const before = check(); + const splitGlobal = await req([ + { "#h": [channel], kinds: [30078] }, + { kinds: [9] }, + ]); + const splitOtherChannel = await req([ + { "#h": [channel], kinds: [30078] }, + { "#h": ["other"], kinds: [9] }, + ]); + const sameFilter = await req([{ "#h": [channel], kinds: [9] }]); + const emptyKinds = await req([ + { "#h": [channel], "#p": [owner], kinds: [] }, + ]); + const omittedKinds = await req([{ "#h": [channel], "#p": [owner] }]); + const globalOnly = await req([{ kinds: [9] }]); + const legacyGlobal = check(9, false); + await send(["CLOSE", subId]); + const afterClose = check(); + return { + before, + splitGlobal, + splitOtherChannel, + sameFilter, + emptyKinds, + omittedKinds, + globalOnly, + legacyGlobal, + afterClose, + }; + } finally { + await invoke("plugin:websocket|disconnect", { id }); + } + }); + + expect(observed).toEqual({ + before: false, + splitGlobal: false, + splitOtherChannel: false, + sameFilter: true, + emptyKinds: false, + omittedKinds: true, + globalOnly: false, + legacyGlobal: true, + afterClose: false, + }); +}); diff --git a/desktop/tests/e2e/voice-note.spec.ts b/desktop/tests/e2e/voice-note.spec.ts index dd8ebc8ff27..eadab504f55 100644 --- a/desktop/tests/e2e/voice-note.spec.ts +++ b/desktop/tests/e2e/voice-note.spec.ts @@ -23,10 +23,14 @@ async function waitForMockLiveSubscription(page: Page, channelName: string) { window as Window & { __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { channelName: string; + kind: number; + exactChannel: boolean; }) => boolean; } ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: currentChannelName, + kind: 9, + exactChannel: true, }) ?? false, channelName, ), @@ -486,10 +490,14 @@ test("hard-caps audio work and cancels active and queued loads on unmount", asyn originalRevoke(url); }; }); - await page.setViewportSize({ width: 1280, height: 400 }); + // Keep all 24 cards in the load margin so the test proves queued cancellation, + // not just the three cards that happened to intersect a short viewport. + await page.setViewportSize({ width: 1280, height: 3000 }); await page.goto("/"); - await page.getByTestId("channel-general").click(); - await waitForMockLiveSubscription(page, "general"); + // Seed offscreen before opening the channel: live injection during initial + // history/scroll settlement can unmount one visible set and load another. + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); await page.evaluate( ({ audioUrl }) => { const emit = ( @@ -498,13 +506,16 @@ test("hard-caps audio work and cancels active and queued loads on unmount", asyn channelName: string; content: string; extraTags: string[][]; + createdAt: number; }) => unknown; } ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__; if (!emit) throw new Error("Mock message emitter is unavailable."); + const createdAt = Math.floor(Date.now() / 1000); for (let index = 0; index < 24; index += 1) { emit({ channelName: "general", + createdAt: createdAt + index, content: `[voice-note-${index}.mp4](${audioUrl})`, extraTags: [ [ @@ -521,6 +532,7 @@ test("hard-caps audio work and cancels active and queued loads on unmount", asyn { audioUrl: AUDIO_URL }, ); + await page.getByTestId("channel-general").click(); const cards = page.getByTestId("audio-message-attachment"); await expect(cards).toHaveCount(24); const readFetchCount = () => @@ -537,6 +549,9 @@ test("hard-caps audio work and cancels active and queued loads on unmount", asyn ), ) .toEqual({ active: 3, peak: 3 }); + await expect + .poll(() => page.evaluate(() => window.__BUZZ_E2E_AUDIO_LOAD_STATE__?.())) + .toEqual({ active: 3, queued: 21 }); expect(await readFetchCount()).toBe(3); expect( await page.evaluate( @@ -551,6 +566,9 @@ test("hard-caps audio work and cancels active and queued loads on unmount", asyn page.evaluate(() => window.__BUZZ_E2E_MEDIA_FETCH_STATE__?.active ?? -1), ) .toBe(0); + await expect + .poll(() => page.evaluate(() => window.__BUZZ_E2E_AUDIO_LOAD_STATE__?.())) + .toEqual({ active: 0, queued: 0 }); const commandCounts = await page.evaluate(() => { const commands = window.__BUZZ_E2E_COMMANDS__ ?? []; return {