dsh-im-ops/test/update-service.test.mjs

404 lines
18 KiB
JavaScript

import assert from 'node:assert/strict';
import { chmod, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { createUpdateService, fetchNpmRelease } from '../plugin-src/host/update-service.mjs';
function release(version = '3.0.8', fields = {}) {
return {
name: '@xmanrui/dsh-im', version, engines: { node: '>=22.19' },
dist: {
tarball: `https://registry.npmjs.org/@xmanrui/dsh-im/-/dsh-im-${version}.tgz`,
integrity: `sha512-${Buffer.alloc(64, 1).toString('base64')}`,
},
...fields,
};
}
async function fixture(t, options = {}) {
const homeDir = await mkdtemp(join(tmpdir(), 'dsh-im-update-service-'));
const profileDir = join(homeDir, 'profiles', 'test');
await mkdir(profileDir, { recursive: true });
await writeFile(join(profileDir, 'package.json'), '{"dependencies":{"@xmanrui/dsh-im":"3.0.7"}}\n');
await writeFile(join(profileDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n');
await writeFile(join(profileDir, '.npmrc'), '//example.test/:_authToken=do-not-copy\n');
const environment = {
homeDir, profileDir, profileName: 'test', environmentKind: 'desktop',
installedVersion: '3.0.7', eligible: true, packageValid: true,
blockedReason: null, installationKey: 'old-installation',
...options.environment,
};
const state = { time: 10_000, release: release(), fetches: 0, installs: [], installKeys: [], failFetch: false };
const runtime = {
inspect: async () => ({ ...environment }),
install: async (version, { signal, expectedInstallationKey }) => {
state.installs.push(version);
state.installKeys.push(expectedInstallationKey);
if (options.install) return options.install({ version, signal, environment, expectedInstallationKey });
environment.installedVersion = version;
environment.installationKey = 'new-installation';
},
};
const serviceOptions = {
runtime, runningVersion: '3.0.7', nodeVersion: '22.20.0', now: () => state.time,
fetchImpl: async (url, init) => {
state.fetches++;
assert.equal(url, 'https://registry.npmjs.org/%40xmanrui%2Fdsh-im/latest');
assert.equal(init.redirect, 'error');
if (state.failFetch) throw new Error('private-path-and-token');
return new Response(JSON.stringify(state.release));
},
...options.service,
};
const service = createUpdateService(serviceOptions);
const services = [service];
t.after(async () => {
await Promise.all(services.map((item) => item.close()));
await rm(homeDir, { recursive: true, force: true });
});
return {
service, environment, state, homeDir,
restart(version = '3.0.8') {
const restarted = createUpdateService({ ...serviceOptions, runningVersion: version });
services.push(restarted);
return restarted;
},
async submit(requestId = 'request-1') {
const { checkId } = await service.check();
return service.install({ checkId, requestId });
},
async directory() {
const root = join(homeDir, 'updates', 'dsh-im');
return join(root, (await readdir(root))[0]);
},
};
}
async function waitForJob(service, expected) {
const deadline = Date.now() + 4_000;
let snapshot;
while (Date.now() < deadline) {
snapshot = await service.status();
if (snapshot.job?.state === expected) return snapshot;
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.fail(`Expected ${expected}, received ${JSON.stringify(snapshot)}`);
}
test('status is local and npm checking only happens on demand', async (t) => {
const f = await fixture(t);
assert.equal((await f.service.status()).latestVersion, null);
assert.equal(f.state.fetches, 0);
const checked = await f.service.check();
assert.equal(checked.latestVersion, '3.0.8');
assert.equal(checked.canInstall, true);
assert.ok(checked.checkId);
await f.service.check();
assert.equal(f.state.fetches, 1);
});
test('SemVer comparison handles two digit patches and refuses downgrades or equal versions', async (t) => {
for (const [runningVersion, latest, expected] of [
['3.0.9', '3.0.10', true], ['3.0.8', '3.0.8', false], ['3.0.10', '3.0.9', false],
]) {
const f = await fixture(t, { service: { runningVersion }, environment: { installedVersion: runningVersion } });
f.state.release = release(latest);
assert.equal((await f.service.check()).canInstall, expected);
}
});
test('npm metadata rejects pre-releases, other sources, wrong package and invalid integrity', async () => {
for (const metadata of [
release('3.0.9-beta.1'), release('not-a-version'), release('3.0.8', { name: 'other-package' }),
release('3.0.8', { dist: { ...release().dist, tarball: 'https://github.com/example/package.tgz' } }),
release('3.0.8', { dist: { ...release().dist, integrity: 'sha512-fake' } }),
release('3.0.8', { engines: { node: 'anything' } }),
]) {
await assert.rejects(fetchNpmRelease(async () => new Response(JSON.stringify(metadata))), { code: 'invalid-release' });
}
await assert.rejects(fetchNpmRelease(async () => new Response('x'.repeat(256 * 1024 + 1))), { code: 'invalid-release' });
await assert.rejects(fetchNpmRelease(async () => new Response('{}', { status: 429 })), { code: 'check-failed' });
});
test('source installs and incompatible Host Node can check but cannot install', async (t) => {
const source = await fixture(t, { environment: { eligible: false, blockedReason: 'source-install' } });
const checked = await source.service.check();
assert.equal(checked.latestVersion, '3.0.8');
assert.equal(checked.blockedReason, 'source-install');
assert.equal(checked.sourceInstall, true);
assert.equal(checked.canInstall, false);
assert.equal(checked.checkId, null);
const oldNode = await fixture(t, { service: { nodeVersion: '22.18.0' } });
assert.equal((await oldNode.service.check()).blockedReason, 'incompatible-node');
assert.deepEqual(source.state.installs, []);
});
test('source-install protection remains independent of the primary status reason', async (t) => {
const f = await fixture(t, { environment: {
eligible: false, blockedReason: 'source-install', sourceInstall: true, installedVersion: '3.0.8',
} });
const status = await f.service.status();
assert.equal(status.blockedReason, 'pending-restart');
assert.equal(status.sourceInstall, true);
f.environment.blockedReason = 'installation-changed';
assert.equal((await f.service.status()).sourceInstall, true);
});
test('a failed check invalidates previous install confirmation without claiming latest', async (t) => {
const f = await fixture(t);
const checked = await f.service.check();
f.state.time += 3_000;
f.state.failFetch = true;
await assert.rejects(f.service.check(), { code: 'check-failed' });
assert.equal((await f.service.status()).canInstall, false);
await assert.rejects(f.service.install({ checkId: checked.checkId, requestId: 'retry' }), { code: 'check-expired' });
});
test('installation pins the confirmed version, verifies disk and waits for a manual restart', async (t) => {
const f = await fixture(t);
const submitted = await f.submit();
assert.equal(submitted.job.state, 'installing');
const finished = await waitForJob(f.service, 'restart-required');
assert.deepEqual(f.state.installs, ['3.0.8']);
assert.deepEqual(f.state.installKeys, ['old-installation']);
assert.equal(finished.runningVersion, '3.0.7');
assert.equal(finished.installedVersion, '3.0.8');
assert.equal(finished.blockedReason, 'pending-restart');
assert.equal(finished.canInstall, false);
await f.service.close();
const restarted = await f.restart().status();
assert.equal(restarted.job.state, 'completed');
assert.equal(restarted.runningVersion, '3.0.8');
const backup = await readFile(join(await f.directory(), 'before.json'), 'utf8');
assert.match(backup, /package\.json/);
assert.doesNotMatch(backup, /authToken|do-not-copy|\.npmrc/);
assert.doesNotMatch(JSON.stringify(finished), new RegExp(f.homeDir));
});
test('idempotent requests and multiple Host instances cannot start a second install', async (t) => {
let complete;
const gate = new Promise((resolve) => { complete = resolve; });
const f = await fixture(t, { install: async ({ version, environment }) => {
await gate;
environment.installedVersion = version;
} });
const checked = await f.service.check();
const first = await f.service.install({ checkId: checked.checkId, requestId: 'same-request' });
const again = await f.service.install({ checkId: checked.checkId, requestId: 'same-request' });
assert.equal(first.job.id, again.job.id);
const otherHost = f.restart('3.0.7');
const otherCheck = await otherHost.check();
await assert.rejects(otherHost.install({ checkId: otherCheck.checkId ?? 'unknown', requestId: 'another' }), { code: 'update-busy' });
assert.equal(f.state.installs.length, 1);
complete();
await waitForJob(f.service, 'restart-required');
});
test('changed installation, expired confirmation or changed latest require a fresh confirmation', async (t) => {
const changed = await fixture(t);
const first = await changed.service.check();
changed.environment.installationKey = 'external-change';
await assert.rejects(changed.service.install({ checkId: first.checkId, requestId: 'changed' }), { code: 'installation-changed' });
const expired = await fixture(t);
const second = await expired.service.check();
expired.state.time += 11 * 60_000;
await assert.rejects(expired.service.install({ checkId: second.checkId, requestId: 'expired' }), { code: 'check-expired' });
const newer = await fixture(t);
const third = await newer.service.check();
newer.state.release = release('3.0.9');
await assert.rejects(newer.service.install({ checkId: third.checkId, requestId: 'newer' }), { code: 'check-expired' });
assert.equal(newer.state.installs.length, 0);
assert.equal((await readdir(await newer.directory())).includes('install.lock'), false);
});
test('successful command without correct installed version or usable entry is a verification failure', async (t) => {
const unchanged = await fixture(t, { install: async () => {} });
await unchanged.submit();
assert.equal((await waitForJob(unchanged.service, 'failed')).job.message, 'verify-failed');
const invalid = await fixture(t, { install: async ({ environment, version }) => {
environment.installedVersion = version;
environment.packageValid = false;
} });
await invalid.submit();
assert.equal((await waitForJob(invalid.service, 'failed')).blockedReason, 'recovery-required');
});
test('failed package mutation reports recovery instead of claiming unchanged or updated', async (t) => {
const f = await fixture(t, { install: async ({ environment, version }) => {
environment.installedVersion = version;
throw new Error('stderr with private-path-and-token');
} });
await f.submit();
const result = await waitForJob(f.service, 'failed');
assert.equal(result.blockedReason, 'recovery-required');
assert.equal(result.canInstall, false);
assert.doesNotMatch(JSON.stringify(result), /private-path-and-token/);
});
test('closing the Host aborts its job and a later healthy Host can retry once the lock is released', async (t) => {
let signal;
const f = await fixture(t, { install: async (options) => {
signal = options.signal;
await new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { code: 'interrupted' })), { once: true });
});
} });
await f.submit();
await f.service.close();
assert.equal(signal.aborted, true);
const result = await f.restart('3.0.7').status();
assert.equal(result.job.state, 'interrupted');
assert.equal(result.blockedReason, null);
assert.equal(result.canInstall, false);
assert.equal((await f.restart('3.0.7').check()).canInstall, true);
});
test('foreign stale lock is reported for manual recovery and is not silently deleted', async (t) => {
const f = await fixture(t);
await f.submit();
await waitForJob(f.service, 'restart-required');
const directory = await f.directory();
await rm(join(directory, 'state.json'));
await writeFile(join(directory, 'install.lock'), JSON.stringify({ id: 'foreign', pid: 999_999_999 }));
const result = await f.restart().status();
assert.equal(result.job.state, 'interrupted');
assert.match(await readFile(join(directory, 'install.lock'), 'utf8'), /foreign/);
});
test('an external version change after installation is not reported as this job completing', async (t) => {
const f = await fixture(t);
await f.submit();
await waitForJob(f.service, 'restart-required');
await f.service.close();
f.environment.installedVersion = '3.0.9';
const result = await f.restart('3.0.9').status();
assert.equal(result.job.state, 'interrupted');
assert.equal(result.job.message, 'installation-changed');
});
test('post-install verification stays bound to the original home and profile', async (t) => {
for (const field of ['homeDir', 'profileDir', 'profileName']) {
const f = await fixture(t, { install: async ({ environment, version }) => {
environment.installedVersion = version;
environment[field] += '-other';
environment.blockedReason = 'installation-changed';
environment.eligible = false;
} });
await f.submit();
const statePath = join(await f.directory(), 'state.json');
const result = await waitForJob({ status: async () => ({ job: JSON.parse(await readFile(statePath, 'utf8')) }) }, 'failed');
assert.equal(result.job.message, 'installation-changed', field);
}
});
test('a correct target version from a changed installation source does not pass verification', async (t) => {
const f = await fixture(t, { install: async ({ environment, version }) => {
environment.installedVersion = version;
environment.blockedReason = 'source-install';
environment.eligible = false;
} });
await f.submit();
assert.equal((await waitForJob(f.service, 'failed')).job.message, 'verify-failed');
});
test('a runtime rejection of the confirmed installation snapshot is persisted as a failure', async (t) => {
const f = await fixture(t, { install: async ({ expectedInstallationKey }) => {
assert.equal(expectedInstallationKey, 'old-installation');
throw Object.assign(new Error('installation-changed'), { code: 'installation-changed' });
} });
await f.submit();
assert.equal((await waitForJob(f.service, 'failed')).job.message, 'installation-changed');
});
test('manual repair followed by a new healthy Host can recover failed and interrupted jobs', async (t) => {
for (const interrupted of [false, true]) {
const f = await fixture(t, { install: async ({ signal }) => {
if (interrupted) await new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(Object.assign(new Error('interrupted'), { code: 'interrupted' })), { once: true });
});
else throw new Error('installation failed');
} });
await f.submit();
if (!interrupted) await waitForJob(f.service, 'failed');
await f.service.close();
f.environment.installedVersion = '3.0.8';
f.environment.installationKey = 'manually-repaired';
f.state.release = release('3.0.9');
const result = await f.restart('3.0.8').check();
assert.equal(result.job.state, 'completed');
assert.equal(result.job.message, 'recovered');
assert.equal(result.blockedReason, null);
assert.equal(result.canInstall, true);
}
});
test('manual recovery never clears a residual lock, including a malformed lock document', async (t) => {
for (const contents of [JSON.stringify({ id: 'old-job', pid: process.pid }), JSON.stringify({ id: 'old-job', pid: 999_999_999 }), 'null']) {
const f = await fixture(t, { install: async () => { throw new Error('failed'); } });
await f.submit();
await waitForJob(f.service, 'failed');
await f.service.close();
const lockPath = join(await f.directory(), 'install.lock');
await writeFile(lockPath, contents);
const result = await f.restart('3.0.7').check();
assert.equal(result.job.state, 'interrupted');
assert.equal(result.blockedReason, 'recovery-required');
assert.equal(result.canInstall, false);
assert.equal(await readFile(lockPath, 'utf8'), contents);
}
});
test('an incomplete package cannot be reported as completed after a restart', async (t) => {
const f = await fixture(t);
await f.submit();
await waitForJob(f.service, 'restart-required');
await f.service.close();
f.environment.packageValid = false;
const result = await f.restart('3.0.8').status();
assert.equal(result.job.state, 'interrupted');
assert.equal(result.blockedReason, 'recovery-required');
});
test('a final journal write failure is visible in memory and leaves its lock intact', {
skip: process.platform === 'win32' || process.getuid?.() === 0,
}, async (t) => {
let directory;
const f = await fixture(t, { install: async ({ environment, version }) => {
directory = await f.directory();
environment.installedVersion = version;
await chmod(directory, 0o500);
} });
try {
await f.submit();
const result = await waitForJob(f.service, 'interrupted');
assert.equal(result.job.message, 'state-unavailable');
assert.equal(result.blockedReason, 'recovery-required');
assert.equal(JSON.parse(await readFile(join(directory, 'state.json'), 'utf8')).state, 'installing');
assert.equal(JSON.parse(await readFile(join(directory, 'install.lock'), 'utf8')).id, result.job.id);
await chmod(directory, 0o700);
assert.equal((await f.service.status()).job.state, 'interrupted');
} finally {
if (directory) await chmod(directory, 0o700);
}
});
test('the service watchdog reports timeout and waits for the owned installer to stop', async (t) => {
let stopped = false;
const f = await fixture(t, {
service: { installTimeoutMs: 20 },
install: async ({ signal }) => new Promise((resolve, reject) => {
signal.addEventListener('abort', () => {
stopped = true;
reject(Object.assign(new Error('install-interrupted'), { code: 'install-interrupted' }));
}, { once: true });
}),
});
await f.submit();
const result = await waitForJob(f.service, 'failed');
assert.equal(stopped, true);
assert.equal(result.job.message, 'install-timeout');
await f.service.close();
assert.equal((await readdir(await f.directory())).includes('install.lock'), false);
});