mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-12 05:16:19 -07:00
feat(launcher): bundle a private Bun runtime
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
detectBun,
|
||||
installBun,
|
||||
installLauncher,
|
||||
refreshManagedCommandLineLauncher,
|
||||
} from './command-line-launcher';
|
||||
import {
|
||||
cleanupOldWindowsManagedRuntimes,
|
||||
MANAGED_LAUNCHER_MARKER,
|
||||
managedLauncherContent,
|
||||
managedLauncherPaths,
|
||||
stageManagedLauncher,
|
||||
windowsManagedRuntimePaths,
|
||||
} from './managed-launcher';
|
||||
|
||||
function workspace(t: test.TestContext) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "subminer bundled bun's "));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
test('a missing packaged Bun never falls back to system Bun or runs an installer', async () => {
|
||||
let calls = 0;
|
||||
const options = {
|
||||
bundledBunPath: '/missing/private/bun',
|
||||
env: { PATH: path.dirname(process.execPath) },
|
||||
existsSync: (candidate: string) => candidate === process.execPath,
|
||||
runCommand: async () => {
|
||||
calls += 1;
|
||||
return { exitCode: 0, stdout: '1.3.5', stderr: '' };
|
||||
},
|
||||
};
|
||||
for (const snapshot of [await detectBun(options), await installBun(options)]) {
|
||||
assert.equal(snapshot.status, 'missing');
|
||||
assert.equal(snapshot.installCommand, null);
|
||||
assert.match(snapshot.message ?? '', /Reinstall SubMiner/);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('packaged POSIX launcher works without Bun on PATH and survives AppImage unmount', async (t) => {
|
||||
if (process.platform !== 'linux') return;
|
||||
const root = workspace(t);
|
||||
const resources = path.join(root, 'mounted resources');
|
||||
const bin = path.join(root, 'home', '.local', 'bin');
|
||||
fs.mkdirSync(resources, { recursive: true });
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
const launcherResourcePath = path.join(resources, 'subminer');
|
||||
const bundledBunPath = path.join(resources, 'bun');
|
||||
fs.symlinkSync(process.execPath, bundledBunPath);
|
||||
fs.writeFileSync(
|
||||
launcherResourcePath,
|
||||
'console.log(JSON.stringify({args:process.argv.slice(2),app:process.env.SUBMINER_BINARY_PATH,managed:process.env.SUBMINER_MANAGED_LAUNCHER}));',
|
||||
);
|
||||
const appPath = path.join(root, 'SubMiner.AppImage');
|
||||
const options = {
|
||||
platform: process.platform,
|
||||
homeDir: path.join(root, 'home'),
|
||||
env: { PATH: bin, XDG_DATA_HOME: path.join(root, 'data'), APPIMAGE: appPath },
|
||||
appExePath: path.join(resources, 'SubMiner'),
|
||||
appVersion: '1.0.0',
|
||||
bundledBunPath,
|
||||
launcherResourcePath,
|
||||
};
|
||||
const installed = await installLauncher(options);
|
||||
assert.equal(installed.status, 'ready', installed.message ?? 'install failed');
|
||||
assert.match(fs.readFileSync(path.join(bin, 'subminer'), 'utf8'), /SubMiner managed launcher/);
|
||||
fs.renameSync(resources, path.join(root, 'unmounted'));
|
||||
const args = ['file with spaces.mkv', "single'quote", '$HOME', '$(touch nope)', '日本語'];
|
||||
const result = spawnSync(path.join(bin, 'subminer'), args, {
|
||||
env: options.env,
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), { args, app: appPath, managed: '1' });
|
||||
});
|
||||
|
||||
test('setup can install into a new user bin despite an empty GUI PATH', async (t) => {
|
||||
if (process.platform === 'win32') return;
|
||||
const root = workspace(t);
|
||||
const script = path.join(root, 'launcher');
|
||||
fs.writeFileSync(script, 'console.log("help");');
|
||||
const snapshot = await installLauncher({
|
||||
platform: 'darwin',
|
||||
homeDir: root,
|
||||
env: { PATH: '' },
|
||||
appExePath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
bundledBunPath: process.execPath,
|
||||
launcherResourcePath: script,
|
||||
});
|
||||
assert.equal(snapshot.status, 'not_on_path', snapshot.message ?? 'install failed');
|
||||
assert.equal(snapshot.installPath, path.join(root, '.local', 'bin', 'subminer'));
|
||||
assert.match(snapshot.message ?? '', /export PATH=/);
|
||||
const result = spawnSync(snapshot.installPath!, ['--help'], {
|
||||
env: { PATH: '' },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
test('app upgrades refresh Linux managed payloads and leave standalone launchers alone', async (t) => {
|
||||
if (process.platform !== 'linux') return;
|
||||
const root = workspace(t);
|
||||
const bin = path.join(root, 'bin');
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
const script = path.join(root, 'resource');
|
||||
fs.writeFileSync(script, 'console.log("old");');
|
||||
const options = {
|
||||
platform: process.platform,
|
||||
homeDir: root,
|
||||
env: { PATH: bin },
|
||||
appVersion: '1',
|
||||
appExePath: '/apps/SubMiner.AppImage',
|
||||
bundledBunPath: process.execPath,
|
||||
launcherResourcePath: script,
|
||||
};
|
||||
assert.equal((await installLauncher(options)).status, 'ready');
|
||||
fs.writeFileSync(script, 'console.log("new");');
|
||||
await refreshManagedCommandLineLauncher({ ...options, env: { PATH: '' }, appVersion: '2' });
|
||||
const payload = managedLauncherPaths(options);
|
||||
assert.equal(fs.readFileSync(payload.scriptPath, 'utf8'), 'console.log("new");');
|
||||
fs.writeFileSync(path.join(bin, 'subminer'), '#!/bin/sh\necho standalone\n');
|
||||
await refreshManagedCommandLineLauncher({ ...options, appVersion: '3' });
|
||||
assert.equal(fs.readFileSync(payload.versionPath, 'utf8'), '2');
|
||||
});
|
||||
|
||||
test('Windows wrapper uses quoted absolute Bun and disables delayed expansion', () => {
|
||||
const content = managedLauncherContent({
|
||||
platform: 'win32',
|
||||
bunPath: 'C:\\Apps & Tools\\100%\\bun.exe',
|
||||
scriptPath: 'C:\\Apps & Tools\\subminer',
|
||||
appPath: 'C:\\Apps!\\SubMiner.exe',
|
||||
});
|
||||
assert.ok(content.includes(MANAGED_LAUNCHER_MARKER));
|
||||
assert.ok(content.includes('setlocal DisableDelayedExpansion'));
|
||||
assert.ok(content.includes('"C:\\Apps & Tools\\100%%\\bun.exe" "C:\\Apps & Tools\\subminer" %*'));
|
||||
assert.ok(content.includes('exit /b %errorlevel%'));
|
||||
});
|
||||
|
||||
test('Windows managed runtime path is absolute, versioned, and injectable', () => {
|
||||
const paths = windowsManagedRuntimePaths({
|
||||
platform: 'win32',
|
||||
localAppData: 'D:\\Profiles\\テスト User\\AppData\\Local',
|
||||
appVersion: '1.2.3-beta.4',
|
||||
});
|
||||
assert.equal(
|
||||
paths.bunPath,
|
||||
'D:\\Profiles\\テスト User\\AppData\\Local\\SubMiner\\launcher-runtime\\1.2.3-beta.4\\bun.exe',
|
||||
);
|
||||
assert.ok(path.win32.isAbsolute(paths.bunPath));
|
||||
assert.throws(
|
||||
() =>
|
||||
windowsManagedRuntimePaths({
|
||||
platform: 'win32',
|
||||
localAppData: 'relative',
|
||||
appVersion: '1.2.3',
|
||||
}),
|
||||
/must be an absolute Windows path/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
windowsManagedRuntimePaths({
|
||||
platform: 'win32',
|
||||
localAppData: 'C:\\Users\\tester\\AppData\\Local',
|
||||
appVersion: '1:2',
|
||||
}),
|
||||
/not a valid directory name/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Windows managed launcher forwards arguments without a system Bun', async (t) => {
|
||||
if (process.platform !== 'win32') return;
|
||||
const root = workspace(t);
|
||||
const script = path.join(root, 'script.js');
|
||||
fs.writeFileSync(script, 'console.log(JSON.stringify(process.argv.slice(2)));');
|
||||
const options = {
|
||||
platform: process.platform,
|
||||
env: { ...process.env, PATH: '' },
|
||||
bundledBunPath: process.execPath,
|
||||
launcherResourcePath: script,
|
||||
appExePath: path.join(root, 'SubMiner.exe'),
|
||||
appVersion: '1.0.0',
|
||||
localAppData: root,
|
||||
getUserPath: () => '',
|
||||
setUserPath: () => {},
|
||||
broadcastEnvironmentChange: () => {},
|
||||
};
|
||||
const snapshot = await installLauncher(options);
|
||||
assert.equal(snapshot.status, 'ready', snapshot.message ?? 'install failed');
|
||||
const { getRunCommand } = await import('./command-line-launcher-deps');
|
||||
const args = ['spaces here', 'a&b', 'bang!z', '日本語'];
|
||||
const result = await getRunCommand({})(snapshot.installPath!, args, { env: options.env });
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), args);
|
||||
});
|
||||
|
||||
test('Windows stages a new runtime version while the prior Bun executable is running', async (t) => {
|
||||
if (process.platform !== 'win32') return;
|
||||
const root = workspace(t);
|
||||
const bundledDirectory = path.join(root, 'packaged', 'bun');
|
||||
const bundledBunPath = path.join(bundledDirectory, 'bun.exe');
|
||||
const launcherResourcePath = path.join(root, 'packaged', 'launcher', 'subminer');
|
||||
fs.mkdirSync(path.join(bundledDirectory, 'licenses'), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(launcherResourcePath), { recursive: true });
|
||||
fs.copyFileSync(process.execPath, bundledBunPath);
|
||||
fs.writeFileSync(path.join(bundledDirectory, 'licenses', 'Bun-LICENSE.md'), 'license');
|
||||
fs.writeFileSync(launcherResourcePath, 'console.log("launcher");');
|
||||
|
||||
const first = stageManagedLauncher({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '1.0.0',
|
||||
bundledBunPath,
|
||||
launcherResourcePath,
|
||||
});
|
||||
const running = spawn(first.bunPath, ['-e', 'setInterval(() => {}, 1000)']);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
running.once('spawn', resolve);
|
||||
running.once('error', reject);
|
||||
});
|
||||
|
||||
const expectedSecond = windowsManagedRuntimePaths({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '2.0.0',
|
||||
});
|
||||
try {
|
||||
const second = stageManagedLauncher({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '2.0.0',
|
||||
bundledBunPath,
|
||||
launcherResourcePath,
|
||||
});
|
||||
assert.notEqual(second.bunPath, first.bunPath);
|
||||
assert.ok(fs.existsSync(second.bunPath));
|
||||
cleanupOldWindowsManagedRuntimes({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '2.0.0',
|
||||
});
|
||||
assert.ok(fs.existsSync(first.bunPath));
|
||||
assert.ok(fs.existsSync(path.join(path.dirname(first.bunPath), 'licenses', 'Bun-LICENSE.md')));
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(path.dirname(second.bunPath), 'licenses', 'Bun-LICENSE.md'),
|
||||
'utf8',
|
||||
),
|
||||
'license',
|
||||
);
|
||||
} finally {
|
||||
if (running.exitCode === null) {
|
||||
const exited = new Promise<void>((resolve) => running.once('exit', () => resolve()));
|
||||
running.kill();
|
||||
await exited;
|
||||
}
|
||||
}
|
||||
cleanupOldWindowsManagedRuntimes({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '2.0.0',
|
||||
});
|
||||
assert.equal(fs.existsSync(path.dirname(first.bunPath)), false);
|
||||
assert.ok(fs.existsSync(expectedSecond.bunPath));
|
||||
});
|
||||
Reference in New Issue
Block a user