345ea1216d
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
228 lines
7.3 KiB
TypeScript
228 lines
7.3 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { pluginRegistry } from "./registry";
|
|
import {
|
|
composeWriterId,
|
|
PLUGIN_USER_STATE_UPDATED_ACTION,
|
|
subscribeToUserStateChanges,
|
|
} from "./user-state-sync";
|
|
|
|
function dispatch(payload: unknown) {
|
|
pluginRegistry
|
|
.getWsHandlers(PLUGIN_USER_STATE_UPDATED_ACTION)
|
|
.forEach((handler) => handler(payload));
|
|
}
|
|
|
|
afterEach(() => {
|
|
pluginRegistry.unregisterPlugin("plugin-a");
|
|
pluginRegistry.unregisterPlugin("plugin-b");
|
|
});
|
|
|
|
describe("subscribeToUserStateChanges", () => {
|
|
it("delivers a matching change for the subscribed plugin", () => {
|
|
const handler = vi.fn();
|
|
subscribeToUserStateChanges("plugin-a", "tab-1", {}, handler);
|
|
|
|
dispatch({
|
|
pluginId: "plugin-a",
|
|
scope: "task",
|
|
scopeId: "task_1",
|
|
key: "note",
|
|
updatedAt: "2026-01-01T00:00:00Z",
|
|
writerId: "tab-2",
|
|
});
|
|
|
|
expect(handler).toHaveBeenCalledWith({
|
|
scope: "task",
|
|
scopeId: "task_1",
|
|
key: "note",
|
|
updatedAt: "2026-01-01T00:00:00Z",
|
|
deleted: undefined,
|
|
});
|
|
});
|
|
|
|
it("ignores notifications for a different plugin (AC25 own-plugin filter)", () => {
|
|
const handler = vi.fn();
|
|
subscribeToUserStateChanges("plugin-a", "tab-1", {}, handler);
|
|
|
|
dispatch({
|
|
pluginId: "plugin-b",
|
|
scope: "task",
|
|
scopeId: "task_1",
|
|
key: "note",
|
|
writerId: "tab-2",
|
|
});
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("suppresses its own tab's echo (AC25)", () => {
|
|
const handler = vi.fn();
|
|
subscribeToUserStateChanges("plugin-a", "tab-1", {}, handler);
|
|
|
|
dispatch({
|
|
pluginId: "plugin-a",
|
|
scope: "task",
|
|
scopeId: "task_1",
|
|
key: "note",
|
|
writerId: "tab-1",
|
|
});
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("filters by scope/scopeId/key when the filter narrows them", () => {
|
|
const handler = vi.fn();
|
|
subscribeToUserStateChanges(
|
|
"plugin-a",
|
|
"tab-1",
|
|
{ scope: "task", scopeId: "task_1", key: "note" },
|
|
handler,
|
|
);
|
|
|
|
dispatch({
|
|
pluginId: "plugin-a",
|
|
scope: "task",
|
|
scopeId: "task_2",
|
|
key: "note",
|
|
writerId: "tab-2",
|
|
});
|
|
expect(handler).not.toHaveBeenCalled();
|
|
|
|
dispatch({
|
|
pluginId: "plugin-a",
|
|
scope: "task",
|
|
scopeId: "task_1",
|
|
key: "note",
|
|
writerId: "tab-2",
|
|
});
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("composeWriterId", () => {
|
|
it("keeps the base id when there is no surface discriminator", () => {
|
|
expect(composeWriterId("tab-1", undefined)).toBe("tab-1");
|
|
});
|
|
|
|
it("uses the same separator and order for surface-scoped ids", () => {
|
|
expect(composeWriterId("tab-1", "panel-xyz")).toBe("tab-1:panel-xyz");
|
|
});
|
|
});
|
|
|
|
describe("subscribeToUserStateChanges — filter.writerId per-surface scoping", () => {
|
|
// Regression test for the cross-surface suppression bug found in review:
|
|
// a task panel (subscribed, its own writes tagged with its panelId) and a
|
|
// kanban menu action (write-only, using the tab-wide default) share one
|
|
// browser tab. Before filter.writerId existed, both looked like the same
|
|
// "tab-1" writer to any subscriber, so the panel's own subscription
|
|
// treated the action's write as its own echo and silently dropped it.
|
|
it("scopes echo suppression to one surface, so a sibling surface's write in the same tab still reaches it", () => {
|
|
const panelHandler = vi.fn();
|
|
// The task panel subscribes under its own panelId, not the tab default.
|
|
subscribeToUserStateChanges("plugin-a", "tab-1", { writerId: "panel-xyz" }, panelHandler);
|
|
|
|
// The kanban action writes under the tab-wide default writerId — it has
|
|
// no ongoing subscription of its own to protect.
|
|
dispatch({
|
|
pluginId: "plugin-a",
|
|
scope: "task",
|
|
scopeId: "task_1",
|
|
key: "note",
|
|
writerId: "tab-1",
|
|
});
|
|
|
|
expect(panelHandler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("still suppresses this surface's own echo, not just the tab default", () => {
|
|
const panelHandler = vi.fn();
|
|
subscribeToUserStateChanges("plugin-a", "tab-1", { writerId: "panel-xyz" }, panelHandler);
|
|
|
|
// The actual comparator is "localWriterId:filter.writerId" (see below) —
|
|
// this panel's own writes are stamped with that same combined id.
|
|
dispatch({
|
|
pluginId: "plugin-a",
|
|
scope: "task",
|
|
scopeId: "task_1",
|
|
key: "note",
|
|
writerId: "tab-1:panel-xyz",
|
|
});
|
|
|
|
expect(panelHandler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Regression test for a bug introduced while fixing the above: a surface
|
|
// id like panelId is a *static* string shared by every browser tab that
|
|
// has that panel open. If filter.writerId fully replaced localWriterId
|
|
// (instead of being combined with it), two different tabs — each with
|
|
// their own random per-tab localWriterId but subscribing under the same
|
|
// panelId — would look like the same writer to each other, and a write
|
|
// in tab A would never reach tab B's subscription (breaking AC24/AC25's
|
|
// actual cross-tab sync, which is what this feature is for).
|
|
it("the same writerId in two different tabs (different localWriterId) does not cross-suppress", () => {
|
|
const tabBHandler = vi.fn();
|
|
// Tab B subscribes under the same panelId as tab A would, but a
|
|
// different tab-unique base — simulating two browser tabs with the
|
|
// same panel open.
|
|
subscribeToUserStateChanges("plugin-a", "tab-B-base", { writerId: "panel-xyz" }, tabBHandler);
|
|
|
|
// Tab A writes under its own combined id (its own tab-unique base,
|
|
// same panelId discriminator).
|
|
dispatch({
|
|
pluginId: "plugin-a",
|
|
scope: "task",
|
|
scopeId: "task_1",
|
|
key: "note",
|
|
writerId: "tab-A-base:panel-xyz",
|
|
});
|
|
|
|
expect(tabBHandler).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("subscribeToUserStateChanges — unsubscribe and revocation", () => {
|
|
it("stops delivering after unsubscribe, without disturbing other subscriptions", () => {
|
|
const handlerA = vi.fn();
|
|
const handlerB = vi.fn();
|
|
const unsubscribeA = subscribeToUserStateChanges("plugin-a", "tab-1", {}, handlerA);
|
|
subscribeToUserStateChanges("plugin-a", "tab-1", {}, handlerB);
|
|
|
|
unsubscribeA();
|
|
dispatch({
|
|
pluginId: "plugin-a",
|
|
scope: "task",
|
|
scopeId: "task_1",
|
|
key: "note",
|
|
writerId: "tab-2",
|
|
});
|
|
|
|
expect(handlerA).not.toHaveBeenCalled();
|
|
expect(handlerB).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// AC26: disabling/uninstalling a plugin bulk-revokes every subscription it
|
|
// holds (via the existing unregisterPlugin path), and re-subscribing after
|
|
// a reload still reaches the registry.
|
|
it("bulk-revokes on unregisterPlugin, and a fresh subscribe after reload works again", () => {
|
|
const handler = vi.fn();
|
|
subscribeToUserStateChanges("plugin-a", "tab-1", {}, handler);
|
|
expect(pluginRegistry.getWsHandlers(PLUGIN_USER_STATE_UPDATED_ACTION)).toHaveLength(1);
|
|
|
|
pluginRegistry.unregisterPlugin("plugin-a");
|
|
expect(pluginRegistry.getWsHandlers(PLUGIN_USER_STATE_UPDATED_ACTION)).toHaveLength(0);
|
|
|
|
const reloadedHandler = vi.fn();
|
|
subscribeToUserStateChanges("plugin-a", "tab-1", {}, reloadedHandler);
|
|
dispatch({
|
|
pluginId: "plugin-a",
|
|
scope: "task",
|
|
scopeId: "task_1",
|
|
key: "note",
|
|
writerId: "tab-2",
|
|
});
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expect(reloadedHandler).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|