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:
xmanrui 2026-08-28 06:37:40 +08:00
parent 5888641297
commit f3cefd8a84
23 changed files with 4058 additions and 271 deletions

669
test/update-ui.test.mjs Normal file
View 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);
});