mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
feat: add npm update checks and manual installation
Add profile-scoped npm updates for Desktop and Web, protect source installations, and keep restarts manual. Cover stale status recovery, request races, and dialog focus with regression tests. Verified with npm run check (1626 tests) and isolated Desktop/Web npm installation flows. Refs #61
This commit is contained in:
parent
5888641297
commit
f3cefd8a84
23 changed files with 4058 additions and 271 deletions
390
test/update-service.test.mjs
Normal file
390
test/update-service.test.mjs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
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.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('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);
|
||||
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');
|
||||
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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue