From 7bee876e82e2691b1c0ee0d2b079be7e949b2c69 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Sat, 12 Sep 2026 19:34:37 +0200 Subject: [PATCH 1/2] feat: expose connection lifecycle and notification themes --- examples/devframe/README.md | 2 +- examples/devframe/host.ts | 7 +- packages/broker/src/contract.ts | 2 + packages/broker/src/error.ts | 38 ++++ packages/broker/src/runtime.ts | 3 +- packages/broker/test/error.test.ts | 20 ++ packages/devframe/README.md | 18 ++ packages/devframe/package.json | 4 + packages/devframe/src/client.ts | 15 +- packages/devframe/src/connection.ts | 185 ++++++++++++++++++ packages/devframe/src/page-script.ts | 38 +++- packages/devframe/src/panel-definition.ts | 76 +++++-- packages/devframe/src/panel.ts | 6 +- packages/devframe/src/service.ts | 4 +- packages/devframe/test/connection.test.ts | 103 ++++++++++ packages/devframe/test/page-script.test.ts | 41 ++++ .../devframe/test/panel-definition.test.ts | 83 ++++++++ packages/devframe/test/service.test.ts | 28 +++ packages/devframe/tsdown.config.ts | 2 +- packages/extension/README.md | 31 +++ packages/extension/src/index.ts | 2 + packages/extension/src/notification-theme.ts | 126 ++++++++++++ packages/extension/src/notifications.ts | 39 ++-- packages/extension/src/page-request.ts | 58 ++++++ .../test/notifications-headless.test.ts | 18 ++ packages/extension/test/notifications.test.ts | 29 +++ packages/extension/test/page-request.test.ts | 60 ++++++ packages/extension/test/scaffold.test.ts | 2 +- packages/mcp/src/index.ts | 39 +++- packages/mcp/test/tool-session.test.ts | 12 ++ tests/e2e/notification-theme.test.ts | 56 ++++++ 31 files changed, 1085 insertions(+), 62 deletions(-) create mode 100644 packages/broker/test/error.test.ts create mode 100644 packages/devframe/src/connection.ts create mode 100644 packages/devframe/test/connection.test.ts create mode 100644 packages/devframe/test/panel-definition.test.ts create mode 100644 packages/extension/src/notification-theme.ts create mode 100644 packages/extension/src/page-request.ts create mode 100644 packages/extension/test/notifications-headless.test.ts create mode 100644 packages/extension/test/page-request.test.ts create mode 100644 tests/e2e/notification-theme.test.ts diff --git a/examples/devframe/README.md b/examples/devframe/README.md index 0559a4f..f075f01 100644 --- a/examples/devframe/README.md +++ b/examples/devframe/README.md @@ -11,7 +11,7 @@ The supported baseline is Devframe 0.9.10, DevTools Kit 0.6.1 and Vite 8.1.5. Up 5. Open the management panel at the host URL. Connect the extension popup to the same host and authenticate with the Devframe code printed in the host's stderr log for that connection. Keep the inspected application tab active when approving. 6. Ask the agent for `browser.request_access`, review its requested level/navigation policy in the extension popup, and approve the current tab, group or window. Run `browser.snapshot` and `browser.batch` from that same agent session. -The example's identity file lives under `examples/devframe/dist/identity`. Preserve it and the extension's storage to retain pairing across restarts. The standalone panel requests review; the popup performs the final approval and validates the selected Chrome scope. +Set `CDB_IDENTITY_DIRECTORY` to a persistent directory chosen by the host. The default remains `examples/devframe/dist/identity` for existing example installations; use a directory outside build output for a long-lived installation. Preserve it and the extension's storage to retain pairing across restarts. The standalone panel requests review; the popup performs the final approval and validates the selected Chrome scope. `pnpm --filter @chrome-debugger-bridge-example/devframe smoke` checks the served panel, page-script assets and native catalogue without a browser. The focused `tests/e2e/devframe-native.test.ts` exercises real Chromium, closed shadow roots, nested cross-origin frames, overlapping grants, live group membership and provider recovery through Devframe RPC and native MCP. diff --git a/examples/devframe/host.ts b/examples/devframe/host.ts index 57a8a1f..c4a258f 100644 --- a/examples/devframe/host.ts +++ b/examples/devframe/host.ts @@ -1,7 +1,7 @@ import { join } from 'node:path'; import { styleText } from 'node:util'; -import { createFileBrokerIdentityStore, defineBroker } from '@dvcol/cdb-broker'; +import { createFileBrokerIdentityStore, defineBroker, normalizeBrowserControlError } from '@dvcol/cdb-broker'; import { Server } from '@modelcontextprotocol/server'; import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; import { createDevServer } from 'devframe/adapters/dev'; @@ -11,7 +11,7 @@ import { createDevframeExample } from './devframe.ts'; async function main(): Promise { const example = createDevframeExample(defineBroker({ - identityStore: await createFileBrokerIdentityStore(join(import.meta.dirname, 'dist', 'identity')), + identityStore: await createFileBrokerIdentityStore(process.env.CDB_IDENTITY_DIRECTORY ?? join(import.meta.dirname, 'dist', 'identity')), navigation: { default: 'same-origin', allowed: ['same-origin', 'follow-tab'] }, })); const extensionOrigin = process.env.CDB_EXTENSION_ORIGIN; @@ -34,6 +34,7 @@ async function main(): Promise { }); const broker = example.service.broker; const agent = { id: 'example-stdio-agent', label: 'Example MCP agent' }; + await broker.connectSession(agent); const mcp = new Server({ name: 'cdb-devframe-example', version: example.definition.version }, { capabilities: { tools: {} } }); mcp.setRequestHandler('tools/list', async () => ({ tools: broker.tools.map(tool => ({ ...tool, inputSchema: { ...tool.inputSchema, type: 'object' as const } })) })); mcp.setRequestHandler('tools/call', async (request, context) => { @@ -41,7 +42,7 @@ async function main(): Promise { const value = await broker.invoke(agent, request.params.name, request.params.arguments, { signal: context.mcpReq.signal }); return { content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value) }] }; } catch (error) { - return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify({ ...(error as object), message: error instanceof Error ? error.message : String(error) }) }] }; + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify(normalizeBrowserControlError(error)) }] }; } }); let closing = false; diff --git a/packages/broker/src/contract.ts b/packages/broker/src/contract.ts index 38a70b8..5fae748 100644 --- a/packages/broker/src/contract.ts +++ b/packages/broker/src/contract.ts @@ -146,3 +146,5 @@ export interface BrokerPeer { export type BrokerTool = Omit; export type { AgentAuthenticationTranscript, AuthorityBinding, GrantRequestClaim, LogicalSessionCredential }; + +export { type BrowserControlError, browserControlToolError, normalizeBrowserControlError } from './error.js'; diff --git a/packages/broker/src/error.ts b/packages/broker/src/error.ts index 9ad35f3..ad6308d 100644 --- a/packages/broker/src/error.ts +++ b/packages/broker/src/error.ts @@ -10,3 +10,41 @@ export class BrokerError extends Error { this.name = 'BrokerError'; } } + +export interface BrowserControlError { + readonly code: string; + readonly message: string; + readonly retryable: boolean; + readonly retryAfterMilliseconds?: number; + readonly details?: Readonly>; +} + +/** Preserve protocol errors and semantic MCP errors without copying their evolving code catalogue. */ +export function normalizeBrowserControlError(error: unknown): BrowserControlError { + const record = error !== null && typeof error === 'object' ? error as Record : {}; + const retryDelay = record.retryAfterMilliseconds ?? record.retryAfterMs; + return { + code: typeof record.code === 'string' ? record.code : 'CDB_OPERATION_FAILED', + message: typeof record.message === 'string' ? record.message : String(error), + retryable: typeof record.retryable === 'boolean' ? record.retryable : false, + ...(typeof retryDelay === 'number' ? { retryAfterMilliseconds: retryDelay } : {}), + ...(record.details !== null && typeof record.details === 'object' && !Array.isArray(record.details) ? { details: record.details as Record } : {}), + }; +} + +/** Returns a semantic failure only for an MCP error result; successful observations are never parsed as errors. */ +export function browserControlToolError(result: unknown): BrowserControlError | undefined { + if (result === null || typeof result !== 'object' || !('isError' in result) || result.isError !== true) return undefined; + if ('content' in result && Array.isArray(result.content)) { + for (const content of result.content as unknown[]) { + if (content === null || typeof content !== 'object' || !('type' in content) || content.type !== 'text' || !('text' in content) || typeof content.text !== 'string') continue; + try { + const payload: unknown = JSON.parse(content.text); + if (payload !== null && typeof payload === 'object' && 'code' in payload && typeof payload.code === 'string') return normalizeBrowserControlError(payload); + } catch { + /** Text-only MCP failures do not contain a structured protocol error. */ + } + } + } + return normalizeBrowserControlError(new Error('The browser tool failed.')); +} diff --git a/packages/broker/src/runtime.ts b/packages/broker/src/runtime.ts index 274ec95..106aee8 100644 --- a/packages/broker/src/runtime.ts +++ b/packages/broker/src/runtime.ts @@ -431,6 +431,7 @@ export async function createBroker(configuration: BrokerDefinition = {}): Promis const requestTool: BrokerTool = { name: 'browser.request_access', + safety: 'action', description: 'Request user-approved browser control. Reuse a current approved target or request another tab. No browser authority exists until approval completes.', inputSchema: { type: 'object', @@ -514,7 +515,7 @@ export async function createBroker(configuration: BrokerDefinition = {}): Promis toolProvider: { name: '@dvcol/cdb', version: packageManifest.version }, connectSession, snapshot, - tools: [requestTool, ...descriptors.definitions.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }))], + tools: [requestTool, ...descriptors.definitions.map(({ name, description, inputSchema, safety }) => ({ name, description, inputSchema, ...(safety === undefined ? {} : { safety }) }))], subscribe(listener: (state: BrokerState) => void) { ensureActive(); listeners.add(listener); diff --git a/packages/broker/test/error.test.ts b/packages/broker/test/error.test.ts new file mode 100644 index 0000000..868f865 --- /dev/null +++ b/packages/broker/test/error.test.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'vitest'; + +import { browserControlToolError, normalizeBrowserControlError } from '../src/error.js'; + +it('preserves arbitrary protocol codes, retry delays and uncertain dispatch details', () => { + expect.assertions(2); + const failure = { code: 'MCP_ACTION_OUTCOME_UNKNOWN', message: 'Input may have dispatched', retryable: false, details: { dispatched: true, outcome: 'uncertain' } }; + expect(normalizeBrowserControlError(Object.assign(new Error(failure.message), failure))).toEqual(failure); + expect(normalizeBrowserControlError({ code: 'FUTURE_RECOVERABLE_ERROR', message: 'Try later', retryable: true, retryAfterMs: 10 })).toEqual({ code: 'FUTURE_RECOVERABLE_ERROR', message: 'Try later', retryable: true, retryAfterMilliseconds: 10 }); +}); + +it('inspects semantic failures while leaving successful text payloads alone', () => { + expect.assertions(4); + const failure = { code: 'MCP_LOCATOR_NOT_FOUND', message: 'Not found', retryable: true }; + const content = [{ type: 'text', text: JSON.stringify(failure) }]; + expect(browserControlToolError({ content })).toBeUndefined(); + expect(browserControlToolError({ content, isError: true })).toEqual(failure); + expect(browserControlToolError({ content: [{ type: 'text', text: 'Unstructured' }], isError: true })).toEqual({ code: 'CDB_OPERATION_FAILED', message: 'The browser tool failed.', retryable: false }); + expect(normalizeBrowserControlError('Failed')).toEqual({ code: 'CDB_OPERATION_FAILED', message: 'Failed', retryable: false }); +}); diff --git a/packages/devframe/README.md b/packages/devframe/README.md index 229edb1..87a9b23 100644 --- a/packages/devframe/README.md +++ b/packages/devframe/README.md @@ -56,3 +56,21 @@ 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 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. + +### Connection-bound clients + +Use `createCdbConnection` from `@dvcol/cdb-devframe/connection` when the embedding +host replaces Devframe peers during reconnect. Call `attach(peer)` from the host's +connection callback, `disconnected()` on transport loss, and `dispose()` at +shutdown. Attaching is synchronous; browser subscriptions and optional logical +session readiness are lazy and do not delay ordinary host traffic. + +The handle supplies the panel's `watch`, `snapshot` and management operations, +plus browser invocation and operation-scoped cancellation. It owns initial state, +subscription deduplication, stale-callback fencing and cleanup. It never closes +the shared host transport or replays a dispatched operation. + +For agent callers, supply `session` with a credential store/key and principal +metadata. Use a distinct authenticated peer and handle for each principal; do not +share one logical session between MCP callers. Reconnecting that principal +resumes its own credential. Management and provider connections omit `session`. diff --git a/packages/devframe/package.json b/packages/devframe/package.json index c6103de..338b447 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -32,6 +32,10 @@ "types": "./dist/client.d.mts", "import": "./dist/client.mjs" }, + "./connection": { + "types": "./dist/connection.d.mts", + "import": "./dist/connection.mjs" + }, "./panel": { "types": "./dist/panel.d.mts", "import": "./dist/panel.mjs" diff --git a/packages/devframe/src/client.ts b/packages/devframe/src/client.ts index f83468b..5e679cb 100644 --- a/packages/devframe/src/client.ts +++ b/packages/devframe/src/client.ts @@ -236,14 +236,13 @@ export function createCdbClient(client: CdbDevframeClient): CdbClient { async dispose() { if (disposed) return; provider?.close(1000, 'CDB client disposed'); - try { - if (watching !== undefined) await call('unwatch'); - } finally { - disposed = true; - stateListeners.clear(); - watching = undefined; - state = undefined; - } + disposed = true; + clients.delete(client); + const subscribed = watching !== undefined; + stateListeners.clear(); + watching = undefined; + state = undefined; + if (subscribed) await rpc.call('unwatch'); }, }; clients.set(client, handle); diff --git a/packages/devframe/src/connection.ts b/packages/devframe/src/connection.ts new file mode 100644 index 0000000..b33ecd6 --- /dev/null +++ b/packages/devframe/src/connection.ts @@ -0,0 +1,185 @@ +import type { BrokerState } from '@dvcol/cdb-broker/contract'; + +import type { CdbClient } from './client.js'; +import type { BrowserControlPanelClient } from './panel.js'; +import type { CdbDevframeClient } from './wire.js'; + +import { createCdbClient, createCdbClientSession } from './client.js'; + +export interface CdbConnectionOptions { + /** Supply only for an agent principal, not a management or provider connection. */ + readonly session?: Parameters[0]; + readonly onError?: (error: unknown) => void; +} + +export interface CdbConnection extends BrowserControlPanelClient { + readonly status: 'disconnected' | 'connected' | 'disposed'; + readonly current: BrokerState; + /** Attaching does not wait for CDB or delay the host's connection establishment. */ + attach: (peer: CdbDevframeClient) => void; + disconnected: () => void; + ready: () => Promise; + invoke: CdbClient['invoke']; + withCancellation: CdbClient['withCancellation']; + terminateSession: () => Promise; + dispose: () => Promise; +} + +function emptyState(): BrokerState { + return { revision: 0, providers: [], principals: [], requests: [], targets: [], grants: [], leases: [], scopes: [] }; +} + +/** Stop waiting locally; the caller forwards cancellation without replaying the operation. */ +async function untilAborted(pending: Promise, signal: AbortSignal): Promise { + const cancelled = Promise.withResolvers(); + const abort = (): void => cancelled.reject(signal.reason); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + try { + return await Promise.race([pending, cancelled.promise]); + } finally { + signal.removeEventListener('abort', abort); + } +} + +/** One handle per host/principal. It never opens or closes the supplied transport. */ +export function createCdbConnection(options: CdbConnectionOptions = {}): CdbConnection { + const session = options.session === undefined ? undefined : createCdbClientSession(options.session); + const listeners = new Set<(state: BrokerState) => void>(); + const cleanups = new Set>(); + let state = emptyState(); + let disposed = false; + let binding: { + peer: CdbDevframeClient; + client: CdbClient; + lifetime: AbortController; + ready?: Promise | undefined; + watching?: Promise | undefined; + unsubscribe?: (() => void) | undefined; + } | undefined; + + function publish(nextState: BrokerState): void { + state = nextState; + for (const listener of listeners) { + try { + listener(state); + } catch (error) { + options.onError?.(error); + } + } + } + + function detach(): void { + const previous = binding; + binding = undefined; + if (previous !== undefined) { + previous.lifetime.abort(new Error('The CDB connection was replaced or disconnected.')); + previous.unsubscribe?.(); + const cleanup = previous.client.dispose().catch(error => options.onError?.(error)); + cleanups.add(cleanup); + void cleanup.finally(() => cleanups.delete(cleanup)); + } + publish(emptyState()); + } + + async function ready(): Promise { + const selected = binding; + if (disposed || selected === undefined) throw new Error(disposed ? 'The CDB connection is disposed.' : 'Browser control is disconnected.'); + selected.ready ??= (async () => { + if (session !== undefined) await session.connect(selected.client); + selected.lifetime.signal.throwIfAborted(); + return selected.client; + })().catch((error) => { + selected.ready = undefined; + throw error; + }); + return untilAborted(selected.ready, selected.lifetime.signal); + } + + async function subscribe(): Promise { + const selected = binding; + if (selected === undefined || listeners.size === 0) return; + selected.watching ??= (async () => { + const client = await ready(); + selected.lifetime.signal.throwIfAborted(); + const stop = await client.watch((nextState) => { + if (binding === selected && !disposed) publish(nextState); + }); + if (binding !== selected || disposed || listeners.size === 0) stop(); + else selected.unsubscribe = stop; + })().catch((error) => { + selected.watching = undefined; + if (binding === selected) throw error; + }); + return selected.watching; + } + + async function operation(signal: AbortSignal | undefined, invoke: (client: CdbClient, signal: AbortSignal) => Promise): Promise { + const selected = binding; + signal?.throwIfAborted(); + if (selected === undefined) throw new Error('Browser control is disconnected.'); + const cancellation = signal === undefined ? selected.lifetime.signal : AbortSignal.any([signal, selected.lifetime.signal]); + const client = await untilAborted(ready(), cancellation); + if (binding !== selected) throw new Error('The CDB connection changed before dispatch.'); + cancellation.throwIfAborted(); + const result = await untilAborted(invoke(client, cancellation), cancellation); + cancellation.throwIfAborted(); + return result; + } + + return { + get status() { + return disposed ? 'disposed' : binding === undefined ? 'disconnected' : 'connected'; + }, + get current() { + return state; + }, + attach(peer) { + if (disposed) throw new Error('The CDB connection is disposed.'); + if (binding?.peer === peer) return; + detach(); + binding = { peer, client: createCdbClient(peer), lifetime: new AbortController() }; + void subscribe().catch(error => options.onError?.(error)); + }, + disconnected: detach, + ready, + async watch(listener) { + if (disposed) throw new Error('The CDB connection is disposed.'); + listeners.add(listener); + try { + listener(state); + await subscribe(); + } catch (error) { + listeners.delete(listener); + throw error; + } + return () => { + listeners.delete(listener); + if (listeners.size === 0 && binding !== undefined) { + binding.unsubscribe?.(); + binding.unsubscribe = undefined; + binding.watching = undefined; + } + }; + }, + snapshot: async () => operation(undefined, async client => client.snapshot()), + invoke: async (name, input, signal) => operation(signal, async (client, cancellation) => client.invoke(name, input, cancellation)), + withCancellation: async (signal, invoke) => operation(signal, async (client, cancellation) => client.withCancellation(cancellation, invoke)), + revokeScope: async requestId => operation(undefined, async client => client.revokeScope(requestId)), + revokeGrant: async grantId => operation(undefined, async client => client.revokeGrant(grantId)), + disconnectProvider: async (providerId, forgetPairing) => operation(undefined, async client => client.disconnectProvider(providerId, forgetPairing)), + async terminateSession() { + if (binding?.ready === undefined) return; + const client = await ready(); + if (session !== undefined) await session.terminate(client); + else await client.terminateSession(); + }, + async dispose() { + if (disposed) return; + disposed = true; + detach(); + listeners.clear(); + await Promise.all(cleanups); + }, + }; +} diff --git a/packages/devframe/src/page-script.ts b/packages/devframe/src/page-script.ts index 8f66489..7259925 100644 --- a/packages/devframe/src/page-script.ts +++ b/packages/devframe/src/page-script.ts @@ -11,11 +11,13 @@ export const browserControlAcceptEvent = 'cdb:accept-request'; export interface BrowserControlPageOptions { /** The embedding application owns the final approval channel for either action. */ readonly approvalAction?: 'review' | 'accept'; + /** Install host approval bindings only while this panel has an available broker. */ + readonly onAvailable?: () => void | (() => void); } /** Client-script entry selected by a host that supplies a direct approval channel. */ -export async function setupBrowserControlAcceptPage(context: BrowserControlPageContext): Promise<() => void> { - return setupBrowserControlPage(context, { approvalAction: 'accept' }); +export async function setupBrowserControlAcceptPage(context: BrowserControlPageContext, options: Omit = {}): Promise<() => void> { + return setupBrowserControlPage(context, { ...options, approvalAction: 'accept' }); } export interface BrowserControlPageContext { @@ -32,7 +34,11 @@ export interface BrowserControlPageContext { /** Uses the hub's existing page connection; the embedding extension handles the review intent. */ export default async function setupBrowserControlPage(context: BrowserControlPageContext, options: BrowserControlPageOptions = {}): Promise<() => void> { const client = createBrowserControlPanelClient(context.rpc); + let disposed = false; + let available = false; + let stopHostBindings: (() => void) | undefined; const review = (requestId: string): void => { + if (disposed || !available) return; 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) }); @@ -85,6 +91,10 @@ export default async function setupBrowserControlPage(context: BrowserControlPag }; let stopWatching: (() => void) | undefined; const dispose = (): void => { + if (disposed) return; + disposed = true; + stopHostBindings?.(); + stopHostBindings = undefined; stopWatching?.(); document.documentElement.removeAttribute('data-cdb-notifications-ready'); stopNotifications(); @@ -94,15 +104,31 @@ export default async function setupBrowserControlPage(context: BrowserControlPag window.removeEventListener('message', receive); window.removeEventListener('pagehide', dispose); }; + window.addEventListener('pagehide', dispose, { once: true }); try { - stopWatching = await client.watch(state => controller.update(state)); - /** Embedding extensions defer their fallback card only once this notifier is listening. */ - document.documentElement.setAttribute('data-cdb-notifications-ready', ''); + stopWatching = await client.watch((state, nextAvailable = true) => { + if (disposed) return; + if (available !== nextAvailable) { + available = nextAvailable; + if (available) stopHostBindings = options.onAvailable?.() ?? undefined; + else { + stopHostBindings?.(); + stopHostBindings = undefined; + } + } + controller.update(available ? state : { requests: [], grants: [] }); + document.documentElement.toggleAttribute('data-cdb-notifications-ready', available); + }); + if (disposed) { + stopWatching(); + return dispose; + } window.addEventListener('message', receive); - window.addEventListener('pagehide', dispose, { once: true }); return dispose; } catch (error) { dispose(); throw error; } } + +export { createPageRequestBridge, type PageRequestBridge, type PageRequestBridgeOptions, type PageRequestOptions } from '@dvcol/cdb-extension'; diff --git a/packages/devframe/src/panel-definition.ts b/packages/devframe/src/panel-definition.ts index f82d8e9..d42cb4e 100644 --- a/packages/devframe/src/panel-definition.ts +++ b/packages/devframe/src/panel-definition.ts @@ -1,5 +1,6 @@ import type { DevframeHubContext } from '@devframes/hub'; import type { JsonRenderView } from '@devframes/json-render'; +import type { BrokerState } from '@dvcol/cdb-broker/contract'; import type { DevframeDefinition, DevframeNodeContext, DevframeScopedNodeRpc } from 'devframe'; import type { BrowserControlPanelComponents } from './panel-view.js'; @@ -40,6 +41,8 @@ export interface CdbPanelOptions { export interface CdbPanel { readonly definition: CdbPanelDefinition; dispose: () => void; + /** Rebind after host replacement, or clear presentation when browser control is disabled. */ + setClient: (client: BrowserControlPanelClient | undefined) => Promise; } /** Serves Devframe's reference SPA; mounted hosts can select their own renderer. */ @@ -48,6 +51,17 @@ export function createCdbPanel(options: CdbPanelOptions): CdbPanel { let disposed = false; let unsubscribe: (() => void) | undefined; let view: JsonRenderView | undefined; + let client: BrowserControlPanelClient | undefined; + let clientResolved = false; + let generation = 0; + let replaceClient: (() => Promise) | undefined; + function activeClient(): BrowserControlPanelClient { + if (disposed || client === undefined) throw new Error('Browser control is unavailable.'); + return client; + } + function emptyState(): BrokerState { + return { revision: 0, providers: [], principals: [], requests: [], targets: [], grants: [], leases: [], scopes: [] }; + } return { definition: { id: 'cdb-browser-control', @@ -63,36 +77,54 @@ export function createCdbPanel(options: CdbPanelOptions): CdbPanel { dock: { defaultOrder: 1_000, ...options.dock, clientScript: { importFrom: join(directory, 'view/page-script.js'), ...(options.approvalAction === 'accept' ? { importName: 'setupBrowserControlAcceptPage' } : {}) } }, async setup(context) { if (disposed) throw new Error('The CDB panel was disposed.'); - const client = options.client(); + if (!clientResolved) { + client = options.client(); + clientResolved = true; + } const renderer = 'docks' in context ? options.renderer : undefined; const rpc = context.scope('cdb:panel').rpc; - const initial = await client.snapshot(); - const state = await rpc.sharedState('state', { initialValue: { broker: initial } }); + const initial = emptyState(); + const state = await rpc.sharedState('state', { initialValue: { broker: initial, available: client !== undefined } }); view = createJsonRenderView(context as DevframeNodeContext, { id: 'browser-control', title: options.name ?? 'Browser control', spec: buildBrowserControlPanelView(initial, renderer?.components), ...(renderer === undefined ? {} : { schema: false as const }), }); - const stop = await client.watch((broker) => { - state.mutate((value) => { - value.broker = broker; - }); - view?.update(buildBrowserControlPanelView(broker, renderer?.components)); - }); + replaceClient = async () => { + const selected = client; + const selectedGeneration = ++generation; + unsubscribe?.(); + unsubscribe = undefined; + const publish = (broker: BrokerState): void => { + if (disposed || generation !== selectedGeneration) return; + state.mutate((value) => { + value.broker = broker; + value.available = selected !== undefined; + }); + view?.update(buildBrowserControlPanelView(broker, renderer?.components)); + }; + publish(emptyState()); + if (selected === undefined) return; + const snapshot = await selected.snapshot(); + if (disposed || generation !== selectedGeneration) return; + publish(snapshot); + const stop = await selected.watch(publish); + if (disposed || generation !== selectedGeneration) stop(); + else unsubscribe = stop; + }; + await replaceClient(); if (disposed) { - stop(); view?.dispose(); return; } - unsubscribe = stop; - rpc.register({ name: 'state', type: 'query', handler: async () => client.snapshot() }); - rpc.register({ name: 'revoke-scope', type: 'action', handler: async (requestId: string) => client.revokeScope(requestId) }); - rpc.register({ name: 'revoke-grant', type: 'action', handler: async (grantId: string) => client.revokeGrant(grantId) }); - rpc.register({ name: 'disconnect-provider', type: 'action', handler: async (providerId: string, forgetPairing: boolean) => client.disconnectProvider(providerId, forgetPairing) }); - rpc.register({ name: 'revoke-target', type: 'action', handler: async ({ grantId }: { grantId: string }) => client.revokeGrant(grantId) }); - rpc.register({ name: 'disconnect', type: 'action', handler: async ({ providerId }: { providerId: string }) => client.disconnectProvider(providerId) }); - rpc.register({ name: 'forget', type: 'action', handler: async ({ providerId }: { providerId: string }) => client.disconnectProvider(providerId, true) }); + rpc.register({ name: 'state', type: 'query', handler: async () => activeClient().snapshot() }); + rpc.register({ name: 'revoke-scope', type: 'action', handler: async (requestId: string) => activeClient().revokeScope(requestId) }); + rpc.register({ name: 'revoke-grant', type: 'action', handler: async (grantId: string) => activeClient().revokeGrant(grantId) }); + rpc.register({ name: 'disconnect-provider', type: 'action', handler: async (providerId: string, forgetPairing: boolean) => activeClient().disconnectProvider(providerId, forgetPairing) }); + rpc.register({ name: 'revoke-target', type: 'action', handler: async ({ grantId }: { grantId: string }) => activeClient().revokeGrant(grantId) }); + rpc.register({ name: 'disconnect', type: 'action', handler: async ({ providerId }: { providerId: string }) => activeClient().disconnectProvider(providerId) }); + rpc.register({ name: 'forget', type: 'action', handler: async ({ providerId }: { providerId: string }) => activeClient().disconnectProvider(providerId, true) }); if ('docks' in context) { const hub = context as unknown as DevframeHubContext; const entry = hub.docks.views.get('cdb-browser-control'); @@ -103,8 +135,16 @@ export function createCdbPanel(options: CdbPanelOptions): CdbPanel { } }, }, + async setClient(next) { + if (disposed) throw new Error('The CDB panel was disposed.'); + if (clientResolved && client === next) return; + clientResolved = true; + client = next; + await replaceClient?.(); + }, dispose() { disposed = true; + generation += 1; unsubscribe?.(); unsubscribe = undefined; view?.dispose(); diff --git a/packages/devframe/src/panel.ts b/packages/devframe/src/panel.ts index fc811e2..05530ed 100644 --- a/packages/devframe/src/panel.ts +++ b/packages/devframe/src/panel.ts @@ -8,7 +8,7 @@ import { createBrowserControlNotificationController, renderBrowserControlNotific /** A panel consumes a broker connection; it does not own its runtime or transport. */ export interface BrowserControlPanelClient { snapshot: () => BrokerState | Promise; - watch: (listener: (state: BrokerState) => void) => (() => void) | Promise<() => void>; + watch: (listener: (state: BrokerState, available?: boolean) => void) => (() => void) | Promise<() => void>; revokeScope: (requestId: string) => Promise; revokeGrant: (grantId: string) => Promise; disconnectProvider: (providerId: string, forgetPairing?: boolean) => Promise; @@ -123,10 +123,10 @@ export function createBrowserControlPanelClient(client: CdbDevframeClient): Brow return { snapshot: async () => rpc.call('state') as Promise, async watch(listener) { - const state = await rpc.sharedState<{ broker: BrokerState }>('state'); + const state = await rpc.sharedState<{ broker: BrokerState; available?: boolean }>('state'); const update = (value: ReturnType | undefined): void => { /** Devframe initializes shared state after the connection becomes trusted. */ - if (value !== undefined) listener(structuredClone(value.broker) as BrokerState); + if (value !== undefined) listener(structuredClone(value.broker) as BrokerState, value.available ?? true); }; const unsubscribe = state.on('updated', update); update(state.value()); diff --git a/packages/devframe/src/service.ts b/packages/devframe/src/service.ts index d972c03..8b6555f 100644 --- a/packages/devframe/src/service.ts +++ b/packages/devframe/src/service.ts @@ -7,6 +7,7 @@ import { Buffer } from 'node:buffer'; import { agentToBrokerMessageSchema } from '@dvcol/cdb'; import { BrokerError, createBroker } from '@dvcol/cdb-broker'; +import { normalizeBrowserControlError } from '@dvcol/cdb-broker/contract'; import packageManifest from '../package.json' with { type: 'json' }; import { cdbServiceScope } from './wire.js'; @@ -103,8 +104,7 @@ function installBroker(context: DevframeScopedNodeContext, broker: BrokerRuntime try { return { ok: true, value: await handler(authorized(session, name), session, ...arguments_) }; } catch (error) { - const failure = error instanceof Error ? error as Partial : {}; - return { ok: false, error: { code: failure.code ?? 'CDB_OPERATION_FAILED', message: error instanceof Error ? error.message : String(error), retryable: failure.retryable ?? false, ...(failure.details === undefined ? {} : { details: failure.details }), ...(failure.retryAfterMilliseconds === undefined ? {} : { retryAfterMilliseconds: failure.retryAfterMilliseconds }) } }; + return { ok: false, error: normalizeBrowserControlError(error) }; } }, }); diff --git a/packages/devframe/test/connection.test.ts b/packages/devframe/test/connection.test.ts new file mode 100644 index 0000000..49c9139 --- /dev/null +++ b/packages/devframe/test/connection.test.ts @@ -0,0 +1,103 @@ +import type { BrokerState } from '@dvcol/cdb-broker/contract'; + +import type { CdbDevframeClient } from '../src/wire.js'; + +import { expect, it, vi } from 'vitest'; + +import { createCdbConnection } from '../src/connection.js'; + +const state: BrokerState = { revision: 1, providers: [], principals: [], requests: [], targets: [], grants: [], leases: [], scopes: [] }; + +function peer() { + const handlers = new Map void>(); + const call = vi.fn(async (_name: string, ..._arguments: unknown[]): Promise => ({ ok: true, value: state })); + const callEvent = vi.fn(); + const transport = { + scope: () => ({ rpc: { call, callEvent, register: (definition: { name: string; handler: (value: unknown) => void }) => handlers.set(definition.name, definition.handler) } }), + close: vi.fn(), + }; + return { transport: transport as unknown as CdbDevframeClient, call, callEvent, handlers, close: transport.close }; +} + +it('shares a subscription and fences obsolete publications on replacement without closing either peer', async () => { + expect.assertions(7); + const first = peer(); + const second = peer(); + const connection = createCdbConnection(); + const listener = vi.fn(); + connection.attach(first.transport); + const unsubscribe = await connection.watch(listener); + const secondUnsubscribe = await connection.watch(vi.fn()); + expect(first.call.mock.calls.filter(([name]) => name === 'watch')).toHaveLength(1); + const stalePublication = first.handlers.get('state-changed')!; + connection.attach(second.transport); + await vi.waitUntil(() => connection.current.revision === 1); + stalePublication({ ...state, revision: 100 }); + expect(connection.current.revision).toBe(1); + second.handlers.get('state-changed')!({ ...state, revision: 2 }); + expect(connection.current.revision).toBe(2); + expect(first.call).toHaveBeenCalledWith('unwatch'); + unsubscribe(); + secondUnsubscribe(); + await connection.dispose(); + expect(connection.status).toBe('disposed'); + expect(first.close).not.toHaveBeenCalled(); + expect(second.close).not.toHaveBeenCalled(); +}); + +it('cancels only the pending operation and keeps ordinary shared RPC usable', async () => { + expect.assertions(5); + const fixture = peer(); + const pending = Promise.withResolvers(); + fixture.call.mockImplementation(async name => name === 'invoke' ? pending.promise : { ok: true, value: 'ordinary' }); + const connection = createCdbConnection(); + connection.attach(fixture.transport); + const controller = new AbortController(); + const invocation = connection.invoke('browser.click', {}, controller.signal); + await vi.waitUntil(() => fixture.call.mock.calls.some(([name]) => name === 'invoke')); + controller.abort(new Error('Cancelled by caller')); + await expect(invocation).rejects.toThrow('Cancelled by caller'); + expect(fixture.callEvent).toHaveBeenCalledWith('cancel', expect.any(String)); + expect(fixture.call.mock.calls.filter(([name]) => name === 'invoke')).toHaveLength(1); + await expect(fixture.call('ordinary')).resolves.toEqual({ ok: true, value: 'ordinary' }); + pending.resolve({ ok: true, value: 'late result' }); + await connection.dispose(); + expect(fixture.close).not.toHaveBeenCalled(); +}); + +it('does not dispatch after cancellation during session readiness', async () => { + expect.assertions(3); + const fixture = peer(); + const session = Promise.withResolvers(); + fixture.call.mockImplementation(async name => name === 'session-connect' ? session.promise : { ok: true, value: state }); + const connection = createCdbConnection({ session: { credentialKey: 'principal-one' } }); + connection.attach(fixture.transport); + expect(fixture.call).not.toHaveBeenCalled(); + const controller = new AbortController(); + const invocation = connection.invoke('browser.click', {}, controller.signal); + await vi.waitUntil(() => fixture.call.mock.calls.some(([name]) => name === 'session-connect')); + controller.abort(); + await expect(invocation).rejects.toThrow(); + expect(fixture.call.mock.calls.some(([name]) => name === 'invoke')).toBe(false); + session.resolve({ ok: true, value: { sessionId: 'one', resumeToken: 'test' } }); + await connection.dispose(); +}); + +it('can attach the same transport after disconnection and rejects obsolete results', async () => { + expect.assertions(4); + const fixture = peer(); + const pending = Promise.withResolvers(); + fixture.call.mockImplementation(async name => name === 'invoke' ? pending.promise : { ok: true, value: state }); + const connection = createCdbConnection(); + connection.attach(fixture.transport); + const invocation = connection.invoke('browser.click', {}); + await vi.waitUntil(() => fixture.call.mock.calls.some(([name]) => name === 'invoke')); + connection.disconnected(); + await expect(invocation).rejects.toThrow('replaced or disconnected'); + expect(connection.status).toBe('disconnected'); + connection.attach(fixture.transport); + await expect(connection.snapshot()).resolves.toEqual(state); + pending.resolve({ ok: true, value: 'obsolete' }); + await connection.dispose(); + expect(fixture.close).not.toHaveBeenCalled(); +}); diff --git a/packages/devframe/test/page-script.test.ts b/packages/devframe/test/page-script.test.ts index dc0cd9c..9209d6b 100644 --- a/packages/devframe/test/page-script.test.ts +++ b/packages/devframe/test/page-script.test.ts @@ -167,3 +167,44 @@ it('continues message updates after a rejected update and still removes the mess report.mockRestore(); } }); + +it('installs host approval bindings only while available and removes notifications on disable', async () => { + 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 publish: (state: BrokerState, available?: boolean) => void = () => {}; + const mock = vi.spyOn(panel, 'createBrowserControlPanelClient').mockReturnValue({ + snapshot: () => state, + watch(listener) { + publish = listener; + listener(state, false); + return () => {}; + }, + revokeScope: async () => {}, + revokeGrant: async () => true, + disconnectProvider: async () => true, + }); + const removeBindings = vi.fn(); + 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 }); + 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); + 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(); + publish(state, true); + expect(onAvailable).toHaveBeenCalledTimes(2); + } finally { + dispose(); + mock.mockRestore(); + } + expect(removeBindings).toHaveBeenCalledTimes(2); +}); diff --git a/packages/devframe/test/panel-definition.test.ts b/packages/devframe/test/panel-definition.test.ts new file mode 100644 index 0000000..da1611d --- /dev/null +++ b/packages/devframe/test/panel-definition.test.ts @@ -0,0 +1,83 @@ +import type { BrokerState } from '@dvcol/cdb-broker/contract'; + +import type { BrowserControlPanelClient } from '../src/panel.js'; + +import { expect, it, vi } from 'vitest'; + +import { createCdbPanel } from '../src/panel-definition.js'; + +vi.mock('@devframes/json-render/node', () => ({ createJsonRenderView: () => ({ update: vi.fn(), dispose: vi.fn() }) })); + +const state: BrokerState = { revision: 1, providers: [], principals: [], requests: [], targets: [], grants: [], leases: [], scopes: [] }; + +function fixtureClient(revision: number) { + let publish: (state: BrokerState) => void = () => {}; + const unsubscribe = vi.fn(); + const client: BrowserControlPanelClient = { + snapshot: vi.fn(async () => ({ ...state, revision })), + watch: vi.fn((listener: (state: BrokerState) => void) => { + publish = listener; + return unsubscribe; + }), + revokeScope: vi.fn(async () => {}), + revokeGrant: vi.fn(async () => true), + disconnectProvider: vi.fn(async () => true), + }; + return { client, unsubscribe, publish: (revision: number) => publish({ ...state, revision }) }; +} + +function fixtureContext() { + let value = { broker: state }; + const context = { + rpc: { sharedState: { get: vi.fn() } }, + scope: () => ({ rpc: { + register: vi.fn(), + sharedState: vi.fn(async (_key: string, { initialValue }: { initialValue: typeof value }) => { + value = initialValue; + return { mutate: (mutate: (current: typeof value) => void) => mutate(value) }; + }), + } }), + }; + return { context: context as unknown as Parameters['definition']['setup']>[0], current: () => value.broker }; +} + +it('rebinds mounted presentation and rejects obsolete state after replacement or disabling', async () => { + expect.assertions(7); + const first = fixtureClient(1); + const second = fixtureClient(2); + const fixture = fixtureContext(); + const getClient = vi.fn(() => first.client); + const panel = createCdbPanel({ client: getClient }); + expect(getClient).not.toHaveBeenCalled(); + await panel.definition.setup(fixture.context); + expect(fixture.current().revision).toBe(1); + await panel.setClient(second.client); + expect(first.unsubscribe).toHaveBeenCalledOnce(); + first.publish(100); + expect(fixture.current().revision).toBe(2); + await panel.setClient(undefined); + expect(second.unsubscribe).toHaveBeenCalledOnce(); + second.publish(200); + expect(fixture.current().revision).toBe(0); + panel.dispose(); + await expect(panel.setClient(first.client)).rejects.toThrow('disposed'); +}); + +it('does not install a stale subscription when snapshot completion races with replacement', async () => { + expect.assertions(3); + const first = fixtureClient(1); + const second = fixtureClient(2); + const pending = Promise.withResolvers(); + first.client.snapshot = async () => pending.promise; + const fixture = fixtureContext(); + const panel = createCdbPanel({ client: () => first.client }); + const setup = panel.definition.setup(fixture.context); + await Promise.resolve(); + await panel.setClient(second.client); + pending.resolve(state); + await setup; + expect(first.client.watch).not.toHaveBeenCalled(); + expect(second.client.watch).toHaveBeenCalledOnce(); + expect(fixture.current().revision).toBe(2); + panel.dispose(); +}); diff --git a/packages/devframe/test/service.test.ts b/packages/devframe/test/service.test.ts index 7e1bb52..6de0ee8 100644 --- a/packages/devframe/test/service.test.ts +++ b/packages/devframe/test/service.test.ts @@ -171,3 +171,31 @@ it.each(['direct', 'host-catalogue'] as const)('cancels %s browser work over an provider.close(); expect(await providerRpc.scope('fixture').rpc.call('echo', 'after channel closure')).toBe('after channel closure'); }, 20_000); + +it('keeps connection-bound principal sessions separate and resumes only their own credentials', async () => { + expect.assertions(6); + const { createCdbConnection } = await import('../src/connection.js'); + const setup = await fixture(); + const store = createMemoryCredentialStore(); + const first = createCdbConnection({ session: { credentialKey: 'one', credentialStore: store } }); + const second = createCdbConnection({ session: { credentialKey: 'two', credentialStore: store } }); + cleanups.push(async () => first.dispose(), async () => second.dispose()); + const firstPeer = await setup.connect(); + first.attach(firstPeer); + second.attach(await setup.connect()); + await Promise.all([first.ready(), second.ready()]); + const firstCredential = await store.get('one'); + const secondCredential = await store.get('two'); + expect(firstCredential).toBeDefined(); + expect(secondCredential).toBeDefined(); + expect(firstCredential).not.toEqual(secondCredential); + expect(setup.service.broker.snapshot().principals).toHaveLength(2); + first.disconnected(); + firstPeer.close?.(); + first.attach(await setup.connect()); + await first.ready(); + expect(setup.service.broker.snapshot().principals).toHaveLength(2); + await second.dispose(); + const ordinary = await setup.connect(); + await expect(ordinary.scope('fixture').rpc.call('echo', 'ordinary')).resolves.toBe('ordinary'); +}); diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index 29af20e..9b77dfe 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -1,3 +1,3 @@ import { defineConfig } from 'tsdown'; -export default defineConfig({ deps: { neverBundle: true }, dts: { sourcemap: true }, entry: ['src/index.ts', 'src/client.ts', 'src/panel.ts', 'src/page-script.ts'], format: 'esm', platform: 'node', sourcemap: true, target: 'es2024' }); +export default defineConfig({ deps: { neverBundle: true }, dts: { sourcemap: true }, entry: ['src/index.ts', 'src/client.ts', 'src/connection.ts', 'src/panel.ts', 'src/page-script.ts'], format: 'esm', platform: 'node', sourcemap: true, target: 'es2024' }); diff --git a/packages/extension/README.md b/packages/extension/README.md index a7ce807..f8dd7f1 100644 --- a/packages/extension/README.md +++ b/packages/extension/README.md @@ -23,3 +23,34 @@ The host chooses installation, recovery and pairing storage keys. Preserve those The `@dvcol/cdb-extension/notifications` entry supplies a headless notification controller and an optional neutral shadow-root renderer. Both derive state from the broker; local dismissal never changes authority. Review actions open the host's final approval UI. Branding and CSS are optional renderer inputs. See the [Devframe example](../../examples/devframe/README.md) for a complete composition over an existing RPC peer. + +### Notification presentation + +`@dvcol/cdb-extension/notifications` exports a headless controller and an optional +Shadow DOM renderer. The controller does not access the DOM or install theme +listeners. A host may render its state itself, or use +`renderBrowserControlNotifications` with the default CDB card. + +The renderer accepts `colorMode: 'system' | 'light' | 'dark'` (default: `system`), +`theme`, `branding`, and optional `css`. `defaultBrowserControlNotificationTheme` +and the exported theme types describe the light/dark palettes, grant colors, +spacing, typography, radii and shadow. Tokens become namespaced `--cdb-*` CSS +variables. Precedence is default theme, supplied theme values, explicit branding, +then custom CSS. Prefer theme values for normal customization. + +Call `setTheme` or `setColorMode` on the returned renderer to change presentation +without replacing buttons, losing focus or restarting pending actions. System +mode follows OS preference through CSS; explicit modes override it. The host +owns placement and may supply its title, client labels, Review/Accept label and +approval/rejection callbacks. Dismiss only hides a request; it does not reject it. +Devframe hosts should use the Devframe notification adapter instead of this DOM +renderer. + +### Page request correlation + +`createPageRequestBridge` matches response type, source, origin and correlation +identifier, with one deadline and listener cleanup for success, cancellation, +timeout and disposal. Supply the receiving and target windows, target origin and +application message names. Readiness probes and approval acknowledgements use +the same request method. A correlated message is **not** proof of human approval: +the host must validate Chrome senders and own the final approval channel. diff --git a/packages/extension/src/index.ts b/packages/extension/src/index.ts index 27d2668..3e6eaef 100644 --- a/packages/extension/src/index.ts +++ b/packages/extension/src/index.ts @@ -57,3 +57,5 @@ export { } from './tab-scope-lifecycle.js'; export { createTabScopeManager, type TabScopeManager, type TabScopeManagerOptions } from './tab-scope-manager.js'; export { matchesTabScope, parseTabScopeSelector, type TabScopeSelector } from './tab-scope.js'; + +export { createPageRequestBridge, type PageRequestBridge, type PageRequestBridgeOptions, type PageRequestOptions } from './page-request.js'; diff --git a/packages/extension/src/notification-theme.ts b/packages/extension/src/notification-theme.ts new file mode 100644 index 0000000..d3e79c0 --- /dev/null +++ b/packages/extension/src/notification-theme.ts @@ -0,0 +1,126 @@ +export type BrowserControlNotificationColorMode = 'system' | 'light' | 'dark'; + +export interface BrowserControlNotificationPalette { + readonly surface: string; + readonly surfaceMuted: string; + readonly border: string; + readonly text: string; + readonly textMuted: string; + readonly primary: string; + readonly primaryHover: string; + readonly secondaryText: string; + readonly primaryText: string; + readonly error: string; + readonly observe: string; + readonly inspect: string; + readonly interact: string; + readonly debug: string; + readonly unsafe: string; +} + +export interface BrowserControlNotificationTheme { + readonly light: BrowserControlNotificationPalette; + readonly dark: BrowserControlNotificationPalette; + readonly spacing: string; + readonly radius: string; + readonly controlRadius: string; + readonly shadow: string; + readonly font: string; + readonly monospaceFont: string; +} + +export type BrowserControlNotificationThemeOverrides = Partial> & { + readonly light?: Partial; + readonly dark?: Partial; +}; + +export const defaultBrowserControlNotificationTheme: BrowserControlNotificationTheme = { + light: { + surface: '#fff', + surfaceMuted: '#f5f2ff', + border: '#c9c1fa', + text: '#1e1a35', + textMuted: '#514a68', + primary: '#6553d8', + primaryHover: '#5543c7', + secondaryText: '#4b3e9f', + primaryText: '#fff', + error: '#b42318', + observe: '#1e40af', + inspect: '#115e59', + interact: '#92400e', + debug: '#9a3412', + unsafe: '#991b1b', + }, + dark: { + surface: '#25242c', + surfaceMuted: '#302e3b', + border: '#5b537d', + text: '#f4f1ff', + textMuted: '#c7c0dc', + primary: '#7665dc', + primaryHover: '#8879e6', + secondaryText: '#e0daff', + primaryText: '#fff', + error: '#fda29b', + observe: '#93c5fd', + inspect: '#5eead4', + interact: '#fcd34d', + debug: '#fdba74', + unsafe: '#fca5a5', + }, + spacing: '14px', + radius: '10px', + controlRadius: '6px', + shadow: '0 12px 36px rgba(30, 26, 53, .24)', + font: 'system-ui, sans-serif', + monospaceFont: 'ui-monospace, monospace', +}; + +/** Renderer-only styles; the headless controller never evaluates or installs them. */ +export const browserControlNotificationStyles = ` + :host { display:block; color:var(--cdb-text); font:12px/1.4 var(--cdb-font); } + * { box-sizing:border-box; } + article { position:relative; margin:8px 0; padding:0 var(--cdb-spacing) 12px; background:var(--cdb-surface); color:var(--cdb-text); border:1px solid var(--cdb-border); border-radius:var(--cdb-radius); box-shadow:var(--cdb-shadow); text-align:right; } + h2 { margin:0 calc(-1 * var(--cdb-spacing)) 10px; padding:10px 42px 10px var(--cdb-spacing); border-radius:var(--cdb-radius) var(--cdb-radius) 0 0; background:var(--cdb-surface-muted); font:700 13px/1.3 var(--cdb-font); text-align:left; } + p { margin:0 0 8px; color:var(--cdb-text-muted); text-align:left; overflow-wrap:anywhere; } + button { border:1px solid var(--cdb-primary); border-radius:var(--cdb-control-radius); padding:7px 11px; font:600 12px/1.2 var(--cdb-font); background:var(--cdb-surface-muted); color:var(--cdb-secondary-text); cursor:pointer; } + footer { display:flex; justify-content:flex-end; gap:8px; } + footer button:first-of-type { background:var(--cdb-primary); color:var(--cdb-primary-text); } + footer button:first-of-type:hover { background:var(--cdb-primary-hover); } + .dismiss { position:absolute; top:5px; right:6px; background:transparent; color:var(--cdb-text-muted); border:0; padding:5px 8px; font-size:18px; } + dl { display:flex; flex-wrap:wrap; gap:1px; margin:12px 0; background:var(--cdb-border); border:1px solid var(--cdb-border); border-radius:var(--cdb-control-radius); overflow:hidden; } + dl > div { flex:1 0 100px; min-width:0; padding:9px 6px; text-align:center; background:var(--cdb-surface-muted); } + dl > div:first-child { order:1; flex-basis:auto; width:max-content; min-width:100px; max-width:100%; } + dt { color:var(--cdb-text-muted); font-weight:600; } + dd { margin:5px 0 0; overflow-wrap:anywhere; } + code { display:inline-block; padding:3px; background:transparent; font:12px/1.4 var(--cdb-monospace-font); } + [data-level=observe] { color:var(--cdb-observe); } + [data-level=inspect] { color:var(--cdb-inspect); } + [data-level=interact] { color:var(--cdb-interact); } + [data-level=debug] { color:var(--cdb-debug); } + [data-level=unsafe] { color:var(--cdb-unsafe); font-weight:700; } + button:focus-visible { outline:2px solid var(--cdb-primary); outline-offset:2px; } + button:disabled { opacity:.6; cursor:wait; } + [role=alert] { color:var(--cdb-error); } +`; + +const uppercaseCharacter = /[A-Z]/g; + +function variables(tokens: Readonly>): string { + return Object.entries(tokens).map(([name, value]) => `--cdb-${name.replaceAll(uppercaseCharacter, character => `-${character.toLowerCase()}`)}:${value};`).join(''); +} + +export function notificationThemeStyles(theme: BrowserControlNotificationThemeOverrides = {}, accent?: string): string { + const { light, dark, ...shared } = defaultBrowserControlNotificationTheme; + const { light: lightOverrides, dark: darkOverrides, ...sharedOverrides } = theme; + const branding = accent === undefined ? {} : { primary: accent, primaryHover: accent, accent }; + return ` + :host { ${variables({ ...shared, ...sharedOverrides })} } + :host, :host([data-color-mode=light]) { color-scheme:light; ${variables({ ...light, ...lightOverrides, ...branding })} } + :host([data-color-mode=dark]) { color-scheme:dark; ${variables({ ...dark, ...darkOverrides, ...branding })} } + @media (prefers-color-scheme:dark) { + :host([data-color-mode=system]) { color-scheme:dark; ${variables({ ...dark, ...darkOverrides, ...branding })} } + } + `; +} diff --git a/packages/extension/src/notifications.ts b/packages/extension/src/notifications.ts index c08a0a8..306c37b 100644 --- a/packages/extension/src/notifications.ts +++ b/packages/extension/src/notifications.ts @@ -1,5 +1,12 @@ import type { BrokerGrant, BrokerRequest, BrokerState } from '@dvcol/cdb-broker/contract'; +import type { BrowserControlNotificationColorMode, BrowserControlNotificationThemeOverrides } from './notification-theme.js'; + +import { browserControlNotificationStyles, notificationThemeStyles } from './notification-theme.js'; + +export { defaultBrowserControlNotificationTheme } from './notification-theme.js'; +export type { BrowserControlNotificationColorMode, BrowserControlNotificationPalette, BrowserControlNotificationTheme, BrowserControlNotificationThemeOverrides } from './notification-theme.js'; + export interface BrowserControlNotification { readonly grants: readonly BrokerGrant[]; readonly requests: readonly BrokerRequest[]; @@ -86,6 +93,8 @@ export interface BrowserControlNotificationRendererOptions { readonly controller: BrowserControlNotificationController; readonly container: HTMLElement; readonly branding?: { readonly title?: string; readonly accent?: string }; + readonly theme?: BrowserControlNotificationThemeOverrides; + readonly colorMode?: BrowserControlNotificationColorMode; /** Describe the host's action, including direct approval when the host provides that policy. */ readonly reviewLabel?: (request: BrokerRequest) => string; readonly clientLabel?: (request: BrokerRequest) => string; @@ -95,27 +104,23 @@ export interface BrowserControlNotificationRendererOptions { readonly css?: string; } -/** Optional neutral presentation. Review buttons delegate to the host's trusted final approval UI. */ -export function renderBrowserControlNotifications(options: BrowserControlNotificationRendererOptions): { dispose: () => void } { +/** Optional themed presentation. Review buttons delegate to the host's trusted final approval UI. */ +export function renderBrowserControlNotifications(options: BrowserControlNotificationRendererOptions): { dispose: () => void; setTheme: (theme: BrowserControlNotificationThemeOverrides) => void; setColorMode: (mode: BrowserControlNotificationColorMode) => void } { const document = options.container.ownerDocument; const host = document.createElement('section'); host.dataset.cdbNotifications = ''; const root = host.attachShadow({ mode: 'open' }); const style = document.createElement('style'); - style.textContent = ` - :host { display: block; font: 14px/1.5 system-ui, sans-serif; color: #172033; color-scheme: light dark; } - article { background: Canvas; color: CanvasText; padding: 12px 16px; margin: 8px 0; border: 1px solid color-mix(in srgb, CanvasText 20%, transparent); border-radius: 8px; } - h2, p { margin: 0 0 8px; } h2 { font-size: 15px; } p { overflow-wrap: anywhere; } - button { padding: 6px 10px; margin: 4px 6px 0 0; cursor: pointer; border-radius: 4px; border: 1px solid currentColor; background: transparent; color: var(--cdb-accent, #7356c8); font: inherit; } - article { position: relative; } h2 { padding-right: 28px; } - .dismiss { position: absolute; top: 5px; right: 6px; margin: 0; border: 0; } - dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; text-align: left; } - dt { font-size: 12px; opacity: .75; } dd { margin: 4px 0 0; overflow-wrap: anywhere; } - code { font: 12px/1.5 ui-monospace, monospace; } - button:disabled { opacity: .6; cursor: wait; } [role=alert] { color: #b42318; } - ${options.css ?? ''} - `; - if (options.branding?.accent !== undefined) host.style.setProperty('--cdb-accent', options.branding.accent); + function setTheme(theme: BrowserControlNotificationThemeOverrides): void { + style.textContent = `${notificationThemeStyles(theme, options.branding?.accent)} +${browserControlNotificationStyles} +${options.css ?? ''}`; + } + function setColorMode(mode: BrowserControlNotificationColorMode): void { + host.dataset.colorMode = mode; + } + setTheme(options.theme ?? {}); + setColorMode(options.colorMode ?? 'system'); const content = document.createElement('div'); root.append(style, content); options.container.append(host); @@ -197,7 +202,7 @@ export function renderBrowserControlNotifications(options: BrowserControlNotific } const unsubscribe = options.controller.subscribe(render); render(options.controller.snapshot()); - return { dispose() { + return { setTheme, setColorMode, dispose() { disposed = true; unsubscribe(); host.remove(); diff --git a/packages/extension/src/page-request.ts b/packages/extension/src/page-request.ts new file mode 100644 index 0000000..5bf2f97 --- /dev/null +++ b/packages/extension/src/page-request.ts @@ -0,0 +1,58 @@ +export interface PageRequestBridgeOptions { + /** Window receiving responses; the target may be its containing page. */ + readonly receiver: Window; + readonly target: Window; + readonly targetOrigin: string; + readonly correlationKey?: string; + readonly timeoutMilliseconds?: number; +} + +export interface PageRequestOptions { + readonly signal?: AbortSignal; + readonly timeoutMilliseconds?: number; +} + +export interface PageRequestBridge { + request: (type: string, responseType: string, payload?: object, options?: PageRequestOptions) => Promise>>; + dispose: () => void; +} + +/** Correlation and acknowledgement are transport mechanics, never proof of trusted human approval. */ +export function createPageRequestBridge(options: PageRequestBridgeOptions): PageRequestBridge { + const correlationKey = options.correlationKey ?? 'requestId'; + const lifetime = new AbortController(); + return { + async request(type, responseType, payload = {}, requestOptions = {}) { + const signal = requestOptions.signal === undefined ? lifetime.signal : AbortSignal.any([lifetime.signal, requestOptions.signal]); + signal.throwIfAborted(); + const requestId = crypto.randomUUID(); + const response = Promise.withResolvers>>(); + const abort = (): void => response.reject(signal.reason); + const receive = (event: MessageEvent): void => { + if (event.source !== options.target || (options.targetOrigin !== '*' && event.origin !== options.targetOrigin)) return; + const message = record(event.data); + const result = record(message?.payload); + if (message?.type !== responseType || result?.[correlationKey] !== requestId) return; + response.resolve(result); + }; + const timeout = options.receiver.setTimeout(() => response.reject(new Error(`The page request ${type} timed out.`)), requestOptions.timeoutMilliseconds ?? options.timeoutMilliseconds ?? 5_000); + options.receiver.addEventListener('message', receive); + signal.addEventListener('abort', abort, { once: true }); + try { + options.target.postMessage({ type, payload: { ...payload, [correlationKey]: requestId } }, options.targetOrigin); + return await response.promise; + } finally { + options.receiver.clearTimeout(timeout); + options.receiver.removeEventListener('message', receive); + signal.removeEventListener('abort', abort); + } + }, + dispose() { + lifetime.abort(new Error('The page request bridge was disposed.')); + }, + }; +} + +function record(value: unknown): Readonly> | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined; +} diff --git a/packages/extension/test/notifications-headless.test.ts b/packages/extension/test/notifications-headless.test.ts new file mode 100644 index 0000000..8ccea6f --- /dev/null +++ b/packages/extension/test/notifications-headless.test.ts @@ -0,0 +1,18 @@ +import { expect, it, vi } from 'vitest'; + +import { createBrowserControlNotificationController } from '../src/notifications.js'; + +it('runs without a document, window or theme listeners', () => { + expect.assertions(2); + vi.stubGlobal('window', undefined); + vi.stubGlobal('document', undefined); + const subscribe = vi.fn(); + const controller = createBrowserControlNotificationController({ onReview: async () => {}, onRevoke: async () => {} }); + const unsubscribe = controller.subscribe(subscribe); + controller.update({ requests: [], grants: [] }); + expect(controller.snapshot()).toEqual({ requests: [], grants: [] }); + expect(subscribe).toHaveBeenCalled(); + unsubscribe(); + controller.dispose(); + vi.unstubAllGlobals(); +}); diff --git a/packages/extension/test/notifications.test.ts b/packages/extension/test/notifications.test.ts index 8cd2f94..6ba3d0e 100644 --- a/packages/extension/test/notifications.test.ts +++ b/packages/extension/test/notifications.test.ts @@ -73,3 +73,32 @@ it('preserves focused notification controls across equivalent broker publication renderer.dispose(); controller.dispose(); }); + +it('changes theme and color mode without replacing focused controls or pending actions', async () => { + expect.assertions(9); + const pending = Promise.withResolvers(); + const controller = createBrowserControlNotificationController({ onReview: async () => pending.promise, onRevoke: async () => {} }); + controller.update({ requests: [{ ...request, expiresAt: null }], grants: [] }); + const renderer = renderBrowserControlNotifications({ controller, container: document.body, theme: { light: { primary: 'green' } }, branding: { accent: 'purple' }, css: ':host { --cdb-primary: orange; }' }); + const host = document.querySelector('section')!; + const root = host.shadowRoot!; + const review = root.querySelectorAll('button')[1]!; + review.focus(); + expect(host.dataset.colorMode).toBe('system'); + expect(root.querySelector('style')!.textContent).toContain('prefers-color-scheme:dark'); + renderer.setColorMode('dark'); + renderer.setTheme({ spacing: '20px', dark: { text: 'yellow' } }); + expect(host.dataset.colorMode).toBe('dark'); + expect(root.activeElement).toBe(review); + expect(root.querySelector('style')!.textContent).toContain('--cdb-spacing:20px'); + expect(root.querySelector('style')!.textContent).toContain('--cdb-primary:purple'); + expect(root.querySelector('style')!.textContent.trim().endsWith(':host { --cdb-primary: orange; }')).toBe(true); + review.click(); + renderer.setColorMode('light'); + expect(root.querySelectorAll('button')[1]).toBe(review); + pending.resolve(); + await vi.waitUntil(() => !review.disabled); + expect(host.dataset.colorMode).toBe('light'); + renderer.dispose(); + controller.dispose(); +}); diff --git a/packages/extension/test/page-request.test.ts b/packages/extension/test/page-request.test.ts new file mode 100644 index 0000000..de3f77f --- /dev/null +++ b/packages/extension/test/page-request.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest'; + +import { createPageRequestBridge } from '../src/page-request.js'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +it('matches source, origin, response type and correlation before delivering an acknowledgement', async () => { + expect.assertions(3); + const post = vi.spyOn(window, 'postMessage').mockImplementation(() => {}); + const remove = vi.spyOn(window, 'removeEventListener'); + const bridge = createPageRequestBridge({ receiver: window, target: window, targetOrigin: 'https://example.test', correlationKey: 'correlation' }); + const response = bridge.request('approve', 'approved', { requestId: 'grant-request' }); + const sent = post.mock.calls[0]![0] as { payload: Record }; + let resolved = false; + void response.then(() => { + resolved = true; + }); + const payload = { ...sent.payload, acknowledged: false }; + const deliver = (source: Window | null, origin: string, type: string, reply: Readonly> = payload): void => { + window.dispatchEvent(new MessageEvent('message', { source, origin, data: { type, payload: reply } })); + }; + deliver(null, 'https://example.test', 'approved'); + deliver(window, 'https://other.test', 'approved'); + deliver(window, 'https://example.test', 'wrong'); + deliver(window, 'https://example.test', 'approved', { ...payload, correlation: 'wrong' }); + await Promise.resolve(); + expect(resolved).toBe(false); + deliver(window, 'https://example.test', 'approved'); + await expect(response).resolves.toEqual(payload); + expect(remove).toHaveBeenCalledWith('message', expect.any(Function)); + bridge.dispose(); +}); + +it('cleans up on deadline, cancellation and disposal without affecting other requests', async () => { + expect.assertions(6); + vi.useFakeTimers(); + vi.spyOn(window, 'postMessage').mockImplementation(() => {}); + const remove = vi.spyOn(window, 'removeEventListener'); + const bridge = createPageRequestBridge({ receiver: window, target: window, targetOrigin: '*', timeoutMilliseconds: 100 }); + const controller = new AbortController(); + const cancelled = bridge.request('one', 'response', {}, { signal: controller.signal }); + const timedOut = bridge.request('two', 'response'); + const cancelledAssertion = expect(cancelled).rejects.toThrow('Cancelled'); + controller.abort(new Error('Cancelled')); + await cancelledAssertion; + const timeoutAssertion = expect(timedOut).rejects.toThrow('timed out'); + await vi.advanceTimersByTimeAsync(100); + await timeoutAssertion; + const disposed = bridge.request('three', 'response'); + const disposedAssertion = expect(disposed).rejects.toThrow('disposed'); + bridge.dispose(); + await disposedAssertion; + await expect(bridge.request('four', 'response')).rejects.toThrow('disposed'); + expect(remove).toHaveBeenCalledTimes(3); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/packages/extension/test/scaffold.test.ts b/packages/extension/test/scaffold.test.ts index beb8711..0418f14 100644 --- a/packages/extension/test/scaffold.test.ts +++ b/packages/extension/test/scaffold.test.ts @@ -6,7 +6,7 @@ import * as testingModule from '../src/testing.js'; it('keeps the extension public entries importable', () => { expect.assertions(3); - expect(Object.keys(indexModule)).toEqual(['sendAgentHeartbeat', 'createAgentRecovery', 'createApprovalChannel', 'createExtensionApprovalSenderValidator', 'createBirpcAgentBootstrap', 'createBirpcOfferContentRelay', 'installBirpcOfferRuntimeHandler', 'parseBirpcConnectionOffer', 'createBrokerTabAssignment', 'createChildSessionRouter', 'createIndexedDbPairingStore', 'createSelectedTabLifecycle', 'createSelectedTabPublisher', 'createTabScopeLifecycle', 'createTabScopeManager', 'matchesTabScope', 'parseTabScopeSelector']); + expect(Object.keys(indexModule)).toEqual(['sendAgentHeartbeat', 'createAgentRecovery', 'createApprovalChannel', 'createExtensionApprovalSenderValidator', 'createBirpcAgentBootstrap', 'createBirpcOfferContentRelay', 'installBirpcOfferRuntimeHandler', 'parseBirpcConnectionOffer', 'createBrokerTabAssignment', 'createChildSessionRouter', 'createIndexedDbPairingStore', 'createSelectedTabLifecycle', 'createSelectedTabPublisher', 'createTabScopeLifecycle', 'createTabScopeManager', 'matchesTabScope', 'parseTabScopeSelector', 'createPageRequestBridge']); expect(Object.keys(bootstrapModule)).toEqual(['parseBirpcConnectionOffer', 'createBirpcOfferContentRelay', 'createBirpcAgentBootstrap', 'installBirpcOfferRuntimeHandler']); expect(Object.keys(testingModule)).toEqual([]); }); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 1eb6e91..7ef6ce5 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1338,6 +1338,8 @@ export interface CdbToolInvocationContext { } export interface CdbToolDefinition { + /** Conservative effect classification supplied by CDB, independent of tool naming. */ + readonly safety?: 'read' | 'action' | 'destructive'; readonly description: string; readonly inputSchema: Record; readonly mcpInputSchema: z.ZodObject; @@ -3105,7 +3107,7 @@ function createCdbToolDefinitionsForSession( const timing = resolveMcpTimingPolicy(options.timing); const register = ( name: string, - config: { readonly description: string; readonly inputSchema: InputSchema }, + config: { readonly description: string; readonly inputSchema: InputSchema; readonly safety: NonNullable }, handler: ( input: z.output, context: { readonly mcpReq: { readonly signal: AbortSignal } }, @@ -3116,6 +3118,7 @@ function createCdbToolDefinitionsForSession( Object.defineProperty(config.inputSchema, '~standard', { value: { ...config.inputSchema['~standard'], jsonSchema: { input: () => inputSchema, output: () => inputSchema } } }); definitions.push({ description: config.description, + safety: config.safety, inputSchema, async invoke(input, context = { signal: new AbortController().signal }) { if (sessionState.disposed) @@ -3319,6 +3322,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.list_targets', { + safety: 'read', description: 'List currently granted browser targets using stable session-local references. Target references survive navigation and authority renewal.', inputSchema: z.object({}), @@ -3339,6 +3343,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.list_target_authorities', { + safety: 'read', description: 'List trusted diagnostic target IDs, generations, and scopes for raw CDB operations.', inputSchema: z.object({}), }, @@ -3353,6 +3358,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.find', { + safety: 'read', description: 'Find fresh element refs using a strict semantic locator. Refs are disposable and must be refreshed after page changes.', inputSchema: z.object({ locator: locatorSchema, @@ -3410,6 +3416,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.acquire', { + safety: 'action', description: 'Acquire an explicit target lease for exact Chrome DevTools Protocol command or event names (for example, "Runtime.evaluate"). Semantic browser tool names such as "evaluate" are not valid requested methods. Do not request or call domain enable/disable methods: CDB owns domain lifecycle and activates a leased domain before its first command or subscription.', inputSchema: z.object({ @@ -3442,6 +3449,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.renew', { + safety: 'action', description: 'Renew an explicit target lease.', inputSchema: z.object({ ...leaseInput, leaseId: z.string().uuid() }), }, @@ -3456,6 +3464,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.release', { + safety: 'action', description: 'Release an explicit target lease.', inputSchema: z.object({ targetGeneration: z.number().int().nonnegative(), @@ -3475,6 +3484,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.release_artifact', { + safety: 'action', description: 'Release an authorized artifact through its owning lease.', inputSchema: z.object({ artifactId: z.string().uuid(), @@ -3495,6 +3505,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.read_artifact', { + safety: 'read', description: 'Read one bounded, authorized artifact range as base64.', inputSchema: z.object({ artifactId: z.string().uuid(), @@ -3540,6 +3551,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.inspect', { + safety: 'read', description: 'Read structured page or element attributes, accessibility state, and geometry without executing JavaScript.', inputSchema: z.object({ @@ -3646,6 +3658,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.raw_cdp', { + safety: 'destructive', description: 'Execute any catalogued Chrome DevTools Protocol command allowed by the grant through an explicit lease. Domain enable/disable methods are broker-owned and must be omitted; CDB activates the domain before the first leased command.', inputSchema: z.object({ @@ -3693,6 +3706,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.snapshot', { + safety: 'read', description: 'Capture a fresh accessibility snapshot with disposable element refs. Interactive mode is compact and actionable; accessibility is complete and bounded; DOM is diagnostic.', inputSchema: z.object({ @@ -3857,6 +3871,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.screenshot', { + safety: 'read', description: 'Capture a screenshot as image content. Debug sessions preserve the inline-or-artifact protocol result.', inputSchema: z.object({ @@ -3883,6 +3898,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.network_body', { + safety: 'read', description: 'Read a network response body, returning the bridge inline-or-artifact result without base64 expansion.', inputSchema: z.object({ @@ -3910,6 +3926,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.evaluate', { + safety: 'destructive', description: 'Debug escape hatch: evaluate page JavaScript. This bypasses locator guarantees and visible pointer presentation.', inputSchema: z.object({ @@ -3938,6 +3955,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.navigate', { + safety: 'action', description: 'Navigate a target and retain its stable target reference across the new document.', inputSchema: z.object({ ...lifecycleInput, url: z.url() }), @@ -3967,6 +3985,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.back', { + safety: 'action', description: 'Navigate one entry back while retaining the stable target reference.', inputSchema: z.object(lifecycleInput), }, @@ -3988,6 +4007,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.forward', { + safety: 'action', description: 'Navigate one entry forward while retaining the stable target reference.', inputSchema: z.object(lifecycleInput), }, @@ -4009,6 +4029,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.reload', { + safety: 'action', description: 'Reload a target while retaining the stable target reference.', inputSchema: z.object(lifecycleInput), }, @@ -4036,6 +4057,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.click', { + safety: 'action', description: 'Click one element by a fresh ref or a strict locator after bounded actionability checks.', inputSchema: z.object({ button: pointerButtonSchema, @@ -4088,6 +4110,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.click_at', { + safety: 'action', description: 'Click explicit viewport coordinates. Prefer browser.click with a ref or locator.', inputSchema: z.object({ @@ -4167,6 +4190,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.move_at', { + safety: 'action', description: 'Move or hover the pointer at explicit viewport coordinates. Prefer browser.hover when a snapshot reference or locator is available.', inputSchema: z.object({ @@ -4216,6 +4240,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.scroll_at', { + safety: 'action', description: 'Dispatch a bounded mouse-wheel scroll at explicit viewport coordinates.', inputSchema: z.object({ ...inputActionTiming, @@ -4274,6 +4299,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.drag_at', { + safety: 'action', description: 'Drag the pointer along a bounded viewport path.', inputSchema: z.object({ button: pointerButtonSchema, @@ -4368,6 +4394,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.drag', { + safety: 'action', description: 'Drag from one element to another using fresh refs or strict locators.', inputSchema: z.object({ button: pointerButtonSchema, @@ -4520,6 +4547,7 @@ function createCdbToolDefinitionsForSession( register( name, { + safety: 'action', description, inputSchema: z.object({ ...elementTargetShape, @@ -4617,6 +4645,7 @@ function createCdbToolDefinitionsForSession( register( name, { + safety: 'action', description: `${desiredState ? 'Check' : 'Uncheck'} a checkbox or switch selected by ref or locator.`, inputSchema: z.object({ ...elementTargetShape, @@ -4683,6 +4712,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.batch', { + safety: 'action', description: 'Run up to 20 ordered element actions on one target under one lease. Stops on failure, cancellation, authority or document replacement. Never rolls back or replays input.', inputSchema: z.object({ targetRef: z.string().regex(targetReferencePattern), @@ -4791,6 +4821,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.select_option', { + safety: 'action', description: 'Focus a select element and choose an option by its visible label.', inputSchema: z.object({ ...elementTargetShape, @@ -4874,6 +4905,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.wait_for_navigation', { + safety: 'read', description: 'Wait for a bounded root or child-session navigation milestone and fail promptly on JavaScript dialogs.', inputSchema: z.object(lifecycleInput), }, @@ -4908,6 +4940,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.wait_for_dialog', { + safety: 'read', description: 'Wait for one JavaScript dialog in a root or opaque child session.', inputSchema: z.object(eventWaitInput), }, @@ -4931,6 +4964,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.handle_dialog', { + safety: 'action', description: 'Accept or dismiss the current JavaScript dialog through an exclusive lease.', inputSchema: z.object({ targetRef: z.string().regex(targetReferencePattern), @@ -4965,6 +4999,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.console', { + safety: 'read', description: 'Wait for one console event through a bounded subscription. An explicit lease keeps its event domain active until that lease is released.', inputSchema: z.object(eventWaitInput), @@ -4995,6 +5030,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.network', { + safety: 'read', description: 'Wait for one network event through a bounded subscription. An explicit lease keeps its event domain active until that lease is released.', inputSchema: z.object(eventWaitInput), @@ -5025,6 +5061,7 @@ function createCdbToolDefinitionsForSession( register( 'browser.wait_for', { + safety: 'read', description: 'Wait for an explicitly named CDP event through a bounded subscription. An explicit lease keeps its event domain active until that lease is released.', inputSchema: z.object({ diff --git a/packages/mcp/test/tool-session.test.ts b/packages/mcp/test/tool-session.test.ts index d0d4d23..1d48018 100644 --- a/packages/mcp/test/tool-session.test.ts +++ b/packages/mcp/test/tool-session.test.ts @@ -309,3 +309,15 @@ it('rejects an element ref after its document generation has renewed', async () code: 'MCP_ELEMENT_REF_STALE', }); }); + +it('defines tool safety explicitly for consumers without name heuristics', () => { + expect.assertions(5); + const session = createCdbToolSession({ client: {} as McpChromeDebuggerBridgeClient, enableRawCdp: true }); + const safety = (name: string) => session.definitions.find(definition => definition.name === name)?.safety; + expect(session.definitions.every(definition => definition.safety !== undefined)).toBe(true); + expect(safety('browser.snapshot')).toBe('read'); + expect(safety('browser.click')).toBe('action'); + expect(safety('browser.evaluate')).toBe('destructive'); + expect(safety('browser.raw_cdp')).toBe('destructive'); + session.dispose(); +}); diff --git a/tests/e2e/notification-theme.test.ts b/tests/e2e/notification-theme.test.ts new file mode 100644 index 0000000..7c79d51 --- /dev/null +++ b/tests/e2e/notification-theme.test.ts @@ -0,0 +1,56 @@ +import { readFile } from 'node:fs/promises'; + +import { chromium } from 'playwright'; +import { expect, it } from 'vitest'; + +it('keeps the built renderer responsive across themes, narrow layouts and pending actions', async () => { + expect.assertions(15); + const browser = await chromium.launch({ channel: 'chromium', headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 440, height: 1100 }, colorScheme: 'light' }); + await page.setContent('
'); + const bundle = await readFile(new URL('../../packages/extension/dist/notifications.js', import.meta.url), 'utf8'); + await page.addScriptTag({ type: 'module', content: `${bundle} + const controller = createBrowserControlNotificationController({ onReview: () => new Promise(resolve => { window.complete = resolve; }), onRevoke: async () => {} }); + window.renderer = renderBrowserControlNotifications({ controller, container: document.querySelector('main'), reviewLabel: () => 'Accept', onReject: async () => { window.rejected = true; } }); + controller.update({ grants: [], requests: ['observe', 'inspect', 'interact', 'debug', 'unsafe'].map((level, index) => ({ id: String(index), principalId: 'synthetic', principalLabel: index === 1 ? 'Long synthetic client label wrapping onto its own row' : '3d12', level, navigation: 'same-origin', state: 'pending', createdAt: 0, expiresAt: null })) }); + ` }); + const card = page.locator('article').first(); + await card.waitFor(); + expect(await card.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(255, 255, 255)'); + const geometry = async (index: number) => page.locator('article').nth(index).locator('dl > div').evaluateAll(elements => elements.map(element => ({ top: element.getBoundingClientRect().top, width: element.getBoundingClientRect().width }))); + const short = await geometry(0); + expect(short[0]!.top).toBe(short[1]!.top); + const long = await geometry(1); + expect(long[0]!.top).toBeGreaterThan(long[1]!.top); + expect(long[1]!.top).toBe(long[2]!.top); + expect(new Set(await page.locator('[data-level]').evaluateAll(elements => elements.map(element => getComputedStyle(element).color))).size).toBe(5); + const accept = card.getByRole('button', { name: 'Accept', exact: true }); + await accept.focus(); + await page.evaluate('window.renderer.setTheme({ radius: "16px" })'); + expect(await accept.evaluate((element) => { + const root = element.getRootNode(); + return root instanceof ShadowRoot && root.activeElement === element; + })).toBe(true); + expect(await card.evaluate(element => getComputedStyle(element).borderRadius)).toBe('16px'); + await page.emulateMedia({ colorScheme: 'dark' }); + expect(await card.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(37, 36, 44)'); + await page.evaluate('window.renderer.setColorMode("light")'); + expect(await card.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(255, 255, 255)'); + await page.evaluate('window.renderer.setColorMode("dark")'); + await page.emulateMedia({ colorScheme: 'light' }); + expect(await card.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(37, 36, 44)'); + await accept.click(); + expect(await accept.isDisabled()).toBe(true); + await page.evaluate('window.renderer.setTheme({ dark: { primary: "#123456" } })'); + expect(await accept.isDisabled()).toBe(true); + await page.evaluate('window.complete()'); + await expect.poll(async () => accept.isDisabled()).toBe(false); + await page.setViewportSize({ width: 280, height: 1100 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await card.getByRole('button', { name: 'Dismiss', exact: true }).click(); + expect(await page.locator('article').count()).toBe(4); + } finally { + await browser.close(); + } +}, 30_000); From 6e033da6fc3ae3e86343a0d22a93997b44539bb9 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Sat, 12 Sep 2026 21:02:22 +0200 Subject: [PATCH 2/2] fix: preserve cleanup failures and adopt notification stylesheets --- examples/devframe/host.ts | 3 +- packages/broker/src/contract.ts | 2 +- packages/broker/src/error.ts | 13 +- packages/broker/src/runtime.ts | 15 +- packages/broker/test/error.test.ts | 12 +- packages/devframe/src/client.ts | 27 ++- packages/devframe/test/connection.test.ts | 36 ++++ packages/extension/src/notification-theme.ts | 189 ++++++++++++++---- packages/extension/src/notifications.ts | 142 ++++++++----- packages/extension/test/notifications.test.ts | 73 +------ tests/browser/notification-renderer.test.ts | 83 ++++++++ 11 files changed, 419 insertions(+), 176 deletions(-) create mode 100644 tests/browser/notification-renderer.test.ts diff --git a/examples/devframe/host.ts b/examples/devframe/host.ts index c4a258f..53406fc 100644 --- a/examples/devframe/host.ts +++ b/examples/devframe/host.ts @@ -10,8 +10,9 @@ import { getTempAuthCode } from 'devframe/node/auth'; import { createDevframeExample } from './devframe.ts'; async function main(): Promise { + const identityDirectory = process.env.CDB_IDENTITY_DIRECTORY ?? join(import.meta.dirname, 'dist', 'identity'); const example = createDevframeExample(defineBroker({ - identityStore: await createFileBrokerIdentityStore(process.env.CDB_IDENTITY_DIRECTORY ?? join(import.meta.dirname, 'dist', 'identity')), + identityStore: await createFileBrokerIdentityStore(identityDirectory), navigation: { default: 'same-origin', allowed: ['same-origin', 'follow-tab'] }, })); const extensionOrigin = process.env.CDB_EXTENSION_ORIGIN; diff --git a/packages/broker/src/contract.ts b/packages/broker/src/contract.ts index 5fae748..e675724 100644 --- a/packages/broker/src/contract.ts +++ b/packages/broker/src/contract.ts @@ -147,4 +147,4 @@ export type BrokerTool = Omit; export type { AgentAuthenticationTranscript, AuthorityBinding, GrantRequestClaim, LogicalSessionCredential }; -export { type BrowserControlError, browserControlToolError, normalizeBrowserControlError } from './error.js'; +export { type BrowserControlError, type BrowserControlErrorData, browserControlToolError, normalizeBrowserControlError } from './error.js'; diff --git a/packages/broker/src/error.ts b/packages/broker/src/error.ts index ad6308d..cb9ed73 100644 --- a/packages/broker/src/error.ts +++ b/packages/broker/src/error.ts @@ -5,13 +5,15 @@ export class BrokerError extends Error { readonly retryable = false, readonly retryAfterMilliseconds?: number, readonly details?: Readonly>, + options?: ErrorOptions, ) { - super(message); + super(message, options); this.name = 'BrokerError'; } } -export interface BrowserControlError { +/** Public error data sent over RPC. Local exception causes and stacks stay with the host. */ +export interface BrowserControlErrorData { readonly code: string; readonly message: string; readonly retryable: boolean; @@ -19,8 +21,11 @@ export interface BrowserControlError { readonly details?: Readonly>; } +/** @deprecated Use BrowserControlErrorData for the serialized error shape. */ +export type BrowserControlError = BrowserControlErrorData; + /** Preserve protocol errors and semantic MCP errors without copying their evolving code catalogue. */ -export function normalizeBrowserControlError(error: unknown): BrowserControlError { +export function normalizeBrowserControlError(error: unknown): BrowserControlErrorData { const record = error !== null && typeof error === 'object' ? error as Record : {}; const retryDelay = record.retryAfterMilliseconds ?? record.retryAfterMs; return { @@ -33,7 +38,7 @@ export function normalizeBrowserControlError(error: unknown): BrowserControlErro } /** Returns a semantic failure only for an MCP error result; successful observations are never parsed as errors. */ -export function browserControlToolError(result: unknown): BrowserControlError | undefined { +export function browserControlToolError(result: unknown): BrowserControlErrorData | undefined { if (result === null || typeof result !== 'object' || !('isError' in result) || result.isError !== true) return undefined; if ('content' in result && Array.isArray(result.content)) { for (const content of result.content as unknown[]) { diff --git a/packages/broker/src/runtime.ts b/packages/broker/src/runtime.ts index 106aee8..9d1b18d 100644 --- a/packages/broker/src/runtime.ts +++ b/packages/broker/src/runtime.ts @@ -1,5 +1,5 @@ import type { AgentTargetConnection, AuthorityBinding, GrantedTargetReference, GrantRequest, GrantRequestClaim, GrantRequestProvider, JsonValue, LogicalSessionCredential } from '@dvcol/cdb'; -import type { CdbToolInvocationContext, McpChromeDebuggerBridgeClient } from '@dvcol/cdb-mcp'; +import type { CdbToolDefinition, CdbToolInvocationContext, McpChromeDebuggerBridgeClient } from '@dvcol/cdb-mcp'; import type { AgentAuthenticationTranscript, BrokerAuthenticationClaims } from '@dvcol/cdb/authentication'; import type { BrokerDefinition } from './config.js'; @@ -18,6 +18,15 @@ import { createBrokerSession } from './session-client.js'; const accessLevels: readonly AccessLevel[] = ['observe', 'inspect', 'interact', 'debug', 'unsafe']; +function toolDescriptor({ name, description, inputSchema, safety }: CdbToolDefinition): BrokerTool { + return { + name, + description, + inputSchema, + ...(safety === undefined ? {} : { safety }), + }; +} + interface Provider { readonly registration: ProviderRegistration; readonly peer: BrokerPeer; @@ -287,7 +296,7 @@ export async function createBroker(configuration: BrokerDefinition = {}): Promis pendingRequests.delete(requestId); const code = reason === 'expired' ? 'ACCESS_REQUEST_TIMEOUT' : reason === 'rejected' ? 'ACCESS_REQUEST_REJECTED' : 'ACCESS_REQUEST_CANCELLED'; const message = reason === 'expired' ? 'The browser access request expired.' : reason === 'rejected' ? 'The browser access request was rejected.' : 'The browser access request was cancelled.'; - pending.reject(new BrokerError(error?.code ?? code, error?.message ?? message, error?.retryable ?? false)); + pending.reject(new BrokerError(error?.code ?? code, error?.message ?? message, error?.retryable ?? false, undefined, undefined, { cause: error })); } changed(); }); @@ -515,7 +524,7 @@ export async function createBroker(configuration: BrokerDefinition = {}): Promis toolProvider: { name: '@dvcol/cdb', version: packageManifest.version }, connectSession, snapshot, - tools: [requestTool, ...descriptors.definitions.map(({ name, description, inputSchema, safety }) => ({ name, description, inputSchema, ...(safety === undefined ? {} : { safety }) }))], + tools: [requestTool, ...descriptors.definitions.map(toolDescriptor)], subscribe(listener: (state: BrokerState) => void) { ensureActive(); listeners.add(listener); diff --git a/packages/broker/test/error.test.ts b/packages/broker/test/error.test.ts index 868f865..04282cf 100644 --- a/packages/broker/test/error.test.ts +++ b/packages/broker/test/error.test.ts @@ -1,6 +1,6 @@ import { expect, it } from 'vitest'; -import { browserControlToolError, normalizeBrowserControlError } from '../src/error.js'; +import { BrokerError, browserControlToolError, normalizeBrowserControlError } from '../src/error.js'; it('preserves arbitrary protocol codes, retry delays and uncertain dispatch details', () => { expect.assertions(2); @@ -18,3 +18,13 @@ it('inspects semantic failures while leaving successful text payloads alone', () expect(browserControlToolError({ content: [{ type: 'text', text: 'Unstructured' }], isError: true })).toEqual({ code: 'CDB_OPERATION_FAILED', message: 'The browser tool failed.', retryable: false }); expect(normalizeBrowserControlError('Failed')).toEqual({ code: 'CDB_OPERATION_FAILED', message: 'Failed', retryable: false }); }); + +it('retains the local exception cause while serializing only public error data', () => { + expect.assertions(4); + const cause = new Error('Private storage path and implementation detail'); + const error = new BrokerError('STORAGE_FAILED', 'Could not save the grant.', true, 20, { operation: 'save' }, { cause }); + expect(error).toBeInstanceOf(Error); + expect(error.cause).toBe(cause); + expect(normalizeBrowserControlError(error)).toEqual({ code: 'STORAGE_FAILED', message: 'Could not save the grant.', retryable: true, retryAfterMilliseconds: 20, details: { operation: 'save' } }); + expect(JSON.stringify(normalizeBrowserControlError(error))).not.toContain(cause.message); +}); diff --git a/packages/devframe/src/client.ts b/packages/devframe/src/client.ts index 5e679cb..885427e 100644 --- a/packages/devframe/src/client.ts +++ b/packages/devframe/src/client.ts @@ -41,6 +41,11 @@ export interface CdbClient { const clients = new WeakMap(); +function replyValue(result: CdbReply): Value { + if (!result.ok) throw Object.assign(new Error(result.error.message), result.error); + return result.value; +} + export interface CdbClientSession { connect: (client: CdbClient) => Promise; terminate: (client: CdbClient) => Promise; @@ -95,8 +100,7 @@ export function createCdbClient(client: CdbDevframeClient): CdbClient { async function call(name: string, ...arguments_: unknown[]): Promise { if (disposed) throw new Error('The CDB client has been disposed.'); const result = await rpc.call(name, ...arguments_) as CdbReply; - if (!result.ok) throw Object.assign(new Error(result.error.message), result.error); - return result.value; + return replyValue(result); } function publish(value: BrokerState): void { @@ -235,14 +239,17 @@ export function createCdbClient(client: CdbDevframeClient): CdbClient { }, async dispose() { if (disposed) return; - provider?.close(1000, 'CDB client disposed'); - disposed = true; - clients.delete(client); - const subscribed = watching !== undefined; - stateListeners.clear(); - watching = undefined; - state = undefined; - if (subscribed) await rpc.call('unwatch'); + try { + provider?.close(1000, 'CDB client disposed'); + } finally { + disposed = true; + clients.delete(client); + const subscribed = watching !== undefined; + stateListeners.clear(); + watching = undefined; + state = undefined; + if (subscribed) replyValue(await rpc.call('unwatch') as CdbReply); + } }, }; clients.set(client, handle); diff --git a/packages/devframe/test/connection.test.ts b/packages/devframe/test/connection.test.ts index 49c9139..4bb37fe 100644 --- a/packages/devframe/test/connection.test.ts +++ b/packages/devframe/test/connection.test.ts @@ -4,6 +4,7 @@ import type { CdbDevframeClient } from '../src/wire.js'; import { expect, it, vi } from 'vitest'; +import { createCdbClient } from '../src/client.js'; import { createCdbConnection } from '../src/connection.js'; const state: BrokerState = { revision: 1, providers: [], principals: [], requests: [], targets: [], grants: [], leases: [], scopes: [] }; @@ -101,3 +102,38 @@ it('can attach the same transport after disconnection and rejects obsolete resul await connection.dispose(); expect(fixture.close).not.toHaveBeenCalled(); }); + +it.each(['transport', 'protocol'])('cleans up listeners and reports %s unsubscribe failure', async (failureKind) => { + expect.assertions(5); + const fixture = peer(); + const client = createCdbClient(fixture.transport); + const listener = vi.fn(); + await client.watch(listener); + fixture.call.mockImplementation(async (name) => { + if (name !== 'unwatch') return { ok: true, value: state }; + if (failureKind === 'transport') throw new Error('Unsubscribe failed'); + return { ok: false, error: { code: 'UNWATCH_FAILED', message: 'Unsubscribe failed', retryable: false } }; + }); + await expect(client.dispose()).rejects.toThrow('Unsubscribe failed'); + fixture.handlers.get('state-changed')!({ ...state, revision: 2 }); + expect(listener).toHaveBeenCalledOnce(); + await expect(client.snapshot()).rejects.toThrow('disposed'); + expect(createCdbClient(fixture.transport)).not.toBe(client); + expect(fixture.close).not.toHaveBeenCalled(); +}); + +it('fences a watch that completes after disposal', async () => { + expect.assertions(3); + const fixture = peer(); + const pending = Promise.withResolvers(); + fixture.call.mockImplementation(async name => name === 'watch' ? pending.promise : { ok: true, value: undefined }); + const client = createCdbClient(fixture.transport); + const listener = vi.fn(); + const watching = client.watch(listener); + const rejected = expect(watching).rejects.toThrow('disposed'); + await client.dispose(); + pending.resolve({ ok: true, value: state }); + await rejected; + expect(listener).not.toHaveBeenCalled(); + expect(fixture.close).not.toHaveBeenCalled(); +}); diff --git a/packages/extension/src/notification-theme.ts b/packages/extension/src/notification-theme.ts index d3e79c0..7c281a3 100644 --- a/packages/extension/src/notification-theme.ts +++ b/packages/extension/src/notification-theme.ts @@ -79,48 +79,169 @@ export const defaultBrowserControlNotificationTheme: BrowserControlNotificationT /** Renderer-only styles; the headless controller never evaluates or installs them. */ export const browserControlNotificationStyles = ` - :host { display:block; color:var(--cdb-text); font:12px/1.4 var(--cdb-font); } - * { box-sizing:border-box; } - article { position:relative; margin:8px 0; padding:0 var(--cdb-spacing) 12px; background:var(--cdb-surface); color:var(--cdb-text); border:1px solid var(--cdb-border); border-radius:var(--cdb-radius); box-shadow:var(--cdb-shadow); text-align:right; } - h2 { margin:0 calc(-1 * var(--cdb-spacing)) 10px; padding:10px 42px 10px var(--cdb-spacing); border-radius:var(--cdb-radius) var(--cdb-radius) 0 0; background:var(--cdb-surface-muted); font:700 13px/1.3 var(--cdb-font); text-align:left; } - p { margin:0 0 8px; color:var(--cdb-text-muted); text-align:left; overflow-wrap:anywhere; } - button { border:1px solid var(--cdb-primary); border-radius:var(--cdb-control-radius); padding:7px 11px; font:600 12px/1.2 var(--cdb-font); background:var(--cdb-surface-muted); color:var(--cdb-secondary-text); cursor:pointer; } - footer { display:flex; justify-content:flex-end; gap:8px; } - footer button:first-of-type { background:var(--cdb-primary); color:var(--cdb-primary-text); } - footer button:first-of-type:hover { background:var(--cdb-primary-hover); } - .dismiss { position:absolute; top:5px; right:6px; background:transparent; color:var(--cdb-text-muted); border:0; padding:5px 8px; font-size:18px; } - dl { display:flex; flex-wrap:wrap; gap:1px; margin:12px 0; background:var(--cdb-border); border:1px solid var(--cdb-border); border-radius:var(--cdb-control-radius); overflow:hidden; } - dl > div { flex:1 0 100px; min-width:0; padding:9px 6px; text-align:center; background:var(--cdb-surface-muted); } - dl > div:first-child { order:1; flex-basis:auto; width:max-content; min-width:100px; max-width:100%; } - dt { color:var(--cdb-text-muted); font-weight:600; } - dd { margin:5px 0 0; overflow-wrap:anywhere; } - code { display:inline-block; padding:3px; background:transparent; font:12px/1.4 var(--cdb-monospace-font); } - [data-level=observe] { color:var(--cdb-observe); } - [data-level=inspect] { color:var(--cdb-inspect); } - [data-level=interact] { color:var(--cdb-interact); } - [data-level=debug] { color:var(--cdb-debug); } - [data-level=unsafe] { color:var(--cdb-unsafe); font-weight:700; } - button:focus-visible { outline:2px solid var(--cdb-primary); outline-offset:2px; } - button:disabled { opacity:.6; cursor:wait; } - [role=alert] { color:var(--cdb-error); } + :host { + display: block; + color: var(--cdb-text); + font: 12px/1.4 var(--cdb-font); + } + * { + box-sizing: border-box; + } + article { + position: relative; + margin: 8px 0; + padding: 0 var(--cdb-spacing) 12px; + background: var(--cdb-surface); + color: var(--cdb-text); + border: 1px solid var(--cdb-border); + border-radius: var(--cdb-radius); + box-shadow: var(--cdb-shadow); + text-align: right; + } + h2 { + margin: 0 calc(-1 * var(--cdb-spacing)) 10px; + padding: 10px 42px 10px var(--cdb-spacing); + border-radius: var(--cdb-radius) var(--cdb-radius) 0 0; + background: var(--cdb-surface-muted); + font: 700 13px/1.3 var(--cdb-font); + text-align: left; + } + p { + margin: 0 0 8px; + color: var(--cdb-text-muted); + text-align: left; + overflow-wrap: anywhere; + } + button { + border: 1px solid var(--cdb-primary); + border-radius: var(--cdb-control-radius); + padding: 7px 11px; + font: 600 12px/1.2 var(--cdb-font); + background: var(--cdb-surface-muted); + color: var(--cdb-secondary-text); + cursor: pointer; + } + footer { + display: flex; + justify-content: flex-end; + gap: 8px; + } + footer button:first-of-type { + background: var(--cdb-primary); + color: var(--cdb-primary-text); + } + footer button:first-of-type:hover { + background: var(--cdb-primary-hover); + } + .dismiss { + position: absolute; + top: 5px; + right: 6px; + background: transparent; + color: var(--cdb-text-muted); + border: 0; + padding: 5px 8px; + font-size: 18px; + } + dl { + display: flex; + flex-wrap: wrap; + gap: 1px; + margin: 12px 0; + background: var(--cdb-border); + border: 1px solid var(--cdb-border); + border-radius: var(--cdb-control-radius); + overflow: hidden; + } + dl > div { + flex: 1 0 100px; + min-width: 0; + padding: 9px 6px; + text-align: center; + background: var(--cdb-surface-muted); + } + dl > div:first-child { + order: 1; + flex-basis: auto; + width: max-content; + min-width: 100px; + max-width: 100%; + } + dt { + color: var(--cdb-text-muted); + font-weight: 600; + } + dd { + margin: 5px 0 0; + overflow-wrap: anywhere; + } + code { + display: inline-block; + padding: 3px; + background: transparent; + font: 12px/1.4 var(--cdb-monospace-font); + } + [data-level=observe] { + color: var(--cdb-observe); + } + [data-level=inspect] { + color: var(--cdb-inspect); + } + [data-level=interact] { + color: var(--cdb-interact); + } + [data-level=debug] { + color: var(--cdb-debug); + } + [data-level=unsafe] { + color: var(--cdb-unsafe); + font-weight: 700; + } + button:focus-visible { + outline: 2px solid var(--cdb-primary); + outline-offset: 2px; + } + button:disabled { + opacity: .6; + cursor: wait; + } + [role=alert] { + color: var(--cdb-error); + } `; const uppercaseCharacter = /[A-Z]/g; -function variables(tokens: Readonly>): string { - return Object.entries(tokens).map(([name, value]) => `--cdb-${name.replaceAll(uppercaseCharacter, character => `-${character.toLowerCase()}`)}:${value};`).join(''); +function applyVariables(style: CSSStyleDeclaration, tokens: Readonly>): void { + for (const [name, value] of Object.entries(tokens)) { + const property = `--cdb-${name.replaceAll(uppercaseCharacter, character => `-${character.toLowerCase()}`)}`; + style.setProperty(property, value); + } } -export function notificationThemeStyles(theme: BrowserControlNotificationThemeOverrides = {}, accent?: string): string { +/** Updates only theme rules, preserving controls and the later custom stylesheet. */ +export function updateNotificationTheme( + stylesheet: CSSStyleSheet, + theme: BrowserControlNotificationThemeOverrides = {}, + accent?: string, +): void { const { light, dark, ...shared } = defaultBrowserControlNotificationTheme; const { light: lightOverrides, dark: darkOverrides, ...sharedOverrides } = theme; const branding = accent === undefined ? {} : { primary: accent, primaryHover: accent, accent }; - return ` - :host { ${variables({ ...shared, ...sharedOverrides })} } - :host, :host([data-color-mode=light]) { color-scheme:light; ${variables({ ...light, ...lightOverrides, ...branding })} } - :host([data-color-mode=dark]) { color-scheme:dark; ${variables({ ...dark, ...darkOverrides, ...branding })} } - @media (prefers-color-scheme:dark) { - :host([data-color-mode=system]) { color-scheme:dark; ${variables({ ...dark, ...darkOverrides, ...branding })} } + stylesheet.replaceSync(` + :host {} + :host, :host(:where([data-color-mode=light])) { color-scheme: light; } + :host(:where([data-color-mode=dark])) { color-scheme: dark; } + @media (prefers-color-scheme: dark) { + :host(:where([data-color-mode=system])) { color-scheme: dark; } } - `; + `); + const sharedRule = stylesheet.cssRules[0] as CSSStyleRule; + const lightRule = stylesheet.cssRules[1] as CSSStyleRule; + const darkRule = stylesheet.cssRules[2] as CSSStyleRule; + const systemRule = (stylesheet.cssRules[3] as CSSMediaRule).cssRules[0] as CSSStyleRule; + applyVariables(sharedRule.style, { ...shared, ...sharedOverrides }); + applyVariables(lightRule.style, { ...light, ...lightOverrides, ...branding }); + applyVariables(darkRule.style, { ...dark, ...darkOverrides, ...branding }); + applyVariables(systemRule.style, { ...dark, ...darkOverrides, ...branding }); } diff --git a/packages/extension/src/notifications.ts b/packages/extension/src/notifications.ts index 306c37b..1ff207e 100644 --- a/packages/extension/src/notifications.ts +++ b/packages/extension/src/notifications.ts @@ -2,7 +2,7 @@ import type { BrokerGrant, BrokerRequest, BrokerState } from '@dvcol/cdb-broker/ import type { BrowserControlNotificationColorMode, BrowserControlNotificationThemeOverrides } from './notification-theme.js'; -import { browserControlNotificationStyles, notificationThemeStyles } from './notification-theme.js'; +import { browserControlNotificationStyles, updateNotificationTheme } from './notification-theme.js'; export { defaultBrowserControlNotificationTheme } from './notification-theme.js'; export type { BrowserControlNotificationColorMode, BrowserControlNotificationPalette, BrowserControlNotificationTheme, BrowserControlNotificationThemeOverrides } from './notification-theme.js'; @@ -104,17 +104,29 @@ export interface BrowserControlNotificationRendererOptions { readonly css?: string; } +export interface BrowserControlNotificationRenderer { + dispose: () => void; + setTheme: (theme: BrowserControlNotificationThemeOverrides) => void; + setColorMode: (mode: BrowserControlNotificationColorMode) => void; +} + /** Optional themed presentation. Review buttons delegate to the host's trusted final approval UI. */ -export function renderBrowserControlNotifications(options: BrowserControlNotificationRendererOptions): { dispose: () => void; setTheme: (theme: BrowserControlNotificationThemeOverrides) => void; setColorMode: (mode: BrowserControlNotificationColorMode) => void } { +export function renderBrowserControlNotifications(options: BrowserControlNotificationRendererOptions): BrowserControlNotificationRenderer { const document = options.container.ownerDocument; const host = document.createElement('section'); host.dataset.cdbNotifications = ''; const root = host.attachShadow({ mode: 'open' }); - const style = document.createElement('style'); + const window = document.defaultView; + if (window === null) throw new Error('Notification rendering requires a document attached to a window.'); + const baseStylesheet = new window.CSSStyleSheet(); + const themeStylesheet = new window.CSSStyleSheet(); + const customStylesheet = new window.CSSStyleSheet(); + baseStylesheet.replaceSync(browserControlNotificationStyles); + customStylesheet.replaceSync(options.css ?? ''); + root.adoptedStyleSheets = [baseStylesheet, themeStylesheet, customStylesheet]; + function setTheme(theme: BrowserControlNotificationThemeOverrides): void { - style.textContent = `${notificationThemeStyles(theme, options.branding?.accent)} -${browserControlNotificationStyles} -${options.css ?? ''}`; + updateNotificationTheme(themeStylesheet, theme, options.branding?.accent); } function setColorMode(mode: BrowserControlNotificationColorMode): void { host.dataset.colorMode = mode; @@ -122,31 +134,46 @@ ${options.css ?? ''}`; setTheme(options.theme ?? {}); setColorMode(options.colorMode ?? 'system'); const content = document.createElement('div'); - root.append(style, content); + root.append(content); options.container.append(host); let disposed = false; let renderedNotification: string | undefined; - function button(article: HTMLElement, label: string, action: () => void | Promise): HTMLButtonElement { - const element = document.createElement('button'); - element.type = 'button'; - element.textContent = label; - element.onclick = () => { - element.disabled = true; - void Promise.resolve().then(action).catch((error: unknown) => { - if (disposed) return; - const message = document.createElement('p'); - message.setAttribute('role', 'alert'); - message.textContent = error instanceof Error ? error.message : 'The browser request could not be completed.'; - article.append(message); - }).finally(() => { - element.disabled = false; - }); + async function runAction( + button: HTMLButtonElement, + container: HTMLElement, + action: () => void | Promise, + ): Promise { + button.disabled = true; + try { + await action(); + } catch (error) { + if (disposed) return; + const message = document.createElement('p'); + message.setAttribute('role', 'alert'); + message.textContent = error instanceof Error ? error.message : 'The browser request could not be completed.'; + container.append(message); + } finally { + button.disabled = false; + } + } + + function createActionButton( + container: HTMLElement, + label: string, + action: () => void | Promise, + ): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + button.onclick = () => { + void runAction(button, container, action); }; - article.append(element); - return element; + container.append(button); + return button; } - function article(title: string, description?: string): HTMLElement { + + function createCard(title: string, description?: string): HTMLElement { const element = document.createElement('article'); const heading = document.createElement('h2'); heading.textContent = title; @@ -158,6 +185,28 @@ ${options.css ?? ''}`; } return element; } + function createRequestDetails(request: BrokerRequest): HTMLElement { + const details = document.createElement('dl'); + const values = [ + ['Client', options.clientLabel?.(request) ?? request.principalLabel], + ['Grant', request.level], + ['Navigation', request.navigation], + ] as const; + for (const [label, value] of values) { + const column = document.createElement('div'); + const term = document.createElement('dt'); + term.textContent = label; + const definition = document.createElement('dd'); + const text = document.createElement('code'); + text.textContent = value; + if (label === 'Grant') text.dataset.level = request.level; + definition.append(text); + column.append(term, definition); + details.append(column); + } + return details; + } + function render(notification: BrowserControlNotification): void { if (disposed) return; /** Unrelated broker publications must not replace focused or pending controls. */ @@ -166,45 +215,38 @@ ${options.css ?? ''}`; renderedNotification = serializedNotification; content.replaceChildren(); for (const request of notification.requests) { - const element = article(options.branding?.title ?? 'Browser control requested'); - const dismiss = button(element, '×', () => options.controller.dismiss(request.id)); + const element = createCard(options.branding?.title ?? 'Browser control requested'); + const dismiss = createActionButton(element, '×', () => options.controller.dismiss(request.id)); dismiss.className = 'dismiss'; dismiss.setAttribute('aria-label', 'Dismiss'); dismiss.title = 'Dismiss notification'; - const details = document.createElement('dl'); - for (const [label, value] of [['Client', options.clientLabel?.(request) ?? request.principalLabel], ['Grant', request.level], ['Navigation', request.navigation]] as const) { - const column = document.createElement('div'); - const term = document.createElement('dt'); - term.textContent = label; - const definition = document.createElement('dd'); - const badge = document.createElement('code'); - badge.textContent = value; - if (label === 'Grant') badge.dataset.level = request.level; - definition.append(badge); - column.append(term, definition); - details.append(column); - } - element.append(details); + element.append(createRequestDetails(request)); const actions = document.createElement('footer'); element.append(actions); - button(actions, options.reviewLabel?.(request) ?? 'Review request', async () => options.controller.review(request.id)); + createActionButton(actions, options.reviewLabel?.(request) ?? 'Review request', async () => options.controller.review(request.id)); const reject = options.onReject; - if (reject !== undefined) button(actions, 'Reject', async () => reject(request)); + if (reject !== undefined) createActionButton(actions, 'Reject', async () => reject(request)); content.append(element); } const grouped = Map.groupBy(notification.grants, grant => grant.requestId); for (const [requestId, grants] of grouped) { const grant = grants[0]!; - const element = article(options.branding?.title ?? 'Browser control', `${grant.principalLabel}: ${grant.level}, ${grant.navigation}. ${grants.length} approved ${grants.length === 1 ? 'tab' : 'tabs'}.`); - button(element, 'Stop control', async () => options.controller.revoke(requestId)); + const tabCount = `${grants.length} approved ${grants.length === 1 ? 'tab' : 'tabs'}`; + const description = `${grant.principalLabel}: ${grant.level}, ${grant.navigation}. ${tabCount}.`; + const element = createCard(options.branding?.title ?? 'Browser control', description); + createActionButton(element, 'Stop control', async () => options.controller.revoke(requestId)); content.append(element); } } const unsubscribe = options.controller.subscribe(render); render(options.controller.snapshot()); - return { setTheme, setColorMode, dispose() { - disposed = true; - unsubscribe(); - host.remove(); - } }; + return { + setTheme, + setColorMode, + dispose() { + disposed = true; + unsubscribe(); + host.remove(); + }, + }; } diff --git a/packages/extension/test/notifications.test.ts b/packages/extension/test/notifications.test.ts index 6ba3d0e..aa78fff 100644 --- a/packages/extension/test/notifications.test.ts +++ b/packages/extension/test/notifications.test.ts @@ -1,13 +1,11 @@ -// @vitest-environment jsdom import type { BrokerRequest, BrokerState } from '@dvcol/cdb-broker/contract'; import { afterEach, expect, it, vi } from 'vitest'; -import { createBrowserControlNotificationController, renderBrowserControlNotifications } from '../src/notifications.js'; +import { createBrowserControlNotificationController } from '../src/notifications.js'; afterEach(() => { vi.useRealTimers(); - document.body.replaceChildren(); }); const request: BrokerRequest = { id: 'request', principalId: 'principal', principalLabel: '', level: 'interact', navigation: 'same-origin', state: 'pending', createdAt: 0, expiresAt: 2_000 }; @@ -33,72 +31,3 @@ it('keeps dismissal local and expires requests without another broker update', a expect(controller.snapshot().requests).toEqual([]); controller.dispose(); }); - -it.each(['Review request', 'Accept INTERACT'])('renders untrusted labels as text and delegates "%s" to the embedding application', async (label) => { - expect.assertions(4); - const review = vi.fn(); - const controller = createBrowserControlNotificationController({ onReview: review, onRevoke: async () => {} }); - controller.update({ ...state, requests: [{ ...request, expiresAt: null }] }); - const renderer = renderBrowserControlNotifications({ controller, container: document.body, branding: { title: 'Browser access' }, ...(label === 'Review request' ? {} : { reviewLabel: (request: BrokerRequest) => `Accept ${request.level.toUpperCase()}` }) }); - const root = document.querySelector('section')!.shadowRoot!; - expect(root.querySelector('script')).toBeNull(); - expect(root.textContent).toContain(request.principalLabel); - [...root.querySelectorAll('button')].find(button => button.textContent === label)!.click(); - await vi.waitUntil(() => review.mock.calls.length === 1); - expect(review).toHaveBeenCalledOnce(); - renderer.dispose(); - expect(document.body.children).toHaveLength(0); - controller.dispose(); -}); - -it('preserves focused notification controls across equivalent broker publications', () => { - expect.assertions(4); - const controller = createBrowserControlNotificationController({ onReview: async () => {}, onRevoke: async () => {} }); - const pendingRequest = { ...request, expiresAt: null }; - controller.update({ requests: [pendingRequest], grants: [] }); - const renderer = renderBrowserControlNotifications({ controller, container: document.body }); - const root = document.querySelector('section')!.shadowRoot!; - const review = root.querySelectorAll('button')[1]!; - review.focus(); - - /** Transport publications contain fresh objects even when the pending request has not changed. */ - controller.update({ requests: [{ ...pendingRequest }], grants: [] }); - expect(root.querySelectorAll('button')[1]).toBe(review); - expect(root.activeElement).toBe(review); - - controller.update({ requests: [{ ...pendingRequest, principalLabel: 'Updated client' }], grants: [] }); - expect(root.textContent).toContain('Updated client'); - controller.update({ requests: [], grants: [] }); - expect(root.querySelector('article')).toBeNull(); - renderer.dispose(); - controller.dispose(); -}); - -it('changes theme and color mode without replacing focused controls or pending actions', async () => { - expect.assertions(9); - const pending = Promise.withResolvers(); - const controller = createBrowserControlNotificationController({ onReview: async () => pending.promise, onRevoke: async () => {} }); - controller.update({ requests: [{ ...request, expiresAt: null }], grants: [] }); - const renderer = renderBrowserControlNotifications({ controller, container: document.body, theme: { light: { primary: 'green' } }, branding: { accent: 'purple' }, css: ':host { --cdb-primary: orange; }' }); - const host = document.querySelector('section')!; - const root = host.shadowRoot!; - const review = root.querySelectorAll('button')[1]!; - review.focus(); - expect(host.dataset.colorMode).toBe('system'); - expect(root.querySelector('style')!.textContent).toContain('prefers-color-scheme:dark'); - renderer.setColorMode('dark'); - renderer.setTheme({ spacing: '20px', dark: { text: 'yellow' } }); - expect(host.dataset.colorMode).toBe('dark'); - expect(root.activeElement).toBe(review); - expect(root.querySelector('style')!.textContent).toContain('--cdb-spacing:20px'); - expect(root.querySelector('style')!.textContent).toContain('--cdb-primary:purple'); - expect(root.querySelector('style')!.textContent.trim().endsWith(':host { --cdb-primary: orange; }')).toBe(true); - review.click(); - renderer.setColorMode('light'); - expect(root.querySelectorAll('button')[1]).toBe(review); - pending.resolve(); - await vi.waitUntil(() => !review.disabled); - expect(host.dataset.colorMode).toBe('light'); - renderer.dispose(); - controller.dispose(); -}); diff --git a/tests/browser/notification-renderer.test.ts b/tests/browser/notification-renderer.test.ts new file mode 100644 index 0000000..d49eb64 --- /dev/null +++ b/tests/browser/notification-renderer.test.ts @@ -0,0 +1,83 @@ +import type { BrokerRequest, BrokerState } from '@dvcol/cdb-broker/contract'; + +import { afterEach, expect, it, vi } from 'vitest'; + +import { createBrowserControlNotificationController, renderBrowserControlNotifications } from '../../packages/extension/src/notifications.js'; + +afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); +}); + +const request: BrokerRequest = { id: 'request', principalId: 'principal', principalLabel: '', level: 'interact', navigation: 'same-origin', state: 'pending', createdAt: 0, expiresAt: 2_000 }; +const state: BrokerState = { revision: 1, providers: [], principals: [], requests: [request], grants: [], scopes: [], targets: [], leases: [] }; + +it.each(['Review request', 'Accept INTERACT'])('renders untrusted labels as text and delegates "%s" to the embedding application', async (label) => { + expect.assertions(4); + const review = vi.fn(); + const controller = createBrowserControlNotificationController({ onReview: review, onRevoke: async () => {} }); + controller.update({ ...state, requests: [{ ...request, expiresAt: null }] }); + const renderer = renderBrowserControlNotifications({ controller, container: document.body, branding: { title: 'Browser access' }, ...(label === 'Review request' ? {} : { reviewLabel: (request: BrokerRequest) => `Accept ${request.level.toUpperCase()}` }) }); + const root = document.querySelector('section')!.shadowRoot!; + expect(root.querySelector('script')).toBeNull(); + expect(root.textContent).toContain(request.principalLabel); + [...root.querySelectorAll('button')].find(button => button.textContent === label)!.click(); + await vi.waitUntil(() => review.mock.calls.length === 1); + expect(review).toHaveBeenCalledOnce(); + renderer.dispose(); + expect(document.body.children).toHaveLength(0); + controller.dispose(); +}); + +it('preserves focused notification controls across equivalent broker publications', () => { + expect.assertions(4); + const controller = createBrowserControlNotificationController({ onReview: async () => {}, onRevoke: async () => {} }); + const pendingRequest = { ...request, expiresAt: null }; + controller.update({ requests: [pendingRequest], grants: [] }); + const renderer = renderBrowserControlNotifications({ controller, container: document.body }); + const root = document.querySelector('section')!.shadowRoot!; + const review = root.querySelectorAll('button')[1]!; + review.focus(); + + /** Transport publications contain fresh objects even when the pending request has not changed. */ + controller.update({ requests: [{ ...pendingRequest }], grants: [] }); + expect(root.querySelectorAll('button')[1]).toBe(review); + expect(root.activeElement).toBe(review); + + controller.update({ requests: [{ ...pendingRequest, principalLabel: 'Updated client' }], grants: [] }); + expect(root.textContent).toContain('Updated client'); + controller.update({ requests: [], grants: [] }); + expect(root.querySelector('article')).toBeNull(); + renderer.dispose(); + controller.dispose(); +}); + +it('changes theme and color mode without replacing focused controls or pending actions', async () => { + expect.assertions(10); + const pending = Promise.withResolvers(); + const controller = createBrowserControlNotificationController({ onReview: async () => pending.promise, onRevoke: async () => {} }); + controller.update({ requests: [{ ...request, expiresAt: null }], grants: [] }); + const renderer = renderBrowserControlNotifications({ controller, container: document.body, theme: { light: { primary: 'green' } }, branding: { accent: 'purple' }, css: ':host { --cdb-primary: orange; }' }); + const host = document.querySelector('section')!; + const root = host.shadowRoot!; + const review = root.querySelectorAll('button')[1]!; + review.focus(); + expect(host.dataset.colorMode).toBe('system'); + expect(root.adoptedStyleSheets[1]!.cssRules[3]!.cssText).toContain('prefers-color-scheme: dark'); + renderer.setColorMode('dark'); + renderer.setTheme({ spacing: '20px', dark: { text: 'yellow' } }); + expect(host.dataset.colorMode).toBe('dark'); + expect(root.activeElement).toBe(review); + expect(getComputedStyle(host).getPropertyValue('--cdb-spacing').trim()).toBe('20px'); + expect(root.adoptedStyleSheets[1]!.cssRules[2]!.cssText).toContain('--cdb-primary: purple'); + expect(getComputedStyle(host).getPropertyValue('--cdb-primary').trim()).toBe('orange'); + expect(root.querySelector('style')).toBeNull(); + review.click(); + renderer.setColorMode('light'); + expect(root.querySelectorAll('button')[1]).toBe(review); + pending.resolve(); + await vi.waitUntil(() => !review.disabled); + expect(host.dataset.colorMode).toBe('light'); + renderer.dispose(); + controller.dispose(); +});