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 });