diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 400c705403..3814dfc63d 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -66,6 +66,10 @@ export const CommandIds = { // Help commands helpKeybinds: () => "help:keybinds" as const, + + // Debug commands + debugLaunchTestInstance: () => "debug:launch-test-instance" as const, + debugDeleteTestInstances: () => "debug:delete-test-instances" as const, } as const; /** diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index d69a9d6dc2..b48ffd723d 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -81,6 +81,7 @@ export const COMMAND_SECTIONS = { HELP: "Help", PROJECTS: "Projects", APPEARANCE: "Appearance", + DEBUG: "Debug", SETTINGS: "Settings", } as const; @@ -92,6 +93,7 @@ const section = { mode: COMMAND_SECTIONS.MODE, help: COMMAND_SECTIONS.HELP, projects: COMMAND_SECTIONS.PROJECTS, + debug: COMMAND_SECTIONS.DEBUG, settings: COMMAND_SECTIONS.SETTINGS, }; @@ -676,6 +678,62 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi }, ]); + // Debug + actions.push(() => { + const api = p.api; + if (!api || typeof window === "undefined" || !window.api) return []; + + return [ + { + id: CommandIds.debugLaunchTestInstance(), + title: "Debug: Launch Test Instance", + subtitle: "Launch a second mux instance under /instances/*", + section: section.debug, + run: async () => { + try { + const result = await api.debug.launchTestInstance(); + if (result.success) { + alert(`Launched test instance at:\n${result.data.rootDir}`); + } else { + alert(`Failed to launch test instance:\n${result.error}`); + } + } catch (err) { + alert( + `Failed to launch test instance:\n${err instanceof Error ? err.message : String(err)}` + ); + } + }, + }, + { + id: CommandIds.debugDeleteTestInstances(), + title: "Debug: Delete Test Instances…", + subtitle: "Delete /instances/*", + section: section.debug, + run: async () => { + const ok = confirm( + "Delete ALL test instances under /instances?\n\nAny running test instances may break." + ); + if (!ok) return; + + try { + const result = await api.debug.deleteTestInstances(); + if (result.success) { + alert( + `Deleted ${result.data.deletedCount} test instance(s).\n\n${result.data.instancesDir}` + ); + } else { + alert(`Failed to delete test instances:\n${result.error}`); + } + } catch (err) { + alert( + `Failed to delete test instances:\n${err instanceof Error ? err.message : String(err)}` + ); + } + }, + }, + ]; + }); + // Projects actions.push(() => { const list: CommandAction[] = [ diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index 821de62e5c..0f7a1797d8 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { CLI_GLOBAL_FLAGS, + consumeMuxRootFromArgv, detectCliEnvironment, getParseOptions, getSubcommand, @@ -90,6 +91,62 @@ describe("getSubcommand", () => { }); }); +describe("consumeMuxRootFromArgv", () => { + test("bun/node: strips --mux-root before subcommand", () => { + const env = detectCliEnvironment({}, undefined); + const argv = ["bun", "script.ts", "--mux-root", "/tmp/mux-test", "server", "--help"]; + const result = consumeMuxRootFromArgv(argv, env); + + expect(result).toEqual({ muxRoot: "/tmp/mux-test" }); + expect(argv).toEqual(["bun", "script.ts", "server", "--help"]); + }); + + test("bun/node: strips --root after subcommand", () => { + const env = detectCliEnvironment({}, undefined); + const argv = ["bun", "script.ts", "server", "--root", "/tmp/mux-test"]; + const result = consumeMuxRootFromArgv(argv, env); + + expect(result).toEqual({ muxRoot: "/tmp/mux-test" }); + expect(argv).toEqual(["bun", "script.ts", "server"]); + }); + + test("packaged electron: strips --mux-root", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, undefined); + const argv = ["mux", "--mux-root", "/tmp/mux-test", "server", "--help"]; + const result = consumeMuxRootFromArgv(argv, env); + + expect(result).toEqual({ muxRoot: "/tmp/mux-test" }); + expect(argv).toEqual(["mux", "server", "--help"]); + }); + + test("supports equals syntax", () => { + const env = detectCliEnvironment({ electron: "33.0.0" }, true); + const argv = ["electron", ".", "server", "--mux-root=/tmp/mux-test"]; + const result = consumeMuxRootFromArgv(argv, env); + + expect(result).toEqual({ muxRoot: "/tmp/mux-test" }); + expect(argv).toEqual(["electron", ".", "server"]); + }); + + test("returns error when flag missing value", () => { + const env = detectCliEnvironment({}, undefined); + const argv = ["bun", "script.ts", "--mux-root"]; + const result = consumeMuxRootFromArgv(argv, env); + + expect(result.error).toBe("Missing value for --mux-root"); + expect(argv).toEqual(["bun", "script.ts", "--mux-root"]); + }); + + test("returns error when flag value is another flag", () => { + const env = detectCliEnvironment({}, undefined); + const argv = ["bun", "script.ts", "--mux-root", "--help", "server"]; + const result = consumeMuxRootFromArgv(argv, env); + + expect(result.error).toBe("Missing value for --mux-root"); + expect(argv).toEqual(["bun", "script.ts", "--mux-root", "--help", "server"]); + }); +}); + describe("getArgsAfterSplice", () => { // These tests simulate what happens AFTER index.ts splices out the subcommand name // Original argv: ["electron", ".", "api", "--help"] diff --git a/src/cli/argv.ts b/src/cli/argv.ts index de7b6f78ca..ba8830d71d 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -53,6 +53,58 @@ export function getSubcommand( return argv[env.firstArgIndex]; } +/** + * Extract and consume a global --mux-root/--root flag. + * + * This must run before subcommand routing so that commands like: + * mux --mux-root /tmp/mux-test server + * correctly route to "server" (instead of treating --mux-root as a subcommand). + * + * Mutates argv in-place by splicing out the flag and its value. + */ +export function consumeMuxRootFromArgv( + argv: string[] = process.argv, + env: CliEnvironment = detectCliEnvironment() +): { muxRoot?: string; error?: string } { + let muxRoot: string | undefined; + + // Matches: + // - --mux-root + // - --root + // - --mux-root= + // - --root= + const rootFlagRe = /^--(mux-root|root)(?:=(.*))?$/; + + for (let i = env.firstArgIndex; i < argv.length; i++) { + const arg = argv[i]; + const match = rootFlagRe.exec(arg); + if (!match) continue; + + const equalsValue = match[2]; + if (equalsValue !== undefined) { + if (!equalsValue) { + return { error: `Missing value for ${arg}` }; + } + + muxRoot = equalsValue; + argv.splice(i, 1); + i -= 1; + continue; + } + + const value = argv[i + 1]; + if (value === undefined || value.startsWith("-")) { + return { error: `Missing value for ${arg}` }; + } + + muxRoot = value; + argv.splice(i, 2); + i -= 1; + } + + return { muxRoot }; +} + /** * Get args for a subcommand after the subcommand name has been spliced out. * This is what subcommand handlers (server.ts, api.ts, run.ts) use after diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 102814c0ff..0908017a62 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -81,6 +81,7 @@ async function createTestServer(authToken?: string): Promise { telemetryService: services.telemetryService, sessionUsageService: services.sessionUsageService, signingService: services.signingService, + testInstanceService: services.testInstanceService, coderService: services.coderService, }; diff --git a/src/cli/index.ts b/src/cli/index.ts index 1193a8acfc..1a3b8f4cf6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -24,6 +24,7 @@ import { Command } from "commander"; import { VERSION } from "../version"; import { CLI_GLOBAL_FLAGS, + consumeMuxRootFromArgv, detectCliEnvironment, getParseOptions, getSubcommand, @@ -32,6 +33,16 @@ import { } from "./argv"; const env = detectCliEnvironment(); + +const muxRootResult = consumeMuxRootFromArgv(process.argv, env); +if (muxRootResult.error) { + console.error(muxRootResult.error); + process.exit(1); +} +if (muxRootResult.muxRoot) { + process.env.MUX_ROOT = muxRootResult.muxRoot; +} + const subcommand = getSubcommand(process.argv, env); function launchDesktop(): void { diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index c8ab80d6ed..82c1cce60e 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -83,6 +83,7 @@ async function createTestServer(): Promise { voiceService: services.voiceService, telemetryService: services.telemetryService, sessionUsageService: services.sessionUsageService, + testInstanceService: services.testInstanceService, signingService: services.signingService, coderService: services.coderService, }; diff --git a/src/cli/server.ts b/src/cli/server.ts index 0d47c99fe4..142c630492 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -115,6 +115,7 @@ const mockWindow: BrowserWindow = { voiceService: serviceContainer.voiceService, telemetryService: serviceContainer.telemetryService, experimentsService: serviceContainer.experimentsService, + testInstanceService: serviceContainer.testInstanceService, sessionUsageService: serviceContainer.sessionUsageService, signingService: serviceContainer.signingService, coderService: serviceContainer.coderService, diff --git a/src/common/constants/paths.ts b/src/common/constants/paths.ts index 389d8c6a60..5b8a0536fc 100644 --- a/src/common/constants/paths.ts +++ b/src/common/constants/paths.ts @@ -14,9 +14,19 @@ const MUX_DIR_NAME = ".mux"; * This ensures old scripts/tools referencing ~/.cmux continue working. */ export function migrateLegacyMuxHome(): void { - const oldPath = join(homedir(), LEGACY_MUX_DIR_NAME); const newPath = join(homedir(), MUX_DIR_NAME); + // When running with a custom mux root (e.g. a test instance), avoid migrating or touching + // the user's real ~/.mux. Allow migration only when MUX_ROOT is unset or explicitly points + // at the default ~/.mux path. + // eslint-disable-next-line no-restricted-syntax, no-restricted-globals + const muxRoot = process.env.MUX_ROOT; + if (muxRoot && muxRoot !== newPath) { + return; + } + + const oldPath = join(homedir(), LEGACY_MUX_DIR_NAME); + // If .mux exists, we're done (already migrated or fresh install) if (existsSync(newPath)) { return; diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 5e6bef6edd..0bc1537302 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1080,4 +1080,23 @@ export const debug = { }), output: z.boolean(), // true if error was triggered on an active stream }, + + /** + * Launch a new mux desktop instance under an isolated root (for QA). + */ + launchTestInstance: { + input: z.void(), + output: ResultSchema(z.object({ rootDir: z.string() }), z.string()), + }, + + /** + * Delete all test instance roots under /instances. + */ + deleteTestInstances: { + input: z.void(), + output: ResultSchema( + z.object({ instancesDir: z.string(), deletedCount: z.number() }), + z.string() + ), + }, }; diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 2420f7569f..ff8ab5b150 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -36,7 +36,9 @@ import * as path from "path"; import type { Config } from "@/node/config"; import type { ServiceContainer } from "@/node/services/serviceContainer"; import { VERSION } from "@/version"; -import { getMuxHome, migrateLegacyMuxHome } from "@/common/constants/paths"; +import { migrateLegacyMuxHome } from "@/common/constants/paths"; + +import { resolveMuxUserDataDir } from "@/desktop/userDataDir"; import assert from "@/common/utils/assert"; import { loadTokenizerModules } from "@/node/utils/main/tokenizer"; @@ -64,16 +66,25 @@ let services: ServiceContainer | null = null; const isE2ETest = process.env.MUX_E2E === "1"; const forceDistLoad = process.env.MUX_E2E_LOAD_DIST === "1"; -if (isE2ETest) { - // For e2e tests, use a test-specific userData directory - // Note: getMuxHome() already respects MUX_ROOT for test isolation - const e2eUserData = path.join(getMuxHome(), "user-data"); +const userDataDir = resolveMuxUserDataDir({ + muxUserDataDir: process.env.MUX_USER_DATA_DIR, + muxRoot: process.env.MUX_ROOT, + isE2E: isE2ETest, +}); + +if (userDataDir) { + // Use a root-scoped userData directory so we can run multiple mux instances + // side-by-side without Electron singleton/localStorage collisions. try { - fs.mkdirSync(e2eUserData, { recursive: true }); - app.setPath("userData", e2eUserData); - console.log("Using test userData directory:", e2eUserData); + fs.mkdirSync(userDataDir, { recursive: true }); + app.setPath("userData", userDataDir); + if (isE2ETest) { + console.log("Using test userData directory:", userDataDir); + } else { + console.log("Using userData directory:", userDataDir); + } } catch (error) { - console.warn("Failed to prepare test userData directory:", error); + console.warn("Failed to prepare userData directory:", error); } } @@ -366,6 +377,7 @@ async function loadServices(): Promise { voiceService: services.voiceService, telemetryService: services.telemetryService, experimentsService: services.experimentsService, + testInstanceService: services.testInstanceService, sessionUsageService: services.sessionUsageService, signingService: services.signingService, coderService: services.coderService, diff --git a/src/desktop/userDataDir.test.ts b/src/desktop/userDataDir.test.ts new file mode 100644 index 0000000000..c419aa3bb2 --- /dev/null +++ b/src/desktop/userDataDir.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import * as path from "path"; +import { resolveMuxUserDataDir } from "./userDataDir"; + +describe("resolveMuxUserDataDir", () => { + test("prefers explicit MUX_USER_DATA_DIR", () => { + const result = resolveMuxUserDataDir({ + muxUserDataDir: "/tmp/custom-user-data", + muxRoot: "/tmp/mux-root", + isE2E: true, + muxHome: "/tmp/mux-home", + }); + + expect(result).toBe("/tmp/custom-user-data"); + }); + + test("defaults to /user-data when muxRoot is set", () => { + const result = resolveMuxUserDataDir({ muxRoot: "/tmp/mux-root" }); + expect(result).toBe(path.join("/tmp/mux-root", "user-data")); + }); + + test("defaults to /user-data when running E2E", () => { + const result = resolveMuxUserDataDir({ isE2E: true, muxHome: "/tmp/mux-e2e" }); + expect(result).toBe(path.join("/tmp/mux-e2e", "user-data")); + }); + + test("returns undefined when no overrides", () => { + const result = resolveMuxUserDataDir({}); + expect(result).toBeUndefined(); + }); +}); diff --git a/src/desktop/userDataDir.ts b/src/desktop/userDataDir.ts new file mode 100644 index 0000000000..4677c8d54c --- /dev/null +++ b/src/desktop/userDataDir.ts @@ -0,0 +1,22 @@ +import * as path from "path"; +import { getMuxHome } from "@/common/constants/paths"; + +export function resolveMuxUserDataDir(options: { + muxUserDataDir?: string | undefined; + muxRoot?: string | undefined; + isE2E?: boolean | undefined; + muxHome?: string | undefined; +}): string | undefined { + if (options.muxUserDataDir) { + return options.muxUserDataDir; + } + + if (options.muxRoot || options.isE2E) { + // Prefer explicit inputs (muxHome / muxRoot) so callers can compute a path + // without mutating process.env before calling this helper. + const muxHome = options.muxHome ?? options.muxRoot ?? getMuxHome(); + return path.join(muxHome, "user-data"); + } + + return undefined; +} diff --git a/src/node/builtinSkills/mux-testing-mux.md b/src/node/builtinSkills/mux-testing-mux.md new file mode 100644 index 0000000000..3c650dcb20 --- /dev/null +++ b/src/node/builtinSkills/mux-testing-mux.md @@ -0,0 +1,62 @@ +--- +name: mux-testing-mux +description: Launch a second mux desktop instance using an isolated root/profile. +--- + +# mux testing mux (isolated roots / profiles) + +Use this skill to launch a **second mux desktop instance on the same machine** for QA/validation. + +This works by setting a distinct mux home (`MUX_ROOT`) *and* a distinct Electron `userData` directory (derived automatically). + +## Quick start (packaged desktop) + +1. Pick an isolated root directory (do **not** use your real `~/.mux`): + + - macOS/Linux: `/tmp/mux-test-1` + - Windows: `%TEMP%\\mux-test-1` + +2. Launch mux pointing at that root: + + ```bash + mux --mux-root /tmp/mux-test-1 + # or + mux desktop --mux-root /tmp/mux-test-1 + ``` + +Mux will automatically use: + +- `muxHome = ` +- `userData = /user-data` + +So you can run multiple instances without collisions. + +## Dev build (electron .) + +If you’re running mux from the repo: + +```bash +# in one terminal +make dev + +# in another terminal +bunx electron . --mux-root /tmp/mux-test-1 +``` + +If your dev server is on a non-default host/port, pass through the values your dev instance uses (e.g. `MUX_DEVSERVER_HOST`, `MUX_DEVSERVER_PORT`). + +## Validate isolation + +In the test root you should see separate state, e.g.: + +- `/server.lock` +- `/user-data/` (Electron `userData`) +- `/config.json` + +## Cleanup + +Delete the test root when you’re done: + +```bash +rm -rf /tmp/mux-test-1 +``` diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 3cd5493476..70e5a2d512 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -22,6 +22,7 @@ import type { SigningService } from "@/node/services/signingService"; import type { FeatureFlagService } from "@/node/services/featureFlagService"; import type { SessionTimingService } from "@/node/services/sessionTimingService"; import type { SessionUsageService } from "@/node/services/sessionUsageService"; +import type { TestInstanceService } from "@/node/services/testInstanceService"; import type { TaskService } from "@/node/services/taskService"; import type { CoderService } from "@/node/services/coderService"; @@ -49,6 +50,7 @@ export interface ORPCContext { telemetryService: TelemetryService; experimentsService: ExperimentsService; sessionUsageService: SessionUsageService; + testInstanceService: TestInstanceService; signingService: SigningService; coderService: CoderService; headers?: IncomingHttpHeaders; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index ad9c1a04f0..2cc5b39417 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1931,6 +1931,18 @@ export const router = (authToken?: string) => { input.errorMessage ); }), + launchTestInstance: t + .input(schemas.debug.launchTestInstance.input) + .output(schemas.debug.launchTestInstance.output) + .handler(({ context }) => { + return context.testInstanceService.launchTestInstance(); + }), + deleteTestInstances: t + .input(schemas.debug.deleteTestInstances.input) + .output(schemas.debug.deleteTestInstances.output) + .handler(({ context }) => { + return context.testInstanceService.deleteTestInstances(); + }), }, telemetry: { track: t diff --git a/src/node/services/agentSkills/agentSkillsService.test.ts b/src/node/services/agentSkills/agentSkillsService.test.ts index 9e640eb2e0..071b60b71c 100644 --- a/src/node/services/agentSkills/agentSkillsService.test.ts +++ b/src/node/services/agentSkills/agentSkillsService.test.ts @@ -38,7 +38,7 @@ describe("agentSkillsService", () => { const skills = await discoverAgentSkills(runtime, project.path, { roots }); // Should include project/global skills plus built-in skills - expect(skills.map((s) => s.name)).toEqual(["bar", "foo", "mux-docs"]); + expect(skills.map((s) => s.name)).toEqual(["bar", "foo", "mux-docs", "mux-testing-mux"]); const foo = skills.find((s) => s.name === "foo"); expect(foo).toBeDefined(); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 2c56bcae0c..e54dd05a0f 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -44,6 +44,7 @@ import { TaskService } from "@/node/services/taskService"; import { getSigningService, type SigningService } from "@/node/services/signingService"; import { coderService, type CoderService } from "@/node/services/coderService"; import { setGlobalCoderService } from "@/node/runtime/runtimeFactory"; +import { TestInstanceService } from "@/node/services/testInstanceService"; /** * ServiceContainer - Central dependency container for all backend services. @@ -77,6 +78,7 @@ export class ServiceContainer { public readonly sessionTimingService: SessionTimingService; public readonly experimentsService: ExperimentsService; public readonly sessionUsageService: SessionUsageService; + public readonly testInstanceService: TestInstanceService; public readonly signingService: SigningService; public readonly coderService: CoderService; private readonly initStateManager: InitStateManager; @@ -87,6 +89,7 @@ export class ServiceContainer { constructor(config: Config) { this.config = config; + this.testInstanceService = new TestInstanceService(config); this.historyService = new HistoryService(config); this.partialService = new PartialService(config, this.historyService); this.projectService = new ProjectService(config); diff --git a/src/node/services/testInstanceService.test.ts b/src/node/services/testInstanceService.test.ts new file mode 100644 index 0000000000..9f9d7e0cea --- /dev/null +++ b/src/node/services/testInstanceService.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; + +import type { ProjectsConfig } from "@/common/types/project"; +import { createIsolatedConfigForTestInstance } from "./testInstanceService"; + +describe("createIsolatedConfigForTestInstance", () => { + test("clears workspace history but preserves project setup", () => { + const source: ProjectsConfig = { + projects: new Map([ + [ + "/repo/project-a", + { + workspaces: [{ path: "/repo/project-a/.mux/ws-1" }], + sections: [{ id: "deadbeef", name: "Section A", nextId: null }], + idleCompactionHours: 12, + }, + ], + ]), + apiServerPort: 1234, + }; + + const isolated = createIsolatedConfigForTestInstance(source); + + expect(isolated.apiServerPort).toBeUndefined(); + expect(Array.from(isolated.projects.keys())).toEqual(["/repo/project-a"]); + + const isolatedProject = isolated.projects.get("/repo/project-a"); + expect(isolatedProject).toBeDefined(); + expect(isolatedProject!.workspaces).toEqual([]); + expect(isolatedProject!.sections).toEqual([ + { id: "deadbeef", name: "Section A", nextId: null }, + ]); + expect(isolatedProject!.idleCompactionHours).toBe(12); + + // Ensure source is not mutated. + expect(source.projects.get("/repo/project-a")!.workspaces).toEqual([ + { path: "/repo/project-a/.mux/ws-1" }, + ]); + }); +}); diff --git a/src/node/services/testInstanceService.ts b/src/node/services/testInstanceService.ts new file mode 100644 index 0000000000..0ea47a09cf --- /dev/null +++ b/src/node/services/testInstanceService.ts @@ -0,0 +1,155 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import type { ProjectsConfig, ProjectConfig } from "@/common/types/project"; +import { Err, Ok, type Result } from "@/common/types/result"; +import { Config } from "@/node/config"; + +function sanitizeProjectConfigForTestInstance(project: ProjectConfig): ProjectConfig { + return { + ...project, + // Drop workspace history so the test instance starts clean. + workspaces: [], + // Ensure arrays aren't shared between instances. + sections: project.sections ? [...project.sections] : undefined, + }; +} + +export function createIsolatedConfigForTestInstance(source: ProjectsConfig): ProjectsConfig { + const projects = new Map(); + for (const [projectPath, projectConfig] of source.projects.entries()) { + projects.set(projectPath, sanitizeProjectConfigForTestInstance(projectConfig)); + } + + return { + ...source, + projects, + // Avoid API server port collisions if the main instance is pinned to a fixed port. + apiServerPort: undefined, + }; +} + +function isMissingFileError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + if (!("code" in err)) return false; + return (err as { code?: unknown }).code === "ENOENT"; +} + +function buildSpawnArgsForNewRoot(newRoot: string): string[] { + const baseArgs = process.argv.slice(1); + const filtered: string[] = []; + + for (let i = 0; i < baseArgs.length; i++) { + const arg = baseArgs[i]; + + const isMuxRootFlag = arg === "--mux-root" || arg === "--root"; + const isRemoteDebuggingFlag = arg === "--remote-debugging-port"; + + if (isMuxRootFlag || isRemoteDebuggingFlag) { + // Handle `--flag value`. + const maybeValue = baseArgs[i + 1]; + if (maybeValue && !maybeValue.startsWith("-")) { + i += 1; + } + continue; + } + + // Handle `--flag=value`. + if ( + arg.startsWith("--mux-root=") || + arg.startsWith("--root=") || + arg.startsWith("--remote-debugging-port=") + ) { + continue; + } + + filtered.push(arg); + } + + return [...filtered, "--mux-root", newRoot]; +} + +export class TestInstanceService { + constructor(private readonly config: Config) {} + + private async createInstanceRoot(): Promise { + const instancesDir = path.join(this.config.rootDir, "instances"); + await fs.mkdir(instancesDir, { recursive: true }); + + // node:fs mkdtemp always appends 6 random characters. + // Keep the prefix Windows-safe (no ':'), but human-readable. + return fs.mkdtemp(path.join(instancesDir, "test-instance-")); + } + + private async copyIfExists(src: string, dest: string): Promise { + try { + await fs.copyFile(src, dest); + } catch (err) { + if (isMissingFileError(err)) return; + throw err; + } + } + + async launchTestInstance(): Promise> { + if (!process.versions.electron) { + return Err("Launch Test Instance is only available in the desktop app."); + } + + const rootDir = await this.createInstanceRoot(); + + // Copy provider setup (API keys, endpoints), but not workspaces/sessions. + for (const file of ["providers.jsonc", "secrets.json"] as const) { + await this.copyIfExists(path.join(this.config.rootDir, file), path.join(rootDir, file)); + } + + const sourceConfig = this.config.loadConfigOrDefault(); + const instanceConfig = new Config(rootDir); + await instanceConfig.saveConfig(createIsolatedConfigForTestInstance(sourceConfig)); + + try { + // Intentionally lazy import (rare debug action). + // eslint-disable-next-line no-restricted-syntax -- main-process-only builtin + const { spawn } = await import("node:child_process"); + + const child = spawn(process.execPath, buildSpawnArgsForNewRoot(rootDir), { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { + ...process.env, + // Ensure test instances stay isolated even if the parent was launched with + // MUX_USER_DATA_DIR (which would otherwise override per-root userData). + MUX_USER_DATA_DIR: path.join(rootDir, "user-data"), + }, + }); + child.unref(); + + return Ok({ rootDir }); + } catch (err) { + return Err(err instanceof Error ? err.message : String(err)); + } + } + + async deleteTestInstances(): Promise> { + const instancesDir = path.join(this.config.rootDir, "instances"); + + let entries; + try { + entries = await fs.readdir(instancesDir, { withFileTypes: true }); + } catch (err) { + if (isMissingFileError(err)) { + return Ok({ instancesDir, deletedCount: 0 }); + } + return Err(err instanceof Error ? err.message : String(err)); + } + + const dirs = entries.filter((e) => e.isDirectory()).map((e) => path.join(instancesDir, e.name)); + + try { + await Promise.all(dirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); + return Ok({ instancesDir, deletedCount: dirs.length }); + } catch (err) { + return Err(err instanceof Error ? err.message : String(err)); + } + } +} diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index e6dd3d9b5a..d533d839c4 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -104,6 +104,7 @@ export async function createTestEnvironment(): Promise { telemetryService: services.telemetryService, sessionUsageService: services.sessionUsageService, signingService: services.signingService, + testInstanceService: services.testInstanceService, coderService: services.coderService, }; const orpc = createOrpcTestClient(orpcContext);