Archived
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.
200 lines
8.0 KiB
TypeScript
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) },
|
|
});
|
|
}
|