First slice of the capacity simplification. Operator: *"just have two
capacity — overall per project agent count and max worktrees. Remove all
other capacities and counts."* Plus two later additions: **merge is
always 1, fixed**, and **worktrees off ⇒ limit by total agents only**.
Three independently revertable commits. No limiter is added anywhere;
one is deleted, one is made structurally absent, and one is pinned.
---
## 1. Merge concurrency ratcheted at 1 (test-only)
I was asked to add a limiter if merge concurrency could be raised. **It
cannot** — there is no setting, workflow property, pool or trait config
anywhere that raises it, so this adds no code and pins what already
holds.
Serialization lives in the **pump**: `drainMergeQueue`’s `mergeRunning`
re-entrancy latch, `activeMergeTaskId` as a single-slot identity, the
`mergeBodyInFlight` next-generation latch, and one `ProjectEngine` per
projectId.
**Not** in the merge-queue lease, which is a per-task ROW (`primaryKey
[projectId, taskId]`) — two tasks can hold leases simultaneously by
construction, and it has exactly one caller (the worktree-reuse
handoff). Ordinary merges never take it. A lease-level test would have
been describing an invariant that layer has never held.
The second half guards the other direction: a merge-concurrency
*setting* would not fail the pump ratchet — it would sit unread until
someone wired it up.
**Revert-proof:** deleting the latch → `expected 1 times, but got 2
times`; deleting the `finally` → latch-stuck; injecting
`maxConcurrentMerges: 2` → fails naming the key; injecting a
`maxParallelLanes` merge-trait field → fails naming the field. Sources
restored byte-identical after each injection.
## 2. `worktreesEnabled` — off means the worktree limit cannot bind
No worktrees-off mode existed (no
`worktreesEnabled`/`useWorktrees`/`worktreeMode` anywhere — only
worktree *configuration*).
**Why not `maxWorktrees: 0`, which needs no new key:** it deadlocks. `??
4` keeps `0` (not nullish), the gate is `used >= limit`, so `0 >= 0`
holds **on an empty board** and nothing ever dispatches — while the
operator-visible reason reads `gate=maxWorktrees; used=0/0`, a limiter
that looks like it is working while the board is dead. It also needs the
Command Center `{min:1}` clamp relaxed. So `0` costs the gate rewrite
*and* the clamp change *and* encodes a mode as a magic value.
**Off is absence, not a big number.** `resolveWorktreeCapacityLimit`
returns `number | null`; `ConcurrencyGateDiagnostic.maxWorktreesGate` is
now optional, so consulting a worktree limit in OFF mode does not
type-check. A gate holding `Infinity` can start binding again the moment
someone "fixes" a comparison; an absent gate cannot.
That paid for itself immediately: making it nullable surfaced a
**second, independent** worktree gate (`activeWorktrees >= maxWorktrees`
early-return) that a skip-by-convention approach would have missed
silently.
**Scope, deliberately:** this is a statement about *counting*, not
isolation. It does not make concurrent agents safe to share one checkout
and builds nothing toward that — the non-worktree paths that exist today
are fallbacks to the operator’s own tree, one of which caused FN-8600.
**Revert-proof:** a resolver ignoring the flag turns both OFF scheduler
tests red while every ON test stays green — they reuse the *same*
fixture (5 in-progress, limit 4) that pre-existing tests prove blocks,
so the pair moves in opposite directions. Removing `disabled:` reddens
the UI test.
## 3. `maxTriageConcurrent` deleted — it controlled nothing
**Measured: zero enforcement reads.** The only `.maxTriageConcurrent`
reference in the repo was a route echoing it back in `/config`. FN-8453
removed the pool it gated and left the knob shipping in
`DEFAULT_SETTINGS`, the settings type, the section registry, the API
response and six i18n catalogs, doing nothing, for releases.
Historical FNXC comments are **updated, not deleted** — they explain a
real past incident; they now say "planning admission slot" so they stop
implying a live setting. Tombstoned so it cannot return.
`/config` loses a field; safe in-repo since `fetchConfig`’s own return
type never declared it.
---
## Two corrections worth recording
- I earlier reported `maxWorktrees` had **no** Settings UI. Wrong —
`WorktreesSection.tsx:47`; my grep was truncated by `head`. It changed
the placement (toggle beside it, rather than a duplicate key in
Scheduling).
- I planned to assert the queued-reason string is rewritten in OFF mode.
Measured that it is **unreachable**: when `maxConcurrent` binds, the
sweep bails before the per-task reason and logs nothing. The test
asserts absence instead.
Two near-misses caught before commit: a pre-existing FN-7505 guard
caught my *new* key missing a description mapping; and editing i18n via
`json.load/dump` silently dropped unrelated duplicate keys
(`autoUpdateAndRestart` in `fr`) — Python keeps only the last of a
duplicated key. Redone textually, every catalog re-validated.
## Verification
`pnpm lint` clean · core/engine/dashboard/i18n typecheck clean · `pnpm
test:gate` green (309 + 10 + 71) · capacity/worktree suites 11/11 ·
engine merge-invariant + scheduler 45/45 · dashboard settings 114/114.
Rebased onto current main and re-verified.
Nothing was booted at any point.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added a project setting to enable or disable running tasks in
worktrees.
* Disabling worktrees removes worktree capacity limits from task
scheduling.
* The “Max Worktrees” setting is disabled when worktree execution is
turned off.
* **Changes**
* Removed the unused triage concurrency setting from configuration and
dashboard responses.
* Updated scheduling diagnostics and queue messages to reflect disabled
worktree capacity limits.
* Added localized labels and help text for the new setting.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
442 lines
19 KiB
TypeScript
442 lines
19 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import "@testing-library/jest-dom";
|
|
import { CommandCenter } from "../CommandCenter";
|
|
import { readAppFile } from "../../../test/cssFixture";
|
|
|
|
/*
|
|
FNXC:CommandCenterSystemTests 2026-07-23-22:35:
|
|
jsdom elements do not implement scrollIntoView, but SystemControlsArea's
|
|
job-section effect calls jobSectionRef.current?.scrollIntoView(...) inside a
|
|
requestAnimationFrame while a rebuild job is running. If that frame fires
|
|
before unmount cleanup, the missing method throws as an unhandled error and
|
|
fails the whole run even though every assertion passed. Stub it module-wide
|
|
(same convention as ChatView tests); the per-test spy below still swaps in
|
|
its own vi.fn() and restores this stub afterwards.
|
|
*/
|
|
Element.prototype.scrollIntoView = vi.fn();
|
|
|
|
const apiMock = vi.fn();
|
|
const mockFetchSystemInfo = vi.fn();
|
|
const mockFetchCurrentSystemRebuild = vi.fn();
|
|
const mockFetchSystemStats = vi.fn();
|
|
const mockFetchSystemLogs = vi.fn();
|
|
const mockFetchNodeSystemStats = vi.fn();
|
|
const mockFetchGlobalSettings = vi.fn();
|
|
const mockFetchNodes = vi.fn();
|
|
const subscribeSseMock = vi.fn(() => () => undefined);
|
|
|
|
vi.mock("../../../api/legacy", () => ({
|
|
fetchCodebaseMetrics: vi.fn().mockResolvedValue({ tokenEstimate: 0, sourceFileCount: 0, sourceByteCount: 0, diskBytes: 0, diskFileCount: 0, method: "local", truncated: false }),
|
|
api: (path: string, opts?: RequestInit) => apiMock(path, opts),
|
|
withProjectId: (path: string, projectId?: string) =>
|
|
projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path,
|
|
fetchOrgTree: vi.fn().mockResolvedValue([]),
|
|
fetchExecutorStats: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2 }),
|
|
fetchSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 5 }),
|
|
fetchConfig: vi.fn().mockResolvedValue({ maxConcurrent: 2, rootDir: "/" }),
|
|
updateSettings: vi.fn().mockResolvedValue({}),
|
|
createBackup: vi.fn().mockResolvedValue({ ok: true }),
|
|
fetchDashboardHealth: vi.fn().mockResolvedValue({ ok: true }),
|
|
fetchCurrentSystemRebuild: (...args: unknown[]) => mockFetchCurrentSystemRebuild(...args),
|
|
fetchSystemInfo: (...args: unknown[]) => mockFetchSystemInfo(...args),
|
|
fetchSystemLogs: (...args: unknown[]) => mockFetchSystemLogs(...args),
|
|
reloadAllSystemPlugins: vi.fn().mockResolvedValue({ ok: true }),
|
|
requestSystemRestart: vi.fn().mockResolvedValue({ ok: true }),
|
|
restartAllSystemAgents: vi.fn().mockResolvedValue({ ok: true }),
|
|
restartSystemEngines: vi.fn().mockResolvedValue({ ok: true }),
|
|
startSystemRebuild: vi.fn().mockResolvedValue({ id: "job-1", status: "running", kind: "rebuild", scope: "app", lines: [] }),
|
|
startFnBinaryLinkLocal: vi.fn().mockResolvedValue({
|
|
id: "job-fn-local",
|
|
status: "running",
|
|
kind: "fn-binary",
|
|
scope: "link-local",
|
|
lines: [],
|
|
}),
|
|
startFnBinaryUseGlobal: vi.fn().mockResolvedValue({
|
|
id: "job-fn-global",
|
|
status: "running",
|
|
kind: "fn-binary",
|
|
scope: "use-global",
|
|
lines: [],
|
|
}),
|
|
refreshUpdateCheck: vi.fn().mockResolvedValue({
|
|
currentVersion: "0.60.0",
|
|
latestVersion: "0.60.0",
|
|
updateAvailable: false,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../../../api", () => ({
|
|
fetchSystemStats: (...args: unknown[]) => mockFetchSystemStats(...args),
|
|
fetchNodeSystemStats: (...args: unknown[]) => mockFetchNodeSystemStats(...args),
|
|
fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args),
|
|
fetchNodes: (...args: unknown[]) => mockFetchNodes(...args),
|
|
killVitestProcesses: vi.fn().mockResolvedValue({ killed: 0, pids: [] }),
|
|
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
|
}));
|
|
|
|
vi.mock("../../../sse-bus", () => ({
|
|
subscribeSse: (...args: unknown[]) => subscribeSseMock(...args),
|
|
}));
|
|
|
|
vi.mock("../../../hooks/useAppSettings", () => ({
|
|
useAppSettings: () => ({
|
|
globalPaused: false,
|
|
enginePaused: false,
|
|
toggleGlobalPause: vi.fn(),
|
|
toggleEnginePause: vi.fn(),
|
|
refresh: vi.fn().mockResolvedValue(undefined),
|
|
}),
|
|
}));
|
|
|
|
function emptyOverviewResponse(path: string) {
|
|
if (path.includes("/command-center/tokens")) {
|
|
return { totals: { totalTokens: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, nTasks: 0 }, cost: { usd: null, unavailable: true, stale: false }, groups: [] };
|
|
}
|
|
if (path.includes("/command-center/tools")) return { totals: { calls: 0, errors: 0 }, groups: [] };
|
|
if (path.includes("/command-center/activity")) return { totals: { agentRuns: 0, tasksDone: 0 }, daily: [] };
|
|
if (path.includes("/command-center/signals")) return { totals: { open: 0, closed: 0 }, groups: [] };
|
|
if (path.includes("/command-center/live")) return { activeTasks: 0, activeAgents: 0, openSignals: 0, tokensPerMinute: 0, tasksByColumn: [] };
|
|
return {};
|
|
}
|
|
|
|
function systemInfoFixture(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
pid: 12345,
|
|
nodeVersion: "v22.0.0",
|
|
platform: "darwin",
|
|
arch: "arm64",
|
|
sourceCheckout: true,
|
|
supervised: true,
|
|
restartSupported: true,
|
|
rebuildSupported: true,
|
|
fnBinaryLinkLocalSupported: true,
|
|
fnBinaryUseGlobalSupported: true,
|
|
engineAvailable: true,
|
|
engineRestartSupported: true,
|
|
agentRestartSupported: true,
|
|
pluginReloadSupported: true,
|
|
logsSupported: true,
|
|
activeRebuild: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function systemStatsFixture() {
|
|
return {
|
|
systemStats: {
|
|
rss: 1024,
|
|
heapUsed: 512,
|
|
heapTotal: 1024,
|
|
heapLimit: 2048,
|
|
external: 0,
|
|
arrayBuffers: 0,
|
|
cpuPercent: 10,
|
|
loadAvg: [0.1, 0.2, 0.3] as [number, number, number],
|
|
cpuCount: 8,
|
|
systemTotalMem: 4096,
|
|
systemFreeMem: 2048,
|
|
pid: 12345,
|
|
nodeVersion: "v22.0.0",
|
|
platform: "darwin/arm64",
|
|
},
|
|
taskStats: {
|
|
total: 0,
|
|
byColumn: {},
|
|
active: 0,
|
|
agents: { idle: 0, active: 0, running: 0, error: 0 },
|
|
},
|
|
vitestProcessCount: 0,
|
|
vitestLastAutoKillAt: null,
|
|
};
|
|
}
|
|
|
|
describe("SystemControlsArea layout integration", () => {
|
|
const originalClipboard = navigator.clipboard;
|
|
const originalExecCommand = document.execCommand;
|
|
|
|
function mockClipboard(value: unknown) {
|
|
Object.defineProperty(navigator, "clipboard", {
|
|
configurable: true,
|
|
value,
|
|
});
|
|
}
|
|
|
|
function mockExecCommand(result: boolean) {
|
|
const execCommand = vi.fn().mockReturnValue(result);
|
|
Object.defineProperty(document, "execCommand", {
|
|
configurable: true,
|
|
value: execCommand,
|
|
});
|
|
return execCommand;
|
|
}
|
|
|
|
async function renderSystemTab(addToast = vi.fn()) {
|
|
render(<CommandCenter projectId="proj-1" addToast={addToast} />);
|
|
fireEvent.click(screen.getByTestId("command-center-tab-system"));
|
|
const diagnosticsCard = await screen.findByTestId("cc-syscontrol-diagnostics");
|
|
return { addToast, diagnosticsCard };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
subscribeSseMock.mockImplementation(() => () => undefined);
|
|
apiMock.mockImplementation((path: string) => Promise.resolve(emptyOverviewResponse(path)));
|
|
mockFetchSystemInfo.mockResolvedValue(systemInfoFixture());
|
|
mockFetchSystemLogs.mockResolvedValue({
|
|
entries: [{ timestamp: "2026-07-12T00:00:00.000Z", level: "info", message: "ready" }],
|
|
});
|
|
mockFetchCurrentSystemRebuild.mockResolvedValue({ job: null });
|
|
mockFetchSystemStats.mockResolvedValue(systemStatsFixture());
|
|
mockFetchNodeSystemStats.mockResolvedValue(systemStatsFixture());
|
|
mockFetchGlobalSettings.mockResolvedValue({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 });
|
|
mockFetchNodes.mockResolvedValue([]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(navigator, "clipboard", {
|
|
configurable: true,
|
|
value: originalClipboard,
|
|
});
|
|
Object.defineProperty(document, "execCommand", {
|
|
configurable: true,
|
|
value: originalExecCommand,
|
|
});
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("wraps System controls, Server logs, and Live system health in the shared gap owner", async () => {
|
|
render(<CommandCenter projectId="proj-1" />);
|
|
|
|
fireEvent.click(screen.getByTestId("command-center-tab-system"));
|
|
|
|
const systemTab = await screen.findByTestId("cc-system-tab");
|
|
const controls = await screen.findByTestId("cc-system-controls");
|
|
const logs = await screen.findByTestId("cc-system-logs");
|
|
const stats = await screen.findByTestId("cc-area-system");
|
|
|
|
expect(systemTab).toHaveClass("cc-system-tab");
|
|
expect(systemTab).toContainElement(controls);
|
|
expect(systemTab).toContainElement(logs);
|
|
expect(systemTab).toContainElement(stats);
|
|
expect(controls.parentElement).toBe(systemTab);
|
|
expect(logs.parentElement).toBe(systemTab);
|
|
expect(stats.parentElement).toBe(systemTab);
|
|
expect(screen.getByTestId("cc-system-logs-toggle")).toBeInTheDocument();
|
|
await waitFor(() => expect(mockFetchSystemInfo).toHaveBeenCalled());
|
|
});
|
|
|
|
it("keeps the System controls refresh button in the scoped inline header", async () => {
|
|
render(<CommandCenter projectId="proj-1" />);
|
|
|
|
fireEvent.click(screen.getByTestId("command-center-tab-system"));
|
|
|
|
const controls = await screen.findByTestId("cc-system-controls");
|
|
const header = controls.querySelector<HTMLElement>(".cc-system-controls-header");
|
|
const title = screen.getByRole("heading", { name: "System controls" });
|
|
const refresh = screen.getByTestId("cc-system-refresh");
|
|
|
|
expect(header).toBeInTheDocument();
|
|
expect(header).toHaveClass("cc-area-section-header", "cc-system-controls-header");
|
|
expect(title.parentElement).toBe(header);
|
|
expect(refresh.parentElement).toBe(header);
|
|
});
|
|
|
|
it("copies diagnostics through the execCommand fallback when Clipboard API is unavailable", async () => {
|
|
mockClipboard(undefined);
|
|
const execCommand = mockExecCommand(true);
|
|
const { addToast, diagnosticsCard } = await renderSystemTab();
|
|
|
|
fireEvent.click(within(diagnosticsCard).getByRole("button", { name: "Copy" }));
|
|
|
|
await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
|
|
expect(addToast).toHaveBeenCalledWith("Diagnostics copied to clipboard", "success");
|
|
expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("writeText"), "error");
|
|
});
|
|
|
|
it("copies diagnostics through navigator.clipboard.writeText in secure contexts", async () => {
|
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
mockClipboard({ writeText });
|
|
const { addToast, diagnosticsCard } = await renderSystemTab();
|
|
|
|
fireEvent.click(within(diagnosticsCard).getByRole("button", { name: "Copy" }));
|
|
|
|
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
|
|
expect(writeText.mock.calls[0]?.[0]).toContain('"recentLogs"');
|
|
expect(addToast).toHaveBeenCalledWith("Diagnostics copied to clipboard", "success");
|
|
expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("writeText"), "error");
|
|
});
|
|
|
|
it("shows a failure toast when diagnostics cannot be copied by either clipboard path", async () => {
|
|
mockFetchSystemInfo.mockResolvedValue({ ...systemInfoFixture(), logsSupported: false });
|
|
mockClipboard(undefined);
|
|
const execCommand = mockExecCommand(false);
|
|
const { addToast, diagnosticsCard } = await renderSystemTab();
|
|
|
|
fireEvent.click(within(diagnosticsCard).getByRole("button", { name: "Copy" }));
|
|
|
|
await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
|
|
expect(addToast).toHaveBeenCalledWith("Could not copy diagnostics to clipboard", "error");
|
|
expect(addToast).not.toHaveBeenCalledWith("Diagnostics copied to clipboard", "success");
|
|
expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("writeText"), "error");
|
|
});
|
|
|
|
it("opens the guided report modal instead of a legacy GitHub window", async () => {
|
|
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
|
await renderSystemTab();
|
|
|
|
const reportCard = await screen.findByTestId("cc-syscontrol-report-bug");
|
|
fireEvent.click(within(reportCard).getByRole("button", { name: "Report" }));
|
|
fireEvent.click(await screen.findByRole("menuitem", { name: "Report bug" }));
|
|
|
|
expect(await screen.findByRole("dialog", { name: "bug report" })).toBeTruthy();
|
|
expect(openSpy).not.toHaveBeenCalled();
|
|
openSpy.mockRestore();
|
|
});
|
|
|
|
it("keeps the System controls header row override active on mobile", () => {
|
|
const css = readAppFile("components/command-center/areas/SystemControlsArea.css");
|
|
|
|
expect(css).toMatch(/\.cc-area-section-header\.cc-system-controls-header\s*{[^}]*flex-direction:\s*row;[^}]*justify-content:\s*space-between;/s);
|
|
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)\s*{[\s\S]*\.cc-area-section-header\.cc-system-controls-header\s*{[^}]*flex-direction:\s*row;[^}]*justify-content:\s*space-between;/);
|
|
});
|
|
|
|
it("keeps the System tab gap and mobile scroll-owner CSS contracts tokenized", () => {
|
|
const css = readAppFile("components/command-center/CommandCenter.css");
|
|
|
|
expect(css).toMatch(/\.cc-system-tab\s*{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*gap:\s*var\(--space-lg\);/s);
|
|
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)\s*{[\s\S]*\.cc-tabpanel\s*{[^}]*padding-bottom:\s*calc\(var\(--space-lg\) \+ env\(safe-area-inset-bottom, 0\) \+ var\(--standalone-bottom-gap\)\);/);
|
|
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)\s*{[\s\S]*\.cc-system-tab\s*{[^}]*gap:\s*var\(--space-lg\);/);
|
|
expect(css).not.toMatch(/\.cc-system-tab\s*{[^}]*overflow-y:\s*auto;/s);
|
|
});
|
|
|
|
/*
|
|
FNXC:SystemPanelFnBinary 2026-07-15-09:54:
|
|
Source/dev hosts expose build-and-link-local; packaged hosts hide it. Use-global
|
|
and check-for-updates stay available, and starting a build job surfaces the
|
|
shared log viewer.
|
|
*/
|
|
it("shows fn binary and update controls for a source checkout and scrolls job output into view", async () => {
|
|
const scrollIntoView = vi.fn();
|
|
const original = Element.prototype.scrollIntoView;
|
|
Element.prototype.scrollIntoView = scrollIntoView;
|
|
|
|
render(<CommandCenter projectId="proj-1" />);
|
|
fireEvent.click(screen.getByTestId("command-center-tab-system"));
|
|
|
|
const linkLocal = await screen.findByTestId("cc-syscontrol-fn-link-local");
|
|
const useGlobal = screen.getByTestId("cc-syscontrol-fn-use-global");
|
|
const checkUpdates = screen.getByTestId("cc-syscontrol-check-updates");
|
|
expect(linkLocal).toBeInTheDocument();
|
|
expect(useGlobal).toBeInTheDocument();
|
|
expect(checkUpdates).toBeInTheDocument();
|
|
|
|
fireEvent.click(within(linkLocal).getByRole("button", { name: "Build & link" }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("cc-system-rebuild-output")).toBeInTheDocument();
|
|
});
|
|
await waitFor(() => {
|
|
expect(scrollIntoView).toHaveBeenCalled();
|
|
});
|
|
|
|
Element.prototype.scrollIntoView = original;
|
|
});
|
|
|
|
function installScrollGeometry(element: HTMLElement, scrollHeight = 500, clientHeight = 100) {
|
|
let scrollTop = 0;
|
|
Object.defineProperties(element, {
|
|
scrollHeight: { configurable: true, get: () => scrollHeight },
|
|
clientHeight: { configurable: true, get: () => clientHeight },
|
|
scrollTop: {
|
|
configurable: true,
|
|
get: () => scrollTop,
|
|
set: (value: number) => {
|
|
scrollTop = value;
|
|
},
|
|
},
|
|
});
|
|
return {
|
|
get scrollTop() { return scrollTop; },
|
|
set scrollTop(value: number) { scrollTop = value; },
|
|
setScrollHeight(value: number) { scrollHeight = value; },
|
|
};
|
|
}
|
|
|
|
function getStreamEvents(path: string) {
|
|
const call = [...subscribeSseMock.mock.calls].reverse().find(([url]) => url === path);
|
|
expect(call).toBeDefined();
|
|
return (call?.[1] as { events: Record<string, (event: MessageEvent) => void> }).events;
|
|
}
|
|
|
|
it("keeps manually scrolled rebuild output in place while SSE lines grow", async () => {
|
|
render(<CommandCenter projectId="proj-1" />);
|
|
fireEvent.click(screen.getByTestId("command-center-tab-system"));
|
|
const rebuild = await screen.findByTestId("cc-syscontrol-rebuild-app");
|
|
fireEvent.click(within(rebuild).getByRole("button", { name: "Rebuild" }));
|
|
const output = (await screen.findByTestId("cc-system-rebuild-output")).querySelector("pre")!;
|
|
const geometry = installScrollGeometry(output);
|
|
const events = getStreamEvents("/api/system/jobs/job-1/stream");
|
|
|
|
await act(async () => events.line(new MessageEvent("message", { data: JSON.stringify({ i: 1, stream: "stdout", text: "first" }) })));
|
|
geometry.scrollTop = 100;
|
|
fireEvent.scroll(output);
|
|
geometry.setScrollHeight(600);
|
|
await act(async () => events.line(new MessageEvent("message", { data: JSON.stringify({ i: 2, stream: "stdout", text: "second" }) })));
|
|
|
|
expect(geometry.scrollTop).toBe(100);
|
|
|
|
geometry.scrollTop = 500;
|
|
fireEvent.scroll(output);
|
|
geometry.setScrollHeight(700);
|
|
await act(async () => events.line(new MessageEvent("message", { data: JSON.stringify({ i: 3, stream: "stdout", text: "third" }) })));
|
|
expect(geometry.scrollTop).toBe(700);
|
|
});
|
|
|
|
it("keeps manually scrolled live server logs in place while SSE lines grow", async () => {
|
|
render(<CommandCenter projectId="proj-1" />);
|
|
fireEvent.click(screen.getByTestId("command-center-tab-system"));
|
|
await screen.findByTestId("cc-system-controls");
|
|
fireEvent.click(screen.getByTestId("cc-system-logs-toggle"));
|
|
const output = await screen.findByText("No log entries yet.");
|
|
const container = output.parentElement!;
|
|
const geometry = installScrollGeometry(container);
|
|
const events = getStreamEvents("/api/system/logs/stream");
|
|
|
|
await act(async () => events.log(new MessageEvent("message", { data: JSON.stringify({ timestamp: "2026-07-18T00:00:00.000Z", level: "info", message: "first" }) })));
|
|
geometry.scrollTop = 100;
|
|
fireEvent.scroll(container);
|
|
geometry.setScrollHeight(600);
|
|
await act(async () => events.log(new MessageEvent("message", { data: JSON.stringify({ timestamp: "2026-07-18T00:00:01.000Z", level: "info", message: "second" }) })));
|
|
|
|
expect(geometry.scrollTop).toBe(100);
|
|
|
|
geometry.scrollTop = 500;
|
|
fireEvent.scroll(container);
|
|
geometry.setScrollHeight(700);
|
|
await act(async () => events.log(new MessageEvent("message", { data: JSON.stringify({ timestamp: "2026-07-18T00:00:02.000Z", level: "info", message: "third" }) })));
|
|
expect(geometry.scrollTop).toBe(700);
|
|
});
|
|
|
|
it("hides build-and-link-local when the host is not a source checkout", async () => {
|
|
mockFetchSystemInfo.mockResolvedValue(
|
|
systemInfoFixture({
|
|
rebuildSupported: false,
|
|
fnBinaryLinkLocalSupported: false,
|
|
sourceWorkspaceRoot: undefined,
|
|
}),
|
|
);
|
|
|
|
render(<CommandCenter projectId="proj-1" />);
|
|
fireEvent.click(screen.getByTestId("command-center-tab-system"));
|
|
|
|
await screen.findByTestId("cc-system-controls");
|
|
expect(screen.queryByTestId("cc-syscontrol-fn-link-local")).not.toBeInTheDocument();
|
|
expect(screen.getByTestId("cc-syscontrol-fn-use-global")).toBeInTheDocument();
|
|
expect(screen.getByTestId("cc-syscontrol-check-updates")).toBeInTheDocument();
|
|
});
|
|
});
|