mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
* Run server tests on Linux+Windows matrix Replace the hand-curated Windows server test allow-list with a full-suite matrix run.\nBoth Ubuntu and Windows now run the same server test command with shared setup and secrets. * Add isPlatform helper and gate Windows-hostile server tests - Add a shared server test isPlatform helper. - Migrate existing Windows-gated spawn, worktree, executable, and worktree-core tests to the helper. - Gate Windows-hostile symlink, macOS path-normalization, and POSIX shell setup tests with skipIf. * Replace POSIX shell calls in test fixtures with Node primitives - Replaced test fixture mkdir/echo shell setup with fs mkdirSync/writeFileSync calls in touched server tests. - Replaced git shell strings with execFileSync/spawnSync argv calls across checkout, worktree, MCP, script, and workspace git fixtures. - Gated the directory suggestion symlink escape tests on Windows because those fixtures require POSIX symlink behavior. * Fix Windows server-test failures and split POSIX-only suites into sibling files - Fix Windows path/cwd assertions in terminal, session/workspace, git-service, checkout-git, MCP, logger, spawn, and registry bootstrap tests.\n- Keep terminal tests runnable on Windows by canonicalizing temp cwd fixtures and ensuring a Windows shell fallback.\n- Gate POSIX-only shell, signal, Unix socket, and git-worktree reuse fixtures that need dedicated Windows coverage later. * Move POSIX-only test blocks into sibling .posix.test.ts files - terminal.test.ts: moved PTY/bash interaction blocks into terminal.posix.test.ts. - worktree.test.ts: moved git-worktree and teardown shell blocks into worktree.posix.test.ts. - worktree-bootstrap.test.ts: moved setup shell and terminal-backed service blocks into worktree-bootstrap.posix.test.ts. - worktree-core.test.ts: moved the POSIX-only worktree-core suite into worktree-core.posix.test.ts and removed the empty original. - provider-availability.test.ts and file-explorer/service.test.ts: moved POSIX PATH/symlink blocks into sibling suites. * Fix Windows 8.3 short-name, EBUSY cleanup, and node-pty shell-path failures in server tests - Normalize temp home directories in directory suggestion assertions to avoid Windows short-name mismatches. - Use Windows-valid terminal shells/cwds in terminal fixtures and wait for terminal-manager PTYs before cleanup. - Replace hardcoded POSIX paths in workspace-git/MCP/loop fixtures with platform-resolved paths or command files. - Gate the two explicitly Linux-only workspace-git watcher tests. * Replace hardcoded POSIX paths with portable Node path constructions in server tests - checkout-git.test.ts and worktree-session.test.ts: compare Windows temp paths with realpathSync.native to avoid 8.3 short-name mismatches. - directory-suggestions.test.ts: canonicalize result and expected paths with realpathSync.native. - session.workspaces.test.ts: replace literal /tmp and /Users fixtures with path.resolve/path.join constructions. - workspace-git-service.primitive.test.ts, loop-service.test.ts, and mcp-server.test.ts: use canonical repo/temp paths and shell-safe relative verify commands. * Fix loop-service verify-check shell and workspace-git-service path-separator on Windows - Run the loop-service verify script through the current Node executable with a relative script path. - Normalize the workspace-git-service expected repo cwd to forward slashes for the listWorktrees assertion. * Fix loop-service worker PTY spawn on Windows - Canonicalize the loop-service temporary root and workspace with realpathSync.native before worker agents use the path as cwd. * Gate loop-service real-worker-PTY test on Windows - Skip the real worker PTY loop test on Windows after ConPTY path resolution still fails with node-pty error 267.\n- Keep the test running on POSIX so the loop behavior remains covered. * Stub getMetricsSnapshot in test mocks to suppress async-leak uncaught exceptions on Windows - Add a no-op AgentManager metrics snapshot to the WebSocket notification test server stub. * Fix terminal-manager Windows-path test to avoid PTY spawn into nonexistent dir - Use an existing temporary cwd for the createTerminal absolute-path validation assertion so node-pty does not spawn into a missing Windows directory.
270 lines
8.5 KiB
TypeScript
270 lines
8.5 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
createWorktree as createWorktreePrimitive,
|
|
deriveWorktreeProjectHash,
|
|
deletePaseoWorktree,
|
|
isPaseoOwnedWorktreeCwd,
|
|
slugify,
|
|
type CreateWorktreeOptions,
|
|
type WorktreeConfig,
|
|
} from "./worktree";
|
|
import { execFileSync } from "child_process";
|
|
import { mkdtempSync, mkdirSync, rmSync, existsSync, realpathSync, writeFileSync } from "fs";
|
|
import { join } from "path";
|
|
import { tmpdir } from "os";
|
|
|
|
interface LegacyCreateWorktreeTestOptions {
|
|
branchName: string;
|
|
cwd: string;
|
|
baseBranch: string;
|
|
worktreeSlug: string;
|
|
runSetup?: boolean;
|
|
paseoHome?: string;
|
|
}
|
|
|
|
function createLegacyWorktreeForTest(
|
|
options: CreateWorktreeOptions | LegacyCreateWorktreeTestOptions,
|
|
): Promise<WorktreeConfig> {
|
|
if ("source" in options) {
|
|
return createWorktreePrimitive(options);
|
|
}
|
|
|
|
return createWorktreePrimitive({
|
|
cwd: options.cwd,
|
|
worktreeSlug: options.worktreeSlug,
|
|
source: {
|
|
kind: "branch-off",
|
|
baseBranch: options.baseBranch,
|
|
branchName: options.branchName,
|
|
},
|
|
runSetup: options.runSetup ?? true,
|
|
paseoHome: options.paseoHome,
|
|
});
|
|
}
|
|
|
|
describe("paseo worktree manager", () => {
|
|
let tempDir: string;
|
|
let repoDir: string;
|
|
let paseoHome: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-manager-test-")));
|
|
repoDir = join(tempDir, "test-repo");
|
|
paseoHome = join(tempDir, "paseo-home");
|
|
|
|
mkdirSync(repoDir, { recursive: true });
|
|
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "file.txt"), "hello\n");
|
|
execFileSync("git", ["add", "."], { cwd: repoDir });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
|
cwd: repoDir,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {
|
|
const created = await createLegacyWorktreeForTest({
|
|
branchName: "orphan-admin-branch",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "orphan-admin",
|
|
paseoHome,
|
|
});
|
|
|
|
// Simulate a previous archive attempt that removed git's admin dir but left
|
|
// the working tree on disk (e.g. because file churn prevented full cleanup).
|
|
rmSync(join(repoDir, ".git", "worktrees", "orphan-admin"), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
expect(existsSync(created.worktreePath)).toBe(true);
|
|
|
|
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
|
|
expect(ownership.allowed).toBe(true);
|
|
});
|
|
|
|
it("rejects paths that are not under the paseo worktrees root", async () => {
|
|
const outsidePath = join(tempDir, "outside-paseo-home");
|
|
mkdirSync(outsidePath, { recursive: true });
|
|
|
|
const ownership = await isPaseoOwnedWorktreeCwd(outsidePath, { paseoHome });
|
|
|
|
expect(ownership.allowed).toBe(false);
|
|
});
|
|
|
|
it("rejects the worktrees root itself and the per-repo hash dir", async () => {
|
|
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
|
const worktreesRoot = join(paseoHome, "worktrees");
|
|
const projectHashDir = join(worktreesRoot, projectHash);
|
|
mkdirSync(projectHashDir, { recursive: true });
|
|
|
|
await expect(isPaseoOwnedWorktreeCwd(worktreesRoot, { paseoHome })).resolves.toMatchObject({
|
|
allowed: false,
|
|
});
|
|
await expect(isPaseoOwnedWorktreeCwd(projectHashDir, { paseoHome })).resolves.toMatchObject({
|
|
allowed: false,
|
|
});
|
|
});
|
|
|
|
it("deletes a worktree whose .git admin dir has already been removed", async () => {
|
|
const created = await createLegacyWorktreeForTest({
|
|
branchName: "orphan-delete-branch",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "orphan-delete",
|
|
paseoHome,
|
|
});
|
|
|
|
rmSync(join(repoDir, ".git", "worktrees", "orphan-delete"), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
expect(existsSync(created.worktreePath)).toBe(true);
|
|
|
|
await deletePaseoWorktree({
|
|
cwd: repoDir,
|
|
worktreePath: created.worktreePath,
|
|
paseoHome,
|
|
});
|
|
|
|
expect(existsSync(created.worktreePath)).toBe(false);
|
|
});
|
|
|
|
it("is idempotent: deleting an already-absent worktree succeeds", async () => {
|
|
const created = await createLegacyWorktreeForTest({
|
|
branchName: "idempotent-delete-branch",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "idempotent-delete",
|
|
paseoHome,
|
|
});
|
|
|
|
await deletePaseoWorktree({
|
|
cwd: repoDir,
|
|
worktreePath: created.worktreePath,
|
|
paseoHome,
|
|
});
|
|
expect(existsSync(created.worktreePath)).toBe(false);
|
|
|
|
// Second call — nothing left on disk and no admin entry — must not throw.
|
|
await expect(
|
|
deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("deletes a worktree when the parent repo root is not available", async () => {
|
|
const created = await createLegacyWorktreeForTest({
|
|
branchName: "no-cwd-branch",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "no-cwd",
|
|
paseoHome,
|
|
});
|
|
|
|
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
|
|
expect(ownership.allowed).toBe(true);
|
|
expect(ownership.worktreeRoot).toBeTruthy();
|
|
|
|
// Simulate the handler path when git has forgotten about the worktree:
|
|
// caller forwards the path-derived worktreesRoot from the ownership check.
|
|
await deletePaseoWorktree({
|
|
cwd: null,
|
|
worktreePath: created.worktreePath,
|
|
worktreesRoot: ownership.worktreeRoot,
|
|
paseoHome,
|
|
});
|
|
|
|
expect(existsSync(created.worktreePath)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("slugify", () => {
|
|
function expectValidHostnameLabel(label: string): void {
|
|
expect(label.length).toBeGreaterThan(0);
|
|
expect(label.length).toBeLessThanOrEqual(63);
|
|
expect(label).toMatch(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
|
|
}
|
|
|
|
it("converts to lowercase kebab-case", () => {
|
|
expect(slugify("Hello World")).toBe("hello-world");
|
|
expect(slugify("FOO_BAR")).toBe("foo-bar");
|
|
expect(slugify("My GREAT App")).toBe("my-great-app");
|
|
});
|
|
|
|
it("replaces dots with hyphens", () => {
|
|
expect(slugify("my.app")).toBe("my-app");
|
|
expect(slugify("v1.2.3")).toBe("v1-2-3");
|
|
});
|
|
|
|
it("collapses multiple consecutive spaces to one hyphen", () => {
|
|
expect(slugify("feature cool stuff")).toBe("feature-cool-stuff");
|
|
});
|
|
|
|
it("replaces slashes with hyphens", () => {
|
|
expect(slugify("feature/cool stuff")).toBe("feature-cool-stuff");
|
|
expect(slugify("owner/repo")).toBe("owner-repo");
|
|
});
|
|
|
|
it("strips unsupported unicode characters", () => {
|
|
expect(slugify("café")).toBe("caf");
|
|
expect(slugify("日本語")).toBe("");
|
|
});
|
|
|
|
it("removes leading and trailing punctuation", () => {
|
|
expect(slugify("-foo-")).toBe("foo");
|
|
expect(slugify("__bar__")).toBe("bar");
|
|
expect(slugify(".baz.")).toBe("baz");
|
|
});
|
|
|
|
it("truncates long strings at word boundary", () => {
|
|
const longInput =
|
|
"https-stackoverflow-com-questions-68349031-only-run-actions-on-non-draft-pull-request";
|
|
const result = slugify(longInput);
|
|
expect(result.length).toBeLessThanOrEqual(50);
|
|
expectValidHostnameLabel(result);
|
|
expect(result).toBe("https-stackoverflow-com-questions-68349031-only");
|
|
});
|
|
|
|
it("truncates without trailing hyphen when no word boundary", () => {
|
|
const longInput = "a".repeat(60);
|
|
const result = slugify(longInput);
|
|
expect(result.length).toBe(50);
|
|
expect(result.endsWith("-")).toBe(false);
|
|
expectValidHostnameLabel(result);
|
|
});
|
|
|
|
it("keeps very long names within the hostname label length limit", () => {
|
|
const result = slugify("Beta Build ".repeat(12));
|
|
|
|
expect(result.length).toBeLessThanOrEqual(63);
|
|
expectValidHostnameLabel(result);
|
|
});
|
|
|
|
it("returns empty when names collapse to empty", () => {
|
|
expect(slugify("---")).toBe("");
|
|
expect(slugify("***")).toBe("");
|
|
expect(slugify("日本語")).toBe("");
|
|
});
|
|
|
|
it("is idempotent for representative inputs", () => {
|
|
const inputs = [
|
|
"my.app",
|
|
"feature/cool stuff",
|
|
" Café Launch ",
|
|
"__bar__",
|
|
"Beta Build ".repeat(12),
|
|
"release***candidate",
|
|
];
|
|
|
|
for (const input of inputs) {
|
|
const slug = slugify(input);
|
|
expect(slugify(slug)).toBe(slug);
|
|
}
|
|
});
|
|
});
|