mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-04 05:40:58 +00:00
* feat(serve): chart model API errors and auto-retries in daemon status The Daemon Status dialog's Metrics tab tracked client→daemon HTTP request errors but had no visibility into model/LLM API failures or the automatic retries that absorb them. Add a "Model API health" chart plotting the per-window model API error and auto-retry counts. Those events originate in the ACP child (where LLM calls run), so they reach the daemon over the same child→daemon channel token usage already rides: a self-contained process-global counter (api-activity-tracker) is bumped at the logApiError / logApiRetry choke points, drained per live model round by the ACP MessageEmitter onto agent_message_chunk._meta, sniffed at the bridge fan-in, and folded into the daemon metrics ring as the llmApiErrors / llmApiRetries window aggregates (mirrored into the SDK wire type). A dedicated counter — rather than a new SessionMetrics field — keeps the change from rippling through ~20 stats/display/snapshot sites. Draining is guarded on a live frame's durationMs so replayed history can't consume real pending counts. * feat(web-shell): add per-chart help tooltips to daemon status metrics The Metrics tab packs ~12 dense time-series charts; with no inline explanation they generate recurring "what does this measure / why are there two 'errors' charts?" questions. Add an ⓘ affordance beside every chart title whose hover/focus reveals a one-line, plain-language note (what it measures, its unit/window, and what's normal). The button's aria-label carries the text to assistive tech; the visual bubble reuses the existing data-tooltip's surface so it reads in light and dark. The copy targets the highest-friction ambiguities: the HTTP "Requests" chart is called out as NOT model calls (vs. the model "API health" chart), and "API health" spells out the errors-vs-retries relationship (each failed attempt = 1 error, each backoff = 1 retry; errors above retries = hard failures). en + zh. * fix(web-shell): keep chart ⓘ hint out of the heading accessible name Review follow-up on #6837: - Move InfoHint out of the <h3> into a flex card header. The accessible-name algorithm folds a descendant button's aria-label (the whole help sentence) into the heading name, so a screen reader would announce "Model API health Each failed attempt = 1 error…" in the heading rotor. The flex header keeps the visual layout identical; a test now asserts no <h3> contains a button. - Add the missing logApiError OTel-SDK-off test, mirroring the logApiRetry one, so a future refactor that moves recordError() after the isTelemetrySdkInitialized guard is caught by a red test. * fix(web-shell): visible focus on chart ⓘ button, plus review nits Second review pass on #6837: - InfoHint focus: the ⓘ button now takes a filled-background focus/hover state (--muted, in scope under the DialogShell portal), matching the dialog's own header buttons (DialogShell .close / .iconButton). Keyboard focus is now unmistakable rather than a color shift alone (WCAG 2.4.7). - logApiRetry: doc comment corrected from "three-sink" to a four-sink fan-out — the apiActivityTracker increment (sink 0, fires before the SDK guard) was undocumented and read as stale. - Tighten the Metrics-tab SvgLineChart count assertion from >=10 to >=12 so a silently dropped chart card fails the test.
1082 lines
36 KiB
TypeScript
1082 lines
36 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import type { DaemonMetricsSeriesBucket } from '@qwen-code/webui/daemon-react-sdk';
|
|
import { I18nProvider } from '../../i18n';
|
|
|
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
|
|
const summaryReport = {
|
|
v: 1,
|
|
detail: 'summary',
|
|
generatedAt: '2026-07-03T08:00:00.000Z',
|
|
status: 'warning',
|
|
issues: [
|
|
{
|
|
code: 'pending_permissions',
|
|
severity: 'warning',
|
|
message: '2 permission requests are waiting for a client response',
|
|
},
|
|
],
|
|
daemon: {
|
|
pid: 4242,
|
|
uptimeMs: 3_723_000,
|
|
mode: 'http-bridge',
|
|
workspaceCwd: '/work/demo',
|
|
qwenCodeVersion: '0.9.0',
|
|
},
|
|
security: {
|
|
tokenConfigured: true,
|
|
requireAuth: false,
|
|
loopbackBind: true,
|
|
allowOriginConfigured: false,
|
|
allowOriginMode: 'default',
|
|
sessionShellCommandEnabled: false,
|
|
},
|
|
limits: {
|
|
maxSessions: 8,
|
|
maxPendingPromptsPerSession: 5,
|
|
listenerMaxConnections: null,
|
|
eventRingSize: 1024,
|
|
promptDeadlineMs: 120_000,
|
|
writerIdleTimeoutMs: null,
|
|
channelIdleTimeoutMs: 60_000,
|
|
sessionIdleTimeoutMs: 300_000,
|
|
acpConnectionCap: null,
|
|
},
|
|
capabilities: {
|
|
protocolVersions: { serve: 1 },
|
|
features: ['daemon_status', 'session_events'],
|
|
},
|
|
runtime: {
|
|
sessions: { active: 3 },
|
|
permissions: { pending: 2, policy: 'vote' },
|
|
channel: { live: true },
|
|
channelWorker: { enabled: false, state: 'disabled', channels: [] },
|
|
transport: {
|
|
restSseActive: 1,
|
|
acp: {
|
|
enabled: true,
|
|
connections: 2,
|
|
connectionStreams: 2,
|
|
sessionStreams: 1,
|
|
sseStreams: 1,
|
|
wsStreams: 0,
|
|
pendingClientRequests: 0,
|
|
},
|
|
},
|
|
// Real daemon rate-limit tiers (RateLimitTier = prompt | mutation | read).
|
|
rateLimit: {
|
|
enabled: true,
|
|
rejectedSinceStart: { prompt: 37, mutation: 3, read: 1 },
|
|
},
|
|
process: {
|
|
rss: 200 * 1024 * 1024,
|
|
heapTotal: 80 * 1024 * 1024,
|
|
heapUsed: 50 * 1024 * 1024,
|
|
},
|
|
},
|
|
};
|
|
|
|
const fullReport = {
|
|
...summaryReport,
|
|
detail: 'full',
|
|
// The daemon rolls workspace/preflight problems into status + issues only for
|
|
// detail=full, so the full report is strictly more severe than the summary.
|
|
status: 'error',
|
|
issues: [
|
|
...summaryReport.issues,
|
|
{
|
|
code: 'preflight_error',
|
|
severity: 'error',
|
|
section: 'workspace.preflight',
|
|
message: 'preflight failed: node version too old',
|
|
},
|
|
],
|
|
full: {
|
|
sessions: [
|
|
{
|
|
sessionId: 'sess-1',
|
|
workspaceCwd: '/work/demo',
|
|
createdAt: '2026-07-03T07:00:00.000Z',
|
|
displayName: 'My session',
|
|
clientCount: 2,
|
|
subscriberCount: 1,
|
|
attachCount: 1,
|
|
pendingPromptCount: 1,
|
|
pendingPermissionCount: 2,
|
|
hasActivePrompt: true,
|
|
lastEventId: 42,
|
|
},
|
|
],
|
|
acpConnections: [{ connectionId: 'conn-1' }],
|
|
workspace: {
|
|
mcp: {
|
|
status: 'ok',
|
|
durationMs: 12,
|
|
summary: { servers: 2, connected: 2 },
|
|
},
|
|
preflight: {
|
|
status: 'error',
|
|
durationMs: 30,
|
|
error: { kind: 'error', message: 'preflight exploded' },
|
|
},
|
|
},
|
|
auth: {
|
|
supportedDeviceFlowProviders: ['qwen'],
|
|
pendingDeviceFlowCount: 0,
|
|
},
|
|
},
|
|
};
|
|
|
|
// A full metrics bucket (typed, so a dropped field is a compile error) for the
|
|
// Metrics-tab tests.
|
|
function makeBucket(t: number): DaemonMetricsSeriesBucket {
|
|
return {
|
|
t,
|
|
activeSessions: 1,
|
|
activePrompts: 0,
|
|
queuedPrompts: 0,
|
|
requests: 5,
|
|
errors: 0,
|
|
latencyP50Ms: 2,
|
|
latencyP95Ms: 8,
|
|
promptsCompleted: 0,
|
|
promptQueueWaitP95Ms: 0,
|
|
promptDurationP95Ms: 0,
|
|
llmApiP50Ms: 0,
|
|
llmApiP95Ms: 0,
|
|
llmApiErrors: 0,
|
|
llmApiRetries: 0,
|
|
cpuPercent: 1,
|
|
rssBytes: 200 * 1024 * 1024,
|
|
heapUsedBytes: 50 * 1024 * 1024,
|
|
eventLoopLagP99Ms: 3,
|
|
pipeInBytes: 0,
|
|
pipeOutBytes: 0,
|
|
sseConnections: 1,
|
|
wsConnections: 0,
|
|
acpConnections: 2,
|
|
rateLimitRejected: 0,
|
|
tokensIn: 0,
|
|
tokensOut: 0,
|
|
childCpuPercent: 0,
|
|
childRssBytes: 0,
|
|
};
|
|
}
|
|
|
|
function summaryWithSeries(count: number) {
|
|
const series = Array.from({ length: count }, (_, i) =>
|
|
makeBucket(1000 + i * 5000),
|
|
);
|
|
return {
|
|
...summaryReport,
|
|
runtime: { ...summaryReport.runtime, metrics: { series } },
|
|
};
|
|
}
|
|
|
|
type HookState = {
|
|
report: unknown;
|
|
loading: boolean;
|
|
error: Error | undefined;
|
|
};
|
|
|
|
const summaryReload = vi.fn(async () => undefined);
|
|
const fullReload = vi.fn(async () => undefined);
|
|
let summaryState: HookState = {
|
|
report: summaryReport,
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
let fullState: HookState = {
|
|
report: fullReport,
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
const seenDetails: Array<string | undefined> = [];
|
|
|
|
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
|
|
useStatusReport: (options: { detail?: string } = {}) => {
|
|
seenDetails.push(options.detail);
|
|
if (options.detail === 'full') {
|
|
return { ...fullState, data: fullState.report, reload: fullReload };
|
|
}
|
|
return {
|
|
...summaryState,
|
|
data: summaryState.report,
|
|
reload: summaryReload,
|
|
};
|
|
},
|
|
}));
|
|
|
|
const { DaemonStatusDialog } = await import('./DaemonStatusDialog');
|
|
|
|
let container: HTMLDivElement | null = null;
|
|
let root: Root | null = null;
|
|
|
|
function mount(language: 'en' | 'zh-CN' = 'en') {
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
act(() => {
|
|
root!.render(
|
|
<I18nProvider language={language}>
|
|
<DaemonStatusDialog />
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
}
|
|
|
|
// The toolbar status badge is the first span whose text is a level label; it
|
|
// renders before the issues card, so `find` returns the top badge.
|
|
function topBadgeText(): string | undefined {
|
|
return Array.from(container!.querySelectorAll('span'))
|
|
.map((s) => s.textContent?.trim() ?? '')
|
|
.find(
|
|
(label) => label === 'OK' || label === 'Warning' || label === 'Error',
|
|
);
|
|
}
|
|
|
|
// Diagnostics (sessions / workspace / auth) live under their own tab now;
|
|
// reveal it before asserting on that content.
|
|
function openDiagnostics(): void {
|
|
// Diagnostics is the last tab; match by role so this works in any locale
|
|
// (the label is "Diagnostics" / "诊断").
|
|
const tabs = container!.querySelectorAll('[role="tab"]');
|
|
const btn = tabs[tabs.length - 1];
|
|
if (!btn) throw new Error('Diagnostics tab not found');
|
|
act(() => {
|
|
btn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
summaryState = { report: summaryReport, loading: false, error: undefined };
|
|
fullState = { report: fullReport, loading: false, error: undefined };
|
|
seenDetails.length = 0;
|
|
summaryReload.mockReset();
|
|
summaryReload.mockImplementation(async () => undefined);
|
|
fullReload.mockReset();
|
|
fullReload.mockImplementation(async () => undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root?.unmount());
|
|
container?.remove();
|
|
root = null;
|
|
container = null;
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('DaemonStatusDialog', () => {
|
|
it('renders live summary counters with the full-detail rollup badge', () => {
|
|
mount();
|
|
const text = container!.textContent ?? '';
|
|
// Live counters come from the summary response.
|
|
expect(text).toContain(
|
|
'2 permission requests are waiting for a client response',
|
|
);
|
|
expect(text).toContain('0.9.0');
|
|
expect(text).toContain('4242');
|
|
expect(text).toContain('/work/demo');
|
|
expect(text).toContain('1h 2m 3s');
|
|
expect(text).toContain('daemon_status');
|
|
// rate-limit rejects are summed across tiers (37 + 4)
|
|
expect(text).toContain('41');
|
|
// The badge + issues reflect the full rollup (error + preflight), not the
|
|
// summary (warning) — otherwise the dialog would read "OK/Warning" while a
|
|
// loaded full diagnostic is failing.
|
|
expect(topBadgeText()).toBe('Error');
|
|
expect(text).toContain('preflight failed: node version too old');
|
|
});
|
|
|
|
it('translates workspace section status badges, including "unavailable"', () => {
|
|
fullState = {
|
|
report: {
|
|
...fullReport,
|
|
full: {
|
|
...fullReport.full,
|
|
workspace: {
|
|
preflight: {
|
|
status: 'unavailable',
|
|
durationMs: 5,
|
|
error: { kind: 'timeout', message: 'timed out' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
mount('zh-CN');
|
|
openDiagnostics();
|
|
const text = container!.textContent ?? '';
|
|
// The section badge is translated ("不可用"), not the raw wire value.
|
|
expect(text).toContain('不可用');
|
|
expect(text).not.toContain('unavailable');
|
|
});
|
|
|
|
it('fetches both summary and full detail; diagnostics live under the Diagnostics tab', () => {
|
|
mount();
|
|
// Both detail levels are requested up front; there is no user-facing
|
|
// summary/full switch to reason about (the tabs are Overview / Metrics /
|
|
// Diagnostics, not a detail selector).
|
|
expect(seenDetails).toContain('summary');
|
|
expect(seenDetails).toContain('full');
|
|
const buttonLabels = Array.from(container!.querySelectorAll('button')).map(
|
|
(el) => el.textContent,
|
|
);
|
|
expect(buttonLabels).not.toContain('Summary');
|
|
expect(buttonLabels).not.toContain('Full');
|
|
// Diagnostics content is on its own tab — the default (Overview) panel does
|
|
// not render it, so the full report is fetched but parked until the tab is
|
|
// opened.
|
|
expect(container!.textContent ?? '').not.toContain('Workspace Diagnostics');
|
|
openDiagnostics();
|
|
// Now the detail sections render.
|
|
const text = container!.textContent ?? '';
|
|
expect(text).toContain('My session');
|
|
expect(text).toContain('preflight exploded');
|
|
expect(text).toContain('Workspace Diagnostics');
|
|
// A healthy workspace section renders its name, translated status, and
|
|
// summary chips.
|
|
expect(text).toContain('mcp');
|
|
expect(text).toContain('OK');
|
|
expect(text).toContain('servers: 2');
|
|
});
|
|
|
|
it('renders charts on the Metrics tab from the series and hides Overview', () => {
|
|
summaryState = {
|
|
report: summaryWithSeries(2),
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
mount();
|
|
// Overview is the default tab.
|
|
expect(container!.textContent ?? '').toContain('4242'); // pid (Overview)
|
|
const metricsTab = container!.querySelector('#daemon-tab-metrics')!;
|
|
act(() => {
|
|
metricsTab.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
const text = container!.textContent ?? '';
|
|
// Chart cards render (spot-check i18n'd titles across the set)...
|
|
expect(text).toContain('Concurrency');
|
|
expect(text).toContain('LLM API latency');
|
|
expect(text).toContain('Model API health');
|
|
expect(text).toContain('Token burn');
|
|
// ...one SvgLineChart per card (12 charts on the Metrics tab); a floor of
|
|
// 12 catches a regression that silently drops a chart card.
|
|
expect(
|
|
container!.querySelectorAll('svg[role="img"]').length,
|
|
).toBeGreaterThanOrEqual(12);
|
|
// ...and the panels are mutually exclusive: Overview content is gone.
|
|
expect(text).not.toContain('4242');
|
|
});
|
|
|
|
it('shows the collecting-metrics placeholder when the series is empty', () => {
|
|
// summaryReport carries no runtime.metrics.
|
|
mount();
|
|
const metricsTab = container!.querySelector('#daemon-tab-metrics')!;
|
|
act(() => {
|
|
metricsTab.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
expect(container!.textContent ?? '').toContain('Collecting metrics');
|
|
});
|
|
|
|
it('formats large legend values via the compact-count, percent, and KB branches', () => {
|
|
const big = makeBucket(1000);
|
|
big.requests = 15000; // formatCount ≥10k branch → "15k"
|
|
big.cpuPercent = 120; // formatPercent ≥100 branch → "120%" (no decimal)
|
|
big.pipeInBytes = 5 * 1024; // formatBytes sub-MB branch → "5.0 KB"
|
|
summaryState = {
|
|
report: {
|
|
...summaryReport,
|
|
runtime: { ...summaryReport.runtime, metrics: { series: [big] } },
|
|
},
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
mount();
|
|
const metricsTab = container!.querySelector('#daemon-tab-metrics')!;
|
|
act(() => {
|
|
metricsTab.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
const text = container!.textContent ?? '';
|
|
expect(text).toContain('15k');
|
|
expect(text).toContain('120%');
|
|
expect(text).toContain('5.0 KB');
|
|
});
|
|
|
|
it('charts model API errors and automatic retries on the Metrics tab', () => {
|
|
const b = makeBucket(1000);
|
|
b.llmApiErrors = 4;
|
|
b.llmApiRetries = 7;
|
|
summaryState = {
|
|
report: {
|
|
...summaryReport,
|
|
runtime: { ...summaryReport.runtime, metrics: { series: [b] } },
|
|
},
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
mount();
|
|
const metricsTab = container!.querySelector('#daemon-tab-metrics')!;
|
|
act(() => {
|
|
metricsTab.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
const text = container!.textContent ?? '';
|
|
// The dedicated model-API-health card renders with both series legends and
|
|
// their peak values (distinct 4 / 7 so neither collides with fixture data).
|
|
expect(text).toContain('Model API health');
|
|
expect(text).toContain('API errors');
|
|
expect(text).toContain('Retries');
|
|
expect(text).toContain('4');
|
|
expect(text).toContain('7');
|
|
});
|
|
|
|
it('gives every metrics chart a hover help affordance with self-explaining copy', () => {
|
|
summaryState = {
|
|
report: summaryWithSeries(2),
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
mount();
|
|
const metricsTab = container!.querySelector('#daemon-tab-metrics')!;
|
|
act(() => {
|
|
metricsTab.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
// One ⓘ help button per chart card; its aria-label carries the full
|
|
// explanation to assistive tech (filter to the long help sentences so the
|
|
// toolbar/tab buttons don't count).
|
|
const helpButtons = Array.from(
|
|
container!.querySelectorAll('button[aria-label]'),
|
|
).filter((b) => (b.getAttribute('aria-label') ?? '').length > 20);
|
|
expect(helpButtons.length).toBeGreaterThanOrEqual(12);
|
|
// The ⓘ must be a SIBLING of the heading, not a child: a nested button's
|
|
// aria-label folds the whole help sentence into the heading's accessible
|
|
// name. No <h3> may contain a button.
|
|
for (const h of container!.querySelectorAll('h3')) {
|
|
expect(h.querySelector('button')).toBeNull();
|
|
}
|
|
// The two highest-value disambiguations render in the DOM: the model-health
|
|
// errors-vs-retries explanation, and that the HTTP "Requests" chart is NOT
|
|
// model calls (resolving the "two errors charts" confusion).
|
|
const text = container!.textContent ?? '';
|
|
expect(text).toContain('Each failed attempt = 1 error');
|
|
expect(text).toContain('NOT model calls');
|
|
expect(
|
|
container!.querySelector(
|
|
'button[aria-label*="Provider-side LLM failures"]',
|
|
),
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it('tolerates a metrics bucket from a daemon predating the API-health fields', () => {
|
|
// Older daemon: the wire bucket omits llmApiErrors/llmApiRetries. The chart
|
|
// must fall back to zero rather than throwing or gapping.
|
|
const legacy = makeBucket(1000) as Partial<DaemonMetricsSeriesBucket>;
|
|
delete legacy.llmApiErrors;
|
|
delete legacy.llmApiRetries;
|
|
summaryState = {
|
|
report: {
|
|
...summaryReport,
|
|
runtime: { ...summaryReport.runtime, metrics: { series: [legacy] } },
|
|
},
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
mount();
|
|
const metricsTab = container!.querySelector('#daemon-tab-metrics')!;
|
|
act(() => {
|
|
metricsTab.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
// Renders without a crash; the card is still present.
|
|
expect(container!.textContent ?? '').toContain('Model API health');
|
|
});
|
|
|
|
it('moves between tabs with arrow / Home / End keys (roving tabindex)', () => {
|
|
mount();
|
|
const tabs = container!.querySelectorAll('[role="tab"]');
|
|
expect(tabs).toHaveLength(4);
|
|
// Overview active by default; ArrowRight advances to the next tab (Usage).
|
|
act(() => {
|
|
tabs[0].dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'ArrowRight',
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}),
|
|
);
|
|
});
|
|
expect(tabs[1].getAttribute('aria-selected')).toBe('true');
|
|
expect(tabs[0].getAttribute('aria-selected')).toBe('false');
|
|
// Roving tabindex: only the active tab stays in the tab order.
|
|
expect(tabs[1].getAttribute('tabindex')).toBe('0');
|
|
expect(tabs[0].getAttribute('tabindex')).toBe('-1');
|
|
// End jumps to the last tab, Home back to the first (with wrap-around via
|
|
// the modulo also covered: ArrowLeft from the first tab would land on End).
|
|
act(() => {
|
|
tabs[1].dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'End',
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}),
|
|
);
|
|
});
|
|
expect(tabs[3].getAttribute('aria-selected')).toBe('true');
|
|
act(() => {
|
|
tabs[3].dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Home',
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}),
|
|
);
|
|
});
|
|
expect(tabs[0].getAttribute('aria-selected')).toBe('true');
|
|
// Each tab points at its panel for assistive tech.
|
|
expect(tabs[0].getAttribute('aria-controls')).toBe(
|
|
'daemon-tabpanel-overview',
|
|
);
|
|
});
|
|
|
|
it('auto-refresh reloads only the cheap summary, never the full report', async () => {
|
|
vi.useFakeTimers();
|
|
mount();
|
|
expect(summaryReload).not.toHaveBeenCalled();
|
|
// Advance one interval at a time, flushing the in-flight `.finally` between
|
|
// ticks so the guard is clear for the next tick.
|
|
for (let tick = 1; tick <= 3; tick++) {
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(5_000);
|
|
await Promise.resolve();
|
|
});
|
|
expect(summaryReload).toHaveBeenCalledTimes(tick);
|
|
}
|
|
// The expensive detail path is never hit by the interval.
|
|
expect(fullReload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips a poll tick while the previous summary reload is still in flight', async () => {
|
|
vi.useFakeTimers();
|
|
let release: () => void = () => {};
|
|
summaryReload.mockImplementationOnce(
|
|
() =>
|
|
new Promise<undefined>((resolve) => {
|
|
release = () => resolve(undefined);
|
|
}),
|
|
);
|
|
mount();
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(5_000);
|
|
await Promise.resolve();
|
|
});
|
|
expect(summaryReload).toHaveBeenCalledTimes(1); // first tick, still pending
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(5_000);
|
|
await Promise.resolve();
|
|
});
|
|
expect(summaryReload).toHaveBeenCalledTimes(1); // coalesced away while pending
|
|
await act(async () => {
|
|
release();
|
|
await Promise.resolve();
|
|
});
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(5_000);
|
|
await Promise.resolve();
|
|
});
|
|
expect(summaryReload).toHaveBeenCalledTimes(2); // fires again once free
|
|
});
|
|
|
|
it('does not poll while the tab is backgrounded', async () => {
|
|
vi.useFakeTimers();
|
|
Object.defineProperty(document, 'hidden', {
|
|
configurable: true,
|
|
get: () => true,
|
|
});
|
|
mount();
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(15_000);
|
|
await Promise.resolve();
|
|
});
|
|
expect(summaryReload).not.toHaveBeenCalled();
|
|
// Bring the tab back to the foreground; polling resumes.
|
|
Object.defineProperty(document, 'hidden', {
|
|
configurable: true,
|
|
get: () => false,
|
|
});
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(5_000);
|
|
await Promise.resolve();
|
|
});
|
|
expect(summaryReload).toHaveBeenCalledTimes(1);
|
|
Reflect.deleteProperty(document, 'hidden');
|
|
});
|
|
|
|
it('manual refresh reloads both summary and full', () => {
|
|
mount();
|
|
const refreshButton = Array.from(
|
|
container!.querySelectorAll('button'),
|
|
).find((el) => el.textContent === 'Refresh');
|
|
expect(refreshButton).toBeDefined();
|
|
act(() => {
|
|
refreshButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
expect(summaryReload).toHaveBeenCalledTimes(1);
|
|
expect(fullReload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('stops refreshing after unmount', () => {
|
|
vi.useFakeTimers();
|
|
mount();
|
|
act(() => root!.unmount());
|
|
act(() => {
|
|
vi.advanceTimersByTime(20_000);
|
|
});
|
|
expect(summaryReload).not.toHaveBeenCalled();
|
|
expect(fullReload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to the summary rollup badge while diagnostics are still loading', () => {
|
|
fullState = { report: undefined, loading: true, error: undefined };
|
|
mount();
|
|
let text = container!.textContent ?? '';
|
|
// Top cards come from the summary and render right away...
|
|
expect(text).toContain('4242');
|
|
// Before the full report lands the badge reflects the summary rollup, and
|
|
// the full-only preflight issue is not shown yet.
|
|
expect(topBadgeText()).toBe('Warning');
|
|
expect(text).not.toContain('preflight failed: node version too old');
|
|
// ...while the detail sections (under the Diagnostics tab) show a loading
|
|
// placeholder rather than the report.
|
|
openDiagnostics();
|
|
text = container!.textContent ?? '';
|
|
expect(text).toContain('Loading diagnostics');
|
|
expect(text).not.toContain('Workspace Diagnostics');
|
|
});
|
|
|
|
it('shows the load error when no report is available', () => {
|
|
summaryState = {
|
|
report: undefined,
|
|
loading: false,
|
|
error: new Error('connection refused'),
|
|
};
|
|
fullState = { report: undefined, loading: false, error: undefined };
|
|
mount();
|
|
const text = container!.textContent ?? '';
|
|
expect(text).toContain('Failed to load daemon status');
|
|
expect(text).toContain('connection refused');
|
|
});
|
|
|
|
it('keeps the toolbar healthy when only the full fetch fails, and flags the detail section', () => {
|
|
// Summary succeeds (cards + timestamp fresh); only the detail fetch fails.
|
|
fullState = {
|
|
report: undefined,
|
|
loading: false,
|
|
error: new Error('full boom'),
|
|
};
|
|
mount();
|
|
let text = container!.textContent ?? '';
|
|
// Live summary cards still render...
|
|
expect(text).toContain('4242');
|
|
expect(text).toContain('http-bridge');
|
|
// ...the toolbar does NOT show the summary-failure banner...
|
|
expect(text).not.toContain('Failed to load daemon status');
|
|
// With no full report, the badge falls back to the summary rollup.
|
|
expect(topBadgeText()).toBe('Warning');
|
|
// ...and the failure is confined to the diagnostics section (its tab).
|
|
openDiagnostics();
|
|
text = container!.textContent ?? '';
|
|
expect(text).toContain('Failed to load diagnostics');
|
|
});
|
|
|
|
it('renders the ACP-disabled branch when the transport is off', () => {
|
|
const acpOff = {
|
|
...summaryReport,
|
|
runtime: {
|
|
...summaryReport.runtime,
|
|
transport: {
|
|
...summaryReport.runtime.transport,
|
|
acp: {
|
|
...summaryReport.runtime.transport.acp,
|
|
enabled: false,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
summaryState = { report: acpOff, loading: false, error: undefined };
|
|
fullState = { report: undefined, loading: true, error: undefined };
|
|
mount();
|
|
const text = container!.textContent ?? '';
|
|
expect(text).toContain('ACP transport disabled');
|
|
expect(text).not.toContain('ACP streams (session/SSE/WS)');
|
|
});
|
|
|
|
it('formats uptime, memory, and durations across unit boundaries', () => {
|
|
const boundaries = {
|
|
...summaryReport,
|
|
daemon: { ...summaryReport.daemon, uptimeMs: 90_061_000 }, // 1d 1h 1m
|
|
limits: {
|
|
...summaryReport.limits,
|
|
promptDeadlineMs: 1_500, // fractional seconds -> "1.5s"
|
|
sessionIdleTimeoutMs: 500, // sub-second -> "500ms"
|
|
},
|
|
runtime: {
|
|
...summaryReport.runtime,
|
|
process: {
|
|
rss: 2 * 1024 * 1024 * 1024, // 2 GB
|
|
heapTotal: 1024 * 1024 * 1024,
|
|
heapUsed: 512 * 1024 * 1024, // 512.0 MB
|
|
},
|
|
},
|
|
};
|
|
summaryState = { report: boundaries, loading: false, error: undefined };
|
|
fullState = { report: undefined, loading: true, error: undefined };
|
|
mount();
|
|
const text = container!.textContent ?? '';
|
|
expect(text).toContain('1d 1h 1m'); // formatUptime day branch
|
|
expect(text).toContain('2.00 GB'); // formatBytes GB branch
|
|
expect(text).toContain('512.0 MB'); // formatBytes MB branch
|
|
expect(text).toContain('1.5s'); // formatDurationMs fractional-second branch
|
|
expect(text).toContain('500ms'); // formatDurationMs sub-second branch
|
|
});
|
|
|
|
it('surfaces runtime startup state and channel-worker diagnostics', () => {
|
|
const degraded = {
|
|
...summaryReport,
|
|
runtime: {
|
|
...summaryReport.runtime,
|
|
loading: true,
|
|
channel: { live: false },
|
|
channelWorker: {
|
|
enabled: true,
|
|
state: 'exited',
|
|
channels: ['alpha'],
|
|
error: 'worker crashed on boot',
|
|
restartCount: 3,
|
|
exitCode: 1,
|
|
},
|
|
},
|
|
};
|
|
summaryState = { report: degraded, loading: false, error: undefined };
|
|
fullState = { report: undefined, loading: true, error: undefined };
|
|
mount();
|
|
const text = container!.textContent ?? '';
|
|
expect(text).toContain('Runtime is starting'); // runtime.loading cue
|
|
expect(text).toContain('down'); // channel.live === false
|
|
expect(text).toContain('exited (exit 1)'); // channelWorkerState()
|
|
expect(text).toContain('worker crashed on boot'); // channelWorker.error
|
|
expect(text).toContain('Worker restarts'); // restartCount > 0
|
|
expect(text).toContain('3');
|
|
});
|
|
|
|
it('shows the runtime start-failure message', () => {
|
|
const failed = {
|
|
...summaryReport,
|
|
runtime: { ...summaryReport.runtime, error: 'bind EADDRINUSE :4170' },
|
|
};
|
|
summaryState = { report: failed, loading: false, error: undefined };
|
|
fullState = { report: undefined, loading: true, error: undefined };
|
|
mount();
|
|
const text = container!.textContent ?? '';
|
|
expect(text).toContain('Runtime failed to start');
|
|
expect(text).toContain('bind EADDRINUSE :4170');
|
|
});
|
|
|
|
it('renders empty/disabled placeholders (sessions, rate limit, capabilities, ACP)', () => {
|
|
const sparseSummary = {
|
|
...summaryReport,
|
|
capabilities: { protocolVersions: { serve: 1 }, features: [] },
|
|
runtime: {
|
|
...summaryReport.runtime,
|
|
rateLimit: { enabled: false, rejectedSinceStart: {} },
|
|
transport: {
|
|
...summaryReport.runtime.transport,
|
|
acp: { ...summaryReport.runtime.transport.acp, enabled: false },
|
|
},
|
|
},
|
|
};
|
|
summaryState = { report: sparseSummary, loading: false, error: undefined };
|
|
fullState = {
|
|
report: { ...fullReport, full: { ...fullReport.full, sessions: [] } },
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
mount();
|
|
let text = container!.textContent ?? '';
|
|
// Overview placeholders: acp disabled, rate-limit disabled, empty caps.
|
|
expect(text).toContain('ACP transport disabled'); // acp.enabled === false
|
|
// rate-limit disabled and empty capabilities both render "disabled"/"none".
|
|
expect(text).toContain('disabled');
|
|
expect(text).toContain('none');
|
|
// The empty-sessions placeholder lives under the Diagnostics tab.
|
|
openDiagnostics();
|
|
text = container!.textContent ?? '';
|
|
expect(text).toContain('No active sessions');
|
|
});
|
|
|
|
it('shows the toolbar failure banner when a poll fails but data is present', () => {
|
|
// Distinct from the no-data early return: the summary has stale data plus
|
|
// an error, so the cards render and the toolbar banner appears.
|
|
summaryState = {
|
|
report: summaryReport,
|
|
loading: false,
|
|
error: new Error('poll failed'),
|
|
};
|
|
mount();
|
|
const text = container!.textContent ?? '';
|
|
expect(text).toContain('4242'); // stale cards still render
|
|
expect(text).toContain('Failed to load daemon status'); // toolbar banner
|
|
});
|
|
|
|
it('shows the pure loading state before any report arrives', () => {
|
|
summaryState = { report: undefined, loading: true, error: undefined };
|
|
fullState = { report: undefined, loading: true, error: undefined };
|
|
mount();
|
|
const text = container!.textContent ?? '';
|
|
expect(text).toContain('Loading daemon status');
|
|
expect(text).not.toContain('Failed to load daemon status');
|
|
});
|
|
|
|
it('renders the workspace empty-state when no sections are reported', () => {
|
|
fullState = {
|
|
report: { ...fullReport, full: { ...fullReport.full, workspace: {} } },
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
mount();
|
|
openDiagnostics();
|
|
expect(container!.textContent ?? '').toContain(
|
|
'No workspace diagnostics reported',
|
|
);
|
|
});
|
|
|
|
it('contains a malformed daemon response and surfaces the render error', () => {
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
// channelWorker is required by the wire type, but an older daemon could
|
|
// omit it; the inner render would throw on `.enabled` without the boundary.
|
|
summaryState = {
|
|
report: {
|
|
...summaryReport,
|
|
runtime: { ...summaryReport.runtime, channelWorker: undefined },
|
|
},
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
fullState = { report: undefined, loading: false, error: undefined };
|
|
mount();
|
|
const text = container!.textContent ?? '';
|
|
// The outer boundary fallback renders instead of the throw escaping, and
|
|
// the function-form fallback surfaces the actual render error.
|
|
expect(text).toContain('Failed to load daemon status');
|
|
expect(text).toContain('enabled'); // the TypeError message is included
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it('contains a detail-section crash without losing the summary cards', () => {
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
// A malformed detail=full payload (auth omitted) throws inside FullDetail.
|
|
summaryState = { report: summaryReport, loading: false, error: undefined };
|
|
fullState = {
|
|
report: {
|
|
...fullReport,
|
|
full: {
|
|
sessions: [],
|
|
workspace: {},
|
|
acpConnections: [],
|
|
auth: undefined,
|
|
},
|
|
},
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
mount();
|
|
let text = container!.textContent ?? '';
|
|
// Summary cards stay live...
|
|
expect(text).toContain('4242');
|
|
// The whole-dialog (outer) fallback did NOT trigger.
|
|
expect(text).not.toContain('Failed to load daemon status');
|
|
// ...only the detail region (its tab) shows its own boundary fallback.
|
|
openDiagnostics();
|
|
text = container!.textContent ?? '';
|
|
expect(text).toContain('Failed to load diagnostics');
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it('shows a failed state when the full fetch resolves without a full section', () => {
|
|
summaryState = { report: summaryReport, loading: false, error: undefined };
|
|
// Fetch resolved (no error, not loading) but the daemon omitted `full`.
|
|
fullState = {
|
|
report: { ...summaryReport },
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
mount();
|
|
openDiagnostics();
|
|
const text = container!.textContent ?? '';
|
|
expect(text).toContain('Failed to load diagnostics');
|
|
expect(text).not.toContain('Loading diagnostics');
|
|
});
|
|
|
|
it('renders runtime.activity counters when the daemon reports them', () => {
|
|
summaryState = {
|
|
report: {
|
|
...summaryReport,
|
|
runtime: {
|
|
...summaryReport.runtime,
|
|
activity: {
|
|
activePrompts: 2,
|
|
lastActivityAt: '2026-07-03T07:59:00.000Z',
|
|
idleSinceMs: 65_000,
|
|
},
|
|
},
|
|
},
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
fullState = { report: undefined, loading: true, error: undefined };
|
|
mount();
|
|
const text = container!.textContent ?? '';
|
|
expect(text).toContain('Active prompts');
|
|
expect(text).toContain('2');
|
|
expect(text).toContain('Idle for');
|
|
expect(text).toContain('1m 5s'); // formatDurationMs(65000)
|
|
});
|
|
|
|
it('shows "no activity yet" when idleSinceMs is null', () => {
|
|
summaryState = {
|
|
report: {
|
|
...summaryReport,
|
|
runtime: {
|
|
...summaryReport.runtime,
|
|
activity: {
|
|
activePrompts: 0,
|
|
lastActivityAt: null,
|
|
idleSinceMs: null,
|
|
},
|
|
},
|
|
},
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
fullState = { report: undefined, loading: true, error: undefined };
|
|
mount();
|
|
expect(container!.textContent ?? '').toContain('no activity yet');
|
|
});
|
|
|
|
it('omits the activity rows for a daemon that predates runtime.activity', () => {
|
|
// The default fixture has no runtime.activity — the section must not render.
|
|
mount();
|
|
expect(container!.textContent ?? '').not.toContain('Active prompts');
|
|
});
|
|
|
|
it('falls back to the session id when a session has no display name', () => {
|
|
fullState = {
|
|
report: {
|
|
...fullReport,
|
|
full: {
|
|
...fullReport.full,
|
|
sessions: [
|
|
{
|
|
sessionId: 'sess-no-name-9',
|
|
workspaceCwd: '/work/demo',
|
|
createdAt: '2026-07-03T07:00:00.000Z',
|
|
clientCount: 1,
|
|
subscriberCount: 0,
|
|
attachCount: 0,
|
|
pendingPromptCount: 0,
|
|
pendingPermissionCount: 0,
|
|
hasActivePrompt: false,
|
|
lastEventId: 1,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
mount();
|
|
openDiagnostics();
|
|
expect(container!.textContent ?? '').toContain('sess-no-name-9');
|
|
});
|
|
|
|
it('names the individual warning/error cells behind a section status', () => {
|
|
fullState = {
|
|
report: {
|
|
...fullReport,
|
|
full: {
|
|
...fullReport.full,
|
|
workspace: {
|
|
preflight: {
|
|
status: 'warning',
|
|
durationMs: 8,
|
|
summary: { initialized: true, cellsCount: 3 },
|
|
data: {
|
|
cells: [
|
|
{ kind: 'node_version', status: 'ok' },
|
|
{
|
|
kind: 'auth',
|
|
status: 'warning',
|
|
error: 'No auth method configured.',
|
|
},
|
|
{ kind: 'egress', status: 'not_started', hint: 'not impl' },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
mount();
|
|
openDiagnostics();
|
|
const text = container!.textContent ?? '';
|
|
// The warning cell is named with its message...
|
|
expect(text).toContain('auth');
|
|
expect(text).toContain('No auth method configured.');
|
|
// ...while ok / not_started cells are not surfaced as problems.
|
|
expect(text).not.toContain('node_version');
|
|
expect(text).not.toContain('not impl');
|
|
});
|
|
|
|
it('formats the channel-worker signal branch', () => {
|
|
summaryState = {
|
|
report: {
|
|
...summaryReport,
|
|
runtime: {
|
|
...summaryReport.runtime,
|
|
channel: { live: false },
|
|
channelWorker: {
|
|
enabled: true,
|
|
state: 'exited',
|
|
channels: [],
|
|
signal: 'SIGTERM', // no exitCode -> signal branch
|
|
},
|
|
},
|
|
},
|
|
loading: false,
|
|
error: undefined,
|
|
};
|
|
fullState = { report: undefined, loading: true, error: undefined };
|
|
mount();
|
|
expect(container!.textContent ?? '').toContain('exited (SIGTERM)');
|
|
});
|
|
|
|
it('suppresses the toolbar banner when the summary is absent but the full fallback provides data', () => {
|
|
summaryState = {
|
|
report: undefined,
|
|
loading: false,
|
|
error: new Error('summary poll down'),
|
|
};
|
|
fullState = { report: fullReport, loading: false, error: undefined };
|
|
mount();
|
|
const text = container!.textContent ?? '';
|
|
// Cards render from the full fallback...
|
|
expect(text).toContain('4242');
|
|
// ...so the toolbar must not claim the dashboard failed to load.
|
|
expect(text).not.toContain('Failed to load daemon status');
|
|
});
|
|
});
|