mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6ec757026 | ||
|
|
9246813fd2 | ||
|
|
06f0522258 | ||
|
|
9b92b2e25c | ||
|
|
a310444c67 | ||
|
|
c364b16d40 | ||
|
|
dc7bc8db12 | ||
|
|
40f0393064 | ||
|
|
166aabd57b |
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
async function openAppearance(page: Page) {
|
||||
await page.goto("/settings");
|
||||
await openSettingsSection(page, "appearance");
|
||||
}
|
||||
|
||||
async function setFontFamily(page: Page, name: string, family: string) {
|
||||
const input = page.getByRole("textbox", { name });
|
||||
await input.fill(family);
|
||||
await input.press("Tab");
|
||||
}
|
||||
|
||||
for (const name of ["Code font family", "Interface font family"]) {
|
||||
test(`keeps settings usable after an unfinished quote in ${name}`, async ({ page }, testInfo) => {
|
||||
await openAppearance(page);
|
||||
await setFontFamily(page, name, "'Fira Code', monospace");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
|
||||
await setFontFamily(page, name, "'foo");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath("rejected-font.png") });
|
||||
await page.reload();
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await setFontFamily(page, name, "");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("");
|
||||
});
|
||||
}
|
||||
|
||||
test("opens settings with defaults when a malformed font was already saved", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ monoFontFamily: "'foo", uiFontFamily: '"foo' }),
|
||||
);
|
||||
});
|
||||
await openAppearance(page);
|
||||
await expect(page.getByRole("textbox", { name: "Code font family" })).toHaveValue("");
|
||||
await expect(page.getByRole("textbox", { name: "Interface font family" })).toHaveValue("");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const test = base.extend<{ projectWithTrailingSpace: string }>({
|
||||
projectWithTrailingSpace: async ({ e2eWorkerClient: client }, provide) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-project-path-spaces-"));
|
||||
try {
|
||||
const repoPath = path.join(root, "repo ");
|
||||
execFileSync("git", ["init", "-b", "main", repoPath], { stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"user.name=Paseo Test",
|
||||
"-c",
|
||||
"user.email=test@getpaseo.local",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
{ cwd: repoPath, stdio: "pipe" },
|
||||
);
|
||||
await writeFile(path.join(repoPath, "visible-change.txt"), "a change\n");
|
||||
const added = await client.addProject(repoPath);
|
||||
expect(added.error).toBeNull();
|
||||
const projectId = added.project!.projectId;
|
||||
try {
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repoPath, projectId },
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
await provide(created.workspace!.id);
|
||||
} finally {
|
||||
await client.removeProject(projectId);
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
test.skip(
|
||||
process.platform === "win32",
|
||||
"Windows does not preserve trailing spaces in folder names",
|
||||
);
|
||||
|
||||
test("Changes shows files in a project whose folder ends in a space", async ({
|
||||
page,
|
||||
projectWithTrailingSpace,
|
||||
}, testInfo) => {
|
||||
await gotoWorkspace(page, projectWithTrailingSpace);
|
||||
await openChangesTreePanel(page);
|
||||
await expect(page.getByText("visible-change.txt", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("Not a git repository", { exact: true })).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("changes.png") });
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
parseClampedFontSize,
|
||||
parseTerminalScrollbackLines,
|
||||
resolveContentMaxWidth,
|
||||
sanitizeFontFamily,
|
||||
saveAppSettings,
|
||||
type SettingsDeps,
|
||||
} from "./storage";
|
||||
@@ -1094,3 +1095,34 @@ describe("content max width", () => {
|
||||
expect((await load("wide")).contentMaxWidth).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("font family CSS safety", () => {
|
||||
it.each(["'foo", '"foo', "Menlo, 'foo", "foo\\", "foo/*", "foo(", "foo["])(
|
||||
"rejects unfinished CSS syntax: %s",
|
||||
(value) => expect(sanitizeFontFamily(value)).toBeNull(),
|
||||
);
|
||||
|
||||
it.each([
|
||||
"Menlo",
|
||||
"'Fira Code', monospace",
|
||||
'"Fira Code", monospace',
|
||||
"'Font\\'s name'",
|
||||
"Font\\ Name",
|
||||
"'Font (Mono)'",
|
||||
"'Font [Mono]'",
|
||||
])("preserves complete font families: %s", (value) =>
|
||||
expect(sanitizeFontFamily(value)).toBe(value),
|
||||
);
|
||||
|
||||
it.each(["uiFontFamily", "monoFontFamily"])(
|
||||
"recovers a persisted unmatched quote in %s",
|
||||
async (field) => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ [field]: "'foo" }),
|
||||
}),
|
||||
});
|
||||
expect(await loadAppSettingsFromStorage(deps)).toMatchObject({ [field]: "" });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -522,7 +522,24 @@ export function sanitizeFontFamily(value: unknown): string | null {
|
||||
if ([...trimmed].some((char) => char.charCodeAt(0) <= 0x1f)) {
|
||||
return null; // control chars would corrupt the font-family string
|
||||
}
|
||||
return trimmed; // quotes/commas are legit in stacks
|
||||
// Unistyles serializes theme values into one shared stylesheet. An unfinished
|
||||
// string, escape, comment, or block consumes the declarations that follow.
|
||||
let quote: string | null = null;
|
||||
let escaped = false;
|
||||
for (const char of trimmed) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (quote !== null) {
|
||||
if (char === quote) quote = null;
|
||||
} else if (char === "'" || char === '"') {
|
||||
quote = char;
|
||||
} else if (/[()[\]/*]/.test(char)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return quote === null && !escaped ? trimmed : null;
|
||||
}
|
||||
|
||||
async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { formatShortcut } from "@/utils/format-shortcut";
|
||||
import { keyboardEventToComboString } from "./shortcut-string";
|
||||
import {
|
||||
buildKeyboardShortcutHelpSections,
|
||||
buildEffectiveBindings,
|
||||
@@ -1026,6 +1027,25 @@ describe("unassigned shortcuts", () => {
|
||||
});
|
||||
|
||||
describe("matching", () => {
|
||||
it("dispatches a captured Dvorak binding on the same logical key", () => {
|
||||
const event = keyboardInput({ key: "b", code: "KeyN", metaKey: true });
|
||||
const combo = keyboardEventToComboString(event as KeyboardEvent);
|
||||
expect(combo).toBe("Cmd+B");
|
||||
const bindings = buildEffectiveBindings({ "sidebar-toggle-left-mac-cmd-b": combo! });
|
||||
|
||||
expect(
|
||||
resolveShortcut({ event, context: { isMac: true, isDesktop: true }, bindings }).match
|
||||
?.action,
|
||||
).toBe("sidebar.toggle.left");
|
||||
expect(
|
||||
resolveShortcut({
|
||||
event: { key: "x", code: "KeyB", metaKey: true },
|
||||
context: { isMac: true, isDesktop: true },
|
||||
bindings,
|
||||
}).match,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("stops matching a shortcut the user unassigned", () => {
|
||||
const bindings = buildEffectiveBindings({ [TAB_NEW_BINDING]: UNASSIGNED_COMBO });
|
||||
|
||||
|
||||
@@ -22,6 +22,45 @@ function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
|
||||
}
|
||||
|
||||
describe("keyboardEventToComboString", () => {
|
||||
it("captures the Dvorak character rather than its QWERTY position", () => {
|
||||
expect(
|
||||
keyboardEventToComboString(keyboardEvent({ key: "b", code: "KeyN", metaKey: true })),
|
||||
).toBe("Cmd+B");
|
||||
});
|
||||
|
||||
it("captures shifted Dvorak letters and punctuation at other physical positions", () => {
|
||||
expect(
|
||||
keyboardEventToComboString(
|
||||
keyboardEvent({ key: "T", code: "KeyK", metaKey: true, shiftKey: true }),
|
||||
),
|
||||
).toBe("Shift+Cmd+T");
|
||||
expect(
|
||||
keyboardEventToComboString(keyboardEvent({ key: ";", code: "KeyZ", ctrlKey: true })),
|
||||
).toBe("Ctrl+;");
|
||||
expect(
|
||||
keyboardEventToComboString(
|
||||
keyboardEvent({ key: "?", code: "BracketLeft", ctrlKey: true, shiftKey: true }),
|
||||
),
|
||||
).toBe("Ctrl+Shift+/");
|
||||
});
|
||||
|
||||
it("keeps named keys and rewritten Option characters capturable", () => {
|
||||
expect(keyboardEventToComboString(keyboardEvent({ key: "ArrowLeft", code: "ArrowLeft" }))).toBe(
|
||||
"ArrowLeft",
|
||||
);
|
||||
expect(
|
||||
keyboardEventToComboString(keyboardEvent({ key: "†", code: "KeyT", altKey: true })),
|
||||
).toBe("Alt+T");
|
||||
});
|
||||
|
||||
it("does not record a named key that dispatch does not support", () => {
|
||||
expect(
|
||||
keyboardEventToComboString(
|
||||
keyboardEvent({ key: "Enter", code: "NumpadEnter", ctrlKey: true }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("captures the minus key (regression: previously returned null)", () => {
|
||||
expect(keyboardEventToComboString(keyboardEvent({ key: "-", code: "Minus" }))).toBe("-");
|
||||
});
|
||||
|
||||
@@ -266,7 +266,16 @@ export function keyboardEventToComboString(event: KeyboardEvent): string | null
|
||||
if (event.shiftKey) parts.push("Shift");
|
||||
if (event.metaKey) parts.push("Cmd");
|
||||
|
||||
const humanKey = CODE_TO_KEY[event.code];
|
||||
// Capture the same logical character that dispatch matches. Keep the code
|
||||
// fallback for named keys and Option combinations whose key is rewritten.
|
||||
const eventKey = event.key.toLowerCase();
|
||||
const logicalKey = Object.entries(KEY_MAP).find(
|
||||
([, mapping]) =>
|
||||
mapping.key?.length === 1 &&
|
||||
(mapping.key.toLowerCase() === eventKey ||
|
||||
(event.shiftKey && mapping.shiftedKey === event.key)),
|
||||
)?.[0];
|
||||
const humanKey = logicalKey ?? CODE_TO_KEY[event.code];
|
||||
if (humanKey) {
|
||||
parts.push(humanKey);
|
||||
} else {
|
||||
|
||||
@@ -241,7 +241,7 @@ interface FontFamilyRowProps {
|
||||
draft: string;
|
||||
withBorder: boolean;
|
||||
onChangeDraft: (value: string) => void;
|
||||
onCommit: (value: string) => void;
|
||||
onCommit: (value: string) => string;
|
||||
}
|
||||
|
||||
function FontFamilyRow({
|
||||
@@ -255,13 +255,16 @@ function FontFamilyRow({
|
||||
onChangeDraft,
|
||||
onCommit,
|
||||
}: FontFamilyRowProps) {
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const handleCommit = useCallback(() => {
|
||||
onCommit(draft);
|
||||
const committed = onCommit(draft);
|
||||
inputRef.current?.replaceText(committed);
|
||||
}, [draft, onCommit]);
|
||||
|
||||
// Resync from the committed value when it changes elsewhere.
|
||||
useEffect(() => {
|
||||
onChangeDraft(value);
|
||||
inputRef.current?.replaceText(value);
|
||||
// Only resync on external value changes, not on local keystrokes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
@@ -273,6 +276,7 @@ function FontFamilyRow({
|
||||
<Text style={settingsStyles.rowHint}>{hint}</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
initialValue={draft}
|
||||
onChangeText={onChangeDraft}
|
||||
onBlur={handleCommit}
|
||||
@@ -533,12 +537,13 @@ export function AppearanceSection() {
|
||||
const sanitized = sanitizeFontFamily(value);
|
||||
if (sanitized === null) {
|
||||
setUiFontDraft(settings.uiFontFamily);
|
||||
return;
|
||||
return settings.uiFontFamily;
|
||||
}
|
||||
setUiFontDraft(sanitized);
|
||||
if (sanitized !== settings.uiFontFamily) {
|
||||
void updateSettings({ uiFontFamily: sanitized });
|
||||
}
|
||||
return sanitized;
|
||||
},
|
||||
[settings.uiFontFamily, updateSettings],
|
||||
);
|
||||
@@ -548,12 +553,13 @@ export function AppearanceSection() {
|
||||
const sanitized = sanitizeFontFamily(value);
|
||||
if (sanitized === null) {
|
||||
setMonoFontDraft(settings.monoFontFamily);
|
||||
return;
|
||||
return settings.monoFontFamily;
|
||||
}
|
||||
setMonoFontDraft(sanitized);
|
||||
if (sanitized !== settings.monoFontFamily) {
|
||||
void updateSettings({ monoFontFamily: sanitized });
|
||||
}
|
||||
return sanitized;
|
||||
},
|
||||
[settings.monoFontFamily, updateSettings],
|
||||
);
|
||||
|
||||
@@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "./workspace-directory";
|
||||
|
||||
describe("resolveWorkspaceDirectory", () => {
|
||||
it("preserves significant whitespace in a workspace folder name", () => {
|
||||
expect(resolveWorkspaceDirectory({ workspaceDirectory: "/repo " })).toBe("/repo ");
|
||||
expect(requireWorkspaceDirectory({ workspaceDirectory: "/repo /" })).toBe("/repo ");
|
||||
});
|
||||
it("canonicalizes a workspace directory and returns null when blank", () => {
|
||||
expect(resolveWorkspaceDirectory({ workspaceDirectory: "C:\\repo\\app\\" })).toBe(
|
||||
"C:/repo/app",
|
||||
|
||||
@@ -13,11 +13,10 @@ export function normalizeWorkspaceOpaqueId(value: string | null | undefined): st
|
||||
}
|
||||
|
||||
export function normalizeWorkspacePath(value: string | null | undefined): string | null {
|
||||
const trimmed = trimNonEmpty(value);
|
||||
if (!trimmed) {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
const withUnixSeparators = trimmed.replace(/\\/g, "/");
|
||||
const withUnixSeparators = value.replace(/\\/g, "/");
|
||||
if (withUnixSeparators === "/") {
|
||||
return withUnixSeparators;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -8,6 +9,35 @@ const appDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
const rootDir = path.resolve(appDir, "../..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const vitestEntry = path.join(path.dirname(require.resolve("vitest/package.json")), "vitest.mjs");
|
||||
const tsgoEntry = path.join(
|
||||
path.dirname(require.resolve("@typescript/native-preview/package.json")),
|
||||
"bin/tsgo.js",
|
||||
);
|
||||
|
||||
it("the test configuration typechecks with Expo's generated environment declaration", () => {
|
||||
const fixtureDir = mkdtempSync(path.join(appDir, ".vitest-expo-env-"));
|
||||
try {
|
||||
writeFileSync(path.join(fixtureDir, "expo-env.d.ts"), '/// <reference types="expo/types" />\n');
|
||||
writeFileSync(
|
||||
path.join(fixtureDir, "tsconfig.json"),
|
||||
JSON.stringify({
|
||||
extends: "../tsconfig.json",
|
||||
files: ["../vitest.config.ts", "expo-env.d.ts"],
|
||||
include: [],
|
||||
}),
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[tsgoEntry, "--noEmit", "--project", path.join(fixtureDir, "tsconfig.json")],
|
||||
{ cwd: appDir, encoding: "utf8", timeout: 30_000 },
|
||||
);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
} finally {
|
||||
rmSync(fixtureDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 40_000);
|
||||
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
// Set this before Vite resolves dependencies and Vitest starts its workers.
|
||||
process.env.NODE_ENV = "test";
|
||||
Object.assign(process.env, { NODE_ENV: "test" });
|
||||
|
||||
const appNodeModules = path.resolve(__dirname, "node_modules");
|
||||
const rootNodeModules = path.resolve(__dirname, "../../node_modules");
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -29,6 +29,7 @@ async function runSupervisorFixture(options: {
|
||||
blockLogPath?: boolean;
|
||||
/** Close the reading end of the supervisor's stdout and stderr as soon as it starts. */
|
||||
closeOutput?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
@@ -84,7 +85,9 @@ async function runSupervisorFixture(options: {
|
||||
];
|
||||
const child = spawn(command, args, {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env },
|
||||
// A Node color-conflict warning can hit closed stderr before runSupervisor installs
|
||||
// its pipe-error handlers. This fixture exercises the supervisor's own output.
|
||||
env: { ...process.env, ...options.env, NO_COLOR: undefined, FORCE_COLOR: undefined },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -205,10 +208,16 @@ describe("supervisor durable logging", () => {
|
||||
expect(result.stderr).toContain('"worker-json-stderr"');
|
||||
});
|
||||
|
||||
test("keeps supervising the worker when nobody reads its stdout or stderr", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
closeOutput: true,
|
||||
workerSource: `
|
||||
test.each([
|
||||
{ colors: "inherited", env: {} },
|
||||
{ colors: "conflicting", env: { NO_COLOR: "1", FORCE_COLOR: "1" } },
|
||||
])(
|
||||
"keeps supervising the worker when nobody reads its stdout or stderr with $colors colors",
|
||||
async ({ env }) => {
|
||||
const result = await runSupervisorFixture({
|
||||
closeOutput: true,
|
||||
env,
|
||||
workerSource: `
|
||||
process.on("message", (message) => {
|
||||
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
|
||||
});
|
||||
@@ -220,14 +229,15 @@ describe("supervisor durable logging", () => {
|
||||
}, 500);
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.log).toContain("first stderr line\n");
|
||||
expect(result.log).toContain("later stdout line\n");
|
||||
expect(result.log).toContain('"reason":"closed_output_probe"');
|
||||
});
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.log).toContain("first stderr line\n");
|
||||
expect(result.log).toContain("later stdout line\n");
|
||||
expect(result.log).toContain('"reason":"closed_output_probe"');
|
||||
},
|
||||
);
|
||||
|
||||
test("preserves raw non-JSON stdout and stderr lines", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
|
||||
@@ -222,6 +222,11 @@ function createFinishNotificationScenario(
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
subscriber?.({
|
||||
type: "agent_stream",
|
||||
agentId: "child-agent",
|
||||
event: { type: "turn_completed", provider: "codex" },
|
||||
});
|
||||
},
|
||||
async finishChildAndReadParentPrompt() {
|
||||
const parentPrompt = new Promise<string>((resolve) => {
|
||||
@@ -540,6 +545,11 @@ it("does not notify archived callers", async () => {
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
subscriber?.({
|
||||
type: "agent_stream",
|
||||
agentId: "child-agent",
|
||||
event: { type: "turn_completed", provider: "codex" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(agentStorageGetSpy).toHaveBeenCalledWith("caller-agent");
|
||||
|
||||
@@ -407,13 +407,20 @@ export interface SetupFinishNotificationParams {
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
type FinishNotificationReason = "finished" | "errored" | "needs permission" | "was closed";
|
||||
type FinishNotificationReason =
|
||||
| "finished"
|
||||
| "errored"
|
||||
| "needs permission"
|
||||
| "was closed"
|
||||
| "was canceled";
|
||||
|
||||
const finishNotificationEvents = {
|
||||
finished: "finished",
|
||||
errored: "errored",
|
||||
"needs permission": "permission-required",
|
||||
"was closed": "closed",
|
||||
// Use the existing failure event so older clients still parse agent-message metadata.
|
||||
"was canceled": "errored",
|
||||
} as const;
|
||||
|
||||
const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000;
|
||||
@@ -568,10 +575,8 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
notifySafely("errored");
|
||||
return;
|
||||
}
|
||||
if (agent.lifecycle === "idle" && hasSeenRunning) {
|
||||
notifySafely("finished");
|
||||
return;
|
||||
}
|
||||
// Idle is published before the terminal stream event for both completion
|
||||
// and cancellation. Let that event identify the outcome instead.
|
||||
if (agent.lifecycle === "closed") {
|
||||
notifySafely("was closed");
|
||||
return;
|
||||
@@ -600,6 +605,18 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.event.type === "turn_completed" || event.event.type === "turn_canceled") {
|
||||
// Replacing a turn keeps the child running through the displaced turn's
|
||||
// cancellation. The task settles only when the child becomes idle.
|
||||
if (agentManager.getAgent(childAgentId)?.lifecycle !== "idle") return;
|
||||
if (event.event.type === "turn_completed" && hasSeenRunning) {
|
||||
notifySafely("finished");
|
||||
} else if (event.event.type === "turn_canceled" && hasSeenTurn) {
|
||||
notifySafely("was canceled");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.event.type === "permission_requested") {
|
||||
// A permission pause is an intermediate checkpoint. Forget the run
|
||||
// observed before it so an idle state during follow-up startup cannot
|
||||
|
||||
@@ -12,6 +12,8 @@ import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
import type { AgentClient, AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { parseAgentMessage } from "./agent-messages/index.js";
|
||||
|
||||
interface StructuredContent {
|
||||
[key: string]: unknown;
|
||||
@@ -311,6 +313,167 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe("Suite A: Core Fixes", () => {
|
||||
test.each([
|
||||
["Stop", "cancelAgent"],
|
||||
["Reload", "refreshAgent"],
|
||||
] as const)(
|
||||
"%s tells the parent that a watched child was canceled",
|
||||
async (_action, method) => {
|
||||
let releaseChild!: () => void;
|
||||
let childReachedGate!: () => void;
|
||||
const heldTurn = new Promise<void>((resolve) => {
|
||||
releaseChild = resolve;
|
||||
});
|
||||
const childAtGate = new Promise<void>((resolve) => {
|
||||
childReachedGate = resolve;
|
||||
});
|
||||
const parentPrompts: string[] = [];
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn(prompt, config) {
|
||||
if (config.title === "Watching parent" && typeof prompt === "string")
|
||||
parentPrompts.push(prompt);
|
||||
},
|
||||
async beforeTurnComplete(prompt) {
|
||||
if (
|
||||
typeof prompt === "string" &&
|
||||
parseAgentMessage(prompt).text === "Hold this child turn"
|
||||
) {
|
||||
childReachedGate();
|
||||
await heldTurn;
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.11.2",
|
||||
});
|
||||
let mcp: McpClient | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const parent = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: daemon.paseoHome,
|
||||
title: "Watching parent",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
mcp = await createMcpClient(
|
||||
buildExpectedAgentMcpUrl({ host: "127.0.0.1", port: daemon.port, agentId: parent.id }),
|
||||
);
|
||||
const created = await callToolStructured(mcp, "create_agent", {
|
||||
relationship: { kind: "subagent" },
|
||||
workspace: { kind: "current" },
|
||||
title: "Watched child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Hold this child turn",
|
||||
notifyOnFinish: true,
|
||||
settings: { modeId: "full-access" },
|
||||
});
|
||||
const childId = str(created.agentId);
|
||||
await childAtGate;
|
||||
expect(daemon.daemon.agentManager.getAgent(childId)?.lifecycle).toBe("running");
|
||||
const terminalEvents: string[] = [];
|
||||
unsubscribe = daemon.daemon.agentManager.subscribe(
|
||||
(event) => {
|
||||
if (
|
||||
event.type === "agent_stream" &&
|
||||
event.agentId === childId &&
|
||||
(event.event.type === "turn_completed" || event.event.type === "turn_canceled")
|
||||
)
|
||||
terminalEvents.push(event.event.type);
|
||||
},
|
||||
{ agentId: childId, replayState: false },
|
||||
);
|
||||
|
||||
await client[method](childId);
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "parent cancellation notification",
|
||||
check: () => (parentPrompts.length === 1 ? true : null),
|
||||
});
|
||||
const notification = parseAgentMessage(parentPrompts[0]);
|
||||
expect(terminalEvents).toEqual(["turn_canceled"]);
|
||||
expect(notification.source).toEqual({
|
||||
kind: "agent-notification",
|
||||
agentId: childId,
|
||||
title: "Watched child",
|
||||
event: "errored",
|
||||
});
|
||||
expect(notification.text).toContain(`Agent ${childId} (Watched child) was canceled.`);
|
||||
expect(notification.text).not.toContain("finished.");
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "parent notification turn drained",
|
||||
check: () =>
|
||||
daemon.daemon.agentManager.getAgent(parent.id)?.lifecycle === "idle" ? true : null,
|
||||
});
|
||||
expect(parentPrompts).toHaveLength(1);
|
||||
} finally {
|
||||
releaseChild();
|
||||
unsubscribe?.();
|
||||
await mcp?.close();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ outcome: "finished", prompt: "say done and stop", status: "idle" },
|
||||
{ outcome: "errored", prompt: "Emit a turn failure", status: "error" },
|
||||
])(
|
||||
"a watched child still reports $outcome after send_agent_prompt",
|
||||
async ({ outcome, prompt, status }) => {
|
||||
const childId = await createChildAgent({
|
||||
title: "Follow-up child",
|
||||
settings: { modeId: "bypassPermissions" },
|
||||
});
|
||||
try {
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "initial child turn completed",
|
||||
check: () =>
|
||||
daemonHandle.daemon.agentManager.getAgent(childId)?.lifecycle === "idle" ? true : null,
|
||||
});
|
||||
await callToolStructured(agentScopedClient, "send_agent_prompt", {
|
||||
agentId: childId,
|
||||
prompt,
|
||||
notifyOnFinish: true,
|
||||
});
|
||||
const notifications = await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: `${outcome} notification`,
|
||||
check: () => {
|
||||
const items = daemonHandle.daemon.agentManager
|
||||
.getTimeline(parentAgentId)
|
||||
.filter(
|
||||
(item) => item.type === "tool_call" && item.agentMessage?.sender.id === childId,
|
||||
);
|
||||
return items.length > 0 ? items : null;
|
||||
},
|
||||
});
|
||||
expect(notifications).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool_call",
|
||||
agentMessage: {
|
||||
event: outcome,
|
||||
sender: { id: childId, title: "Follow-up child" },
|
||||
text: expect.stringContaining(`(Follow-up child) ${outcome}.`),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(daemonHandle.daemon.agentManager.getAgent(childId)?.lifecycle).toBe(status);
|
||||
} finally {
|
||||
await archiveAgentIfPresent(childId);
|
||||
}
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
test("AGENT_WAIT_TIMEOUT_MS is 30000", () => {
|
||||
expect(AGENT_WAIT_TIMEOUT_MS).toBe(30_000);
|
||||
});
|
||||
|
||||
@@ -4359,6 +4359,10 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
await vi.waitFor(() => expect(finishNotifications()).not.toHaveLength(0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(finishNotifications()).toHaveLength(1);
|
||||
expect(parentClient.sessions[0]!.prompts).toHaveLength(1);
|
||||
expect(parseAgentMessage(parentClient.sessions[0]!.prompts[0])).toMatchObject({
|
||||
source: { kind: "agent-notification", agentId: childId, event: "finished" },
|
||||
});
|
||||
} finally {
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export interface PaseoToolHostDependencies {
|
||||
github?: ForgeService;
|
||||
workspaceGitService?: Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
|
||||
"getSnapshot" | "listWorktrees" | "resolveRepoRoot" | "invalidateWorktrees"
|
||||
>;
|
||||
findWorkspaceIdForCwd?: ArchiveDependencies["findWorkspaceIdForCwd"];
|
||||
listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"];
|
||||
|
||||
@@ -23,6 +23,71 @@ afterEach(async () => {
|
||||
}, 60000);
|
||||
|
||||
describe("file explorer", () => {
|
||||
test.each([
|
||||
{ description: "a long ASCII name", fileName: `${"a".repeat(236)}.txt` },
|
||||
{ description: "an ordinary name", fileName: "notes.txt" },
|
||||
{ description: "a long Unicode name", fileName: `${"文".repeat(76)}.txt` },
|
||||
])("saves text files with $description", async ({ fileName }) => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const created = await ctx.client.createFileEntry({
|
||||
cwd,
|
||||
parentPath: ".",
|
||||
name: fileName,
|
||||
kind: "file",
|
||||
});
|
||||
expect(created).toMatchObject({ path: fileName, success: true, error: null });
|
||||
writeFileSync(path.join(cwd, fileName), "original payload\n");
|
||||
const initial = await ctx.client.readFile(cwd, fileName);
|
||||
|
||||
const saved = await ctx.client.writeFile({
|
||||
cwd,
|
||||
path: fileName,
|
||||
content: "saved payload\n",
|
||||
expectedModifiedAt: initial.modifiedAt,
|
||||
expectedRevision: initial.revision,
|
||||
});
|
||||
expect(saved).toMatchObject({ status: "written", size: 14 });
|
||||
const after = await ctx.client.readFile(cwd, fileName);
|
||||
expect(after.path).toBe(fileName);
|
||||
expect(new TextDecoder().decode(after.bytes)).toBe("saved payload\n");
|
||||
|
||||
const staleSave = await ctx.client.writeFile({
|
||||
cwd,
|
||||
path: fileName,
|
||||
content: "stale payload\n",
|
||||
expectedModifiedAt: initial.modifiedAt,
|
||||
expectedRevision: initial.revision,
|
||||
});
|
||||
expect(staleSave).toMatchObject({
|
||||
status: "conflict",
|
||||
version: {
|
||||
status: "ready",
|
||||
path: fileName,
|
||||
size: after.size,
|
||||
modifiedAt: after.modifiedAt,
|
||||
revision: after.revision,
|
||||
},
|
||||
});
|
||||
const afterConflict = await ctx.client.readFile(cwd, fileName);
|
||||
expect(new TextDecoder().decode(afterConflict.bytes)).toBe("saved payload\n");
|
||||
|
||||
const secondSave = await ctx.client.writeFile({
|
||||
cwd,
|
||||
path: fileName,
|
||||
content: "second payload\n",
|
||||
expectedModifiedAt: afterConflict.modifiedAt,
|
||||
expectedRevision: afterConflict.revision,
|
||||
});
|
||||
expect(secondSave).toMatchObject({ status: "written", size: 15 });
|
||||
const final = await ctx.client.readFile(cwd, fileName);
|
||||
expect(new TextDecoder().decode(final.bytes)).toBe("second payload\n");
|
||||
expect(readdirSync(cwd)).toEqual([fileName]);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
query: "implementation",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, existsSync, rmSync, readFileSync, realpathSync } from "fs";
|
||||
import {
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
mkdirSync,
|
||||
} from "fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
@@ -152,6 +161,78 @@ afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
}, 60000);
|
||||
|
||||
interface GitCommandOptions {
|
||||
cwd: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
async function mergeFeatureIntoBaseWorktree(baseName: string): Promise<void> {
|
||||
const directory = realpathSync(tmpCwd());
|
||||
const base = path.join(directory, baseName);
|
||||
const feature = path.join(directory, "feature");
|
||||
function git({ cwd, args }: GitCommandOptions): string {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" });
|
||||
}
|
||||
try {
|
||||
mkdirSync(base);
|
||||
git({ cwd: base, args: ["init", "-b", "main"] });
|
||||
git({ cwd: base, args: ["config", "user.name", "Paseo Test"] });
|
||||
git({ cwd: base, args: ["config", "user.email", "test@paseo.local"] });
|
||||
git({ cwd: base, args: ["config", "commit.gpgsign", "false"] });
|
||||
git({ cwd: base, args: ["config", "core.autocrlf", "false"] });
|
||||
git({ cwd: base, args: ["commit", "--allow-empty", "-m", "base"] });
|
||||
git({ cwd: base, args: ["worktree", "add", "-b", "feature", feature] });
|
||||
writeFileSync(path.join(feature, "feature.txt"), "feature payload\n");
|
||||
git({ cwd: feature, args: ["add", "feature.txt"] });
|
||||
git({ cwd: feature, args: ["commit", "-m", "feature"] });
|
||||
const featureCommit = git({ cwd: feature, args: ["rev-parse", "HEAD"] }).trim();
|
||||
|
||||
const merged = await ctx.client.checkoutMerge(feature, { baseRef: "main" });
|
||||
expect(merged).toMatchObject({ success: true, error: null });
|
||||
expect(git({ cwd: base, args: ["rev-parse", "HEAD"] }).trim()).toBe(featureCommit);
|
||||
expect(readFileSync(path.join(base, "feature.txt"), "utf8")).toBe("feature payload\n");
|
||||
expect(git({ cwd: feature, args: ["symbolic-ref", "--short", "HEAD"] }).trim()).toBe("feature");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test.skipIf(isPlatform("win32")).each(["base\ncheckout", "base "])(
|
||||
"merges into a base worktree whose path preserves %j",
|
||||
mergeFeatureIntoBaseWorktree,
|
||||
);
|
||||
|
||||
test.each(["base checkout", "中文 checkout"])(
|
||||
"merges into a base worktree named %j",
|
||||
mergeFeatureIntoBaseWorktree,
|
||||
);
|
||||
|
||||
test("merges into a local base branch without another worktree", async () => {
|
||||
const cwd = realpathSync(tmpCwd());
|
||||
function git(args: string[]): string {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" });
|
||||
}
|
||||
try {
|
||||
git(["init", "-b", "main"]);
|
||||
git(["config", "user.name", "Paseo Test"]);
|
||||
git(["config", "user.email", "test@paseo.local"]);
|
||||
git(["config", "commit.gpgsign", "false"]);
|
||||
git(["commit", "--allow-empty", "-m", "base"]);
|
||||
git(["checkout", "-b", "feature"]);
|
||||
writeFileSync(path.join(cwd, "feature.txt"), "feature payload\n");
|
||||
git(["add", "feature.txt"]);
|
||||
git(["commit", "-m", "feature"]);
|
||||
const featureCommit = git(["rev-parse", "HEAD"]).trim();
|
||||
|
||||
const merged = await ctx.client.checkoutMerge(cwd, { baseRef: "main" });
|
||||
expect(merged).toMatchObject({ success: true, error: null });
|
||||
expect(git(["rev-parse", "main"]).trim()).toBe(featureCommit);
|
||||
expect(git(["symbolic-ref", "--short", "HEAD"]).trim()).toBe("feature");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("returns diff for modified file in git repo", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, expect, onTestFinished, test } from "vitest";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
|
||||
// Windows does not preserve trailing spaces in folder names.
|
||||
const testOnPosix = test.skipIf(process.platform === "win32");
|
||||
|
||||
let client: DaemonClient;
|
||||
let repoDir: string;
|
||||
let projectId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-path-spaces-")));
|
||||
onTestFinished(() => rmSync(root, { recursive: true, force: true }));
|
||||
repoDir = join(root, "repo ");
|
||||
execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"user.name=Paseo Test",
|
||||
"-c",
|
||||
"user.email=test@getpaseo.local",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
{ cwd: repoDir, stdio: "pipe" },
|
||||
);
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
onTestFinished(() => daemon.close());
|
||||
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const testClient = client;
|
||||
onTestFinished(() => testClient.close());
|
||||
await client.connect();
|
||||
const added = await client.addProject(repoDir);
|
||||
expect(added.error).toBeNull();
|
||||
expect(added.project?.projectRootPath).toBe(repoDir);
|
||||
projectId = added.project!.projectId;
|
||||
});
|
||||
|
||||
testOnPosix("a project folder ending in a space keeps its Git status", async () => {
|
||||
const opened = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repoDir, projectId },
|
||||
});
|
||||
expect(opened.error).toBeNull();
|
||||
expect(opened.workspace?.workspaceDirectory).toBe(repoDir);
|
||||
const status = await client.getCheckoutStatus(repoDir);
|
||||
expect(status.isGit).toBe(true);
|
||||
expect(status.repoRoot).toBe(repoDir);
|
||||
expect(status.currentBranch).toBe("main");
|
||||
});
|
||||
|
||||
testOnPosix("a project folder ending in a space can create a worktree", async () => {
|
||||
const created = await client.createWorkspace({
|
||||
source: {
|
||||
kind: "worktree",
|
||||
projectId,
|
||||
action: "branch-off",
|
||||
branchName: "feature/spaces",
|
||||
baseBranch: "main",
|
||||
},
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
expect(created.workspace?.gitRuntime?.currentBranch).toBe("feature/spaces");
|
||||
});
|
||||
@@ -489,7 +489,7 @@ export async function writeExplorerFile({
|
||||
|
||||
const temporaryPath = path.join(
|
||||
path.dirname(filePath.resolvedPath),
|
||||
`.${path.basename(filePath.resolvedPath)}.paseo-${randomUUID()}.tmp`,
|
||||
`.paseo-${randomUUID()}.tmp`,
|
||||
);
|
||||
let temporaryHandle: FileHandle | null = null;
|
||||
try {
|
||||
|
||||
@@ -55,6 +55,7 @@ export function createNoopWorkspaceGitService(
|
||||
suggestBranchesForCwd: async () => [],
|
||||
listStashes: async () => [],
|
||||
listWorktrees: async () => [],
|
||||
invalidateWorktrees: () => {},
|
||||
getProjectSlug: async (cwd: string) => {
|
||||
const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd);
|
||||
return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null);
|
||||
|
||||
@@ -246,6 +246,7 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
|
||||
suggestBranchesForCwd: async () => [],
|
||||
listStashes: async () => [],
|
||||
listWorktrees: async () => [],
|
||||
invalidateWorktrees: () => {},
|
||||
getProjectSlug: async (cwd: string) => {
|
||||
const snapshot = createFallbackWorkspaceGitSnapshot(cwd);
|
||||
return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { execFile, execFileSync, spawn } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
|
||||
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import { createWorktree } from "../utils/worktree.js";
|
||||
import {
|
||||
createDaemonTestContext,
|
||||
DaemonClient,
|
||||
@@ -54,6 +57,32 @@ function createGitRepo(): string {
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
async function runCli(args: string[], cwd: string): Promise<string> {
|
||||
const result = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
"--conditions=source",
|
||||
"--import",
|
||||
import.meta.resolve("tsx"),
|
||||
fileURLToPath(new URL("../../../cli/src/index.ts", import.meta.url)),
|
||||
"--host",
|
||||
`127.0.0.1:${ctx.daemon.port}`,
|
||||
...args,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
|
||||
),
|
||||
PASEO_HOME: ctx.daemon.paseoHome,
|
||||
},
|
||||
timeout: 20000,
|
||||
},
|
||||
);
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async function createLocalWorkspace(cwd: string, title: string): Promise<string> {
|
||||
const result = await ctx.client.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
@@ -248,6 +277,78 @@ test("renaming a workspace updates every subscribed client", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["worktree", "workspace"] as const)(
|
||||
"%s archive immediately removes the deleted worktree from a warm CLI listing",
|
||||
async (command) => {
|
||||
const repoDir = createGitRepo();
|
||||
const created = await ctx.client.createWorkspace({
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "cached-worktree",
|
||||
baseBranch: "main",
|
||||
},
|
||||
});
|
||||
const workspace = created.workspace;
|
||||
if (!workspace?.workspaceDirectory) {
|
||||
throw new Error(created.error ?? "Failed to create worktree workspace");
|
||||
}
|
||||
const worktreePath = workspace.workspaceDirectory;
|
||||
const name = path.basename(worktreePath);
|
||||
const before = JSON.parse(await runCli(["worktree", "ls", "--json"], repoDir));
|
||||
expect(before).toEqual([expect.objectContaining({ name, cwd: worktreePath })]);
|
||||
|
||||
await runCli(
|
||||
[command, "archive", command === "worktree" ? name : workspace.id, "--json"],
|
||||
repoDir,
|
||||
);
|
||||
expect(existsSync(worktreePath)).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir }).toString(),
|
||||
).not.toContain(worktreePath);
|
||||
const after = JSON.parse(await runCli(["worktree", "ls", "--json"], repoDir));
|
||||
console.log(
|
||||
`${command} archive: warm listing=${JSON.stringify(before)}\nimmediate listing=${JSON.stringify(after)}`,
|
||||
);
|
||||
expect(after).toEqual([]);
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
test("archiving an unregistered worktree in a bare repo clears the main checkout listing", async () => {
|
||||
const sourceRepo = createGitRepo();
|
||||
const repoRoot = makeTempDir("workspace-archive-bare-");
|
||||
const bareRepo = path.join(repoRoot, "repo.git");
|
||||
const mainCheckout = path.join(repoRoot, "main");
|
||||
execFileSync("git", ["clone", "--bare", sourceRepo, bareRepo], { stdio: "pipe" });
|
||||
execFileSync("git", ["worktree", "add", mainCheckout, "main"], {
|
||||
cwd: bareRepo,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const { worktreePath } = await createWorktree({
|
||||
cwd: mainCheckout,
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "cached-bare-worktree" },
|
||||
worktreeSlug: "cached-bare-worktree",
|
||||
runSetup: false,
|
||||
paseoHome: ctx.daemon.paseoHome,
|
||||
});
|
||||
expect(await activeWorkspaceIds()).toEqual(new Set());
|
||||
const name = path.basename(worktreePath);
|
||||
const before = JSON.parse(await runCli(["worktree", "ls", "--json"], mainCheckout));
|
||||
expect(before).toEqual([expect.objectContaining({ name, cwd: worktreePath })]);
|
||||
|
||||
await runCli(["worktree", "archive", name, "--json"], mainCheckout);
|
||||
expect(existsSync(worktreePath)).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: mainCheckout }).toString(),
|
||||
).not.toContain(worktreePath);
|
||||
const after = JSON.parse(await runCli(["worktree", "ls", "--json"], mainCheckout));
|
||||
console.log(
|
||||
`bare repo archive: warm listing=${JSON.stringify(before)}\nimmediate listing=${JSON.stringify(after)}`,
|
||||
);
|
||||
expect(after).toEqual([]);
|
||||
}, 60000);
|
||||
|
||||
test("archiving the last reference to a worktree removes it from disk regardless of the disk flag", async () => {
|
||||
const repoDir = createGitRepo();
|
||||
|
||||
|
||||
@@ -140,7 +140,8 @@ function createArchiveDeps(input: ArchiveDepsInput): ArchiveTestDependencies {
|
||||
github: createGitHubServiceStub(),
|
||||
workspaceGitService: {
|
||||
getSnapshot: vi.fn(async () => null),
|
||||
} as unknown as Pick<WorkspaceGitService, "getSnapshot">,
|
||||
invalidateWorktrees: () => {},
|
||||
} as unknown as Pick<WorkspaceGitService, "getSnapshot" | "invalidateWorktrees">,
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
@@ -617,28 +618,37 @@ describe("archiveByScope", () => {
|
||||
expect(deps.emitWorkspaceUpdatesForWorkspaceIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("worktree scope removes an owned directory with zero matching records", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records");
|
||||
test.each(["intact", "missing"] as const)(
|
||||
"worktree scope removes an owned directory with zero matching records and %s Git administration",
|
||||
async (gitAdministration) => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records");
|
||||
if (gitAdministration === "missing") {
|
||||
const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
|
||||
cwd: worktree.worktreePath,
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
rmSync(gitDir, { recursive: true, force: true });
|
||||
}
|
||||
const dependencies = createArchiveDeps({ paseoHome, activeWorkspaces: [] });
|
||||
const invalidateWorktrees = vi.fn();
|
||||
dependencies.workspaceGitService.invalidateWorktrees = invalidateWorktrees;
|
||||
|
||||
const result = await archiveByScope(
|
||||
createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [],
|
||||
}),
|
||||
{
|
||||
const result = await archiveByScope(dependencies, {
|
||||
scope: { kind: "worktree", targetPath: worktree.worktreePath },
|
||||
requestId: "req-zero-records",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
assertArchiveResult(result, {
|
||||
archivedWorkspaceIds: [],
|
||||
removedDirectory: true,
|
||||
});
|
||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
||||
});
|
||||
assertArchiveResult(result, {
|
||||
archivedWorkspaceIds: [],
|
||||
removedDirectory: true,
|
||||
});
|
||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
||||
expect(invalidateWorktrees).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
test("marks archiving, emits an upsert carrying the archiving state, then clears it and emits a remove", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface ArchiveDependencies {
|
||||
// Base directory that may hold worktrees across repositories.
|
||||
paseoWorktreesBaseRoot?: string;
|
||||
github: ForgeService;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "invalidateWorktrees">;
|
||||
agentManager: Pick<AgentManager, "listAgents" | "getAgent" | "archiveAgent" | "archiveSnapshot">;
|
||||
agentStorage: Pick<AgentStorage, "listByWorkspace">;
|
||||
// Resolves the worktree at a path to its workspaceId for archive-by-path. The
|
||||
@@ -408,6 +408,7 @@ async function maybeRemoveDirectory(
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
|
||||
});
|
||||
dependencies.workspaceGitService.invalidateWorktrees();
|
||||
dependencies.github.invalidate({ cwd: backing.path });
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1819,6 +1819,46 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("invalidating worktrees discards warm listings across checkout roots and in-flight reads", async () => {
|
||||
const worktrees = [
|
||||
{
|
||||
path: "/tmp/paseo-home/worktrees/repo/feature",
|
||||
createdAt: "2026-04-12T00:00:00.000Z",
|
||||
branchName: "feature",
|
||||
},
|
||||
];
|
||||
const readStarted = createDeferred<void>();
|
||||
const oldRead = createDeferred<typeof worktrees>();
|
||||
const alternateCheckout = resolvePath("/tmp/alternate-checkout");
|
||||
let reads = 0;
|
||||
const service = createService({
|
||||
listPaseoWorktrees: async () => {
|
||||
reads += 1;
|
||||
if (reads <= 2) return worktrees;
|
||||
if (reads === 3) {
|
||||
readStarted.resolve();
|
||||
return oldRead.promise;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
});
|
||||
try {
|
||||
await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual(worktrees);
|
||||
await expect(service.listWorktrees(alternateCheckout)).resolves.toEqual(worktrees);
|
||||
const pending = service.listWorktrees(REPO_CWD, { force: true, reason: "test" });
|
||||
await readStarted.promise;
|
||||
service.invalidateWorktrees();
|
||||
await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual([]);
|
||||
await expect(service.listWorktrees(alternateCheckout)).resolves.toEqual([]);
|
||||
oldRead.resolve(worktrees);
|
||||
await expect(pending).resolves.toEqual(worktrees);
|
||||
await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual([]);
|
||||
} finally {
|
||||
oldRead.resolve(worktrees);
|
||||
await service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("listWorktrees shares one repo-root scoped read across sibling workspace cwds", async () => {
|
||||
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-")));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
|
||||
@@ -241,6 +241,7 @@ export interface WorkspaceGitService {
|
||||
cwdOrRepoRoot: string,
|
||||
options?: WorkspaceGitReadOptions,
|
||||
): Promise<WorkspaceGitWorktreeInfo[]>;
|
||||
invalidateWorktrees(): void;
|
||||
getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||
resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||
resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||
@@ -918,6 +919,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
: (snapshot.git.repoRoot ?? resolve(cwd));
|
||||
}
|
||||
|
||||
invalidateWorktrees(): void {
|
||||
this.assertNotDisposed();
|
||||
// Checkout paths can cache the same list under different roots, including bare repos.
|
||||
// Detach in-flight reads too, so a pre-mutation result cannot refill the cache.
|
||||
this.worktreeListCache.clear();
|
||||
}
|
||||
|
||||
async resolveDefaultBranch(
|
||||
cwdOrRepoRoot: string,
|
||||
options?: WorkspaceGitReadOptions,
|
||||
|
||||
@@ -2017,6 +2017,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
|
||||
workspaceGitService: {
|
||||
getSnapshot: vi.fn(async () => null),
|
||||
listWorktrees: vi.fn(async () => []),
|
||||
invalidateWorktrees: () => {},
|
||||
},
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
@@ -2088,6 +2089,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
|
||||
workspaceGitService: {
|
||||
getSnapshot: vi.fn(async () => null),
|
||||
listWorktrees: vi.fn(async () => []),
|
||||
invalidateWorktrees: () => {},
|
||||
},
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
@@ -2164,6 +2166,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
|
||||
workspaceGitService: {
|
||||
getSnapshot: vi.fn(async () => null),
|
||||
listWorktrees: vi.fn(async () => []),
|
||||
invalidateWorktrees: () => {},
|
||||
},
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
@@ -2240,6 +2243,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
|
||||
workspaceGitService: {
|
||||
getSnapshot: vi.fn(async () => null),
|
||||
listWorktrees: vi.fn(async () => []),
|
||||
invalidateWorktrees: () => {},
|
||||
},
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
|
||||
@@ -501,7 +501,10 @@ export async function handlePaseoWorktreeArchiveRequest(
|
||||
"emitWorkspaceUpdatesForWorkspaceIds" | "workspaceGitService"
|
||||
> & {
|
||||
emit: EmitSessionMessage;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
|
||||
workspaceGitService: Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees" | "invalidateWorktrees"
|
||||
>;
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
|
||||
},
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
|
||||
|
||||
@@ -93,7 +93,10 @@ export interface ArchiveCommandDependencies extends Omit<
|
||||
ArchiveDependencies,
|
||||
"workspaceGitService"
|
||||
> {
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
|
||||
workspaceGitService: Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees" | "invalidateWorktrees"
|
||||
>;
|
||||
}
|
||||
|
||||
export interface ArchiveCommandInput {
|
||||
|
||||
@@ -1187,13 +1187,16 @@ export function parseWorktreeList(output: string): GitWorktreeEntry[] {
|
||||
|
||||
async function getWorktreePathForBranch(cwd: string, branchName: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const entries = parseWorktreeList(stdout);
|
||||
const ref = branchName.startsWith("refs/heads/") ? branchName : `refs/heads/${branchName}`;
|
||||
return entries.find((entry) => entry.branchRef === ref)?.path ?? null;
|
||||
const { stdout } = await runGitCommand(
|
||||
["for-each-ref", "--format=%(refname)%00%(worktreepath)%00", ref],
|
||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
for (const record of stdout.split("\0\n")) {
|
||||
const [reference, worktreePath] = record.split("\0");
|
||||
if (reference === ref) return worktreePath || null;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
|
||||
describe("Git path output", () => {
|
||||
it.each(["\n", "\r\n", ""])("preserves path whitespace with delimiter %j", (delimiter) => {
|
||||
expect(parseGitRevParsePath(`/repo ${delimiter}`)).toBe("/repo ");
|
||||
expect(parseGitRevParsePath(` relative path ${delimiter}`)).toBe(" relative path ");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"\n",
|
||||
"\r\n",
|
||||
"--path-format=absolute\n",
|
||||
"--path-format=absolute\n/repo\n",
|
||||
"/repo\n/another\n",
|
||||
"/repo\n\n",
|
||||
])("rejects invalid output %j", (output) => {
|
||||
expect(parseGitRevParsePath(output)).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves a relative path without stripping its trailing space", () => {
|
||||
expect(resolveGitRevParsePath("/base", "repo \n")).toBe(resolve("/base", "repo "));
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,9 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export function parseGitRevParsePath(stdout: string): string | null {
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = trimmed.split(/\r?\n/);
|
||||
if (lines.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = lines[0]?.trim() ?? "";
|
||||
if (!path || path.startsWith("--")) {
|
||||
// Remove Git's record delimiter, not whitespace belonging to the path.
|
||||
const path = stdout.replace(/\r?\n$/, "");
|
||||
if (!path || /[\r\n]/.test(path) || path.startsWith("--")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,10 +126,15 @@ async function runFixture(params: {
|
||||
command: string;
|
||||
args: string[];
|
||||
shell?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<SpawnResult> {
|
||||
const child = spawnProcess(params.command, params.args, {
|
||||
env: {
|
||||
...process.env,
|
||||
...params.env,
|
||||
// Terminal color settings must not add Node warnings to the argv fixture's stderr.
|
||||
NO_COLOR: undefined,
|
||||
FORCE_COLOR: undefined,
|
||||
PASEO_EXPECTED_ARGV_JSON: JSON.stringify(["--config", JSON_ARG]),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
@@ -216,6 +221,8 @@ async function findAndLaunch(fixture: LaunchFixture): Promise<{
|
||||
const child = spawnProcess(found, fixture.args, {
|
||||
env: {
|
||||
...process.env,
|
||||
NO_COLOR: undefined,
|
||||
FORCE_COLOR: undefined,
|
||||
PASEO_EXPECTED_ARGV_JSON: fixture.expectedArgvJson,
|
||||
PASEO_ARGV_SLICE_FROM: String(fixture.sliceFrom),
|
||||
},
|
||||
@@ -331,18 +338,25 @@ describe.runIf(isPlatform("win32"))("Windows spawn launch regression", () => {
|
||||
});
|
||||
|
||||
describe.skipIf(isPlatform("win32"))("spawn launch regression smoke", () => {
|
||||
test("direct launch with a space-containing executable works on this platform", async () => {
|
||||
const fixture = makeFixture();
|
||||
test.each([
|
||||
{ colors: "inherited", env: {} },
|
||||
{ colors: "conflicting", env: { NO_COLOR: "1", FORCE_COLOR: "1" } },
|
||||
])(
|
||||
"direct launch with a space-containing executable works with $colors colors",
|
||||
async ({ env }) => {
|
||||
const fixture = makeFixture();
|
||||
|
||||
const result = await runFixture({
|
||||
command: fixture.fakeDaemonNode,
|
||||
args: [fixture.assertScript, ...fixture.expectedArgs],
|
||||
shell: false,
|
||||
});
|
||||
const result = await runFixture({
|
||||
command: fixture.fakeDaemonNode,
|
||||
args: [fixture.assertScript, ...fixture.expectedArgs],
|
||||
shell: false,
|
||||
env,
|
||||
});
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout.trim()).toBe("ARGV_OK");
|
||||
});
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout.trim()).toBe("ARGV_OK");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user