修复客户端更新诊断与错误展示
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
# Task: Fix Makelore updater diagnostics and release checks
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260813-makelore-updater-client-7d3a9c
|
||||
- Mode: Feature
|
||||
- Branch: main
|
||||
- Worktree: D:\Datas\OthersProjects\makelore
|
||||
- Base commit: a03043d048826609cb4f8cb7d782bde93e0e77f4
|
||||
- Owner: codex
|
||||
- Status: Ready for integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Correlate the packaged Windows updater failure with the Works Square stable
|
||||
feed and current platform/architecture resolution.
|
||||
- Normalize the known missing stable-feed failure into a concise actionable
|
||||
renderer-facing message while retaining raw diagnostic logging.
|
||||
- Add focused updater regression coverage without changing download, install,
|
||||
channel, or credential ownership behavior.
|
||||
- Remove the duplicate renderer presentation shown by the installed `v1.0.0`
|
||||
settings screenshot and keep raw stack diagnostics out of the ordinary user
|
||||
surface while retaining them in Main-process logs.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Electron Main remains the sole owner of update feed selection and
|
||||
electron-updater integration.
|
||||
- A missing stable updater artifact remains an error/release-readiness problem;
|
||||
it must not be mislabeled as "already latest".
|
||||
- Unexpected errors must retain their original messages, and no installer-role
|
||||
or cross-architecture fallback may be introduced.
|
||||
- Production recovery requires a separately published updater artifact; client
|
||||
messaging alone does not repair the update chain.
|
||||
- The currently installed `v1.0.0` is evidence of the old packaged behavior;
|
||||
source changes do not affect it until a new client is built and installed.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Confirmed the packaged Windows client resolves the supported stable target to
|
||||
`https://square.nianxx.cn/api/app-updates/windows/x64` and that its local log
|
||||
contains the reported `latest.yml` `HttpError: 404`. The corresponding public
|
||||
feed currently returns the intentional server-side not-available response.
|
||||
- Added a narrow renderer-facing normalization for missing Works Square stable
|
||||
manifests on both Windows (`latest.yml`) and macOS (`latest-mac.yml`). The
|
||||
concise Chinese message identifies an unpublished platform package and points
|
||||
to retry/website download without misreporting the client as up to date.
|
||||
- Raw errors remain logged, emitted, and rethrown; updater status remains
|
||||
`error`; prerelease OSS feeds and unrelated errors are unchanged.
|
||||
- Confirmed the screenshot's duplicated output came from one Zustand `error`
|
||||
value rendered both in the status row and a second error-details card. The
|
||||
settings page now renders one concise message, hides technical stacks and
|
||||
URLs behind the existing generic failure copy, and keeps the retry action.
|
||||
- De-duplicated the Main-process `error` event plus rejected-promise path by
|
||||
per-invocation check tokens. Concurrent callers sharing one electron-updater
|
||||
error now produce one renderer error status, while raw logging/rethrow and
|
||||
later independent retries remain intact.
|
||||
- Hardened the settings boundary so only concise Chinese user-facing prose is
|
||||
shown directly. Raw English diagnostics, stacks, URLs, paths, error codes and
|
||||
structured payloads fall back to the localized generic failure message.
|
||||
- Confirmed the installed `v1.0.0` `app.asar` predates these source changes.
|
||||
Built an isolated unsigned `v2.0.0` `win-unpacked` artifact under the user's
|
||||
temporary directory without replacing the installed application or the
|
||||
repository's existing `release/` output.
|
||||
- No release was published. Current evidence shows website installer `0.9.2`,
|
||||
installed client `1.0.0`, source version `2.0.0`, and unsigned local Windows
|
||||
artifacts, so production recovery still requires release-owner action.
|
||||
|
||||
## Verification
|
||||
|
||||
- Regression tests were observed red before each Windows/macOS normalization
|
||||
change and green afterward.
|
||||
- `pnpm exec vitest run tests/unit/app-updater.test.ts
|
||||
tests/unit/update-settings.test.tsx tests/unit/update-store.test.ts
|
||||
tests/unit/sidebar-update-button.test.tsx`: initially `30 passed` across four
|
||||
files; the final rerun after review fixes is recorded below.
|
||||
- `pnpm exec eslint electron/main/updater.ts
|
||||
src/components/settings/UpdateSettings.tsx
|
||||
tests/unit/app-updater.test.ts tests/unit/update-settings.test.tsx`: exit
|
||||
`0`, no findings.
|
||||
- `pnpm run typecheck`: exit `0`.
|
||||
- `pnpm run build:vite`: exit `0`; the production Main bundle contains the
|
||||
normalized Chinese message.
|
||||
- Isolated `electron-builder --dir` packaging: exit `0`; extracted `app.asar`
|
||||
reports version `2.0.0`, contains the Main normalization and renderer
|
||||
sanitizer, and no longer contains the `updates.errorDetails` render key.
|
||||
- `pnpm test`: updater-related tests pass, but the full suite remains red with
|
||||
three failures outside the changed updater scope: missing `.opencode/agent`
|
||||
in `youth-plain-language-skill.test.ts`, plus two
|
||||
`opencode-manager.test.ts` timing/generation failures. Focused reruns
|
||||
reproduced the missing-directory failure and one generation failure; no
|
||||
base-commit run was performed, so they are recorded without attributing their
|
||||
origin to this task.
|
||||
- Read-only Authenticode checks report `NotSigned` for the available local
|
||||
Windows artifacts, including the isolated artifact; none were installed or
|
||||
uploaded.
|
||||
- Initial independent read-only review: FAIL with two medium findings covering
|
||||
concurrent duplicate suppression and denylist-only renderer sanitization.
|
||||
Both findings were corrected with focused regression tests before final
|
||||
review.
|
||||
- Final focused rerun after those corrections: 4 files / 37 tests passed;
|
||||
typecheck, scoped ESLint, `build:vite`, `git diff --check`, and
|
||||
`check_doc_drift.py --task-id 20260813-makelore-updater-client-7d3a9c` all
|
||||
passed.
|
||||
- Final independent read-only review: PASS with no remaining implementation,
|
||||
architecture, or test-coverage finding.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Align the authoritative release version, generate final signed/notarized
|
||||
platform artifacts, publish them to Works Square as updater-role records, and
|
||||
execute discover/download/restart/install smoke tests from older packages.
|
||||
- Add a release job that publishes signed stable outputs to Works Square rather
|
||||
than only the legacy OSS channel, with inactive-first verification and an
|
||||
explicit activation policy.
|
||||
- Harden the stable Windows signing job by verifying Authenticode status and
|
||||
expected publisher after SignPath. Enabling publisher pinning in new clients
|
||||
requires a staged rollout after the stable feed always serves signed packages.
|
||||
- Repair the missing `.opencode/agent` fixture and independently diagnose the
|
||||
two existing opencode-manager concurrency failures before treating the full
|
||||
test suite as a release gate.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- After live proof, promote the stable-feed error behavior and release pipeline
|
||||
requirements to canonical updater/release documentation; keep the external
|
||||
release gate open until then.
|
||||
- Consider promoting the UI contract that ordinary updater errors are concise,
|
||||
non-duplicated, and free of raw stack/URL details; technical diagnostics stay
|
||||
in Main logs.
|
||||
@@ -14,6 +14,18 @@ import { setQuitting } from './app-state';
|
||||
/** Base CDN URL (without trailing channel path) */
|
||||
const OSS_BASE_URL = 'https://oss.intelli-spectrum.com';
|
||||
const WORKS_SQUARE_UPDATE_BASE_URL = 'https://square.nianxx.cn/api/app-updates';
|
||||
const STABLE_UPDATE_NOT_PUBLISHED_ERROR = '当前平台的正式更新包尚未发布,请稍后重试或从官网下载最新版';
|
||||
|
||||
export function normalizeUpdateError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const isMissingStableChannel = message.includes('Cannot find channel "latest.yml" update info')
|
||||
|| message.includes('Cannot find channel "latest-mac.yml" update info');
|
||||
const isMissingStableManifest = isMissingStableChannel
|
||||
&& message.includes('HttpError: 404')
|
||||
&& message.includes(WORKS_SQUARE_UPDATE_BASE_URL);
|
||||
|
||||
return isMissingStableManifest ? STABLE_UPDATE_NOT_PUBLISHED_ERROR : message;
|
||||
}
|
||||
|
||||
export interface UpdateStatus {
|
||||
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
|
||||
@@ -69,6 +81,8 @@ export class AppUpdater extends EventEmitter {
|
||||
private autoInstallTimer: NodeJS.Timeout | null = null;
|
||||
private autoInstallCountdown = 0;
|
||||
private readonly updateFeedError: string | null;
|
||||
private readonly activeUpdateChecks = new Set<symbol>();
|
||||
private readonly updaterEventErrorChecks = new Map<Error, Set<symbol>>();
|
||||
|
||||
/** Delay (in seconds) before auto-installing a downloaded update. */
|
||||
private static readonly AUTO_INSTALL_DELAY_SECONDS = 5;
|
||||
@@ -162,7 +176,10 @@ export class AppUpdater extends EventEmitter {
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (error: Error) => {
|
||||
this.updateStatus({ status: 'error', error: error.message });
|
||||
if (this.activeUpdateChecks.size > 0) {
|
||||
this.updaterEventErrorChecks.set(error, new Set(this.activeUpdateChecks));
|
||||
}
|
||||
this.updateStatus({ status: 'error', error: normalizeUpdateError(error) });
|
||||
this.emit('error', error);
|
||||
});
|
||||
}
|
||||
@@ -198,6 +215,8 @@ export class AppUpdater extends EventEmitter {
|
||||
* final status so the UI never gets stuck in 'checking'.
|
||||
*/
|
||||
async checkForUpdates(): Promise<UpdateInfo | null> {
|
||||
const checkToken = Symbol('update-check');
|
||||
this.activeUpdateChecks.add(checkToken);
|
||||
try {
|
||||
this.updateStatus({ status: 'checking' });
|
||||
|
||||
@@ -230,8 +249,22 @@ export class AppUpdater extends EventEmitter {
|
||||
return result.updateInfo || null;
|
||||
} catch (error) {
|
||||
logger.error('[Updater] Check for updates failed:', error);
|
||||
this.updateStatus({ status: 'error', error: (error as Error).message || String(error) });
|
||||
const reportedChecks = error instanceof Error
|
||||
? this.updaterEventErrorChecks.get(error)
|
||||
: undefined;
|
||||
const alreadyReported = reportedChecks?.has(checkToken) ?? false;
|
||||
if (!alreadyReported) {
|
||||
this.updateStatus({ status: 'error', error: normalizeUpdateError(error) });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
this.activeUpdateChecks.delete(checkToken);
|
||||
for (const [error, reportedChecks] of this.updaterEventErrorChecks) {
|
||||
reportedChecks.delete(checkToken);
|
||||
if (reportedChecks.size === 0) {
|
||||
this.updaterEventErrorChecks.delete(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,20 @@ function formatBytes(bytes: number): string {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatUpdateError(error: string | null, fallback: string): string {
|
||||
if (!error) return fallback;
|
||||
|
||||
const firstLine = error.replace(/\\r\\n|\\n|\r\n|\n/g, '\n').split('\n', 1)[0].trim();
|
||||
const isTechnical =
|
||||
/(?:^\w*Error\s*:|HttpError|Cannot find channel|node_modules|https?:\/\/|\bnet::ERR_[A-Z0-9_]+\b|\bE(?:ACCES|CONNREFUSED|CONNRESET|EXIST|HOSTUNREACH|ISDIR|NETUNREACH|NOENT|NOTDIR|PERM|PIPE|TIMEDOUT)\b|\b(?:ERR|UPDATER)_[A-Z0-9_]+\b|[A-Z]:\\|\\\\[^\\]|\bat\s+\S+\s*\()/i.test(
|
||||
firstLine,
|
||||
) || /^[{[]/.test(firstLine);
|
||||
const isUserFacingProse = /\p{Script=Han}/u.test(firstLine);
|
||||
|
||||
if (!firstLine || firstLine.length > 180 || isTechnical || !isUserFacingProse) return fallback;
|
||||
return firstLine;
|
||||
}
|
||||
|
||||
export function UpdateSettings() {
|
||||
const { t } = useTranslation('settings');
|
||||
const {
|
||||
@@ -75,7 +89,7 @@ export function UpdateSettings() {
|
||||
case 'downloaded':
|
||||
return t('updates.status.downloaded', { version: updateInfo?.version });
|
||||
case 'error':
|
||||
return error || t('updates.status.failed');
|
||||
return formatUpdateError(error, t('updates.status.failed'));
|
||||
case 'not-available':
|
||||
return t('updates.status.latest');
|
||||
default:
|
||||
@@ -160,7 +174,9 @@ export function UpdateSettings() {
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center justify-between py-3 border-t border-b">
|
||||
<p className="text-sm text-muted-foreground">{renderStatusText()}</p>
|
||||
<p className="min-w-0 flex-1 break-words pr-3 text-sm text-muted-foreground">
|
||||
{renderStatusText()}
|
||||
</p>
|
||||
{renderAction()}
|
||||
</div>
|
||||
|
||||
@@ -200,14 +216,6 @@ export function UpdateSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Details */}
|
||||
{status === 'error' && error && (
|
||||
<div className="rounded-lg bg-red-50 bg-red-500/10 p-4 text-red-600 text-red-700 text-sm">
|
||||
<p className="font-medium mb-1">{t('updates.errorDetails')}</p>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help Text */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('updates.help')}
|
||||
|
||||
@@ -5,6 +5,10 @@ const electronMocks = vi.hoisted(() => ({
|
||||
ipcHandle: vi.fn(),
|
||||
}));
|
||||
|
||||
const loggerMocks = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
const updaterMocks = vi.hoisted(() => {
|
||||
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
const autoUpdater = {
|
||||
@@ -41,7 +45,7 @@ vi.mock('@electron/utils/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
error: loggerMocks.error,
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -127,6 +131,97 @@ describe('AppUpdater feed delegation', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a concise actionable error when the stable feed has no latest manifest', async () => {
|
||||
const rawError = new Error(
|
||||
'Cannot find channel "latest.yml" update info: HttpError: 404\n' +
|
||||
'GET https://square.nianxx.cn/api/app-updates/windows/x64/latest.yml',
|
||||
);
|
||||
updaterMocks.autoUpdater.checkForUpdates.mockRejectedValue(rawError);
|
||||
const updater = new AppUpdater();
|
||||
const rendererSend = vi.fn();
|
||||
updater.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: rendererSend },
|
||||
} as unknown as Parameters<AppUpdater['setMainWindow']>[0]);
|
||||
|
||||
await expect(updater.checkForUpdates()).rejects.toBe(rawError);
|
||||
|
||||
expect(updater.getStatus()).toMatchObject({
|
||||
status: 'error',
|
||||
error: '当前平台的正式更新包尚未发布,请稍后重试或从官网下载最新版',
|
||||
});
|
||||
expect(rendererSend.mock.calls.filter(([channel, status]) => (
|
||||
channel === 'update:status-changed' && status?.status === 'error'
|
||||
))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports one error status when electron-updater emits and rejects the same check error', async () => {
|
||||
const rawError = new Error(
|
||||
'Cannot find channel "latest.yml" update info: HttpError: 404\n' +
|
||||
'GET https://square.nianxx.cn/api/app-updates/windows/x64/latest.yml',
|
||||
);
|
||||
const updater = new AppUpdater();
|
||||
const errorListener = vi.fn();
|
||||
const rendererSend = vi.fn();
|
||||
updater.on('error', errorListener);
|
||||
updater.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: rendererSend },
|
||||
} as unknown as Parameters<AppUpdater['setMainWindow']>[0]);
|
||||
let rejectSharedCheck!: (error: Error) => void;
|
||||
const sharedCheck = new Promise<never>((_resolve, reject) => {
|
||||
rejectSharedCheck = reject;
|
||||
});
|
||||
updaterMocks.autoUpdater.checkForUpdates.mockImplementationOnce(() => sharedCheck);
|
||||
updaterMocks.autoUpdater.checkForUpdates.mockImplementationOnce(() => sharedCheck);
|
||||
updaterMocks.autoUpdater.checkForUpdates.mockImplementationOnce(async () => {
|
||||
const updaterErrorListeners = updaterMocks.listeners.get('error') ?? [];
|
||||
updaterErrorListeners.at(-1)?.(rawError);
|
||||
throw rawError;
|
||||
});
|
||||
|
||||
const firstCheck = updater.checkForUpdates();
|
||||
const concurrentCheck = updater.checkForUpdates();
|
||||
const updaterErrorListeners = updaterMocks.listeners.get('error') ?? [];
|
||||
updaterErrorListeners.at(-1)?.(rawError);
|
||||
rejectSharedCheck(rawError);
|
||||
|
||||
await expect(Promise.allSettled([firstCheck, concurrentCheck])).resolves.toEqual([
|
||||
{ status: 'rejected', reason: rawError },
|
||||
{ status: 'rejected', reason: rawError },
|
||||
]);
|
||||
|
||||
expect(rendererSend.mock.calls.filter(([channel, status]) => (
|
||||
channel === 'update:status-changed' && status?.status === 'error'
|
||||
))).toHaveLength(1);
|
||||
expect(errorListener).toHaveBeenCalledOnce();
|
||||
expect(errorListener).toHaveBeenCalledWith(rawError);
|
||||
expect(loggerMocks.error).toHaveBeenCalledWith('[Updater] Check for updates failed:', rawError);
|
||||
|
||||
await expect(updater.checkForUpdates()).rejects.toBe(rawError);
|
||||
|
||||
expect(rendererSend.mock.calls.filter(([channel, status]) => (
|
||||
channel === 'update:status-changed' && status?.status === 'error'
|
||||
))).toHaveLength(2);
|
||||
expect(errorListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reports the same actionable error when the macOS stable feed has no latest manifest', async () => {
|
||||
const rawError = new Error(
|
||||
'Cannot find channel "latest-mac.yml" update info: HttpError: 404\n' +
|
||||
'GET https://square.nianxx.cn/api/app-updates/mac/arm64/latest-mac.yml',
|
||||
);
|
||||
updaterMocks.autoUpdater.checkForUpdates.mockRejectedValue(rawError);
|
||||
const updater = new AppUpdater();
|
||||
|
||||
await expect(updater.checkForUpdates()).rejects.toBe(rawError);
|
||||
|
||||
expect(updater.getStatus()).toMatchObject({
|
||||
status: 'error',
|
||||
error: '当前平台的正式更新包尚未发布,请稍后重试或从官网下载最新版',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps prerelease channels on their existing electron-updater feed', async () => {
|
||||
electronMocks.getVersion.mockReturnValue('0.9.2-beta.1');
|
||||
updaterMocks.autoUpdater.checkForUpdates.mockResolvedValue({
|
||||
|
||||
97
tests/unit/update-settings.test.tsx
Normal file
97
tests/unit/update-settings.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { UpdateSettings } from '@/components/settings/UpdateSettings';
|
||||
import { useUpdateStore } from '@/stores/update';
|
||||
|
||||
const actionableMessage =
|
||||
'\u5f53\u524d\u5e73\u53f0\u7684\u6b63\u5f0f\u66f4\u65b0\u5305\u5c1a\u672a\u53d1\u5e03\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002';
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
({
|
||||
'updates.currentVersion': 'Current version',
|
||||
'updates.status.failed': 'Generic update failure',
|
||||
'updates.action.retry': 'Retry',
|
||||
'updates.help': 'Keep Makelore updated',
|
||||
})[key] ?? key,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('UpdateSettings update errors', () => {
|
||||
const init = vi.fn().mockResolvedValue(undefined);
|
||||
const checkForUpdates = vi.fn().mockResolvedValue(undefined);
|
||||
const clearError = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useUpdateStore.setState({
|
||||
status: 'error',
|
||||
currentVersion: '1.0.0',
|
||||
updateInfo: null,
|
||||
progress: null,
|
||||
error: actionableMessage,
|
||||
isInitialized: true,
|
||||
isInitializing: false,
|
||||
autoInstallCountdown: null,
|
||||
init,
|
||||
checkForUpdates,
|
||||
clearError,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a concise updater error only once', () => {
|
||||
render(<UpdateSettings />);
|
||||
|
||||
expect(screen.getAllByText(actionableMessage)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'Cannot find channel "latest.yml" update info: HttpError: 404\r\nPlease double check authentication\r\n at ElectronHttpExecutor.handleResponse (app.asar/node_modules/builder-util-runtime/out/httpExecutor.js:121:20)',
|
||||
'Cannot find channel "latest.yml" update info: HttpError: 404\\r\\nPlease double check authentication\\n at ElectronHttpExecutor.handleResponse (app.asar/node_modules/builder-util-runtime/out/httpExecutor.js:121:20)',
|
||||
])('does not expose updater stack details for a long technical error', (error) => {
|
||||
useUpdateStore.setState({ error });
|
||||
|
||||
render(<UpdateSettings />);
|
||||
|
||||
expect(screen.getByText('Generic update failure')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/ElectronHttpExecutor|node_modules|Please double check/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('updates.errorDetails')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'net::ERR_NAME_NOT_RESOLVED',
|
||||
'Error: Update check failed',
|
||||
'Update check temporarily unavailable',
|
||||
"ENOENT: no such file or directory, open 'C:\\Users\\tester\\AppData\\Local\\Makelore\\latest.yml'",
|
||||
'ERR_UPDATER_INVALID_RELEASE_FEED',
|
||||
'{"code":"ERR_UPDATER_INVALID_RELEASE_FEED","message":"Update check failed"}',
|
||||
])('falls back for a short technical updater diagnostic: %s', (error) => {
|
||||
useUpdateStore.setState({ error });
|
||||
|
||||
render(<UpdateSettings />);
|
||||
|
||||
expect(screen.getByText('Generic update failure')).toBeInTheDocument();
|
||||
expect(screen.queryByText(error)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps concise actionable Chinese prose', () => {
|
||||
render(<UpdateSettings />);
|
||||
|
||||
expect(screen.getByText(actionableMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes on mount and retries through the existing store action', async () => {
|
||||
render(<UpdateSettings />);
|
||||
|
||||
await waitFor(() => expect(init).toHaveBeenCalledTimes(1));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
|
||||
await waitFor(() => expect(checkForUpdates).toHaveBeenCalledTimes(1));
|
||||
expect(clearError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user