/ packages / pi-session-switch / test / session-switch.test.ts
session-switch.test.ts
  1  import { spawnSync } from "node:child_process";
  2  import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
  3  import { tmpdir } from "node:os";
  4  import * as path from "node:path";
  5  
  6  import type { SessionInfo } from "@mariozechner/pi-coding-agent";
  7  import { afterEach, describe, expect, test } from "bun:test";
  8  
  9  import type { PiSpawnDeps } from "../../../extensions/_shared/pi-spawn.ts";
 10  import { buildPreviewLines, clampPreviewScrollFromBottom } from "../../../extensions/session-switch/picker.ts";
 11  import { resolveCommandPickerAction } from "../../../extensions/session-switch/index.ts";
 12  import {
 13  	buildStartupRelaunchArgs,
 14  	resolveStartupAction,
 15  	resolveStartupSessionTarget,
 16  } from "../../../extensions/session-switch/relaunch.ts";
 17  
 18  const REPO_ROOT = path.resolve(import.meta.dir, "../../..");
 19  const PREPACK_SCRIPT_PATH = path.join(REPO_ROOT, "scripts/pi-package-prepack.mjs");
 20  const SESSION_SWITCH_PACKAGE_JSON_PATH = path.join(REPO_ROOT, "packages/pi-session-switch/package.json");
 21  
 22  const tempDirs: string[] = [];
 23  
 24  afterEach(async () => {
 25  	while (tempDirs.length > 0) {
 26  		const dir = tempDirs.pop();
 27  		if (!dir) continue;
 28  		await rm(dir, { recursive: true, force: true });
 29  	}
 30  });
 31  
 32  function makeDeps(input: {
 33  	execPath?: string;
 34  	argv0?: string;
 35  	argv1?: string;
 36  	existing?: string[];
 37  	packageJsonPath?: string;
 38  	packageJsonContent?: string;
 39  }): PiSpawnDeps {
 40  	const existing = new Set(input.existing ?? []);
 41  	const packageJsonPath = input.packageJsonPath;
 42  	const packageJsonContent = input.packageJsonContent;
 43  	return {
 44  		execPath: input.execPath,
 45  		argv0: input.argv0,
 46  		argv1: input.argv1,
 47  		existsSync: (filePath) => existing.has(filePath),
 48  		readFileSync: (_filePath, _encoding) => {
 49  			if (!packageJsonPath || !packageJsonContent) {
 50  				throw new Error("package json not configured");
 51  			}
 52  			return packageJsonContent;
 53  		},
 54  		resolvePackageJson: () => {
 55  			if (!packageJsonPath) throw new Error("package json path missing");
 56  			return packageJsonPath;
 57  		},
 58  	};
 59  }
 60  
 61  async function writeTempFile(rootDir: string, relativePath: string, content: string): Promise<void> {
 62  	const filePath = path.join(rootDir, relativePath);
 63  	await mkdir(path.dirname(filePath), { recursive: true });
 64  	await writeFile(filePath, content, "utf8");
 65  }
 66  
 67  describe("buildPreviewLines", () => {
 68  	test("prefers allMessagesText and keeps only the last 1200 lines", () => {
 69  		const allMessagesText = Array.from({ length: 1205 }, (_value, index) => `line ${index}   `).join("\n");
 70  		const session = { allMessagesText, firstMessage: "first" } as SessionInfo;
 71  
 72  		expect(buildPreviewLines(session)).toEqual([
 73  			...Array.from({ length: 1200 }, (_value, index) => `line ${index + 5}`),
 74  		]);
 75  	});
 76  
 77  	test("falls back to firstMessage when allMessagesText is absent", () => {
 78  		const session = { firstMessage: "hello\nworld   " } as SessionInfo;
 79  		expect(buildPreviewLines(session)).toEqual(["hello", "world"]);
 80  	});
 81  });
 82  
 83  describe("clampPreviewScrollFromBottom", () => {
 84  	test("clamps top overscroll so paging back down stays reversible", () => {
 85  		expect(clampPreviewScrollFromBottom(999, 20, 5)).toBe(15);
 86  	});
 87  
 88  	test("does not underflow below zero", () => {
 89  		expect(clampPreviewScrollFromBottom(-3, 20, 5)).toBe(0);
 90  	});
 91  });
 92  
 93  describe("buildStartupRelaunchArgs", () => {
 94  	test("preserves non-session arguments while stripping startup and session conflict flags", () => {
 95  		const args = buildStartupRelaunchArgs(
 96  			["--model", "anthropic/claude-sonnet-4", "--switch-session", "--session", "old.jsonl", "-c", "--no-session", "Review this"],
 97  			"new.jsonl",
 98  		);
 99  
100  		expect(args).toEqual([
101  			"--model",
102  			"anthropic/claude-sonnet-4",
103  			"Review this",
104  			"--session",
105  			"new.jsonl",
106  		]);
107  	});
108  
109  	test("strips --fork before appending the selected session", () => {
110  		const args = buildStartupRelaunchArgs(
111  			["--switch-session", "--fork", "old.jsonl", "--model", "anthropic/claude-sonnet-4"],
112  			"new.jsonl",
113  		);
114  
115  		expect(args).toEqual([
116  			"--model",
117  			"anthropic/claude-sonnet-4",
118  			"--session",
119  			"new.jsonl",
120  		]);
121  	});
122  });
123  
124  describe("resolveCommandPickerAction", () => {
125  	test("maps exit dismissals to shutdown", () => {
126  		expect(resolveCommandPickerAction({ kind: "dismissed", reason: "exit" })).toEqual({ kind: "shutdown" });
127  	});
128  
129  	test("maps cancel dismissals to noop", () => {
130  		expect(resolveCommandPickerAction({ kind: "dismissed", reason: "cancel" })).toEqual({ kind: "noop" });
131  	});
132  });
133  
134  describe("resolveStartupSessionTarget", () => {
135  	test("returns the selected session cwd when it exists", () => {
136  		const target = resolveStartupSessionTarget("/tmp/selected.jsonl", {
137  			readFile: (() => JSON.stringify({ type: "session", cwd: "/tmp/selected-project" }) + "\n") as any,
138  			exists: () => true,
139  			stat: (() => ({ isDirectory: () => true })) as any,
140  		});
141  
142  		expect(target).toEqual({ cwd: "/tmp/selected-project" });
143  	});
144  
145  	test("warns when the selected session cwd is missing", () => {
146  		const target = resolveStartupSessionTarget("/tmp/selected.jsonl", {
147  			readFile: (() => JSON.stringify({ type: "session" }) + "\n") as any,
148  			exists: () => true,
149  			stat: (() => ({ isDirectory: () => true })) as any,
150  		});
151  
152  		expect(target).toEqual({
153  			warning: "Selected session does not have a recorded cwd. Use `/switch-session` or native `pi --resume` instead.",
154  		});
155  	});
156  
157  	test("warns when the selected session cwd path is missing", () => {
158  		const target = resolveStartupSessionTarget("/tmp/selected.jsonl", {
159  			readFile: (() => JSON.stringify({ type: "session", cwd: "/tmp/missing-project" }) + "\n") as any,
160  			exists: () => false,
161  			stat: (() => ({ isDirectory: () => false })) as any,
162  		});
163  
164  		expect(target).toEqual({
165  			warning:
166  				"Selected session cwd no longer exists: /tmp/missing-project. `pi --switch-session` cannot recover missing cwd state because startup switching is implemented as a relaunch. Use `/switch-session` or native `pi --resume` instead.",
167  		});
168  	});
169  });
170  
171  describe("resolveStartupAction", () => {
172  	test("returns native-like exit when the picker requests cancel", () => {
173  		const action = resolveStartupAction(
174  			{ kind: "dismissed", reason: "cancel" },
175  			{ cwd: "/tmp/project", argvTokens: ["--switch-session"] },
176  		);
177  
178  		expect(action).toEqual({ kind: "exit", code: 0, message: "No session selected" });
179  	});
180  
181  	test("returns shutdown when the picker requests exit", () => {
182  		const action = resolveStartupAction(
183  			{ kind: "dismissed", reason: "exit" },
184  			{ cwd: "/tmp/project", argvTokens: ["--switch-session"] },
185  		);
186  
187  		expect(action).toEqual({ kind: "shutdown" });
188  	});
189  
190  	test("preserves the original pi invocation when relaunching into the selected session", () => {
191  		const argv1 = "/tmp/pi-entry.mjs";
192  		const deps = makeDeps({
193  			execPath: "/usr/local/bin/node",
194  			argv1,
195  			existing: [argv1],
196  		});
197  
198  		const action = resolveStartupAction(
199  			{ kind: "selected", sessionPath: "/tmp/selected.jsonl" },
200  			{
201  				cwd: "/tmp/project",
202  				argvTokens: ["--switch-session", "--model", "anthropic/claude-sonnet-4"],
203  				spawnDeps: deps,
204  			},
205  		);
206  
207  		expect(action).toEqual({
208  			kind: "relaunch",
209  			cwd: "/tmp/project",
210  			command: "/usr/local/bin/node",
211  			args: [argv1, "--model", "anthropic/claude-sonnet-4", "--session", "/tmp/selected.jsonl"],
212  		});
213  	});
214  
215  	test("falls back to the current pi executable when it is identifiable", () => {
216  		const action = resolveStartupAction(
217  			{ kind: "selected", sessionPath: "/tmp/selected.jsonl" },
218  			{
219  				cwd: "/tmp/project",
220  				argvTokens: ["--switch-session"],
221  				spawnDeps: makeDeps({ argv0: "/opt/custom/bin/pi" }),
222  			},
223  		);
224  
225  		expect(action).toEqual({
226  			kind: "relaunch",
227  			cwd: "/tmp/project",
228  			command: "/opt/custom/bin/pi",
229  			args: ["--session", "/tmp/selected.jsonl"],
230  		});
231  	});
232  
233  	test("falls back to the packaged pi CLI when argv1 is not runnable", () => {
234  		const packageJsonPath = "/opt/pi/package.json";
235  		const cliPath = path.resolve(path.dirname(packageJsonPath), "dist/cli/index.js");
236  		const deps = makeDeps({
237  			execPath: "/usr/local/bin/node",
238  			argv1: "/opt/pi/not-runnable.txt",
239  			packageJsonPath,
240  			packageJsonContent: JSON.stringify({ bin: { pi: "dist/cli/index.js" } }),
241  			existing: [cliPath],
242  		});
243  
244  		const action = resolveStartupAction(
245  			{ kind: "selected", sessionPath: "/tmp/selected.jsonl" },
246  			{
247  				cwd: "/tmp/project",
248  				argvTokens: ["--switch-session", "--print"],
249  				spawnDeps: deps,
250  			},
251  		);
252  
253  		expect(action).toEqual({
254  			kind: "relaunch",
255  			cwd: "/tmp/project",
256  			command: "/usr/local/bin/node",
257  			args: [cliPath, "--print", "--session", "/tmp/selected.jsonl"],
258  		});
259  	});
260  });
261  
262  describe("pi-package-prepack", () => {
263  	test("packages the foldered session-switch layout and removes stale flat artifacts", async () => {
264  		const tempRoot = await mkdtemp(path.join(tmpdir(), "pi-session-switch-prepack-"));
265  		tempDirs.push(tempRoot);
266  
267  		const packageDir = path.join(tempRoot, "packages/pi-session-switch");
268  		await mkdir(packageDir, { recursive: true });
269  		await writeFile(
270  			path.join(packageDir, "package.json"),
271  			await readFile(SESSION_SWITCH_PACKAGE_JSON_PATH, "utf8"),
272  			"utf8",
273  		);
274  
275  		await writeTempFile(tempRoot, "extensions/session-switch/index.ts", "export default function () {}\n");
276  		await writeTempFile(tempRoot, "extensions/session-switch/picker.ts", "export const picker = true;\n");
277  		await writeTempFile(tempRoot, "extensions/session-switch/relaunch.ts", "export const relaunch = true;\n");
278  		await writeTempFile(tempRoot, "extensions/session-switch/session-switch.LICENSE", "MIT\n");
279  		await writeTempFile(tempRoot, "extensions/_shared/pi-spawn.ts", "export const spawn = true;\n");
280  		await writeTempFile(tempRoot, "LICENSE", "MIT\n");
281  		await writeTempFile(tempRoot, "packages/pi-session-switch/extensions/session-switch.ts", "legacy flat artifact\n");
282  		await writeTempFile(tempRoot, "packages/pi-session-switch/extensions/session-switch.LICENSE", "legacy license\n");
283  		await writeTempFile(tempRoot, "packages/pi-session-switch/extensions/session-switch/stale.ts", "stale nested artifact\n");
284  
285  		const result = spawnSync(process.execPath, [PREPACK_SCRIPT_PATH], {
286  			cwd: packageDir,
287  			encoding: "utf8",
288  		});
289  
290  		expect(result.status).toBe(0);
291  		expect(result.stderr).toBe("");
292  		expect(await Bun.file(path.join(packageDir, "extensions/session-switch/index.ts")).exists()).toBe(true);
293  		expect(await Bun.file(path.join(packageDir, "extensions/session-switch/picker.ts")).exists()).toBe(true);
294  		expect(await Bun.file(path.join(packageDir, "extensions/session-switch/relaunch.ts")).exists()).toBe(true);
295  		expect(await Bun.file(path.join(packageDir, "extensions/session-switch/session-switch.LICENSE")).exists()).toBe(true);
296  		expect(await Bun.file(path.join(packageDir, "extensions/_shared/pi-spawn.ts")).exists()).toBe(true);
297  		expect(await Bun.file(path.join(packageDir, "extensions/session-switch/stale.ts")).exists()).toBe(false);
298  		expect(await Bun.file(path.join(packageDir, "extensions/session-switch.ts")).exists()).toBe(false);
299  		expect(await Bun.file(path.join(packageDir, "extensions/session-switch.LICENSE")).exists()).toBe(false);
300  	});
301  });