blob: 8ba66ec576077c6d4682415bd59dc655e9b8d79f [file]
/*
* 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'),
);
});