mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
* perf(workspaces): stop duplicating setup output in progress snapshots Keep the required combined log field empty for setup clients, which display per-command output. Preserve concatenation only for legacy agent bootstrap timeline cards whose released renderers still read it directly. * refactor(workspaces): own setup exclusively at the workspace boundary Preserve keyed modern agent worktree creation while retiring legacy placement that selected an agent-owned setup lifecycle. Keep output in per-command workspace snapshots and retain empty strings required by existing wire schemas. * fix(server): avoid duplicate cwd in worktree provisioning request * test(e2e): keep developer toasts out of concurrent app assertions Concurrent layout measurements could invoke the same Playwright locator handler before its first dismissal finished, leaving checks waiting for a removed button. Hide only the Expo toast host through test styling while retaining console error capture.
525 lines
18 KiB
TypeScript
525 lines
18 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
createWorktree as createWorktreePrimitive,
|
|
deriveWorktreeProjectHash,
|
|
deletePaseoWorktree,
|
|
isPaseoOwnedWorktreeCwd,
|
|
mapWorkspaceCwdToWorktree,
|
|
slugify,
|
|
type CreateWorktreeOptions,
|
|
type WorktreeConfig,
|
|
} from "./worktree";
|
|
import { execFileSync } from "child_process";
|
|
import { rm } from "node:fs/promises";
|
|
import {
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
rmSync,
|
|
existsSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "fs";
|
|
import { join } from "path";
|
|
import { createServer, type AddressInfo, type Socket } from "net";
|
|
import { tmpdir } from "os";
|
|
import { createRealpathAwarePathMatcher } from "./path";
|
|
|
|
interface LegacyCreateWorktreeTestOptions {
|
|
branchName: string;
|
|
cwd: string;
|
|
baseBranch: string;
|
|
worktreeSlug: string;
|
|
|
|
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,
|
|
},
|
|
|
|
paseoHome: options.paseoHome,
|
|
});
|
|
}
|
|
|
|
// A remote that accepts connections and never answers, like a VPN-only host while off the VPN.
|
|
async function startSilentRemote(): Promise<{ url: string; close: () => Promise<void> }> {
|
|
const connections = new Set<Socket>();
|
|
const server = createServer((socket) => connections.add(socket));
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const { port } = server.address() as AddressInfo;
|
|
return {
|
|
url: `http://127.0.0.1:${port}/repo.git`,
|
|
close: () => {
|
|
for (const socket of connections) socket.destroy();
|
|
return new Promise<void>((resolve) => server.close(() => resolve()));
|
|
},
|
|
};
|
|
}
|
|
|
|
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(async () => {
|
|
await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
});
|
|
|
|
it.each([
|
|
{ existing: [], preferred: "main/patch-1", expected: "main-1/patch-1" },
|
|
{ existing: ["main-1"], preferred: "main/patch-1", expected: "main-2/patch-1" },
|
|
{
|
|
existing: ["main-1/patch-1"],
|
|
preferred: "main/patch-1",
|
|
expected: "main-1/patch-1-2",
|
|
},
|
|
{
|
|
existing: ["bob/patch-1/child"],
|
|
preferred: "bob/patch-1",
|
|
expected: "bob/patch-1-1",
|
|
},
|
|
])(
|
|
"checks out a fork when $preferred conflicts with existing branch paths",
|
|
async ({ existing, preferred, expected }) => {
|
|
const git = (...args: string[]) =>
|
|
execFileSync("git", args, { cwd: repoDir, encoding: "utf8", stdio: "pipe" }).trim();
|
|
git("config", "core.autocrlf", "false");
|
|
const baseHead = git("rev-parse", "main");
|
|
for (const branch of existing) git("branch", branch);
|
|
git("checkout", "-b", "fixture-pr");
|
|
writeFileSync(join(repoDir, "file.txt"), "from-pr\n");
|
|
git("add", "file.txt");
|
|
git("-c", "commit.gpgsign=false", "commit", "-m", "fork patch");
|
|
const prHead = git("rev-parse", "HEAD");
|
|
git("checkout", "main");
|
|
const remote = join(tempDir, "origin.git");
|
|
git("clone", "--bare", repoDir, remote);
|
|
git("remote", "add", "origin", remote);
|
|
git(`--git-dir=${remote}`, "update-ref", "refs/pull/1/head", prHead);
|
|
git("pack-refs", "--all");
|
|
|
|
const result = await createWorktreePrimitive({
|
|
cwd: repoDir,
|
|
paseoHome,
|
|
worktreeSlug: "fork-review",
|
|
|
|
source: {
|
|
kind: "checkout-change-request",
|
|
forge: "gitea",
|
|
changeRequestNumber: 1,
|
|
headRef: "patch-1",
|
|
localBranchName: preferred,
|
|
baseRefName: "main",
|
|
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/1/head" }],
|
|
},
|
|
});
|
|
|
|
expect(result.branchName).toBe(expected);
|
|
expect(git("rev-parse", result.branchName)).toBe(prHead);
|
|
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n");
|
|
for (const branch of ["main", ...existing]) {
|
|
expect(git("rev-parse", branch)).toBe(baseHead);
|
|
}
|
|
},
|
|
);
|
|
|
|
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);
|
|
await expect(
|
|
isPaseoOwnedWorktreeCwd(join(created.worktreePath, "packages", "app"), { paseoHome }),
|
|
).resolves.toMatchObject({
|
|
allowed: true,
|
|
worktreePath: created.worktreePath,
|
|
});
|
|
});
|
|
|
|
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("reports the source checkout root separately from Git's common directory", async () => {
|
|
const created = await createLegacyWorktreeForTest({
|
|
branchName: "placement-root-branch",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "placement-root",
|
|
paseoHome,
|
|
});
|
|
|
|
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
|
|
|
|
expect(ownership.allowed).toBe(true);
|
|
expect(createRealpathAwarePathMatcher(repoDir)(ownership.repoRoot ?? "")).toBe(true);
|
|
expect(createRealpathAwarePathMatcher(created.worktreePath)(ownership.worktreePath ?? "")).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("maps only root-contained workspace paths into a replacement worktree", () => {
|
|
const sourceWorktreePath = join(tempDir, "source-worktree");
|
|
const targetWorktreePath = join(tempDir, "target-worktree");
|
|
const nestedWorkspaceCwd = join(sourceWorktreePath, "packages", "app");
|
|
|
|
expect(
|
|
mapWorkspaceCwdToWorktree({
|
|
sourceWorktreePath,
|
|
workspaceCwd: sourceWorktreePath,
|
|
targetWorktreePath,
|
|
}),
|
|
).toBe(targetWorktreePath);
|
|
expect(
|
|
mapWorkspaceCwdToWorktree({
|
|
sourceWorktreePath,
|
|
workspaceCwd: nestedWorkspaceCwd,
|
|
targetWorktreePath,
|
|
}),
|
|
).toBe(join(targetWorktreePath, "packages", "app"));
|
|
expect(() =>
|
|
mapWorkspaceCwdToWorktree({
|
|
sourceWorktreePath,
|
|
workspaceCwd: join(tempDir, "outside-worktree"),
|
|
targetWorktreePath,
|
|
}),
|
|
).toThrow("outside its source worktree");
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"maps a realpath-equivalent source workspace into the matching target subdirectory",
|
|
() => {
|
|
const sourceWorktreePath = join(tempDir, "source-worktree");
|
|
const workspaceCwd = join(sourceWorktreePath, "packages", "app");
|
|
const sourceAlias = join(tempDir, "source-alias");
|
|
const targetWorktreePath = join(tempDir, "target-worktree");
|
|
mkdirSync(workspaceCwd, { recursive: true });
|
|
symlinkSync(sourceWorktreePath, sourceAlias, "dir");
|
|
|
|
expect(
|
|
mapWorkspaceCwdToWorktree({
|
|
sourceWorktreePath: sourceAlias,
|
|
workspaceCwd,
|
|
targetWorktreePath,
|
|
}),
|
|
).toBe(join(targetWorktreePath, "packages", "app"));
|
|
},
|
|
);
|
|
|
|
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("branch-off from a remote-tracking base", () => {
|
|
function git(args: string[], cwd: string): string {
|
|
return execFileSync("git", ["-c", "commit.gpgsign=false", ...args], {
|
|
cwd,
|
|
encoding: "utf8",
|
|
}).trim();
|
|
}
|
|
|
|
// The repo caches <remote>/main at "initial" while the remote itself moves one commit ahead,
|
|
// as when nothing has fetched since someone else pushed.
|
|
function pushPastCachedRemoteRef(remoteName: string): string {
|
|
const remoteDir = join(tempDir, "remote.git");
|
|
git(["clone", "--bare", "-q", repoDir, remoteDir], tempDir);
|
|
git(["remote", "add", remoteName, remoteDir], repoDir);
|
|
git(["fetch", "-q", remoteName], repoDir);
|
|
|
|
const pusherDir = join(tempDir, "pusher");
|
|
git(["clone", "-q", remoteDir, pusherDir], tempDir);
|
|
git(["config", "user.email", "test@test.com"], pusherDir);
|
|
git(["config", "user.name", "Test"], pusherDir);
|
|
writeFileSync(join(pusherDir, "file.txt"), "pushed after the last fetch\n");
|
|
git(["commit", "-q", "-am", "remote ahead"], pusherDir);
|
|
git(["push", "-q", "origin", "HEAD:main"], pusherDir);
|
|
return git(["rev-parse", "HEAD"], pusherDir);
|
|
}
|
|
|
|
it("starts the new branch at the remote tip, not the cached origin/main", async () => {
|
|
const remoteTip = pushPastCachedRemoteRef("origin");
|
|
const fetchRefspecs = git(["config", "--get-all", "remote.origin.fetch"], repoDir);
|
|
|
|
const created = await createLegacyWorktreeForTest({
|
|
branchName: "from-remote-tip",
|
|
cwd: repoDir,
|
|
baseBranch: "origin/main",
|
|
worktreeSlug: "from-remote-tip",
|
|
paseoHome,
|
|
});
|
|
|
|
expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(remoteTip);
|
|
expect(git(["config", "--get-all", "remote.origin.fetch"], repoDir)).toBe(fetchRefspecs);
|
|
});
|
|
|
|
it("refreshes a base on a remote other than origin", async () => {
|
|
const remoteTip = pushPastCachedRemoteRef("team/upstream");
|
|
|
|
const created = await createLegacyWorktreeForTest({
|
|
branchName: "from-upstream-tip",
|
|
cwd: repoDir,
|
|
baseBranch: "refs/remotes/team/upstream/main",
|
|
worktreeSlug: "from-upstream-tip",
|
|
paseoHome,
|
|
});
|
|
|
|
expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(remoteTip);
|
|
});
|
|
|
|
it("branches from the cached ref when the remote is unreachable", async () => {
|
|
pushPastCachedRemoteRef("origin");
|
|
const cachedTip = git(["rev-parse", "refs/remotes/origin/main"], repoDir);
|
|
git(["remote", "set-url", "origin", join(tempDir, "missing.git")], repoDir);
|
|
|
|
const created = await createLegacyWorktreeForTest({
|
|
branchName: "from-cached-ref",
|
|
cwd: repoDir,
|
|
baseBranch: "origin/main",
|
|
worktreeSlug: "from-cached-ref",
|
|
paseoHome,
|
|
});
|
|
|
|
expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(cachedTip);
|
|
});
|
|
|
|
it("branches from the cached ref when the remote accepts but never answers", async () => {
|
|
pushPastCachedRemoteRef("origin");
|
|
const cachedTip = git(["rev-parse", "refs/remotes/origin/main"], repoDir);
|
|
const silentRemote = await startSilentRemote();
|
|
git(["remote", "set-url", "origin", silentRemote.url], repoDir);
|
|
|
|
try {
|
|
const startedAt = Date.now();
|
|
const created = await createLegacyWorktreeForTest({
|
|
branchName: "from-silent-remote",
|
|
cwd: repoDir,
|
|
baseBranch: "origin/main",
|
|
worktreeSlug: "from-silent-remote",
|
|
paseoHome,
|
|
});
|
|
|
|
// Clients give up on a create request after 60s, so the fallback has to land well before.
|
|
expect(Date.now() - startedAt).toBeLessThan(30_000);
|
|
expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(cachedTip);
|
|
} finally {
|
|
await silentRemote.close();
|
|
}
|
|
}, 45_000);
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
});
|