From 970d047b03d385a3939b858adef3ee83ae052783 Mon Sep 17 00:00:00 2001 From: zhangmin Date: Mon, 25 May 2026 15:26:17 +0800 Subject: [PATCH] Add Windows 7 compatibility branch logging --- .github/workflows/build-packages.yml | 4 +- README.md | 11 +- electron/logger.mjs | 89 ++++++++++++++ electron/main.mjs | 174 ++++++++++++++++++++++++--- electron/preload.cjs | 1 + package-lock.json | 94 ++++++--------- package.json | 2 +- src/App.jsx | 24 +++- src/domain/config.mjs | 1 + test/goldQuote.test.mjs | 5 + test/logger.test.mjs | 31 +++++ vite.config.mjs | 1 + 12 files changed, 364 insertions(+), 73 deletions(-) create mode 100644 electron/logger.mjs create mode 100644 src/domain/config.mjs create mode 100644 test/logger.test.mjs diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml index f14087f..54da379 100644 --- a/.github/workflows/build-packages.yml +++ b/.github/workflows/build-packages.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - win7-compat-logging workflow_dispatch: env: @@ -12,6 +13,7 @@ env: jobs: macos: name: macOS packages + if: github.ref_name == 'main' runs-on: macos-latest env: CSC_IDENTITY_AUTO_DISCOVERY: "false" @@ -84,7 +86,7 @@ jobs: - name: Upload Windows artifacts uses: actions/upload-artifact@v4 with: - name: gold-dashboard-windows + name: ${{ github.ref_name == 'main' && 'gold-dashboard-windows' || 'gold-dashboard-windows-win7' }} path: | release/*.exe release/*.blockmap diff --git a/README.md b/README.md index b124d07..3b8f4f5 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ 一个基于 Electron + React + Vite 的桌面实时金价面板,适配 macOS 和 Windows。 +当前分支是 Windows 7 兼容分支,使用 Electron 22。`main` 分支继续使用最新 Electron,用于 macOS 和现代 Windows 系统迭代。 + ## 功能 -- 默认每 10 秒刷新浙商银行积存金价格 +- 默认每 3 秒刷新浙商银行积存金价格 - 可在浙商银行 / 新浪两个 API 源之间切换 - 涨红色 `▲`,跌绿色 `▼`,持平灰色 `—` - 无系统标题栏,窗口始终置顶,正常窗口全区域可拖拽 @@ -43,3 +45,10 @@ npm run dist - 新浪行情:`https://hq.sinajs.cn/list=SGE_AU9999` 这些接口均由主进程请求,渲染进程只接收标准化后的行情数据。 + +## 本地日志 + +应用会把启动错误、窗口加载失败、接口请求失败和渲染进程异常写入本地日志,方便排查打不开的问题。 + +- Windows 打包版:`%APPDATA%\实时金价面板\gold-dashboard.log` +- macOS 打包版:`~/Library/Application Support/实时金价面板/gold-dashboard.log` diff --git a/electron/logger.mjs b/electron/logger.mjs new file mode 100644 index 0000000..2a58d92 --- /dev/null +++ b/electron/logger.mjs @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const LOG_FILE_NAME = 'gold-dashboard.log'; +const FALLBACK_APP_NAME = '实时金价面板'; + +export function createFileLogger({ getDirectory, fileName = LOG_FILE_NAME, now = () => new Date() } = {}) { + let cachedPath; + + function getLogFilePath() { + if (cachedPath) { + return cachedPath; + } + + const directory = resolveLogDirectory(getDirectory); + fs.mkdirSync(directory, { recursive: true }); + cachedPath = path.join(directory, fileName); + return cachedPath; + } + + function write(level, message, details) { + const suffix = details === undefined ? '' : ` ${formatDetails(details)}`; + const line = `[${now().toISOString()}] [${level}] ${message}${suffix}\n`; + + try { + fs.appendFileSync(getLogFilePath(), line, 'utf8'); + } catch (error) { + console.error('Failed to write gold dashboard log:', error); + console.error(line.trimEnd()); + } + } + + return { + get path() { + return getLogFilePath(); + }, + info: (message, details) => write('INFO', message, details), + warn: (message, details) => write('WARN', message, details), + error: (message, details) => write('ERROR', message, details), + }; +} + +export function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + code: error.code, + }; + } + + return error; +} + +function resolveLogDirectory(getDirectory) { + try { + const directory = getDirectory?.(); + if (directory) { + return directory; + } + } catch { + // Fall back below; logging must not block app startup. + } + + const home = os.homedir(); + + if (process.platform === 'win32') { + return path.join(process.env.APPDATA || process.env.LOCALAPPDATA || home || process.cwd(), FALLBACK_APP_NAME); + } + + if (process.platform === 'darwin') { + return path.join(home || process.cwd(), 'Library', 'Application Support', FALLBACK_APP_NAME); + } + + return path.join(process.env.XDG_CONFIG_HOME || path.join(home || process.cwd(), '.config'), FALLBACK_APP_NAME); +} + +function formatDetails(details) { + try { + return JSON.stringify(details, (_key, value) => serializeError(value)); + } catch (error) { + return JSON.stringify({ + details: String(details), + stringifyError: serializeError(error), + }); + } +} diff --git a/electron/main.mjs b/electron/main.mjs index 90ffee4..1b5bd31 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -1,11 +1,15 @@ import { app, BrowserWindow, ipcMain, screen } from 'electron'; +import http from 'node:http'; +import https from 'node:https'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createFileLogger, serializeError } from './logger.mjs'; import { formatBeijingTimestamp, parseSinaQuote, parseZheshangQuote } from '../src/domain/goldQuote.mjs'; import { DEFAULT_SETTINGS, normalizeSettings } from '../src/domain/settings.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const logger = createFileLogger({ getDirectory: () => app.getPath('userData') }); const WINDOW_SIZES = { normal: { width: 360, height: 168 }, @@ -40,7 +44,12 @@ let shaking = false; let settings = { ...DEFAULT_SETTINGS }; let dragState; +installErrorLogging(); + +logger.info('app starting', getRuntimeInfo()); + app.whenReady().then(() => { + logger.info('app ready', { logFile: logger.path }); createWindow(); app.on('activate', () => { @@ -48,6 +57,9 @@ app.whenReady().then(() => { createWindow(); } }); +}).catch((error) => { + logger.error('app failed during startup', serializeError(error)); + throw error; }); app.on('window-all-closed', () => { @@ -57,6 +69,7 @@ app.on('window-all-closed', () => { }); function createWindow() { + logger.info('creating main window', { mode: currentMode, size: WINDOW_SIZES.normal }); mainWindow = new BrowserWindow({ width: WINDOW_SIZES.normal.width, height: WINDOW_SIZES.normal.height, @@ -80,13 +93,25 @@ function createWindow() { mainWindow.setAlwaysOnTop(true, 'screen-saver'); mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); - mainWindow.once('ready-to-show', () => mainWindow.show()); + mainWindow.once('ready-to-show', () => { + logger.info('main window ready to show'); + mainWindow.show(); + }); + wireWindowLogging(mainWindow, 'main'); wireMainWindowInteractions(mainWindow); if (app.isPackaged) { - mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); + const filePath = path.join(__dirname, '../dist/index.html'); + logger.info('loading main window file', { filePath }); + mainWindow.loadFile(filePath).catch((error) => { + logger.error('main window loadFile failed', serializeError(error)); + }); } else { - mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL || 'http://127.0.0.1:5173'); + const url = process.env.VITE_DEV_SERVER_URL || 'http://127.0.0.1:5173'; + logger.info('loading main window url', { url }); + mainWindow.loadURL(url).catch((error) => { + logger.error('main window loadURL failed', serializeError(error)); + }); } } @@ -115,17 +140,25 @@ function createSettingsWindow() { settingsWindow.setMenuBarVisibility(false); settingsWindow.once('ready-to-show', () => settingsWindow?.show()); + wireWindowLogging(settingsWindow, 'settings'); wireWindowShortcuts(settingsWindow); settingsWindow.on('closed', () => { settingsWindow = undefined; }); if (app.isPackaged) { - settingsWindow.loadFile(path.join(__dirname, '../dist/index.html'), { query: { window: 'settings' } }); + const filePath = path.join(__dirname, '../dist/index.html'); + logger.info('loading settings window file', { filePath }); + settingsWindow.loadFile(filePath, { query: { window: 'settings' } }).catch((error) => { + logger.error('settings window loadFile failed', serializeError(error)); + }); } else { const devUrl = new URL(process.env.VITE_DEV_SERVER_URL || 'http://127.0.0.1:5173'); devUrl.searchParams.set('window', 'settings'); - settingsWindow.loadURL(devUrl.toString()); + logger.info('loading settings window url', { url: devUrl.toString() }); + settingsWindow.loadURL(devUrl.toString()).catch((error) => { + logger.error('settings window loadURL failed', serializeError(error)); + }); } return settingsWindow; @@ -133,18 +166,20 @@ function createSettingsWindow() { ipcMain.handle('gold:fetch', async (_event, sourceId = 'zheshang') => { const source = SOURCES[sourceId] ?? SOURCES.zheshang; - const response = await fetch(source.url, { headers: source.headers }); - if (!response.ok) { - throw new Error(`${source.label}接口请求失败:${response.status}`); + try { + const buffer = await requestBuffer(source.url, source.headers); + const text = new TextDecoder(source.encoding).decode(buffer); + const quote = { + ...source.parse(text), + updatedAt: formatBeijingTimestamp(), + }; + logger.info('gold quote fetched', { source: source.label, price: quote.price, bytes: buffer.length }); + return quote; + } catch (error) { + logger.error('gold quote fetch failed', { source: source.label, error: serializeError(error) }); + throw error; } - - const buffer = await response.arrayBuffer(); - const text = new TextDecoder(source.encoding).decode(buffer); - return { - ...source.parse(text), - updatedAt: formatBeijingTimestamp(), - }; }); ipcMain.handle('window:set-mode', (_event, mode) => { @@ -160,6 +195,12 @@ ipcMain.handle('settings:get', () => settings); ipcMain.handle('settings:update', (_event, nextSettings) => { settings = normalizeSettings(nextSettings); + logger.info('settings updated', { + source: settings.source, + backgroundColor: settings.backgroundColor, + opacity: settings.opacity, + hasAlertPrice: Boolean(settings.alertPrice), + }); BrowserWindow.getAllWindows().forEach((window) => { window.webContents.send('settings:changed', settings); }); @@ -182,6 +223,13 @@ ipcMain.on('window:quit', () => { app.quit(); }); +ipcMain.on('app:renderer-error', (event, payload) => { + logger.error('renderer error', { + url: event.sender.getURL(), + payload, + }); +}); + ipcMain.on('window:drag-start', (_event, point) => { if (!mainWindow || currentMode !== 'compact') { return; @@ -227,6 +275,7 @@ function setWindowMode(mode) { } mainWindow.webContents.send('ui:window-mode-changed', currentMode); + logger.info('window mode changed', { mode: currentMode, bounds: mainWindow.getBounds() }); } function toggleCompactMode() { @@ -300,3 +349,98 @@ async function shakeWindow() { function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } + +function requestBuffer(url, headers = {}, redirectCount = 0) { + return new Promise((resolve, reject) => { + if (redirectCount > 5) { + reject(new Error(`接口重定向次数过多:${url}`)); + return; + } + + const parsedUrl = new URL(url); + const client = parsedUrl.protocol === 'https:' ? https : http; + const request = client.request(parsedUrl, { method: 'GET', headers, timeout: 10_000 }, (response) => { + const statusCode = response.statusCode ?? 0; + + if (statusCode >= 300 && statusCode < 400 && response.headers.location) { + response.resume(); + const nextUrl = new URL(response.headers.location, parsedUrl).toString(); + requestBuffer(nextUrl, headers, redirectCount + 1).then(resolve, reject); + return; + } + + if (statusCode < 200 || statusCode >= 300) { + response.resume(); + reject(new Error(`接口请求失败:${statusCode}`)); + return; + } + + const chunks = []; + response.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + response.on('end', () => resolve(Buffer.concat(chunks))); + }); + + request.on('timeout', () => { + request.destroy(new Error(`接口请求超时:${url}`)); + }); + request.on('error', reject); + request.end(); + }); +} + +function installErrorLogging() { + process.on('uncaughtException', (error) => { + logger.error('uncaught exception', serializeError(error)); + app.exit(1); + }); + + process.on('unhandledRejection', (reason) => { + logger.error('unhandled rejection', serializeError(reason)); + app.exit(1); + }); + + app.on('child-process-gone', (_event, details) => { + logger.error('child process gone', details); + }); + + app.on('render-process-gone', (_event, webContents, details) => { + logger.error('render process gone', { + url: webContents?.getURL?.(), + details, + }); + }); +} + +function wireWindowLogging(window, name) { + window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + logger.error(`${name} window failed to load`, { + errorCode, + errorDescription, + validatedURL, + isMainFrame, + }); + }); + + window.webContents.on('render-process-gone', (_event, details) => { + logger.error(`${name} window render process gone`, details); + }); + + window.webContents.on('console-message', (_event, level, message, line, sourceId) => { + if (level >= 1) { + logger.warn(`${name} window console`, { level, message, line, sourceId }); + } + }); +} + +function getRuntimeInfo() { + return { + appVersion: app.getVersion(), + platform: process.platform, + arch: process.arch, + osRelease: process.getSystemVersion?.(), + electron: process.versions.electron, + chrome: process.versions.chrome, + node: process.versions.node, + v8: process.versions.v8, + }; +} diff --git a/electron/preload.cjs b/electron/preload.cjs index ecaebcc..83eaa72 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -7,6 +7,7 @@ contextBridge.exposeInMainWorld('goldDashboard', { closeSettingsWindow: () => ipcRenderer.send('settings:close-window'), getSettings: () => ipcRenderer.invoke('settings:get'), updateSettings: (settings) => ipcRenderer.invoke('settings:update', settings), + logRendererError: (payload) => ipcRenderer.send('app:renderer-error', payload), closeWindow: () => ipcRenderer.send('window:close'), quitApp: () => ipcRenderer.send('window:quit'), startWindowDrag: (point) => ipcRenderer.send('window:drag-start', point), diff --git a/package-lock.json b/package-lock.json index b462fed..daad7e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@vitejs/plugin-react": "^5.0.0", - "electron": "^42.2.0", + "electron": "^22.3.27", "electron-builder": "^26.0.0", "typescript": "^5.8.0", "vite": "^7.0.0" @@ -424,61 +424,25 @@ } }, "node_modules/@electron/get": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", - "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", - "env-paths": "^3.0.0", - "graceful-fs": "^4.2.11", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", "progress": "^2.0.3", - "semver": "^7.6.3", + "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "engines": { - "node": ">=22.12.0" + "node": ">=12" }, "optionalDependencies": { - "undici": "^7.24.4" - } - }, - "node_modules/@electron/get/node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@electron/get/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@electron/get/node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=20.18.1" + "global-agent": "^3.0.0" } }, "node_modules/@electron/notarize": { @@ -3069,22 +3033,22 @@ } }, "node_modules/electron": { - "version": "42.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.2.0.tgz", - "integrity": "sha512-b2Tc7sIKiZEl0tBVwFM5GJ+FT5KYhmy9QJHjx8BGVZPVW2SctXWEvrE959ElB56qw7H05dBkhlikDA1DmpaAMw==", + "version": "22.3.27", + "resolved": "https://registry.npmjs.org/electron/-/electron-22.3.27.tgz", + "integrity": "sha512-7Rht21vHqj4ZFRnKuZdFqZFsvMBCmDqmjetiMqPtF+TmTBiGne1mnstVXOA/SRGhN2Qy5gY5bznJKpiqogjM8A==", "dev": true, + "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electron/get": "^5.0.0", - "@types/node": "^24.9.0", + "@electron/get": "^2.0.0", + "@types/node": "^16.11.26", "extract-zip": "^2.0.1" }, "bin": { - "electron": "cli.js", - "install-electron": "install.js" + "electron": "cli.js" }, "engines": { - "node": ">= 22.12.0" + "node": ">= 12.20.55" } }, "node_modules/electron-builder": { @@ -3264,6 +3228,13 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/electron/node_modules/@types/node": { + "version": "16.18.126", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", + "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3559,6 +3530,21 @@ "node": ">= 6" } }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", diff --git a/package.json b/package.json index d8c405a..60d1069 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ }, "devDependencies": { "@vitejs/plugin-react": "^5.0.0", - "electron": "^42.2.0", + "electron": "^22.3.27", "electron-builder": "^26.0.0", "typescript": "^5.8.0", "vite": "^7.0.0" diff --git a/src/App.jsx b/src/App.jsx index de1d729..c985878 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; +import { PRICE_REFRESH_INTERVAL_MS } from './domain/config.mjs'; import { formatQuote, shouldTriggerAlert } from './domain/goldQuote.mjs'; import { DEFAULT_SETTINGS, normalizeSettings } from './domain/settings.mjs'; import './styles.css'; @@ -20,6 +21,7 @@ const bridge = window.goldDashboard ?? { closeSettingsWindow: () => {}, getSettings: async () => DEFAULT_SETTINGS, updateSettings: async (settings) => normalizeSettings(settings), + logRendererError: () => {}, closeWindow: () => {}, quitApp: () => {}, startWindowDrag: () => {}, @@ -84,7 +86,7 @@ function DashboardWindow() { useEffect(() => { fetchQuote(); - const timer = window.setInterval(fetchQuote, 10_000); + const timer = window.setInterval(fetchQuote, PRICE_REFRESH_INTERVAL_MS); return () => window.clearInterval(timer); }, [fetchQuote]); @@ -342,4 +344,24 @@ function isFormField(target) { return ['INPUT', 'SELECT', 'TEXTAREA'].includes(target?.tagName); } +window.addEventListener('error', (event) => { + bridge.logRendererError({ + type: 'error', + message: event.message, + stack: event.error?.stack, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + }); +}); + +window.addEventListener('unhandledrejection', (event) => { + const reason = event.reason; + bridge.logRendererError({ + type: 'unhandledrejection', + message: reason instanceof Error ? reason.message : String(reason), + stack: reason instanceof Error ? reason.stack : undefined, + }); +}); + createRoot(document.getElementById('root')).render(); diff --git a/src/domain/config.mjs b/src/domain/config.mjs new file mode 100644 index 0000000..1621817 --- /dev/null +++ b/src/domain/config.mjs @@ -0,0 +1 @@ +export const PRICE_REFRESH_INTERVAL_MS = 3_000; diff --git a/test/goldQuote.test.mjs b/test/goldQuote.test.mjs index fd3a8a9..dfc1cd1 100644 --- a/test/goldQuote.test.mjs +++ b/test/goldQuote.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { PRICE_REFRESH_INTERVAL_MS } from '../src/domain/config.mjs'; import { formatBeijingTimestamp, formatQuote, @@ -9,6 +10,10 @@ import { shouldTriggerAlert, } from '../src/domain/goldQuote.mjs'; +test('refreshes gold price every 3 seconds', () => { + assert.equal(PRICE_REFRESH_INTERVAL_MS, 3_000); +}); + test('formats refresh time as 24-hour Beijing time', () => { const timestamp = formatBeijingTimestamp(new Date('2026-05-23T18:30:05Z')); diff --git a/test/logger.test.mjs b/test/logger.test.mjs new file mode 100644 index 0000000..2a3c425 --- /dev/null +++ b/test/logger.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { createFileLogger, serializeError } from '../electron/logger.mjs'; + +test('writes diagnostic logs to a local file', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'gold-dashboard-log-')); + const logger = createFileLogger({ + getDirectory: () => directory, + now: () => new Date('2026-05-25T10:00:00.000Z'), + }); + + logger.info('app starting', { electron: '22.3.27' }); + + const content = fs.readFileSync(path.join(directory, 'gold-dashboard.log'), 'utf8'); + assert.match(content, /\[2026-05-25T10:00:00.000Z\] \[INFO\] app starting/); + assert.match(content, /"electron":"22.3.27"/); + assert.equal(logger.path, path.join(directory, 'gold-dashboard.log')); +}); + +test('serializes errors for file logging', () => { + const error = new Error('startup failed'); + const serialized = serializeError(error); + + assert.equal(serialized.name, 'Error'); + assert.equal(serialized.message, 'startup failed'); + assert.match(serialized.stack, /startup failed/); +}); diff --git a/vite.config.mjs b/vite.config.mjs index 85845ce..a3a8ebd 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -10,6 +10,7 @@ export default defineConfig({ strictPort: true, }, build: { + target: 'chrome108', outDir: 'dist', emptyOutDir: true, },