mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
404 lines
18 KiB
JavaScript
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);
|
|
});
|