From 8fbac7d7d6fff4aa41631676b7d7356fc26d6f60 Mon Sep 17 00:00:00 2001 From: erKam Date: Sun, 11 Oct 2026 03:41:27 +0000 Subject: [PATCH 1/3] fix(http): keep focus on rule removal and name the preview body Removing a fault rule or an SSR override dropped focus to the page body. Focus now moves to the next Remove button, the previous one when the last row went, or the list heading when the list is empty. A repeated "Rule removed." message is cleared and set again so the status region reads it each time. The status and Apply on dropdowns point at the SSR mock note, and the override kind dropdown and the error message field point at the help text for the chosen override. The shared select gains a describedBy input for this. The timeline count is hidden until there is a call, instead of showing "0 of 0" next to the empty state. The response preview and the TransferState values are scrollable focus stops, so they get role region and a label. --- app/src/__tests__/network-a11y.test.ts | 176 ++++++++++++++++++ app/src/pages/network-inspector.ts | 93 ++++++--- app/src/ui/select.ts | 3 + apps/docs/src/content/inspectors/ssr-http.md | 4 +- ...=> browser-agent-rpc-BXhoSh1z-J4TrlP2H.js} | 2 +- .../{index-CgvJVwtz.js => index-BC4CNdzR.js} | 11 +- extension/ui/index.html | 2 +- 7 files changed, 263 insertions(+), 28 deletions(-) create mode 100644 app/src/__tests__/network-a11y.test.ts rename extension/ui/assets/{browser-agent-rpc-BXhoSh1z-CQUbrXfP.js => browser-agent-rpc-BXhoSh1z-J4TrlP2H.js} (93%) rename extension/ui/assets/{index-CgvJVwtz.js => index-BC4CNdzR.js} (95%) diff --git a/app/src/__tests__/network-a11y.test.ts b/app/src/__tests__/network-a11y.test.ts new file mode 100644 index 00000000..e973b346 --- /dev/null +++ b/app/src/__tests__/network-a11y.test.ts @@ -0,0 +1,176 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock('../rpc', () => ({ + rpcCall: async (_rpc: unknown, _name: string, ...args: unknown[]) => args[0], +})); + +const { NetworkInspector } = await import('../pages/network-inspector'); + +type Inspector = InstanceType; +type Rule = ReturnType[number]; +type Call = ReturnType[number]; + +let fixture: ComponentFixture; + +const host = () => fixture.nativeElement as HTMLElement; +const status = () => host().querySelector('.toolbar [role="status"]')!.textContent!.trim(); + +function rule(id: string, pattern: string): Rule { + return { id, pattern, enabled: true, target: 'client', status: 503 }; +} + +function call(id: string, preview?: string): Call { + return { + id, + url: `http://localhost/api/${id}`, + method: 'GET', + status: 200, + durationMs: 1, + side: 'server', + cacheHit: false, + faulted: false, + at: 1000, + preview, + }; +} + +async function mount() { + fixture = TestBed.createComponent(NetworkInspector); + document.body.append(fixture.nativeElement); + await fixture.whenStable(); + return fixture.componentInstance; +} + +async function settle() { + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); +} + +async function clickRemove(selector: string, index: number) { + const buttons = host().querySelectorAll(selector); + buttons[index].focus(); + buttons[index].click(); + await settle(); +} + +describe('NetworkInspector accessibility', () => { + beforeAll(() => { + Element.prototype.scrollIntoView ??= () => {}; + }); + + afterEach(() => { + fixture?.destroy(); + TestBed.resetTestingModule(); + }); + + it('moves focus to the next, then the previous Remove button, then the heading', async () => { + const inspector = await mount(); + inspector.rules.set([rule('a', '/a'), rule('b', '/b'), rule('c', '/c')]); + await fixture.whenStable(); + const label = () => document.activeElement?.getAttribute('aria-label'); + + await clickRemove('[data-remove-rule]', 0); + expect(label()).toBe('Remove rule for /b'); + expect(status()).toBe('Rule removed.'); + + await clickRemove('[data-remove-rule]', 1); + expect(label()).toBe('Remove rule for /b'); + expect(status()).toBe('Rule removed.'); + + await clickRemove('[data-remove-rule]', 0); + expect(document.activeElement?.id).toBe('rule-list-heading'); + expect(inspector.rules()).toEqual([]); + }); + + it('says a repeated removal again', async () => { + const inspector = await mount(); + inspector.rules.set([rule('a', '/a'), rule('b', '/b')]); + await fixture.whenStable(); + await clickRemove('[data-remove-rule]', 0); + expect(status()).toBe('Rule removed.'); + + const node = host().querySelector('.toolbar [role="status"]')!; + const seen: (string | null)[] = []; + const observer = new MutationObserver((records) => + seen.push(...records.map((r) => r.oldValue)), + ); + observer.observe(node, { characterData: true, characterDataOldValue: true, subtree: true }); + await clickRemove('[data-remove-rule]', 0); + observer.disconnect(); + // Emptied, then filled again, so the live region has a change to read. + expect(seen).toEqual(['Rule removed.', '']); + expect(status()).toBe('Rule removed.'); + }); + + it('moves focus the same way when an override is removed', async () => { + const inspector = await mount(); + inspector.overrides.set([ + { id: 'o1', kind: 'client-render', pattern: '/one', enabled: true }, + { id: 'o2', kind: 'client-render', pattern: '/two', enabled: true }, + ]); + await fixture.whenStable(); + + await clickRemove('[data-remove-override]', 1); + expect(document.activeElement?.getAttribute('aria-label')).toBe( + 'Remove Client render for /one', + ); + expect(status()).toBe('Override removed.'); + + await clickRemove('[data-remove-override]', 0); + expect(document.activeElement?.id).toBe('override-list-heading'); + }); + + it('points the form controls at their help text', async () => { + const inspector = await mount(); + const trigger = (label: string) => + host().querySelector(`button[role="combobox"][aria-labelledby="${label}"]`)!; + + expect(trigger('rule-status-label').hasAttribute('aria-describedby')).toBe(false); + inspector.setDraft('pattern', '/api'); + inspector.setDraft('status', '200'); + await fixture.whenStable(); + expect(host().querySelector('#rule-ssr-note')).not.toBeNull(); + expect(trigger('rule-status-label').getAttribute('aria-describedby')).toBe('rule-ssr-note'); + expect(trigger('rule-target-label').getAttribute('aria-describedby')).toBe('rule-ssr-note'); + + const help = () => host().querySelector('#override-kind-help')?.textContent ?? ''; + expect(help()).toContain('Throws while the server renders'); + expect(trigger('override-kind-label').getAttribute('aria-describedby')).toBe( + 'override-kind-help', + ); + const message = host().querySelector('input[placeholder="Simulated render error"]')!; + expect(message.getAttribute('aria-describedby')).toBe('override-kind-help'); + + inspector.setOverrideDraft('kind', 'client-render'); + await fixture.whenStable(); + expect(help()).toContain('index.csr.html'); + + inspector.setOverrideDraft('kind', 'state-edit'); + await fixture.whenStable(); + expect(trigger('override-kind-label').hasAttribute('aria-describedby')).toBe(false); + }); + + it('shows the filter count only once there are calls', async () => { + const inspector = await mount(); + const total = () => host().querySelector('.filters .total')!; + expect(host().querySelector('.filters')!.classList).toContain('no-calls'); + + inspector.serverCalls.set([call('a')]); + await fixture.whenStable(); + expect(host().querySelector('.filters')!.classList).not.toContain('no-calls'); + expect(total().textContent!.trim()).toBe('1 of 1'); + }); + + it('names the scrollable response preview', async () => { + const inspector = await mount(); + inspector.serverCalls.set([call('a', '{"ok":true}')]); + inspector.selectedCallId.set('a'); + await fixture.whenStable(); + const pre = host().querySelector('pre.preview-body')!; + expect(pre.getAttribute('tabindex')).toBe('0'); + expect(pre.getAttribute('role')).toBe('region'); + expect(pre.getAttribute('aria-label')).toBe('Response body'); + }); +}); diff --git a/app/src/pages/network-inspector.ts b/app/src/pages/network-inspector.ts index d78005a1..9b81c521 100644 --- a/app/src/pages/network-inspector.ts +++ b/app/src/pages/network-inspector.ts @@ -534,7 +534,12 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ what="HTTP calls" limit="httpCalls" /> -
+
@@ -760,6 +768,7 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ Apply on Status

{{ bodyError() }}

@if (draftMocksOnServer()) { -

+

SSR mocks are not transferred to the client: the browser requests the URL again and gets the real response unless the rule also applies on the client.

@@ -820,7 +830,9 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [
-

Rules ({{ rules().length }})

+

+ Rules ({{ rules().length }}) +

    @for (rule of rules(); track rule.id) {
  • @@ -841,6 +853,7 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [
-

Overrides ({{ overrides().length }})

+

+ Overrides ({{ overrides().length }}) +

    @for (o of overrides(); track o.id) {
  • @@ -1002,6 +1019,7 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [