Skip to content

Commit e5699cf

Browse files
authored
feat(ui): add a Browser agents switch to Settings (#406)
The browser agent tools could only be turned on in the config file. A Settings switch now turns them on or off for every connected page without a reload. agent.browserTools still sets where the switch starts, a change lasts until the dev server restarts, and the default stays off. Refs #396.
1 parent e91767b commit e5699cf

20 files changed

Lines changed: 1039 additions & 131 deletions
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// @vitest-environment jsdom
2+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
3+
import type { DevframeRpcClient } from 'devframe/client';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
5+
import { BrowserToolsSetting, browserToolsState } from '../pages/browser-tools-setting';
6+
7+
let fixture: ComponentFixture<BrowserToolsSetting> | undefined;
8+
9+
afterEach(() => {
10+
fixture?.destroy();
11+
fixture = undefined;
12+
document.body.innerHTML = '';
13+
TestBed.resetTestingModule();
14+
});
15+
16+
async function settle() {
17+
for (let i = 0; i < 3; i++) {
18+
await new Promise((resolve) => setTimeout(resolve));
19+
await fixture!.whenStable();
20+
}
21+
}
22+
23+
interface FakeServer {
24+
value: unknown;
25+
set?: (arg: unknown) => unknown;
26+
backend?: string;
27+
sharedStateFails?: boolean;
28+
}
29+
30+
function render(server: FakeServer | null) {
31+
const listeners: ((value: unknown) => void)[] = [];
32+
const call = vi.fn(async (name: string, arg: unknown) => {
33+
if (name !== 'set-browser-tools') throw new Error(`unexpected ${name}`);
34+
return server?.set ? server.set(arg) : { ...(arg as object), config: false };
35+
});
36+
const client = server && {
37+
connectionMeta: { backend: server.backend ?? 'websocket' },
38+
scope: () => ({
39+
rpc: {
40+
call,
41+
sharedState: async (key: string) => {
42+
if (server.sharedStateFails) throw new Error('down');
43+
expect(key).toBe('browser-tools');
44+
return {
45+
value: () => server.value,
46+
on: (_: string, listener: (value: unknown) => void) => {
47+
listeners.push(listener);
48+
return () => {};
49+
},
50+
};
51+
},
52+
},
53+
}),
54+
};
55+
fixture = TestBed.createComponent(BrowserToolsSetting);
56+
fixture.componentRef.setInput('rpc', client as unknown as DevframeRpcClient | null);
57+
document.body.appendChild(fixture.nativeElement);
58+
const root = fixture.nativeElement as HTMLElement;
59+
const toggle = () => root.querySelector<HTMLInputElement>('input[role="switch"]')!;
60+
const text = () => root.textContent?.replace(/\s+/g, ' ') ?? '';
61+
const push = (value: unknown) => {
62+
for (const listener of listeners) listener(value);
63+
};
64+
return { root, toggle, text, call, push };
65+
}
66+
67+
describe('browser agent tools setting', () => {
68+
it('reads only a boolean switch from the shared state', () => {
69+
expect(browserToolsState({ enabled: true, config: false })).toEqual({
70+
enabled: true,
71+
config: false,
72+
});
73+
expect(browserToolsState({ enabled: false })).toEqual({ enabled: false, config: false });
74+
for (const value of [undefined, null, {}, { enabled: 'yes' }, 'on']) {
75+
expect(browserToolsState(value)).toBeNull();
76+
}
77+
});
78+
79+
it('is a labelled switch, off by default, that says how long it lasts', async () => {
80+
const { toggle, text } = render({ value: { enabled: false, config: false } });
81+
await settle();
82+
const input = toggle();
83+
expect(input.type).toBe('checkbox');
84+
expect(input.checked).toBe(false);
85+
expect(input.disabled).toBe(false);
86+
expect(input.closest('label')?.textContent).toContain('Browser agent tools');
87+
const described = input.getAttribute('aria-describedby')!.split(' ');
88+
for (const id of described) expect(document.getElementById(id)).not.toBeNull();
89+
expect(text()).toMatch(/until the server restarts/);
90+
expect(text()).toMatch(/off in this project/);
91+
expect(text()).toMatch(/no reload/);
92+
});
93+
94+
it('turns the tools on through the server and follows other panels', async () => {
95+
const { toggle, call, push, root } = render({ value: { enabled: false, config: false } });
96+
await settle();
97+
toggle().click();
98+
await settle();
99+
expect(call).toHaveBeenCalledWith('set-browser-tools', { enabled: true });
100+
expect(toggle().checked).toBe(true);
101+
expect(root.querySelector('[role="status"]')?.textContent).toMatch(/on\. Connected pages/);
102+
103+
// Another panel turned them off.
104+
push({ enabled: false, config: false });
105+
await settle();
106+
expect(toggle().checked).toBe(false);
107+
});
108+
109+
it('shows the error with Retry when the server refuses, and retries the change', async () => {
110+
let fail = true;
111+
const { toggle, call, root } = render({
112+
value: { enabled: false, config: true },
113+
set: (arg) => {
114+
if (fail) throw new Error('gone');
115+
return { ...(arg as object), config: true };
116+
},
117+
});
118+
await settle();
119+
toggle().click();
120+
await settle();
121+
const alert = root.querySelector('[role="alert"]')!;
122+
expect(alert.textContent).toMatch(/Couldn't turn the browser agent tools on/);
123+
expect(toggle().checked).toBe(false);
124+
fail = false;
125+
alert.querySelector('button')!.click();
126+
await settle();
127+
expect(call).toHaveBeenLastCalledWith('set-browser-tools', { enabled: true });
128+
expect(toggle().checked).toBe(true);
129+
expect(root.querySelector('[role="alert"]')).toBeNull();
130+
});
131+
132+
it('is disabled while connecting, on a saved report and on a server without the switch', async () => {
133+
let view = render(null);
134+
await settle();
135+
expect(view.toggle().disabled).toBe(true);
136+
expect(view.text()).toMatch(/Waiting for the devtools server/);
137+
fixture!.destroy();
138+
139+
view = render({ value: { enabled: false, config: false }, backend: 'static' });
140+
await settle();
141+
expect(view.toggle().disabled).toBe(true);
142+
expect(view.text()).toMatch(/saved report/);
143+
fixture!.destroy();
144+
145+
view = render({ value: undefined });
146+
await settle();
147+
expect(view.toggle().disabled).toBe(true);
148+
expect(view.text()).toMatch(/browserTools: true/);
149+
});
150+
151+
it('shows a Retry when the setting cannot be read', async () => {
152+
const { toggle, root } = render({ value: undefined, sharedStateFails: true });
153+
await settle();
154+
expect(toggle().disabled).toBe(true);
155+
expect(root.querySelector('[role="alert"]')?.textContent).toMatch(/Couldn't read/);
156+
expect(root.querySelector('[role="alert"] button')?.textContent).toMatch(/Retry/);
157+
});
158+
});

‎app/src/__tests__/settings.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ describe('Settings page', () => {
126126
'Theme',
127127
'Tabs',
128128
'Popup on the page',
129+
'Browser agents',
129130
'Keyboard shortcuts',
130131
]);
131132
expect(root.querySelector('h1, h3')).toBeNull();
@@ -145,7 +146,7 @@ describe('Settings page', () => {
145146
'forms',
146147
]);
147148
expect(document.activeElement).toBe(button('Move Signals up'));
148-
expect(root.querySelector('[role="status"]')?.textContent).toContain(
149+
expect(root.querySelector(':scope > [role="status"]')?.textContent).toContain(
149150
'Signals moved to position 2 of 4.',
150151
);
151152
// At the top, focus moves to the other arrow instead of a disabled one.

‎app/src/app.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@ function readView(): View | null {
480480
(passiveChange)="setPassive($event)"
481481
[hotkey]="popupHotkey()"
482482
[paletteShortcut]="paletteShortcut"
483+
[rpc]="rpc()"
483484
/>
484485
} @else if (connectionFailed()) {
485486
<div class="connection-error" role="alert">

0 commit comments

Comments
 (0)