| /* |
| * 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'), |
| ); |
| }); |