Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 6c01455f27 test(server): isolate config responsiveness from test runner work
Measure config reads in a fresh process so unrelated runner callbacks cannot consume the config CPU budget. Keep the existing budget, result, async I/O and yield assertions, and prove isolation with a busy parent callback.
2026-10-28 15:13:02 +00:00
5 changed files with 77 additions and 167 deletions
@@ -1,134 +0,0 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { createCreationScenario } from "../support/helpers/creation";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const DRAG_DISTANCE = -40;
const TOLERANCE_PX = 2;
function splitDividers(page: Page): Locator {
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
}
async function dividerX(divider: Locator): Promise<number> {
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
return box.x;
}
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + deltaX, y, { steps: 8 });
await page.mouse.up();
}
async function dragDividerWithTouch(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
const input = await page.context().newCDPSession(page);
try {
await input.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (let step = 1; step <= 8; step++) {
await input.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: x + (deltaX * step) / 8, y }],
});
}
await input.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
} finally {
await input.detach();
}
}
async function splitIntoFourPanes(page: Page): Promise<void> {
for (const expectedDividers of [1, 2, 3]) {
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(splitDividers(page)).toHaveCount(expectedDividers);
}
}
async function expectDividersTrackPointer(
page: Page,
distance = DRAG_DISTANCE,
drag = dragDivider,
): Promise<void> {
for (const index of [0, 1, 2]) {
const divider = splitDividers(page).nth(index);
const before = await dividerX(divider);
await drag(divider, distance);
await expect
.poll(async () => Math.abs((await dividerX(divider)) - before - distance), {
message: `divider ${index + 1} should stay under the pointer`,
})
.toBeLessThanOrEqual(TOLERANCE_PX);
}
}
for (const hasTouch of [false, true]) {
test.describe(`Split pane drag tracking (${hasTouch ? "coarse" : "fine"} pointer)`, () => {
test.use({ hasTouch });
test("a divider moves as far as the pointer in the main row", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(
page,
DRAG_DISTANCE,
hasTouch ? dragDividerWithTouch : dragDivider,
);
} finally {
await workspace.cleanup();
}
});
test("dividers track the pointer after app creation, Explorer changes and reload", async ({
page,
}) => {
await page.setViewportSize({ width: 1600, height: 900 });
const creation = await createCreationScenario(page);
try {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Reply briefly.", "Create");
await creation.expectAssistantReply();
await waitForWorkspaceTabsVisible(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(page);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expectDividersTrackPointer(page, -DRAG_DISTANCE);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeVisible();
await page.reload();
await expect(splitDividers(page)).toHaveCount(3);
await expectDividersTrackPointer(page);
} finally {
await creation.cleanup();
}
});
});
}
+14 -21
View File
@@ -49,7 +49,7 @@ export function ResizeHandle({
const { theme } = useUnistyles();
const finePointer = useHasFinePointer();
const pointerStatesRef = useRef(new Map<number, PointerState>());
const touchStateRef = useRef<PointerState | null>(null);
const touchDragRef = useRef<ResizeHandleDrag | null>(null);
const cursorBeforeDragRef = useRef<string | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false);
@@ -151,30 +151,23 @@ export function ResizeHandle({
const touchGesture = useMemo(() => {
const gesture = Gesture.Pan()
.runOnJS(true)
.onBegin((event) => {
setDragging(true);
touchStateRef.current = {
containerSize,
pointerStart: direction === "horizontal" ? event.absoluteX : event.absoluteY,
drag: startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
}),
};
.onBegin(() => setDragging(true))
.onStart(() => {
touchDragRef.current = startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
});
})
.onUpdate((event) => {
const state = touchStateRef.current;
if (!state || state.containerSize <= 0) return;
// Gesture Handler resets web translation when the pan activates. Measure from
// the initial press so the activation distance is included on every platform.
const pointerCurrent = direction === "horizontal" ? event.absoluteX : event.absoluteY;
state.drag.move((pointerCurrent - state.pointerStart) / state.containerSize);
if (containerSize <= 0) return;
const translation = direction === "horizontal" ? event.translationX : event.translationY;
touchDragRef.current?.move(translation / containerSize);
})
.onEnd(() => touchStateRef.current?.drag.finish())
.onEnd(() => touchDragRef.current?.finish())
.onFinalize(() => {
touchStateRef.current = null;
touchDragRef.current = null;
setDragging(false);
});
@@ -48,7 +48,6 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
import {
collectAllPanes,
createDefaultLayout,
selectExplorerSidebarPaneId,
useWorkspaceLayoutStore,
type SplitNode,
type SplitPane,
@@ -184,8 +183,8 @@ export function SplitContainer({
} | null>(null);
const maximizedPaneId =
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
selectExplorerSidebarPaneId(state, workspaceKey),
const explorerSidebarPaneId = useWorkspaceLayoutStore(
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
);
const mainRoot = useMemo(
@@ -3,7 +3,6 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { writeFile, truncate, rm } from "node:fs/promises";
import { measureMainThreadWork } from "../test-utils/main-thread-work.js";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -21,6 +20,24 @@ import {
writePaseoConfigForEdit,
} from "./paseo-config-file.js";
async function measureConfigRead(directory: string) {
const fixture = fileURLToPath(
new URL("./test-fixtures/config-responsiveness.ts", import.meta.url),
);
const { stdout, stderr } = await promisify(execFile)(
process.execPath,
["--import", "tsx", fixture, directory],
{ timeout: 15_000 },
);
process.stderr.write(stderr);
return JSON.parse(stdout) as {
result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
blockingMs: number;
filesystemRequests: number;
yields: number;
};
}
describe("paseo config file substrate", () => {
let tempDir: string;
@@ -67,6 +84,33 @@ describe("paseo config file substrate", () => {
);
});
it("excludes unrelated test-runner CPU from the config read budget", async () => {
await writeFile(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
const unrelatedWork = new Promise<void>((resolve) => {
setImmediate(() => {
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
process,
"threadCpuUsage",
);
const start = threadCpuUsage();
let elapsed: NodeJS.CpuUsage;
do {
elapsed = threadCpuUsage(start);
} while (elapsed.user + elapsed.system < 60_000);
resolve();
});
});
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
await unrelatedWork;
expect(result).toEqual({
ok: true,
config: { worktree: { setup: "npm ci" } },
revision: await statPaseoConfigPath(tempDir),
});
expect(filesystemRequests).toBeGreaterThan(0);
expect(blockingMs).toBeLessThan(20);
});
it("keeps main-thread work below 20ms while admitting large configs", async () => {
const config = {
scripts: Object.fromEntries(
@@ -75,10 +119,7 @@ describe("paseo config file substrate", () => {
worktree: { setup: "npm ci" },
};
await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config));
const { result, blockingMs, filesystemRequests, yields } = await measureMainThreadWork(
"project config 7000 scripts main-thread work",
() => readPaseoConfigForEdit(tempDir),
);
const { result, blockingMs, filesystemRequests, yields } = await measureConfigRead(tempDir);
expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) });
expect(blockingMs).toBeLessThan(20);
expect(filesystemRequests).toBeGreaterThan(0);
@@ -90,10 +131,7 @@ describe("paseo config file substrate", () => {
join(tempDir, "paseo.json"),
JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }),
);
const { result, blockingMs, filesystemRequests } = await measureMainThreadWork(
"project config 32MiB main-thread work",
() => readPaseoConfigForEdit(tempDir),
);
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
expect(blockingMs).toBeLessThan(20);
expect(filesystemRequests).toBeGreaterThan(0);
expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } });
@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import { setImmediate as nextTurn } from "node:timers/promises";
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
import { readPaseoConfigForEdit } from "../paseo-config-file.js";
// Keep Vitest transforms, reporting, and unrelated callbacks outside the measured
// thread. Read the real file once, without warming the config snapshot or schema.
const directory = process.argv[2];
assert.ok(directory, "Config directory is required");
await nextTurn();
const measured = await measureMainThreadWork("project config main-thread work", () =>
readPaseoConfigForEdit(directory),
);
process.stdout.write(JSON.stringify(measured));