Files
fluxer/fluxer_desktop/src/main/Updater.test.mjs
T

287 lines
9.4 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
import {mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
import {createServer} from 'node:http';
import {createRequire} from 'node:module';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {after, before, describe, test} from 'node:test';
import {fileURLToPath} from 'node:url';
import vm from 'node:vm';
const require = createRequire(import.meta.url);
const esbuild = require('esbuild');
function transform(name) {
const url = new URL(`./${name}`, import.meta.url);
const path = fileURLToPath(url);
return {
path,
code: esbuild.transformSync(readFileSync(path, 'utf8'), {
loader: 'ts',
format: 'cjs',
platform: 'node',
target: 'node20',
define: {'import.meta.url': JSON.stringify(url.href)},
}).code,
};
}
const appImageUpdateSource = transform('AppImageUpdate.ts');
const shellDownloadFormatsSource = transform('ShellDownloadFormats.ts');
const shellUpdateCapabilitySource = transform('ShellUpdateCapability.ts');
const updaterDownloadsSource = transform('UpdaterDownloads.ts');
const updaterSource = transform('Updater.ts');
const INSTALLED_NAME = 'My Fluxer.AppImage';
const CURRENT_VERSION = '2026.903.231208';
const PUBLISHED_VERSION = '2026.904.135113';
const OLD_BYTES = Buffer.from('installed-appimage');
const NEW_BYTES = Buffer.alloc(300_000, 3);
const NEW_SHA256 = createHash('sha256').update(NEW_BYTES).digest('hex');
let server;
let baseUrl;
let appImageRequests = 0;
before(async () => {
server = createServer((request, response) => {
if (request.url === '/latest') {
response.writeHead(200, {'content-type': 'application/json'});
response.end(
JSON.stringify({
version: PUBLISHED_VERSION,
pub_date: '2026-09-04T13:51:13Z',
files: {
appimage: {url: `${baseUrl}/appimage`, sha256: NEW_SHA256},
deb: {url: `${baseUrl}/deb`, sha256: 'deadbeef'},
setup: {url: `${baseUrl}/setup`, sha256: 'cafebabe'},
},
}),
);
return;
}
if (request.url === `/${PUBLISHED_VERSION}/appimage`) {
appImageRequests += 1;
response.writeHead(200, {'content-type': 'application/octet-stream', 'content-length': NEW_BYTES.length});
response.end(NEW_BYTES);
return;
}
response.writeHead(404);
response.end('missing');
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
await new Promise((resolve) => server.close(resolve));
});
function loadUpdater({
appImagePath,
version = CURRENT_VERSION,
appDir,
platform = 'linux',
arch = 'arm64',
packaged = true,
flatpak = false,
gateCheck = async () => ({available: true}),
}) {
const events = [];
const handlers = new Map();
const gate = {checks: 0};
const module = {exports: {}};
const stubs = {
'@electron/common/BuildChannel': {BUILD_CHANNEL: 'canary'},
'@electron/common/Constants': {DOWNLOAD_PAGE_URLS: {canary: 'https://canary.fluxer.app/download'}},
'@electron/common/DesktopIdentity': {DESKTOP_ARTIFACT_PRODUCT_NAME: 'Fluxer-Canary'},
'@electron/common/UserDataPath': {isPortableMode: () => false},
'@electron/main/DesktopUpdateGate': {
checkDesktopUpdateNow: async () => {
gate.checks += 1;
return await gateCheck();
},
},
'@electron/main/LinuxSandbox': {isFlatpakRuntime: () => flatpak},
'electron-log': {info() {}, warn() {}, error() {}, debug() {}},
electron: {
app: {
isPackaged: packaged,
getVersion: () => version,
},
net: {fetch: (input, init) => sandbox.fetch(input, init)},
ipcMain: {
handle(channel, handler) {
handlers.set(channel, handler);
},
},
},
};
const sandbox = {
console,
Buffer,
URL,
process: {
...process,
platform,
arch,
execPath: appDir ? join(appDir, 'fluxer-canary') : process.execPath,
env: {
...(appImagePath ? {APPIMAGE: appImagePath} : {}),
...(appDir ? {APPDIR: appDir} : {}),
},
},
setTimeout,
clearTimeout,
setImmediate,
fetch: (input, init) => {
const url = String(input).replace(/https:\/\/pkgs\.fluxer\.com\/desktop\/canary\/[^/]+\/[^/]+/, baseUrl);
return fetch(url, init);
},
require: (specifier) => stubs[specifier] ?? require(specifier),
};
const context = vm.createContext(sandbox);
const load = (source, specifier) => {
const loaded = {exports: {}};
sandbox.module = loaded;
sandbox.exports = loaded.exports;
sandbox.__filename = source.path;
vm.runInContext(source.code, context, {filename: source.path});
stubs[specifier] = loaded.exports;
};
load(appImageUpdateSource, '@electron/main/AppImageUpdate');
load(shellUpdateCapabilitySource, '@electron/main/ShellUpdateCapability');
load(shellDownloadFormatsSource, '@electron/main/ShellDownloadFormats');
load(updaterDownloadsSource, '@electron/main/UpdaterDownloads');
sandbox.module = module;
sandbox.exports = module.exports;
sandbox.__filename = updaterSource.path;
vm.runInContext(updaterSource.code, context, {filename: updaterSource.path});
module.exports.registerUpdater(() => ({webContents: {send: (_channel, event) => events.push(event)}}));
return {
events,
gate,
channels: () => [...handlers.keys()],
check: (context = 'user') => handlers.get('updater-check')({}, context),
};
}
function types(events) {
return events.map((event) => event.type);
}
function createInstall() {
const root = mkdtempSync(join(tmpdir(), 'fluxer-updater-test-'));
const applications = join(root, 'Applications');
mkdirSync(applications);
const mount = join(root, '.mount_Fluxer');
mkdirSync(mount);
writeFileSync(join(mount, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755});
const installedPath = join(applications, INSTALLED_NAME);
writeFileSync(installedPath, OLD_BYTES, {mode: 0o755});
return {applications, installedPath, mount};
}
describe('Updater on a shell that updates itself', () => {
test('registers only the check, never a download or an install channel', () => {
const install = createInstall();
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
assert.deepEqual(updater.channels(), ['updater-check']);
});
test('a background check downloads nothing and leaves the poll to the bootstrap', async () => {
const install = createInstall();
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
appImageRequests = 0;
await updater.check('background');
await updater.check('focus');
assert.equal(updater.gate.checks, 0);
assert.deepEqual(updater.events, []);
assert.equal(appImageRequests, 0);
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
});
test('a user check asks the manifest and leaves the update itself to the click', async () => {
const install = createInstall();
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
appImageRequests = 0;
await updater.check('user');
assert.equal(updater.gate.checks, 1);
assert.deepEqual(types(updater.events), ['not-available']);
assert.equal(appImageRequests, 0);
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
});
test('a user check that cannot reach the manifest says so', async () => {
const install = createInstall();
const updater = loadUpdater({
appImagePath: install.installedPath,
appDir: install.mount,
gateCheck: async () => {
throw new Error('manifest unreachable');
},
});
await updater.check('user');
assert.deepEqual(types(updater.events), ['error']);
assert.match(updater.events[0].message, /manifest unreachable/);
});
test('macOS and Windows go through the same check', async () => {
for (const platform of ['darwin', 'win32']) {
const updater = loadUpdater({platform, arch: 'x64'});
await updater.check('user');
assert.equal(updater.gate.checks, 1, platform);
assert.deepEqual(updater.channels(), ['updater-check'], platform);
}
});
});
describe('Updater on a shell that cannot update itself', () => {
test('a Linux package install offers the newer package for manual download', async () => {
const updater = loadUpdater({appImagePath: null});
await updater.check('user');
assert.equal(updater.gate.checks, 1, 'the renderer module can update even when the shell cannot');
assert.deepEqual(types(updater.events), ['checking', 'available']);
const available = updater.events.at(-1);
assert.equal(available.version, PUBLISHED_VERSION);
assert.equal(Object.hasOwn(available, 'downloadStarted'), false);
assert.ok(available.downloadOptions.some((option) => option.format === 'deb'));
});
test('a Linux package install that is current reports no update', async () => {
const updater = loadUpdater({appImagePath: null, version: PUBLISHED_VERSION});
await updater.check('user');
assert.deepEqual(types(updater.events), ['checking', 'not-available']);
});
test('Flatpak and development builds point at their own update path', async () => {
const flatpak = loadUpdater({appImagePath: null, flatpak: true});
await flatpak.check('user');
assert.deepEqual(JSON.parse(JSON.stringify(flatpak.events)), [
{type: 'unsupported', context: 'user', reason: 'managed-package'},
]);
const unpackaged = loadUpdater({appImagePath: null, packaged: false});
await unpackaged.check('user');
assert.equal(unpackaged.events[0].reason, 'unpackaged');
});
});