This repository has been archived on 2026-08-23. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
pi-web/src/server/sessions/piSessionService.crossWorkspace.test.ts
T
Federico Jaramillo Martinez 31c3cfe43e test(sessions): resolve POSIX fixture paths that are drive-relative on Windows
Five tests failed only on the Windows runner. Their fixtures used bare
POSIX-absolute paths such as /srv/other-worktree and /old-project, which
win32 treats as absolute but drive-relative: resolve() maps them onto the
runner's current drive (D:\...). The code under test canonicalizes stored
cwds by contract, so assertions comparing against the raw fixture string
never matched on Windows:

- parentSessionLocator and crossWorkspace listings annotate
  parentSessionCwd with canonicalizeStoredCwd(header.cwd);
- cleanup forgets unread via canonicalizeStoredCwd(record.cwd), which
  missed the marker the test seeded with the raw path.

Resolve the fixture paths once at declaration, matching the existing
WORKSPACE_CWD = resolve("/workspace") convention, so fixtures model what
a real Windows Pi would record. Linux behavior is unchanged.
2026-07-28 19:59:27 +02:00

200 lines
8.0 KiB
TypeScript

import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { PiSessionService, type PiSessionListEntry } from "./piSessionService.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeSessionManager, sessionRecord, testModelRuntime, type SessionGateway } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
const CHILD_CWD = "/srv/dev/pi-web";
// Resolved because the service canonicalizes header cwds before annotating: a
// bare "/srv/..." is drive-relative on Windows and would land on the runner's
// current drive.
const PARENT_CWD = resolve("/srv/dev/pi-web-feature");
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-parent-location-test-"));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("PiSessionService.list parent locations", () => {
it("reports the cwd and id of a parent living in another worktree", async () => {
const parentFile = await parentSessionFile({ id: "parent-id", cwd: PARENT_CWD });
const service = serviceListing([childRecord(parentFile)]);
const [child] = await service.list(CHILD_CWD);
expect(child).toMatchObject({ id: "child", parentSessionCwd: PARENT_CWD, parentSessionId: "parent-id" });
});
it("leaves sessions untouched when the parent is in the same workspace listing", async () => {
const parent = sessionRecord("parent", CHILD_CWD);
const child = { ...sessionRecord("child", CHILD_CWD), parentSessionPath: parent.path };
const service = serviceListing([parent, child]);
const listed = await service.list(CHILD_CWD);
expect(listed.find((session) => session.id === "child")).not.toHaveProperty("parentSessionCwd");
});
it("does not annotate a parent whose file records the same cwd, since it is not elsewhere", async () => {
const parentFile = await parentSessionFile({ id: "parent-id", cwd: CHILD_CWD });
const service = serviceListing([childRecord(parentFile)]);
const [child] = await service.list(CHILD_CWD);
expect(child).not.toHaveProperty("parentSessionCwd");
expect(child).toHaveProperty("parentSessionPath", parentFile);
});
it("still lists a child whose parent file is gone, without location fields", async () => {
const service = serviceListing([childRecord(join(tempDir, "deleted-parent.jsonl"))]);
const [child] = await service.list(CHILD_CWD);
expect(child).toMatchObject({ id: "child" });
expect(child).not.toHaveProperty("parentSessionCwd");
expect(child).not.toHaveProperty("parentSessionId");
});
it("reads each parent header only once across repeated listings", async () => {
const parentFile = await parentSessionFile({ id: "parent-id", cwd: PARENT_CWD });
const service = serviceListing([childRecord(parentFile)]);
await service.list(CHILD_CWD);
// A cached header keeps the annotation after the file is removed, proving no
// second read happened for the same path.
await rm(parentFile);
const [child] = await service.list(CHILD_CWD);
expect(child).toMatchObject({ parentSessionCwd: PARENT_CWD, parentSessionId: "parent-id" });
});
it("releases cached headers on dispose so the cache cannot outlive the service", async () => {
const parentFile = await parentSessionFile({ id: "parent-id", cwd: PARENT_CWD });
const service = serviceListing([childRecord(parentFile)]);
await service.list(CHILD_CWD);
await service.dispose();
await rm(parentFile);
const [child] = await service.list(CHILD_CWD);
expect(child).not.toHaveProperty("parentSessionCwd");
});
});
describe("PiSessionService.list children in sibling workspaces", () => {
it("counts children that live in other workspaces of the same project", async () => {
const parent = sessionRecord("parent", CHILD_CWD);
const service = serviceListing({
[CHILD_CWD]: [parent],
[PARENT_CWD]: [{ ...sessionRecord("child-a", PARENT_CWD), parentSessionPath: parent.path }, { ...sessionRecord("child-b", PARENT_CWD), parentSessionPath: parent.path }],
}, [CHILD_CWD, PARENT_CWD]);
const [listed] = await service.list(CHILD_CWD);
expect(listed).toMatchObject({ id: "parent", childSessionsElsewhere: 2 });
});
it("does not count children nested in the same workspace listing", async () => {
const parent = sessionRecord("parent", CHILD_CWD);
const service = serviceListing({
[CHILD_CWD]: [parent, { ...sessionRecord("child", CHILD_CWD), parentSessionPath: parent.path }],
[PARENT_CWD]: [],
}, [CHILD_CWD, PARENT_CWD]);
const listed = await service.list(CHILD_CWD);
expect(listed.find((session) => session.id === "parent")).not.toHaveProperty("childSessionsElsewhere");
});
it("skips sibling scanning when the cwd belongs to no registered project", async () => {
const parent = sessionRecord("parent", CHILD_CWD);
const service = serviceListing({
[CHILD_CWD]: [parent],
[PARENT_CWD]: [{ ...sessionRecord("child", PARENT_CWD), parentSessionPath: parent.path }],
}, undefined);
const [listed] = await service.list(CHILD_CWD);
expect(listed).not.toHaveProperty("childSessionsElsewhere");
});
it("still lists sessions when a sibling workspace cannot be listed", async () => {
const parent = sessionRecord("parent", CHILD_CWD);
const service = serviceListing({ [CHILD_CWD]: [parent] }, [CHILD_CWD, PARENT_CWD], {
listCwd: (cwd) => {
if (cwd === PARENT_CWD) throw new Error("sibling workspace is gone");
return undefined;
},
});
const [listed] = await service.list(CHILD_CWD);
expect(listed).toMatchObject({ id: "parent" });
expect(listed).not.toHaveProperty("childSessionsElsewhere");
});
it("reports both an out-of-workspace parent and children elsewhere on one listing", async () => {
const grandparentFile = await parentSessionFile({ id: "grandparent-id", cwd: PARENT_CWD });
const middle = { ...sessionRecord("middle", CHILD_CWD), parentSessionPath: grandparentFile };
const service = serviceListing({
[CHILD_CWD]: [middle],
[PARENT_CWD]: [{ ...sessionRecord("grandchild", PARENT_CWD), parentSessionPath: middle.path }],
}, [CHILD_CWD, PARENT_CWD]);
const [listed] = await service.list(CHILD_CWD);
expect(listed).toMatchObject({ id: "middle", parentSessionCwd: PARENT_CWD, parentSessionId: "grandparent-id", childSessionsElsewhere: 1 });
});
});
function childRecord(parentSessionPath: string) {
return { ...sessionRecord("child", CHILD_CWD), parentSessionPath };
}
async function parentSessionFile(header: { id: string; cwd: string }): Promise<string> {
const path = join(tempDir, `${header.id}.jsonl`);
const lines = [
JSON.stringify({ type: "session", version: 3, ...header }),
JSON.stringify({ type: "model_change", id: "m1", parentId: null }),
];
await writeFile(path, `${lines.join("\n")}\n`, "utf8");
return path;
}
type SessionRecord = PiSessionListEntry;
/**
* Build a service over per-cwd session listings. `projectCwds` is the workspace
* set of the containing project, or undefined to model an unregistered cwd.
*/
function serviceListing(
recordsByCwd: SessionRecord[] | Record<string, SessionRecord[]>,
projectCwds?: string[],
options: { listCwd?: (cwd: string) => void } = {},
): PiSessionService {
const listings = Array.isArray(recordsByCwd) ? { [CHILD_CWD]: recordsByCwd } : recordsByCwd;
const gateway: SessionGateway = {
create: () => fakeSessionManager(),
list: (cwd: string) => {
options.listCwd?.(cwd);
return Promise.resolve(listings[cwd] ?? []);
},
open: () => fakeSessionManager(),
};
return new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
archiveStore: emptyArchiveStore(),
sessionManager: gateway,
heartbeatIntervalMs: 60_000,
projectWorkspaces: { forCwd: () => Promise.resolve(projectCwds) },
});
}