From d106604a22a28aa44f8b234fc993e053fa77a59f Mon Sep 17 00:00:00 2001 From: erKam Date: Tue, 13 Oct 2026 18:13:34 +0000 Subject: [PATCH 1/6] feat(mcp): let the panel turn the browser agent tools on and off The page tools from agent.browserTools could only be turned on in the config file, which needs a server restart. Keep a switch on the server that starts from agent.browserTools, add set-browser-tools for the panel and a browser-tools shared state, and broadcast browser-tools-changed so every connected page registers or unregisters its tools without a reload. The overlay now always asks the server, which lists no tools while the switch is off. Refs #396 --- .../src/__tests__/browser-tools.test.ts | 60 ++++++++++++++ packages/devtools/src/browser-tools.ts | 48 +++++++++-- packages/devtools/src/config.ts | 16 +++- packages/devtools/src/devframe.ts | 25 +++++- packages/devtools/src/overlay.ts | 10 ++- .../src/rpc/__tests__/browser-tools.test.ts | 62 ++++++++++++++ packages/devtools/src/rpc/browser-tools.ts | 82 +++++++++++++++++-- 7 files changed, 282 insertions(+), 21 deletions(-) diff --git a/packages/devtools/src/__tests__/browser-tools.test.ts b/packages/devtools/src/__tests__/browser-tools.test.ts index 45978174..f634a2b7 100644 --- a/packages/devtools/src/__tests__/browser-tools.test.ts +++ b/packages/devtools/src/__tests__/browser-tools.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest'; import { + CHANGED_EVENT, DISCOVERY_EVENT, TOOL_GROUP, attachBrowserTools, @@ -219,6 +220,65 @@ describe('browser tools on the page', () => { expect(discover()).toEqual([]); }); + it('registers or unregisters without a reload when the panel switch changes', async () => { + const { tools } = fakeContext(); + let listed: unknown = []; + const handlers = new Map void>(); + const my = { + rpc: { + call: vi.fn(async (name: string) => { + if (name === 'list-browser-tools') return listed; + throw new Error(`unexpected ${name}`); + }), + register: (definition: { name: string; handler: (message: unknown) => void }) => + void handlers.set(definition.name, definition.handler), + }, + }; + attach(my); + await tick(); + expect(tools.size).toBe(0); + expect(discover()).toEqual([]); + + listed = LISTED; + handlers.get(CHANGED_EVENT)!({ enabled: true, config: false }); + await tick(); + expect([...tools.keys()]).toEqual(['pangular_inspect-component', 'pangular_get-routes']); + expect(discover()).toHaveLength(1); + + listed = []; + handlers.get(CHANGED_EVENT)!({ enabled: false, config: false }); + await tick(); + expect(tools.size).toBe(0); + expect(discover()).toEqual([]); + }); + + it('ignores the switch while the page is away or after it stopped', async () => { + const { tools } = fakeContext(); + const handlers = new Map void>(); + const my = { + rpc: { + call: vi.fn(async () => LISTED), + register: (definition: { name: string; handler: (message: unknown) => void }) => + void handlers.set(definition.name, definition.handler), + }, + }; + const handle = attach(my); + await tick(); + handle.leave(); + handlers.get(CHANGED_EVENT)!({ enabled: true }); + await tick(); + expect(tools.size).toBe(0); + handle.resume(); + await tick(); + expect(tools.size).toBe(2); + handle.stop(); + handlers.get(CHANGED_EVENT)!({ enabled: true }); + handle.resume(); + await tick(); + expect(tools.size).toBe(0); + expect(my.rpc.call).toHaveBeenCalledTimes(2); + }); + it('stays out of the Forms inspector WebMCP list', async () => { const { context } = fakeContext(); const watcher = watchWebMcp(() => {}); diff --git a/packages/devtools/src/browser-tools.ts b/packages/devtools/src/browser-tools.ts index 4d355e0a..ec7ba26f 100644 --- a/packages/devtools/src/browser-tools.ts +++ b/packages/devtools/src/browser-tools.ts @@ -9,7 +9,10 @@ import { modelContextOf } from './forms-webmcp.ts'; * `window` that answers with one tool group. * * Each call goes to the server (`call-browser-tool`), which runs the same - * handler an MCP client gets and masks secrets in the answer. The tools + * handler an MCP client gets and masks secrets in the answer. The server + * lists no tools while `agent.browserTools` (or the panel's switch) is off, + * and says `browser-tools-changed` when the switch moves, so the page asks + * again then. The tools * answer for this page unless the agent names another one. Nothing is * written to the app's DOM. */ @@ -31,8 +34,19 @@ export interface PageTool { inputSchema: Record; } +/** Broadcast by the server when the panel's Settings turns the tools on or off. */ +export const CHANGED_EVENT = 'browser-tools-changed'; + interface PageRpc { - rpc: { call(name: string, ...args: unknown[]): Promise }; + rpc: { + call(name: string, ...args: unknown[]): Promise; + register?(definition: { + name: string; + type: 'event'; + jsonSerializable: true; + handler: (message: unknown) => void; + }): unknown; + }; } export interface BrowserToolsHandle { @@ -171,18 +185,42 @@ export function attachBrowserTools( listening = true; } + let away = false; + let stopped = false; const leave = () => { generation++; unregister(); }; + const restart = () => { + leave(); + void start(); + }; + + // The server lists no tools while the switch is off, so asking again + // registers or unregisters them without a reload. + my.rpc.register?.({ + name: CHANGED_EVENT, + type: 'event', + jsonSerializable: true, + handler: () => { + if (!away && !stopped) restart(); + }, + }); void start(); return { - leave, + leave: () => { + away = true; + leave(); + }, resume: () => { + if (stopped) return; + away = false; + restart(); + }, + stop: () => { + stopped = true; leave(); - void start(); }, - stop: leave, }; } diff --git a/packages/devtools/src/config.ts b/packages/devtools/src/config.ts index 8a97dbf0..60595f18 100644 --- a/packages/devtools/src/config.ts +++ b/packages/devtools/src/config.ts @@ -170,7 +170,9 @@ export interface PangularConfig { * Register the `read` agent tools on the page for browser agents, through * WebMCP (`document.modelContext`) and Chrome DevTools for agents * (`devtoolstooldiscovery`). Off by default, since any script on the page could - * then call them; `true` turns them on. + * then call them; `true` turns them on. This is where the switch starts: + * **Browser agent tools** in the panel's Settings changes it for every + * page until the server restarts. */ browserTools?: boolean; }; @@ -970,9 +972,11 @@ export const SHARED_RPC: readonly string[] = [ 'list-pages', // Opens any tab, so it stays with every inspector off; it lists only the tabs that are on. 'open-in-panel', - // The page registers the `read` agent tools for browser agents (agent.browserTools). + // The page registers the `read` agent tools for browser agents (agent.browserTools), + // and the panel's Settings turns them on or off while the server runs. 'list-browser-tools', 'call-browser-tool', + 'set-browser-tools', 'open-in-editor', 'hub-dock-seen', // Store as global spans the components, injectors and NgRx inspectors; the @@ -1211,7 +1215,13 @@ export const SHARED_STATE_INSPECTOR: Record = { }; /** Shared-state keys that belong to no inspector, without the `pangular:` prefix. */ -export const SHARED_STATE_KEYS: readonly string[] = ['page-visibility', 'page-angular', 'audit']; +export const SHARED_STATE_KEYS: readonly string[] = [ + 'page-visibility', + 'page-angular', + 'audit', + // Whether the page registers the browser agent tools (agent.browserTools and the panel switch). + 'browser-tools', +]; /** * Whether MCP clients may read a shared-state key. A `pangular:` key in diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index 4d5259a7..1464c890 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -277,7 +277,13 @@ export { } from './plugin.ts'; import { registerAuthTokens } from './auth-tokens.ts'; -import { registerBrowserTools, type BrowserToolHost } from './rpc/browser-tools.ts'; +import { + BROWSER_TOOLS_CHANGED, + BROWSER_TOOLS_STATE, + createBrowserToolsSwitch, + registerBrowserTools, + type BrowserToolHost, +} from './rpc/browser-tools.ts'; import pkg from '../package.json' with { type: 'json' }; import { INTEREST_TICK_MS, createPageInterest, leased } from './rpc/page-interest.ts'; import { createReportDeltas, deltaPush } from './rpc/report-deltas.ts'; @@ -427,9 +433,24 @@ const pangular = defineDevframe({ }; // The `read` agent tools, for the page to register with browser agents. + // `agent.browserTools` sets where the switch starts; the panel's Settings + // flips it for every page until the server restarts. + const browserToolsState = await my.rpc.sharedState(BROWSER_TOOLS_STATE, { + initialValue: { enabled: config.agent.browserTools, config: config.agent.browserTools }, + }); + const browserToolsSwitch = createBrowserToolsSwitch(config.agent.browserTools, (state) => { + browserToolsState.mutate((draft) => { + draft.enabled = state.enabled; + draft.config = state.config; + }); + void my.rpc + .broadcast({ method: BROWSER_TOOLS_CHANGED, args: [state], optional: true }) + ?.catch?.(() => {}); + }); registerBrowserTools(register, ctx as unknown as BrowserToolHost, { - enabled: config.agent.browserTools, + enabled: browserToolsSwitch.enabled, owns, + switch: browserToolsSwitch, }); let onPageClaimed = (_pageId: string) => {}; diff --git a/packages/devtools/src/overlay.ts b/packages/devtools/src/overlay.ts index 584927ea..c5485cf2 100644 --- a/packages/devtools/src/overlay.ts +++ b/packages/devtools/src/overlay.ts @@ -534,7 +534,9 @@ async function startOverlay(options: OverlayOptions, own: (cleanup: () => void) // The component owning each element is component data: only with that inspector on. const axe = attachAxe(my, pageId, axeOwnerNg(on.components, getNg)); const angularInfo = attachAngularInfo(my, pageId, getNg, { tickMs }); - const browserTools = devtoolsConfig.agent.browserTools ? attachBrowserTools(my, pageId) : null; + // Always attached: the panel's Settings can turn the tools on after the page + // connects, and the server lists none while they are off. + const browserTools = attachBrowserTools(my, pageId); const consoleGlobals = attachConsoleGlobals(my, pageId, { getNg, inspectors: on, @@ -975,7 +977,7 @@ async function startOverlay(options: OverlayOptions, own: (cleanup: () => void) plugins?.leave(); startup?.leave(); angularInfo.leave(); - browserTools?.leave(); + browserTools.leave(); }; addEventListener('pagehide', leave); const resendConfig = () => { @@ -986,7 +988,7 @@ async function startOverlay(options: OverlayOptions, own: (cleanup: () => void) leaks?.resume(); plugins?.resume(); startup?.resume(); - browserTools?.resume(); + browserTools.resume(); void lease.refresh(); left = false; onVisibility(); @@ -1011,7 +1013,7 @@ async function startOverlay(options: OverlayOptions, own: (cleanup: () => void) leaks?.stop(); plugins?.stop(); startup?.stop(); - browserTools?.stop(); + browserTools.stop(); axe.stop(); cd?.stop(); hydrationOverlay?.stop(); diff --git a/packages/devtools/src/rpc/__tests__/browser-tools.test.ts b/packages/devtools/src/rpc/__tests__/browser-tools.test.ts index b1ec827a..199e876d 100644 --- a/packages/devtools/src/rpc/__tests__/browser-tools.test.ts +++ b/packages/devtools/src/rpc/__tests__/browser-tools.test.ts @@ -5,6 +5,7 @@ import { resolvePangularConfig } from '../../config.ts'; import { browserTools, callBrowserTool, + createBrowserToolsSwitch, redactAnswer, scopedArgs, type BrowserTool, @@ -117,6 +118,67 @@ describe('list-browser-tools', () => { }); }); +describe('set-browser-tools', () => { + it('turns the page tools on and off while the server runs, starting from the config', async () => { + const { ctx, rpc, names } = await boot({ agent: { browserTools: false } }); + const broadcast = vi.spyOn(ctx.rpc, 'broadcast'); + const state = await ctx.rpc.sharedState.get('pangular:browser-tools' as never); + expect(state.value()).toEqual({ enabled: false, config: false }); + expect(await names()).toEqual([]); + + expect(await rpc('set-browser-tools', { enabled: true })).toEqual({ + enabled: true, + config: false, + }); + expect(state.value()).toEqual({ enabled: true, config: false }); + expect(await names()).toContain('pangular_inspect-component'); + const answer = await rpc('call-browser-tool', { pageId: 'p1', name: 'pangular_list-errors' }); + expect(answer.isError).toBeUndefined(); + expect(broadcast).toHaveBeenCalledWith( + expect.objectContaining({ + method: expect.stringMatching(/browser-tools-changed$/), + args: [{ enabled: true, config: false }], + }), + ); + + await rpc('set-browser-tools', { enabled: false }); + expect(state.value()).toEqual({ enabled: false, config: false }); + expect(await names()).toEqual([]); + const refused = await rpc('call-browser-tool', { pageId: 'p1', name: 'pangular_list-errors' }); + expect(refused).toEqual({ text: expect.stringMatching(/turned off/), isError: true }); + }); + + it('can turn off tools the config turned on', async () => { + const { ctx, rpc, names } = await boot({ agent: { browserTools: true } }); + const state = await ctx.rpc.sharedState.get('pangular:browser-tools' as never); + expect(state.value()).toEqual({ enabled: true, config: true }); + await rpc('set-browser-tools', { enabled: false }); + expect(await names()).toEqual([]); + expect(state.value()).toEqual({ enabled: false, config: true }); + }); + + it('refuses anything but a boolean and tells the pages only about a change', () => { + const onChange = vi.fn(); + const toggle = createBrowserToolsSwitch(false, onChange); + for (const message of [null, 'on', { enabled: 'true' }, { enabled: 1 }, []]) { + expect(toggle.set(message)).toEqual({ error: expect.stringMatching(/enabled/) }); + } + expect(toggle.set({ enabled: false })).toEqual({ enabled: false, config: false }); + expect(onChange).not.toHaveBeenCalled(); + toggle.set({ enabled: true }); + toggle.set({ enabled: true }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ enabled: true, config: false }); + expect(toggle.enabled()).toBe(true); + }); + + it('is not an agent tool', async () => { + const { ctx } = await boot(); + const ids = ctx.agent.list().tools.map((tool) => tool.id); + expect(ids.some((id) => id.includes('browser-tools'))).toBe(false); + }); +}); + describe('call-browser-tool', () => { it('runs a read tool through the agent host and answers its markdown', async () => { const { rpc } = await boot(); diff --git a/packages/devtools/src/rpc/browser-tools.ts b/packages/devtools/src/rpc/browser-tools.ts index d12fce2c..85174a55 100644 --- a/packages/devtools/src/rpc/browser-tools.ts +++ b/packages/devtools/src/rpc/browser-tools.ts @@ -14,6 +14,52 @@ import { setCallerPageReader } from './page-scope.ts'; export const LIST_BROWSER_TOOLS = 'list-browser-tools'; export const CALL_BROWSER_TOOL = 'call-browser-tool'; +/** The panel's Settings switch: turns the page tools on or off while the server runs. */ +export const SET_BROWSER_TOOLS = 'set-browser-tools'; +/** Broadcast to the pages after the switch changes, so they register or unregister. */ +export const BROWSER_TOOLS_CHANGED = 'browser-tools-changed'; +/** The shared-state key the panel reads the switch from. */ +export const BROWSER_TOOLS_STATE = 'browser-tools'; + +/** What the panel shows: the switch, and where `agent.browserTools` started it. */ +export interface BrowserToolsSwitchState { + enabled: boolean; + /** `agent.browserTools` from the devtools config, the value after a restart. */ + config: boolean; +} + +export interface BrowserToolsSwitch { + enabled(): boolean; + state(): BrowserToolsSwitchState; + /** Sets the switch from a panel message; refuses anything but a boolean `enabled`. */ + set(message: unknown): BrowserToolsSwitchState | { error: string }; +} + +/** + * The page tools switch. `agent.browserTools` sets where it starts; the + * panel's Settings changes it for every page until the server restarts. + * `onChange` runs only when the value changes. + */ +export function createBrowserToolsSwitch( + config: boolean, + onChange: (state: BrowserToolsSwitchState) => void = () => {}, +): BrowserToolsSwitch { + let enabled = config; + const state = () => ({ enabled, config }); + return { + enabled: () => enabled, + state, + set(message) { + const next = isRecord(message) ? message['enabled'] : undefined; + if (typeof next !== 'boolean') return { error: 'Send {enabled: true} or {enabled: false}.' }; + if (next !== enabled) { + enabled = next; + onChange(state()); + } + return state(); + }, + }; +} /** What the page registers: the MCP name, so a tool has one name everywhere. */ export interface BrowserTool { @@ -194,19 +240,29 @@ export function scopedArgs( } export interface BrowserToolOptions { - /** `agent.browserTools`: off answers no tools and refuses calls. */ - enabled: boolean; + /** + * `agent.browserTools`, or the switch that starts from it: off answers no + * tools and refuses calls. + */ + enabled: boolean | (() => boolean); /** Whether the calling connection owns `pageId` (the shared owner check). */ owns: (pageId: unknown) => pageId is string; } +function isOn(options: BrowserToolOptions): boolean { + return typeof options.enabled === 'function' ? options.enabled() : options.enabled; +} + export async function callBrowserTool( host: BrowserToolHost, message: unknown, options: BrowserToolOptions, ): Promise { - if (!options.enabled) { - return { text: 'Browser tools are turned off (agent.browserTools).', isError: true }; + if (!isOn(options)) { + return { + text: 'Browser tools are turned off (agent.browserTools, or Browser agent tools in the panel Settings).', + isError: true, + }; } const request = isRecord(message) ? message : {}; const pageId = request['pageId']; @@ -239,18 +295,30 @@ type Register = (definition: { handler: (...args: any[]) => unknown; }) => void; -/** Registers `list-browser-tools` and `call-browser-tool` (shared RPC functions, not agent tools). */ +/** + * Registers `list-browser-tools` and `call-browser-tool`, and with a switch + * `set-browser-tools` for the panel (shared RPC functions, not agent tools). + */ export function registerBrowserTools( register: Register, host: BrowserToolHost, - options: BrowserToolOptions, + options: BrowserToolOptions & { switch?: BrowserToolsSwitch }, ) { register({ name: LIST_BROWSER_TOOLS, type: 'query', jsonSerializable: true, - handler: () => (options.enabled ? browserTools(host) : []), + handler: () => (isOn(options) ? browserTools(host) : []), }); + const toggle = options.switch; + if (toggle) { + register({ + name: SET_BROWSER_TOOLS, + type: 'action', + jsonSerializable: true, + handler: (message: unknown) => toggle.set(message), + }); + } register({ name: CALL_BROWSER_TOOL, type: 'action', From ae71d05ce5dd3f8663f872726d6b261d49af6fc5 Mon Sep 17 00:00:00 2001 From: erKam Date: Tue, 13 Oct 2026 18:13:38 +0000 Subject: [PATCH 2/6] feat(ui): add a Browser agents switch to Settings Settings gets an accessible switch for the browser agent tools. It reads the server's browser-tools state, follows changes made in other panels, says that the change lasts until the server restarts and what the config starts it at, and shows loading, unsupported and error states with Retry. The axe run now also checks the Settings page. Refs #396 --- .../__tests__/browser-tools-setting.test.ts | 158 +++++++++ app/src/__tests__/settings.test.ts | 3 +- app/src/app.ts | 1 + app/src/pages/browser-tools-setting.ts | 318 ++++++++++++++++++ app/src/pages/settings.ts | 18 +- scripts/panel-axe.mjs | 4 + 6 files changed, 498 insertions(+), 4 deletions(-) create mode 100644 app/src/__tests__/browser-tools-setting.test.ts create mode 100644 app/src/pages/browser-tools-setting.ts diff --git a/app/src/__tests__/browser-tools-setting.test.ts b/app/src/__tests__/browser-tools-setting.test.ts new file mode 100644 index 00000000..f01e0e1f --- /dev/null +++ b/app/src/__tests__/browser-tools-setting.test.ts @@ -0,0 +1,158 @@ +// @vitest-environment jsdom +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { DevframeRpcClient } from 'devframe/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { BrowserToolsSetting, browserToolsState } from '../pages/browser-tools-setting'; + +let fixture: ComponentFixture | undefined; + +afterEach(() => { + fixture?.destroy(); + fixture = undefined; + document.body.innerHTML = ''; + TestBed.resetTestingModule(); +}); + +async function settle() { + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture!.whenStable(); + } +} + +interface FakeServer { + value: unknown; + set?: (arg: unknown) => unknown; + backend?: string; + sharedStateFails?: boolean; +} + +function render(server: FakeServer | null) { + const listeners: ((value: unknown) => void)[] = []; + const call = vi.fn(async (name: string, arg: unknown) => { + if (name !== 'set-browser-tools') throw new Error(`unexpected ${name}`); + return server?.set ? server.set(arg) : { ...(arg as object), config: false }; + }); + const client = server && { + connectionMeta: { backend: server.backend ?? 'websocket' }, + scope: () => ({ + rpc: { + call, + sharedState: async (key: string) => { + if (server.sharedStateFails) throw new Error('down'); + expect(key).toBe('browser-tools'); + return { + value: () => server.value, + on: (_: string, listener: (value: unknown) => void) => { + listeners.push(listener); + return () => {}; + }, + }; + }, + }, + }), + }; + fixture = TestBed.createComponent(BrowserToolsSetting); + fixture.componentRef.setInput('rpc', client as unknown as DevframeRpcClient | null); + document.body.appendChild(fixture.nativeElement); + const root = fixture.nativeElement as HTMLElement; + const toggle = () => root.querySelector('input[role="switch"]')!; + const text = () => root.textContent?.replace(/\s+/g, ' ') ?? ''; + const push = (value: unknown) => { + for (const listener of listeners) listener(value); + }; + return { root, toggle, text, call, push }; +} + +describe('browser agent tools setting', () => { + it('reads only a boolean switch from the shared state', () => { + expect(browserToolsState({ enabled: true, config: false })).toEqual({ + enabled: true, + config: false, + }); + expect(browserToolsState({ enabled: false })).toEqual({ enabled: false, config: false }); + for (const value of [undefined, null, {}, { enabled: 'yes' }, 'on']) { + expect(browserToolsState(value)).toBeNull(); + } + }); + + it('is a labelled switch, off by default, that says how long it lasts', async () => { + const { toggle, text } = render({ value: { enabled: false, config: false } }); + await settle(); + const input = toggle(); + expect(input.type).toBe('checkbox'); + expect(input.checked).toBe(false); + expect(input.disabled).toBe(false); + expect(input.closest('label')?.textContent).toContain('Browser agent tools'); + const described = input.getAttribute('aria-describedby')!.split(' '); + for (const id of described) expect(document.getElementById(id)).not.toBeNull(); + expect(text()).toMatch(/until the server restarts/); + expect(text()).toMatch(/off in this project/); + expect(text()).toMatch(/no reload/); + }); + + it('turns the tools on through the server and follows other panels', async () => { + const { toggle, call, push, root } = render({ value: { enabled: false, config: false } }); + await settle(); + toggle().click(); + await settle(); + expect(call).toHaveBeenCalledWith('set-browser-tools', { enabled: true }); + expect(toggle().checked).toBe(true); + expect(root.querySelector('[role="status"]')?.textContent).toMatch(/on\. Connected pages/); + + // Another panel turned them off. + push({ enabled: false, config: false }); + await settle(); + expect(toggle().checked).toBe(false); + }); + + it('shows the error with Retry when the server refuses, and retries the change', async () => { + let fail = true; + const { toggle, call, root } = render({ + value: { enabled: false, config: true }, + set: (arg) => { + if (fail) throw new Error('gone'); + return { ...(arg as object), config: true }; + }, + }); + await settle(); + toggle().click(); + await settle(); + const alert = root.querySelector('[role="alert"]')!; + expect(alert.textContent).toMatch(/Couldn't turn the browser agent tools on/); + expect(toggle().checked).toBe(false); + fail = false; + alert.querySelector('button')!.click(); + await settle(); + expect(call).toHaveBeenLastCalledWith('set-browser-tools', { enabled: true }); + expect(toggle().checked).toBe(true); + expect(root.querySelector('[role="alert"]')).toBeNull(); + }); + + it('is disabled while connecting, on a saved report and on a server without the switch', async () => { + let view = render(null); + await settle(); + expect(view.toggle().disabled).toBe(true); + expect(view.text()).toMatch(/Waiting for the devtools server/); + fixture!.destroy(); + + view = render({ value: { enabled: false, config: false }, backend: 'static' }); + await settle(); + expect(view.toggle().disabled).toBe(true); + expect(view.text()).toMatch(/saved report/); + fixture!.destroy(); + + view = render({ value: undefined }); + await settle(); + expect(view.toggle().disabled).toBe(true); + expect(view.text()).toMatch(/browserTools: true/); + }); + + it('shows a Retry when the setting cannot be read', async () => { + const { toggle, root } = render({ value: undefined, sharedStateFails: true }); + await settle(); + expect(toggle().disabled).toBe(true); + expect(root.querySelector('[role="alert"]')?.textContent).toMatch(/Couldn't read/); + expect(root.querySelector('[role="alert"] button')?.textContent).toMatch(/Retry/); + }); +}); diff --git a/app/src/__tests__/settings.test.ts b/app/src/__tests__/settings.test.ts index a7d0e916..b17bc2e6 100644 --- a/app/src/__tests__/settings.test.ts +++ b/app/src/__tests__/settings.test.ts @@ -126,6 +126,7 @@ describe('Settings page', () => { 'Theme', 'Tabs', 'Popup on the page', + 'Browser agents', 'Keyboard shortcuts', ]); expect(root.querySelector('h1, h3')).toBeNull(); @@ -145,7 +146,7 @@ describe('Settings page', () => { 'forms', ]); expect(document.activeElement).toBe(button('Move Signals up')); - expect(root.querySelector('[role="status"]')?.textContent).toContain( + expect(root.querySelector(':scope > [role="status"]')?.textContent).toContain( 'Signals moved to position 2 of 4.', ); // At the top, focus moves to the other arrow instead of a disabled one. diff --git a/app/src/app.ts b/app/src/app.ts index fe05f447..66f878bb 100644 --- a/app/src/app.ts +++ b/app/src/app.ts @@ -480,6 +480,7 @@ function readView(): View | null { (passiveChange)="setPassive($event)" [hotkey]="popupHotkey()" [paletteShortcut]="paletteShortcut" + [rpc]="rpc()" /> } @else if (connectionFailed()) {