mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +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
|
|
@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import { transform } from 'esbuild';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
|
|
@ -130,6 +131,8 @@ test('IM settings renders nine IM channels plus the AI Office connector', async
|
|||
assert.match(markup, /target="_blank"/);
|
||||
assert.match(markup, /rel="noopener noreferrer"/);
|
||||
assert.match(markup, /aria-label="dsh-im GitHub"/);
|
||||
assert.match(markup, /dim-updateTrigger[^>]*aria-haspopup="dialog"[^>]*>检查更新</);
|
||||
assert.ok(markup.indexOf('dim-updateTrigger') < markup.indexOf('dim-githubAction'));
|
||||
assert.match(markup, /aria-describedby="[^"]+"/);
|
||||
assert.match(markup, /role="tooltip"[^>]*>帮助与反馈 · 前往 GitHub</);
|
||||
assert.match(styles, /\.dim-title \{[^}]*margin: 0 0 18px;/);
|
||||
|
|
@ -650,6 +653,12 @@ test('the bundled DingTalk channel has no local sender approval workflow', async
|
|||
);
|
||||
});
|
||||
|
||||
test('the bilingual dictionary has no duplicate object keys', async () => {
|
||||
const source = await readFile(new URL('../plugin-src/client/i18n.js', import.meta.url), 'utf8');
|
||||
const { warnings } = await transform(source, { loader: 'js', logLevel: 'silent' });
|
||||
assert.deepEqual(warnings.filter((warning) => warning.id === 'duplicate-object-key'), []);
|
||||
});
|
||||
|
||||
test('every shipped Chinese client string has an English projection', async () => {
|
||||
const paths = (await readdir(CLIENT_SOURCE_DIRECTORY_URL, { recursive: true }))
|
||||
.filter((path) => path.endsWith('.js') && path !== 'i18n.js');
|
||||
|
|
@ -692,7 +701,11 @@ test('client registers one top-level bilingual IM settings section with a direct
|
|||
const registrations = [];
|
||||
const dictionaries = [];
|
||||
const directoryCalls = [];
|
||||
const rpcCall = async () => ({ ok: true, value: {} });
|
||||
const rpcCalls = [];
|
||||
const rpcCall = async (...args) => {
|
||||
rpcCalls.push(args);
|
||||
return { ok: true, value: {} };
|
||||
};
|
||||
const ctx = {
|
||||
effect(install, label) {
|
||||
effects.push({ install, label });
|
||||
|
|
@ -749,6 +762,8 @@ test('client registers one top-level bilingual IM settings section with a direct
|
|||
|
||||
const injected = registrations[0].options.inject();
|
||||
const signal = new AbortController().signal;
|
||||
await injected.updateRpcCall('update.status', {}, signal);
|
||||
assert.deepEqual(rpcCalls, [['/dsh-im', 'update.status', {}, signal]]);
|
||||
assert.deepEqual(
|
||||
await injected.workspaceDirectoryPicker.listDirectory('/workspace/current', signal),
|
||||
{ path: '/workspace/current', entries: [] },
|
||||
|
|
|
|||
66
test/update-rpc.test.mjs
Normal file
66
test/update-rpc.test.mjs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createUpdateRpcHandler, installUpdateRpc } from '../plugin-src/host/update-rpc.mjs';
|
||||
import { createImHostPlugin } from '../plugin-src/host/index.mjs';
|
||||
|
||||
test('update RPC rejects arbitrary commands, paths, profiles, sources and invalid request identifiers', async () => {
|
||||
const calls = [];
|
||||
const service = Object.fromEntries(['status', 'check', 'install'].map((key) => [key, async (value) => calls.push([key, value])]));
|
||||
const handle = createUpdateRpcHandler(service);
|
||||
for (const [endpoint, payload] of [
|
||||
['unknown', {}], ['update.check', { registry: 'https://evil.test/' }],
|
||||
['update.status', { profile: '../other' }], ['update.install', null],
|
||||
['update.install', { checkId: 'id', requestId: 'request', command: 'anything' }],
|
||||
['update.install', { checkId: 'id', requestId: '; rm -rf /' }],
|
||||
['update.install', { checkId: 'id', requestId: 'request', version: '9.0.0' }],
|
||||
]) {
|
||||
assert.equal((await handle(endpoint, payload)).error.code, 'bad-request');
|
||||
}
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('update RPC keeps local authority even when other management uses trusted-host', () => {
|
||||
const calls = [];
|
||||
const service = { close: async () => {} };
|
||||
const ctx = {
|
||||
connection: { rpc: { handle: (...args) => calls.push(args) } },
|
||||
effect: () => {},
|
||||
};
|
||||
installUpdateRpc(ctx, { service, runtime: {} });
|
||||
assert.equal(calls[0][0], '/dsh-im');
|
||||
assert.deepEqual(calls[0][2], { authority: 'loopback' });
|
||||
});
|
||||
|
||||
test('update RPC returns only safe codes for unanticipated runtime errors', async () => {
|
||||
const handle = createUpdateRpcHandler({ status: async () => {
|
||||
throw new Error('token=secret /Users/private/profile');
|
||||
} });
|
||||
assert.deepEqual(await handle('update.status', {}), {
|
||||
ok: false, error: { code: 'update-failed', message: 'update-failed' },
|
||||
});
|
||||
});
|
||||
|
||||
test('aborting a submitted browser request does not cancel the Host installation', async () => {
|
||||
const abort = new AbortController();
|
||||
let complete;
|
||||
const pending = new Promise((resolve) => { complete = resolve; });
|
||||
const handle = createUpdateRpcHandler({ install: async () => pending });
|
||||
const result = handle('update.install', { checkId: 'confirmed', requestId: 'request' }, abort.signal);
|
||||
abort.abort();
|
||||
complete({ job: { state: 'installing' } });
|
||||
assert.deepEqual(await result, { ok: true, value: { job: { state: 'installing' } } });
|
||||
});
|
||||
|
||||
test('Host update initialization failure leaves all channel activations available', async () => {
|
||||
const calls = [];
|
||||
const channels = ['Feishu', 'Weixin', 'Dingtalk', 'Wecom', 'Qq', 'Slack', 'Telegram', 'Discord', 'Whatsapp', 'Office'];
|
||||
const internals = Object.fromEntries(channels.map((channel) => [`apply${channel}`, async () => calls.push(channel)]));
|
||||
internals.installUpdateRpc = () => { throw new Error('updater unavailable'); };
|
||||
const errors = [];
|
||||
await createImHostPlugin(internals).apply({
|
||||
connection: { rpc: {} }, logger: { error: (...args) => errors.push(args) },
|
||||
});
|
||||
assert.deepEqual(calls, channels);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0][0], /update management/);
|
||||
});
|
||||
366
test/update-runtime.test.mjs
Normal file
366
test/update-runtime.test.mjs
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, symlink, unlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import test from 'node:test';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { createUpdateRuntime, NPM_REGISTRY, PACKAGE_NAME } from '../plugin-src/host/update-runtime.mjs';
|
||||
|
||||
async function json(filename, value) {
|
||||
await mkdir(join(filename, '..'), { recursive: true });
|
||||
await writeFile(filename, JSON.stringify(value));
|
||||
}
|
||||
|
||||
async function plugin(directory, version = '3.0.8') {
|
||||
await mkdir(join(directory, 'lib'), { recursive: true });
|
||||
await json(join(directory, 'package.json'), {
|
||||
name: PACKAGE_NAME,
|
||||
version,
|
||||
main: './lib/index.js',
|
||||
exports: { '.': './lib/index.js', './client': './lib/client.js' },
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
});
|
||||
await Promise.all(['lib/index.js', 'lib/client.js', 'cordis.patch.yml']
|
||||
.map((entry) => writeFile(join(directory, entry), '')));
|
||||
}
|
||||
|
||||
function operation({ output = '', errors = '', exitCode = 0, signal, pending = false, onCancel } = {}) {
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
let finish;
|
||||
let closed = false;
|
||||
const done = new Promise((resolve) => { finish = resolve; });
|
||||
const settle = (code, terminationSignal = null) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
signal?.removeEventListener('abort', cancel);
|
||||
stdout.end(output);
|
||||
stderr.end(errors);
|
||||
finish({ exitCode: code, signal: terminationSignal });
|
||||
};
|
||||
const cancel = () => {
|
||||
onCancel?.();
|
||||
settle(null, 'SIGTERM');
|
||||
};
|
||||
signal?.addEventListener('abort', cancel, { once: true });
|
||||
if (!pending) setImmediate(() => settle(exitCode));
|
||||
return { stdout, stderr, done, cancel, terminate: cancel, waitForExit: () => done };
|
||||
}
|
||||
|
||||
async function fixture(t, { desktop = false, name = 'update-test', hoisted = false } = {}) {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-im-update-runtime-')));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const homeDir = join(root, 'dsh-home');
|
||||
const profileDir = join(homeDir, 'profiles', name);
|
||||
const installedLink = join(profileDir, 'node_modules', PACKAGE_NAME);
|
||||
const installedDir = hoisted ? installedLink
|
||||
: join(profileDir, 'node_modules/.pnpm/@xmanrui+dsh-im@3.0.8/node_modules', PACKAGE_NAME);
|
||||
await plugin(installedDir);
|
||||
if (!hoisted) {
|
||||
await mkdir(join(installedLink, '..'), { recursive: true });
|
||||
await symlink(installedDir, installedLink, 'dir');
|
||||
}
|
||||
const manifest = { name: `dsh-profile-${name}`, private: true, dependencies: { [PACKAGE_NAME]: '3.0.8' } };
|
||||
await json(join(profileDir, 'package.json'), manifest);
|
||||
await writeFile(join(profileDir, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n");
|
||||
const cliRoot = join(root, 'harness');
|
||||
const cliEntry = join(cliRoot, 'lib/bin.js');
|
||||
await json(join(cliRoot, 'package.json'), { name: '@deepseek-ai/dsh', bin: { dsh: 'lib/bin.js' } });
|
||||
await mkdir(join(cliEntry, '..'), { recursive: true });
|
||||
await writeFile(cliEntry, '');
|
||||
const calls = [];
|
||||
let registry = '';
|
||||
let installResult = {};
|
||||
const ctx = {
|
||||
subprocess: {
|
||||
spawn(spec) {
|
||||
calls.push(spec);
|
||||
return operation({
|
||||
...spec.argv.includes('config') ? { output: registry } : installResult,
|
||||
signal: spec.signal,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
const options = {
|
||||
ctx,
|
||||
env: { DSH_HOME: homeDir },
|
||||
argv: [process.execPath, cliEntry, '--profile', name],
|
||||
execArgv: [],
|
||||
execPath: process.execPath,
|
||||
cwd: root,
|
||||
moduleUrl: pathToFileURL(join(installedDir, 'lib/index.js')).href,
|
||||
platform: 'darwin',
|
||||
electron: false,
|
||||
};
|
||||
if (desktop) {
|
||||
const desktopRoot = join(root, 'desktop-app');
|
||||
const bootstrapPath = join(desktopRoot, 'lib/desktop-cli.js');
|
||||
await json(join(desktopRoot, 'package.json'), { name: 'dsh-plugin-desktop', version: '2.0.3' });
|
||||
await mkdir(join(bootstrapPath, '..'), { recursive: true });
|
||||
await writeFile(bootstrapPath, '');
|
||||
ctx.desktopProfiles = { current: { name, dir: profileDir } };
|
||||
ctx.desktopPnpmBootstrap = {
|
||||
activeProfileName: name,
|
||||
activeProfileDir: profileDir,
|
||||
homeDir,
|
||||
appExecutable: process.execPath,
|
||||
dshBootstrapPath: bootstrapPath,
|
||||
};
|
||||
ctx.desktopPnpm = {
|
||||
run(args, signal) {
|
||||
calls.push({ method: 'run', args, signal });
|
||||
return operation({ output: registry, signal });
|
||||
},
|
||||
runPlugin(args, cwd, signal) {
|
||||
calls.push({ method: 'runPlugin', args, cwd, signal });
|
||||
return operation({ ...installResult, signal });
|
||||
},
|
||||
};
|
||||
options.electron = '43.4.0';
|
||||
}
|
||||
return {
|
||||
root, homeDir, profileDir, installedDir, installedLink, manifest, cliEntry, ctx, options, calls,
|
||||
runtime: () => createUpdateRuntime(options),
|
||||
registry: (value) => { registry = value; },
|
||||
installResult: (value) => { installResult = value; },
|
||||
};
|
||||
}
|
||||
|
||||
test('read-only inspection accepts normal pnpm symlinks and never starts a process', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const info = await f.runtime().inspect();
|
||||
assert.equal(info.profileName, 'update-test');
|
||||
assert.equal(info.environmentKind, 'cli');
|
||||
assert.equal(info.installedVersion, '3.0.8');
|
||||
assert.equal(info.packageValid, true);
|
||||
assert.equal(info.eligible, true);
|
||||
assert.match(info.installationKey, /^[a-f0-9]{64}$/u);
|
||||
assert.deepEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test('Desktop uses optional services and accepts the hoisted installation layout', async (t) => {
|
||||
const f = await fixture(t, { desktop: true, hoisted: true });
|
||||
const values = { ...f.ctx };
|
||||
f.options.ctx = { get: (name) => values[name] };
|
||||
const info = await f.runtime().inspect();
|
||||
assert.equal(info.environmentKind, 'desktop');
|
||||
assert.equal(info.profileName, 'update-test');
|
||||
assert.equal(info.eligible, true);
|
||||
assert.deepEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test('profile detection follows only launcher arguments and never guesses from cwd', async (t) => {
|
||||
const f = await fixture(t, { name: 'web' });
|
||||
for (const args of [['web', '--profile', 'wrong'], ['--profile=web', '--prompt', '--profile', 'wrong']]) {
|
||||
f.options.argv = [process.execPath, f.cliEntry, ...args];
|
||||
assert.equal((await f.runtime().inspect()).profileName, 'web');
|
||||
}
|
||||
f.options.argv = [process.execPath, f.cliEntry, '--prompt', '--profile', 'web'];
|
||||
assert.equal((await f.runtime().inspect()).blockedReason, 'unknown-profile');
|
||||
f.options.argv = [process.execPath, f.cliEntry, '--profile', '../web'];
|
||||
assert.equal((await f.runtime().inspect()).blockedReason, 'unknown-profile');
|
||||
});
|
||||
|
||||
test('local, Git, alias and external-directory installations cannot be replaced', async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (const spec of ['link:/work/dsh-im', 'file:../dsh-im', 'github:xmanrui/dsh-im', 'git+ssh://git@example.com/repo.git', 'npm:another-plugin@1.0.0']) {
|
||||
await json(join(f.profileDir, 'package.json'), { ...f.manifest, dependencies: { [PACKAGE_NAME]: spec } });
|
||||
assert.equal((await f.runtime().inspect()).blockedReason, 'source-install', spec);
|
||||
}
|
||||
const sourceDir = join(f.root, 'source-checkout');
|
||||
await plugin(sourceDir);
|
||||
await unlink(f.installedLink);
|
||||
await symlink(sourceDir, f.installedLink, 'dir');
|
||||
await json(join(f.profileDir, 'package.json'), f.manifest);
|
||||
f.options.moduleUrl = pathToFileURL(join(sourceDir, 'lib/index.js')).href;
|
||||
assert.equal((await f.runtime().inspect()).blockedReason, 'source-install');
|
||||
assert.deepEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test('a loaded plugin from another profile cannot authorize an installation', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const otherPackage = join(f.root, 'other-profile-package');
|
||||
await plugin(otherPackage);
|
||||
f.options.moduleUrl = pathToFileURL(join(otherPackage, 'lib/index.js')).href;
|
||||
assert.equal((await f.runtime().inspect()).blockedReason, 'installation-changed');
|
||||
});
|
||||
|
||||
test('disk verification checks package identity and required entry files', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const runtime = f.runtime();
|
||||
assert.equal((await runtime.inspect()).packageValid, true);
|
||||
await unlink(join(f.installedDir, 'lib/client.js'));
|
||||
const missingClient = await runtime.inspect();
|
||||
assert.equal(missingClient.packageValid, false);
|
||||
assert.equal(missingClient.blockedReason, 'invalid-installation');
|
||||
assert.equal(missingClient.installedVersion, '3.0.8');
|
||||
});
|
||||
|
||||
test('inspection reads new disk versions and snapshots without require cache', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const runtime = f.runtime();
|
||||
const before = await runtime.inspect();
|
||||
const newDir = join(f.profileDir, 'node_modules/.pnpm/@xmanrui+dsh-im@3.0.9/node_modules', PACKAGE_NAME);
|
||||
await plugin(newDir, '3.0.9');
|
||||
await unlink(f.installedLink);
|
||||
await symlink(newDir, f.installedLink, 'dir');
|
||||
const after = await runtime.inspect();
|
||||
assert.equal(after.installedVersion, '3.0.9');
|
||||
assert.equal(after.packageValid, true);
|
||||
assert.equal(after.blockedReason, 'pending-restart');
|
||||
assert.notEqual(after.installationKey, before.installationKey);
|
||||
});
|
||||
|
||||
test('in-place Desktop replacement also requires restart until a new Host loads', async (t) => {
|
||||
const f = await fixture(t, { desktop: true, hoisted: true });
|
||||
const runtime = f.runtime();
|
||||
await runtime.inspect();
|
||||
await plugin(f.installedDir, '3.0.9');
|
||||
assert.equal((await runtime.inspect()).blockedReason, 'pending-restart');
|
||||
assert.equal((await f.runtime().inspect()).eligible, true);
|
||||
});
|
||||
|
||||
test('manifest and lockfile changes invalidate an installation snapshot', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const runtime = f.runtime();
|
||||
const first = await runtime.inspect();
|
||||
await writeFile(join(f.profileDir, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n# changed\n");
|
||||
const second = await runtime.inspect();
|
||||
assert.notEqual(first.installationKey, second.installationKey);
|
||||
await json(join(f.profileDir, 'package.json'), { ...f.manifest, dependencies: { ...f.manifest.dependencies, another: '1.0.0' } });
|
||||
assert.notEqual(second.installationKey, (await runtime.inspect()).installationKey);
|
||||
});
|
||||
|
||||
test('installation rejects changes after service confirmation before starting any process', async (t) => {
|
||||
for (const desktop of [false, true]) {
|
||||
const f = await fixture(t, { desktop });
|
||||
const runtime = f.runtime();
|
||||
const confirmed = await runtime.inspect();
|
||||
await writeFile(join(f.profileDir, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n# external change after confirmation\n");
|
||||
await assert.rejects(runtime.install('3.0.9', { expectedInstallationKey: confirmed.installationKey }), {
|
||||
code: 'installation-changed',
|
||||
});
|
||||
assert.deepEqual(f.calls, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('Desktop refuses a mismatched bootstrap or a changed active profile', async (t) => {
|
||||
const f = await fixture(t, { desktop: true });
|
||||
const runtime = f.runtime();
|
||||
assert.equal((await runtime.inspect()).eligible, true);
|
||||
f.ctx.desktopPnpmBootstrap.activeProfileName = 'wrong';
|
||||
assert.equal((await runtime.inspect()).blockedReason, 'executor-unavailable');
|
||||
delete f.ctx.desktopPnpm;
|
||||
assert.equal((await runtime.inspect()).eligible, false);
|
||||
});
|
||||
|
||||
test('preflight validates the effective scoped registry without exposing credentials', async (t) => {
|
||||
const f = await fixture(t, { desktop: true });
|
||||
const runtime = f.runtime();
|
||||
for (const output of ['', 'undefined\n', 'null\n', JSON.stringify(NPM_REGISTRY)]) {
|
||||
f.registry(output);
|
||||
assert.equal((await runtime.inspect({ preflight: true })).eligible, true);
|
||||
}
|
||||
for (const value of ['https://mirror.example/', 'https://user:password@registry.npmjs.org/', 'http://registry.npmjs.org/']) {
|
||||
f.registry(JSON.stringify(value));
|
||||
const result = await runtime.inspect({ preflight: true });
|
||||
assert.equal(result.blockedReason, 'registry-conflict');
|
||||
assert.doesNotMatch(JSON.stringify(result), /password|mirror\.example/u);
|
||||
}
|
||||
f.registry('not valid json');
|
||||
assert.equal((await runtime.inspect({ preflight: true })).blockedReason, 'registry-check-failed');
|
||||
assert.ok(f.calls.every((call) => call.method === 'run'));
|
||||
});
|
||||
|
||||
test('Desktop installation reuses runPlugin with one pinned npm target and no restart', async (t) => {
|
||||
const f = await fixture(t, { desktop: true });
|
||||
let restartCalls = 0;
|
||||
f.ctx.desktopProfiles.select = () => { restartCalls += 1; };
|
||||
f.ctx.desktopActions = { restart: () => { restartCalls += 1; } };
|
||||
const runtime = f.runtime();
|
||||
const confirmed = await runtime.inspect();
|
||||
assert.deepEqual(await runtime.install('3.0.9', { expectedInstallationKey: confirmed.installationKey }), { exitCode: 0, signal: null });
|
||||
assert.equal(f.calls.length, 2);
|
||||
assert.deepEqual(f.calls[0].args, ['config', 'get', '@xmanrui:registry', '--json']);
|
||||
assert.deepEqual(f.calls[1].args, ['add', '-w', '--save-exact', `${PACKAGE_NAME}@3.0.9`, `--registry=${NPM_REGISTRY}`]);
|
||||
assert.equal(f.calls[1].cwd, f.profileDir);
|
||||
assert.equal(restartCalls, 0);
|
||||
});
|
||||
|
||||
test('CLI installation uses the running Node and verified dsh entry, without shell interpolation', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.options.execArgv = ['--expose-internals'];
|
||||
await f.runtime().install('3.0.9');
|
||||
const [preflight, install] = f.calls;
|
||||
assert.deepEqual(preflight.argv, ['pnpm', 'config', 'get', '@xmanrui:registry', '--json']);
|
||||
assert.equal(preflight.cwd, f.profileDir);
|
||||
assert.deepEqual(install.argv, [process.execPath, '--expose-internals', f.cliEntry, 'plugin', '--profile', 'update-test',
|
||||
'add', '-w', '--save-exact', `${PACKAGE_NAME}@3.0.9`, `--registry=${NPM_REGISTRY}`]);
|
||||
assert.equal(install.env.DSH_HOME, f.homeDir);
|
||||
assert.equal(install.stdio.stdin, 'ignore');
|
||||
assert.equal(install.env.CI, 'true');
|
||||
assert.equal('shell' in install, false);
|
||||
});
|
||||
|
||||
test('installation rejects ranges, prereleases and argument injection before starting any command', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const runtime = f.runtime();
|
||||
for (const version of ['latest', '^3.0.9', '3.0.9-beta.1', 'v3.0.9', '3.0.9; echo secret', undefined]) {
|
||||
await assert.rejects(runtime.install(version), { code: 'invalid-version' });
|
||||
}
|
||||
assert.deepEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test('a registry conflict prevents the install command', async (t) => {
|
||||
const f = await fixture(t, { desktop: true });
|
||||
f.registry(JSON.stringify('https://mirror.example/'));
|
||||
await assert.rejects(f.runtime().install('3.0.9'), { code: 'registry-conflict' });
|
||||
assert.ok(f.calls.every((call) => call.method === 'run'));
|
||||
});
|
||||
|
||||
test('nonzero installation exits return only safe diagnostics', async (t) => {
|
||||
const f = await fixture(t, { desktop: true });
|
||||
f.installResult({ exitCode: 1, errors: 'secret=abcd /Users/private ERR_PNPM_EACCES https://user:password@example.com/' });
|
||||
await assert.rejects(f.runtime().install('3.0.9'), (error) => {
|
||||
assert.equal(error.code, 'install-failed');
|
||||
assert.equal(error.exitCode, 1);
|
||||
assert.equal(error.diagnosticCode, 'ERR_PNPM_EACCES');
|
||||
assert.doesNotMatch(JSON.stringify(error) + error.message, /abcd|private|password/u);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('timeout and abort use the Host-owned cancellation and await exit', async (t) => {
|
||||
for (const mode of ['timeout', 'abort']) {
|
||||
const f = await fixture(t, { desktop: true });
|
||||
let cancellations = 0;
|
||||
f.installResult({ pending: true, onCancel: () => { cancellations += 1; } });
|
||||
f.options.installTimeoutMs = mode === 'timeout' ? 20 : 10_000;
|
||||
const controller = new AbortController();
|
||||
const promise = f.runtime().install('3.0.9', { signal: controller.signal });
|
||||
if (mode === 'abort') {
|
||||
while (f.calls.length < 2) await new Promise((resolve) => setImmediate(resolve));
|
||||
controller.abort();
|
||||
}
|
||||
await assert.rejects(promise, { code: mode === 'timeout' ? 'install-timeout' : 'install-interrupted' });
|
||||
assert.ok(cancellations >= 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows CLI without a verified pnpm adapter fails closed', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.options.platform = 'win32';
|
||||
assert.equal((await f.runtime().inspect()).blockedReason, 'executor-unavailable');
|
||||
assert.deepEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test('package validation rejects entry files escaping the installed package', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const metadata = JSON.parse(await readFile(join(f.installedDir, 'package.json'), 'utf8'));
|
||||
metadata.exports['./client'] = '../../../../../outside.js';
|
||||
await json(join(f.installedDir, 'package.json'), metadata);
|
||||
assert.equal((await f.runtime().inspect()).packageValid, false);
|
||||
});
|
||||
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);
|
||||
});
|
||||
669
test/update-ui.test.mjs
Normal file
669
test/update-ui.test.mjs
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as React from 'react';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import { en, setImTranslator } from '../plugin-src/client/i18n.js';
|
||||
import { UpdatePanel } from '../plugin-src/client/update-panel.js';
|
||||
|
||||
const { act, create } = TestRenderer;
|
||||
|
||||
function snapshot(overrides = {}) {
|
||||
return {
|
||||
runningVersion: '3.0.8',
|
||||
installedVersion: '3.0.8',
|
||||
latestVersion: null,
|
||||
profileName: 'desktop-test',
|
||||
environmentKind: 'desktop',
|
||||
canInstall: false,
|
||||
blockedReason: null,
|
||||
checkedAt: null,
|
||||
checkId: null,
|
||||
job: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function available(overrides = {}) {
|
||||
return snapshot({
|
||||
latestVersion: '3.0.9', canInstall: true,
|
||||
checkedAt: 1_000, checkId: 'check_one', ...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function installing(state = 'installing') {
|
||||
return available({
|
||||
canInstall: false,
|
||||
job: { id: 'job_one', state, targetVersion: '3.0.9', message: '' },
|
||||
});
|
||||
}
|
||||
|
||||
function ok(value) { return { ok: true, value }; }
|
||||
|
||||
function textOf(node) {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
if (Array.isArray(node)) return node.map(textOf).join('');
|
||||
return node?.children?.map(textOf).join('') ?? '';
|
||||
}
|
||||
|
||||
function buttonNamed(renderer, label) {
|
||||
return renderer.root.findAllByType('button').find((node) => textOf(node) === label);
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
for (let index = 0; index < 8; index += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
async function mount(t, rpcCall, props = {}, options) {
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(UpdatePanel, {
|
||||
rpcCall, clientVersion: '3.0.8', ...props,
|
||||
}), options);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
t.after(async () => { await act(async () => { renderer.unmount(); }); });
|
||||
return renderer;
|
||||
}
|
||||
|
||||
async function click(renderer, label) {
|
||||
const button = buttonNamed(renderer, label);
|
||||
assert.ok(button, `Missing button: ${label}`);
|
||||
assert.equal(button.props.disabled === true, false, `Disabled button: ${label}`);
|
||||
await act(async () => {
|
||||
button.props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function tick(t, milliseconds = 1_000) {
|
||||
await act(async () => {
|
||||
t.mock.timers.tick(milliseconds);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
}
|
||||
|
||||
test('update panel reads local status first and requires an explicit npm check and confirmation', async (t) => {
|
||||
const calls = [];
|
||||
const reported = [];
|
||||
const renderer = await mount(t, async (endpoint, payload, signal) => {
|
||||
calls.push({ endpoint, payload, signal });
|
||||
if (endpoint === 'update.status') return ok(snapshot());
|
||||
if (endpoint === 'update.check') return ok(available());
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
}, { onStatus: (value) => reported.push(value.runningVersion) });
|
||||
|
||||
assert.deepEqual(calls.map((call) => call.endpoint), ['update.status']);
|
||||
assert.deepEqual(calls[0].payload, {});
|
||||
assert.deepEqual(reported, ['3.0.8']);
|
||||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
|
||||
|
||||
await click(renderer, '检查更新');
|
||||
assert.deepEqual(calls.map((call) => call.endpoint), ['update.status', 'update.check']);
|
||||
assert.deepEqual(calls[1].payload, {});
|
||||
assert.equal(renderer.root.findByProps({ role: 'dialog' }).props['aria-modal'], 'true');
|
||||
assert.match(textOf(renderer.toJSON()), /发现新版本/);
|
||||
assert.match(textOf(renderer.toJSON()), /desktop-test/);
|
||||
assert.match(textOf(renderer.toJSON()), /registry\.npmjs\.org/);
|
||||
assert.match(textOf(renderer.toJSON()), /需手动重启后台.*本功能不会自动重启或主动刷新页面/);
|
||||
assert.ok(buttonNamed(renderer, '安装更新'));
|
||||
assert.ok(buttonNamed(renderer, '更新至 v3.0.9'));
|
||||
assert.equal(calls.some((call) => call.endpoint === 'update.install'), false);
|
||||
await click(renderer, '关闭');
|
||||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
|
||||
});
|
||||
|
||||
test('confirmed installation ignores double clicks, polls the Host and stops at manual restart', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const pendingInstall = deferred();
|
||||
const calls = [];
|
||||
let current = snapshot();
|
||||
const renderer = await mount(t, async (endpoint, payload, signal) => {
|
||||
calls.push({ endpoint, payload, signal });
|
||||
if (endpoint === 'update.status') return ok(current);
|
||||
if (endpoint === 'update.check') return ok(current = available());
|
||||
if (endpoint === 'update.install') return pendingInstall.promise;
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
});
|
||||
|
||||
await click(renderer, '检查更新');
|
||||
const confirm = buttonNamed(renderer, '安装更新');
|
||||
await act(async () => {
|
||||
confirm.props.onClick();
|
||||
confirm.props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const installs = calls.filter((call) => call.endpoint === 'update.install');
|
||||
assert.equal(installs.length, 1);
|
||||
assert.deepEqual(Object.keys(installs[0].payload).sort(), ['checkId', 'requestId']);
|
||||
assert.equal(installs[0].payload.checkId, 'check_one');
|
||||
assert.equal(typeof installs[0].payload.requestId, 'string');
|
||||
assert.equal(installs[0].signal, undefined);
|
||||
|
||||
current = installing();
|
||||
await act(async () => {
|
||||
pendingInstall.resolve(ok(current));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
assert.match(textOf(renderer.toJSON()), /正在安装/);
|
||||
await click(renderer, '关闭');
|
||||
current = installing('verifying');
|
||||
await tick(t);
|
||||
await click(renderer, '正在更新…');
|
||||
assert.match(textOf(renderer.toJSON()), /正在校验安装结果/);
|
||||
|
||||
current = installing('restart-required');
|
||||
current.installedVersion = '3.0.9';
|
||||
await tick(t);
|
||||
assert.match(textOf(renderer.toJSON()), /已安装,待手动重启/);
|
||||
assert.match(textOf(renderer.toJSON()), /已安装版本v3.0.9/);
|
||||
assert.equal(buttonNamed(renderer, '安装更新'), undefined);
|
||||
assert.equal(buttonNamed(renderer, '重新检查'), undefined);
|
||||
const callsAtCompletion = calls.length;
|
||||
await tick(t, 10_000);
|
||||
assert.equal(calls.length, callsAtCompletion);
|
||||
assert.equal(calls.some((call) => /restart|reload/.test(call.endpoint)), false);
|
||||
assert.ok(buttonNamed(renderer, '待手动重启'));
|
||||
});
|
||||
|
||||
test('a retry after a lost installation response reuses the same request id', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const requests = [];
|
||||
const renderer = await mount(t, async (endpoint, payload) => {
|
||||
if (endpoint === 'update.status') return ok(available());
|
||||
if (endpoint === 'update.install') {
|
||||
requests.push(payload);
|
||||
if (requests.length === 1) throw new Error('Connection closed');
|
||||
return ok(installing());
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
});
|
||||
await click(renderer, '更新至 v3.0.9');
|
||||
await click(renderer, '安装更新');
|
||||
assert.match(textOf(renderer.toJSON()), /Connection closed/);
|
||||
await click(renderer, '安装更新');
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(requests[1], requests[0]);
|
||||
});
|
||||
|
||||
test('a failed npm check cannot reuse an earlier up-to-date result', async (t) => {
|
||||
const renderer = await mount(t, async (endpoint) => {
|
||||
if (endpoint === 'update.status') {
|
||||
return ok(snapshot({ latestVersion: '3.0.8', checkedAt: 1_000, blockedReason: 'no-update' }));
|
||||
}
|
||||
return { ok: false, error: { code: 'check-failed', message: 'npm timed out' } };
|
||||
});
|
||||
await click(renderer, '检查更新');
|
||||
assert.match(textOf(renderer.toJSON()), /无法访问 npm 或请求超时/);
|
||||
assert.match(textOf(renderer.toJSON()), /尚未检查/);
|
||||
assert.doesNotMatch(textOf(renderer.toJSON()), /已是最新版本|当前版本无需更新/);
|
||||
assert.equal(buttonNamed(renderer, '安装更新'), undefined);
|
||||
});
|
||||
|
||||
test('source installations can check npm but never offer automatic replacement', async (t) => {
|
||||
const calls = [];
|
||||
const renderer = await mount(t, async (endpoint) => {
|
||||
calls.push(endpoint);
|
||||
return ok(endpoint === 'update.status'
|
||||
? snapshot({ blockedReason: 'source-install' })
|
||||
: available({ blockedReason: 'source-install', canInstall: false, checkId: null }));
|
||||
});
|
||||
await click(renderer, '检查更新');
|
||||
assert.deepEqual(calls, ['update.status', 'update.check']);
|
||||
assert.match(textOf(renderer.toJSON()), /当前是源码或链接安装/);
|
||||
assert.match(textOf(renderer.toJSON()), /v3.0.9/);
|
||||
assert.equal(buttonNamed(renderer, '安装更新'), undefined);
|
||||
});
|
||||
|
||||
test('an older Host gives manual update guidance instead of breaking settings', async (t) => {
|
||||
const renderer = await mount(t, async () => {
|
||||
const error = new Error('Unknown endpoint update.status');
|
||||
error.rpcError = { code: 'endpoint-not-found', message: error.message };
|
||||
throw error;
|
||||
});
|
||||
await click(renderer, '检查更新');
|
||||
assert.match(textOf(renderer.toJSON()), /当前 Host 不支持更新接口/);
|
||||
assert.equal(buttonNamed(renderer, '安装更新'), undefined);
|
||||
assert.ok(buttonNamed(renderer, '重新检查'));
|
||||
});
|
||||
|
||||
test('unmount aborts an in-flight status poll and cannot create a zombie timer', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const pendingPoll = deferred();
|
||||
const signals = [];
|
||||
let reads = 0;
|
||||
const reported = [];
|
||||
const renderer = await mount(t, async (endpoint, payload, signal) => {
|
||||
assert.equal(endpoint, 'update.status');
|
||||
signals.push(signal);
|
||||
reads += 1;
|
||||
return reads === 1 ? ok(installing()) : pendingPoll.promise;
|
||||
}, { onStatus: (value) => reported.push(value) });
|
||||
await tick(t);
|
||||
assert.equal(reads, 2);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
assert.equal(signals[1].aborted, true);
|
||||
await act(async () => {
|
||||
pendingPoll.resolve(ok(installing()));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
await tick(t, 10_000);
|
||||
assert.equal(reads, 2);
|
||||
assert.equal(reported.length, 1);
|
||||
});
|
||||
|
||||
test('unmount aborts a pending version check but does not cancel an installation request', async (t) => {
|
||||
const pending = deferred();
|
||||
let checkSignal;
|
||||
const checking = await mount(t, async (endpoint, payload, signal) => {
|
||||
if (endpoint === 'update.status') return ok(snapshot());
|
||||
checkSignal = signal;
|
||||
return pending.promise;
|
||||
});
|
||||
await click(checking, '检查更新');
|
||||
await act(async () => { checking.unmount(); });
|
||||
assert.equal(checkSignal.aborted, true);
|
||||
await act(async () => { pending.resolve(ok(available())); });
|
||||
|
||||
const pendingInstall = deferred();
|
||||
let installSignal = 'not called';
|
||||
const reports = [];
|
||||
const installingRenderer = await mount(t, async (endpoint, payload, signal) => {
|
||||
if (endpoint === 'update.status') return ok(available());
|
||||
assert.equal(endpoint, 'update.install');
|
||||
installSignal = signal;
|
||||
return pendingInstall.promise;
|
||||
}, { onStatus: (value) => reports.push(value) });
|
||||
await click(installingRenderer, '更新至 v3.0.9');
|
||||
await click(installingRenderer, '安装更新');
|
||||
await act(async () => { installingRenderer.unmount(); });
|
||||
assert.equal(installSignal, undefined);
|
||||
await act(async () => { pendingInstall.resolve(ok(installing())); });
|
||||
assert.equal(reports.length, 1);
|
||||
});
|
||||
|
||||
test('pending restart shows manual restart guidance instead of a redundant page refresh suggestion', async (t) => {
|
||||
const calls = [];
|
||||
const current = installing('restart-required');
|
||||
current.installedVersion = '3.0.9';
|
||||
const renderer = await mount(t, async (endpoint) => {
|
||||
calls.push(endpoint);
|
||||
return ok(current);
|
||||
}, { clientVersion: '3.0.7' });
|
||||
await click(renderer, '待手动重启');
|
||||
assert.deepEqual(calls, ['update.status', 'update.status']);
|
||||
assert.match(textOf(renderer.toJSON()), /运行版本v3.0.8/);
|
||||
assert.match(textOf(renderer.toJSON()), /页面版本v3.0.7/);
|
||||
assert.match(textOf(renderer.toJSON()), /手动重启当前 Harness 或 Desktop/);
|
||||
assert.doesNotMatch(textOf(renderer.toJSON()), /页面版本与运行版本不同|手动刷新页面/);
|
||||
assert.equal(buttonNamed(renderer, '安装更新'), undefined);
|
||||
assert.equal(buttonNamed(renderer, '重新检查'), undefined);
|
||||
|
||||
const stalePage = await mount(t, async () => ok(available()), { clientVersion: '3.0.7' });
|
||||
await click(stalePage, '更新至 v3.0.9');
|
||||
assert.match(textOf(stalePage.toJSON()), /页面版本与运行版本不同/);
|
||||
assert.match(textOf(stalePage.toJSON()), /手动刷新页面.*手动重启 Harness 或 Desktop/);
|
||||
});
|
||||
|
||||
test('remounting the new client retains the old Host version and restores the restart requirement', async (t) => {
|
||||
const calls = [];
|
||||
let current = snapshot();
|
||||
let headerVersion = '3.0.8';
|
||||
const rpcCall = async (endpoint) => {
|
||||
calls.push(endpoint);
|
||||
return ok(current);
|
||||
};
|
||||
const onStatus = (value) => { headerVersion = value.runningVersion; };
|
||||
const initial = await mount(t, rpcCall, { onStatus });
|
||||
await act(async () => { initial.unmount(); });
|
||||
|
||||
current = snapshot({ installedVersion: '3.0.9', blockedReason: 'pending-restart' });
|
||||
headerVersion = '3.0.9';
|
||||
const remounted = await mount(t, rpcCall, { clientVersion: '3.0.9', onStatus });
|
||||
assert.equal(headerVersion, '3.0.8');
|
||||
await click(remounted, '待手动重启');
|
||||
assert.match(textOf(remounted.toJSON()), /运行版本v3.0.8/);
|
||||
assert.match(textOf(remounted.toJSON()), /已安装版本v3.0.9/);
|
||||
assert.match(textOf(remounted.toJSON()), /页面版本v3.0.9/);
|
||||
assert.match(textOf(remounted.toJSON()), /已安装,待手动重启/);
|
||||
assert.doesNotMatch(textOf(remounted.toJSON()), /手动刷新页面/);
|
||||
assert.equal(buttonNamed(remounted, '安装更新'), undefined);
|
||||
assert.equal(buttonNamed(remounted, '重新检查'), undefined);
|
||||
assert.deepEqual(calls, ['update.status', 'update.status', 'update.status']);
|
||||
});
|
||||
|
||||
test('manual status refresh observes a restarted Host without remounting or checking npm', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const calls = [];
|
||||
const headerVersions = [];
|
||||
let current = snapshot({
|
||||
runningVersion: '3.0.7', installedVersion: '3.0.8', blockedReason: 'pending-restart',
|
||||
job: { id: 'restart_job', state: 'restart-required', targetVersion: '3.0.8', message: null },
|
||||
});
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
return ok(current);
|
||||
};
|
||||
const renderer = await mount(t, rpcCall, {
|
||||
clientVersion: '3.0.7', onStatus: (value) => headerVersions.push(value.runningVersion),
|
||||
});
|
||||
const originalPanel = renderer.root.findByType(UpdatePanel);
|
||||
await click(renderer, '待手动重启');
|
||||
const refreshButton = buttonNamed(renderer, '刷新状态');
|
||||
assert.ok(refreshButton);
|
||||
await tick(t, 10_000);
|
||||
assert.equal(calls.length, 2);
|
||||
|
||||
current = { ...current, runningVersion: '3.0.8', blockedReason: null,
|
||||
job: { ...current.job, state: 'completed' } };
|
||||
await click(renderer, '刷新状态');
|
||||
assert.equal(renderer.root.findByType(UpdatePanel), originalPanel);
|
||||
assert.equal(originalPanel.props.rpcCall, rpcCall);
|
||||
assert.deepEqual(headerVersions, ['3.0.7', '3.0.7', '3.0.8']);
|
||||
assert.match(textOf(renderer.toJSON()), /运行版本v3.0.8/);
|
||||
assert.match(textOf(renderer.toJSON()), /更新已生效/);
|
||||
assert.doesNotMatch(textOf(renderer.toJSON()), /待手动重启/);
|
||||
assert.equal(buttonNamed(renderer, '刷新状态'), undefined);
|
||||
assert.ok(buttonNamed(renderer, '重新检查') === refreshButton, 'The focused footer button must keep its node identity');
|
||||
assert.deepEqual(calls, Array.from({ length: 3 }, () => ({ endpoint: 'update.status', payload: {} })));
|
||||
await tick(t, 10_000);
|
||||
assert.equal(calls.length, 3);
|
||||
});
|
||||
|
||||
test('a late uncertainty poll cannot restore an old Host after an install retry and manual refresh', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const oldPoll = deferred();
|
||||
const headers = [];
|
||||
const pending = snapshot({
|
||||
runningVersion: '3.0.7', installedVersion: '3.0.8', blockedReason: 'pending-restart',
|
||||
job: { id: 'retry_job', state: 'restart-required', targetVersion: '3.0.8', message: null },
|
||||
});
|
||||
const completed = { ...pending, runningVersion: '3.0.8', blockedReason: null,
|
||||
job: { ...pending.job, state: 'completed' } };
|
||||
let reads = 0;
|
||||
let installs = 0;
|
||||
let oldSignal;
|
||||
const renderer = await mount(t, async (endpoint, payload, signal) => {
|
||||
if (endpoint === 'update.install') {
|
||||
installs += 1;
|
||||
if (installs === 1) throw new Error('Lost install response');
|
||||
return ok(pending);
|
||||
}
|
||||
assert.equal(endpoint, 'update.status');
|
||||
reads += 1;
|
||||
if (reads === 1) return ok(available({
|
||||
runningVersion: '3.0.7', installedVersion: '3.0.7', latestVersion: '3.0.8',
|
||||
}));
|
||||
if (reads === 2) {
|
||||
oldSignal = signal;
|
||||
return oldPoll.promise;
|
||||
}
|
||||
return ok(completed);
|
||||
}, { clientVersion: '3.0.7', onStatus: (value) => headers.push(value.runningVersion) });
|
||||
await click(renderer, '更新至 v3.0.8');
|
||||
await click(renderer, '安装更新');
|
||||
await tick(t);
|
||||
await click(renderer, '安装更新');
|
||||
assert.equal(oldSignal.aborted, true);
|
||||
await click(renderer, '刷新状态');
|
||||
await act(async () => {
|
||||
oldPoll.resolve(ok(pending));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
assert.deepEqual(headers, ['3.0.7', '3.0.7', '3.0.8']);
|
||||
assert.equal(oldSignal.aborted, true);
|
||||
assert.match(textOf(renderer.toJSON()), /更新已生效/);
|
||||
assert.doesNotMatch(textOf(renderer.toJSON()), /待手动重启/);
|
||||
await tick(t, 10_000);
|
||||
assert.equal(reads, 3);
|
||||
});
|
||||
|
||||
test('cancelling an uncertainty poll still resumes polling an accepted active installation', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const oldPoll = deferred();
|
||||
const states = [];
|
||||
let reads = 0;
|
||||
let installs = 0;
|
||||
let oldSignal;
|
||||
const renderer = await mount(t, async (endpoint, payload, signal) => {
|
||||
if (endpoint === 'update.install') {
|
||||
installs += 1;
|
||||
if (installs === 1) throw new Error('Lost install response');
|
||||
return ok(installing());
|
||||
}
|
||||
assert.equal(endpoint, 'update.status');
|
||||
reads += 1;
|
||||
if (reads === 1) return ok(available());
|
||||
if (reads === 2) {
|
||||
oldSignal = signal;
|
||||
return oldPoll.promise;
|
||||
}
|
||||
return ok(installing('restart-required'));
|
||||
}, { onStatus: (value) => states.push(value.job?.state ?? null) });
|
||||
await click(renderer, '更新至 v3.0.9');
|
||||
await click(renderer, '安装更新');
|
||||
await tick(t);
|
||||
await click(renderer, '安装更新');
|
||||
assert.equal(oldSignal.aborted, true);
|
||||
await act(async () => {
|
||||
oldPoll.resolve(ok(available()));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
assert.equal(oldSignal.aborted, true);
|
||||
assert.match(textOf(renderer.toJSON()), /正在安装/);
|
||||
await tick(t);
|
||||
assert.deepEqual(states, [null, 'installing', 'restart-required']);
|
||||
assert.equal(reads, 3);
|
||||
});
|
||||
|
||||
test('a failed local status refresh releases the button and can be retried', async (t) => {
|
||||
const calls = [];
|
||||
const pending = snapshot({
|
||||
runningVersion: '3.0.7', installedVersion: '3.0.8', blockedReason: 'pending-restart',
|
||||
});
|
||||
const renderer = await mount(t, async (endpoint) => {
|
||||
calls.push(endpoint);
|
||||
if (calls.length === 1) return ok(pending);
|
||||
if (calls.length === 2) throw new Error('Host is restarting');
|
||||
return ok(snapshot({
|
||||
job: { id: 'restart_job', state: 'completed', targetVersion: '3.0.8', message: null },
|
||||
}));
|
||||
}, { clientVersion: '3.0.7' });
|
||||
await click(renderer, '待手动重启');
|
||||
assert.match(textOf(renderer.toJSON()), /Host is restarting/);
|
||||
assert.match(textOf(renderer.toJSON()), /待手动重启/);
|
||||
assert.equal(buttonNamed(renderer, '刷新状态').props.disabled, false);
|
||||
await click(renderer, '刷新状态');
|
||||
assert.match(textOf(renderer.toJSON()), /更新已生效/);
|
||||
assert.doesNotMatch(textOf(renderer.toJSON()), /Host is restarting|待手动重启/);
|
||||
assert.deepEqual(calls, ['update.status', 'update.status', 'update.status']);
|
||||
});
|
||||
|
||||
test('opening a stale restart notice reads the new Host status once', async (t) => {
|
||||
const calls = [];
|
||||
const headerVersions = [];
|
||||
let current = snapshot({
|
||||
runningVersion: '3.0.7', installedVersion: '3.0.8', blockedReason: 'pending-restart',
|
||||
});
|
||||
const rpcCall = async (endpoint) => {
|
||||
calls.push(endpoint);
|
||||
return ok(current);
|
||||
};
|
||||
const renderer = await mount(t, rpcCall, {
|
||||
clientVersion: '3.0.7', onStatus: (value) => headerVersions.push(value.runningVersion),
|
||||
});
|
||||
current = snapshot({
|
||||
job: { id: 'restart_job', state: 'completed', targetVersion: '3.0.8', message: null },
|
||||
});
|
||||
await click(renderer, '待手动重启');
|
||||
assert.deepEqual(headerVersions, ['3.0.7', '3.0.8']);
|
||||
assert.match(textOf(renderer.toJSON()), /更新已生效/);
|
||||
assert.doesNotMatch(textOf(renderer.toJSON()), /待手动重启/);
|
||||
assert.deepEqual(calls, ['update.status', 'update.status']);
|
||||
});
|
||||
|
||||
test('unmount aborts a manual status refresh and ignores its late response', async (t) => {
|
||||
const pending = deferred();
|
||||
const headers = [];
|
||||
const signals = [];
|
||||
const current = snapshot({
|
||||
runningVersion: '3.0.7', installedVersion: '3.0.8', blockedReason: 'pending-restart',
|
||||
});
|
||||
const renderer = await mount(t, async (endpoint, payload, signal) => {
|
||||
assert.equal(endpoint, 'update.status');
|
||||
signals.push(signal);
|
||||
return signals.length < 3 ? ok(current) : pending.promise;
|
||||
}, { onStatus: (value) => headers.push(value.runningVersion) });
|
||||
await click(renderer, '待手动重启');
|
||||
const refresh = buttonNamed(renderer, '刷新状态');
|
||||
await act(async () => {
|
||||
refresh.props.onClick();
|
||||
refresh.props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
assert.equal(signals.length, 3);
|
||||
assert.equal(buttonNamed(renderer, '刷新状态').props.disabled, true);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
assert.equal(signals[2].aborted, true);
|
||||
await act(async () => { pending.resolve(ok(snapshot())); });
|
||||
assert.deepEqual(headers, ['3.0.7', '3.0.7']);
|
||||
});
|
||||
|
||||
test('disk changes and a persisted failure render actionable localized status', async (t) => {
|
||||
const pendingRestart = await mount(t, async () => ok(snapshot({
|
||||
installedVersion: '3.0.9', blockedReason: 'pending-restart',
|
||||
})));
|
||||
await click(pendingRestart, '待手动重启');
|
||||
assert.match(textOf(pendingRestart.toJSON()), /已安装,待手动重启/);
|
||||
assert.equal(buttonNamed(pendingRestart, '重新检查'), undefined);
|
||||
|
||||
const failed = await mount(t, async () => ok(snapshot({
|
||||
job: { id: 'failed_job', state: 'failed', targetVersion: '3.0.9', message: 'install-failed' },
|
||||
})));
|
||||
await click(failed, '检查更新');
|
||||
assert.match(textOf(failed.toJSON()), /目标版本v3.0.9/);
|
||||
assert.match(textOf(failed.toJSON()), /安装失败,请检查当前安装状态后重试/);
|
||||
assert.doesNotMatch(textOf(failed.toJSON()), /install-failed/);
|
||||
|
||||
const uncertain = await mount(t, async () => ok(snapshot({
|
||||
job: { id: 'uncertain_job', state: 'interrupted', targetVersion: '3.0.9', message: 'state-unavailable' },
|
||||
})));
|
||||
await click(uncertain, '检查更新');
|
||||
assert.match(textOf(uncertain.toJSON()), /无法安全保存更新状态,请先检查当前安装结果/);
|
||||
assert.doesNotMatch(textOf(uncertain.toJSON()), /未开始安装/);
|
||||
});
|
||||
|
||||
test('update confirmation, source protection and failures have English copy', async (t) => {
|
||||
setImTranslator((key) => en[key] ?? key);
|
||||
t.after(() => setImTranslator(null));
|
||||
let current = available();
|
||||
const renderer = await mount(t, async (endpoint) => {
|
||||
if (endpoint === 'update.install') {
|
||||
return { ok: false, error: { code: 'check-expired', message: 'expired' } };
|
||||
}
|
||||
return ok(current);
|
||||
});
|
||||
await click(renderer, 'Update to v3.0.9');
|
||||
assert.match(textOf(renderer.toJSON()), /Install update/);
|
||||
assert.doesNotMatch(textOf(renderer.toJSON()), /[\p{Script=Han}]/u);
|
||||
await click(renderer, 'Install update');
|
||||
assert.match(textOf(renderer.toJSON()), /version confirmation expired/);
|
||||
assert.doesNotMatch(textOf(renderer.toJSON()), /[\p{Script=Han}]/u);
|
||||
current = available({ canInstall: false, checkId: null, blockedReason: 'source-install' });
|
||||
await click(renderer, 'Check again');
|
||||
assert.match(textOf(renderer.toJSON()), /source or linked installation/);
|
||||
assert.doesNotMatch(textOf(renderer.toJSON()), /[\p{Script=Han}]/u);
|
||||
current = installing('completed');
|
||||
current.runningVersion = '3.0.9';
|
||||
current.installedVersion = '3.0.9';
|
||||
await click(renderer, 'Check again');
|
||||
assert.match(textOf(renderer.toJSON()), /Updated version is active/);
|
||||
assert.doesNotMatch(textOf(renderer.toJSON()), /Update is running|[\p{Script=Han}]/u);
|
||||
});
|
||||
|
||||
test('dialog actions retain focus before disabling the initiating button', async (t) => {
|
||||
const scenarios = [
|
||||
{
|
||||
initial: snapshot({ installedVersion: '3.0.9', blockedReason: 'pending-restart' }),
|
||||
open: '待手动重启', action: '刷新状态', endpoint: 'update.status',
|
||||
},
|
||||
{ initial: available(), open: '更新至 v3.0.9', action: '重新检查', endpoint: 'update.check' },
|
||||
{ initial: available(), open: '更新至 v3.0.9', action: '安装更新', endpoint: 'update.install' },
|
||||
];
|
||||
for (const scenario of scenarios) {
|
||||
const response = deferred();
|
||||
const trace = [];
|
||||
let submitting = false;
|
||||
const renderer = await mount(t, async (endpoint) => {
|
||||
if (submitting) {
|
||||
trace.push(endpoint);
|
||||
return response.promise;
|
||||
}
|
||||
return ok(scenario.initial);
|
||||
});
|
||||
await click(renderer, scenario.open);
|
||||
const button = buttonNamed(renderer, scenario.action);
|
||||
submitting = true;
|
||||
await act(async () => {
|
||||
button.props.onClick({
|
||||
currentTarget: {
|
||||
closest(selector) {
|
||||
assert.equal(selector, '.dim-updateDialog');
|
||||
return { focus() { trace.push('focus'); } };
|
||||
},
|
||||
},
|
||||
});
|
||||
await flushMicrotasks();
|
||||
});
|
||||
assert.deepEqual(trace, ['focus', scenario.endpoint], scenario.action);
|
||||
assert.equal(button.props.disabled, true);
|
||||
await act(async () => { response.resolve(ok(scenario.initial)); });
|
||||
}
|
||||
});
|
||||
|
||||
test('the confirmation supports keyboard focus, tab wrapping, Escape and focus restoration', async (t) => {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const previous = { isConnected: true, focus() { document.activeElement = previous; } };
|
||||
const first = { focus() { document.activeElement = first; } };
|
||||
const last = { focus() { document.activeElement = last; } };
|
||||
const dialogNode = {
|
||||
focus() { document.activeElement = dialogNode; },
|
||||
querySelectorAll() { return [first, last]; },
|
||||
};
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true, value: { activeElement: previous },
|
||||
});
|
||||
t.after(() => {
|
||||
if (original) Object.defineProperty(globalThis, 'document', original);
|
||||
else delete globalThis.document;
|
||||
});
|
||||
const renderer = await mount(t, async () => ok(available()), {}, {
|
||||
createNodeMock(element) {
|
||||
return element.props?.className === 'dim-updateDialog' ? dialogNode : null;
|
||||
},
|
||||
});
|
||||
await click(renderer, '更新至 v3.0.9');
|
||||
assert.equal(document.activeElement, dialogNode);
|
||||
const dialog = renderer.root.findByProps({ role: 'dialog' });
|
||||
let prevented = 0;
|
||||
await act(async () => {
|
||||
dialog.props.onKeyDown({ key: 'Tab', shiftKey: false, preventDefault() { prevented += 1; } });
|
||||
});
|
||||
assert.equal(document.activeElement, first);
|
||||
await act(async () => {
|
||||
dialog.props.onKeyDown({ key: 'Tab', shiftKey: true, preventDefault() { prevented += 1; } });
|
||||
});
|
||||
assert.equal(document.activeElement, last);
|
||||
await act(async () => {
|
||||
dialog.props.onKeyDown({ key: 'Escape', preventDefault() {}, stopPropagation() {} });
|
||||
});
|
||||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
|
||||
assert.equal(document.activeElement, previous);
|
||||
assert.equal(prevented, 2);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue