fix(ui): group mobile system metrics (#3880)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -943,9 +943,28 @@ func (m *Manager) resumePassthroughCommand(ctx context.Context, execution *Agent
|
||||
// without --resume, effectively clearing the agent's conversation context.
|
||||
// The workflow step prompt is delivered afterwards via stdin (autoStartPassthroughPrompt).
|
||||
func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *AgentExecution) error {
|
||||
interactiveRunner := m.GetInteractiveRunner()
|
||||
if interactiveRunner == nil {
|
||||
return fmt.Errorf("interactive runner not available")
|
||||
}
|
||||
execution.passthroughLifecycleMu.Lock()
|
||||
defer execution.passthroughLifecycleMu.Unlock()
|
||||
processID, err := m.replacePassthroughProcess(ctx, execution, interactiveRunner)
|
||||
execution.passthroughLifecycleMu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// First-idle callbacks acquire the lifecycle lock. Settle startup outside
|
||||
// that lock before a workflow prompt can claim the next running turn.
|
||||
readyCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
if err := interactiveRunner.WaitForFirstIdle(readyCtx, processID); err != nil {
|
||||
return fmt.Errorf("wait for restarted passthrough readiness: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) replacePassthroughProcess(ctx context.Context, execution *AgentExecution, interactiveRunner *process.InteractiveRunner) (string, error) {
|
||||
m.logger.Info("restarting passthrough process for context reset",
|
||||
zap.String("execution_id", execution.ID),
|
||||
zap.String("session_id", execution.SessionID),
|
||||
@@ -953,19 +972,14 @@ func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *Agen
|
||||
|
||||
// 1. Resolve the replacement command and environment before touching the
|
||||
// current PTY. A profile-secret failure must leave the active session usable.
|
||||
interactiveRunner := m.GetInteractiveRunner()
|
||||
if interactiveRunner == nil {
|
||||
return fmt.Errorf("interactive runner not available")
|
||||
}
|
||||
|
||||
// Build fresh command (no SessionID, no Resume, no Prompt).
|
||||
pt, rt, cmd, err := m.freshPassthroughCommand(ctx, execution)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
env, err := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 2. Stop the current PTY process. Clear PassthroughProcessID before
|
||||
@@ -989,7 +1003,7 @@ func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *Agen
|
||||
processInfo, err := interactiveRunner.Start(ctx, startReq)
|
||||
if err != nil {
|
||||
m.updateExecutionError(execution.ID, "failed to restart passthrough session: "+err.Error())
|
||||
return fmt.Errorf("failed to restart passthrough session: %w", err)
|
||||
return "", fmt.Errorf("failed to restart passthrough session: %w", err)
|
||||
}
|
||||
|
||||
// 4. Update execution with new process ID
|
||||
@@ -1013,7 +1027,7 @@ func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *Agen
|
||||
// 6. Publish context reset event
|
||||
m.eventPublisher.PublishAgentEvent(ctx, events.AgentContextReset, execution)
|
||||
|
||||
return nil
|
||||
return processInfo.ID, nil
|
||||
}
|
||||
|
||||
// ResumePassthroughSession restarts a passthrough session after backend restart.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/process"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
eventbus "github.com/kandev/kandev/internal/events/bus"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
func newPassthroughResetReadyTest(t *testing.T, command string) (*Manager, *process.InteractiveRunner, *AgentExecution) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("requires POSIX shell prompt output")
|
||||
}
|
||||
mgr, runner, execution := newPassthroughRunnerManager(t)
|
||||
const agentID = "reset-ready-test-agent"
|
||||
require.NoError(t, mgr.registry.Register(&testAgent{
|
||||
id: agentID, enabled: true,
|
||||
StandardPassthrough: agents.StandardPassthrough{Cfg: agents.PassthroughConfig{
|
||||
Supported: true, PassthroughCmd: agents.NewCommand("sh", "-c", command),
|
||||
PromptPattern: "READY> $", IdleTimeout: time.Minute, SubmitSequence: "\r",
|
||||
}},
|
||||
}))
|
||||
mgr.profileResolver = &mockPassthroughProfileResolver{agentName: agentID, cliPassthrough: true}
|
||||
execution.AgentID = agentID
|
||||
execution.Status = v1.AgentStatusReady
|
||||
t.Cleanup(func() {
|
||||
_ = runner.Stop(context.Background(), execution.PassthroughProcessID)
|
||||
})
|
||||
return mgr, runner, execution
|
||||
}
|
||||
|
||||
func TestRestartPassthroughProcessSettlesStartupBeforeNextTurn(t *testing.T) {
|
||||
mgr, runner, execution := newPassthroughResetReadyTest(t, "printf 'READY> '; read -r input")
|
||||
var startupSettled atomic.Bool
|
||||
runner.SetTurnCompleteCallback(func(sessionID, processID string) {
|
||||
mgr.handlePassthroughTurnComplete(sessionID, processID)
|
||||
startupSettled.Store(true)
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
require.NoError(t, mgr.restartPassthroughProcess(ctx, execution))
|
||||
require.True(t, startupSettled.Load(), "reset must settle boot readiness before admitting a prompt")
|
||||
require.NoError(t, mgr.MarkPassthroughRunning(execution.SessionID))
|
||||
mgr.handlePassthroughTurnComplete(execution.SessionID, execution.PassthroughProcessID)
|
||||
require.Equal(t, v1.AgentStatusReady, execution.Status)
|
||||
bus := mgr.eventBus.(*MockEventBus)
|
||||
var readyCount int
|
||||
for _, event := range bus.PublishedEvents {
|
||||
if event.Type == events.AgentReady {
|
||||
readyCount++
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, readyCount, "the next turn must retain its completion event")
|
||||
}
|
||||
|
||||
func TestRestartPassthroughProcessCancelsStartupWait(t *testing.T) {
|
||||
mgr, _, execution := newPassthroughResetReadyTest(t, "read -r input")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
bus := mgr.eventBus.(*MockEventBus)
|
||||
bus.OnPublish = func(subject string, _ *eventbus.Event) {
|
||||
if subject == events.AgentContextReset {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
err := mgr.restartPassthroughProcess(ctx, execution)
|
||||
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
@@ -178,15 +178,13 @@ describe("StatusSurfaceMetrics", () => {
|
||||
expect(screen.queryByTestId(metricsTestId)).toBeNull();
|
||||
});
|
||||
|
||||
it("subscribes and renders rows when the phone Status drawer opens", () => {
|
||||
it("subscribes and renders host metrics when the phone Status drawer opens", () => {
|
||||
responsiveState.isMobile = true;
|
||||
renderMetrics(true);
|
||||
|
||||
expect(subscribeMock).toHaveBeenCalledWith(true);
|
||||
const metricsRow = screen.getByLabelText("Host metrics").parentElement;
|
||||
expect(metricsRow?.className).toContain("min-h-11");
|
||||
expect(metricsRow?.className).toContain("w-full");
|
||||
expect(metricsRow?.className).toContain("min-w-0");
|
||||
expect(screen.getByLabelText("Host metrics")).toBeTruthy();
|
||||
expect(screen.getByLabelText(cpuAccessibleName)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders every received host metric in the phone Status drawer", () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useAppStore } from "@/components/state-provider";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { useSystemMetricsSubscription } from "@/hooks/use-system-metrics-subscription";
|
||||
import type { SystemMetricSample, SystemMetricsSource } from "@/lib/types/system";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type StatusSurfaceMetricsProps = {
|
||||
presentation: "bar" | "mobile-drawer";
|
||||
@@ -49,18 +50,29 @@ export function StatusSurfaceMetrics({
|
||||
return (
|
||||
<section
|
||||
data-testid="app-status-metrics"
|
||||
className="w-full min-w-0 space-y-2 py-0.5"
|
||||
className="w-full min-w-0 space-y-3 rounded-lg border bg-muted/20 p-3"
|
||||
aria-label={t("system:systemMetrics")}
|
||||
>
|
||||
<h3 className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{t("system:systemMetrics")}
|
||||
</h3>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<h3 className="text-[10px] font-semibold uppercase tracking-[0.12em]">
|
||||
{t("system:systemMetrics")}
|
||||
</h3>
|
||||
{host && !simplified ? (
|
||||
<SourceBadge
|
||||
source={host}
|
||||
updatedAt={snapshot.timestamp}
|
||||
showLabel
|
||||
iconSize={iconSize}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{!host ? (
|
||||
<EmptyMetrics drawer iconSize={iconSize} />
|
||||
) : (
|
||||
<DrawerSourceMetrics
|
||||
<MetricValues
|
||||
source={host}
|
||||
updatedAt={snapshot?.timestamp}
|
||||
updatedAt={snapshot.timestamp}
|
||||
layout="grid"
|
||||
simplified={simplified}
|
||||
iconSize={iconSize}
|
||||
/>
|
||||
@@ -144,35 +156,6 @@ function BarSourceMetrics({
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerSourceMetrics({
|
||||
source,
|
||||
updatedAt,
|
||||
simplified,
|
||||
iconSize,
|
||||
}: {
|
||||
source: SystemMetricsSource;
|
||||
updatedAt?: string;
|
||||
simplified: boolean;
|
||||
iconSize: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-11 w-full min-w-0 flex-col items-stretch gap-1 px-0 text-sm">
|
||||
{!simplified ? (
|
||||
<div className="flex min-h-11 w-full min-w-0 items-center">
|
||||
<SourceBadge source={source} updatedAt={updatedAt} showLabel iconSize={iconSize} />
|
||||
</div>
|
||||
) : null}
|
||||
<MetricValues
|
||||
source={source}
|
||||
updatedAt={updatedAt}
|
||||
layout="grid"
|
||||
simplified={simplified}
|
||||
iconSize={iconSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({
|
||||
source,
|
||||
updatedAt,
|
||||
@@ -239,12 +222,7 @@ function MetricValues({
|
||||
/>
|
||||
));
|
||||
if (layout === "grid") {
|
||||
return (
|
||||
// Keep detailed cells compact enough for two columns in the Pixel 5 drawer.
|
||||
<div className="grid w-full min-w-0 grid-cols-[repeat(auto-fit,minmax(8rem,1fr))] gap-x-3 gap-y-1">
|
||||
{values}
|
||||
</div>
|
||||
);
|
||||
return <div className="grid w-full min-w-0 grid-cols-3 gap-x-3 gap-y-4">{values}</div>;
|
||||
}
|
||||
return <span className="flex min-w-0 items-center gap-3 overflow-hidden">{values}</span>;
|
||||
}
|
||||
@@ -267,16 +245,34 @@ function MetricValue({
|
||||
const { t } = useTranslation();
|
||||
const locale = useDateLocale();
|
||||
const help = metric.id === "io_load" ? t("system:ioLoadHelp") : null;
|
||||
const detailedTile = layout === "grid" && !simplified;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={`${layout === "grid" ? "flex min-h-11 min-w-0 w-full" : "inline-flex shrink-0"} items-center gap-1.5 tabular-nums ${metricColor(metric)}`}
|
||||
className={cn(
|
||||
"items-center gap-1.5 tabular-nums",
|
||||
layout === "grid" ? "flex min-h-11 w-full min-w-0" : "inline-flex shrink-0",
|
||||
detailedTile && "flex-col items-stretch gap-1",
|
||||
metricColor(metric),
|
||||
)}
|
||||
aria-label={`${metricLabel(t, metric.id)} ${formatMetric(metric)}`}
|
||||
>
|
||||
{metricIcon(metric.id, iconSize)}
|
||||
{!simplified ? <MetricMeter metric={metric} /> : null}
|
||||
<span className="font-medium tracking-[-0.015em] [font-family:var(--font-geist-mono)]">
|
||||
{detailedTile ? (
|
||||
<span className="flex min-h-8 items-center gap-1 text-xs text-muted-foreground">
|
||||
{metricIcon(metric.id, iconSize)}
|
||||
<span className="min-w-0 break-words">{metricLabel(t, metric.id)}</span>
|
||||
</span>
|
||||
) : (
|
||||
metricIcon(metric.id, iconSize)
|
||||
)}
|
||||
{!simplified ? <MetricMeter metric={metric} wide={detailedTile} /> : null}
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium tracking-[-0.015em] [font-family:var(--font-geist-mono)]",
|
||||
detailedTile && "min-w-0 break-words text-lg",
|
||||
)}
|
||||
>
|
||||
{formatMetric(metric)}
|
||||
</span>
|
||||
</span>
|
||||
@@ -324,16 +320,19 @@ function metricIcon(id: string, iconSize: string) {
|
||||
cpu_temp: IconFlame,
|
||||
io_load: IconGauge,
|
||||
}[id] ?? IconActivity;
|
||||
return <Icon className={`${iconSize} opacity-80`} stroke={1.6} />;
|
||||
return <Icon className={`${iconSize} shrink-0 opacity-80`} stroke={1.6} />;
|
||||
}
|
||||
|
||||
function MetricMeter({ metric }: { metric: SystemMetricSample }) {
|
||||
function MetricMeter({ metric, wide = false }: { metric: SystemMetricSample; wide?: boolean }) {
|
||||
if (metric.unit !== "%" || typeof metric.value !== "number") return null;
|
||||
const width = `${Math.max(0, Math.min(100, metric.value))}%`;
|
||||
return (
|
||||
<span
|
||||
data-testid="system-metric-meter"
|
||||
className="h-1 w-7 overflow-hidden rounded-full bg-muted-foreground/20"
|
||||
className={cn(
|
||||
"h-1 shrink-0 overflow-hidden rounded-full bg-muted-foreground/20",
|
||||
wide ? "order-last mt-1 w-full" : "w-7",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="block h-full rounded-full bg-current opacity-65" style={{ width }} />
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { waitForFiniteAnimations } from "../../helpers/animations";
|
||||
import {
|
||||
assertNoDocumentHorizontalOverflow,
|
||||
assertNoElementHorizontalOverflow,
|
||||
requireBox,
|
||||
} from "../../helpers/layout-assertions";
|
||||
import {
|
||||
captureAppStatusBarSettings,
|
||||
restoreAppStatusBarSettings,
|
||||
@@ -29,7 +35,8 @@ test.describe("Mobile resource metrics display", () => {
|
||||
let statusBarBaseline: AppStatusBarSettingsBaseline;
|
||||
let globalMetricsBaseline: SystemMetricsGlobalSettings;
|
||||
|
||||
test.beforeEach(async ({ apiClient }) => {
|
||||
test.beforeEach(async ({ apiClient, testPage }) => {
|
||||
void testPage;
|
||||
const settings = await apiClient.getUserSettings();
|
||||
baseline = settings.settings.system_metrics_display as SystemMetricsDisplay;
|
||||
statusBarBaseline = await captureAppStatusBarSettings(apiClient);
|
||||
@@ -58,7 +65,7 @@ test.describe("Mobile resource metrics display", () => {
|
||||
await restoreAppStatusBarSettings(apiClient, statusBarBaseline);
|
||||
});
|
||||
|
||||
test("stacks all enabled metrics inside the Status drawer", async ({ testPage, apiClient }) => {
|
||||
test("aligns all enabled metrics inside the Status drawer", async ({ testPage, apiClient }) => {
|
||||
const globalMetricsUpdate = await apiClient.rawRequest(
|
||||
"PATCH",
|
||||
"/api/v1/system/metrics/settings",
|
||||
@@ -71,12 +78,13 @@ test.describe("Mobile resource metrics display", () => {
|
||||
expect(displayUpdate.ok).toBe(true);
|
||||
|
||||
await testPage.goto("/");
|
||||
await testPage.getByTestId("app-nav-trigger").click();
|
||||
await testPage.getByTestId("mobile-home-status-button").click();
|
||||
await testPage.getByTestId("app-nav-trigger").tap();
|
||||
await testPage.getByTestId("mobile-home-status-button").tap();
|
||||
|
||||
const drawer = testPage.getByTestId("app-status-drawer");
|
||||
const metrics = drawer.getByTestId("app-status-metrics");
|
||||
await expect(metrics.getByLabel(/^CPU (?!temperature)/)).toBeVisible();
|
||||
await waitForFiniteAnimations(testPage.getByRole("dialog", { name: "Status", exact: true }));
|
||||
const geometry = await metrics.evaluate((element) => {
|
||||
const indicators = Array.from(
|
||||
element.querySelectorAll<HTMLElement>('span[aria-label]:not([aria-label="Host metrics"])'),
|
||||
@@ -105,17 +113,88 @@ test.describe("Mobile resource metrics display", () => {
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.rowCount).toBe(3);
|
||||
expect(geometry.firstRowCount).toBe(2);
|
||||
expect(geometry.rowCount).toBe(2);
|
||||
expect(geometry.firstRowCount).toBe(3);
|
||||
expect(geometry.count).toBe(5);
|
||||
expect(geometry.contained).toBe(true);
|
||||
expect(geometry.valuesScrollWidth).toBeLessThanOrEqual(geometry.valuesClientWidth + 1);
|
||||
for (const label of ["CPU", "Memory", "Disk", "CPU temperature", "System load (1 min)"]) {
|
||||
const reading = metrics.getByText(label, { exact: true });
|
||||
await reading.scrollIntoViewIfNeeded();
|
||||
await expect(reading).toBeInViewport();
|
||||
}
|
||||
expect(await drawer.locator("[class*='overflow-y-auto']").count()).toBe(1);
|
||||
expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe(
|
||||
await testPage.evaluate(() => document.documentElement.clientWidth),
|
||||
);
|
||||
});
|
||||
|
||||
test("groups the default readings in one compact card in the phone menu", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
prCapture,
|
||||
}) => {
|
||||
await setAppStatusBarEnabled(apiClient, false);
|
||||
const globalUpdate = await apiClient.rawRequest("PATCH", "/api/v1/system/metrics/settings", {
|
||||
...globalMetricsBaseline,
|
||||
metrics: ALL_HOST_METRICS.slice(0, 3),
|
||||
});
|
||||
expect(globalUpdate.ok).toBe(true);
|
||||
const displayUpdate = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
||||
system_metrics_display: { show_in_topbar: true, simplified: false },
|
||||
});
|
||||
expect(displayUpdate.ok).toBe(true);
|
||||
await testPage.emulateMedia({ colorScheme: "dark" });
|
||||
await testPage.goto("/");
|
||||
|
||||
for (const width of [393, 320, 767]) {
|
||||
await testPage.setViewportSize({ width, height: 851 });
|
||||
const trigger = testPage.getByTestId("app-nav-trigger");
|
||||
await trigger.tap();
|
||||
const menu = testPage.getByTestId("app-nav-sheet");
|
||||
const metrics = menu.getByTestId("app-status-metrics");
|
||||
await expect(metrics.getByLabel(/^CPU /)).toBeVisible();
|
||||
await metrics.scrollIntoViewIfNeeded();
|
||||
await waitForFiniteAnimations(menu);
|
||||
const heading = await requireBox(metrics.getByRole("heading"), "metrics heading");
|
||||
const host = await requireBox(metrics.getByLabel("Host metrics"), "host badge");
|
||||
expect(
|
||||
Math.abs(heading.y + heading.height / 2 - host.y - host.height / 2),
|
||||
).toBeLessThanOrEqual(1);
|
||||
const boxes = [];
|
||||
for (const label of ["CPU", "Memory", "Disk"]) {
|
||||
await expect(metrics.getByText(label, { exact: true })).toBeVisible();
|
||||
const reading = metrics.getByLabel(new RegExp(`^${label} `));
|
||||
boxes.push(await requireBox(reading, label));
|
||||
await assertNoElementHorizontalOverflow(reading, label);
|
||||
}
|
||||
for (const box of boxes) {
|
||||
expect(box.y).toBeCloseTo(boxes[0].y, 0);
|
||||
expect(box.width).toBeCloseTo(boxes[0].width, 0);
|
||||
expect(box.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
await assertNoElementHorizontalOverflow(metrics);
|
||||
await assertNoDocumentHorizontalOverflow(testPage);
|
||||
await expect(menu.locator("nav.overflow-y-auto")).toHaveCount(1);
|
||||
if (width === 393) {
|
||||
if (prCapture.capturing) await expect(testPage.getByTestId("toast-message")).toHaveCount(0);
|
||||
await prCapture.screenshot("system-metrics-menu-mobile", {
|
||||
caption: "CPU, memory, and disk grouped in the phone menu's built-in System metrics card",
|
||||
});
|
||||
}
|
||||
await testPage.keyboard.press("Escape");
|
||||
await expect(menu).toBeHidden();
|
||||
await expect(trigger).toBeFocused();
|
||||
}
|
||||
await testPage.setViewportSize({ width: 768, height: 851 });
|
||||
await expect(testPage.getByTestId("app-nav-trigger")).toBeHidden();
|
||||
const metrics = testPage.getByTestId("app-status-metrics");
|
||||
await expect(metrics.getByLabel(/^CPU /)).toBeVisible();
|
||||
await expect(metrics).toHaveCSS("display", "flex");
|
||||
await expect(metrics.getByRole("heading")).toHaveCount(0);
|
||||
await assertNoDocumentHorizontalOverflow(testPage);
|
||||
});
|
||||
|
||||
test("renders simplified metrics in the Status drawer", async ({ testPage }) => {
|
||||
await testPage.goto("/settings");
|
||||
await testPage.getByTestId("settings-index").getByRole("link", { name: "Appearance" }).click();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { assertNoDocumentHorizontalOverflow, requireBox } from "../../helpers/layout-assertions";
|
||||
import {
|
||||
captureAppStatusBarSettings,
|
||||
restoreAppStatusBarSettings,
|
||||
@@ -19,7 +20,8 @@ test.describe("Resource metrics display", () => {
|
||||
let baseline: SystemMetricsDisplay;
|
||||
let statusBarBaseline: AppStatusBarSettingsBaseline;
|
||||
|
||||
test.beforeEach(async ({ apiClient }) => {
|
||||
test.beforeEach(async ({ apiClient, testPage }) => {
|
||||
void testPage;
|
||||
const settings = await apiClient.getUserSettings();
|
||||
baseline = settings.settings.system_metrics_display as SystemMetricsDisplay;
|
||||
statusBarBaseline = await captureAppStatusBarSettings(apiClient);
|
||||
@@ -33,6 +35,31 @@ test.describe("Resource metrics display", () => {
|
||||
await restoreAppStatusBarSettings(apiClient, statusBarBaseline);
|
||||
});
|
||||
|
||||
test("keeps detailed desktop metrics in a compact inline status bar", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
prCapture,
|
||||
}) => {
|
||||
const update = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
||||
system_metrics_display: { show_in_topbar: true, simplified: false },
|
||||
});
|
||||
expect(update.ok).toBe(true);
|
||||
await testPage.emulateMedia({ colorScheme: "dark" });
|
||||
await testPage.goto("/");
|
||||
const metrics = testPage.getByTestId("app-status-metrics");
|
||||
await expect(metrics.getByLabel(/^CPU /)).toBeVisible();
|
||||
await expect(metrics.getByRole("heading")).toHaveCount(0);
|
||||
const host = await requireBox(metrics.getByLabel("Host metrics"), "host badge");
|
||||
const cpu = await requireBox(metrics.getByLabel(/^CPU /), "CPU reading");
|
||||
expect(Math.abs(host.y + host.height / 2 - cpu.y - cpu.height / 2)).toBeLessThanOrEqual(1);
|
||||
expect((await requireBox(metrics, "desktop metrics")).height).toBeLessThanOrEqual(24);
|
||||
await assertNoDocumentHorizontalOverflow(testPage);
|
||||
if (prCapture.capturing) await expect(testPage.getByTestId("toast-message")).toHaveCount(0);
|
||||
await prCapture.screenshot("system-metrics-desktop", {
|
||||
caption: "Desktop retains the compact inline host metrics in the status bar",
|
||||
});
|
||||
});
|
||||
|
||||
test("renders simplified metrics in the status bar", async ({ testPage }) => {
|
||||
await testPage.goto("/settings/preferences/appearance");
|
||||
const showMetrics = testPage.getByRole("switch", { name: "Show host metrics in status bar" });
|
||||
|
||||
Reference in New Issue
Block a user