mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-24 08:24:07 +00:00
* feat(web-shell): time-series metrics charts on Daemon Status
Add seven bottleneck-analysis line charts (concurrency, requests, API
latency, prompt latency, event-loop lag, memory, token burn) to the
Daemon Status dashboard, backed by a new server-side metrics ring.
The status endpoint is a point-in-time snapshot, so line charts need a
time series. A bounded ring buffer in the daemon (daemon-metrics-ring.ts)
seals one bucket every 5s (~15min retained) from three seams:
- HTTP request rate/latency via the telemetry middleware
- prompt queue-wait/duration via the bridge telemetry hooks
- per-round token usage sniffed at the bridge session/update fan-in
(new DaemonBridgeTelemetryMetrics.tokenUsage hook)
plus memory / active sessions+prompts / a window-scoped event-loop lag
p99 read as gauges at seal time.
The series rides the existing GET /daemon/status contract
(runtime.metrics.series), threaded through the SDK types (JSON passthrough)
to a dependency-free inline-SVG chart component in web-shell -- no charting
library added to the CSP-strict serve --web bundle.
Tests: metrics-ring math, token-usage sniffing on the real sessionUpdate
path, and SVG chart rendering. Verified end-to-end against a live daemon
(GLM-5.2): requests/latency/memory/event-loop, real token burn and prompt
duration, with the concurrency gauge tracking active prompts.
* feat(web-shell): tabs, chart tooltips, and fullscreen for Daemon Status
Split the now chart-heavy Daemon Status dashboard into Overview / Metrics /
Diagnostics tabs (status badge, refresh, and issues stay global) so
monitoring, configuration, and troubleshooting each get their own space
instead of one long 70vh scroll.
Add an interactive hover cursor to the charts: a vertical time line, a dot on
each series, and a tooltip reading the bucket time plus every series' value at
that point -- previously only the latest value and peak were legible, from the
legend.
Add an opt-in fullscreen toggle to DialogShell (via allowFullscreen, wired for
Daemon Status) that expands the panel to near the full viewport; scrolling is
consolidated into the shell body so the content actually grows with it.
Tests: tab switching + diagnostics-behind-tab, SVG tooltip rendering, and the
DialogShell fullscreen toggle. Verified end-to-end against a live daemon
(GLM-5.2) with real request / token / prompt data.
* feat(web-shell): add CPU, LLM-latency, queue-depth, IPC & connection metrics
Extend the Daemon Status metrics ring with more bottleneck-analysis
dimensions, filling the two biggest gaps — resource cost had only memory
(no CPU), and latency had only client->daemon HTTP (not daemon->model):
- CPU %: process.cpuUsage() delta, core-normalized (memoryPressureMonitor
formula, clamped 0-100), sampled alongside memory.
- LLM API latency p50/p95: the token frame's _meta.durationMs (the
daemon->model round-trip), separating 'model is slow' from 'we are slow'.
- Prompt queue depth: a new bridge.pendingPromptTotal aggregate, folded into
the concurrency chart beside active tasks.
- IPC pipe throughput: daemon<->ACP-child stdio bytes (already measured; now
windowed via metricsRing.recordPipe).
- Connection counts (SSE/WS/ACP) and rate-limit rejections, read lazily in the
sampler from the ACP handle registry and the rate limiter.
The tokenUsage telemetry hook is widened to carry durationMs. Verified
end-to-end against a live daemon (GLM-5.2): LLM p95 28.6s vs HTTP p95 324ms,
queue depth 1, IPC peak 0.3MB, SSE gauge 1 on a live stream.
* feat(web-shell): add ACP child process CPU/memory (self-reported over ACP)
The daemon's own CPU/memory only tell half the story — the real LLM/tool work
runs in the spawned 'qwen --acp' child, which is where the resource cost lives.
Surface it: the child self-reports its rss + cpuPercent to the daemon over a new
read-only ACP extMethod (qwen/status/workspace/resource); the bridge caches the
latest sample on the live channel, and the metrics sampler reads it
synchronously each tick (firing an async refresh for the next, off the hot path).
The child computes cpuPercent as a process.cpuUsage() delta between polls (no
dependency on MemoryPressureMonitor's tool-gated sampling), core-normalized and
clamped. Rendered as a second line on the CPU and Memory charts (daemon vs
child, side by side).
Verified end-to-end (GLM-5.2): child RSS ~300MB vs daemon RSS ~225MB, child CPU
tracking above the daemon's -- the child is the resource hog, now visible.
* test(web-shell): cover Metrics tab, chart rendering, and the recordRequest seam
Address review — the metrics dashboard's rendering and its HTTP data seam had
no tests:
- DaemonStatusDialog: switching to the Metrics tab renders the charts from the
series (one SvgLineChart per card) and hides the Overview panel; an empty
series shows the collecting-metrics placeholder.
- daemonTelemetryMiddleware: recordRequest fires once with (durationMs,
statusCode) on a matched route (real status code; once across finish/close),
is not called for unmatched routes, and is a silent no-op when omitted.
* fix(web-shell): enlarge Daemon Status charts in fullscreen
Fullscreen widened the panel but the charts stayed small — the grid just packed
in more 280px cards at a fixed 52px SVG height, so the extra viewport bought
more small charts, not bigger ones. Now the DialogShell body carries a
`data-dialog-fullscreen` marker; the chart grid switches to wider cards (min
480px → fewer columns) and the SVG grows to 120px, so fullscreen actually
enlarges the plots. Verified: 2 wide columns at 120px vs 3-4 columns at 52px.
* fix(web-shell): resolve chart colors in portal, guard child-resource polling
Address review (real-user + ci-bot):
- [Critical] Chart colors (--primary, --agent-blue-400) resolved to nothing in
the DialogShell portal (createPortal to document.body escapes the app root that
defines them), so ~half the chart lines rendered stroke:none. Add both vars to
DialogShell's own theme scope. Verified: 25/25 path strokes colored (was 5 none).
- [Critical] refreshChildResource had no in-flight guard; requestWorkspaceStatus
waits up to 10s (> the 5s cadence), so a degraded child accumulated concurrent
polls. Add a single-flight guard.
- [Critical] getChildResourceSnapshot returned last-good rss/cpu forever; add a
30s staleness window so a stuck child reads 0 instead of looking healthy.
- Exclude GET /daemon/status (the dashboard's own poll) from the metrics-ring
request rate, so the Requests chart doesn't count itself.
- Fix cpuPercent JSDoc (percent of total capacity across cores, clamped [0,100])
in the ring + SDK mirror; add a keep-in-sync cross-reference on the mirror.
Tests: recordRequest excludes /daemon/status; buildDaemonStatusResponse embeds
runtime.metrics.series when provided and omits it otherwise.
* fix(web-shell): address Daemon Status charts review feedback
Correctness fixes surfaced in review:
- bridgeClient: guard token accounting on a live `entry`. On the
`session/load` path HistoryReplayer re-emits saved usage as live
session/update frames before the session entry is registered, which
otherwise dumped a session's historical token total into the current
metrics window as a phantom burn spike with no model call.
- run-qwen-serve metrics sampler: wrap each tick in try/catch/finally so a
throwing getter can't crash the daemon; reset the event-loop-lag histogram
in finally so a thrown tick can't permanently discard it; skip the CPU
delta (and leave the baseline untouched) when process.cpuUsage() throws;
seed the rate-reject baseline on the first tick instead of reporting the
whole since-start backlog as one spike.
- acpAgent workspaceResource: advance the child-CPU baseline only on a
successful read, avoiding a ~2x phantom spike on the poll after a failure.
- bridge.pendingPromptTotal: count only queued prompts (state === 'queued'),
not the running one, so the "Queued" chart reflects real backpressure and
no longer shadows the "Active tasks" line.
- Make the new Daemon Status bridge hooks optional in AcpSessionBridge and
optional-chain them in the sampler, so a bridge injected via
RunQwenServeDeps.bridge that predates them degrades gracefully.
Robustness / UX:
- daemon-metrics-ring sanitizes non-finite gauges to 0 so a bad reading
never serializes as JSON null and gaps the chart.
- child-resource refresh logs failures at debug for observability.
- formatBytes drops to KB/B for sub-MB pipe traffic (was "0.0 MB").
- SvgLineChart peak label is now i18n'd (daemon.charts.peak).
- Daemon Status tabs get the full WAI-ARIA tabs pattern: aria-controls,
role=tabpanel, and Arrow/Home/End keyboard navigation with roving tabindex.
Tests: replay token guard (no live entry), pipe/gauge/sample-cap defenses,
large-value legend formatting, and tab keyboard navigation.
* fix(web-shell): keep Daemon Status fullscreen + tooltip correct in dialog portal
Two DialogShell-portal theme-scope issues surfaced by a follow-up review:
- Fullscreen was clamped back to 80vh on narrow screens: the
`@media (max-width: 560px)` `.panel` rule has equal specificity and later
source order than the base `.panelFullscreen`, so it won. Add a media-scoped
`.panelFullscreen` override so fullscreen actually expands on mobile.
- SvgLineChart tooltip background used `var(--popover, var(--card))`, neither of
which the portal theme scope defines, so the declaration dropped and the
tooltip rendered transparent over the chart. Fall back to `--background`
(which the dialog scope does define).
* fix(web-shell): flip chart tooltip below cursor near scroll-container top
The Daemon Status charts live inside DialogShell's overflow-y:auto body, so the
topmost chart's upward tooltip (bottom: calc(100% + 4px)) clipped against the
scroll container's top edge, truncating the time header / first series row on
hover. SvgLineChart now resolves its nearest scroll parent and flips the tooltip
below the cursor when the plot sits within ~one tooltip-height of that clip
boundary.
* fix(daemon-status): harden child-resource CPU/memory + sampler lag on failure
Follow-up review fixes:
- acpAgent: prevChildCpu inits to null (not {0,0}) and the workspaceResource
handler gates the delta on a live prevCpu baseline, so an init-time
cpuUsage() failure no longer manufactures a phantom spike on the first poll
— mirrors the daemon sampler's safeCpuUsage null-on-failure contract.
- acpAgent: guard process.memoryUsage() too, reporting 0 rss on failure while
keeping the already-computed cpuPercent instead of throwing the handler.
- bridge: require Number.isFinite() (typeof NaN === 'number' is true) and
clamp cpuPercent to [0,100] when caching the child's self-report.
- run-qwen-serve sampler: gate the 5s child-resource refresh on an active
SSE/WS client (idle staleness already reads 0), and hoist the event-loop
lag read before the try so a thrown tick charts the real accumulated lag
instead of a misleading 0.
* fix(daemon-status): protect artifact path from metrics callback + share CPU delta
Follow-up review fixes:
- bridgeClient: wrap recordLiveTokenUsage in try/catch so a throwing injected
onTokenUsage callback can't skip the critical artifact processing after it —
metrics are optional, artifacts are not.
- Extract computeCpuPercent() into daemon-metrics-ring and share it between the
daemon self-sampler and the ACP child's workspaceResource handler, removing
the duplicated delta/normalize/clamp math and giving it direct unit coverage
(null sample, non-positive window, normalization, phantom-spike + negative
clamps).
- Add a single-flight test for bridge.refreshChildResource (two rapid calls
collapse to one in-flight RPC).
482 lines
14 KiB
TypeScript
482 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { act, useEffect, useRef } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { I18nProvider } from '../../i18n';
|
|
import { ThemeProvider } from '../../themeContext';
|
|
import { DialogShell } from './DialogShell';
|
|
|
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
|
|
let container: HTMLDivElement | null = null;
|
|
let root: Root | null = null;
|
|
|
|
function mount(node: React.ReactNode) {
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
act(() => {
|
|
root!.render(
|
|
<I18nProvider language="en">
|
|
<ThemeProvider value="dark">{node}</ThemeProvider>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
}
|
|
|
|
function press(key: string, options: KeyboardEventInit = {}) {
|
|
act(() => {
|
|
document.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key, cancelable: true, ...options }),
|
|
);
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
act(() => root?.unmount());
|
|
container?.remove();
|
|
root = null;
|
|
container = null;
|
|
});
|
|
|
|
function AutofocusChild() {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
inputRef.current?.focus();
|
|
}, []);
|
|
|
|
return <input ref={inputRef} type="text" />;
|
|
}
|
|
|
|
describe('DialogShell', () => {
|
|
it('hides the fullscreen toggle by default', () => {
|
|
mount(
|
|
<DialogShell title="T" onClose={() => {}}>
|
|
<div>content</div>
|
|
</DialogShell>,
|
|
);
|
|
// DialogShell portals into document.body, so query there, not the root.
|
|
expect(document.querySelector('button[aria-pressed]')).toBeNull();
|
|
});
|
|
|
|
it('shows a fullscreen toggle when allowFullscreen and toggles its state', () => {
|
|
mount(
|
|
<DialogShell title="T" allowFullscreen onClose={() => {}}>
|
|
<div>content</div>
|
|
</DialogShell>,
|
|
);
|
|
const toggle = document.querySelector(
|
|
'button[aria-pressed]',
|
|
) as HTMLElement | null;
|
|
expect(toggle).toBeTruthy();
|
|
expect(toggle!.getAttribute('aria-pressed')).toBe('false');
|
|
act(() => {
|
|
toggle!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
expect(toggle!.getAttribute('aria-pressed')).toBe('true');
|
|
});
|
|
|
|
it('closes on Escape', () => {
|
|
const onClose = vi.fn();
|
|
mount(
|
|
<DialogShell title="Test" onClose={onClose}>
|
|
<button type="button">inner</button>
|
|
</DialogShell>,
|
|
);
|
|
|
|
press('Escape');
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('ignores Escape that belongs to an IME composition', () => {
|
|
const onClose = vi.fn();
|
|
mount(
|
|
<DialogShell title="Test" onClose={onClose}>
|
|
<input type="text" />
|
|
</DialogShell>,
|
|
);
|
|
|
|
// Chrome/Firefox: Escape cancelling a composition reports isComposing.
|
|
press('Escape', { isComposing: true });
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
|
|
// WebKit: compositionend fires first, so only keyCode 229 marks the key.
|
|
act(() => {
|
|
const imeEscape = new KeyboardEvent('keydown', {
|
|
key: 'Escape',
|
|
cancelable: true,
|
|
});
|
|
Object.defineProperty(imeEscape, 'keyCode', { value: 229 });
|
|
document.dispatchEvent(imeEscape);
|
|
});
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
|
|
// A genuine Escape still closes.
|
|
press('Escape');
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('keeps focus on the panel when Tab is pressed with nothing focusable inside', () => {
|
|
mount(
|
|
<DialogShell title="Test" onClose={vi.fn()}>
|
|
<button type="button" data-testid="inner">
|
|
inner
|
|
</button>
|
|
</DialogShell>,
|
|
);
|
|
|
|
const panel = document.querySelector<HTMLElement>('[role="dialog"]')!;
|
|
// Simulate content whose focusables all went away (e.g. everything became
|
|
// disabled/hidden while an action runs).
|
|
document.querySelector('[data-dialog-close]')!.remove();
|
|
document.querySelector('[data-testid="inner"]')!.remove();
|
|
|
|
panel.focus();
|
|
act(() => {
|
|
document.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Tab',
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}),
|
|
);
|
|
});
|
|
// Tab must not escape to the page behind; focus stays parked on the panel.
|
|
expect(document.activeElement).toBe(panel);
|
|
});
|
|
|
|
it('lets a dialog control consume Escape instead of closing', () => {
|
|
const onClose = vi.fn();
|
|
mount(
|
|
<DialogShell title="Test" onClose={onClose}>
|
|
<input
|
|
type="text"
|
|
onKeyDown={(event) => {
|
|
// e.g. an inline editor cancelling its edit on Escape.
|
|
if (event.key === 'Escape') event.preventDefault();
|
|
}}
|
|
/>
|
|
</DialogShell>,
|
|
);
|
|
|
|
const input = document.querySelector<HTMLInputElement>('input')!;
|
|
input.focus();
|
|
act(() => {
|
|
input.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Escape',
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}),
|
|
);
|
|
});
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('only the topmost of stacked shells handles Escape', () => {
|
|
const onCloseBottom = vi.fn();
|
|
const onCloseTop = vi.fn();
|
|
mount(
|
|
<>
|
|
<DialogShell title="Bottom" onClose={onCloseBottom}>
|
|
<button type="button">bottom</button>
|
|
</DialogShell>
|
|
<DialogShell title="Top" onClose={onCloseTop}>
|
|
<button type="button">top</button>
|
|
</DialogShell>
|
|
</>,
|
|
);
|
|
|
|
// One Escape peels off one layer — the top one — not both at once.
|
|
press('Escape');
|
|
expect(onCloseTop).toHaveBeenCalledTimes(1);
|
|
expect(onCloseBottom).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps focus inside the top shell if a lower shell unmounts first', () => {
|
|
const opener = document.createElement('button');
|
|
document.body.appendChild(opener);
|
|
opener.focus();
|
|
|
|
function Harness({ showBottom }: { showBottom: boolean }) {
|
|
return (
|
|
<>
|
|
{showBottom ? (
|
|
<DialogShell title="Bottom" onClose={vi.fn()}>
|
|
<button type="button">bottom</button>
|
|
</DialogShell>
|
|
) : null}
|
|
<DialogShell title="Top" onClose={vi.fn()}>
|
|
<button type="button" data-testid="top-focus">
|
|
top
|
|
</button>
|
|
</DialogShell>
|
|
</>
|
|
);
|
|
}
|
|
|
|
mount(<Harness showBottom={true} />);
|
|
const topButton = document.querySelector<HTMLElement>(
|
|
'[data-testid="top-focus"]',
|
|
)!;
|
|
expect(document.activeElement).toBe(topButton);
|
|
|
|
act(() => {
|
|
root!.render(
|
|
<I18nProvider language="en">
|
|
<ThemeProvider value="dark">
|
|
<Harness showBottom={false} />
|
|
</ThemeProvider>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
|
|
// Focus must stay inside the remaining top shell, not jump back behind it.
|
|
expect(document.activeElement).toBe(topButton);
|
|
opener.remove();
|
|
});
|
|
|
|
it('restores focus to the remaining top shell when the lower shell unmounts after focus moved', () => {
|
|
const opener = document.createElement('button');
|
|
document.body.appendChild(opener);
|
|
opener.focus();
|
|
|
|
function Harness({ showBottom }: { showBottom: boolean }) {
|
|
return (
|
|
<>
|
|
{showBottom ? (
|
|
<DialogShell title="Bottom" onClose={vi.fn()}>
|
|
<button type="button">bottom</button>
|
|
</DialogShell>
|
|
) : null}
|
|
<DialogShell title="Top" onClose={vi.fn()}>
|
|
<button type="button" data-testid="top-focus">
|
|
top
|
|
</button>
|
|
</DialogShell>
|
|
</>
|
|
);
|
|
}
|
|
|
|
mount(<Harness showBottom={true} />);
|
|
const topButton = document.querySelector<HTMLElement>(
|
|
'[data-testid="top-focus"]',
|
|
)!;
|
|
document.querySelector<HTMLElement>('button:not([data-testid])')!.focus();
|
|
|
|
act(() => {
|
|
root!.render(
|
|
<I18nProvider language="en">
|
|
<ThemeProvider value="dark">
|
|
<Harness showBottom={false} />
|
|
</ThemeProvider>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
|
|
expect(document.activeElement).toBe(topButton);
|
|
opener.remove();
|
|
});
|
|
|
|
it('closes when the backdrop is clicked but not when the panel is clicked', () => {
|
|
const onClose = vi.fn();
|
|
mount(
|
|
<DialogShell title="Test" onClose={onClose}>
|
|
<button type="button">inner</button>
|
|
</DialogShell>,
|
|
);
|
|
|
|
const backdrop = document.querySelector<HTMLElement>(
|
|
'[data-keyboard-scope]',
|
|
);
|
|
const panel = document.querySelector<HTMLElement>('[role="dialog"]');
|
|
expect(backdrop).toBeTruthy();
|
|
|
|
act(() => {
|
|
panel!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
|
panel!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
backdrop!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
|
backdrop!.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
|
backdrop!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not close when a drag starts in the panel and ends on the backdrop', () => {
|
|
const onClose = vi.fn();
|
|
mount(
|
|
<DialogShell title="Test" onClose={onClose}>
|
|
<button type="button">inner</button>
|
|
</DialogShell>,
|
|
);
|
|
|
|
const backdrop = document.querySelector<HTMLElement>(
|
|
'[data-keyboard-scope]',
|
|
)!;
|
|
const panel = document.querySelector<HTMLElement>('[role="dialog"]')!;
|
|
|
|
act(() => {
|
|
panel.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
|
backdrop.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
|
backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not close when a press starts on the backdrop and ends on the panel', () => {
|
|
const onClose = vi.fn();
|
|
mount(
|
|
<DialogShell title="Test" onClose={onClose}>
|
|
<button type="button">inner</button>
|
|
</DialogShell>,
|
|
);
|
|
|
|
const backdrop = document.querySelector<HTMLElement>(
|
|
'[data-keyboard-scope]',
|
|
)!;
|
|
const panel = document.querySelector<HTMLElement>('[role="dialog"]')!;
|
|
|
|
act(() => {
|
|
backdrop.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
|
panel.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
|
// Browsers synthesize click on the nearest common ancestor for mismatched
|
|
// press/release targets; here that's effectively the backdrop.
|
|
backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('moves focus into the dialog on open', () => {
|
|
mount(
|
|
<DialogShell title="Test" onClose={vi.fn()}>
|
|
<button type="button" data-testid="first">
|
|
first
|
|
</button>
|
|
<button type="button">second</button>
|
|
</DialogShell>,
|
|
);
|
|
|
|
const first = document.querySelector<HTMLElement>('[data-testid="first"]');
|
|
expect(document.activeElement).toBe(first);
|
|
});
|
|
|
|
it('restores focus to the opener on close', () => {
|
|
const opener = document.createElement('button');
|
|
document.body.appendChild(opener);
|
|
opener.focus();
|
|
expect(document.activeElement).toBe(opener);
|
|
|
|
mount(
|
|
<DialogShell title="Test" onClose={vi.fn()}>
|
|
<button type="button">inner</button>
|
|
</DialogShell>,
|
|
);
|
|
// Focus moved into the dialog.
|
|
expect(document.activeElement).not.toBe(opener);
|
|
|
|
act(() => root?.unmount());
|
|
root = null;
|
|
expect(document.activeElement).toBe(opener);
|
|
opener.remove();
|
|
});
|
|
|
|
it('restores focus to the opener even if a child autofocuses first', () => {
|
|
const opener = document.createElement('button');
|
|
document.body.appendChild(opener);
|
|
opener.focus();
|
|
expect(document.activeElement).toBe(opener);
|
|
|
|
mount(
|
|
<DialogShell title="Test" onClose={vi.fn()}>
|
|
<AutofocusChild />
|
|
</DialogShell>,
|
|
);
|
|
|
|
const input = document.querySelector<HTMLInputElement>('input');
|
|
expect(document.activeElement).toBe(input);
|
|
|
|
act(() => root?.unmount());
|
|
root = null;
|
|
expect(document.activeElement).toBe(opener);
|
|
opener.remove();
|
|
});
|
|
|
|
it('traps Tab within the dialog, wrapping at both ends', () => {
|
|
mount(
|
|
<DialogShell title="Test" onClose={vi.fn()}>
|
|
<button type="button" data-testid="last">
|
|
inner
|
|
</button>
|
|
</DialogShell>,
|
|
);
|
|
|
|
// Focusables in DOM order: [close button, inner button].
|
|
const close = document.querySelector<HTMLElement>('[data-dialog-close]')!;
|
|
const last = document.querySelector<HTMLElement>('[data-testid="last"]')!;
|
|
|
|
// Tab from the last focusable wraps to the first (the close button).
|
|
last.focus();
|
|
act(() => {
|
|
document.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }),
|
|
);
|
|
});
|
|
expect(document.activeElement).toBe(close);
|
|
|
|
// Shift+Tab from the first focusable wraps to the last.
|
|
close.focus();
|
|
act(() => {
|
|
document.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Tab',
|
|
shiftKey: true,
|
|
bubbles: true,
|
|
}),
|
|
);
|
|
});
|
|
expect(document.activeElement).toBe(last);
|
|
});
|
|
|
|
it('pulls focus into the dialog when Tab is pressed while the panel holds focus', () => {
|
|
mount(
|
|
<DialogShell title="Test" onClose={vi.fn()}>
|
|
<button type="button" data-testid="last">
|
|
inner
|
|
</button>
|
|
</DialogShell>,
|
|
);
|
|
|
|
const panel = document.querySelector<HTMLElement>('[role="dialog"]')!;
|
|
const close = document.querySelector<HTMLElement>('[data-dialog-close]')!;
|
|
const last = document.querySelector<HTMLElement>('[data-testid="last"]')!;
|
|
|
|
// Focus sits on the panel itself (roving-list fallback). Tab pulls it in.
|
|
panel.focus();
|
|
act(() => {
|
|
document.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }),
|
|
);
|
|
});
|
|
expect(document.activeElement).toBe(close);
|
|
|
|
// Shift+Tab from the panel pulls in from the end instead.
|
|
panel.focus();
|
|
act(() => {
|
|
document.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Tab',
|
|
shiftKey: true,
|
|
bubbles: true,
|
|
}),
|
|
);
|
|
});
|
|
expect(document.activeElement).toBe(last);
|
|
});
|
|
});
|