修复客户端更新诊断与错误展示
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) */
|
/** Base CDN URL (without trailing channel path) */
|
||||||
const OSS_BASE_URL = 'https://oss.intelli-spectrum.com';
|
const OSS_BASE_URL = 'https://oss.intelli-spectrum.com';
|
||||||
const WORKS_SQUARE_UPDATE_BASE_URL = 'https://square.nianxx.cn/api/app-updates';
|
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 {
|
export interface UpdateStatus {
|
||||||
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
|
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 autoInstallTimer: NodeJS.Timeout | null = null;
|
||||||
private autoInstallCountdown = 0;
|
private autoInstallCountdown = 0;
|
||||||
private readonly updateFeedError: string | null;
|
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. */
|
/** Delay (in seconds) before auto-installing a downloaded update. */
|
||||||
private static readonly AUTO_INSTALL_DELAY_SECONDS = 5;
|
private static readonly AUTO_INSTALL_DELAY_SECONDS = 5;
|
||||||
@@ -162,7 +176,10 @@ export class AppUpdater extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
autoUpdater.on('error', (error: Error) => {
|
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);
|
this.emit('error', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -198,6 +215,8 @@ export class AppUpdater extends EventEmitter {
|
|||||||
* final status so the UI never gets stuck in 'checking'.
|
* final status so the UI never gets stuck in 'checking'.
|
||||||
*/
|
*/
|
||||||
async checkForUpdates(): Promise<UpdateInfo | null> {
|
async checkForUpdates(): Promise<UpdateInfo | null> {
|
||||||
|
const checkToken = Symbol('update-check');
|
||||||
|
this.activeUpdateChecks.add(checkToken);
|
||||||
try {
|
try {
|
||||||
this.updateStatus({ status: 'checking' });
|
this.updateStatus({ status: 'checking' });
|
||||||
|
|
||||||
@@ -230,8 +249,22 @@ export class AppUpdater extends EventEmitter {
|
|||||||
return result.updateInfo || null;
|
return result.updateInfo || null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[Updater] Check for updates failed:', 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;
|
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];
|
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() {
|
export function UpdateSettings() {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const {
|
const {
|
||||||
@@ -75,7 +89,7 @@ export function UpdateSettings() {
|
|||||||
case 'downloaded':
|
case 'downloaded':
|
||||||
return t('updates.status.downloaded', { version: updateInfo?.version });
|
return t('updates.status.downloaded', { version: updateInfo?.version });
|
||||||
case 'error':
|
case 'error':
|
||||||
return error || t('updates.status.failed');
|
return formatUpdateError(error, t('updates.status.failed'));
|
||||||
case 'not-available':
|
case 'not-available':
|
||||||
return t('updates.status.latest');
|
return t('updates.status.latest');
|
||||||
default:
|
default:
|
||||||
@@ -160,7 +174,9 @@ export function UpdateSettings() {
|
|||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div className="flex items-center justify-between py-3 border-t border-b">
|
<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()}
|
{renderAction()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -200,14 +216,6 @@ export function UpdateSettings() {
|
|||||||
</div>
|
</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 */}
|
{/* Help Text */}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('updates.help')}
|
{t('updates.help')}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ const electronMocks = vi.hoisted(() => ({
|
|||||||
ipcHandle: vi.fn(),
|
ipcHandle: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const loggerMocks = vi.hoisted(() => ({
|
||||||
|
error: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const updaterMocks = vi.hoisted(() => {
|
const updaterMocks = vi.hoisted(() => {
|
||||||
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||||
const autoUpdater = {
|
const autoUpdater = {
|
||||||
@@ -41,7 +45,7 @@ vi.mock('@electron/utils/logger', () => ({
|
|||||||
logger: {
|
logger: {
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
error: vi.fn(),
|
error: loggerMocks.error,
|
||||||
debug: vi.fn(),
|
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 () => {
|
it('keeps prerelease channels on their existing electron-updater feed', async () => {
|
||||||
electronMocks.getVersion.mockReturnValue('0.9.2-beta.1');
|
electronMocks.getVersion.mockReturnValue('0.9.2-beta.1');
|
||||||
updaterMocks.autoUpdater.checkForUpdates.mockResolvedValue({
|
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