diff --git a/README.md b/README.md index 904d16c..4c44be8 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,11 @@ Or try it without installing: `npx @nbtca/prompt`. unreachable. - **Settings**: language, icons, and colors. +The first load needs a network connection. Cached events and school calendars +remain available without an age limit; a failed refresh shows a cached-data notice. +Docs are available offline after reading them or loading them through search, +subject to the local cache's 4 MiB size limit. + ## Commands ```text @@ -48,6 +53,10 @@ nbtca update `nbtca --help` lists every command and flag. Add `--plain` for output without color. +Service checks wait up to 15 seconds by default. Use `--timeout=` to change +the limit. A timeout is shown separately from an offline result and appears as +`timedOut: true` in JSON. Failed public checks still return a nonzero exit code. + Prompt never stores your password. A saved campus session gets user-only file permissions. See [SECURITY.md](SECURITY.md). diff --git a/package-lock.json b/package-lock.json index 57ad85a..222f902 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@nbtca/prompt", - "version": "1.5.19", + "version": "1.5.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nbtca/prompt", - "version": "1.5.19", + "version": "1.5.20", "license": "MIT", "dependencies": { - "@nbtca/docs": "^0.3.4", + "@nbtca/docs": "^0.3.5", "@nbtca/nbtcal": "^0.4.4", "chalk": "^5.6.2", "marked": "^15.0.12" @@ -542,9 +542,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -623,9 +623,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -770,9 +770,9 @@ "license": "MIT" }, "node_modules/@nbtca/docs": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@nbtca/docs/-/docs-0.3.4.tgz", - "integrity": "sha512-wvxatkhj+D7/a0iI9O848boJUA43wfXel4tHxQ4A1qnjLsl07VdipI2WJS1MjWe9TNnG7IuHYb6Wzfcbeq57YQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@nbtca/docs/-/docs-0.3.5.tgz", + "integrity": "sha512-NL1Y95UbFIda54QXIJo8QdXQ0OWvZiqOv9ik7O6QAJr8kc7qer79PVcsNOujCeitJV18vonRjV1T7A5rYYBdUg==", "license": "MIT", "engines": { "node": ">=20.12.0" @@ -1628,9 +1628,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1918,9 +1918,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -2732,9 +2732,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/package.json b/package.json index 07082ce..636b2d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nbtca/prompt", - "version": "1.5.19", + "version": "1.5.20", "type": "module", "main": "dist/index.js", "exports": { @@ -46,7 +46,7 @@ "interactive" ], "dependencies": { - "@nbtca/docs": "^0.3.4", + "@nbtca/docs": "^0.3.5", "@nbtca/nbtcal": "^0.4.4", "chalk": "^5.6.2", "marked": "^15.0.12" diff --git a/scripts/test-cli.sh b/scripts/test-cli.sh index 57f1354..20911f6 100644 --- a/scripts/test-cli.sh +++ b/scripts/test-cli.sh @@ -171,7 +171,7 @@ node -e ' const statuses = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); if ( statuses.length !== 8 || - statuses.some((item) => item.ok || item.error !== "Request timed out") + statuses.some((item) => item.ok || !item.timedOut || item.error !== "Request timed out") ) process.exit(1); ' "$ignored_status_file" if [[ -s "$cli_test_root/status-ignored-signal.stderr" ]]; then diff --git a/src/app/chrome.test.ts b/src/app/chrome.test.ts index e58d912..83bc6f9 100644 --- a/src/app/chrome.test.ts +++ b/src/app/chrome.test.ts @@ -82,7 +82,7 @@ describe('renderHeader', () => { describe('offlineNotice', () => { it('leads with a warning icon and leaves a blank line before the content', () => { const lines = offlineNotice(80); - expect(lines.map(stripAnsi)).toEqual([' ! Offline, showing cached data', '']); + expect(lines.map(stripAnsi)).toEqual([' ! Refresh failed; showing cache', '']); }); it('keeps its indent on one line at forty columns in both languages', () => { diff --git a/src/app/views/schedule-render.test.ts b/src/app/views/schedule-render.test.ts index 46c8c6b..115505f 100644 --- a/src/app/views/schedule-render.test.ts +++ b/src/app/views/schedule-render.test.ts @@ -3,7 +3,7 @@ import chalk from 'chalk'; import { renderSchedule, hubShortcuts, type ScheduleViewState } from './schedule-render.js'; import { ListField } from '../fields/list-field.js'; import { TextField } from '../fields/text-field.js'; -import { setLanguage } from '../../i18n/index.js'; +import { setLanguage, t } from '../../i18n/index.js'; import { resetIconCache } from '../../core/icons.js'; import { campusDateTime } from '@nbtca/nbtcal/timetable'; import { stripAnsi, visualWidth } from '../../core/text.js'; @@ -930,6 +930,21 @@ describe('hubShortcuts', () => { }); describe('renderSchedule — public mode', () => { + it('marks a cached school calendar when its refresh fails', () => { + const out = stripAnsi( + renderSchedule( + { + mode: 'public', + publicStale: true, + publicWindow: { status: 'onBreak', breakTitle: '暑假' }, + }, + new Date(), + ).join('\n'), + ); + expect(out).toContain(t().common.offline); + expect(out).toContain('暑假'); + }); + it.each([ ['en', 'Academic calendar not available yet', 'Log in to export your timetable (.ics)'], ['zh', '校历数据暂不可用', '登录后可导出课表 (.ics)'], diff --git a/src/app/views/schedule-render.ts b/src/app/views/schedule-render.ts index ad70763..4665249 100644 --- a/src/app/views/schedule-render.ts +++ b/src/app/views/schedule-render.ts @@ -36,6 +36,7 @@ import { } from '../../core/text.js'; import { isoDayDifference } from '../../core/calendar-day.js'; import { loadingLines } from '../../core/components/spinner.js'; +import { offlineNotice } from '../chrome.js'; export type ScheduleMode = | 'loading' @@ -68,6 +69,7 @@ export interface ScheduleViewState { timetable?: Timetable; publicField?: ListField; publicWindow?: AcademicWindow | OnBreak | null; + publicStale?: boolean; gridCursor?: GridCursor; detailMeeting?: TimetableMeeting; detailFrom?: 'hub' | 'week'; @@ -447,7 +449,7 @@ function renderPublicBody( cols: number, ): string[] { const trans = t(); - const lines: string[] = []; + const lines: string[] = state.publicStale ? offlineNotice(cols) : []; const w = state.publicWindow; if (w === undefined) { diff --git a/src/app/views/schedule.test.ts b/src/app/views/schedule.test.ts index c462910..856b610 100644 --- a/src/app/views/schedule.test.ts +++ b/src/app/views/schedule.test.ts @@ -91,12 +91,15 @@ vi.mock('../../features/calendar.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - loadSchoolCalendar: vi.fn().mockResolvedValue({ - upcoming: calendarUpcoming, - past: vi.fn().mockReturnValue([]), - next: vi.fn().mockReturnValue([]), - inRange: calendarInRange, - heatmap: vi.fn().mockReturnValue([]), + loadSchoolCalendarOrCache: vi.fn().mockResolvedValue({ + calendar: { + upcoming: calendarUpcoming, + past: vi.fn().mockReturnValue([]), + next: vi.fn().mockReturnValue([]), + inRange: calendarInRange, + heatmap: vi.fn().mockReturnValue([]), + }, + stale: false, }), }; }); diff --git a/src/app/views/schedule.ts b/src/app/views/schedule.ts index 77992e3..9aefb9a 100644 --- a/src/app/views/schedule.ts +++ b/src/app/views/schedule.ts @@ -42,7 +42,7 @@ import { loadTimetableCache, clearScheduleCache, } from '../../features/schedule-store.js'; -import { loadSchoolCalendar } from '../../features/calendar.js'; +import { loadSchoolCalendarOrCache } from '../../features/calendar.js'; import { currentAcademicWindow, inferWeekOneMonday, @@ -175,12 +175,12 @@ async function goToPublic(ctx: AppContext, generation = lifecycleGeneration): Pr state = { mode: 'public', publicField: buildPublicField() }; ctx.rerender(); try { - const cal = await loadSchoolCalendar(ctx.signal); + const { calendar: cal, stale } = await loadSchoolCalendarOrCache(ctx.signal); if (!isLifecycleActive(ctx, generation)) return; const now = new Date(); const windowEvents = cal.inRange(addLocalDays(now, -400), addLocalDays(now, 400)); const publicWindow: AcademicWindow | OnBreak | null = currentAcademicWindow(windowEvents, now); - state = { ...state, publicWindow }; + state = { ...state, publicWindow, publicStale: stale }; } catch { if (!isLifecycleActive(ctx, generation)) return; state = { ...state, publicWindow: null }; @@ -190,7 +190,7 @@ async function goToPublic(ctx: AppContext, generation = lifecycleGeneration): Pr async function tryInferWeekOne(ctx: AppContext, generation: number): Promise { try { - const cal = await loadSchoolCalendar(ctx.signal); + const { calendar: cal } = await loadSchoolCalendarOrCache(ctx.signal); if (!isLifecycleActive(ctx, generation)) return null; const now = new Date(); const events = cal.inRange(addLocalDays(now, -400), addLocalDays(now, 400)); diff --git a/src/cli.ts b/src/cli.ts index fe1d816..cb11bad 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,7 @@ import { renderHeatmap } from './features/calendar-heatmap.js'; import { currentEvents, dayRange, monthRange, weekRange } from './features/calendar-query.js'; import { checkServices, + DEFAULT_STATUS_TIMEOUT_MS, countServiceHealth, hasServiceFailures, renderServiceStatusTable, @@ -360,7 +361,7 @@ async function runStatusCommand(flags: Set): Promise { : 10; const timeoutMs = timeoutFlag ? parseAsciiDecimalInteger(timeoutFlag.slice('--timeout='.length)) - : 6000; + : DEFAULT_STATUS_TIMEOUT_MS; const retries = retriesFlag ? parseAsciiDecimalInteger(retriesFlag.slice('--retries='.length)) : 1; @@ -441,7 +442,7 @@ async function runStatusCommand(flags: Set): Promise { console.log(chalk.bold(`${trans.status.watchUpdated}: ${new Date().toLocaleString()}`)); console.log( chalk.dim( - `${trans.status.up}: ${health.up} | ${trans.status.down}: ${health.down} | ${trans.status.watchHint}`, + `${trans.status.up}: ${health.up} | ${trans.status.down}: ${health.down} | ${trans.status.timeout}: ${health.timedOut} | ${trans.status.watchHint}`, ), ); console.log(); diff --git a/src/features/calendar-load.test.ts b/src/features/calendar-load.test.ts index d70a7e6..8d2ebdb 100644 --- a/src/features/calendar-load.test.ts +++ b/src/features/calendar-load.test.ts @@ -158,13 +158,15 @@ describe('loadCalendarOrThrow', () => { }); }); -describe('loadSchoolCalendar', () => { +describe('loadSchoolCalendarOrCache', () => { it('fetches the school feed into its own cache', async () => { - const { loadSchoolCalendar, loadCalendarOrThrow } = await freshCalendar(); + const { loadSchoolCalendarOrCache, loadCalendarOrThrow } = await freshCalendar(); loadFeedCache.mockReturnValue(null); const school = { status: 'modified', text: feed('school'), validators: {} }; fetchFeedConditional.mockResolvedValueOnce(school); - expect(uids(await loadSchoolCalendar())).toEqual(['school']); + const loaded = await loadSchoolCalendarOrCache(); + expect(uids(loaded.calendar)).toEqual(['school']); + expect(loaded.stale).toBe(false); expect(fetchFeedConditional.mock.calls[0]?.[0]).toBe('https://ical.nbtca.space/school.ics'); expect(saveFeedCache).toHaveBeenCalledWith('school-feed', school); fetchFeedConditional.mockResolvedValueOnce({ @@ -176,10 +178,12 @@ describe('loadSchoolCalendar', () => { }); it('falls back to the cached school feed when the network fails', async () => { - const { loadSchoolCalendar } = await freshCalendar(); + const { loadSchoolCalendarOrCache } = await freshCalendar(); fetchFeedConditional.mockRejectedValue(new Error('offline')); loadFeedCache.mockReturnValue(cached); - expect(uids(await loadSchoolCalendar())).toEqual(['cached']); + const loaded = await loadSchoolCalendarOrCache(); + expect(uids(loaded.calendar)).toEqual(['cached']); + expect(loaded.stale).toBe(true); expect(loadFeedCache).toHaveBeenCalledWith('school-feed'); }); }); diff --git a/src/features/calendar-store.test.ts b/src/features/calendar-store.test.ts index bf2ac56..64ebedd 100644 --- a/src/features/calendar-store.test.ts +++ b/src/features/calendar-store.test.ts @@ -55,13 +55,12 @@ describe('calendar-store', () => { }); }); - it('refuses a feed older than the max age', () => { + it('retains old feeds and validators for offline use and revalidation', () => { withDir((dir) => { - saveFeedCache('calendar-feed', { text: 'stale', validators: {} }, dir); - const longAgo = new Date(Date.now() - 60 * 60 * 1000); + saveFeedCache('calendar-feed', { text: 'stale', validators }, dir); + const longAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); utimesSync(join(dir, 'calendar-feed.ics'), longAgo, longAgo); - expect(loadFeedCache('calendar-feed', dir, 30 * 60 * 1000)).toBeNull(); - expect(loadFeedCache('calendar-feed', dir, 2 * 60 * 60 * 1000)?.text).toBe('stale'); + expect(loadFeedCache('calendar-feed', dir)).toEqual({ text: 'stale', validators }); }); }); @@ -71,7 +70,8 @@ describe('calendar-store', () => { const longAgo = new Date(Date.now() - 60 * 60 * 1000); utimesSync(join(dir, 'calendar-feed.ics'), longAgo, longAgo); touchFeedCache('calendar-feed', { etag: '"v2"' }, dir); - expect(loadFeedCache('calendar-feed', dir, 30 * 60 * 1000)).toEqual({ + expect(statSync(join(dir, 'calendar-feed.ics')).mtimeMs).toBeGreaterThan(longAgo.getTime()); + expect(loadFeedCache('calendar-feed', dir)).toEqual({ text: 'same', validators: { etag: '"v2"' }, }); diff --git a/src/features/calendar-store.ts b/src/features/calendar-store.ts index fa1a438..81908c8 100644 --- a/src/features/calendar-store.ts +++ b/src/features/calendar-store.ts @@ -3,7 +3,6 @@ import path from 'path'; import type { FeedValidators } from '@nbtca/nbtcal'; import { getStateDir, getWritableStateDir } from '../config/paths.js'; -const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; const HEADER_VALUE = /^[\x21-\x7e][\x20-\x7e]*$/; export interface FeedCache { @@ -54,12 +53,11 @@ export function touchFeedCache(name: string, validators: FeedValidators, dir?: s } } -export function loadFeedCache(name: string, dir?: string, maxAgeMs = MAX_AGE_MS): FeedCache | null { +export function loadFeedCache(name: string, dir?: string): FeedCache | null { try { const source = dir ?? getStateDir(); const validators = readValidators(name, source); const file = path.join(source, `${name}.ics`); - if (Date.now() - fs.statSync(file).mtimeMs > maxAgeMs) return null; return { text: fs.readFileSync(file, 'utf8'), validators }; } catch { return null; diff --git a/src/features/calendar.ts b/src/features/calendar.ts index be334e2..62006e0 100644 --- a/src/features/calendar.ts +++ b/src/features/calendar.ts @@ -127,7 +127,19 @@ function feedLoader(url: string | undefined, name: string) { return inFlight; } - return { peek, load }; + async function loadOrCache( + signal?: AbortSignal, + ): Promise<{ calendar: Calendar; stale: boolean }> { + try { + return { calendar: await load(signal), stale: false }; + } catch (err) { + const cached = peek(); + if (!cached) throw err; + return { calendar: cached, stale: true }; + } + } + + return { peek, load, loadOrCache }; } const eventsFeed = feedLoader(undefined, 'calendar-feed'); @@ -135,16 +147,8 @@ const schoolFeed = feedLoader(SCHOOL_FEED_URL, 'school-feed'); export const peekCalendar = eventsFeed.peek; export const loadCalendarOrThrow = eventsFeed.load; - -export async function loadSchoolCalendar(signal?: AbortSignal): Promise { - try { - return await schoolFeed.load(signal); - } catch (err) { - const cached = schoolFeed.peek(); - if (!cached) throw err; - return cached; - } -} +export const loadCalendarOrCache = eventsFeed.loadOrCache; +export const loadSchoolCalendarOrCache = schoolFeed.loadOrCache; export function toDisplayEvent(e: CalendarEvent): Event { const trans = t(); @@ -176,18 +180,6 @@ export function eventTimeRange(e: CalendarEvent): string { : `${start} ${dash} ${endDate} ${formatTime(e.end)}`; } -export async function loadCalendarOrCache( - signal?: AbortSignal, -): Promise<{ calendar: Calendar; stale: boolean }> { - try { - return { calendar: await loadCalendarOrThrow(signal), stale: false }; - } catch (err) { - const cached = peekCalendar(); - if (!cached) throw err; - return { calendar: cached, stale: true }; - } -} - export function yearHeatmap(calendar: Calendar, now: Date): HeatmapBucket[] { return calendar.heatmap({ ...pastYearRange(now), bucket: 'day' }); } diff --git a/src/features/status.test.ts b/src/features/status.test.ts index 3b4ef0c..50bf2c5 100644 --- a/src/features/status.test.ts +++ b/src/features/status.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import { checkServices, renderServiceStatusTable, type ServiceStatus } from './status.js'; +import { + checkServices, + countServiceHealth, + hasServiceFailures, + renderServiceStatusTable, + serializeServiceStatus, + type ServiceStatus, +} from './status.js'; import { setLanguage } from '../i18n/index.js'; import { resetIconCache } from '../core/icons.js'; import { stripAnsi } from '../core/text.js'; @@ -53,6 +60,65 @@ describe('renderServiceStatusTable pending state', () => { }); describe('checkServices', () => { + it('accepts an eight-second public response with the default timeout', async () => { + vi.useFakeTimers(); + try { + vi.stubGlobal( + 'fetch', + vi.fn( + () => + new Promise((resolve) => + setTimeout(() => { + resolve(new Response(null)); + }, 8_000), + ), + ), + ); + const request = checkServices({ retries: 0 }); + await vi.advanceTimersByTimeAsync(8_000); + const result = await request; + expect(result.filter((item) => !item.intranet).every((item) => item.ok)).toBe(true); + expect(result.filter((item) => item.intranet).every((item) => item.timedOut)).toBe(true); + expect(hasServiceFailures(result)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('distinguishes timeouts from HTTP failures in text and JSON', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input) => + (input instanceof Request ? input.url : input.toString()).includes('docs.nbtca.space') + ? new Promise(() => undefined) + : Promise.resolve(new Response(null, { status: 503 })), + ), + ); + const result = await checkServices({ timeoutMs: 5, retries: 0 }); + const timedOut = result.find((item) => item.url.includes('docs.nbtca.space')); + const failed = result.find((item) => item.url === 'https://nbtca.space'); + expect(timedOut).toMatchObject({ ok: false, timedOut: true }); + expect(failed).toMatchObject({ ok: false, statusCode: 503 }); + expect(failed?.timedOut).toBeUndefined(); + const rows = stripAnsi(renderServiceStatusTable(result, { color: false })).split('\n'); + expect(rows.find((row) => row.includes('Docs'))).toContain('timed out'); + expect(rows.find((row) => row.includes('Homepage'))).toContain('offline'); + expect(serializeServiceStatus(result).find((item) => item.name === 'Docs')?.timedOut).toBe( + true, + ); + expect(serializeServiceStatus(result).find((item) => item.name === 'Homepage')?.timedOut).toBe( + false, + ); + expect(hasServiceFailures(result)).toBe(true); + expect(countServiceHealth(result)).toEqual({ up: 0, down: 5, timedOut: 1 }); + }); + + it('does not classify an unrelated abort error as a timeout', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError'))); + const result = await checkServices({ timeoutMs: 100, retries: 0 }); + expect(result.every((item) => !item.timedOut && item.error === 'Aborted')).toBe(true); + }); + it('cancels response bodies after reading status headers', async () => { const cancelSpies: ReturnType[] = []; vi.stubGlobal( diff --git a/src/features/status.ts b/src/features/status.ts index 6f54a4a..494991e 100644 --- a/src/features/status.ts +++ b/src/features/status.ts @@ -7,6 +7,8 @@ import { t } from '../i18n/index.js'; type ServiceGroup = 'nbtca' | 'external' | 'intranet'; +export const DEFAULT_STATUS_TIMEOUT_MS = 15_000; + interface ServiceTarget { name: string; url: string; @@ -21,6 +23,7 @@ export interface ServiceStatus { statusCode?: number; latencyMs?: number; error?: string; + timedOut?: boolean; group?: ServiceGroup; intranet?: boolean; pending?: boolean; @@ -119,13 +122,14 @@ async function checkService( ): Promise> { const start = Date.now(); const controller = new AbortController(); + const timeoutReason = Symbol('timeout'); const onAbort = () => { controller.abort(signal?.reason); }; signal?.addEventListener('abort', onAbort, { once: true }); if (signal?.aborted) onAbort(); const timeout = setTimeout(() => { - controller.abort(); + controller.abort(timeoutReason); }, timeoutMs); let response: Response | undefined; try { @@ -145,14 +149,11 @@ async function checkService( } catch (err: unknown) { if (signal?.aborted) throw abortError(signal); const latencyMs = Date.now() - start; + const timedOut = controller.signal.reason === timeoutReason; const error = sanitizeTerminalLine( - err instanceof Error - ? err.name === 'AbortError' - ? 'Request timed out' - : err.message - : String(err), + timedOut ? 'Request timed out' : err instanceof Error ? err.message : String(err), ); - return { name, url, ok: false, latencyMs, error }; + return { name, url, ok: false, latencyMs, error, ...(timedOut ? { timedOut } : {}) }; } finally { clearTimeout(timeout); signal?.removeEventListener('abort', onAbort); @@ -187,7 +188,7 @@ async function checkServiceWithRetry( } export async function checkServices(options: StatusCheckOptions = {}): Promise { - const timeoutMs = options.timeoutMs ?? 6000; + const timeoutMs = options.timeoutMs ?? DEFAULT_STATUS_TIMEOUT_MS; const retries = options.retries ?? 1; if (options.signal?.aborted) throw abortError(options.signal); return Promise.all( @@ -207,6 +208,7 @@ export function serializeServiceStatus(items: ServiceStatus[]) { statusCode: item.statusCode ?? null, latencyMs: item.latencyMs ?? null, error: item.error ?? null, + timedOut: item.timedOut ?? false, group: item.group ?? null, intranet: item.intranet ?? false, })); @@ -216,14 +218,20 @@ export function hasServiceFailures(items: ServiceStatus[]): boolean { return items.some((item) => !item.ok && !item.intranet); } -export function countServiceHealth(items: ServiceStatus[]): { up: number; down: number } { +export function countServiceHealth(items: ServiceStatus[]): { + up: number; + down: number; + timedOut: number; +} { let up = 0; let down = 0; + let timedOut = 0; for (const item of items.filter((i) => !i.intranet)) { if (item.ok) up++; + else if (item.timedOut) timedOut++; else down++; } - return { up, down }; + return { up, down, timedOut }; } export function renderServiceStatusTable( @@ -235,6 +243,7 @@ export function renderServiceStatusTable( const applyDim = useColor ? chalk.dim : id; const applyGreen = useColor ? chalk.green : id; const applyRed = useColor ? chalk.red : id; + const applyYellow = useColor ? chalk.yellow : id; const applyCyan = useColor ? chalk.cyan : id; const applyLatency = useColor ? c.latency : (ms: number) => `${ms}ms`; @@ -243,7 +252,7 @@ export function renderServiceStatusTable( ...items.map((i) => visualWidth(i.name)), visualWidth(trans.status.service), ); - const statusWidth = 10; + const statusWidth = Math.max(10, visualWidth(trans.status.timeout) + 2); const onIcon = pickIcon('●', '+'); const offIcon = pickIcon('✕', '!'); @@ -285,6 +294,8 @@ export function renderServiceStatusTable( statusLabel = applyDim(pickIcon('…', '.')); } else if (item.ok) { statusLabel = applyGreen(`${onIcon} ${trans.status.up}`); + } else if (item.timedOut) { + statusLabel = (item.intranet ? applyDim : applyYellow)(`${offIcon} ${trans.status.timeout}`); } else if (item.intranet) { statusLabel = applyDim(`${lanIcon} ${trans.status.down}`); } else { diff --git a/src/i18n/index.ts b/src/i18n/index.ts index eef876c..e0b8180 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -201,6 +201,7 @@ export interface Translations { url: string; up: string; down: string; + timeout: string; groupNbtca: string; groupExternal: string; groupIntranet: string; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3d03f56..7af8c82 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -9,7 +9,7 @@ "current": "current", "moreAbove": "{count} more above", "moreBelow": "{count} more below", - "offline": "Offline, showing cached data" + "offline": "Refresh failed; showing cache" }, "help": { "title": "Keys", @@ -181,7 +181,7 @@ "status": { "checking": "Checking service status...", "summaryOk": "All services are healthy", - "summaryFail": "Some services are unavailable", + "summaryFail": "Some checks failed. A timeout does not confirm an outage.", "summaryIntranet": "Public services are healthy; intranet availability depends on your network", "service": "Service", "health": "Health", @@ -190,6 +190,7 @@ "url": "URL", "up": "online", "down": "offline", + "timeout": "timed out", "groupNbtca": "NBTCA Services", "groupExternal": "External", "groupIntranet": "Intranet (LAN only)", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 0100702..fabcb09 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -9,7 +9,7 @@ "current": "当前", "moreAbove": "上方还有 {count} 项", "moreBelow": "下方还有 {count} 项", - "offline": "当前离线,显示的是上次获取的内容" + "offline": "无法刷新,显示上次获取的内容" }, "help": { "title": "快捷键", @@ -181,7 +181,7 @@ "status": { "checking": "正在检查服务状态...", "summaryOk": "所有服务运行正常", - "summaryFail": "部分服务不可用", + "summaryFail": "部分检查未通过,超时不代表服务已经停止运行。", "summaryIntranet": "公共服务运行正常;内网可用性取决于当前网络", "service": "服务", "health": "状态", @@ -190,6 +190,7 @@ "url": "URL", "up": "在线", "down": "离线", + "timeout": "超时", "groupNbtca": "NBTCA 服务", "groupExternal": "外部平台", "groupIntranet": "内网服务(仅校园网)",