diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a9cc036..6ee5cad 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -137,7 +137,9 @@ reinjection. The notification controller is headless. The optional Shadow DOM renderer supplies the default light/dark theme, typed CSS-variable customization and runtime theme updates without rebuilding controls. Hosts choose branding, placement and approval callbacks. A host using Devframe -notifications uses its message API instead of injecting the default renderer. +notifications uses its message API instead of injecting the default renderer. The panel host owns +each shared Devframe notification. Viewing tabs own matching local command registrations, keeping +approval in the clicked tab while avoiding duplicate messages across connected tabs. ### Automation providers diff --git a/packages/devframe/README.md b/packages/devframe/README.md index 87a9b23..1cbbd0b 100644 --- a/packages/devframe/README.md +++ b/packages/devframe/README.md @@ -43,7 +43,7 @@ const panel = createCdbPanel({ }); ``` -The renderer must support Devframe's JSON view and action contracts. Component overrides apply only to that mounted renderer; the standalone SPA uses the reference catalogue. Dock ordering follows the host's categories and saved preferences. The page script remains available through `dock.clientScript` for host notifications and review intents. +The renderer must support Devframe's JSON view and action contracts. Component overrides apply only to that mounted renderer; the standalone SPA uses the reference catalogue. Dock ordering follows the host's categories and saved preferences. The page script remains available through `dock.clientScript` for tab-local notification commands and review intents. For an existing application container, import `mountBrowserControlPanel` from `@dvcol/cdb-devframe/panel` and pass a `client`, `container`, optional branding/CSS and an `onReview` callback. Unmounting releases the panel subscription without disposing that client. @@ -53,7 +53,9 @@ Hosts with a direct approval channel can set `approvalAction: 'accept'` on `crea See the [runnable example](../../examples/devframe/README.md). -Notification descriptions and approved-tab counts update through the existing Devframe message handle. Changed content retains its message ID and follows the host's normal notification behavior, including resurfacing a dismissed toast. Unchanged broker publications do not update the message. Request completion, expiry and disposal remove its message and command. +The panel host publishes one notification per request or approved scope into Devframe’s shared message feed. Each viewing tab registers the matching command locally, so approval executes in the tab where the user clicks it. Closing a tab releases its command registrations without removing the shared message. + +Notification descriptions and approved-tab counts update through the existing Devframe message handle. Changed content retains its message ID and follows the host's normal notification behavior, including resurfacing a dismissed toast. Unchanged broker publications do not update the message. Request completion, expiry and panel-host disposal remove the shared message. Each page removes its command when its subscription reports that the request or scope ended. The public validation workspace backports Devframe's toast-removal fix to hub-ui 0.9.10 using [a temporary pnpm patch](https://github.com/dvcol/chrome-debugger-bridge/blob/main/patches/README.md). This workspace patch is not inherited by consumers of the published CDB package; embedding applications using that hub-ui version must apply the patch themselves until adopting an upstream version containing the fix. diff --git a/packages/devframe/src/notification-items.ts b/packages/devframe/src/notification-items.ts new file mode 100644 index 0000000..76c88bd --- /dev/null +++ b/packages/devframe/src/notification-items.ts @@ -0,0 +1,39 @@ +import type { BrowserControlNotification } from '@dvcol/cdb-extension/notifications'; + +export interface BrowserControlNotificationItem { + readonly id: string; + readonly requestId: string; + readonly kind: 'request' | 'grant'; + readonly title: string; + readonly description: string; + readonly label: string; +} + +/** Shared message IDs resolve to a command installed independently in each viewing tab. */ +export function browserControlNotificationItems(state: BrowserControlNotification, approvalAction: 'review' | 'accept' = 'review'): BrowserControlNotificationItem[] { + const items: BrowserControlNotificationItem[] = state.requests.map(request => ({ + id: `cdb:browser-control:request:${request.id}`, + requestId: request.id, + kind: 'request', + title: 'Browser control requested', + description: `${request.principalLabel} requests ${request.level} access with ${request.navigation} navigation.`, + label: approvalAction === 'accept' ? 'Accept' : 'Review request', + })); + for (const [requestId, grants] of Map.groupBy(state.grants, grant => grant.requestId)) { + const grant = grants[0]!; + const tabLabel = grants.length === 1 ? 'tab' : 'tabs'; + items.push({ + id: `cdb:browser-control:grant:${requestId}`, + requestId, + kind: 'grant', + title: 'Browser control active', + description: `${grant.principalLabel}: ${grant.level} access to ${grants.length} approved ${tabLabel}.`, + label: 'Stop control', + }); + } + return items; +} + +export interface BrowserControlMessages { + info: (message: string, options: { id: string; description: string; notify: boolean; autoDismiss: false; actions: { id: string; label: string; kind: 'command'; command: { id: string } }[] }) => Promise<{ dismiss: () => Promise; update: (patch: { description: string }) => Promise }>; +} diff --git a/packages/devframe/src/notification-publisher.ts b/packages/devframe/src/notification-publisher.ts new file mode 100644 index 0000000..fa2d0d7 --- /dev/null +++ b/packages/devframe/src/notification-publisher.ts @@ -0,0 +1,73 @@ +import type { BrowserControlNotificationController } from '@dvcol/cdb-extension/notifications'; + +import type { BrowserControlMessages, BrowserControlNotificationItem } from './notification-items.js'; + +import { styleText } from 'node:util'; + +import { browserControlNotificationItems } from './notification-items.js'; + +/** The panel host owns shared messages; page clients own only their local command registrations. */ +export function publishBrowserControlNotifications(controller: BrowserControlNotificationController, messages: BrowserControlMessages, approvalAction: 'review' | 'accept' = 'review'): () => void { + const notifications = new Map void; remove: () => void }>(); + const pending = new Map>(); + + /** Keep removal ordered before re-creation of the same ID during connection replacement. */ + function enqueue(id: string, operation: () => Promise): void { + const next = (pending.get(id) ?? Promise.resolve()).then(operation).catch((error) => { + console.error(styleText('red', '❌ [cdb]'), 'Unable to publish browser-control notification.', error); + }); + pending.set(id, next); + void next.then(() => { + if (pending.get(id) === next) pending.delete(id); + }); + } + + function createNotification(item: BrowserControlNotificationItem): void { + let active = true; + let handle: Awaited> | undefined; + enqueue(item.id, async () => { + if (!active) return; + handle = await messages.info(item.title, { id: item.id, description: item.description, notify: true, autoDismiss: false, actions: [{ id: 'control', label: item.label, kind: 'command', command: { id: item.id } }] }); + }); + notifications.set(item.id, { + description: item.description, + update(description) { + enqueue(item.id, async () => { + if (active) await handle?.update({ description }); + }); + }, + remove() { + active = false; + enqueue(item.id, async () => { + await handle?.dismiss(); + }); + }, + }); + } + + const unsubscribe = controller.subscribe((state) => { + const items = browserControlNotificationItems(state, approvalAction); + const activeIds = new Set(items.map(item => item.id)); + for (const [id, notification] of notifications) { + if (activeIds.has(id)) continue; + notification.remove(); + notifications.delete(id); + } + for (const item of items) { + const notification = notifications.get(item.id); + if (notification === undefined) { + createNotification(item); + continue; + } + if (notification.description === item.description) continue; + notification.description = item.description; + notification.update(item.description); + } + }); + + return function dispose(): void { + unsubscribe(); + for (const notification of notifications.values()) notification.remove(); + notifications.clear(); + }; +} diff --git a/packages/devframe/src/page-script.ts b/packages/devframe/src/page-script.ts index 7259925..78600a8 100644 --- a/packages/devframe/src/page-script.ts +++ b/packages/devframe/src/page-script.ts @@ -1,7 +1,10 @@ import type { DevframeRpcClient } from 'devframe/client'; +import type { BrowserControlMessages } from './notification-items.js'; + import { createBrowserControlNotificationController } from '@dvcol/cdb-extension/notifications'; +import { browserControlNotificationItems } from './notification-items.js'; import { createBrowserControlPanelClient } from './panel.js'; /** Page events request the host's final approval UI. They never approve browser access. */ @@ -26,9 +29,8 @@ export interface BrowserControlPageContext { readonly commands: { register: (command: { id: string; title: string; source: 'client'; action: () => Promise; showInPalette: boolean }) => () => void; }; - readonly messages: { - info: (message: string, options: { id: string; description: string; notify: boolean; autoDismiss: false; actions: { id: string; label: string; kind: 'command'; command: { id: string } }[] }) => Promise<{ dismiss: () => Promise; update: (patch: { description: string }) => Promise }>; - }; + /** Retained for compatibility with existing page hosts; messages are published by the panel host. */ + readonly messages: BrowserControlMessages; } /** Uses the hub's existing page connection; the embedding extension handles the review intent. */ @@ -42,46 +44,23 @@ export default async function setupBrowserControlPage(context: BrowserControlPag window.dispatchEvent(new CustomEvent(options.approvalAction === 'accept' ? browserControlAcceptEvent : browserControlReviewEvent, { detail: { requestId } })); }; const controller = createBrowserControlNotificationController({ onReview: request => review(request.id), onRevoke: async requestId => client.revokeScope(requestId) }); - const notifications = new Map void; remove: () => void }>(); - const prefix = `cdb:browser-control:${crypto.randomUUID()}`; + const commands = new Map void>(); const stopNotifications = controller.subscribe((state) => { - const items = [ - ...state.requests.map(request => ({ id: `request:${request.id}`, title: 'Browser control requested', description: `${request.principalLabel} requests ${request.level} access with ${request.navigation} navigation.`, label: options.approvalAction === 'accept' ? 'Accept' : 'Review request', action: async () => controller.review(request.id) })), - ...Array.from(Map.groupBy(state.grants, grant => grant.requestId), ([requestId, grants]) => ({ id: `grant:${requestId}`, title: 'Browser control active', description: `${grants[0]!.principalLabel}: ${grants[0]!.level} access to ${grants.length} approved ${grants.length === 1 ? 'tab' : 'tabs'}.`, label: 'Stop control', action: async () => controller.revoke(requestId) })), - ]; - for (const [id, notification] of notifications) { - if (items.some(item => item.id === id)) continue; - notification.remove(); - notifications.delete(id); + const items = browserControlNotificationItems(state, options.approvalAction); + const activeIds = new Set(items.map(item => item.id)); + for (const [id, unregister] of commands) { + if (activeIds.has(id)) continue; + unregister(); + commands.delete(id); } for (const item of items) { - const notification = notifications.get(item.id); - if (notification !== undefined) { - if (notification.description !== item.description) { - notification.description = item.description; - notification.update(item.description); - } - continue; + if (commands.has(item.id)) continue; + async function invoke(): Promise { + if (item.kind === 'request') return controller.review(item.requestId); + await controller.revoke(item.requestId); } - const id = `${prefix}:${item.id}`; - const unregister = context.commands.register({ id, title: item.label, source: 'client', action: item.action, showInPalette: false }); - const message = context.messages.info(item.title, { id, description: item.description, notify: true, autoDismiss: false, actions: [{ id: 'control', label: item.label, kind: 'command', command: { id } }] }).catch(error => console.error('Unable to show browser-control notification.', error)); - let active = true; - let pending = Promise.resolve(); - notifications.set(item.id, { - description: item.description, - update(description) { - pending = pending.then(async () => { - const handle = await message; - if (active) await handle?.update({ description }); - }).catch(error => console.error('Unable to update browser-control notification.', error)); - }, - remove() { - active = false; - unregister(); - pending = pending.then(async () => (await message)?.dismiss()).catch(error => console.error('Unable to dismiss browser-control notification.', error)); - }, - }); + const unregister = context.commands.register({ id: item.id, title: item.label, source: 'client', action: invoke, showInPalette: false }); + commands.set(item.id, unregister); } }); const receive = (event: MessageEvent): void => { @@ -98,8 +77,8 @@ export default async function setupBrowserControlPage(context: BrowserControlPag stopWatching?.(); document.documentElement.removeAttribute('data-cdb-notifications-ready'); stopNotifications(); - for (const notification of notifications.values()) notification.remove(); - notifications.clear(); + for (const unregister of commands.values()) unregister(); + commands.clear(); controller.dispose(); window.removeEventListener('message', receive); window.removeEventListener('pagehide', dispose); diff --git a/packages/devframe/src/panel-definition.ts b/packages/devframe/src/panel-definition.ts index d42cb4e..e4364df 100644 --- a/packages/devframe/src/panel-definition.ts +++ b/packages/devframe/src/panel-definition.ts @@ -3,6 +3,7 @@ import type { JsonRenderView } from '@devframes/json-render'; import type { BrokerState } from '@dvcol/cdb-broker/contract'; import type { DevframeDefinition, DevframeNodeContext, DevframeScopedNodeRpc } from 'devframe'; +import type { BrowserControlMessages } from './notification-items.js'; import type { BrowserControlPanelComponents } from './panel-view.js'; import type { BrowserControlPanelClient } from './panel.js'; @@ -12,12 +13,16 @@ import { fileURLToPath } from 'node:url'; import { jsonRenderSpaDir } from '@devframes/json-render-ui/spa'; import { toJsonRenderDockEntry } from '@devframes/json-render/hub'; import { createJsonRenderView } from '@devframes/json-render/node'; +import { createBrowserControlNotificationController } from '@dvcol/cdb-extension/notifications'; import packageManifest from '../package.json' with { type: 'json' }; +import { publishBrowserControlNotifications } from './notification-publisher.js'; import { buildBrowserControlPanelView } from './panel-view.js'; /** Hosts may augment their RPC catalogue without exporting those declarations to CDB. */ interface PanelContext { + /** Hub hosts supply their shared message feed. Plain view hosts may omit notifications. */ + readonly messages?: BrowserControlMessages; rpc: { sharedState: Pick }; scope: (namespace: string) => { readonly rpc: Pick & { register: (definition: Pick[0], 'name' | 'type' | 'handler'>) => unknown; @@ -49,6 +54,13 @@ export interface CdbPanel { export function createCdbPanel(options: CdbPanelOptions): CdbPanel { const directory = dirname(fileURLToPath(import.meta.url)); let disposed = false; + let stopNotifications: (() => void) | undefined; + const notifications = createBrowserControlNotificationController({ + onReview() { + throw new Error('Browser approval must run in the viewing tab.'); + }, + onRevoke: async requestId => activeClient().revokeScope(requestId), + }); let unsubscribe: (() => void) | undefined; let view: JsonRenderView | undefined; let client: BrowserControlPanelClient | undefined; @@ -81,6 +93,7 @@ export function createCdbPanel(options: CdbPanelOptions): CdbPanel { client = options.client(); clientResolved = true; } + if (context.messages !== undefined) stopNotifications = publishBrowserControlNotifications(notifications, context.messages, options.approvalAction); const renderer = 'docks' in context ? options.renderer : undefined; const rpc = context.scope('cdb:panel').rpc; const initial = emptyState(); @@ -103,6 +116,7 @@ export function createCdbPanel(options: CdbPanelOptions): CdbPanel { value.available = selected !== undefined; }); view?.update(buildBrowserControlPanelView(broker, renderer?.components)); + notifications.update(broker); }; publish(emptyState()); if (selected === undefined) return; @@ -145,6 +159,9 @@ export function createCdbPanel(options: CdbPanelOptions): CdbPanel { dispose() { disposed = true; generation += 1; + stopNotifications?.(); + stopNotifications = undefined; + notifications.dispose(); unsubscribe?.(); unsubscribe = undefined; view?.dispose(); diff --git a/packages/devframe/test/notification-ownership.test.ts b/packages/devframe/test/notification-ownership.test.ts new file mode 100644 index 0000000..599c115 --- /dev/null +++ b/packages/devframe/test/notification-ownership.test.ts @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +import type { BrokerState } from '@dvcol/cdb-broker/contract'; + +import type { BrowserControlPageContext } from '../src/page-script.js'; +import type { BrowserControlPanelClient } from '../src/panel.js'; + +import { expect, it, vi } from 'vitest'; + +import { setupBrowserControlAcceptPage } from '../src/page-script.js'; +import { createCdbPanel } from '../src/panel-definition.js'; +import * as panel from '../src/panel.js'; + +vi.mock('@devframes/json-render/node', () => ({ createJsonRenderView: () => ({ update: vi.fn(), dispose: vi.fn() }) })); + +it('publishes one shared notification with an action in both clients and keeps it when either client leaves', async () => { + expect.assertions(10); + const state: BrokerState = { revision: 1, providers: [], principals: [], targets: [], grants: [], scopes: [], leases: [], requests: [{ id: 'request', principalId: 'principal', principalLabel: 'Agent', level: 'interact', navigation: 'same-origin', state: 'pending', createdAt: 0, expiresAt: null }] }; + const listeners = new Set<(state: BrokerState) => void>(); + const client: BrowserControlPanelClient = { + snapshot: () => state, + watch(listener) { + listeners.add(listener); + listener(state); + return () => listeners.delete(listener); + }, + revokeScope: async () => {}, + revokeGrant: async () => true, + disconnectProvider: async () => true, + }; + const mock = vi.spyOn(panel, 'createBrowserControlPanelClient').mockReturnValue(client); + const dismiss = vi.fn(async () => {}); + const info = vi.fn(async () => ({ dismiss, update: vi.fn(async () => {}) })); + const messages = { info }; + const host = createCdbPanel({ client: () => client, approvalAction: 'accept' }); + const context = { + messages, + rpc: { sharedState: { get: vi.fn() } }, + scope: () => ({ rpc: { + register: vi.fn(), + sharedState: async (_key: string, options: { initialValue: unknown }) => ({ mutate: (mutate: (value: unknown) => void) => mutate(options.initialValue) }), + } }), + } as unknown as Parameters[0]; + const firstUnregister = vi.fn(); + const secondUnregister = vi.fn(); + const firstRegister = vi.fn(() => firstUnregister); + const secondRegister = vi.fn(() => secondUnregister); + const dispatch = vi.spyOn(window, 'dispatchEvent'); + await host.definition.setup(context); + const common = { rpc: {} as BrowserControlPageContext['rpc'], current: { domElements: {} }, messages }; + const disposeFirst = await setupBrowserControlAcceptPage({ ...common, commands: { register: firstRegister } }); + const disposeSecond = await setupBrowserControlAcceptPage({ ...common, commands: { register: secondRegister } }); + try { + expect(info).toHaveBeenCalledOnce(); + const commandId = info.mock.calls[0]![1].actions[0]!.command.id; + expect(firstRegister.mock.calls[0]![0].id).toBe(commandId); + expect(secondRegister.mock.calls[0]![0].id).toBe(commandId); + disposeFirst(); + await Promise.resolve(); + expect(firstUnregister).toHaveBeenCalledOnce(); + expect(secondUnregister).not.toHaveBeenCalled(); + expect(dismiss).not.toHaveBeenCalled(); + await secondRegister.mock.calls[0]![0].action(); + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'cdb:accept-request', detail: { requestId: 'request' } })); + for (const listener of listeners) listener({ ...state, requests: [] }); + await vi.waitUntil(() => dismiss.mock.calls.length === 1); + expect(secondUnregister).toHaveBeenCalledOnce(); + expect(dismiss).toHaveBeenCalledOnce(); + await expect(secondRegister.mock.calls[0]![0].action()).rejects.toThrow('no longer pending'); + } finally { + disposeFirst(); + disposeSecond(); + host.dispose(); + mock.mockRestore(); + dispatch.mockRestore(); + } +}); diff --git a/packages/devframe/test/notification-publisher.test.ts b/packages/devframe/test/notification-publisher.test.ts new file mode 100644 index 0000000..f275ce1 --- /dev/null +++ b/packages/devframe/test/notification-publisher.test.ts @@ -0,0 +1,148 @@ +import type { BrokerState } from '@dvcol/cdb-broker/contract'; + +import { createBrowserControlNotificationController } from '@dvcol/cdb-extension/notifications'; +import { expect, it, vi } from 'vitest'; + +import { publishBrowserControlNotifications } from '../src/notification-publisher.js'; + +async function notificationFixture(message = { dismiss: vi.fn(async () => {}), update: vi.fn(async (_patch: { description: string }) => {}) }, creation?: Promise) { + const state: BrokerState = { revision: 1, providers: [], principals: [], targets: [], grants: [], scopes: [], leases: [], requests: [{ id: 'request', principalId: 'principal', principalLabel: 'Agent', level: 'interact', navigation: 'same-origin', state: 'pending', createdAt: 0, expiresAt: null }] }; + const controller = createBrowserControlNotificationController({ onReview: vi.fn(), onRevoke: vi.fn(async () => {}) }); + const info = vi.fn(async () => creation ?? message); + const stopPublishing = publishBrowserControlNotifications(controller, { info }); + controller.update(state); + await Promise.resolve(); + return { + state, + publish: (next: BrokerState) => controller.update(next), + message, + info, + dispose() { + stopPublishing(); + controller.dispose(); + }, + }; +} + +it('updates changed descriptions through the existing message without registering another command', async () => { + expect.assertions(3); + const fixture = await notificationFixture(); + try { + fixture.publish({ ...fixture.state, revision: 2 }); + expect(fixture.message.update).not.toHaveBeenCalled(); + fixture.publish({ ...fixture.state, requests: [{ ...fixture.state.requests[0]!, principalLabel: 'Renamed agent' }] }); + await vi.waitUntil(() => fixture.message.update.mock.calls.length === 1); + expect(fixture.message.update).toHaveBeenCalledWith({ description: 'Renamed agent requests interact access with same-origin navigation.' }); + expect(fixture.info).toHaveBeenCalledOnce(); + } finally { + fixture.dispose(); + } +}); + +it('updates overlapping-grant tab counts without notifying unchanged publications', async () => { + expect.assertions(3); + const fixture = await notificationFixture(); + const grant = { id: 'grant', requestId: 'request', principalId: 'principal', principalLabel: 'Agent', providerId: 'provider', targetId: 'one', targetGeneration: 1, level: 'interact' as const, navigation: 'same-origin' as const, approvedOrigin: 'https://example.test', createdAt: 0, state: 'active' as const }; + try { + fixture.publish({ ...fixture.state, requests: [], grants: [grant] }); + fixture.publish({ ...fixture.state, requests: [], grants: [grant, { ...grant, id: 'second', targetId: 'two' }] }); + await vi.waitUntil(() => fixture.message.update.mock.calls.length === 1); + expect(fixture.message.update).toHaveBeenLastCalledWith({ description: 'Agent: interact access to 2 approved tabs.' }); + fixture.publish({ ...fixture.state, revision: 3, requests: [], grants: [grant, { ...grant, id: 'second', targetId: 'two' }] }); + await Promise.resolve(); + expect(fixture.message.update).toHaveBeenCalledOnce(); + expect(fixture.info).toHaveBeenCalledTimes(2); + } finally { + fixture.dispose(); + } +}); + +it('serializes updates and dismisses after a pending update without publishing queued stale data', async () => { + expect.assertions(4); + const pending = Promise.withResolvers(); + const update = vi.fn(async (_patch: { description: string }) => pending.promise); + const fixture = await notificationFixture({ update, dismiss: vi.fn(async () => {}) }); + try { + fixture.publish({ ...fixture.state, requests: [{ ...fixture.state.requests[0]!, principalLabel: 'First' }] }); + await vi.waitUntil(() => update.mock.calls.length === 1); + fixture.publish({ ...fixture.state, requests: [{ ...fixture.state.requests[0]!, principalLabel: 'Second' }] }); + await Promise.resolve(); + expect(update).toHaveBeenCalledOnce(); + fixture.publish({ ...fixture.state, requests: [] }); + expect(fixture.message.dismiss).not.toHaveBeenCalled(); + pending.resolve(); + await vi.waitUntil(() => fixture.message.dismiss.mock.calls.length === 1); + expect(update).toHaveBeenCalledOnce(); + expect(fixture.message.dismiss).toHaveBeenCalledOnce(); + } finally { + pending.resolve(); + fixture.dispose(); + } +}); + +it.each(['expiry', 'disposal'] as const)('cleans up delayed message creation after %s', async (ending) => { + expect.assertions(2); + const message = { dismiss: vi.fn(async () => {}), update: vi.fn(async (_patch: { description: string }) => {}) }; + const creation = Promise.withResolvers(); + const fixture = await notificationFixture(message, creation.promise); + try { + if (ending === 'expiry') fixture.publish({ ...fixture.state, requests: [{ ...fixture.state.requests[0]!, expiresAt: 1 }] }); + else fixture.dispose(); + creation.resolve(message); + await vi.waitUntil(() => message.dismiss.mock.calls.length === 1); + expect(message.dismiss).toHaveBeenCalledOnce(); + expect(message.update).not.toHaveBeenCalled(); + } finally { + fixture.dispose(); + } +}); + +it('continues message updates after a rejected update and still removes the message', async () => { + expect.assertions(3); + const fixture = await notificationFixture(); + const failure = new Error('Host update failed'); + fixture.message.update.mockRejectedValueOnce(failure); + const report = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + fixture.publish({ ...fixture.state, requests: [{ ...fixture.state.requests[0]!, principalLabel: 'First' }] }); + await vi.waitUntil(() => report.mock.calls.length === 1); + fixture.publish({ ...fixture.state, requests: [{ ...fixture.state.requests[0]!, principalLabel: 'Second' }] }); + await vi.waitUntil(() => fixture.message.update.mock.calls.length === 2); + expect(fixture.message.update).toHaveBeenLastCalledWith({ description: 'Second requests interact access with same-origin navigation.' }); + fixture.publish({ ...fixture.state, requests: [] }); + await vi.waitUntil(() => fixture.message.dismiss.mock.calls.length === 1); + expect(fixture.message.dismiss).toHaveBeenCalledOnce(); + expect(report).toHaveBeenCalledWith(expect.any(String), 'Unable to publish browser-control notification.', failure); + } finally { + fixture.dispose(); + report.mockRestore(); + } +}); + +it('finishes old updates and removal before recreating the same message after reconnect', async () => { + expect.assertions(5); + const pending = Promise.withResolvers(); + const update = vi.fn(async (_patch: { description: string }) => pending.promise); + const fixture = await notificationFixture({ update, dismiss: vi.fn(async () => {}) }); + const replacement = { dismiss: vi.fn(async () => {}), update: vi.fn(async () => {}) }; + try { + fixture.publish({ ...fixture.state, requests: [{ ...fixture.state.requests[0]!, principalLabel: 'Changed' }] }); + await vi.waitUntil(() => update.mock.calls.length === 1); + fixture.publish({ ...fixture.state, requests: [] }); + fixture.info.mockResolvedValueOnce(replacement); + fixture.publish(fixture.state); + await Promise.resolve(); + expect(fixture.info).toHaveBeenCalledOnce(); + pending.resolve(); + await vi.waitUntil(() => fixture.info.mock.calls.length === 2); + expect(fixture.message.dismiss).toHaveBeenCalledOnce(); + expect(fixture.message.dismiss.mock.invocationCallOrder[0]).toBeLessThan(fixture.info.mock.invocationCallOrder[1]!); + expect(replacement.dismiss).not.toHaveBeenCalled(); + fixture.dispose(); + await vi.waitUntil(() => replacement.dismiss.mock.calls.length === 1); + expect(replacement.dismiss).toHaveBeenCalledOnce(); + } finally { + pending.resolve(); + fixture.dispose(); + } +}); diff --git a/packages/devframe/test/page-script.test.ts b/packages/devframe/test/page-script.test.ts index 9209d6b..89bf944 100644 --- a/packages/devframe/test/page-script.test.ts +++ b/packages/devframe/test/page-script.test.ts @@ -8,8 +8,8 @@ import { expect, it, vi } from 'vitest'; import setupBrowserControlPage, { setupBrowserControlAcceptPage } from '../src/page-script.js'; import * as panel from '../src/panel.js'; -it.each(['review', 'accept'] as const)('uses native hub notifications with %s and removes ended requests', async (approvalAction) => { - expect.assertions(9); +it.each(['review', 'accept'] as const)('registers local %s commands without publishing shared messages', async (approvalAction) => { + expect.assertions(8); const state: BrokerState = { revision: 1, providers: [], principals: [], targets: [], grants: [], scopes: [], leases: [], requests: [{ id: 'request', principalId: 'principal', principalLabel: 'Agent', level: 'interact', navigation: 'same-origin', state: 'pending', createdAt: 0, expiresAt: null }] }; let update: (state: BrokerState) => void = () => {}; const stopWatching = vi.fn(); @@ -32,7 +32,7 @@ it.each(['review', 'accept'] as const)('uses native hub notifications with %s an const setupPage = approvalAction === 'accept' ? setupBrowserControlAcceptPage : setupBrowserControlPage; const dispose = await setupPage({ rpc: {} as BrowserControlPageContext['rpc'], current: { domElements: {} }, commands: { register }, messages: { info } }); try { - expect(info).toHaveBeenCalledWith('Browser control requested', expect.objectContaining({ notify: true, autoDismiss: false, description: 'Agent requests interact access with same-origin navigation.' })); + expect(info).not.toHaveBeenCalled(); expect(document.querySelector('aside')).toBeNull(); expect(document.documentElement.hasAttribute('data-cdb-notifications-ready')).toBe(true); const command = register.mock.calls[0]![0]; @@ -40,9 +40,7 @@ it.each(['review', 'accept'] as const)('uses native hub notifications with %s an await command.action(); expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: `cdb:${approvalAction}-request`, detail: { requestId: 'request' } })); update({ ...state, requests: [] }); - await vi.waitUntil(() => dismiss.mock.calls.length === 1); expect(unregister).toHaveBeenCalledOnce(); - expect(dismiss).toHaveBeenCalledOnce(); } finally { dispose(); client.mockRestore(); @@ -52,124 +50,8 @@ it.each(['review', 'accept'] as const)('uses native hub notifications with %s an expect(document.documentElement.hasAttribute('data-cdb-notifications-ready')).toBe(false); }); -async function notificationFixture(message = { dismiss: vi.fn(async () => {}), update: vi.fn(async (_patch: { description: string }) => {}) }, creation?: Promise) { - const state: BrokerState = { revision: 1, providers: [], principals: [], targets: [], grants: [], scopes: [], leases: [], requests: [{ id: 'request', principalId: 'principal', principalLabel: 'Agent', level: 'interact', navigation: 'same-origin', state: 'pending', createdAt: 0, expiresAt: null }] }; - let publish: (state: BrokerState) => void = () => {}; - const client = vi.spyOn(panel, 'createBrowserControlPanelClient').mockReturnValue({ snapshot: () => state, watch: (listener) => { - publish = listener; - listener(state); - return () => {}; - }, revokeScope: async () => {}, revokeGrant: async () => true, disconnectProvider: async () => true }); - const unregister = vi.fn(); - const register = vi.fn(() => unregister); - const info = vi.fn(async () => creation ?? message); - const dispose = await setupBrowserControlPage({ rpc: {} as BrowserControlPageContext['rpc'], current: { domElements: {} }, commands: { register }, messages: { info } }); - return { state, publish: (next: BrokerState) => publish(next), message, info, register, unregister, dispose: () => { - dispose(); - client.mockRestore(); - } }; -} - -it('updates changed descriptions through the existing message without registering another command', async () => { - expect.assertions(4); - const fixture = await notificationFixture(); - try { - fixture.publish({ ...fixture.state, revision: 2 }); - expect(fixture.message.update).not.toHaveBeenCalled(); - fixture.publish({ ...fixture.state, requests: [{ ...fixture.state.requests[0]!, principalLabel: 'Renamed agent' }] }); - await vi.waitUntil(() => fixture.message.update.mock.calls.length === 1); - expect(fixture.message.update).toHaveBeenCalledWith({ description: 'Renamed agent requests interact access with same-origin navigation.' }); - expect(fixture.info).toHaveBeenCalledOnce(); - expect(fixture.register).toHaveBeenCalledOnce(); - } finally { - fixture.dispose(); - } -}); - -it('updates overlapping-grant tab counts without notifying unchanged publications', async () => { - expect.assertions(3); - const fixture = await notificationFixture(); - const grant = { id: 'grant', requestId: 'request', principalId: 'principal', principalLabel: 'Agent', providerId: 'provider', targetId: 'one', targetGeneration: 1, level: 'interact' as const, navigation: 'same-origin' as const, approvedOrigin: 'https://example.test', createdAt: 0, state: 'active' as const }; - try { - fixture.publish({ ...fixture.state, requests: [], grants: [grant] }); - fixture.publish({ ...fixture.state, requests: [], grants: [grant, { ...grant, id: 'second', targetId: 'two' }] }); - await vi.waitUntil(() => fixture.message.update.mock.calls.length === 1); - expect(fixture.message.update).toHaveBeenLastCalledWith({ description: 'Agent: interact access to 2 approved tabs.' }); - fixture.publish({ ...fixture.state, revision: 3, requests: [], grants: [grant, { ...grant, id: 'second', targetId: 'two' }] }); - await Promise.resolve(); - expect(fixture.message.update).toHaveBeenCalledOnce(); - expect(fixture.info).toHaveBeenCalledTimes(2); - } finally { - fixture.dispose(); - } -}); - -it('serializes updates and dismisses after a pending update without publishing queued stale data', async () => { - expect.assertions(5); - const pending = Promise.withResolvers(); - const update = vi.fn(async (_patch: { description: string }) => pending.promise); - const fixture = await notificationFixture({ update, dismiss: vi.fn(async () => {}) }); - try { - fixture.publish({ ...fixture.state, requests: [{ ...fixture.state.requests[0]!, principalLabel: 'First' }] }); - await vi.waitUntil(() => update.mock.calls.length === 1); - fixture.publish({ ...fixture.state, requests: [{ ...fixture.state.requests[0]!, principalLabel: 'Second' }] }); - await Promise.resolve(); - expect(update).toHaveBeenCalledOnce(); - fixture.publish({ ...fixture.state, requests: [] }); - expect(fixture.unregister).toHaveBeenCalledOnce(); - expect(fixture.message.dismiss).not.toHaveBeenCalled(); - pending.resolve(); - await vi.waitUntil(() => fixture.message.dismiss.mock.calls.length === 1); - expect(update).toHaveBeenCalledOnce(); - expect(fixture.message.dismiss).toHaveBeenCalledOnce(); - } finally { - pending.resolve(); - fixture.dispose(); - } -}); - -it.each(['expiry', 'disposal'] as const)('cleans up delayed message creation after %s', async (ending) => { - expect.assertions(3); - const message = { dismiss: vi.fn(async () => {}), update: vi.fn(async (_patch: { description: string }) => {}) }; - const creation = Promise.withResolvers(); - const fixture = await notificationFixture(message, creation.promise); - try { - if (ending === 'expiry') fixture.publish({ ...fixture.state, requests: [{ ...fixture.state.requests[0]!, expiresAt: 1 }] }); - else fixture.dispose(); - expect(fixture.unregister).toHaveBeenCalledOnce(); - creation.resolve(message); - await vi.waitUntil(() => message.dismiss.mock.calls.length === 1); - expect(message.dismiss).toHaveBeenCalledOnce(); - expect(message.update).not.toHaveBeenCalled(); - } finally { - fixture.dispose(); - } -}); - -it('continues message updates after a rejected update and still removes the message', async () => { - expect.assertions(3); - const fixture = await notificationFixture(); - const failure = new Error('Host update failed'); - fixture.message.update.mockRejectedValueOnce(failure); - const report = vi.spyOn(console, 'error').mockImplementation(() => {}); - try { - fixture.publish({ ...fixture.state, requests: [{ ...fixture.state.requests[0]!, principalLabel: 'First' }] }); - await vi.waitUntil(() => report.mock.calls.length === 1); - fixture.publish({ ...fixture.state, requests: [{ ...fixture.state.requests[0]!, principalLabel: 'Second' }] }); - await vi.waitUntil(() => fixture.message.update.mock.calls.length === 2); - expect(fixture.message.update).toHaveBeenLastCalledWith({ description: 'Second requests interact access with same-origin navigation.' }); - fixture.publish({ ...fixture.state, requests: [] }); - await vi.waitUntil(() => fixture.message.dismiss.mock.calls.length === 1); - expect(fixture.message.dismiss).toHaveBeenCalledOnce(); - expect(report).toHaveBeenCalledWith('Unable to update browser-control notification.', failure); - } finally { - fixture.dispose(); - report.mockRestore(); - } -}); - -it('installs host approval bindings only while available and removes notifications on disable', async () => { - expect.assertions(8); +it('installs host approval bindings only while available and removes local commands on disable', async () => { + expect.assertions(10); const state: BrokerState = { revision: 1, providers: [], principals: [], targets: [], grants: [], scopes: [], leases: [], requests: [{ id: 'request', principalId: 'principal', principalLabel: 'Agent', level: 'interact', navigation: 'same-origin', state: 'pending', createdAt: 0, expiresAt: null }] }; let publish: (state: BrokerState, available?: boolean) => void = () => {}; const mock = vi.spyOn(panel, 'createBrowserControlPanelClient').mockReturnValue({ @@ -187,19 +69,21 @@ it('installs host approval bindings only while available and removes notificatio const onAvailable = vi.fn(() => removeBindings); const dismiss = vi.fn(async () => {}); const info = vi.fn(async () => ({ dismiss, update: vi.fn(async () => {}) })); - const dispose = await setupBrowserControlAcceptPage({ rpc: {} as BrowserControlPageContext['rpc'], current: { domElements: {} }, commands: { register: () => () => {} }, messages: { info } }, { onAvailable }); + const unregister = vi.fn(); + const register = vi.fn(() => unregister); + const dispose = await setupBrowserControlAcceptPage({ rpc: {} as BrowserControlPageContext['rpc'], current: { domElements: {} }, commands: { register }, messages: { info } }, { onAvailable }); try { expect(onAvailable).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); publish(state, true); publish(state, true); expect(onAvailable).toHaveBeenCalledOnce(); - await vi.waitUntil(() => info.mock.calls.length === 1); + expect(register).toHaveBeenCalledOnce(); publish(state, false); expect(removeBindings).toHaveBeenCalledOnce(); expect(document.documentElement.hasAttribute('data-cdb-notifications-ready')).toBe(false); - await vi.waitUntil(() => dismiss.mock.calls.length === 1); - expect(dismiss).toHaveBeenCalledOnce(); + expect(unregister).toHaveBeenCalledOnce(); + expect(info).not.toHaveBeenCalled(); publish(state, true); expect(onAvailable).toHaveBeenCalledTimes(2); } finally { diff --git a/tests/e2e/devframe-notification-ownership.test.ts b/tests/e2e/devframe-notification-ownership.test.ts new file mode 100644 index 0000000..09518e5 --- /dev/null +++ b/tests/e2e/devframe-notification-ownership.test.ts @@ -0,0 +1,86 @@ +import type { BrokerState } from '@dvcol/cdb-broker/contract'; +import type { BrowserControlPanelClient } from '@dvcol/cdb-devframe/panel'; + +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createUi } from '@devframes/hub-ui'; +import { initHub } from '@devframes/hub/initiate'; +import { createCdbPanel } from '@dvcol/cdb-devframe'; +import { chromium } from 'playwright'; +import { expect, it } from 'vitest'; + +it.each(['embedded', 'standalone'] as const)('shares one approval notification between two %s clients with local actions', async (mode) => { + expect.assertions(12); + const directory = await mkdtemp(join(tmpdir(), 'cdb-notification-ownership-')); + const server = createServer(); + let origin = ''; + let state: BrokerState = { revision: 1, providers: [], principals: [], targets: [], grants: [], scopes: [], leases: [], requests: [] }; + const listeners = new Set<(state: BrokerState) => void>(); + const client: BrowserControlPanelClient = { + snapshot: () => state, + watch(listener) { + listeners.add(listener); + listener(state); + return () => listeners.delete(listener); + }, + revokeScope: async () => {}, + revokeGrant: async () => true, + disconnectProvider: async () => true, + }; + const panel = createCdbPanel({ client: () => client, approvalAction: 'accept' }); + const hub = initHub({ base: '/hub/', cwd: directory, origin: () => origin, auth: false, server, ui: createUi(), devframes: [panel.definition], configure(context) { + context.rpc.register({ name: 'devframes:plugin:messages:list', type: 'query', handler: (since?: number | null) => context.messages.listSince(since) }); + } }); + server.on('request', (request, response) => { + if (request.url !== '/app') return hub.nodeMiddleware(request, response); + response.setHeader('content-type', 'text/html'); + response.end('

