diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index a478bb597c..f5addefd50 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -304,6 +304,7 @@ let sessionLaunchRuntime: ReturnType | null = const pluginServices = createPluginServices({ dataDir, logger, + pluginActiveInProject, getMainWindow, getHost, sendToRenderer, diff --git a/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts b/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts index 199ad72f25..c0d516a958 100644 --- a/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts +++ b/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts @@ -11,6 +11,7 @@ import { PluginPanelHost } from "../plugin-panel-host"; import type { PluginPanelTheme } from "../../shared/plugin-panel-chrome"; import type { IpcRegistrar } from "./types"; import { pluginProviderCatalogEntries } from "../plugin-provider-catalog"; +import { createSessionPushGate } from "../plugin-session-push-gate"; export type PluginUiIpcDependencies = { registrar: IpcRegistrar; @@ -48,7 +49,7 @@ export function registerPluginUiIpc({ * The newest selection the shell reported, so a slower invoke that lands * after a newer one cannot restore a session the user already left. */ - let activeSessionSeq = 0; + const sessionPushGate = createSessionPushGate(); /** * The session on screen, as the shell sees it. * @@ -63,12 +64,14 @@ export function registerPluginUiIpc({ IPC.invoke.pluginSessionActive, async ( payload: - | { sessionId?: string | null; projectPath?: string | null; seq?: number | null } + | { sessionId?: string | null; projectPath?: string | null; seq?: number | null; bootId?: string | null } | null, ) => { const seq = typeof payload?.seq === "number" ? payload.seq : 0; - if (seq > 0 && seq < activeSessionSeq) return { ok: true, ignored: true }; - if (seq > 0) activeSessionSeq = seq; + const bootId = typeof payload?.bootId === "string" ? payload.bootId : null; + if (sessionPushGate({ seq, bootId }).ignored) { + return { ok: true, ignored: true }; + } const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId.trim() : ""; if (!sessionId) { setActivePluginSession(null); diff --git a/apps/desktop/electron/main/plugin-runtime.ts b/apps/desktop/electron/main/plugin-runtime.ts index f98f190a38..fb3e4859ae 100644 --- a/apps/desktop/electron/main/plugin-runtime.ts +++ b/apps/desktop/electron/main/plugin-runtime.ts @@ -1994,8 +1994,9 @@ export class PluginRuntime { * receive the same names through `pluginBridge.on`; this is the process * half (spec 07 §5). */ - broadcastEvent(event: string, args: unknown[] = []): void { + broadcastEvent(event: string, args: unknown[] = [], filter?: (pluginId: string) => boolean): void { for (const loaded of this.loaded.values()) { + if (filter && !filter(loaded.manifest.id)) continue; try { loaded.child?.postMessage({ t: "event", event, args }); } catch (error) { diff --git a/apps/desktop/electron/main/plugin-session-push-gate.ts b/apps/desktop/electron/main/plugin-session-push-gate.ts new file mode 100644 index 0000000000..2e6f03ca28 --- /dev/null +++ b/apps/desktop/electron/main/plugin-session-push-gate.ts @@ -0,0 +1,27 @@ +/** + * The out-of-order gate for plugin session pushes (#1753): within one + * renderer load a slower push must never undo a newer one, and a renderer + * reload (the automatic recovery for render-process-gone) restarts the + * renderer's counter — so a changed boot id resets the high-water mark + * instead of silently dropping every push afterwards. The boot id is per + * renderer load, so with two windows open the ordering guarantee is per + * window; only one window's session is on screen at a time. Kept dependency + * free so `plugin-session-follow.test.mjs` can drive it directly. + */ +export function createSessionPushGate() { + let activeSessionSeq = 0; + let activeSessionBootId: string | null = null; + return (push: { seq: number; bootId?: string | null }): { ok: boolean; ignored?: boolean } => { + if (push.seq > 0) { + if (push.bootId && push.bootId !== activeSessionBootId) { + activeSessionBootId = push.bootId; + activeSessionSeq = push.seq; + } else if (push.seq >= activeSessionSeq) { + activeSessionSeq = push.seq; + } else { + return { ok: true, ignored: true }; + } + } + return { ok: true }; + }; +} diff --git a/apps/desktop/electron/main/plugin-session-scope.ts b/apps/desktop/electron/main/plugin-session-scope.ts new file mode 100644 index 0000000000..0f534fbe62 --- /dev/null +++ b/apps/desktop/electron/main/plugin-session-scope.ts @@ -0,0 +1,28 @@ +/** + * Scope rules for plugin session events (#1754). Selection events carry + * another project's session identity, so their recipients are scope-checked + * against the announced session's project — the same line `session.getActive` + * enforces on the pull side. Kept dependency free so + * `plugin-services.test.mjs` can drive the filter directly. + */ + +/** Session events whose payload describes the user's current selection. */ +export const SESSION_SCOPED_EVENTS = new Set(["session:activated"]); + +/** + * The recipient filter for a session event, or `undefined` when the event + * fans out unconditionally. + */ +export function sessionEventScopeFilter( + event: string, + sessionId: string | undefined, + sessionProjects: ReadonlyMap, + pluginActiveInProject: (pluginId: string, projectPath: string | null | undefined) => boolean, +): ((pluginId: string) => boolean) | undefined { + if (!SESSION_SCOPED_EVENTS.has(event)) return undefined; + return (pluginId: string) => + pluginActiveInProject( + pluginId, + (sessionId && sessionProjects.get(sessionId)) ?? null, + ); +} diff --git a/apps/desktop/electron/main/plugin-view-host.ts b/apps/desktop/electron/main/plugin-view-host.ts index 9375cd97f9..a4976d3d1e 100644 --- a/apps/desktop/electron/main/plugin-view-host.ts +++ b/apps/desktop/electron/main/plugin-view-host.ts @@ -141,9 +141,10 @@ export class PluginViewHost { * are broadcast separately by `PluginPanelHost`; both surfaces share the * preload channel `pi-plugin-panel-event:`. */ - broadcast(event: string, payload: unknown): void { + broadcast(event: string, payload: unknown, filter?: (pluginId: string) => boolean): void { const channel = `pi-plugin-panel-event:${event}`; for (const entry of this.views.values()) { + if (filter && !filter(entry.pluginId)) continue; const wc = entry.view.webContents; if (wc.isDestroyed()) continue; try { diff --git a/apps/desktop/electron/main/services/plugin-services.ts b/apps/desktop/electron/main/services/plugin-services.ts index fea3cec9f5..9fb135f361 100644 --- a/apps/desktop/electron/main/services/plugin-services.ts +++ b/apps/desktop/electron/main/services/plugin-services.ts @@ -48,6 +48,7 @@ import { import { McpOAuthManager } from "../mcp-oauth"; import { PluginPanelHost } from "../plugin-panel-host"; import { PluginViewHost } from "../plugin-view-host"; +import { SESSION_SCOPED_EVENTS, sessionEventScopeFilter } from "../plugin-session-scope"; import { BrowserPane } from "../browser-view"; import { BrowserHost, BROWSER_PLUGIN_ID } from "../browser-host"; import { OAUTH_AUTH_KIND, type VendorOAuth } from "../oauth"; @@ -85,6 +86,12 @@ export type PluginServicesDependencies = { * Scope records live in Main, so this is the one lookup that decides. */ isPluginActiveInProject: (pluginId: string) => boolean; + /** + * Scope check against an explicit project path, so the session-event + * fan-out can hold the same line `session.getActive` does for sessions + * that are not the window's own selection (#1754). + */ + pluginActiveInProject: (pluginId: string, projectPath: string | null | undefined) => boolean; }; /** @@ -92,8 +99,6 @@ export type PluginServicesDependencies = { * detached `ui.panel` window is application-scoped — it does not belong to a * session — so it never receives them; plugin processes and docked views do. */ -const SESSION_SCOPED_EVENTS = new Set(["session:activated"]); - export function createPluginServices({ dataDir, logger, @@ -114,6 +119,7 @@ export function createPluginServices({ vendorOAuth, getActiveTurn, isPluginActiveInProject, + pluginActiveInProject, }: PluginServicesDependencies) { // A plugin request can lose its race with host shutdown or restart. const isHostUnavailable = (error: unknown): boolean => @@ -575,8 +581,19 @@ export function createPluginServices({ const broadcastSessionEvent = (event: string, payload: unknown): void => { const sessionId = (payload as { sessionId?: string } | null | undefined)?.sessionId ?? undefined; + // Selection events carry another project's session identity, so the + // fan-out holds the scope line the pull side (session.getActive, + // ui.revealView) already enforces: a project-scoped plugin does not + // receive a session its scope excludes (#1754). Sessions without a + // project fall back to the workspace-relative check. + const scopeFilter = sessionEventScopeFilter( + event, + sessionId, + sessionProjects, + pluginActiveInProject, + ); try { - plugins.broadcastEvent(event, [payload]); + plugins.broadcastEvent(event, [payload], scopeFilter); } catch (error) { logger.app("plugin", "warn", `${event} plugin broadcast failed`, { sessionId, @@ -594,7 +611,7 @@ export function createPluginServices({ } } try { - pluginViews.broadcast(event, payload); + pluginViews.broadcast(event, payload, scopeFilter); } catch (error) { logger.app("plugin", "warn", `${event} view broadcast failed`, { sessionId, diff --git a/apps/desktop/src/features/app/useAppShellRuntime.tsx b/apps/desktop/src/features/app/useAppShellRuntime.tsx index edc5404816..f7058af029 100644 --- a/apps/desktop/src/features/app/useAppShellRuntime.tsx +++ b/apps/desktop/src/features/app/useAppShellRuntime.tsx @@ -74,6 +74,9 @@ export function useAppShellRuntime() { const projectPath = useAppStore((s) => s.workspace?.path ?? null); /** Monotonic counter for the plugin active-session push. */ const activeSessionSeq = useRef(0); + /** Identifies this renderer load: a reload restarts the counter, and the + * main-side high-water mark must restart with it (#1753). */ + const sessionPushBootId = useRef(crypto.randomUUID()); /** The project of the session on screen, for the plugin session snapshot. */ const activeSessionProjectPath = useAppStore((s) => s.activeSessionId @@ -476,6 +479,7 @@ export function useAppShellRuntime() { sessionId: activeSessionId ?? null, projectPath: activeSessionProjectPath, seq: activeSessionSeq.current, + bootId: sessionPushBootId.current, }) .catch(() => undefined); }, [activeSessionId, activeSessionProjectPath]); diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index a23632a115..85810d88a6 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -684,6 +684,9 @@ export const api = { projectPath?: string | null; /** Renderer-side counter: a slower push must never undo a newer one. */ seq?: number; + /** Identifies the renderer load: a reload restarts the counter, and the + * main-side high-water mark restarts with it (#1753). */ + bootId?: string; }) => invoke<{ ok: boolean }>(IPC.invoke.pluginSessionActive, input), listSessions: () => invoke<{ sessions: SessionSummary[] }>(IPC.invoke.sessionList).then((result) => ({ diff --git a/apps/desktop/test/main-wiring-contract.test.mjs b/apps/desktop/test/main-wiring-contract.test.mjs index f29da9c740..0b04a75444 100644 --- a/apps/desktop/test/main-wiring-contract.test.mjs +++ b/apps/desktop/test/main-wiring-contract.test.mjs @@ -204,9 +204,9 @@ test("a finished turn is announced to every plugin surface", () => { services, services.indexOf("{", services.indexOf("const broadcastSessionEvent")), ); - assert.match(fanOut, /plugins\.broadcastEvent\(event, \[payload\]\)/); + assert.match(fanOut, /plugins\.broadcastEvent\(event, \[payload\], scopeFilter\)/); assert.match(fanOut, /pluginPanels\.broadcast\(event, payload\)/); - assert.match(fanOut, /pluginViews\.broadcast\(event, payload\)/); + assert.match(fanOut, /pluginViews\.broadcast\(event, payload, scopeFilter\)/); // Each surface owns its failure: one unreachable plugin process, panel or // view must not suppress the other two. assert.equal( @@ -228,9 +228,9 @@ test("the selected session is announced to every plugin surface", () => { // The snapshot is announced from the one module that owns it, and reaches the // same three surfaces as a finished turn. assert.match(context, /announce\("session:activated", next\)/); - assert.match(services, /plugins\.broadcastEvent\(event, \[payload\]\)/); + assert.match(services, /plugins\.broadcastEvent\(event, \[payload\], scopeFilter\)/); assert.match(services, /pluginPanels\.broadcast\(event, payload\)/); - assert.match(services, /pluginViews\.broadcast\(event, payload\)/); + assert.match(services, /pluginViews\.broadcast\(event, payload, scopeFilter\)/); // Turn identity and start time come from the host, never from the renderer. assert.match( services, @@ -242,6 +242,25 @@ test("the selected session is announced to every plugin surface", () => { assert.match(root, /pluginServices\.pluginSession\.noteTurnStarted\(sessionId, turnId\)/); }); +test("session activation derives one project filter for process and docked-view delivery", () => { + const services = read("services/plugin-services.ts"); + const fanOut = braceBody( + services, + services.indexOf("{", services.indexOf("const broadcastSessionEvent")), + ); + assert.match( + fanOut, + /sessionEventScopeFilter\(\s*event,\s*sessionId,\s*sessionProjects,\s*pluginActiveInProject,\s*\)/, + ); + assert.match(fanOut, /plugins\.broadcastEvent\(event, \[payload\], scopeFilter\)/); + assert.match(fanOut, /pluginViews\.broadcast\(event, payload, scopeFilter\)/); + assert.match( + fanOut, + /if \(!SESSION_SCOPED_EVENTS\.has\(event\)\) \{[\s\S]*?pluginPanels\.broadcast\(event, payload\)/, + "selection events stay out of detached app-scoped panels", + ); +}); + test("the shell publishes its selection and opens a revealed view", () => { const shell = readFileSync( join(here, "..", "src", "features", "app", "useAppShellRuntime.tsx"), diff --git a/apps/desktop/test/plugin-provider-catalog-ipc.test.mjs b/apps/desktop/test/plugin-provider-catalog-ipc.test.mjs index 34fc89ce3c..b61f6d5fff 100644 --- a/apps/desktop/test/plugin-provider-catalog-ipc.test.mjs +++ b/apps/desktop/test/plugin-provider-catalog-ipc.test.mjs @@ -4,9 +4,10 @@ import test from "node:test"; import ts from "typescript"; import { IPC } from "../../../packages/shared/src/protocol.ts"; import { pluginProviderCatalogEntries } from "../electron/main/plugin-provider-catalog.ts"; +import { createSessionPushGate } from "../electron/main/plugin-session-push-gate.ts"; /** Load the real main-process registration function with unrelated hosts stubbed. */ -function loadPluginUiIpc() { +function loadPluginUiIpc({ gateFactory = () => () => ({ ignored: false }) } = {}) { const file = new URL("../electron/main/ipc/plugin-ui-ipc.ts", import.meta.url); const { outputText } = ts.transpileModule(fs.readFileSync(file, "utf8"), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, @@ -28,6 +29,7 @@ function loadPluginUiIpc() { "../plugin-view-host": { pluginViewKey: (...parts) => parts.join("/") }, "../plugin-panel-host": {}, "../plugin-provider-catalog": { pluginProviderCatalogEntries }, + "../plugin-session-push-gate": { createSessionPushGate: gateFactory }, }; new Function("require", "exports", "module", outputText)( (id) => { @@ -90,3 +92,44 @@ test("the plugin Add Service IPC returns declared categories through the Host ha description: "一句话中文简介。", }]); }); + +test("the plugin session IPC resets ordering on renderer reload and rejects stale pushes", async () => { + const handlers = new Map(); + const activeSessions = []; + loadPluginUiIpc({ gateFactory: createSessionPushGate })({ + registrar: { handle: (channel, handler) => handlers.set(channel, handler) }, + plugins: { listLoaded: () => [] }, + browserHost: {}, + pluginViews: {}, + pluginPanels: {}, + pluginActiveInProject: () => true, + currentWorkspacePath: () => null, + getUpdaterLocale: () => "en", + getPluginPanelTheme: () => ({}), + setActivePluginSession: (session) => activeSessions.push(session), + }); + + const handler = handlers.get(IPC.invoke.pluginSessionActive); + assert.equal(typeof handler, "function", "session-active IPC handler is registered"); + assert.deepEqual( + await handler({ sessionId: "before-reload", projectPath: "/work/a", seq: 8, bootId: "boot-a" }), + { ok: true }, + ); + assert.deepEqual( + await handler({ sessionId: "after-reload", projectPath: "/work/b", seq: 1, bootId: "boot-b" }), + { ok: true }, + ); + assert.deepEqual( + await handler({ sessionId: "after-newer-push", projectPath: "/work/b", seq: 3, bootId: "boot-b" }), + { ok: true }, + ); + assert.deepEqual( + await handler({ sessionId: "stale", projectPath: "/work/a", seq: 2, bootId: "boot-b" }), + { ok: true, ignored: true }, + ); + assert.deepEqual(activeSessions, [ + { sessionId: "before-reload", projectPath: "/work/a" }, + { sessionId: "after-reload", projectPath: "/work/b" }, + { sessionId: "after-newer-push", projectPath: "/work/b" }, + ]); +}); diff --git a/apps/desktop/test/plugin-services.test.mjs b/apps/desktop/test/plugin-services.test.mjs index 1a1a86256b..9a1ead6622 100644 --- a/apps/desktop/test/plugin-services.test.mjs +++ b/apps/desktop/test/plugin-services.test.mjs @@ -522,3 +522,41 @@ test("capability badges include every declared plugin capability", () => { "bus", ]); }); + +test("the session:activated scope filter derives from the announced session's project", async (t) => { + const { sessionEventScopeFilter, SESSION_SCOPED_EVENTS } = await import( + "../electron/main/plugin-session-scope.ts" + ); + const sessionProjects = new Map([["s-a", "/work/a"]]); + const activeInProject = (pluginId, projectPath) => + projectPath === "/work/a" && pluginId === "demo.in-scope"; + + // A selection event produces a filter that keys on the announced session's + // project: the in-scope plugin passes, another project's plugin does not. + const filter = sessionEventScopeFilter( + "session:activated", + "s-a", + sessionProjects, + activeInProject, + ); + assert.equal(typeof filter, "function"); + assert.equal(filter("demo.in-scope"), true); + assert.equal(filter("demo.out-of-scope"), false); + + // A session without a project checks against null, not against whatever + // the map happens to hold for another session. + const noProject = sessionEventScopeFilter( + "session:activated", + "s-global", + sessionProjects, + activeInProject, + ); + assert.equal(noProject("demo.in-scope"), false, "null project is passed through, not the map's value"); + + // Other session events fan out unconditionally. + assert.equal( + sessionEventScopeFilter("session:turnEnded", "s-a", sessionProjects, activeInProject), + undefined, + ); + assert.ok(SESSION_SCOPED_EVENTS.has("session:activated")); +}); diff --git a/apps/desktop/test/plugin-session-follow.test.mjs b/apps/desktop/test/plugin-session-follow.test.mjs index 2160b8caa2..39106521b0 100644 --- a/apps/desktop/test/plugin-session-follow.test.mjs +++ b/apps/desktop/test/plugin-session-follow.test.mjs @@ -322,3 +322,126 @@ test("turn start times stay bounded when a turn ends without a notice", () => { context.setActiveSession({ sessionId: "session-0", projectPath: null }); assert.equal(context.snapshot().activeTurn, null); }); + +test("broadcastEvent's filter keeps an out-of-scope plugin from receiving the session push", async (t) => { + const sent = []; + const spawnWithSpy = (options) => { + const proc = forkPluginProcess(options); + const ownerId = currentId; + const originalPostMessage = proc.postMessage; + proc.postMessage = (message) => { + sent.push({ plugin: ownerId, message }); + originalPostMessage(message); + }; + return proc; + }; + let currentId = "demo.in-scope"; + const runtime = new PluginRuntime({ + hostEntry: hostProcessEntry, + spawnProcess: spawnWithSpy, + isPluginActiveInProject: (pluginId) => pluginId === "demo.in-scope", + }); + t.after(async () => { + for (const loaded of runtime.listLoaded()) await runtime.unload(loaded.manifest.id); + }); + + await runtime.loadFromPath( + writeViewPlugin({ + id: "demo.in-scope", + permissions: ["ui.view"], + views: [CHANGES_VIEW], + main: "module.exports = { async onLoad() {} };", + }), + ["ui.view"], + ); + currentId = "demo.out-of-scope"; + await runtime.loadFromPath( + writeViewPlugin({ + id: "demo.out-of-scope", + permissions: ["ui.view"], + views: [CHANGES_VIEW], + main: "module.exports = { async onLoad() {} };", + }), + ["ui.view"], + ); + + const payload = { sessionId: "s-other", projectPath: "/work/other" }; + runtime.broadcastEvent( + "session:activated", + [payload], + (pluginId) => pluginId === "demo.in-scope", + ); + await new Promise((resolve) => setImmediate(resolve)); + + const events = sent.filter((entry) => entry.message?.event === "session:activated"); + assert.equal(events.length, 1, "exactly the in-scope plugin process received the push"); + assert.equal(events[0].plugin, "demo.in-scope"); + assert.deepEqual(events[0].message.args, [payload]); +}); + +test("the session-push gate restarts with the renderer's boot id and the payload carries it", async () => { + const { readMainSource, readStoreModule } = await import("./helpers/source-contracts.mjs"); + const { readFile } = await import("node:fs/promises"); + const gateSource = await readFile( + new URL("../electron/main/plugin-session-push-gate.ts", import.meta.url), + "utf8", + ); + + // The gate module owns the boot-id reset (#1753): a new renderer boot + // restarts the high-water mark, and within a boot the out-of-order guard + // still ignores older sequences. + assert.match( + gateSource, + /push\.bootId !== activeSessionBootId[\s\S]*?activeSessionSeq = push\.seq;[\s\S]*?return \{ ok: true, ignored: true \};/, + "the gate must reset the high-water mark on a new renderer boot and keep the out-of-order guard within a boot", + ); + + // The production handler gates every push through the shared gate. + const ipcSource = await readMainSource("ipc/plugin-ui-ipc.ts"); + assert.match( + ipcSource, + /const sessionPushGate = createSessionPushGate\(\);[\s\S]*?sessionPushGate\(\{ seq, bootId \}\)\.ignored/, + "plugin-ui-ipc must route session pushes through the shared gate", + ); + + // Renderer: the push carries the boot id the gate compares. + const runtimeSource = await readStoreModule("../../src/features/app/useAppShellRuntime.tsx"); + assert.match( + runtimeSource, + /sessionPushBootId\s*=\s*useRef\(crypto\.randomUUID\(\)\)/, + "the renderer must own a per-load boot id", + ); + assert.match( + runtimeSource, + /bootId:\s*sessionPushBootId\.current/, + "the push must carry the boot id", + ); +}); + +test("the session-push gate accepts a new renderer boot and still rejects an older sequence within it (#1753)", async (t) => { + const { createSessionPushGate } = await import( + "../electron/main/plugin-session-push-gate.ts" + ); + const gate = createSessionPushGate(); + + // Within one boot: in-order accepted, out-of-order ignored. + assert.deepEqual(gate({ seq: 3, bootId: "boot-1" }), { ok: true }); + assert.deepEqual(gate({ seq: 5, bootId: "boot-1" }), { ok: true }); + assert.deepEqual(gate({ seq: 4, bootId: "boot-1" }), { + ok: true, + ignored: true, + }); + + // A reload restarts the renderer counter: the new boot resets the mark and + // the low sequences flow again. + assert.deepEqual(gate({ seq: 1, bootId: "boot-2" }), { ok: true }); + assert.deepEqual(gate({ seq: 2, bootId: "boot-2" }), { ok: true }); + assert.deepEqual(gate({ seq: 1, bootId: "boot-2" }), { + ok: true, + ignored: true, + }); + + // Zero-sequence pushes (no selection) never touch the mark. + assert.deepEqual(gate({ seq: 0, bootId: "boot-2" }), { ok: true }); + assert.deepEqual(gate({ seq: 2, bootId: "boot-2" }), { ok: true }); +}); diff --git a/apps/desktop/test/plugin-work-panel-views.test.mjs b/apps/desktop/test/plugin-work-panel-views.test.mjs index c802ac0f14..c7ed51eaa4 100644 --- a/apps/desktop/test/plugin-work-panel-views.test.mjs +++ b/apps/desktop/test/plugin-work-panel-views.test.mjs @@ -282,7 +282,8 @@ test("opening a different project refreshes the scope-filtered view list", () => }); test("host panel events reach docked views as well as detached windows", () => { - assert.match(viewHostSource, /broadcast\(event: string, payload: unknown\)/); + assert.match(viewHostSource, /broadcast\(event: string, payload: unknown(?:, filter\?:[^)]*)?\)/); + assert.match(viewHostSource, /if \(filter && !filter\(entry\.pluginId\)\) continue/); assert.match(viewHostSource, /pi-plugin-panel-event:\$\{event\}/); assert.match(mainSource, /function broadcastPluginPanelEvent/); assert.match(mainSource, /broadcastPluginPanelEvent\("appearance:changed"/);