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