Skip to content
Source
Sign inSign up

electron-launcher.test.mjs

308 lines10.1 KB

1import * as NodeChildProcess from "node:child_process";
2import * as NodeEvents from "node:events";
3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6import * as NodeVM from "node:vm";
7​
8import { assert, describe, it } from "vite-plus/test";
9​
10import {
11 makeDevelopmentEnvironmentScript,
12 makeDevelopmentLauncherScript,
13 resolveElectronBinaryPath,
14 resolveMacLauncherPaths,
15 writeDevelopmentLauncherScript,
16} from "./electron-launcher.mjs";
17​
18describe("electron development launcher", () => {
19 it("uses captured values only as fallbacks for a live runner environment", () => {
20 const environmentScript = makeDevelopmentEnvironmentScript({
21 VITE_DEV_SERVER_URL: "http://127.0.0.1:8526",
22 T3CODE_PORT: "16566",
23 T3CODE_HOME: "/tmp/t3",
24 T3CODE_OTLP_PROTOCOL: "http/protobuf",
25 });
26​
27 assert.include(
28 environmentScript,
29 "if [ -z \"${VITE_DEV_SERVER_URL:-}\" ]; then export VITE_DEV_SERVER_URL='http://127.0.0.1:8526'; fi",
30 );
31 assert.include(
32 environmentScript,
33 "if [ -z \"${T3CODE_OTLP_PROTOCOL:-}\" ]; then export T3CODE_OTLP_PROTOCOL='http/protobuf'; fi",
34 );
35 assert.notInclude(environmentScript, "\nexport VITE_DEV_SERVER_URL=");
36 });
37​
38 it("keeps the launcher script free of volatile environment values", () => {
39 const script = makeDevelopmentLauncherScript({
40 electronBinaryPath: "/repo/node_modules/electron/Electron",
41 mainEntryPath: "/repo/apps/desktop/dist-electron/main.cjs",
42 desktopRoot: "/repo/apps/desktop",
43 environmentFilePath: "/repo/apps/desktop/.electron-runtime/dev-environment.sh",
44 });
45​
46 assert.include(
47 script,
48 "if [ -f '/repo/apps/desktop/.electron-runtime/dev-environment.sh' ]; then . '/repo/apps/desktop/.electron-runtime/dev-environment.sh'; fi",
49 );
50 assert.notInclude(script, "VITE_DEV_SERVER_URL");
51 assert.include(
52 script,
53 "exec '/repo/node_modules/electron/Electron' --t3code-dev-root='/repo/apps/desktop' '/repo/apps/desktop/dist-electron/main.cjs' \"$@\"",
54 );
55 });
56​
57 it("repairs Electron before loading the package entrypoint", () => {
58 const calls = [];
59 const electronPath = resolveElectronBinaryPath({
60 ensureRuntime: () => {
61 calls.push("ensure");
62 },
63 createRequire: () => (specifier) => {
64 calls.push(`require:${specifier}`);
65 return "/repo/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron";
66 },
67 moduleUrl: import.meta.url,
68 });
69​
70 assert.equal(
71 electronPath,
72 "/repo/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron",
73 );
74 assert.deepEqual(calls, ["ensure", "require:electron"]);
75 });
76​
77 it("keeps the native Electron executable name inside the branded macOS bundle", () => {
78 const paths = resolveMacLauncherPaths(
79 "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app",
80 "T3 Code (Dev)",
81 );
82​
83 assert.equal(paths.launcherExecutableName, "T3 Code (Dev) Launcher");
84 assert.equal(
85 paths.launcherBinaryPath,
86 "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/T3 Code (Dev) Launcher",
87 );
88 assert.equal(
89 paths.runtimeElectronBinaryPath,
90 "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/Electron",
91 );
92​
93 const script = makeDevelopmentLauncherScript({
94 electronBinaryPath: paths.runtimeElectronBinaryPath,
95 mainEntryPath: "/repo/apps/desktop/dist-electron/main.cjs",
96 desktopRoot: "/repo/apps/desktop",
97 environmentFilePath: "/repo/apps/desktop/.electron-runtime/dev-environment.sh",
98 });
99 assert.include(
100 script,
101 "exec '/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/Electron'",
102 );
103 assert.notInclude(script, "node_modules/electron");
104 });
105​
106 it("restores execute permissions on an unchanged launcher", () => {
107 const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-launcher-"));
108 const launcherPath = NodePath.join(directory, "launcher");
109 try {
110 writeDevelopmentLauncherScript(launcherPath, "/runtime/Electron");
111 NodeFS.chmodSync(launcherPath, 0o644);
112​
113 assert.isFalse(writeDevelopmentLauncherScript(launcherPath, "/runtime/Electron"));
114 assert.equal(NodeFS.statSync(launcherPath).mode & 0o777, 0o755);
115 } finally {
116 NodeFS.rmSync(directory, { recursive: true, force: true });
117 }
118 });
119});
120​
121// Supply inert imports so the real startup runs without launching Electron or signalling processes.
122async function runDevElectronStartup({
123 desktopDir = "/repo/apps/desktop",
124 spawnSync,
125 spawn,
126 ...context
127}) {
128 const source = NodeFS.readFileSync(new URL("./dev-electron.mjs", import.meta.url), "utf8");
129 await NodeVM.runInNewContext(
130 `(async () => { ${source.replace(/^import\s[\s\S]*?;\n/gm, "")} })()`,
131 {
132 NodeChildProcess: {
133 execFileSync: () => undefined,
134 spawnSync: spawnSync ?? (() => undefined),
135 spawn: spawn ?? (() => new NodeEvents.EventEmitter()),
136 },
137 NodeFS: { watch: () => ({ close: () => undefined }) },
138 NodeOS: { platform: () => "linux" },
139 NodePath,
140 desktopDir,
141 resolveDevProtocolClient: () => null,
142 resolveElectronLaunchCommand: (args) => ({ electronPath: "electron", args }),
143 waitForResources: async () => undefined,
144 URL,
145 ...makeFakeClock(),
146 process: {
147 env: { VITE_DEV_SERVER_URL: "http://localhost:1234" },
148 execPath: "node",
149 ppid: 1,
150 once: () => undefined,
151 },
152 ...context,
153 },
154 );
156​
157async function startupCleanupPattern(desktopDir) {
158 let pattern;
159 await runDevElectronStartup({
160 desktopDir,
161 spawnSync: (command, args) => {
162 if (command === "pkill" && args.includes("-f")) pattern = args.at(-1);
163 },
164 });
165 assert.isString(pattern);
166 return pattern;
168​
169function makeFakeClock() {
170 let now = 0;
171 let timers = [];
172 const schedule = (callback, delay, repeat) => {
173 const timer = { callback, due: now + (delay ?? 0), repeat: repeat ? (delay ?? 0) : null };
174 timer.handle = { unref: () => timer.handle, ref: () => timer.handle };
175 timers.push(timer);
176 return timer.handle;
177 };
178 const clear = (handle) => {
179 timers = timers.filter((timer) => timer.handle !== handle);
180 };
181 const flushMicrotasks = async () => {
182 for (let index = 0; index < 50; index += 1) await Promise.resolve();
183 };
184 return {
185 setTimeout: (callback, delay) => schedule(callback, delay, false),
186 setInterval: (callback, delay) => schedule(callback, delay, true),
187 clearTimeout: clear,
188 clearInterval: clear,
189 async advance(ms) {
190 const end = now + ms;
191 for (;;) {
192 await flushMicrotasks();
193 const next = timers
194 .filter((timer) => timer.due <= end)
195 .toSorted((a, b) => a.due - b.due)[0];
196 if (!next) break;
197 now = next.due;
198 if (next.repeat === null) clear(next.handle);
199 else next.due = now + Math.max(next.repeat, 1);
200 next.callback();
201 }
202 now = end;
203 await flushMicrotasks();
204 },
205 };
207​
208it("dev supervisor stops Electron and exits once its parent process goes away", async () => {
209 const clock = makeFakeClock();
210 let ppid = 4242;
211 const exitCodes = [];
212 const appSignals = [];
213 await runDevElectronStartup({
214 ...clock,
215 spawn: () => {
216 const app = new NodeEvents.EventEmitter();
217 app.pid = 5151;
218 app.kill = (signal) => {
219 appSignals.push(signal);
220 app.emit("exit", null, signal);
221 };
222 return app;
223 },
224 process: {
225 env: { VITE_DEV_SERVER_URL: "http://localhost:1234" },
226 execPath: "node",
227 pid: 4343,
228 get ppid() {
229 return ppid;
230 },
231 once: () => undefined,
232 exit: (code) => {
233 exitCodes.push(code);
234 },
235 },
236 });
237​
238 await clock.advance(5_000);
239 assert.deepEqual(appSignals, [], "supervisor stopped Electron while its parent was alive");
240​
241 // The launching process died without signalling us; the OS reparented the supervisor.
242 ppid = 1;
243 await clock.advance(5_000);
244​
245 assert.include(
246 appSignals,
247 "SIGTERM",
248 "supervisor kept Electron running after its parent process went away",
249 );
250 assert.lengthOf(exitCodes, 1, "supervisor did not exit after its parent process went away");
251});
252​
253it
254 // oxlint-disable-next-line t3code/no-global-process-runtime -- Native pgrep is unavailable on Windows.
255 .skipIf(NodeOS.platform() === "win32")
256 .each([
257 "/tmp/t3-feature.one/apps/desktop",
258 "/tmp/t3-feature.one [two](three)+?^${four}|back\\slash*/apps/desktop",
259 ])("dev cleanup matches only the literal root argument: %s", async (desktopDir) => {
260 const pattern = await startupCleanupPattern(desktopDir);
261 const flag = `--t3code-dev-root=${desktopDir}`;
262 const argvCases = [
263 [flag],
264 [flag, "main.cjs"],
265 [flag.replace(".one", "Xone")],
266 [`${flag}-two`],
267 [`prefix${flag}`],
268 [`--t3code-dev-root=/other${desktopDir}`],
269 ];
270 const children = [];
271 try {
272 for (const args of argvCases) {
273 const child = NodeChildProcess.spawn(
274 process.execPath,
275 ["-e", "process.send('ready'); setInterval(() => {}, 1000)", "--", ...args],
276 { stdio: ["ignore", "ignore", "inherit", "ipc"] },
277 );
278 children.push(child);
279 await Promise.race([
280 NodeEvents.once(child, "message"),
281 NodeEvents.once(child, "exit").then(() => {
282 throw new Error("Fixture exited before readiness");
283 }),
284 ]);
285 }
286 // Use the native ERE engine, but never execute the launcher's signal-producing command.
287 const result = NodeChildProcess.spawnSync(
288 "pgrep",
289 ["-P", String(process.pid), "-f", "--", pattern],
290 { encoding: "utf8" },
291 );
292 assert.equal(result.status, 0, result.stderr);
293 const matches = result.stdout.trim().split(/\s+/).map(Number);
294 assert.sameMembers(
295 matches,
296 children.slice(0, 2).map((child) => child.pid),
297 );
298 } finally {
299 await Promise.all(
300 children.map(async (child) => {
301 if (child.exitCode !== null || child.signalCode !== null) return;
302 const exited = NodeEvents.once(child, "exit");
303 child.kill("SIGTERM");
304 await exited;
305 }),
306 );
307 }
308});