Public CDB approval fixture

'); + }); + const browser = await chromium.launch({ channel: 'chromium', headless: true }); + try { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address === null || typeof address === 'string') throw new Error('Missing fixture address.'); + origin = `http://127.0.0.1:${address.port}`; + await hub.ready; + const context = await hub.context; + const first = await browser.newPage(); + const second = await browser.newPage(); + for (const page of [first, second]) { + await page.addInitScript(() => { + window.addEventListener('cdb:accept-request', (event) => { + document.documentElement.dataset.acceptedRequest = (event as CustomEvent<{ requestId: string }>).detail.requestId; + }); + }); + await page.goto(`${origin}${mode === 'embedded' ? '/app' : '/hub/'}`); + await expect.poll(async () => page.locator('html').getAttribute('data-cdb-notifications-ready'), { timeout: 5_000 }).toBe(''); + } + state = { ...state, requests: [{ id: 'public-request', principalId: 'public-agent', principalLabel: 'Public test agent', level: 'interact', navigation: 'same-origin', state: 'pending', createdAt: Date.now(), expiresAt: null }] }; + for (const listener of listeners) listener(state); + for (const page of [first, second]) { + await expect.poll(async () => page.getByRole('button', { name: 'Accept', exact: true }).count(), { timeout: 5_000 }).toBe(1); + } + expect(Array.from(context.messages.entries.values()).filter(message => message.message === 'Browser control requested')).toHaveLength(1); + await first.getByRole('button', { name: 'Accept', exact: true }).click(); + await expect.poll(async () => first.locator('html').getAttribute('data-accepted-request')).toBe('public-request'); + expect(await second.locator('html').getAttribute('data-accepted-request')).toBeNull(); + await first.close(); + expect(Array.from(context.messages.entries.values()).filter(message => message.message === 'Browser control requested')).toHaveLength(1); + await second.getByRole('button', { name: 'Accept', exact: true }).click(); + await expect.poll(async () => second.locator('html').getAttribute('data-accepted-request')).toBe('public-request'); + state = { ...state, requests: [] }; + for (const listener of listeners) listener(state); + await expect.poll(async () => second.getByText('Browser control requested', { exact: true }).count()).toBe(0); + expect(Array.from(context.messages.entries.values()).filter(message => message.message === 'Browser control requested')).toHaveLength(0); + expect(listeners.size).toBe(1); + } finally { + await browser.close(); + panel.dispose(); + await hub.close(); + await new Promise(resolve => server.close(() => resolve())); + await rm(directory, { recursive: true, force: true }); + } +}, 30_000);