FN-8635: keep worktree slider visible
Keep Command Center capacity controls visible and correctly editable across settings states. - Render Max worktrees in the shared full-width range wrapper. - Preserve capacity values after load failures and explain disabled worktree limits. - Add control tests, browser geometry coverage, documentation, and a release changeset. Files changed: .changeset/fn-8635-worktrees-slider.md | 7 + docs/dashboard-guide.md | 2 +- .../command-center/CommandCenterControls.css | 6 + .../command-center/CommandCenterControls.tsx | 63 +++++--- .../__tests__/CommandCenterControls.test.tsx | 76 ++++++++- packages/engine/e2e/fn-8635-worktrees-slider.mjs | 180 +++++++++++++++++++++ 6 files changed, 310 insertions(+), 24 deletions(-) Fusion-Task-Id: FN-8635 Fusion-Task-Lineage: fb9c2a46-3c5c-4871-bab4-c43af20cb4de Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8635-worktrees-slider.md
Normal file
7
.changeset/fn-8635-worktrees-slider.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Restore the visible Max worktrees control in Command Center concurrency settings.
|
||||
category: fix
|
||||
dev: Both per-project capacity sliders now share the range layout invariant and explicit loading/error behavior.
|
||||
@@ -1332,7 +1332,7 @@ Features:
|
||||
<!-- FNXC:CommandCenter 2026-06-27-10:03: Tokens detail charts must show every model bucket returned by analytics for accurate spend attribution; Overview remains a compact top-model summary because its copy explicitly frames those cards as top consumers/share. -->
|
||||
<!-- FNXC:CommandCenterActivity 2026-06-30-00:00: Activity active-agent counts include both durable-agent usage events and ephemeral task-worker execution runs from agentRuns, because task execution can be visible without a matching usage_events row. -->
|
||||
<!-- FNXC:CommandCenterActivity 2026-07-01-00:00: Graph-owned workflow step sessions publish active-to-terminal agentRuns lifecycle rows with task lineage and step metadata, so daily activity and Activity throughput charts include new workflow execution without dashboard-side recounting. -->
|
||||
- **Overview controls dashboard** includes AI engine stop/start backed by `globalPause`, the shared Global Max Concurrent slider, and current-project **Max concurrency** plus **Max worktrees** controls. Max concurrency caps top-level working agents across planning, execution, and review/merge; free capacity is admitted oldest-first within the project. The footer reports **Waiting**, **Running (N/max)**, and **Blocked**; column headers report executing/total (live agents in the lane over card count). Nested helper agents remain parent-internal and may temporarily exceed the displayed top-level count.
|
||||
- **Overview controls dashboard** includes AI engine stop/start backed by `globalPause` and current-project **Max concurrent tasks** plus **Max worktrees** controls. Both capacity sliders remain visible while settings load or fail, but are disabled until settings are editable; a failed load shows its error and an intentionally disabled worktree limit explains how to enable it in Settings. Max concurrency caps top-level working agents across planning, execution, and review/merge; free capacity is admitted oldest-first within the project. The footer reports **Waiting**, **Running (N/max)**, and **Blocked**; column headers report executing/total (live agents in the lane over card count). Nested helper agents remain parent-internal and may temporarily exceed the displayed top-level count.
|
||||
<!-- FNXC:TeamArea 2026-07-18-12:30: FN-8351 moves organization export and import to the Team tab so team-level portability controls are not presented as Overview dashboard controls. -->
|
||||
- **Team tab — Org export / import** lets an operator download a portable organization JSON bundle or paste one for a dry-run preview before confirming the apply step. Exports are secret-scrubbed by default: credentials and tokens are never included, while safe secret references can remain for setup in the destination project.
|
||||
- **Configuration versions** lives in **Settings → Project → Configuration Versions**. It lists recorded project-setting revisions newest first; select **Roll back** on any revision and confirm once to restore it. The restore is recorded as a new forward revision, so it can itself be undone without manually reconstructing settings.
|
||||
|
||||
@@ -118,12 +118,18 @@ cannot desynchronize the range tracks at desktop or tablet widths.
|
||||
/*
|
||||
FNXC:GlobalConcurrencyControls 2026-07-15-17:30:
|
||||
FN-8007 makes the current-use dot's half-thumb edge inset match the native desktop range thumb. This preserves the min-relative thumb alignment used by the marker: one running agent stays visible at the start and over-cap use stays on the cap thumb.
|
||||
|
||||
FNXC:CommandCenter 2026-08-01-00:14:
|
||||
Both capacity controls use this wrapper so their range sizing and native thumb token remain
|
||||
identical. A disabled capacity is still a visible control; only its editability changes.
|
||||
*/
|
||||
.cc-controls-range-wrap {
|
||||
--cc-controls-range-thumb-size: calc(var(--space-lg) + var(--space-xs) / 2);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.cc-controls-range-wrap,
|
||||
|
||||
@@ -32,20 +32,22 @@ type AsyncState<T> =
|
||||
type ConcurrencyValues = {
|
||||
maxConcurrent: number;
|
||||
maxWorktrees: number;
|
||||
worktreeLimitEnabled: boolean;
|
||||
};
|
||||
|
||||
const CONCURRENCY_SAVE_DEBOUNCE_MS = 500;
|
||||
const DEFAULT_CONCURRENCY_VALUES: ConcurrencyValues = {
|
||||
maxConcurrent: DEFAULT_PROJECT_SETTINGS.maxConcurrent,
|
||||
maxWorktrees: DEFAULT_PROJECT_SETTINGS.maxWorktrees,
|
||||
worktreeLimitEnabled: Boolean(DEFAULT_PROJECT_SETTINGS.worktreeLimitEnabled),
|
||||
};
|
||||
|
||||
const CONCURRENCY_SLIDER_LIMITS: Record<keyof ConcurrencyValues, { min: number; max: number }> = {
|
||||
const CONCURRENCY_SLIDER_LIMITS: Record<Exclude<keyof ConcurrencyValues, "worktreeLimitEnabled">, { min: number; max: number }> = {
|
||||
maxConcurrent: { min: 1, max: 50 },
|
||||
maxWorktrees: { min: 1, max: 50 },
|
||||
};
|
||||
|
||||
const CONCURRENCY_SETTING_LABEL_KEYS: Record<keyof ConcurrencyValues, { key: string; defaultValue: string }> = {
|
||||
const CONCURRENCY_SETTING_LABEL_KEYS: Record<Exclude<keyof ConcurrencyValues, "worktreeLimitEnabled">, { key: string; defaultValue: string }> = {
|
||||
maxConcurrent: { key: "commandCenter.controls.concurrency.maxConcurrent", defaultValue: "Max concurrent tasks" },
|
||||
maxWorktrees: { key: "commandCenter.controls.concurrency.maxWorktrees", defaultValue: "Max worktrees" },
|
||||
};
|
||||
@@ -61,7 +63,7 @@ function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getConcurrencySliderMax(key: keyof ConcurrencyValues, value: number) {
|
||||
function getConcurrencySliderMax(key: Exclude<keyof ConcurrencyValues, "worktreeLimitEnabled">, value: number) {
|
||||
return Math.max(CONCURRENCY_SLIDER_LIMITS[key].max, value);
|
||||
}
|
||||
|
||||
@@ -82,7 +84,8 @@ function getUseMarkerStyle(ratio: number): CSSProperties {
|
||||
}
|
||||
|
||||
function getChangedConcurrencyKeys(values: ConcurrencyValues, persisted: ConcurrencyValues) {
|
||||
return (Object.keys(values) as Array<keyof ConcurrencyValues>).filter((key) => values[key] !== persisted[key]);
|
||||
return (Object.keys(CONCURRENCY_SETTING_LABEL_KEYS) as Array<Exclude<keyof ConcurrencyValues, "worktreeLimitEnabled">>)
|
||||
.filter((key) => values[key] !== persisted[key]);
|
||||
}
|
||||
|
||||
function StatusPill({ paused, label }: { paused: boolean; label: string }) {
|
||||
@@ -123,6 +126,7 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
|
||||
const persistedValues = {
|
||||
maxConcurrent: settings.maxConcurrent ?? config.maxConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxConcurrent,
|
||||
maxWorktrees: settings.maxWorktrees ?? DEFAULT_CONCURRENCY_VALUES.maxWorktrees,
|
||||
worktreeLimitEnabled: settings.worktreeLimitEnabled !== false,
|
||||
};
|
||||
persistedConcurrencyRef.current = persistedValues;
|
||||
pendingConcurrencyKeyRef.current = null;
|
||||
@@ -137,7 +141,7 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
|
||||
if (!cancelled) {
|
||||
setConcurrencyState({
|
||||
status: "error",
|
||||
data: DEFAULT_CONCURRENCY_VALUES,
|
||||
data: persistedConcurrencyRef.current,
|
||||
error: error instanceof Error ? error.message : t("commandCenter.controls.concurrency.error", "Unable to load concurrency settings"),
|
||||
});
|
||||
}
|
||||
@@ -219,7 +223,7 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [confirm, concurrencyDirty, concurrencyState.data, projectId, refresh, t]);
|
||||
|
||||
const updateConcurrencyValue = (key: keyof ConcurrencyValues, rawValue: string, min: number, max: number) => {
|
||||
const updateConcurrencyValue = (key: Exclude<keyof ConcurrencyValues, "worktreeLimitEnabled">, rawValue: string, min: number, max: number) => {
|
||||
const nextValue = clamp(Number(rawValue), min, max);
|
||||
pendingConcurrencyKeyRef.current = key;
|
||||
setConcurrencyState((current) => ({
|
||||
@@ -236,6 +240,8 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
|
||||
const globalCountsLoaded = gc.status === "loaded";
|
||||
const projectActive = gc.projectActiveCount(projectId);
|
||||
const maxConcurrentSliderMax = getConcurrencySliderMax("maxConcurrent", concurrencyValues.maxConcurrent);
|
||||
const worktreesEditable = concurrencyState.status === "loaded" && concurrencyValues.worktreeLimitEnabled;
|
||||
const slidersEditable = concurrencyState.status === "loaded";
|
||||
const projectUseMarkerRatio = getUseMarkerRatio(
|
||||
projectActive,
|
||||
concurrencyValues.maxConcurrent,
|
||||
@@ -369,7 +375,7 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
|
||||
min={CONCURRENCY_SLIDER_LIMITS.maxConcurrent.min}
|
||||
max={maxConcurrentSliderMax}
|
||||
value={concurrencyValues.maxConcurrent}
|
||||
disabled={concurrencyState.status === "loading"}
|
||||
disabled={!slidersEditable}
|
||||
onChange={(event) => updateConcurrencyValue(
|
||||
"maxConcurrent",
|
||||
event.target.value,
|
||||
@@ -387,26 +393,39 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
{/*
|
||||
FNXC:CommandCenter 2026-08-01-00:14:
|
||||
The Concurrency card must always show both per-project capacity sliders. When
|
||||
settings are loading, failed, or intentionally disable the worktree limit, keep
|
||||
the native control visible but disabled with an explanation rather than hiding it.
|
||||
*/}
|
||||
<label className="cc-controls-slider" htmlFor="cc-max-worktrees">
|
||||
<span className="cc-controls-slider-label">
|
||||
{t("commandCenter.controls.concurrency.maxWorktrees", "Max worktrees")}
|
||||
<strong>{concurrencyValues.maxWorktrees}</strong>
|
||||
</span>
|
||||
<input
|
||||
id="cc-max-worktrees"
|
||||
className="cc-controls-touch-slider"
|
||||
type="range"
|
||||
min={CONCURRENCY_SLIDER_LIMITS.maxWorktrees.min}
|
||||
max={getConcurrencySliderMax("maxWorktrees", concurrencyValues.maxWorktrees)}
|
||||
value={concurrencyValues.maxWorktrees}
|
||||
disabled={concurrencyState.status === "loading"}
|
||||
onChange={(event) => updateConcurrencyValue(
|
||||
"maxWorktrees",
|
||||
event.target.value,
|
||||
CONCURRENCY_SLIDER_LIMITS.maxWorktrees.min,
|
||||
getConcurrencySliderMax("maxWorktrees", concurrencyValues.maxWorktrees),
|
||||
)}
|
||||
/>
|
||||
<span className="cc-controls-range-wrap">
|
||||
<input
|
||||
id="cc-max-worktrees"
|
||||
className="cc-controls-touch-slider"
|
||||
type="range"
|
||||
min={CONCURRENCY_SLIDER_LIMITS.maxWorktrees.min}
|
||||
max={getConcurrencySliderMax("maxWorktrees", concurrencyValues.maxWorktrees)}
|
||||
value={concurrencyValues.maxWorktrees}
|
||||
disabled={!worktreesEditable}
|
||||
onChange={(event) => updateConcurrencyValue(
|
||||
"maxWorktrees",
|
||||
event.target.value,
|
||||
CONCURRENCY_SLIDER_LIMITS.maxWorktrees.min,
|
||||
getConcurrencySliderMax("maxWorktrees", concurrencyValues.maxWorktrees),
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{!concurrencyValues.worktreeLimitEnabled && concurrencyState.status === "loaded" ? (
|
||||
<small className="cc-controls-slider-caption">
|
||||
{t("commandCenter.controls.concurrency.worktreeLimitDisabled", "Enable the worktree limit in Settings to edit this capacity.")}
|
||||
</small>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
{concurrencyState.status === "error" ? <p className="cc-controls-error" role="alert">{concurrencyState.error}</p> : null}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { CommandCenterControls } from "../CommandCenterControls";
|
||||
import { ConfirmDialogProvider } from "../../../hooks/useConfirm";
|
||||
import { readAppFile } from "../../../test/cssFixture";
|
||||
@@ -165,6 +165,80 @@ describe("CommandCenterControls concurrency markers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:CommandCenter 2026-08-01-00:14:
|
||||
jsdom cannot calculate grid layout or detect a visually suppressed native range
|
||||
thumb. The browser e2e verification owns geometry; these tests lock the DOM,
|
||||
label, range, state, and save invariants for both capacity controls.
|
||||
*/
|
||||
it("keeps both capacity sliders visible and disabled while concurrency settings load", () => {
|
||||
legacyMocks.fetchConfig.mockReturnValue(new Promise(() => {}));
|
||||
legacyMocks.fetchSettings.mockReturnValue(new Promise(() => {}));
|
||||
renderControls();
|
||||
|
||||
for (const name of [/Max concurrent tasks/i, /Max worktrees/i]) {
|
||||
const slider = screen.getByRole("slider", { name });
|
||||
expect(slider).toHaveAttribute("min", "1");
|
||||
expect(slider).toBeVisible();
|
||||
expect(slider).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps both capacity sliders enabled and saves a loaded worktree edit", async () => {
|
||||
renderControls();
|
||||
const worktrees = await screen.findByRole("slider", { name: /Max worktrees/i });
|
||||
vi.useFakeTimers();
|
||||
const concurrent = screen.getByRole("slider", { name: /Max concurrent tasks/i });
|
||||
|
||||
expect(concurrent).toBeEnabled();
|
||||
expect(worktrees).toBeEnabled();
|
||||
expect(worktrees).toHaveAttribute("min", "1");
|
||||
expect(worktrees).toHaveAttribute("max", "50");
|
||||
expect(worktrees).toHaveValue("4");
|
||||
|
||||
try {
|
||||
fireEvent.change(worktrees, { target: { value: "5" } });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save change" }));
|
||||
await act(async () => {});
|
||||
expect(legacyMocks.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ maxWorktrees: 5 }),
|
||||
"proj_123",
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps both sliders visible and disabled with an error explanation when loading fails", async () => {
|
||||
legacyMocks.fetchSettings.mockRejectedValue(new Error("settings unavailable"));
|
||||
renderControls();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("settings unavailable");
|
||||
expect(screen.getByRole("slider", { name: /Max concurrent tasks/i })).toBeDisabled();
|
||||
expect(screen.getByRole("slider", { name: /Max worktrees/i })).toBeDisabled();
|
||||
expect(screen.getByRole("slider", { name: /Max worktrees/i })).toHaveValue("4");
|
||||
});
|
||||
|
||||
it("uses the default for missing worktrees, expands persisted values, and explains an intentionally disabled limit", async () => {
|
||||
legacyMocks.fetchSettings.mockResolvedValue({ maxConcurrent: 12 });
|
||||
renderControls();
|
||||
expect(await screen.findByRole("slider", { name: /Max worktrees/i })).toHaveValue("4");
|
||||
expect(screen.getByRole("slider", { name: /Max worktrees/i })).toBeEnabled();
|
||||
|
||||
document.body.innerHTML = "";
|
||||
cleanup();
|
||||
legacyMocks.fetchSettings.mockResolvedValue({ maxConcurrent: 12, maxWorktrees: 80, worktreeLimitEnabled: false });
|
||||
renderControls();
|
||||
const worktrees = await screen.findByRole("slider", { name: /Max worktrees/i });
|
||||
expect(worktrees).toHaveValue("80");
|
||||
expect(worktrees).toHaveAttribute("max", "80");
|
||||
expect(worktrees).toBeDisabled();
|
||||
expect(screen.getByText("Enable the worktree limit in Settings to edit this capacity.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches the desktop and mobile native thumb-size CSS contract", () => {
|
||||
expect(commandCenterControlsCss).toContain(
|
||||
"--cc-controls-range-thumb-size: calc(var(--space-lg) + var(--space-xs) / 2);",
|
||||
|
||||
180
packages/engine/e2e/fn-8635-worktrees-slider.mjs
Normal file
180
packages/engine/e2e/fn-8635-worktrees-slider.mjs
Normal file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env node
|
||||
/* global process, fetch, AbortSignal, setTimeout, console */
|
||||
/**
|
||||
* FN-8635 browser geometry verification.
|
||||
*
|
||||
* Invocation from the repository root: `pnpm build`, then
|
||||
* `node packages/engine/e2e/fn-8635-worktrees-slider.mjs`.
|
||||
* The built CLI is required; this script never downloads a browser.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const cliBin = path.join(repoRoot, "packages/cli/bin.mjs");
|
||||
const screenshotsDir = path.join(repoRoot, "packages/dashboard/e2e/__screenshots__/fn-8635");
|
||||
const reservedPorts = new Set([4040, ...String(process.env.FUSION_RESERVED_PORTS ?? "").split(",").map(Number).filter(Number.isInteger)]);
|
||||
const healthTimeoutMs = 180_000;
|
||||
let cleanup = () => {};
|
||||
|
||||
/*
|
||||
FNXC:CommandCenter 2026-08-01-00:14:
|
||||
FN-8635 requires browser-computed geometry at desktop and mobile because jsdom cannot
|
||||
observe a native range thumb or a grid-clipped control. This isolated smoke starts no
|
||||
operator project and avoids the production dashboard port.
|
||||
*/
|
||||
async function getEphemeralPort() {
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const port = await new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
if (!reservedPorts.has(port)) return port;
|
||||
}
|
||||
throw new Error("could not obtain a non-reserved ephemeral port");
|
||||
}
|
||||
|
||||
async function pollHealth(port, deadline) {
|
||||
let lastError = "no response";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(2_000) });
|
||||
if (response.status === 200) return;
|
||||
lastError = `HTTP ${response.status}`;
|
||||
} catch (error) {
|
||||
lastError = error?.cause?.code ?? error?.name ?? String(error);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`health check never returned 200 (last: ${lastError})`);
|
||||
}
|
||||
|
||||
function removeTempDir(directory) {
|
||||
try {
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch (error) {
|
||||
console.warn(`cleanup failed for ${directory}: ${error?.message ?? error}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function bootServer(attempt) {
|
||||
const port = await getEphemeralPort();
|
||||
const isolatedHome = mkdtempSync(path.join(tmpdir(), "fusion-fn8635-home-"));
|
||||
const isolatedProject = mkdtempSync(path.join(tmpdir(), "fusion-fn8635-project-"));
|
||||
let output = "";
|
||||
const child = spawn(process.execPath, [cliBin, "serve", "--port", String(port), "--host", "127.0.0.1", "--paused"], {
|
||||
cwd: isolatedProject,
|
||||
env: { ...process.env, HOME: isolatedHome, FUSION_SKIP_ONBOARDING: "1", DATABASE_URL: undefined, FUSION_NO_EMBEDDED_PG: undefined, PORT: undefined },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.on("data", (chunk) => { output += chunk; });
|
||||
child.stderr.on("data", (chunk) => { output += chunk; });
|
||||
const exited = new Promise((resolve) => child.once("exit", (code, signal) => resolve({ code, signal })));
|
||||
cleanup = () => {
|
||||
try { if (child.exitCode === null && !child.killed) child.kill("SIGKILL"); } catch { /* child already exited */ }
|
||||
removeTempDir(isolatedHome);
|
||||
removeTempDir(isolatedProject);
|
||||
};
|
||||
try {
|
||||
await Promise.race([
|
||||
pollHealth(port, Date.now() + healthTimeoutMs),
|
||||
exited.then(({ code, signal }) => { throw new Error(`server exited before becoming healthy (${code ?? `signal ${signal}`})`); }),
|
||||
]);
|
||||
return { child, port, exited };
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
if (/EADDRINUSE/.test(output) && attempt === 0) return bootServer(1);
|
||||
throw new Error(`${error.message}\n--- server output ---\n${output.split("\n").slice(-200).join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function seed(port) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/settings`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ maxConcurrent: 12, maxWorktrees: 4, worktreeLimitEnabled: true }),
|
||||
});
|
||||
assert(response.ok, `settings seed failed: HTTP ${response.status}`);
|
||||
const settings = await response.json();
|
||||
assert(settings.maxWorktrees === 4, "settings seed did not echo maxWorktrees === 4");
|
||||
}
|
||||
|
||||
async function assertViewport(browser, port, viewport, screenshotName) {
|
||||
const page = await browser.newPage({ viewport });
|
||||
await page.goto(`http://127.0.0.1:${port}/?view=command-center`, { waitUntil: "networkidle" });
|
||||
const card = page.locator(".cc-controls-card--concurrency");
|
||||
await card.waitFor({ state: "attached" });
|
||||
await card.getByText("Ready", { exact: true }).waitFor({ state: "visible" });
|
||||
for (const id of ["cc-max-concurrent", "cc-max-worktrees"]) {
|
||||
const slider = page.locator(`#${id}`);
|
||||
await slider.waitFor({ state: "visible" });
|
||||
assert(await slider.isDisabled() === false, `${id} is disabled in loaded state`);
|
||||
const [sliderBox, containerBox] = await Promise.all([slider.boundingBox(), page.locator(".cc-controls-sliders").boundingBox()]);
|
||||
assert(sliderBox && sliderBox.width > 0 && sliderBox.height > 0, `${id} has a zero-size bounding box`);
|
||||
assert(containerBox && sliderBox.x >= containerBox.x && sliderBox.y >= containerBox.y && sliderBox.x + sliderBox.width <= containerBox.x + containerBox.width && sliderBox.y + sliderBox.height <= containerBox.y + containerBox.height, `${id} is clipped or overflows its slider container`);
|
||||
}
|
||||
const worktrees = page.locator("#cc-max-worktrees");
|
||||
const originalValue = await worktrees.inputValue();
|
||||
await worktrees.focus();
|
||||
await worktrees.press("ArrowRight");
|
||||
assert(await worktrees.inputValue() === String(Number(originalValue) + 1), "worktrees slider did not update synchronously on ArrowRight");
|
||||
await page.getByRole("button", { name: "Save change" }).waitFor({ state: "visible" });
|
||||
await page.getByRole("button", { name: "Save change" }).click();
|
||||
// FNXC:CommandCenter 2026-08-01-00:29: Waiting for Saved, rather than the already-visible Ready label, proves the confirmed worktree edit completed without a save error.
|
||||
await card.getByText("Saved", { exact: true }).waitFor({ state: "visible" });
|
||||
mkdirSync(screenshotsDir, { recursive: true });
|
||||
await page.screenshot({ path: path.join(screenshotsDir, screenshotName), fullPage: true });
|
||||
await page.close();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!await import("node:fs").then(({ existsSync }) => existsSync(cliBin))) {
|
||||
throw new Error(`built CLI not found at ${cliBin}; run pnpm build first`);
|
||||
}
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = await import("playwright-core"));
|
||||
} catch {
|
||||
console.log("SKIP: playwright-core not resolvable from this package");
|
||||
return;
|
||||
}
|
||||
const { child, port, exited } = await bootServer(0);
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch();
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
if (/executable|browser.*install|chromium/i.test(String(error.message))) {
|
||||
console.log("SKIP: no Chromium binary available");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await seed(port);
|
||||
await assertViewport(browser, port, { width: 1280, height: 900 }, "desktop-after.png");
|
||||
await assertViewport(browser, port, { width: 390, height: 844 }, "mobile-after.png");
|
||||
child.kill("SIGTERM");
|
||||
await exited;
|
||||
console.log("FN-8635 browser verification: PASS");
|
||||
} finally {
|
||||
await browser.close();
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
process.on("exit", () => cleanup());
|
||||
for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => { cleanup(); process.exit(signal === "SIGINT" ? 130 : 143); });
|
||||
main().catch((error) => { console.error(`FN-8635 browser verification: FAIL — ${error.message ?? error}`); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user