/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { test } from 'node:test'; test('a launcher signal cancels preparation before Electron can spawn', async () => { const { createDevelopmentLaunchSession } = await import('./dev-app-runtime.mjs'); const signals = new EventEmitter(); let finishPreparation; const preparation = new Promise((resolve) => { finishPreparation = resolve; }); let finishStopping; const stopped = new Promise((resolve) => { finishStopping = resolve; }); const spawns = []; const exits = []; let closes = 0; const session = createDevelopmentLaunchSession({ signals, close: async () => { closes += 1; }, exit: (code) => { exits.push(code); finishStopping(); }, }); const launching = session.start({ prepareMacosDevelopmentLaunch: () => preparation, createDevelopmentLaunchFiles: () => { throw new Error('cancelled launch must not create files'); }, spawn: (...args) => spawns.push(args), }); signals.emit('SIGINT'); finishPreparation({ appPath: '/tmp/Maka Dev.app', resultFileArgPrefix: '--result=' }); assert.equal(await launching, null); await stopped; assert.deepEqual(spawns, []); assert.equal(closes, 1); assert.deepEqual(exits, [0]); }); test('a launcher signal adopts and stops a delayed launch handle exactly once', async () => { const { createDevelopmentLaunchSession } = await import('./dev-app-runtime.mjs'); const signals = new EventEmitter(); let publishHandle; const pendingHandle = new Promise((resolve) => { publishHandle = resolve; }); let finishStopping; const stopped = new Promise((resolve) => { finishStopping = resolve; }); let stops = 0; const session = createDevelopmentLaunchSession({ signals, startDevelopmentApp: () => pendingHandle, close: async () => {}, exit: finishStopping, }); const launching = session.start(); signals.emit('SIGTERM'); signals.emit('SIGHUP'); publishHandle({ stop: async () => { stops += 1; } }); await launching; await stopped; assert.equal(stops, 1); }); test('cancelling during launch preparation prevents any later process spawn', async () => { const { startDevelopmentApp } = await import('./dev-app-runtime.mjs'); const controller = new AbortController(); let finishPreparation; const preparation = new Promise((resolve) => { finishPreparation = resolve; }); const launchFiles = []; const spawns = []; const launching = startDevelopmentApp({ signal: controller.signal, prepareMacosDevelopmentLaunch: () => preparation, devSingleInstanceConstants: async () => ({}), createDevelopmentLaunchFiles: () => { launchFiles.push('created'); return { logFile: '/tmp/log', resultFile: '/tmp/result' }; }, spawn: (...args) => { spawns.push(args); return { exitCode: null, killed: false, once: () => {}, kill: () => {} }; }, }); controller.abort(); finishPreparation({ appPath: '/tmp/Maka Dev.app', resultFileArgPrefix: '--result=' }); await assert.rejects(launching, (error) => error === controller.signal.reason); assert.deepEqual(launchFiles, []); assert.deepEqual(spawns, []); }); test('cancelling while the plain launcher loads its lock contract prevents spawn', async () => { const { startDevelopmentApp } = await import('./dev-app-runtime.mjs'); const controller = new AbortController(); let finishContractLoad; const contract = new Promise((resolve) => { finishContractLoad = resolve; }); const spawns = []; const launching = startDevelopmentApp({ signal: controller.signal, prepareMacosDevelopmentLaunch: async () => null, devSingleInstanceConstants: () => contract, spawn: (...args) => spawns.push(args), }); await Promise.resolve(); controller.abort(); finishContractLoad({ DEV_CONFLICT_HANDLED_BY_LAUNCHER_FLAG: '--handled' }); await assert.rejects(launching, (error) => error === controller.signal.reason); assert.deepEqual(spawns, []); }); test('launcher and core agree on the loser contract', async () => { const { devSingleInstanceConstants } = await import('./dev-app-runtime.mjs'); const core = await import('@maka/core/dev-single-instance'); const launcher = await devSingleInstanceConstants(); assert.equal(launcher.DEV_LOSER_EXIT_CODE, core.DEV_LOSER_EXIT_CODE); assert.equal(launcher.DEV_CONFLICT_HANDLED_BY_LAUNCHER_FLAG, core.DEV_CONFLICT_HANDLED_BY_LAUNCHER_FLAG); assert.equal(launcher.DEV_LAUNCH_RESULT_FILE_ARG_PREFIX, core.DEV_LAUNCH_RESULT_FILE_ARG_PREFIX); }); test('plainLoserExitCode matches only the contract value', async () => { const { plainLoserExitCode } = await import('./dev-app-runtime.mjs'); const { DEV_LOSER_EXIT_CODE } = await import('@maka/core/dev-single-instance'); assert.equal(await plainLoserExitCode(DEV_LOSER_EXIT_CODE), true); assert.equal(await plainLoserExitCode(0), false); assert.equal(await plainLoserExitCode(1), false); }); test('loser contract import failure does not claim a loser', async () => { const { plainLoserExitCode } = await import('./dev-app-runtime.mjs'); const failingLoader = async () => { throw new Error('dist not built'); }; const warnings = []; const origWarn = console.warn; console.warn = (m) => warnings.push(String(m)); try { assert.equal(await plainLoserExitCode(42, failingLoader), false); } finally { console.warn = origWarn; } assert.ok(warnings.some((m) => m.includes('dev single-instance constants unavailable'))); }); async function runVerdictCase({ result, stopped = () => false, startupAttempts = 10 }) { const { waitForDevelopmentLaunchVerdict } = await import('./dev-app-runtime.mjs'); const { writeFileSync, mkdtempSync, rmSync } = await import('node:fs'); const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const dir = mkdtempSync(join(tmpdir(), 'maka-abs-')); const resultFile = join(dir, 'launch-result.json'); try { if (result) writeFileSync(resultFile, `${JSON.stringify(result)}\n`); return await waitForDevelopmentLaunchVerdict({ resultFile, pollMs: 1, startupAttempts, stopped, }); } finally { rmSync(dir, { recursive: true, force: true }); } } test('verdict wait reports only its own private loser result', async () => { const { waitForDevelopmentLaunchVerdict } = await import('./dev-app-runtime.mjs'); let resultChecks = 0; let stopChecks = 0; assert.equal( await waitForDevelopmentLaunchVerdict({ readLaunchResult: async () => (++resultChecks >= 2 ? { status: 'loser' } : undefined), delay: async () => {}, stopped: () => ++stopChecks > 4, }), 'absorbed', ); }); test('the TCC launcher stops supervising after Electron wins the lock', async () => { const { waitForDevelopmentLaunchVerdict } = await import('./dev-app-runtime.mjs'); assert.equal( await waitForDevelopmentLaunchVerdict({ readLaunchResult: () => ({ status: 'winner' }), isWinnerRunning: () => { throw new Error('the launcher must not observe Electron after the lock verdict'); }, delay: async () => {}, startupAttempts: 1, }), 'started', ); }); test('verdict wait marks a loser that never starts as absorbed', async () => { assert.equal( await runVerdictCase({ result: { status: 'loser' }, startupAttempts: 1 }), 'absorbed', ); }); test('verdict wait keeps an ordinary never-started launch distinct', async () => { assert.equal(await runVerdictCase({ startupAttempts: 1 }), 'never-started'); }); test('verdict wait marks a stopped loser as absorbed', async () => { let stopChecks = 0; assert.equal( await runVerdictCase({ result: { status: 'loser' }, stopped: () => ++stopChecks > 1, }), 'absorbed', ); }); test('development-app liveness probes only the TCC bundle', async () => { const { isDevelopmentAppRunning } = await import('./dev-app-runtime.mjs'); const calls = []; assert.equal( isDevelopmentAppRunning({ executable: '/wt/Maka Dev.app/Contents/MacOS/Electron', probe: (executable) => { calls.push(executable); return false; }, }), false, ); assert.deepEqual(calls, ['/wt/Maka Dev.app/Contents/MacOS/Electron']); }); test('a runtime rebuild refuses to replace a running TCC bundle instead of killing it', async () => { const { assertDevelopmentAppNotRunning } = await import('./dev-app-runtime.mjs'); assert.throws( () => assertDevelopmentAppNotRunning({ isRunning: () => true }), /Quit it.*retry/, ); assert.doesNotThrow(() => assertDevelopmentAppNotRunning({ isRunning: () => false })); }); test('TCC launcher cleanup removes only its own launch artifacts', async () => { const { cleanupDevelopmentLaunch } = await import('./dev-app-runtime.mjs'); const { existsSync, mkdtempSync, rmSync, writeFileSync } = await import('node:fs'); const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const directory = mkdtempSync(join(tmpdir(), 'maka-cleanup-')); const logFile = join(directory, 'app.log'); const resultFile = join(directory, 'launch-result.json'); let logStopped = false; try { writeFileSync(logFile, 'log output\n'); writeFileSync(resultFile, '{"status":"winner"}\n'); cleanupDevelopmentLaunch({ logFile, resultFile, logStream: { kill: () => { logStopped = true; } }, }); assert.equal(logStopped, true); assert.equal(existsSync(logFile), false); assert.equal(existsSync(resultFile), false); } finally { rmSync(directory, { recursive: true, force: true }); } }); test('each TCC launch gets private and separate result and log files', async () => { const { createDevelopmentLaunchFiles } = await import('./dev-app-runtime.mjs'); const writes = []; const first = createDevelopmentLaunchFiles({ directory: '/tmp/maka-dev-launches', id: 'first', mkdir: () => {}, write: (path) => writes.push(path), }); const second = createDevelopmentLaunchFiles({ directory: '/tmp/maka-dev-launches', id: 'second', mkdir: () => {}, write: (path) => writes.push(path), }); assert.notEqual(first.logFile, second.logFile); assert.notEqual(first.resultFile, second.resultFile); assert.deepEqual(writes, [first.logFile, second.logFile]); }); test('other launch outcomes retain their exit codes', async () => { const { handleDevelopmentLaunchOutcome } = await import('./dev-app-runtime.mjs'); for (const [outcome, expectedCode] of [['never-started', 1], ['stopped', 0], ['unexpected', 1]]) { let exitCode; handleDevelopmentLaunchOutcome(outcome, { log: () => {}, exit: (code) => { exitCode = code; } }); assert.equal(exitCode, expectedCode, outcome); } let startedExitCode; handleDevelopmentLaunchOutcome('started', { exit: (code) => { startedExitCode = code; } }); assert.equal(startedExitCode, undefined); }); test('shared-profile launches warn about legacy TCC data before choosing plain or bundle mode', async () => { const { DEV_USER_DATA_DIR, warnAboutLegacyTccDataRoot } = await import('./dev-app-runtime.mjs'); const warnings = []; warnAboutLegacyTccDataRoot({ platform: 'darwin', effectiveUserDataDir: DEV_USER_DATA_DIR, legacyUserDataDir: '/tmp/Maka Dev-legacy', exists: () => true, warn: (message) => warnings.push(message), }); assert.equal(warnings.length, 1); assert.match(warnings[0], /Maka Dev-legacy/); }); test('an explicit isolated profile does not warn about unrelated legacy TCC data', async () => { const { warnAboutLegacyTccDataRoot } = await import('./dev-app-runtime.mjs'); const warnings = []; warnAboutLegacyTccDataRoot({ platform: 'darwin', effectiveUserDataDir: '/tmp/isolated-profile', legacyUserDataDir: '/tmp/Maka Dev-legacy', exists: () => true, warn: (message) => warnings.push(message), }); assert.deepEqual(warnings, []); }); test('createMacosDevelopmentLaunch passes its private result file after --args', async () => { const { createMacosDevelopmentLaunch } = await import('./dev-app-runtime.mjs'); const { DEV_LAUNCH_RESULT_FILE_ARG_PREFIX } = await import('@maka/core/dev-single-instance'); const launch = createMacosDevelopmentLaunch( '/tmp/Maka Dev.app', '/tmp/app.log', '/tmp/launch-result.json', DEV_LAUNCH_RESULT_FILE_ARG_PREFIX, ); assert.equal(launch.command, 'open'); assert.ok(launch.args.indexOf('--stdout') < launch.args.indexOf('--args')); assert.equal( launch.args.at(-1), `${DEV_LAUNCH_RESULT_FILE_ARG_PREFIX}/tmp/launch-result.json`, ); assert.ok( !createMacosDevelopmentLaunch('/tmp/Maka Dev.app', '/tmp/app.log').args.includes('--args'), ); });