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

870 lines
37 KiB
JavaScript

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) => (node.props['aria-label'] ?? 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 };
}
function stubClipboard(t, clipboard) {
const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { clipboard } });
t.after(() => {
if (original) Object.defineProperty(globalThis, 'navigator', original);
else delete globalThis.navigator;
});
}
function manualCommandOf(renderer) {
return renderer.root.findAllByType('textarea')[0]?.props.value;
}
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()), /需手动重启后台.*本功能不会自动重启或主动刷新页面/);
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('version details hide npm, registry and client metadata while preserving running and installed versions', async (t) => {
for (const latestVersion of [null, '3.1.2']) {
const renderer = await mount(t, async () => ok(snapshot({
runningVersion: '3.1.0', installedVersion: '3.1.1', latestVersion,
profileName: 'web', blockedReason: 'pending-restart',
})), { clientVersion: '3.1.1' });
await click(renderer, '待手动重启');
const details = renderer.root.findByProps({ className: 'dim-updateVersions' });
assert.deepEqual(details.findAllByType('dt').map(textOf), ['运行版本', '已安装版本', '目标 profile']);
assert.deepEqual(details.findAllByType('dd').map(textOf), ['v3.1.0', 'v3.1.1', 'web']);
}
});
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(manualCommandOf(renderer), /@latest$/);
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 reported = [];
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 }));
}, { onStatus: (value) => reported.push(value) });
await click(renderer, '检查更新');
assert.deepEqual(calls, ['update.status', 'update.check']);
assert.match(textOf(renderer.toJSON()), /当前是源码或链接安装/);
assert.equal(reported.at(-1).latestVersion, '3.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()), /手动重启当前 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()), /已安装,待手动重启/);
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(selector) {
assert.match(selector, /textarea:not\(:disabled\)/);
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);
});
test('manual commands use the current profile and a known target without downgrading', async (t) => {
const cases = [
{ profileName: 'web', runningVersion: '3.1.0', installedVersion: '3.1.1',
blockedReason: 'pending-restart', expected: 'web', version: '3.1.1' },
{ profileName: 'desktop-test', job: { state: 'failed', targetVersion: '3.1.2', message: 'install-failed' },
expected: 'desktop-test', version: '3.1.2' },
{ profileName: 'web', latestVersion: '3.1.3', job: { state: 'failed', targetVersion: '3.1.2' },
expected: 'web', version: '3.1.3' },
{ profileName: 'web', runningVersion: '3.1.10', installedVersion: '3.1.10', latestVersion: '3.1.9',
expected: 'web', version: '3.1.10' },
{ profileName: 'Team One', expected: '"Team One"', version: 'latest' },
{ profileName: '测试环境', expected: '"测试环境"', version: 'latest' },
{ profileName: 'web', latestVersion: '3.1.1; echo unsafe', expected: 'web', version: 'latest' },
{ profileName: 'web', job: { state: 'completed', targetVersion: '3.0.8' }, expected: 'web', version: 'latest' },
];
for (const { expected, version, ...fields } of cases) {
const renderer = await mount(t, async () => ok(snapshot(fields)));
await click(renderer, fields.blockedReason === 'pending-restart' ? '待手动重启' : '检查更新');
assert.equal(manualCommandOf(renderer),
`dsh plugin --profile ${expected} add -w @xmanrui/dsh-im@${version}`);
assert.equal(renderer.root.findByType('textarea').props.readOnly, true);
assert.match(textOf(renderer.toJSON()), /自动更新失败可以使用命令更新:/);
assert.doesNotMatch(textOf(renderer.toJSON()), /通常只需手动重启/);
}
});
test('manual copying uses an accessible icon at the right of the command row', async (t) => {
stubClipboard(t, { async writeText() {} });
const renderer = await mount(t, async () => ok(available()));
await click(renderer, '更新至 v3.0.9');
const row = renderer.root.findByProps({ className: 'dim-updateCommandRow' });
const copy = buttonNamed(renderer, '复制命令');
assert.equal(row.children[0].type, 'textarea');
assert.equal(row.children.at(-1), copy);
assert.equal(copy.props.className, 'dim-updateCopy');
assert.equal(copy.props.title, '复制命令');
assert.equal(textOf(copy), '');
assert.equal(copy.findByType('svg').props['aria-hidden'], 'true');
assert.equal(copy.findByType('svg').props.focusable, 'false');
await click(renderer, '复制命令');
const copied = buttonNamed(renderer, '已复制');
assert.match(copied.props.className, /dim-updateCopyCopied/);
assert.equal(copied.props.title, '已复制');
assert.equal(textOf(copied), '');
assert.equal(copied.findAllByType('rect').length, 0);
});
test('manual commands remain available after a failed npm check and explain the latest fallback', async (t) => {
const renderer = await mount(t, async (endpoint) => {
if (endpoint === 'update.status') return ok(snapshot({ profileName: 'web', environmentKind: 'cli' }));
throw new Error('npm unavailable');
});
await click(renderer, '检查更新');
assert.match(manualCommandOf(renderer), /@xmanrui\/dsh-im@latest$/);
assert.match(textOf(renderer.toJSON()), /尚未确认目标版本.*执行时 npm 的 latest/);
assert.match(textOf(renderer.toJSON()), /DSH_HOME 一致/);
assert.equal(buttonNamed(renderer, '复制命令').props.disabled, false);
assert.equal(buttonNamed(renderer, '安装更新'), undefined);
});
test('manual commands cannot overwrite source installs or interpolate unsafe profiles', async (t) => {
for (const profileName of [null, '', '..', 'node_modules', '-other', '../other', 'work; echo x',
'work$(echo x)', 'work`echo x`', 'work%PATH%', 'work"name', 'work\nname']) {
const renderer = await mount(t, async () => ok(snapshot({ profileName })));
await click(renderer, '检查更新');
assert.equal(manualCommandOf(renderer), undefined, String(profileName));
assert.equal(buttonNamed(renderer, '复制命令'), undefined);
}
for (const blockedReason of ['source-install', 'pending-restart', 'recovery-required']) {
const renderer = await mount(t, async () => ok(snapshot({
blockedReason, ...(blockedReason === 'source-install' ? {} : { sourceInstall: true }),
})));
await click(renderer, blockedReason === 'pending-restart' ? '待手动重启' : '检查更新');
assert.equal(manualCommandOf(renderer), undefined);
assert.equal(buttonNamed(renderer, '复制命令'), undefined);
assert.match(textOf(renderer.toJSON()), /不提供覆盖源码的 npm 命令/);
}
});
test('copying writes exactly the displayed command and never submits an installation', async (t) => {
const writes = [];
const calls = [];
const clipboard = { async writeText(value) { assert.equal(this, clipboard); writes.push(value); } };
stubClipboard(t, clipboard);
const renderer = await mount(t, async (endpoint) => {
calls.push(endpoint);
return ok(available());
});
await click(renderer, '更新至 v3.0.9');
assert.match(textOf(renderer.toJSON()), /Desktop 的内置终端/);
const before = [...calls];
await click(renderer, '复制命令');
assert.deepEqual(writes, [manualCommandOf(renderer)]);
assert.deepEqual(calls, before);
assert.ok(buttonNamed(renderer, '已复制'));
assert.match(textOf(renderer.toJSON()), /命令已复制/);
});
test('copy failure selects the command for keyboard copying and allows a retry', async (t) => {
const selection = [];
let denied = true;
stubClipboard(t, { async writeText() { if (denied) throw new Error('NotAllowedError'); } });
const renderer = await mount(t, async () => ok(available()), {}, {
createNodeMock(element) {
return element.type === 'textarea' ? {
focus() { selection.push('focus'); }, select() { selection.push('select'); },
} : null;
},
});
await click(renderer, '更新至 v3.0.9');
await click(renderer, '复制命令');
assert.deepEqual(selection, ['focus', 'select']);
assert.match(textOf(renderer.root.findByProps({ role: 'alert' })), /Ctrl\+C 或 ⌘C/);
assert.equal(buttonNamed(renderer, '复制命令').props.disabled, false);
denied = false;
await click(renderer, '复制命令');
assert.ok(buttonNamed(renderer, '已复制'));
assert.equal(renderer.root.findAllByProps({ role: 'alert' }).length, 0);
});
test('missing clipboard support exposes a selectable command without claiming success', async (t) => {
stubClipboard(t, undefined);
const renderer = await mount(t, async () => ok(available()));
await click(renderer, '更新至 v3.0.9');
await click(renderer, '复制命令');
assert.match(textOf(renderer.toJSON()), /复制失败/);
assert.ok(manualCommandOf(renderer));
assert.equal(buttonNamed(renderer, '已复制'), undefined);
});
test('an old clipboard result cannot mark a changed command or reopened dialog as copied', async (t) => {
const pending = deferred();
let writes = 0;
stubClipboard(t, { writeText() { writes += 1; return pending.promise; } });
const renderer = await mount(t, async (endpoint) => ok(endpoint === 'update.check'
? available({ latestVersion: '3.0.10' }) : available()));
await click(renderer, '更新至 v3.0.9');
const copy = buttonNamed(renderer, '复制命令');
await act(async () => { copy.props.onClick(); copy.props.onClick(); await flushMicrotasks(); });
assert.equal(writes, 1);
assert.equal(buttonNamed(renderer, '复制中…').props.disabled, true);
await click(renderer, '重新检查');
assert.match(manualCommandOf(renderer), /@3\.0\.10$/);
await act(async () => { pending.resolve(); await flushMicrotasks(); });
assert.equal(buttonNamed(renderer, '已复制'), undefined);
await click(renderer, '复制命令');
assert.ok(buttonNamed(renderer, '已复制'));
await click(renderer, '关闭');
await click(renderer, '更新至 v3.0.10');
assert.ok(buttonNamed(renderer, '复制命令'));
});
test('manual copying is disabled while the Host may still be installing', async (t) => {
const renderer = await mount(t, async () => ok(installing()));
await click(renderer, '正在更新…');
assert.equal(buttonNamed(renderer, '复制命令').props.disabled, true);
assert.match(textOf(renderer.toJSON()), /确认没有安装进程运行/);
});
test('manual update instructions and copy feedback render in English', async (t) => {
setImTranslator((key) => en[key] ?? key);
t.after(() => setImTranslator(null));
stubClipboard(t, { async writeText() {} });
const renderer = await mount(t, async () => ok(available()));
await click(renderer, 'Update to v3.0.9');
assert.match(textOf(renderer.toJSON()), /Manual update/);
assert.match(textOf(renderer.toJSON()), /If the automatic update fails, update with this command:/);
assert.equal(renderer.root.findByType('textarea').props['aria-label'], 'Manual update command');
assert.equal(buttonNamed(renderer, 'Copy command').props.title, 'Copy command');
await click(renderer, 'Copy command');
assert.match(textOf(renderer.toJSON()), /Command copied/);
assert.doesNotMatch(textOf(renderer.toJSON()), /[\p{Script=Han}]/u);
});