Files
multica/apps/desktop/scripts/worktree-dev-env.test.mjs
Jiayuan Zhang 6dcf82a58a feat(desktop): isolate pnpm dev:desktop per worktree (MUL-3724) (#4598)
* feat(desktop): isolate pnpm dev:desktop per worktree (MUL-3724)

Two worktrees could not run pnpm dev:desktop at once: both grabbed the
renderer port 5173 and the single-instance lock keyed by the app name
"Multica Canary". The env hooks to override each already existed
(DESKTOP_RENDERER_PORT in electron.vite.config.ts, DESKTOP_APP_SUFFIX in
src/main/index.ts) but nothing derived per-worktree values.

A new dev launcher (scripts/dev.mjs) derives both from the worktree path
for linked worktrees only — reusing the same cksum%1000 offset as
scripts/init-worktree-env.sh, so renderer port is 5173+offset and the app
becomes "Multica Canary <folder>" with its own userData/lock. The primary
checkout is untouched; explicit env vars still win. Backend targeting is
unchanged (apps/desktop/.env*). Also: brand-dev-electron honors the suffix,
turbo globalEnv passes it through, and CONTRIBUTING documents the flow.

Co-authored-by: multica-agent <github@multica.ai>

* fix(desktop): make worktree dev port/suffix collision-safe (MUL-3724)

Addresses code review on #4598:

- Renderer port base 5173 → 5174 so a worktree whose offset is 0 (e.g.
  cksum("/tmp/multica-3494") % 1000 === 0) no longer collides with the
  primary checkout's default 5173.
- DESKTOP_APP_SUFFIX is now "<folder>-<offset>" instead of just the folder
  name, so worktrees that share a basename at different paths (or names that
  slug to the same fallback) get distinct single-instance locks. Without it
  the second Electron was still blocked by the shared lock.
- Tests: offset-0 port guard, and same-basename-different-path disambiguation.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 15:11:28 +08:00

102 lines
3.8 KiB
JavaScript

import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
appSuffixForPath,
applyWorktreeDevEnv,
cksum,
offsetForPath,
rendererPortForPath,
} from "./worktree-dev-env.mjs";
const cleanups = [];
afterEach(() => {
while (cleanups.length) cleanups.pop()();
});
function tmpRoot(kind /* "file" | "dir" | "none" */) {
const root = mkdtempSync(join(tmpdir(), "wt-"));
cleanups.push(() => rmSync(root, { recursive: true, force: true }));
if (kind === "file") writeFileSync(join(root, ".git"), "gitdir: /elsewhere\n");
else if (kind === "dir") mkdirSync(join(root, ".git"));
return root;
}
describe("worktree-dev-env", () => {
it("cksum is byte-compatible with coreutils cksum(1)", () => {
// `printf '%s' "/tmp/foo" | cksum` → 427878967 8
expect(cksum(Buffer.from("/tmp/foo"))).toBe(427878967);
// `printf '' | cksum` → 4294967295 0
expect(cksum(Buffer.from(""))).toBe(4294967295);
});
it("derives the offset from the path, mod 1000", () => {
expect(offsetForPath("/tmp/foo")).toBe(427878967 % 1000);
});
it("renderer port is 5174 + offset (5173 reserved for the primary checkout)", () => {
expect(rendererPortForPath("/tmp/foo")).toBe(5174 + (427878967 % 1000));
});
it("never reuses 5173 even when the offset is 0", () => {
// POSIX cksum("/tmp/multica-3494") === 1189739000, % 1000 === 0
expect(offsetForPath("/tmp/multica-3494")).toBe(0);
expect(rendererPortForPath("/tmp/multica-3494")).toBe(5174);
expect(rendererPortForPath("/tmp/multica-3494")).not.toBe(5173);
});
it("suffix is '<folder>-<offset>' so it stays recognizable and unique", () => {
expect(appSuffixForPath("/work/MUL-3724_Desktop")).toBe(
`mul-3724-desktop-${offsetForPath("/work/MUL-3724_Desktop")}`,
);
expect(appSuffixForPath("/work/feat/some thing")).toBe(
`some-thing-${offsetForPath("/work/feat/some thing")}`,
);
// empty/non-ascii slug falls back to "worktree", still disambiguated by offset
expect(appSuffixForPath("/work/___")).toBe(`worktree-${offsetForPath("/work/___")}`);
});
it("disambiguates worktrees that share a folder name at different paths", () => {
// Same basename "multica", different parent dirs → different offsets/suffixes,
// so each gets its own single-instance lock.
expect(offsetForPath("/tmp/a/multica")).not.toBe(offsetForPath("/tmp/b/multica"));
expect(appSuffixForPath("/tmp/a/multica")).not.toBe(
appSuffixForPath("/tmp/b/multica"),
);
});
it("auto-isolates a linked worktree (.git is a file)", () => {
const root = tmpRoot("file");
const env = {};
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBe(String(rendererPortForPath(root)));
expect(env.DESKTOP_APP_SUFFIX).toBe(appSuffixForPath(root));
});
it("leaves the primary checkout untouched (.git is a dir)", () => {
const root = tmpRoot("dir");
const env = {};
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBeUndefined();
expect(env.DESKTOP_APP_SUFFIX).toBeUndefined();
});
it("respects explicit env overrides", () => {
const root = tmpRoot("file");
const env = { DESKTOP_RENDERER_PORT: "9999", DESKTOP_APP_SUFFIX: "manual" };
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBe("9999");
expect(env.DESKTOP_APP_SUFFIX).toBe("manual");
});
it("fills only the missing knob when one is set explicitly", () => {
const root = tmpRoot("file");
const env = { DESKTOP_RENDERER_PORT: "9999" };
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBe("9999");
expect(env.DESKTOP_APP_SUFFIX).toBe(appSuffixForPath(root));
});
});