mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
0688faaec3
* reland(sidecar): converge product clients after weekly release
Restore the sidecar convergence from #7244 after the release-scheduling revert in #7634. This tree matches the previously reviewed and cross-platform QA-accepted implementation at d83d4e032f.
* fix(sidecar): clarify identity and cache witnesses
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(packaged): probe cross-source desktop owners
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(sidecar): preserve cross-source launch ownership
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* test(pack): assert cross-source Windows launch ownership
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(sidecar): align HyperFrames with client boundary
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(e2e): keep HyperFrames fixture in helper lane
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* docs(sidecar): restore review and handoff contracts
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(sidecar): restore cache and daemon boundaries
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(pack): restore sourcemaps and Linux smoke discovery
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(sidecar): make payload handoff generation-atomic
* fix(sidecar): preserve packaged action and mode parity
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* test(pack): align Linux cleanup resource count
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(packaged): preserve headless sidecar mode
* test: normalize source line endings
* fix(daemon): launch Codex cmd shims on Windows
---------
Co-authored-by: Looper <looper@noreply.github.com>
951 lines
36 KiB
TypeScript
951 lines
36 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import { access, chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { posix } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { findSidecarProcesses, getSidecarStatus, invokeSidecar, stopSidecar } from "@open-design/sidecar";
|
|
import {
|
|
APP_KEYS,
|
|
OPEN_DESIGN_SIDECAR_CONTRACT,
|
|
SIDECAR_MESSAGES,
|
|
SIDECAR_MODES,
|
|
SIDECAR_SOURCES,
|
|
} from "@open-design/sidecar-proto";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
const stopSidecarMock = vi.hoisted(() => vi.fn(async (_stamp?: unknown, _options?: unknown) => ({
|
|
alreadyStopped: true,
|
|
forcedPids: [],
|
|
gracefulAccepted: false,
|
|
matchedPids: [],
|
|
remainingPids: [],
|
|
stoppedPids: [],
|
|
})));
|
|
const stopSidecarsMock = vi.hoisted(() => vi.fn(async (requests: Array<{ options?: unknown; stamp: unknown }>) => {
|
|
const results = await Promise.all(requests.map(async ({ options, stamp }) => ({
|
|
result: await stopSidecarMock(stamp, options),
|
|
stamp,
|
|
})));
|
|
const stopped = results.map(({ result }) => result);
|
|
return {
|
|
alreadyStopped: stopped.every(({ alreadyStopped }) => alreadyStopped),
|
|
forcedPids: [],
|
|
gracefulAccepted: stopped.some(({ gracefulAccepted }) => gracefulAccepted),
|
|
matchedPids: [...new Set(stopped.flatMap(({ matchedPids }) => matchedPids))],
|
|
remainingPids: [...new Set(stopped.flatMap(({ remainingPids }) => remainingPids))],
|
|
results,
|
|
stoppedPids: [...new Set(stopped.flatMap(({ stoppedPids }) => stoppedPids))],
|
|
};
|
|
}));
|
|
|
|
vi.mock("@open-design/sidecar", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@open-design/sidecar")>();
|
|
return {
|
|
...actual,
|
|
findSidecarProcesses: vi.fn(async () => []),
|
|
getSidecarStatus: vi.fn(async () => { throw new Error("status unavailable"); }),
|
|
invokeSidecar: vi.fn(async () => { throw new Error("invoke unavailable"); }),
|
|
stopSidecar: stopSidecarMock,
|
|
stopSidecars: stopSidecarsMock,
|
|
};
|
|
});
|
|
|
|
import type { ToolPackConfig } from "@/config/index.js";
|
|
import {
|
|
buildDockerArgs,
|
|
cleanupPackedLinuxNamespace,
|
|
createLinuxDesktopLaunchEnv,
|
|
inspectPackedLinuxApp,
|
|
LINUX_APPIMAGE_EXECUTABLE_ARGS,
|
|
matchesAppImageProcess,
|
|
renderDesktopTemplate,
|
|
renderLinuxAppImageAppRun,
|
|
renderLinuxPackagedMainEntry,
|
|
resolveLinuxLifecycleMode,
|
|
resolveProductionInstallCommand,
|
|
shouldRejectLinuxHeadlessInspectOptions,
|
|
stopPackedLinuxApp,
|
|
sanitizeNamespace,
|
|
stopPackedLinuxHeadless,
|
|
} from "@/linux.js";
|
|
|
|
async function pathExists(path: string): Promise<boolean> {
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function makeConfig(): ToolPackConfig {
|
|
return {
|
|
containerized: true,
|
|
electronBuilderCliPath: "/x/electron-builder/cli.js",
|
|
electronDistPath: "/x/electron/dist",
|
|
electronVersion: "41.3.0",
|
|
macCompression: "normal",
|
|
namespace: "default",
|
|
platform: "linux",
|
|
portable: false,
|
|
removeData: false,
|
|
removeLogs: false,
|
|
removeProductUserData: false,
|
|
removeSidecars: false,
|
|
requireVelaCli: false,
|
|
roots: {
|
|
output: {
|
|
appBuilderRoot: "/work/.tmp/tools-pack/out/linux/namespaces/default/builder",
|
|
namespaceRoot: "/work/.tmp/tools-pack/out/linux/namespaces/default",
|
|
platformRoot: "/work/.tmp/tools-pack/out/linux",
|
|
root: "/work/.tmp/tools-pack/out",
|
|
},
|
|
runtime: {
|
|
namespaceBaseRoot: "/work/.tmp/tools-pack/runtime/linux/namespaces",
|
|
namespaceRoot: "/work/.tmp/tools-pack/runtime/linux/namespaces/default",
|
|
},
|
|
cacheRoot: "/work/.tmp/tools-pack/cache",
|
|
toolPackRoot: "/work/.tmp/tools-pack",
|
|
},
|
|
silent: true,
|
|
signed: false,
|
|
to: "all",
|
|
webOutputMode: "server",
|
|
workspaceRoot: "/work",
|
|
};
|
|
}
|
|
|
|
const linuxOnlyIt = process.platform === "linux" ? it : it.skip;
|
|
|
|
async function waitForChildExit(child: ChildProcess, timeoutMs = 5000): Promise<void> {
|
|
if (child.exitCode != null || child.signalCode != null) return;
|
|
await new Promise<void>((resolve) => {
|
|
const timer = setTimeout(resolve, timeoutMs);
|
|
child.once("exit", () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
describe("buildDockerArgs", () => {
|
|
it("returns the expected docker argv array", () => {
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
expect(args[0]).toBe("run");
|
|
expect(args).toContain("--rm");
|
|
expect(args).toContain("--user");
|
|
expect(args).toContain("1000:1000");
|
|
expect(args).toContain("electronuserland/builder:base");
|
|
});
|
|
|
|
it("mounts the workspace at /project", () => {
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
expect(args).toContain("-v");
|
|
expect(args).toContain("/work:/project");
|
|
});
|
|
|
|
it("mounts docker home and electron caches under .tmp/tools-pack/.docker-*", () => {
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
expect(args).toContain(`${posix.join("/work/.tmp/tools-pack", ".docker-home")}:/home/builder`);
|
|
expect(args).toContain(`${posix.join("/work/.tmp/tools-pack", ".docker-cache", "electron")}:/home/builder/.cache/electron`);
|
|
expect(args).toContain(
|
|
`${posix.join("/work/.tmp/tools-pack", ".docker-cache", "electron-builder")}:/home/builder/.cache/electron-builder`,
|
|
);
|
|
});
|
|
|
|
it("mounts the tool-pack root at /tools-pack so inner build writes to host-visible output dir", () => {
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
expect(args).toContain("/work/.tmp/tools-pack:/tools-pack");
|
|
});
|
|
|
|
it("sets HOME and ELECTRON_CACHE env vars", () => {
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
expect(args).toContain("HOME=/home/builder");
|
|
expect(args).toContain("ELECTRON_CACHE=/home/builder/.cache/electron");
|
|
expect(args).toContain("ELECTRON_BUILDER_CACHE=/home/builder/.cache/electron-builder");
|
|
});
|
|
|
|
it("passes the telemetry relay URL into containerized builds when configured", () => {
|
|
const args = buildDockerArgs(
|
|
{
|
|
...makeConfig(),
|
|
telemetryRelayUrl: "https://telemetry.open-design.ai/api/langfuse",
|
|
},
|
|
{ uid: 1000, gid: 1000 },
|
|
);
|
|
expect(args).toContain("OPEN_DESIGN_TELEMETRY_RELAY_URL=https://telemetry.open-design.ai/api/langfuse");
|
|
});
|
|
|
|
it("passes the AMR profile into containerized builds when configured", () => {
|
|
const args = buildDockerArgs(
|
|
{
|
|
...makeConfig(),
|
|
amrProfile: "test",
|
|
},
|
|
{ uid: 1000, gid: 1000 },
|
|
);
|
|
expect(args).toContain("OPEN_DESIGN_AMR_PROFILE=test");
|
|
});
|
|
|
|
it("bind-mounts the host Vela binary directory and rewrites the env path into the container", () => {
|
|
const previous = process.env.OPEN_DESIGN_VELA_CLI_BIN;
|
|
process.env.OPEN_DESIGN_VELA_CLI_BIN = "/host/bin/vela";
|
|
try {
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
// The container only mounts /project, /tools-pack, and cache/home by
|
|
// default — a host-path env value like `/host/bin/vela` would resolve
|
|
// to a non-existent path inside. The directory must be bind-mounted
|
|
// and the env rewritten to the container-side path so the resource
|
|
// copier can actually read the binary.
|
|
expect(args).toContain("/host/bin:/opt/vela-cli:ro");
|
|
expect(args).toContain("OPEN_DESIGN_VELA_CLI_BIN=/opt/vela-cli/vela");
|
|
expect(args).not.toContain("OPEN_DESIGN_VELA_CLI_BIN=/host/bin/vela");
|
|
} finally {
|
|
if (previous === undefined) delete process.env.OPEN_DESIGN_VELA_CLI_BIN;
|
|
else process.env.OPEN_DESIGN_VELA_CLI_BIN = previous;
|
|
}
|
|
});
|
|
|
|
it("runs the built tools-pack CLI through node inside the container without generated package-bin shims", () => {
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
const last = args[args.length - 1];
|
|
expect(last).toMatch(/command -v curl >\/dev\/null/);
|
|
expect(last).toMatch(/case "\$\(uname -m\)" in/);
|
|
expect(last).toMatch(/x86_64\) PNPM_ASSET=pnpm-linuxstatic-x64; PNPM_SHA256=[a-f0-9]{64}/);
|
|
expect(last).toMatch(/aarch64\) PNPM_ASSET=pnpm-linuxstatic-arm64; PNPM_SHA256=[a-f0-9]{64}/);
|
|
expect(last).toMatch(
|
|
/curl --retry 3 --retry-all-errors --connect-timeout 10 --max-time 60 -fsSL "https:\/\/github\.com\/pnpm\/pnpm\/releases\/download\/v\d+\.\d+\.\d+\/\$PNPM_ASSET" -o \/tmp\/pnpm\.tmp/,
|
|
);
|
|
expect(last).toMatch(/echo "\$PNPM_SHA256 \/tmp\/pnpm\.tmp" \| sha256sum -c -/);
|
|
expect(last).toMatch(/mv \/tmp\/pnpm\.tmp \/tmp\/pnpm/);
|
|
expect(last).toMatch(/chmod \+x \/tmp\/pnpm/);
|
|
expect(last).toMatch(/\/tmp\/pnpm env use --global 24\.\d+\.\d+/);
|
|
expect(last).toMatch(/\/tmp\/pnpm install --frozen-lockfile/);
|
|
expect(last).toMatch(/node tools\/pack\/bin\/tools-pack\.mjs linux build --to all --namespace default/);
|
|
expect(last).not.toMatch(/\/tmp\/pnpm tools-pack linux build/);
|
|
expect(last).not.toMatch(/--containerized/);
|
|
});
|
|
|
|
it("fetches pnpm standalone binary instead of relying on image npm tooling", () => {
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
const last = args[args.length - 1];
|
|
expect(last).not.toMatch(/corepack/);
|
|
expect(last).not.toMatch(/\bnpx\b/);
|
|
expect(last).not.toMatch(/(^|[;&|]\s*)npm(\s|$)/);
|
|
expect(last).toMatch(/pnpm-linuxstatic-x64/);
|
|
expect(last).toMatch(/pnpm-linuxstatic-arm64/);
|
|
});
|
|
|
|
it("routes container setup and install output to stderr before the JSON-emitting build", () => {
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
const last = args[args.length - 1];
|
|
expect(last).toContain("{ command -v curl");
|
|
expect(last).toContain("/tmp/pnpm install --frozen-lockfile; } >&2 && node tools/pack/bin/tools-pack.mjs linux build");
|
|
expect(last.indexOf("/tmp/pnpm install --frozen-lockfile")).toBeLessThan(
|
|
last.indexOf("} >&2 && node tools/pack/bin/tools-pack.mjs linux build"),
|
|
);
|
|
});
|
|
|
|
it("picks the pnpm asset by container CPU so amd64 and arm64 hosts both work", () => {
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
const last = args[args.length - 1];
|
|
expect(last).toContain('case "$(uname -m)" in');
|
|
expect(last).toContain("x86_64) PNPM_ASSET=pnpm-linuxstatic-x64");
|
|
expect(last).toContain("aarch64) PNPM_ASSET=pnpm-linuxstatic-arm64");
|
|
expect(last).toMatch(/unsupported container arch/);
|
|
});
|
|
|
|
it("verifies the downloaded standalone pnpm binary before executing it", () => {
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
const last = args[args.length - 1];
|
|
expect(last).toMatch(/PNPM_SHA256=[a-f0-9]{64}/);
|
|
expect(last).toMatch(/sha256sum -c -/);
|
|
expect(last).toMatch(/\/tmp\/pnpm\.tmp/);
|
|
expect(last.indexOf("sha256sum -c -")).toBeLessThan(last.indexOf("mv /tmp/pnpm.tmp /tmp/pnpm"));
|
|
expect(last.indexOf("mv /tmp/pnpm.tmp /tmp/pnpm")).toBeLessThan(last.indexOf("chmod +x /tmp/pnpm"));
|
|
});
|
|
|
|
it("hardcoded pnpm version stays in lockstep with root package.json `packageManager`", () => {
|
|
// Guard against silent drift: if someone bumps packageManager in the
|
|
// root package.json but forgets to update PNPM_VERSION in linux.ts,
|
|
// the Linux container build would silently keep downloading the old pnpm.
|
|
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
const rootPkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf-8")) as {
|
|
packageManager?: string;
|
|
};
|
|
const match = String(rootPkg.packageManager ?? "").match(/^pnpm@(\d+\.\d+\.\d+)$/);
|
|
expect(match, `expected root packageManager "pnpm@x.y.z", got ${rootPkg.packageManager}`).not.toBeNull();
|
|
const expectedVersion = match![1];
|
|
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
const last = args[args.length - 1];
|
|
expect(last).toContain(`pnpm/releases/download/v${expectedVersion}/$PNPM_ASSET`);
|
|
});
|
|
|
|
it("container Node major stays in lockstep with root .node-version", () => {
|
|
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
const expectedMajor = readFileSync(join(repoRoot, ".node-version"), "utf-8").trim();
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
const last = args[args.length - 1];
|
|
const match = last.match(/\/tmp\/pnpm env use --global (\d+)\.\d+\.\d+/);
|
|
expect(match, "expected container bootstrap to install an explicit Node version").not.toBeNull();
|
|
expect(match?.[1]).toBe(expectedMajor);
|
|
});
|
|
|
|
it("forwards --dir /tools-pack so inner build output lands under the mounted host dir", () => {
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
const last = args[args.length - 1];
|
|
expect(last).toMatch(/--dir \/tools-pack/);
|
|
});
|
|
|
|
it("forwards --portable when config.portable is true", () => {
|
|
const args = buildDockerArgs({ ...makeConfig(), portable: true }, { uid: 1000, gid: 1000 });
|
|
const last = args[args.length - 1];
|
|
expect(last).toMatch(/--portable/);
|
|
});
|
|
|
|
it("forwards --require-vela-cli to the inner containerized build when strict packaging is requested", () => {
|
|
const args = buildDockerArgs({ ...makeConfig(), requireVelaCli: true }, { uid: 1000, gid: 1000 });
|
|
const last = args[args.length - 1];
|
|
expect(last).toMatch(/--require-vela-cli/);
|
|
});
|
|
|
|
it("omits --require-vela-cli from containerized builds by default", () => {
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
const last = args[args.length - 1];
|
|
expect(last).not.toMatch(/--require-vela-cli/);
|
|
});
|
|
|
|
it("omits --portable when config.portable is false", () => {
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
const last = args[args.length - 1];
|
|
expect(last).not.toMatch(/--portable/);
|
|
});
|
|
|
|
it("forwards a shell-quoted --app-version to the inner build", () => {
|
|
const args = buildDockerArgs(
|
|
{ ...makeConfig(), appVersion: "0.5.0-beta.1;echo-nope" },
|
|
{ uid: 1000, gid: 1000 },
|
|
);
|
|
const last = args[args.length - 1];
|
|
expect(last).toContain("--app-version '0.5.0-beta.1;echo-nope'");
|
|
});
|
|
|
|
it("shell-quotes apostrophes in --app-version", () => {
|
|
const args = buildDockerArgs(
|
|
{ ...makeConfig(), appVersion: "0.5.0-beta.1'quoted" },
|
|
{ uid: 1000, gid: 1000 },
|
|
);
|
|
const last = args[args.length - 1];
|
|
expect(last).toContain("--app-version '0.5.0-beta.1'\\''quoted'");
|
|
});
|
|
|
|
it("exports OD_TOOLS_PACK_PNPM_BIN=/tmp/pnpm so the inner build's production install skips npm", () => {
|
|
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
const envFlagIndex = args.findIndex(
|
|
(arg, i) => arg === "-e" && args[i + 1] === "OD_TOOLS_PACK_PNPM_BIN=/tmp/pnpm",
|
|
);
|
|
expect(envFlagIndex).toBeGreaterThan(-1);
|
|
});
|
|
});
|
|
|
|
describe("stopPackedLinuxHeadless", () => {
|
|
it("ignores the desktop AppImage identity marker and reads only the headless marker", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "od-linux-headless-marker-"));
|
|
const namespace = "marker-split";
|
|
const namespaceRoot = join(root, "runtime", "linux", "namespaces", namespace);
|
|
const config: ToolPackConfig = {
|
|
...makeConfig(),
|
|
namespace,
|
|
roots: {
|
|
...makeConfig().roots,
|
|
runtime: {
|
|
namespaceBaseRoot: join(root, "runtime", "linux", "namespaces"),
|
|
namespaceRoot,
|
|
},
|
|
},
|
|
};
|
|
const markerPath = join(namespaceRoot, "runtime", "desktop-root.json");
|
|
const stamp = {
|
|
app: APP_KEYS.DESKTOP,
|
|
ipc: "/deprecated-derived-identity",
|
|
mode: SIDECAR_MODES.RUNTIME,
|
|
namespace,
|
|
source: SIDECAR_SOURCES.PACKAGED,
|
|
};
|
|
|
|
try {
|
|
await mkdir(dirname(markerPath), { recursive: true });
|
|
await writeFile(
|
|
markerPath,
|
|
`${JSON.stringify({
|
|
appPath: "/tmp/Open-Design.AppImage",
|
|
executablePath: "/tmp/.mount_od/AppRun",
|
|
logPath: join(namespaceRoot, "logs", "desktop", "latest.log"),
|
|
namespaceRoot,
|
|
pid: Number.MAX_SAFE_INTEGER,
|
|
ppid: 1,
|
|
stamp,
|
|
startedAt: new Date(0).toISOString(),
|
|
updatedAt: new Date(0).toISOString(),
|
|
version: 1,
|
|
})}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const result = await stopPackedLinuxHeadless(config);
|
|
|
|
expect(result.status).toBe("not-running");
|
|
expect(result.fallback).toBeUndefined();
|
|
} finally {
|
|
await rm(root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("removes stale desktop AppImage markers during headless cleanup", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "od-linux-headless-cleanup-"));
|
|
const namespace = "cleanup-split";
|
|
const namespaceRoot = join(root, "runtime", "linux", "namespaces", namespace);
|
|
const config: ToolPackConfig = {
|
|
...makeConfig(),
|
|
namespace,
|
|
roots: {
|
|
...makeConfig().roots,
|
|
output: {
|
|
...makeConfig().roots.output,
|
|
namespaceRoot: join(root, "out", "linux", "namespaces", namespace),
|
|
},
|
|
runtime: {
|
|
namespaceBaseRoot: join(root, "runtime", "linux", "namespaces"),
|
|
namespaceRoot,
|
|
},
|
|
},
|
|
};
|
|
const markerPath = join(namespaceRoot, "runtime", "desktop-root.json");
|
|
const stamp = {
|
|
app: APP_KEYS.DESKTOP,
|
|
ipc: "/deprecated-derived-identity",
|
|
mode: SIDECAR_MODES.RUNTIME,
|
|
namespace,
|
|
source: SIDECAR_SOURCES.PACKAGED,
|
|
};
|
|
|
|
try {
|
|
await mkdir(dirname(markerPath), { recursive: true });
|
|
await writeFile(
|
|
markerPath,
|
|
`${JSON.stringify({
|
|
appPath: "/tmp/Open-Design.AppImage",
|
|
executablePath: "/tmp/.mount_od/AppRun",
|
|
logPath: join(namespaceRoot, "logs", "desktop", "latest.log"),
|
|
namespaceRoot,
|
|
pid: Number.MAX_SAFE_INTEGER,
|
|
ppid: 1,
|
|
stamp,
|
|
startedAt: new Date(0).toISOString(),
|
|
updatedAt: new Date(0).toISOString(),
|
|
version: 1,
|
|
})}\n`,
|
|
"utf8",
|
|
);
|
|
await mkdir(config.roots.output.namespaceRoot, { recursive: true });
|
|
|
|
const result = await cleanupPackedLinuxNamespace(config, { headless: true });
|
|
|
|
expect(result.skipped).toBe(false);
|
|
expect(result.removedOutputRoot).toBe(true);
|
|
expect(result.removedRuntimeNamespaceRoot).toBe(true);
|
|
expect(await pathExists(markerPath)).toBe(false);
|
|
expect(await pathExists(namespaceRoot)).toBe(false);
|
|
expect(await pathExists(config.roots.output.namespaceRoot)).toBe(false);
|
|
} finally {
|
|
await rm(root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("skips headless cleanup while the desktop marker PID is live in the snapshot table", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "od-linux-headless-cleanup-live-"));
|
|
const namespace = "cleanup-split-live";
|
|
const namespaceRoot = join(root, "runtime", "linux", "namespaces", namespace);
|
|
const config: ToolPackConfig = {
|
|
...makeConfig(),
|
|
namespace,
|
|
roots: {
|
|
...makeConfig().roots,
|
|
output: {
|
|
...makeConfig().roots.output,
|
|
namespaceRoot: join(root, "out", "linux", "namespaces", namespace),
|
|
},
|
|
runtime: {
|
|
namespaceBaseRoot: join(root, "runtime", "linux", "namespaces"),
|
|
namespaceRoot,
|
|
},
|
|
},
|
|
};
|
|
try {
|
|
vi.mocked(findSidecarProcesses).mockImplementation(async (stamp) =>
|
|
stamp.mode === SIDECAR_MODES.RUNTIME ? [{ command: "desktop", pid: process.pid, ppid: 1 }] : [],
|
|
);
|
|
await mkdir(namespaceRoot, { recursive: true });
|
|
await mkdir(config.roots.output.namespaceRoot, { recursive: true });
|
|
|
|
const result = await cleanupPackedLinuxNamespace(config, { headless: true });
|
|
|
|
expect(result.skipped).toBe(true);
|
|
expect(result.removedOutputRoot).toBe(false);
|
|
expect(result.removedRuntimeNamespaceRoot).toBe(false);
|
|
expect(await pathExists(namespaceRoot)).toBe(true);
|
|
expect(await pathExists(config.roots.output.namespaceRoot)).toBe(true);
|
|
} finally {
|
|
vi.mocked(findSidecarProcesses).mockResolvedValue([]);
|
|
await rm(root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("stopPackedLinuxApp", () => {
|
|
it("stops both exact launch sources without consulting derived files", async () => {
|
|
const stop = vi.mocked(stopSidecar);
|
|
stop.mockClear();
|
|
stop.mockResolvedValue({
|
|
alreadyStopped: true,
|
|
forcedPids: [],
|
|
gracefulAccepted: false,
|
|
matchedPids: [],
|
|
remainingPids: [],
|
|
stoppedPids: [],
|
|
});
|
|
await expect(stopPackedLinuxApp(makeConfig())).resolves.toMatchObject({ status: "not-running" });
|
|
expect(stop).toHaveBeenCalledTimes(12);
|
|
expect(stop).toHaveBeenCalledWith(
|
|
expect.objectContaining({ source: SIDECAR_SOURCES.TOOLS_PACK }),
|
|
undefined,
|
|
);
|
|
expect(stop).toHaveBeenCalledWith(
|
|
expect.objectContaining({ source: SIDECAR_SOURCES.PACKAGED }),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("resolveProductionInstallCommand", () => {
|
|
it("defaults to npm install --omit=dev --no-package-lock when OD_TOOLS_PACK_PNPM_BIN is unset", () => {
|
|
expect(resolveProductionInstallCommand({})).toEqual({
|
|
command: "npm",
|
|
args: ["install", "--omit=dev", "--no-package-lock"],
|
|
});
|
|
});
|
|
|
|
it("treats an empty OD_TOOLS_PACK_PNPM_BIN as unset and keeps the npm host default", () => {
|
|
expect(resolveProductionInstallCommand({ OD_TOOLS_PACK_PNPM_BIN: "" })).toEqual({
|
|
command: "npm",
|
|
args: ["install", "--omit=dev", "--no-package-lock"],
|
|
});
|
|
});
|
|
|
|
it("uses OD_TOOLS_PACK_PNPM_BIN with hoisted-layout pnpm flags when set", () => {
|
|
// --config.node-linker=hoisted intentionally matches the prior
|
|
// npm/electron-builder packaging layout so the AppImage pack step keeps
|
|
// working when the assembled-app install runs through pnpm.
|
|
expect(
|
|
resolveProductionInstallCommand({ OD_TOOLS_PACK_PNPM_BIN: "/tmp/pnpm" }),
|
|
).toEqual({
|
|
command: "/tmp/pnpm",
|
|
args: ["install", "--prod", "--no-lockfile", "--config.node-linker=hoisted"],
|
|
});
|
|
});
|
|
|
|
it("chains end-to-end with buildDockerArgs: docker exports OD_TOOLS_PACK_PNPM_BIN and the resolver returns the standalone pnpm install for that value", () => {
|
|
const dockerArgs = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
|
const envFlagIndex = dockerArgs.findIndex(
|
|
(arg, i) => arg === "-e" && dockerArgs[i + 1]?.startsWith("OD_TOOLS_PACK_PNPM_BIN="),
|
|
);
|
|
expect(envFlagIndex).toBeGreaterThan(-1);
|
|
const envValue = dockerArgs[envFlagIndex + 1]?.split("=")[1];
|
|
expect(envValue).toBe("/tmp/pnpm");
|
|
|
|
const resolved = resolveProductionInstallCommand({ OD_TOOLS_PACK_PNPM_BIN: envValue });
|
|
expect(resolved).toEqual({
|
|
command: "/tmp/pnpm",
|
|
args: ["install", "--prod", "--no-lockfile", "--config.node-linker=hoisted"],
|
|
});
|
|
expect(resolved.command).not.toBe("npm");
|
|
});
|
|
});
|
|
|
|
describe("renderDesktopTemplate", () => {
|
|
const template = `[Desktop Entry]
|
|
Type=Application
|
|
Name=Open Design (@@NAMESPACE@@)
|
|
Exec=env -u ELECTRON_RUN_AS_NODE OD_PACKAGED_NAMESPACE=@@NAMESPACE@@ @@EXEC_PATH@@ --appimage-extract-and-run %U
|
|
Icon=@@ICON_PATH@@
|
|
MimeType=x-scheme-handler/od;
|
|
`;
|
|
|
|
it("substitutes all @@TOKEN@@ placeholders", () => {
|
|
const out = renderDesktopTemplate(template, {
|
|
namespace: "default",
|
|
execPath: "/home/u/.local/bin/Open-Design.default.AppImage",
|
|
iconName: "open-design-default",
|
|
});
|
|
expect(out).toContain("Name=Open Design (default)");
|
|
expect(out).toContain(
|
|
"Exec=env -u ELECTRON_RUN_AS_NODE OD_PACKAGED_NAMESPACE=default /home/u/.local/bin/Open-Design.default.AppImage --appimage-extract-and-run %U",
|
|
);
|
|
expect(out).toContain("Icon=open-design-default");
|
|
});
|
|
|
|
it("uses OD_PACKAGED_NAMESPACE (not OD_NAMESPACE) so apps/packaged actually picks up the namespace override", () => {
|
|
const out = renderDesktopTemplate(template, {
|
|
namespace: "ns",
|
|
execPath: "/x",
|
|
iconName: "open-design-ns",
|
|
});
|
|
expect(out).toMatch(/^Exec=env -u ELECTRON_RUN_AS_NODE OD_PACKAGED_NAMESPACE=ns /m);
|
|
expect(out).not.toMatch(/OD_NAMESPACE=/);
|
|
});
|
|
|
|
it("unsets ELECTRON_RUN_AS_NODE on the Exec= line so desktop launches run Electron normally", () => {
|
|
const out = renderDesktopTemplate(template, {
|
|
namespace: "ns",
|
|
execPath: "/x",
|
|
iconName: "open-design-ns",
|
|
});
|
|
expect(out).toMatch(/^Exec=env -u ELECTRON_RUN_AS_NODE /m);
|
|
});
|
|
|
|
it("preserves --appimage-extract-and-run on the Exec= line so menu launches bypass FUSE", () => {
|
|
const out = renderDesktopTemplate(template, {
|
|
namespace: "ns",
|
|
execPath: "/x",
|
|
iconName: "open-design-ns",
|
|
});
|
|
expect(out).toMatch(/^Exec=.*--appimage-extract-and-run .*%U$/m);
|
|
});
|
|
|
|
it("leaves no @@...@@ tokens unsubstituted", () => {
|
|
const out = renderDesktopTemplate(template, {
|
|
namespace: "ns",
|
|
execPath: "/x",
|
|
iconName: "open-design-ns",
|
|
});
|
|
expect(out).not.toMatch(/@@[A-Z_]+@@/);
|
|
});
|
|
|
|
it("preserves the MimeType=x-scheme-handler/od; line", () => {
|
|
const out = renderDesktopTemplate(template, {
|
|
namespace: "ns",
|
|
execPath: "/x",
|
|
iconName: "open-design-ns",
|
|
});
|
|
expect(out).toContain("MimeType=x-scheme-handler/od;");
|
|
});
|
|
});
|
|
|
|
describe("renderLinuxPackagedMainEntry", () => {
|
|
it("loads the ESM packaged entry without require or temporary keepalive handles", () => {
|
|
const out = renderLinuxPackagedMainEntry();
|
|
|
|
expect(out).toContain('import("@open-design/packaged")');
|
|
expect(out).not.toContain('require("@open-design/packaged")');
|
|
expect(out).not.toContain("setTimeout");
|
|
});
|
|
});
|
|
|
|
describe("LINUX_APPIMAGE_EXECUTABLE_ARGS", () => {
|
|
it("keeps the AppImage no-sandbox fallback explicit for constrained Linux hosts", () => {
|
|
expect([...LINUX_APPIMAGE_EXECUTABLE_ARGS]).toEqual(["--no-sandbox"]);
|
|
});
|
|
});
|
|
|
|
describe("renderLinuxAppImageAppRun", () => {
|
|
it("unsets ELECTRON_RUN_AS_NODE before execing the Electron binary", () => {
|
|
const out = renderLinuxAppImageAppRun();
|
|
|
|
expect(out).toContain("unset ELECTRON_RUN_AS_NODE");
|
|
expect(out.indexOf("unset ELECTRON_RUN_AS_NODE")).toBeLessThan(out.indexOf('exec "$BIN"'));
|
|
expect(out).toContain('BIN="$APPDIR/Open Design"');
|
|
});
|
|
|
|
it("preserves AppImageLauncher install-only behavior", () => {
|
|
const out = renderLinuxAppImageAppRun();
|
|
|
|
expect(out).toContain('if [ -z "$APPIMAGE_EXIT_AFTER_INSTALL" ] ; then');
|
|
expect(out).toContain("trap atexit EXIT");
|
|
});
|
|
|
|
it("passes desktop Exec arguments through to Electron", () => {
|
|
const out = renderLinuxAppImageAppRun();
|
|
|
|
expect(out).toContain('args=("$@")');
|
|
expect(out).toContain('exec "$BIN" "${args[@]}"');
|
|
});
|
|
|
|
it("sets APPIMAGE when running an extracted AppRun directly", () => {
|
|
const out = renderLinuxAppImageAppRun();
|
|
|
|
expect(out).toContain('APPIMAGE="$APPDIR/AppRun"');
|
|
});
|
|
|
|
linuxOnlyIt("exports APPIMAGE fallback to the execed Electron process", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "od-linux-apprun-export-"));
|
|
const appDir = join(root, "AppDir");
|
|
const appRunPath = join(appDir, "AppRun");
|
|
const observedEnvPath = join(root, "observed-env.txt");
|
|
const electronPath = join(appDir, "Open Design");
|
|
|
|
try {
|
|
await mkdir(appDir, { recursive: true });
|
|
await writeFile(appRunPath, renderLinuxAppImageAppRun(), "utf8");
|
|
await chmod(appRunPath, 0o755);
|
|
await writeFile(
|
|
electronPath,
|
|
`#!/bin/bash
|
|
{
|
|
printf 'APPIMAGE=%s\\n' "$APPIMAGE"
|
|
printf 'ELECTRON_RUN_AS_NODE=%s\\n' "\${ELECTRON_RUN_AS_NODE-unset}"
|
|
} > ${JSON.stringify(observedEnvPath)}
|
|
`,
|
|
"utf8",
|
|
);
|
|
await chmod(electronPath, 0o755);
|
|
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
APPDIR: appDir,
|
|
ELECTRON_RUN_AS_NODE: "1",
|
|
};
|
|
delete env.APPIMAGE;
|
|
const child = spawn(appRunPath, [], { env, stdio: "ignore" });
|
|
const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
|
child.once("error", reject);
|
|
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
});
|
|
|
|
expect(exit).toEqual({ code: 0, signal: null });
|
|
expect(await readFile(observedEnvPath, "utf8")).toBe(
|
|
`APPIMAGE=${appRunPath}\nELECTRON_RUN_AS_NODE=unset\n`,
|
|
);
|
|
} finally {
|
|
await rm(root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("createLinuxDesktopLaunchEnv", () => {
|
|
it("strips ELECTRON_RUN_AS_NODE before spawning the Electron AppImage", () => {
|
|
const config = makeConfig();
|
|
const stamp = {
|
|
app: APP_KEYS.DESKTOP,
|
|
channel: "stable",
|
|
mode: SIDECAR_MODES.RUNTIME,
|
|
namespace: "default",
|
|
source: SIDECAR_SOURCES.TOOLS_PACK,
|
|
};
|
|
|
|
const env = createLinuxDesktopLaunchEnv(config, stamp, {
|
|
ELECTRON_RUN_AS_NODE: "1",
|
|
KEEP_ME: "yes",
|
|
});
|
|
|
|
expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined();
|
|
expect(env.KEEP_ME).toBe("yes");
|
|
expect(env.OD_SIDECAR_BASE).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("sanitizeNamespace", () => {
|
|
it("replaces non-alphanumeric chars with hyphens", () => {
|
|
expect(sanitizeNamespace("a/b c")).toBe("a-b-c");
|
|
});
|
|
});
|
|
|
|
describe("resolveLinuxLifecycleMode", () => {
|
|
it("uses headless mode for every lifecycle action when --headless is set", () => {
|
|
expect(resolveLinuxLifecycleMode({ headless: true }, "install")).toBe("headless");
|
|
expect(resolveLinuxLifecycleMode({ headless: true }, "start")).toBe("headless");
|
|
expect(resolveLinuxLifecycleMode({ headless: true }, "stop")).toBe("headless");
|
|
expect(resolveLinuxLifecycleMode({ headless: true }, "uninstall")).toBe("headless");
|
|
expect(resolveLinuxLifecycleMode({ headless: true }, "cleanup")).toBe("headless");
|
|
});
|
|
|
|
it("uses appimage mode when --headless is omitted", () => {
|
|
expect(resolveLinuxLifecycleMode({}, "install")).toBe("appimage");
|
|
expect(resolveLinuxLifecycleMode({}, "start")).toBe("appimage");
|
|
expect(resolveLinuxLifecycleMode({}, "stop")).toBe("appimage");
|
|
expect(resolveLinuxLifecycleMode({}, "uninstall")).toBe("appimage");
|
|
expect(resolveLinuxLifecycleMode({}, "cleanup")).toBe("appimage");
|
|
});
|
|
});
|
|
|
|
describe("shouldRejectLinuxHeadlessInspectOptions", () => {
|
|
it("allows status-only headless inspect", () => {
|
|
expect(shouldRejectLinuxHeadlessInspectOptions({})).toBe(false);
|
|
});
|
|
|
|
it("rejects headless eval and screenshot requests", () => {
|
|
expect(shouldRejectLinuxHeadlessInspectOptions({ expr: "document.title" })).toBe(true);
|
|
expect(shouldRejectLinuxHeadlessInspectOptions({ path: "/tmp/open-design-linux.png" })).toBe(true);
|
|
expect(
|
|
shouldRejectLinuxHeadlessInspectOptions({
|
|
expr: "document.title",
|
|
path: "/tmp/open-design-linux.png",
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("inspectPackedLinuxApp", () => {
|
|
it("rejects unsupported headless inspect options before opening IPC", async () => {
|
|
const getStatus = vi.mocked(getSidecarStatus);
|
|
getStatus.mockClear();
|
|
|
|
await expect(
|
|
inspectPackedLinuxApp(makeConfig(), {
|
|
expr: "document.title",
|
|
headless: true,
|
|
}),
|
|
).rejects.toThrow("linux inspect --headless supports status only; omit --expr and --path");
|
|
expect(getStatus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows desktop inspect eval and screenshot options when headless is omitted", async () => {
|
|
vi.mocked(getSidecarStatus).mockImplementation(async (stamp) => {
|
|
if (stamp.source === SIDECAR_SOURCES.TOOLS_PACK) return { state: "running", url: "od://app/" };
|
|
throw new Error("packaged status unavailable");
|
|
});
|
|
vi.mocked(invokeSidecar)
|
|
.mockResolvedValueOnce({ ok: true, value: "Open Design" })
|
|
.mockResolvedValueOnce({ path: "/tmp/open-design-linux.png" });
|
|
|
|
const result = await inspectPackedLinuxApp(makeConfig(), {
|
|
expr: "document.title",
|
|
path: "/tmp/open-design-linux.png",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
eval: { ok: true, value: "Open Design" },
|
|
screenshot: { path: "/tmp/open-design-linux.png" },
|
|
status: { state: "running", url: "od://app/" },
|
|
});
|
|
expect(getSidecarStatus).toHaveBeenCalledTimes(2);
|
|
expect(invokeSidecar).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("targets packaged IPC when a tools-pack process marker is stale", async () => {
|
|
vi.mocked(findSidecarProcesses).mockImplementation(async (stamp) =>
|
|
stamp.source === SIDECAR_SOURCES.TOOLS_PACK ? [{ command: "desktop", pid: 42, ppid: 1 }] : [],
|
|
);
|
|
vi.mocked(getSidecarStatus).mockImplementation(async (stamp) => {
|
|
if (stamp.source === SIDECAR_SOURCES.PACKAGED) return { state: "running", url: "od://app/" };
|
|
throw new Error("stale tools-pack endpoint");
|
|
});
|
|
vi.mocked(invokeSidecar).mockResolvedValueOnce({ ok: true, value: "Open Design" });
|
|
|
|
const result = await inspectPackedLinuxApp(makeConfig(), { expr: "document.title" });
|
|
|
|
expect(result.status).toEqual({ state: "running", url: "od://app/" });
|
|
expect(invokeSidecar).toHaveBeenCalledWith(
|
|
expect.objectContaining({ source: SIDECAR_SOURCES.PACKAGED }),
|
|
SIDECAR_MESSAGES.EVAL,
|
|
{ expression: "document.title" },
|
|
{ timeoutMs: 5000 },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("matchesAppImageProcess", () => {
|
|
const installPath = "/home/u/.local/bin/Open-Design.default.AppImage";
|
|
|
|
it("matches FUSE-mode (executable === installPath)", () => {
|
|
const ok = matchesAppImageProcess(
|
|
{ pid: 1234, executable: installPath, env: {} },
|
|
installPath,
|
|
);
|
|
expect(ok).toBe(true);
|
|
});
|
|
|
|
it("matches extracted-mode (env.APPIMAGE === installPath, executable matches /tmp/.mount_*/AppRun)", () => {
|
|
const ok = matchesAppImageProcess(
|
|
{ pid: 1234, executable: "/tmp/.mount_abc123/AppRun", env: { APPIMAGE: installPath } },
|
|
installPath,
|
|
);
|
|
expect(ok).toBe(true);
|
|
});
|
|
|
|
it("rejects unrelated processes", () => {
|
|
const ok = matchesAppImageProcess(
|
|
{ pid: 9999, executable: "/usr/bin/node", env: {} },
|
|
installPath,
|
|
);
|
|
expect(ok).toBe(false);
|
|
});
|
|
|
|
it("rejects extracted-mode with mismatched APPIMAGE env", () => {
|
|
const ok = matchesAppImageProcess(
|
|
{ pid: 1234, executable: "/tmp/.mount_abc/AppRun", env: { APPIMAGE: "/other/path.AppImage" } },
|
|
installPath,
|
|
);
|
|
expect(ok).toBe(false);
|
|
});
|
|
|
|
it("rejects extracted-mode when APPIMAGE env is missing", () => {
|
|
const ok = matchesAppImageProcess(
|
|
{ pid: 1234, executable: "/tmp/.mount_abc123/AppRun", env: {} },
|
|
installPath,
|
|
);
|
|
expect(ok).toBe(false);
|
|
});
|
|
|
|
it("matches direct AppRun fallback when APPIMAGE points to the sibling AppRun", () => {
|
|
const ok = matchesAppImageProcess(
|
|
{
|
|
pid: 1234,
|
|
executable: "/tmp/appimage_extracted_fe548e54/Open Design",
|
|
env: { APPIMAGE: "/tmp/appimage_extracted_fe548e54/AppRun" },
|
|
},
|
|
installPath,
|
|
);
|
|
expect(ok).toBe(true);
|
|
});
|
|
|
|
it("rejects direct AppRun fallback when APPIMAGE points outside the executable directory", () => {
|
|
const ok = matchesAppImageProcess(
|
|
{
|
|
pid: 1234,
|
|
executable: "/tmp/appimage_extracted_fe548e54/Open Design",
|
|
env: { APPIMAGE: "/tmp/other/AppRun" },
|
|
},
|
|
installPath,
|
|
);
|
|
expect(ok).toBe(false);
|
|
});
|
|
|
|
it("matches --appimage-extract-and-run mode (executable in /tmp/appimage_extracted_*/<binary>)", () => {
|
|
const ok = matchesAppImageProcess(
|
|
{
|
|
pid: 1234,
|
|
executable: "/tmp/appimage_extracted_fe548e54/Open Design",
|
|
env: { APPIMAGE: installPath },
|
|
},
|
|
installPath,
|
|
);
|
|
expect(ok).toBe(true);
|
|
});
|
|
|
|
it("rejects extract-and-run mode with mismatched APPIMAGE env", () => {
|
|
const ok = matchesAppImageProcess(
|
|
{
|
|
pid: 1234,
|
|
executable: "/tmp/appimage_extracted_fe548e54/Open Design",
|
|
env: { APPIMAGE: "/elsewhere/Other.AppImage" },
|
|
},
|
|
installPath,
|
|
);
|
|
expect(ok).toBe(false);
|
|
});
|
|
});
|