From 6e3388159e1de3bd0aa4b9a9b4e87fa9a2ba630a Mon Sep 17 00:00:00 2001 From: haotool Date: Tue, 12 May 2026 23:17:26 +0800 Subject: [PATCH 01/20] =?UTF-8?q?fix(ratewise):=20=E4=BF=AE=E5=BE=A9=20lin?= =?UTF-8?q?t=20=E5=9F=BA=E7=B7=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正測試檔動態 import type annotation 警告 - 新增生產治理 spec 與 implementation plan - 恢復 RateWise lint 0 warning 品質閘門 測試:pnpm --filter @app/ratewise lint;pnpm --filter @app/ratewise typecheck --- .../__tests__/rateProviderRanking.test.ts | 3 +- .../__tests__/RateProviderMenu.test.tsx | 5 +- .../__tests__/SingleConverter.trend.test.tsx | 3 +- .../dev/002_development_reward_penalty_log.md | 7 +- ...26-05-12-ratewise-production-governance.md | 1123 +++++++++++++++++ ...2-ratewise-production-governance-design.md | 243 ++++ 6 files changed, 1379 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-12-ratewise-production-governance.md create mode 100644 docs/superpowers/specs/2026-05-12-ratewise-production-governance-design.md diff --git a/apps/ratewise/src/features/ratewise/__tests__/rateProviderRanking.test.ts b/apps/ratewise/src/features/ratewise/__tests__/rateProviderRanking.test.ts index 22e1cd11d..0b2033c53 100644 --- a/apps/ratewise/src/features/ratewise/__tests__/rateProviderRanking.test.ts +++ b/apps/ratewise/src/features/ratewise/__tests__/rateProviderRanking.test.ts @@ -4,6 +4,7 @@ import type { RateProviderRef, ResolvedRateProvider, } from '../rateProviderTypes'; +import type * as RateProvidersModule from '../../../config/rateProviders'; import { rankProviderQuotes, resolveProviderPreference, @@ -322,7 +323,7 @@ describe('resolveProviderPreference - 退化情境(registry default 缺失)' it('當 getDefaultProvider("bank") 回 null 時,硬退回 {bot, bank}', async () => { vi.resetModules(); vi.doMock('../../../config/rateProviders', async () => { - const actual = await vi.importActual( + const actual = await vi.importActual( '../../../config/rateProviders', ); return { diff --git a/apps/ratewise/src/features/ratewise/components/__tests__/RateProviderMenu.test.tsx b/apps/ratewise/src/features/ratewise/components/__tests__/RateProviderMenu.test.tsx index 1aee488d7..9f43f7a70 100644 --- a/apps/ratewise/src/features/ratewise/components/__tests__/RateProviderMenu.test.tsx +++ b/apps/ratewise/src/features/ratewise/components/__tests__/RateProviderMenu.test.tsx @@ -3,6 +3,7 @@ import '@testing-library/jest-dom/vitest'; import { render, screen, cleanup } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type * as RateProvidersModule from '../../../../config/rateProviders'; afterEach(() => { cleanup(); @@ -23,7 +24,7 @@ describe('RateProviderMenu — 單銀行預設狀態', () => { describe('RateProviderMenu — 多銀行 provider 模擬', () => { it('啟用後渲染推薦最佳 + 銀行清單 + 換錢所清單', async () => { vi.doMock('../../../../config/rateProviders', async () => { - const actual = await vi.importActual( + const actual = await vi.importActual( '../../../../config/rateProviders', ); const fakeBank2 = { @@ -61,7 +62,7 @@ describe('RateProviderMenu — 多銀行 provider 模擬', () => { it('manual 模式下,selectedRef 對應的 provider 應 aria-checked=true', async () => { vi.doMock('../../../../config/rateProviders', async () => { - const actual = await vi.importActual( + const actual = await vi.importActual( '../../../../config/rateProviders', ); const fakeBank2 = { diff --git a/apps/ratewise/src/features/ratewise/components/__tests__/SingleConverter.trend.test.tsx b/apps/ratewise/src/features/ratewise/components/__tests__/SingleConverter.trend.test.tsx index ae444be39..a4b5bbad9 100644 --- a/apps/ratewise/src/features/ratewise/components/__tests__/SingleConverter.trend.test.tsx +++ b/apps/ratewise/src/features/ratewise/components/__tests__/SingleConverter.trend.test.tsx @@ -6,6 +6,7 @@ import type { CurrencyCode } from '../../types'; import * as historyService from '../../../../services/exchangeRateHistoryService'; import type { RateSnapshot } from '../../../../services/exchangeRateHistoryService'; import * as moneyboxRateService from '../../../../services/moneyboxRateService'; +import type * as MoneyboxRateServiceModule from '../../../../services/moneyboxRateService'; import { TREND_CHART_DEFER_MS } from '../../../../config/performance'; // Mock services with controllable responses @@ -15,7 +16,7 @@ vi.mock('../../../../services/exchangeRateHistoryService', () => ({ })); vi.mock('../../../../services/moneyboxRateService', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index b918ae992..0d73661e7 100644 --- a/docs/dev/002_development_reward_penalty_log.md +++ b/docs/dev/002_development_reward_penalty_log.md @@ -2,7 +2,7 @@ > 版本:outline-v2-ultra > 原則:每筆只保留日期、ID、原因、解法。 -> 本次分數變化:+1(reward 1)|累計總分:前次總分 +39 +> 本次分數變化:+1(reward 1)|累計總分:前次總分 +40 ## 新增模板(4 行) @@ -13,6 +13,11 @@ ## 條目(新→舊) +- 日期:2026-05-12 +- ID:reward-ratewise-lint-baseline +- 原因:RateWise lint gate 因 test type import warning 無法通過 +- 解法:修正測試檔 type-only import 寫法並恢復 0 warning baseline + - 日期:2026-05-11 - ID:multi-converter-exchange-shop-text-switch - 原因:MultiConverter 頁面沒有 UI 讓使用者切換銀行/換錢所匯率來源,只有 SingleConverter 有 RateSelector。 diff --git a/docs/superpowers/plans/2026-05-12-ratewise-production-governance.md b/docs/superpowers/plans/2026-05-12-ratewise-production-governance.md new file mode 100644 index 000000000..7d4f9e391 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-ratewise-production-governance.md @@ -0,0 +1,1123 @@ +# RateWise Production Governance Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring RateWise back to a production-grade baseline by fixing delivery gates, removing internal production surface area, restoring error observability, tightening QA gates, clarifying build artifacts, and reducing route/SEO drift risk. + +**Architecture:** Execute in phases with minimal behavioral blast radius. Start with hard quality gates and public route surface, then improve error classification, QA coverage, artifact policy, and finally route registry convergence. Each phase should be independently reviewable and shippable. + +**Tech Stack:** React 19, TypeScript, Vite 8, vite-react-ssg, Vitest, Playwright, pnpm workspace, existing RateWise SEO/PWA scripts. + +--- + +## File Map + +- Modify: `apps/ratewise/src/features/ratewise/__tests__/rateProviderRanking.test.ts` + - Fix ESLint `consistent-type-imports` warning. +- Modify: `apps/ratewise/src/features/ratewise/components/__tests__/RateProviderMenu.test.tsx` + - Fix ESLint `consistent-type-imports` warnings in dynamic import mock typing. +- Modify: `apps/ratewise/src/features/ratewise/components/__tests__/SingleConverter.trend.test.tsx` + - Fix ESLint `consistent-type-imports` warning around `importOriginal` typing. +- Modify: `apps/ratewise/src/routes.tsx` + - Gate internal-only routes out of production route table. +- Modify: `apps/ratewise/src/config/seo-paths.ts` + - Split app-only public noindex routes from internal-only routes, and remove internal-only routes from production prerender paths. +- Modify or create: `apps/ratewise/src/config/__tests__/route-surface.test.ts` + - Assert internal-only routes are not in production public/prerender surface. +- Modify: `apps/ratewise/src/main.tsx` + - Replace broad `Failed to fetch` suppression with precise error classification. +- Modify: `apps/ratewise/src/suppress-hydration-warning.ts` + - Restrict or remove production global console monkey patch. +- Create: `apps/ratewise/src/utils/errorClassification.ts` + - Centralize chunk/history/generic fetch classification helpers. +- Create: `apps/ratewise/src/utils/__tests__/errorClassification.test.ts` + - Cover expected and non-expected rejection classification. +- Modify: `apps/ratewise/tests/e2e/accessibility.spec.ts` + - Convert critical skipped/fixme accessibility checks into scoped assertions. +- Modify: `apps/ratewise/tests/e2e/offline-pwa.spec.ts` + - Restore at least one reliable offline indicator scenario or explicitly move it into scheduled gate. +- Modify: `apps/ratewise/tests/e2e/trend-chart-latency.spec.ts` + - Convert skipped trend latency test into a runnable scheduled/performance test or document gate ownership in config. +- Modify: `apps/ratewise/tests/e2e/cloudflare-cache.spec.ts` + - Keep production guard but make it workflow-friendly. +- Modify or create: `.github/workflows/ratewise-production-governance.yml` + - Scheduled/release gate for live production checks if no existing workflow owns this. +- Modify: `apps/ratewise/package.json` + - Add explicit artifact/data refresh scripts only if needed. +- Modify: `apps/ratewise/README.md` + - Align quality and artifact policy with actual gates. +- Modify: `AGENTS.md`, `CLAUDE.md` + - Sync route, QA, and generated artifact policies if implementation changes those rules. +- Modify: `docs/dev/002_development_reward_penalty_log.md` + - Required before any commit, per repo SOP. +- Create later: `apps/ratewise/src/config/currencyLandingRouteRegistry.ts` + - Registry for currency landing route metadata. +- Create later: `apps/ratewise/src/config/__tests__/currencyLandingRouteRegistry.test.ts` + - Consistency tests before route generation refactor. + +## Task 0: Baseline Cleanup + +**Files:** + +- Modify: `apps/ratewise/src/features/ratewise/__tests__/rateProviderRanking.test.ts` +- Modify: `apps/ratewise/src/features/ratewise/components/__tests__/RateProviderMenu.test.tsx` +- Modify: `apps/ratewise/src/features/ratewise/components/__tests__/SingleConverter.trend.test.tsx` +- Modify: `docs/dev/002_development_reward_penalty_log.md` before commit + +- [ ] **Step 1: Reproduce the failing gate** + +Run: + +```bash +pnpm --filter @app/ratewise lint +``` + +Expected: FAIL with 4 `@typescript-eslint/consistent-type-imports` warnings and exit status 1. + +- [ ] **Step 2: Fix dynamic import type annotations in tests** + +Replace inline `typeof import('...')` type annotations with top-level type-only aliases. + +For `RateProviderMenu.test.tsx`, add near imports: + +```ts +import type * as RateProvidersModule from '../../../../config/rateProviders'; +``` + +Then change mocked import blocks from: + +```ts +const actual = await vi.importActual( + '../../../../config/rateProviders', +); +``` + +to: + +```ts +const actual = await vi.importActual( + '../../../../config/rateProviders', +); +``` + +For `SingleConverter.trend.test.tsx`, add near imports: + +```ts +import type * as MoneyboxRateServiceModule from '../../../../services/moneyboxRateService'; +``` + +Then change: + +```ts +const actual = await importOriginal(); +``` + +to: + +```ts +const actual = await importOriginal(); +``` + +For `rateProviderRanking.test.ts`, locate the reported line and apply the same pattern: convert inline `typeof import('...')` to a top-level `import type * as ...Module from '...'`. + +- [ ] **Step 3: Verify lint is clean** + +Run: + +```bash +pnpm --filter @app/ratewise lint +``` + +Expected: PASS with 0 warnings. + +- [ ] **Step 4: Verify typecheck still passes** + +Run: + +```bash +pnpm --filter @app/ratewise typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Update 002 log for baseline cleanup** + +Append one neutral or reward entry using the current four-line format in `docs/dev/002_development_reward_penalty_log.md`. + +Entry content: + +```markdown +- 日期:2026-05-12 +- ID:reward-ratewise-lint-baseline +- 原因:RateWise lint gate 因 test type import warning 無法通過 +- 解法:修正測試檔 type-only import 寫法並恢復 0 warning baseline +``` + +Also update the file's score summary according to its current SSOT formula. + +- [ ] **Step 6: Commit baseline cleanup** + +Run: + +```bash +git add apps/ratewise/src/features/ratewise/__tests__/rateProviderRanking.test.ts \ + apps/ratewise/src/features/ratewise/components/__tests__/RateProviderMenu.test.tsx \ + apps/ratewise/src/features/ratewise/components/__tests__/SingleConverter.trend.test.tsx \ + docs/dev/002_development_reward_penalty_log.md +git commit -m "fix(ratewise): 修復 lint 基線 + +- 修正測試檔動態 import type annotation 警告 +- 恢復 RateWise lint 0 warning 品質閘門 + +測試:pnpm --filter @app/ratewise lint;pnpm --filter @app/ratewise typecheck" +``` + +Expected: commit succeeds and commitlint passes. + +## Task 1: Product Surface Governance + +**Files:** + +- Modify: `apps/ratewise/src/routes.tsx` +- Modify: `apps/ratewise/src/config/seo-paths.ts` +- Create: `apps/ratewise/src/config/__tests__/route-surface.test.ts` +- Modify: `docs/dev/002_development_reward_penalty_log.md` before commit + +- [ ] **Step 1: Write failing route surface tests** + +Create `apps/ratewise/src/config/__tests__/route-surface.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { APP_ONLY_NOINDEX_PATHS, DEV_ONLY_PATHS, PRERENDER_PATHS, APP_CONFIG } from '../seo-paths'; + +const internalOnlyRoutes = [ + '/theme-showcase/', + '/color-scheme/', + '/update-prompt-test/', + '/ui-showcase/', +] as const; + +describe('RateWise public route surface', () => { + it('keeps only real user app routes in public noindex app paths', () => { + expect(APP_ONLY_NOINDEX_PATHS).toEqual(['/multi/', '/favorites/', '/settings/']); + }); + + it('keeps internal-only routes out of production prerender paths', () => { + for (const route of internalOnlyRoutes) { + expect(PRERENDER_PATHS).not.toContain(route); + } + }); + + it('keeps internal-only routes out of production app shell paths', () => { + for (const route of internalOnlyRoutes) { + expect(APP_CONFIG.appShellPaths).not.toContain(route); + } + }); + + it('tracks internal-only routes separately for development tooling', () => { + expect(DEV_ONLY_PATHS).toEqual([...internalOnlyRoutes]); + }); +}); +``` + +- [ ] **Step 2: Run the new test and verify it fails** + +Run: + +```bash +pnpm --filter @app/ratewise exec vitest run src/config/__tests__/route-surface.test.ts +``` + +Expected: FAIL because internal-only routes are currently included in `PRERENDER_PATHS` / `APP_CONFIG.appShellPaths`. + +- [ ] **Step 3: Split public app paths and internal paths** + +In `apps/ratewise/src/config/seo-paths.ts`, replace the current `APP_ONLY_PATHS` block with: + +```ts +export const APP_ONLY_NOINDEX_PATHS = ['/multi/', '/favorites/', '/settings/'] as const; + +export const DEV_ONLY_PATHS = [ + '/theme-showcase/', + '/color-scheme/', + '/update-prompt-test/', + '/ui-showcase/', +] as const; + +export const APP_ONLY_PATHS = [...APP_ONLY_NOINDEX_PATHS] as const; + +export const APP_ONLY_PRERENDER_PATHS = [...APP_ONLY_NOINDEX_PATHS] as const; +``` + +Keep `DEV_ONLY_PATHS` exported so robots/dev tooling can still reference it. + +- [ ] **Step 4: Gate internal-only route records in `routes.tsx`** + +Add near route helpers: + +```ts +const shouldEnableInternalRoutes = + import.meta.env.DEV || import.meta.env.VITE_ENABLE_INTERNAL_ROUTES === 'true'; +``` + +Replace the internal route entries at the bottom with a spread: + +```tsx + ...(shouldEnableInternalRoutes + ? [ + createLazyRoute( + '/color-scheme', + () => import('./pages/ColorSchemeComparison'), + 'src/pages/ColorSchemeComparison.tsx', + ), + createLazyRoute( + '/update-prompt-test', + () => import('./pages/UpdatePromptTest'), + 'src/pages/UpdatePromptTest.tsx', + ), + createLazyRoute('/ui-showcase', () => import('./pages/UIShowcase'), 'src/pages/UIShowcase.tsx'), + ] + : []), +``` + +Gate `theme-showcase` child route the same way inside `children`: + +```tsx + ...(shouldEnableInternalRoutes + ? [ + { + path: 'theme-showcase', + lazy: async () => { + try { + const module = await import('./pages/ThemeShowcase'); + return { Component: module.default }; + } catch (error) { + return { Component: () => }; + } + }, + }, + ] + : []), +``` + +- [ ] **Step 5: Run route surface tests** + +Run: + +```bash +pnpm --filter @app/ratewise exec vitest run src/config/__tests__/route-surface.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Run SEO surface tests** + +Run: + +```bash +pnpm --filter @app/ratewise exec vitest run src/__tests__/seo-public-surface.test.ts src/prerender.test.ts +``` + +Expected: PASS. If a test expects internal routes to be prerendered, update that test to reflect this spec: production prerender excludes internal-only routes. + +- [ ] **Step 7: Build smoke** + +Run: + +```bash +pnpm --filter @app/ratewise build +``` + +Expected: PASS. After build, inspect `git status --short`; only deterministic generated changes expected by build policy may appear. + +- [ ] **Step 8: Update 002 log and commit** + +Append: + +```markdown +- 日期:2026-05-12 +- ID:reward-ratewise-public-surface-governance +- 原因:內部展示與測試頁仍存在於正式路由與 prerender surface +- 解法:將 internal-only routes 從 production route/prerender surface 移除並補測試 +``` + +Commit: + +```bash +git add apps/ratewise/src/routes.tsx \ + apps/ratewise/src/config/seo-paths.ts \ + apps/ratewise/src/config/__tests__/route-surface.test.ts \ + docs/dev/002_development_reward_penalty_log.md +git commit -m "fix(ratewise): 收斂正式公開路由表面 + +- 將內部展示與測試頁排除於 production route surface +- 補上 route surface 測試避免 prerender 與 app shell 漂移 + +測試:pnpm --filter @app/ratewise exec vitest run src/config/__tests__/route-surface.test.ts src/__tests__/seo-public-surface.test.ts src/prerender.test.ts;pnpm --filter @app/ratewise build" +``` + +## Task 2: Error Observability And Hydration Policy + +**Files:** + +- Create: `apps/ratewise/src/utils/errorClassification.ts` +- Create: `apps/ratewise/src/utils/__tests__/errorClassification.test.ts` +- Modify: `apps/ratewise/src/main.tsx` +- Modify: `apps/ratewise/src/suppress-hydration-warning.ts` +- Modify: `docs/dev/002_development_reward_penalty_log.md` before commit + +- [ ] **Step 1: Write error classification helper tests** + +Create `apps/ratewise/src/utils/__tests__/errorClassification.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { classifyUnhandledRejection, isHydrationSuppressionEnabled } from '../errorClassification'; + +describe('classifyUnhandledRejection', () => { + it('classifies chunk load errors before generic fetch errors', () => { + expect( + classifyUnhandledRejection(new TypeError('Failed to fetch dynamically imported module')), + ).toBe('chunk-load'); + }); + + it('classifies verified history endpoint 404 as expected history miss', () => { + expect( + classifyUnhandledRejection( + new Error( + 'GET https://cdn.jsdelivr.net/gh/haotool/app@data/public/rates/history/2026-05-12.json 404', + ), + ), + ).toBe('expected-history-miss'); + }); + + it('does not classify generic Failed to fetch as expected history miss', () => { + expect(classifyUnhandledRejection(new TypeError('Failed to fetch'))).toBe( + 'generic-fetch-failure', + ); + }); + + it('classifies unrelated errors as unknown', () => { + expect(classifyUnhandledRejection(new Error('Unexpected application state'))).toBe('unknown'); + }); +}); + +describe('isHydrationSuppressionEnabled', () => { + it('only enables suppression for explicit non-production diagnostics', () => { + expect(isHydrationSuppressionEnabled({ prod: true, flag: 'true' })).toBe(false); + expect(isHydrationSuppressionEnabled({ prod: false, flag: 'true' })).toBe(true); + expect(isHydrationSuppressionEnabled({ prod: false, flag: undefined })).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run the new tests and verify they fail** + +Run: + +```bash +pnpm --filter @app/ratewise exec vitest run src/utils/__tests__/errorClassification.test.ts +``` + +Expected: FAIL because `errorClassification.ts` does not exist. + +- [ ] **Step 3: Implement `errorClassification.ts`** + +Create `apps/ratewise/src/utils/errorClassification.ts`: + +```ts +import { isChunkLoadError } from './chunkLoadRecovery'; + +export type UnhandledRejectionKind = + | 'chunk-load' + | 'expected-history-miss' + | 'generic-fetch-failure' + | 'unknown'; + +function getErrorMessage(reason: unknown): string { + if (reason instanceof Error) { + return reason.message; + } + if (typeof reason === 'string') { + return reason; + } + if (reason && typeof reason === 'object' && 'message' in reason) { + const message = (reason as { message: unknown }).message; + return typeof message === 'string' ? message : JSON.stringify(reason); + } + return ''; +} + +function isVerifiedHistoryMiss(message: string): boolean { + return /\/rates\/history\/[^/\s]+\.json/i.test(message) && /\b404\b/.test(message); +} + +export function classifyUnhandledRejection(reason: unknown): UnhandledRejectionKind { + const error = reason instanceof Error ? reason : new Error(getErrorMessage(reason)); + const message = getErrorMessage(reason); + + if (isChunkLoadError(error)) { + return 'chunk-load'; + } + + if (isVerifiedHistoryMiss(message)) { + return 'expected-history-miss'; + } + + if (message.includes('Failed to fetch')) { + return 'generic-fetch-failure'; + } + + return 'unknown'; +} + +export function getUnhandledRejectionMessage(reason: unknown): string { + return getErrorMessage(reason); +} + +export function toError(reason: unknown): Error { + return reason instanceof Error + ? reason + : new Error(getErrorMessage(reason) || 'Unhandled rejection'); +} + +export function isHydrationSuppressionEnabled(input: { + prod: boolean; + flag: string | undefined; +}): boolean { + return !input.prod && input.flag === 'true'; +} +``` + +- [ ] **Step 4: Replace broad classification in `main.tsx`** + +Import helpers: + +```ts +import { + classifyUnhandledRejection, + getUnhandledRejectionMessage, + toError, +} from './utils/errorClassification'; +``` + +In the `unhandledrejection` listener, replace manual string classification with: + +```ts +const reason: unknown = event.reason; +const errorMessage = getUnhandledRejectionMessage(reason); +const errorObject = toError(reason); +const rejectionKind = classifyUnhandledRejection(reason); + +if (rejectionKind === 'chunk-load') { + logger.warn('Chunk load error captured by global handler', { reason: errorMessage }); + recordPwaDiagnostic('chunk-load-error', errorMessage, 'error'); + event.preventDefault(); + void recoverFromChunkLoadError(); + return; +} + +if (rejectionKind === 'expected-history-miss') { + logger.debug('Historical data fetch failed (expected)', { reason: errorMessage }); + event.preventDefault(); + return; +} + +if (rejectionKind === 'generic-fetch-failure') { + logger.warn('Generic fetch failure captured by global handler', { reason: errorMessage }); + recordPwaDiagnostic('generic-fetch-failure', errorMessage, 'warn'); + return; +} + +logger.error('Unhandled promise rejection', errorObject); +recordPwaDiagnostic('unhandled-rejection', errorMessage || errorObject.message, 'error'); +``` + +- [ ] **Step 5: Restrict hydration suppression** + +In `apps/ratewise/src/suppress-hydration-warning.ts`, import: + +```ts +import { isHydrationSuppressionEnabled } from './utils/errorClassification'; +``` + +Wrap the existing suppression body: + +```ts +if ( + typeof window !== 'undefined' && + isHydrationSuppressionEnabled({ + prod: import.meta.env.PROD, + flag: import.meta.env.VITE_SUPPRESS_HYDRATION_WARNINGS, + }) +) { + // existing suppression body +} +``` + +This keeps an explicit non-production diagnostic escape hatch but prevents production from hiding hydration errors. + +- [ ] **Step 6: Run focused tests** + +Run: + +```bash +pnpm --filter @app/ratewise exec vitest run src/utils/__tests__/errorClassification.test.ts src/utils/__tests__/chunkLoadRecovery.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Run broader app startup tests** + +Run: + +```bash +pnpm --filter @app/ratewise typecheck +pnpm --filter @app/ratewise exec vitest run src/__tests__/sw.test.ts src/bootstrap/pwa-recovery-bootstrap.test.ts +``` + +Expected: PASS. + +- [ ] **Step 8: Update 002 log and commit** + +Append: + +```markdown +- 日期:2026-05-12 +- ID:reward-ratewise-error-observability +- 原因:hydration 與 fetch 類錯誤被全域 suppression 遮蔽 +- 解法:集中錯誤分類並限制 production hydration suppression +``` + +Commit: + +```bash +git add apps/ratewise/src/utils/errorClassification.ts \ + apps/ratewise/src/utils/__tests__/errorClassification.test.ts \ + apps/ratewise/src/main.tsx \ + apps/ratewise/src/suppress-hydration-warning.ts \ + docs/dev/002_development_reward_penalty_log.md +git commit -m "fix(ratewise): 改善全域錯誤可觀測性 + +- 將 unhandled rejection 改為集中分類 +- 限制 production hydration suppression 避免遮蔽真錯誤 + +測試:pnpm --filter @app/ratewise exec vitest run src/utils/__tests__/errorClassification.test.ts src/utils/__tests__/chunkLoadRecovery.test.ts src/__tests__/sw.test.ts src/bootstrap/pwa-recovery-bootstrap.test.ts;pnpm --filter @app/ratewise typecheck" +``` + +## Task 3: QA Gate Governance + +**Files:** + +- Modify: `apps/ratewise/tests/e2e/accessibility.spec.ts` +- Modify: `apps/ratewise/tests/e2e/offline-pwa.spec.ts` +- Modify: `apps/ratewise/tests/e2e/trend-chart-latency.spec.ts` +- Modify: `apps/ratewise/tests/e2e/cloudflare-cache.spec.ts` +- Create or modify: `.github/workflows/ratewise-production-governance.yml` +- Modify: `docs/dev/002_development_reward_penalty_log.md` before commit + +- [ ] **Step 1: Inventory active skips** + +Run: + +```bash +rg -n "test\\.skip|test\\.fixme|describe\\.skip|describe\\.fixme|skipIf" apps/ratewise/tests/e2e apps/ratewise/src +``` + +Expected: list includes accessibility, offline PWA, trend latency, production Cloudflare checks, and generated-file conditional skips. + +- [ ] **Step 2: Convert accessibility checks from passive warnings to scoped assertions** + +In `apps/ratewise/tests/e2e/accessibility.spec.ts`, keep multi-currency scan either runnable or explicitly tagged for a dedicated project. Replace warning-only label check with assertion: + +```ts +expect + .soft(hasLabel, `input ${i} should have aria-label, aria-labelledby, or label[for]`) + .toBe(true); +``` + +Replace skipped button name test with: + +```ts +test('可見按鈕應該有 accessible name', async ({ rateWisePage: page }) => { + const buttons = page.locator('button:visible'); + const buttonCount = await buttons.count(); + + for (let i = 0; i < buttonCount; i += 1) { + const button = buttons.nth(i); + const name = await button.evaluate( + (element) => element.getAttribute('aria-label') || element.textContent || '', + ); + expect + .soft(name.trim().length, `button ${i} should expose an accessible name`) + .toBeGreaterThan(0); + } +}); +``` + +- [ ] **Step 3: Restore one offline indicator gate** + +In `apps/ratewise/tests/e2e/offline-pwa.spec.ts`, unskip the shortest indicator visibility test if it passes with current fixtures. If the existing PWA environment remains unstable, create a component-level Playwright route test instead and leave a comment pointing to the scheduled full PWA gate. + +Runnable minimum assertion: + +```ts +test('should show offline indicator when network disconnects', async ({ page }) => { + const offlinePage = new OfflinePWAPage(page); + await offlinePage.goto(); + await offlinePage.waitForPrecache(); + await offlinePage.goOffline(); + await expect(offlinePage.offlineIndicator).toBeVisible({ timeout: 10_000 }); + await offlinePage.goOnline(); +}); +``` + +- [ ] **Step 4: Move trend chart latency into an explicit performance gate** + +If the test is stable locally, remove `test.skip` from the trend latency test. If it is not stable in PR CI, keep it behind an env guard: + +```ts +const isPerformanceGate = process.env['RUN_RATEWISE_PERFORMANCE_TESTS'] === 'true'; + +test.skip( + !isPerformanceGate, + 'Set RUN_RATEWISE_PERFORMANCE_TESTS=true to run trend latency budget', +); +``` + +This is acceptable only if a workflow in this task runs it on schedule. + +- [ ] **Step 5: Add scheduled production governance workflow** + +Create `.github/workflows/ratewise-production-governance.yml` if no existing workflow owns these gates: + +```yaml +name: RateWise Production Governance + +on: + workflow_dispatch: + schedule: + - cron: '17 20 * * *' + +jobs: + production-governance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + with: + version: 9.10.0 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @app/ratewise exec playwright install --with-deps chromium + - run: RUN_PRODUCTION_TESTS=true pnpm --filter @app/ratewise exec playwright test tests/e2e/cloudflare-cache.spec.ts --project=chromium + - run: RUN_RATEWISE_PERFORMANCE_TESTS=true pnpm --filter @app/ratewise exec playwright test tests/e2e/trend-chart-latency.spec.ts --project=chromium +``` + +- [ ] **Step 6: Run local QA focused checks** + +Run: + +```bash +pnpm --filter @app/ratewise exec playwright test tests/e2e/accessibility.spec.ts --project=chromium +``` + +Expected: PASS. If browser dependencies are missing locally, install with: + +```bash +pnpm --filter @app/ratewise exec playwright install chromium +``` + +- [ ] **Step 7: Verify workflow YAML parses** + +Run: + +```bash +pnpm exec prettier --check .github/workflows/ratewise-production-governance.yml +``` + +Expected: PASS. + +- [ ] **Step 8: Update 002 log and commit** + +Append: + +```markdown +- 日期:2026-05-12 +- ID:reward-ratewise-qa-gate-governance +- 原因:使用者可感知功能存在未追蹤 skip/fixme 與手動 production checks +- 解法:恢復核心 accessibility/offline/performance gate 並加入 scheduled production governance +``` + +Commit: + +```bash +git add apps/ratewise/tests/e2e/accessibility.spec.ts \ + apps/ratewise/tests/e2e/offline-pwa.spec.ts \ + apps/ratewise/tests/e2e/trend-chart-latency.spec.ts \ + apps/ratewise/tests/e2e/cloudflare-cache.spec.ts \ + .github/workflows/ratewise-production-governance.yml \ + docs/dev/002_development_reward_penalty_log.md +git commit -m "test(ratewise): 補強生產級 QA 閘門 + +- 將核心無障礙與離線體驗納入可執行驗證 +- 加入 scheduled production governance workflow 覆蓋 live headers 與效能預算 + +測試:pnpm --filter @app/ratewise exec playwright test tests/e2e/accessibility.spec.ts --project=chromium;pnpm exec prettier --check .github/workflows/ratewise-production-governance.yml" +``` + +## Task 4: Build Reproducibility And Artifact Hygiene + +**Files:** + +- Modify: `apps/ratewise/package.json` +- Modify: `apps/ratewise/README.md` +- Modify: `AGENTS.md` +- Modify: `CLAUDE.md` +- Possibly remove from tracking: `apps/ratewise/lighthouse-report.json`, `apps/ratewise/tsconfig.node.tsbuildinfo`, `apps/ratewise/tsconfig.tsbuildinfo` +- Modify: `docs/dev/002_development_reward_penalty_log.md` before commit + +- [ ] **Step 1: Classify tracked artifacts** + +Run: + +```bash +git ls-files apps/ratewise | rg '(^|/)(lighthouse-report|tsconfig.*tsbuildinfo|dist|coverage|playwright-report|test-results)' +``` + +Expected: currently shows tracked historical artifacts that should be reviewed. + +- [ ] **Step 2: Confirm ignored local artifacts** + +Run: + +```bash +git status --ignored --short apps/ratewise | sed -n '1,160p' +``` + +Expected: local QA artifacts show as ignored, not tracked. + +- [ ] **Step 3: Add explicit scripts if missing** + +In `apps/ratewise/package.json`, add scripts only if they do not already exist: + +```json +{ + "scripts": { + "refresh:data": "node scripts/prebuild-fetch-rates.mjs && SEO_RATE_EXAMPLES_OPTIONAL=1 node scripts/update-seo-rate-examples.mjs && node scripts/fetch-rating-snapshot.mjs", + "generate:deterministic": "node ../../scripts/generate-sitemap-2026.mjs && node scripts/generate-robots-txt.mjs && node scripts/generate-manifest.mjs && node scripts/generate-offline-html.mjs && node scripts/generate-llms-txt.mjs && node scripts/generate-markdown-mirrors.mjs && node scripts/generate-api-json.mjs && node scripts/generate-pair-json.mjs && node scripts/generate-openapi.mjs", + "verify:artifacts": "node ../../scripts/verify-ssot-sync.mjs && node ../../scripts/verify-image-resources.mjs && node scripts/verify-seo-ssot.mjs" + } +} +``` + +Do not change `prebuild` behavior in the same step unless tests prove the new scripts are equivalent. + +- [ ] **Step 4: Document generated artifact policy** + +Update `apps/ratewise/README.md` with a short section: + +```markdown +## Generated Artifacts + +RateWise separates generated files into three buckets: + +- Deterministic generated artifacts: rebuilt from repo SSOT by `pnpm --filter @app/ratewise generate:deterministic`. +- Live data snapshots: refreshed by `pnpm --filter @app/ratewise refresh:data` and committed only when the task explicitly updates data snapshots. +- Local QA artifacts: `dist/`, `coverage/`, `playwright-report/`, `test-results/`, Lighthouse reports, and squirrel outputs are local verification outputs and must not be committed. +``` + +Mirror the same policy in `AGENTS.md` and `CLAUDE.md` if those files currently define build or artifact policy. + +- [ ] **Step 5: Remove historical local artifacts from tracking if approved** + +Only remove tracked artifacts that are not used by tests or docs. Use: + +```bash +git rm --cached apps/ratewise/lighthouse-report.json apps/ratewise/tsconfig.node.tsbuildinfo apps/ratewise/tsconfig.tsbuildinfo +``` + +Expected: files remain locally if ignored, but are removed from source tracking. + +- [ ] **Step 6: Verify scripts** + +Run: + +```bash +pnpm --filter @app/ratewise run verify:artifacts +pnpm --filter @app/ratewise typecheck +``` + +Expected: PASS. + +- [ ] **Step 7: Update 002 log and commit** + +Append: + +```markdown +- 日期:2026-05-12 +- ID:reward-ratewise-artifact-governance +- 原因:build/generated/local QA artifacts 責任混雜且歷史產物污染追蹤 +- 解法:文件化 artifact 分類並建立明確 refresh/generate/verify 指令 +``` + +Commit: + +```bash +git add apps/ratewise/package.json apps/ratewise/README.md AGENTS.md CLAUDE.md \ + docs/dev/002_development_reward_penalty_log.md +git add -u apps/ratewise/lighthouse-report.json apps/ratewise/tsconfig.node.tsbuildinfo apps/ratewise/tsconfig.tsbuildinfo +git commit -m "docs(ratewise): 建立生成產物治理規範 + +- 區分 deterministic artifacts、live data snapshots 與 local QA artifacts +- 補上明確 refresh/generate/verify 指令與文件同步 + +測試:pnpm --filter @app/ratewise run verify:artifacts;pnpm --filter @app/ratewise typecheck" +``` + +## Task 5: Currency Route Architecture Convergence + +**Files:** + +- Create: `apps/ratewise/src/config/currencyLandingRouteRegistry.ts` +- Create: `apps/ratewise/src/config/__tests__/currencyLandingRouteRegistry.test.ts` +- Modify later: `apps/ratewise/src/routes.tsx` +- Modify later: `apps/ratewise/src/config/seo-paths.ts` +- Modify: `docs/dev/002_development_reward_penalty_log.md` before commit + +- [ ] **Step 1: Write registry consistency tests** + +Create `apps/ratewise/src/config/__tests__/currencyLandingRouteRegistry.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { + CURRENCY_LANDING_ROUTES, + FORWARD_CURRENCY_LANDING_ROUTES, + REVERSE_CURRENCY_LANDING_ROUTES, +} from '../currencyLandingRouteRegistry'; + +describe('currencyLandingRouteRegistry', () => { + it('contains 17 forward and 17 reverse currency landing routes', () => { + expect(FORWARD_CURRENCY_LANDING_ROUTES).toHaveLength(17); + expect(REVERSE_CURRENCY_LANDING_ROUTES).toHaveLength(17); + expect(CURRENCY_LANDING_ROUTES).toHaveLength(34); + }); + + it('uses canonical slash-wrapped paths', () => { + for (const route of CURRENCY_LANDING_ROUTES) { + expect(route.path).toMatch(/^\/[a-z]{3}-[a-z]{3}\/$/); + expect(route.amountPathPattern).toBe(`${route.path}:amount/`); + } + }); + + it('does not duplicate canonical paths', () => { + const paths = CURRENCY_LANDING_ROUTES.map((route) => route.path); + expect(new Set(paths).size).toBe(paths.length); + }); +}); +``` + +- [ ] **Step 2: Run registry tests and verify they fail** + +Run: + +```bash +pnpm --filter @app/ratewise exec vitest run src/config/__tests__/currencyLandingRouteRegistry.test.ts +``` + +Expected: FAIL because registry does not exist. + +- [ ] **Step 3: Create registry with existing route metadata** + +Create `apps/ratewise/src/config/currencyLandingRouteRegistry.ts`: + +```ts +export type CurrencyLandingDirection = 'foreign-to-twd' | 'twd-to-foreign'; + +export interface CurrencyLandingRouteDefinition { + direction: CurrencyLandingDirection; + from: string; + to: string; + path: `/${string}-${string}/`; + amountPathPattern: `/${string}-${string}/:amount/`; + entry: string; +} + +const forwardCodes = [ + 'aud', + 'cad', + 'chf', + 'cny', + 'eur', + 'gbp', + 'hkd', + 'idr', + 'jpy', + 'krw', + 'myr', + 'nzd', + 'php', + 'sgd', + 'thb', + 'usd', + 'vnd', +] as const; + +function pageName(from: string, to: string): string { + return `${from.toUpperCase()}To${to.toUpperCase()}`; +} + +export const FORWARD_CURRENCY_LANDING_ROUTES = forwardCodes.map((code) => ({ + direction: 'foreign-to-twd', + from: code.toUpperCase(), + to: 'TWD', + path: `/${code}-twd/`, + amountPathPattern: `/${code}-twd/:amount/`, + entry: `src/pages/${pageName(code, 'twd')}.tsx`, +})) satisfies readonly CurrencyLandingRouteDefinition[]; + +export const REVERSE_CURRENCY_LANDING_ROUTES = forwardCodes.map((code) => ({ + direction: 'twd-to-foreign', + from: 'TWD', + to: code.toUpperCase(), + path: `/twd-${code}/`, + amountPathPattern: `/twd-${code}/:amount/`, + entry: `src/pages/${pageName('twd', code)}.tsx`, +})) satisfies readonly CurrencyLandingRouteDefinition[]; + +export const CURRENCY_LANDING_ROUTES = [ + ...FORWARD_CURRENCY_LANDING_ROUTES, + ...REVERSE_CURRENCY_LANDING_ROUTES, +] as const; +``` + +- [ ] **Step 4: Run registry tests** + +Run: + +```bash +pnpm --filter @app/ratewise exec vitest run src/config/__tests__/currencyLandingRouteRegistry.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Wire registry into tests before changing route generation** + +Add tests that compare registry paths to existing `CURRENCY_SEO_PATHS` and `REVERSE_CURRENCY_SEO_PATHS` in `seo-paths.ts`. This creates a safety net before replacing manual route lists. + +Test snippet: + +```ts +import { CURRENCY_SEO_PATHS, REVERSE_CURRENCY_SEO_PATHS } from '../seo-paths'; + +it('matches existing SEO currency paths', () => { + expect(FORWARD_CURRENCY_LANDING_ROUTES.map((route) => route.path)).toEqual([ + ...CURRENCY_SEO_PATHS, + ]); + expect(REVERSE_CURRENCY_LANDING_ROUTES.map((route) => route.path)).toEqual([ + ...REVERSE_CURRENCY_SEO_PATHS, + ]); +}); +``` + +- [ ] **Step 6: Defer route generation refactor to a separate PR** + +Do not replace all `routes.tsx` manual currency entries in the same commit. The first architecture commit only introduces the registry and parity tests. A later PR can change route generation with much lower risk. + +- [ ] **Step 7: Update 002 log and commit** + +Append: + +```markdown +- 日期:2026-05-12 +- ID:reward-ratewise-currency-route-registry +- 原因:幣別 landing routes 與 SEO paths 手寫展開造成長期漂移風險 +- 解法:建立 registry 與 parity tests,先保證既有 URL 不變 +``` + +Commit: + +```bash +git add apps/ratewise/src/config/currencyLandingRouteRegistry.ts \ + apps/ratewise/src/config/__tests__/currencyLandingRouteRegistry.test.ts \ + docs/dev/002_development_reward_penalty_log.md +git commit -m "refactor(ratewise): 建立幣別路由 registry 基線 + +- 新增幣別 landing route registry +- 補上 SEO path parity tests 保證 canonical URL 不變 + +測試:pnpm --filter @app/ratewise exec vitest run src/config/__tests__/currencyLandingRouteRegistry.test.ts" +``` + +## Final Verification + +- [ ] **Step 1: Run RateWise core gates** + +Run: + +```bash +pnpm --filter @app/ratewise lint +pnpm --filter @app/ratewise typecheck +pnpm --filter @app/ratewise test +pnpm --filter @app/ratewise build +``` + +Expected: all PASS. + +- [ ] **Step 2: Run changed E2E gates** + +Run: + +```bash +pnpm --filter @app/ratewise exec playwright test tests/e2e/accessibility.spec.ts --project=chromium +``` + +Expected: PASS. + +- [ ] **Step 3: Check root hygiene** + +Run: + +```bash +git status --short +git status --ignored --short | sed -n '1,160p' +``` + +Expected: no root-level QA screenshots or unexpected tracked generated drift. Ignored QA artifacts may exist under ignored paths. + +- [ ] **Step 4: Confirm documentation sync** + +Run: + +```bash +rg -n "internal route|generated artifact|QA gate|production governance|prebuild|refresh:data|generate:deterministic" README.md AGENTS.md CLAUDE.md apps/ratewise/README.md +``` + +Expected: route, QA, and generated artifact policies are documented consistently wherever changed. + +- [ ] **Step 5: Prepare PR summary** + +Use this PR summary structure: + +```markdown +## Summary + +- Restores RateWise delivery baseline by fixing lint warnings. +- Removes internal-only pages from production route/prerender surface. +- Improves global error classification and production observability. +- Adds or restores product-level QA gates for accessibility, offline, live headers, and performance. +- Documents generated artifact policy and introduces currency route registry parity tests. + +## Tests + +- pnpm --filter @app/ratewise lint +- pnpm --filter @app/ratewise typecheck +- pnpm --filter @app/ratewise test +- pnpm --filter @app/ratewise build +- pnpm --filter @app/ratewise exec playwright test tests/e2e/accessibility.spec.ts --project=chromium +``` diff --git a/docs/superpowers/specs/2026-05-12-ratewise-production-governance-design.md b/docs/superpowers/specs/2026-05-12-ratewise-production-governance-design.md new file mode 100644 index 000000000..76d78a570 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-ratewise-production-governance-design.md @@ -0,0 +1,243 @@ +# RateWise Production Governance Design + +> **Status (2026-05-12):** Draft for review. This spec defines the governance blueprint only. +> Implementation plans must be written after maintainer approval. + +## Goal + +把 RateWise 從「功能多、測試多,但仍有明顯產品治理缺口」收斂成可穩定交付的生產級前端產品。第一輪不追求大改版,而是先清掉會破壞交付信任的公開表面、錯誤遮蔽、QA gate、build drift 與架構漂移。 + +## Current Evidence + +- `pnpm --filter @app/ratewise typecheck` 通過。 +- `pnpm --filter @app/ratewise lint` 失敗,因 4 個 warning 被 `--max-warnings 0` 擋下。 +- 公開路由含內部展示與測試頁:`/theme-showcase`、`/color-scheme`、`/update-prompt-test`、`/ui-showcase`。 +- `APP_ONLY_PRERENDER_PATHS` 目前包含所有 app-only paths,導致內部展示/測試頁也進入 prerender set。 +- `suppress-hydration-warning.ts` 全域覆寫 `console.error` 並壓制 hydration 類錯誤。 +- `main.tsx` 對 `history`、`404`、`Failed to fetch` 的 unhandled rejection 判斷過寬。 +- 多個使用者可感知 E2E 被 `skip` / `fixme`,包含多幣別 accessibility、button accessible name、offline indicator 與 trend chart latency。 +- Cloudflare production headers/cache 測試預設 skip,僅在 `RUN_PRODUCTION_TESTS=true` 時執行。 +- build/prebuild 同時負責抓資料、產 SEO 檔、產 API metadata、產 OpenAPI、抓 rating snapshot;目前工作區已有 generated data drift。 +- 幣別 landing page 與 route mapping 大量手寫,長期有 SEO / canonical / schema 漂移風險。 + +## Design Principles + +- 先修交付信任,再修優雅性:lint、公開表面、錯誤可觀測性與 QA gate 優先於架構整理。 +- 生產產品不公開內部工具:展示頁、測試頁、設計比較頁不得直接出現在正式路由或 prerender set。 +- 不用「吞錯」換取乾淨 console:可預期錯誤要被分類、記錄與測試,不應全域遮蔽整類錯誤。 +- QA gate 必須覆蓋使用者可感知功能:離線、無障礙、趨勢圖載入、部署 headers/cache 都是產品行為,不是可永久跳過的測試。 +- build 必須可審核:生成檔要區分 deterministic artifact、live data snapshot 與本地暫存。 +- 架構收斂只服務明確風險:先資料化路由與幣別頁,避免一次性大重構。 + +## Workstream 1: Product Surface Governance + +### Problem + +內部頁面目前存在於 production route table。雖然部分路徑被 robots disallow,但使用者仍可直接訪問,且 prerender path 把內部頁也納入建置輸出。這讓公開產品看起來像仍在開發中的 demo site。 + +### Target Design + +把路由分成三類: + +- Public indexable routes:首頁、內容頁、幣別 landing pages、合法的金額 landing pages。 +- Public noindex app routes:`/multi/`、`/favorites/`、`/settings/`,這些是使用者功能頁,可訪問但不索引。 +- Internal-only routes:`/theme-showcase/`、`/color-scheme/`、`/update-prompt-test/`、`/ui-showcase/`,正式 build 不註冊、不 prerender、不出現在 known route set。 + +Internal-only routes 的保留方式: + +- 開發環境可用,方便設計與 PWA prompt 驗證。 +- production build 預設不可訪問,直接落到 404。 +- 若未來需要 staging preview,可用明確 env flag 開啟,例如 `VITE_ENABLE_INTERNAL_ROUTES=true`,但 production env 不設定。 + +### Acceptance Criteria + +- Production route table 不含 internal-only routes。 +- `PRERENDER_PATHS` 不含 internal-only routes。 +- robots/sitemap/known route tests 驗證 internal-only routes 不在公開 surface。 +- 開發模式仍可選擇性開啟內部工具,不影響設計工作。 + +## Workstream 2: Error Observability And Hydration Policy + +### Problem + +目前全域 suppression 讓 hydration mismatch、部分 unhandled rejection 與 fetch failure 變得不可見。這能讓 console 看起來乾淨,但會讓真正的 UI mismatch、資料 API 故障或 chunk 問題更難被發現。 + +### Target Design + +建立明確錯誤分類: + +- Hydration mismatch:只允許針對已知、可證明無害的位置用局部 `suppressHydrationWarning` 或修正 SSR/client data source。 +- Chunk load failure:維持現有 recovery,但要記錄 diagnostic event,且測試它不吞一般 fetch error。 +- Historical rate missing:只在錯誤來源可驗證為 history endpoint 且 HTTP status 符合預期時分類為 expected。 +- Generic fetch failure:不可被 `Failed to fetch` 字串直接吞掉,必須記錄為 warn/error。 + +`suppress-hydration-warning.ts` 的目標狀態: + +- 移除全域 `console.error` 覆寫,或縮到只在 test/dev diagnostic mode 啟用。 +- production 不阻止 hydration error 上報。 +- 對已知 mismatch 改由元件層處理,例如 footer 年份、build time、使用者 locale。 + +### Acceptance Criteria + +- 沒有 production-only 全域 `console.error` monkey patch。 +- `unhandledrejection` 測試覆蓋 chunk error、history 404、generic fetch failure 三類。 +- Generic `Failed to fetch` 不會被分類成 expected historical data error。 +- Sentry/diagnostics 能接到真正未處理錯誤,不被全域 suppression 攔截。 + +## Workstream 3: QA Gates And Release Confidence + +### Problem + +目前 unit 覆蓋很多,但數個產品級 E2E 被 skip/fixme。這表示 CI 綠燈不等於使用者關鍵體驗可靠。 + +### Target Design + +把 QA gate 分三層: + +- Fast local gate:`typecheck`、`lint`、核心 Vitest、受影響檔案 tests。 +- PR gate:RateWise build、核心 E2E smoke、accessibility smoke、PWA app shell smoke。 +- Scheduled / release gate:live production headers/cache、offline PWA full scenario、Lighthouse smoke、squirrel live audit。 + +Skip/fixme 治理規則: + +- 任何 `test.skip` / `test.fixme` 必須有 issue 或 plan task 對應。 +- 使用者可感知功能不得永久 skip;若 CI 不穩,改成隔離 project、穩定 fixture 或 scheduled gate。 +- Accessibility 測試不能只 `console.warn`,核心規則要有 assertion。 + +### Acceptance Criteria + +- `pnpm --filter @app/ratewise lint` 通過且 README 指標可信。 +- 多幣別 accessibility 不再是 untracked `fixme`。 +- Button accessible name 測試不是永久 skip;若規則過嚴,改成符合 WCAG 的 scoped assertion。 +- Offline indicator 至少有一條可跑的 E2E 或 component+browser integration gate。 +- Trend chart latency 有明確 performance budget test;若只適合 nightly,納入 scheduled gate。 +- Production headers/cache 測試有 release 或 scheduled workflow 執行,不只靠手動 env。 + +## Workstream 4: Build Reproducibility And Artifact Hygiene + +### Problem + +`prebuild` 責任過重,混合 deterministic generation、live data fetch、SEO mirror、OpenAPI 與 rating snapshot。這讓 build 結果容易跟網路狀態、時間與資料源綁在一起,也讓 generated drift 常態化。 + +### Target Design + +把產物分成三類: + +- Deterministic generated artifacts:manifest、offline HTML、llms text、markdown mirrors、OpenAPI、API metadata。這些應可由 repo SSOT 穩定重建。 +- Live data snapshots:build-time rates、SEO rate examples、rating snapshot。這些允許漂移,但必須有明確 refresh command 與 commit policy。 +- Local QA artifacts:dist、coverage、playwright-report、test-results、lighthouse reports、squirrel output。這些不應進入 source tracking。 + +Build command 分層: + +- `prebuild` 只做 production build 必要的 deterministic steps。 +- `refresh:data` 或既有 schedule scripts 負責 live data snapshots。 +- `verify:artifacts` 檢查 deterministic generated artifacts 是否同步。 +- Release flow 明確說明哪些 generated drift 應 commit、哪些應 restore。 + +### Acceptance Criteria + +- generated data drift 有明確來源與處理規則。 +- `prebuild` 不因可選外部 API 失敗造成不可預期 diff。 +- repo 不追蹤新的 local QA artifacts。 +- 已追蹤但不該追蹤的 historical artifacts 有清理 plan,例如 `lighthouse-report.json`、`tsconfig*.tsbuildinfo`。 +- README / AGENTS / CLAUDE 對 build 與 generated artifact policy 一致。 + +## Workstream 5: Architecture Convergence + +### Problem + +幣別 landing pages、route records、SEO paths 與 amount paths 目前有大量手寫展開。這在小規模時可接受,但現在有 34 個幣別方向頁與大量金額路由,長期容易造成新增幣別時漏改頁面、sitemap、prerender、canonical 或 JSON-LD。 + +### Target Design + +建立幣別頁 route registry 作為單一來源: + +- `currencyLandingRouteRegistry` 描述方向、from/to currency、page component import、entry path、canonical base path、indexable amounts。 +- `routes.tsx` 從 registry 生成 currency landing routes。 +- `seo-paths.ts` 從同一份 registry 或 shared config 產生 currency SEO paths 與 amount paths。 +- 單一 `CurrencyLandingPage` 承接大多數頁面內容;個別幣別只保留必要 copy override。 + +收斂順序: + +- 第一階段只新增 registry 與 tests,不一次刪完所有頁面檔。 +- 第二階段把重複 route/path 生成改成 registry-driven。 +- 第三階段再評估是否移除 34 個 thin wrapper page files。 + +### Acceptance Criteria + +- 新增幣別方向時只需改 registry 與必要文案,不需手動同步 3 到 5 個清單。 +- `routes.tsx` 不再手寫所有 `createLazyRoute('/xxx-twd')` 與 `:amount` 變體。 +- SEO path tests 保證 sitemap、prerender、known routes 與 route registry 一致。 +- 不改變既有 canonical URLs。 + +## Rollout Strategy + +Phase 0:Baseline cleanup。 + +- 修復現有 lint warnings。 +- 記錄並確認目前 generated drift 的處理方式。 +- 不改行為,只取得可交付 baseline。 + +Phase 1:公開表面收斂。 + +- 移除 production internal routes。 +- 調整 `APP_ONLY_PATHS` / `PRERENDER_PATHS`。 +- 補 route surface tests。 + +Phase 2:錯誤可觀測性。 + +- 收斂 hydration suppression。 +- 精準化 unhandled rejection 分類。 +- 補 diagnostics tests。 + +Phase 3:QA gate 補強。 + +- 處理 skip/fixme。 +- 將 live production tests 放入 scheduled 或 release gate。 +- 補 accessibility/offline/trend chart 可靠 gate。 + +Phase 4:build artifact 治理。 + +- 拆分 deterministic generation 與 live data refresh。 +- 清理 historical artifacts。 +- 同步 README / AGENTS / CLAUDE。 + +Phase 5:架構收斂。 + +- 建立 currency landing route registry。 +- route 與 SEO paths 逐步改為 registry-driven。 +- 視風險決定是否移除 thin wrapper page files。 + +## Non-Goals + +- 不在第一輪重新設計 UI。 +- 不在第一輪改品牌、網域或資料來源策略。 +- 不在第一輪重寫 PWA/service worker 架構。 +- 不在第一輪引入新 framework。 +- 不為了架構漂亮而改動所有幣別頁文案。 +- 不在未確認 release policy 前刪除任何使用者可見公開 SEO URL。 + +## Verification Matrix + +| Workstream | Required Verification | +| ------------------------ | ----------------------------------------------------------------------------------- | +| Product surface | route tests、sitemap/prerender tests、production build smoke | +| Error observability | Vitest for error classification、browser console smoke、diagnostic event assertions | +| QA gates | lint、typecheck、targeted Vitest、targeted Playwright、scheduled live tests | +| Build reproducibility | artifact sync tests、git status after build、generated drift policy check | +| Architecture convergence | registry consistency tests、canonical URL snapshot tests、route generation tests | + +## Documentation Updates + +以下變更需要同步文件: + +- 公開路由與 internal route policy:`README.md`、`AGENTS.md`、`CLAUDE.md`。 +- QA gate 或 CI workflow 變更:`AGENTS.md`、`CLAUDE.md`、相關 workflow 註解。 +- generated artifact policy:`README.md`、`AGENTS.md`、`CLAUDE.md`。 +- 長期架構決策:本 spec 與後續 implementation plan。 + +## Open Decision For Maintainer + +建議採用此順序:Phase 0 → Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5。 + +理由是 Phase 0/1/2 直接影響交付可信度與公開產品面,風險最低、收益最高;Phase 5 屬長期維護收益,應等品質 gate 穩定後再做。 From f8c814bf6a5dea00d6571cd5b7068962514b5d60 Mon Sep 17 00:00:00 2001 From: haotool Date: Tue, 12 May 2026 23:23:38 +0800 Subject: [PATCH 02/20] =?UTF-8?q?fix(ratewise):=20=E6=94=B6=E6=96=82?= =?UTF-8?q?=E6=AD=A3=E5=BC=8F=E5=85=AC=E9=96=8B=E8=B7=AF=E7=94=B1=E8=A1=A8?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 將內部展示與測試頁排除於 production route surface - 補 route surface 測試,避免 prerender 與 app shell 漂移 - 同步 TypeScript / Node SEO path SSOT,並新增 changeset 測試:route surface、seo public surface、prerender、typecheck、build --- .changeset/ratewise-production-surface.md | 5 ++ apps/ratewise/seo-paths.config.mjs | 20 ++---- .../config/__tests__/route-surface.test.ts | 31 +++++++++ apps/ratewise/src/config/seo-paths.ts | 17 ++--- apps/ratewise/src/routes.tsx | 67 +++++++++++-------- .../dev/002_development_reward_penalty_log.md | 7 +- 6 files changed, 95 insertions(+), 52 deletions(-) create mode 100644 .changeset/ratewise-production-surface.md create mode 100644 apps/ratewise/src/config/__tests__/route-surface.test.ts diff --git a/.changeset/ratewise-production-surface.md b/.changeset/ratewise-production-surface.md new file mode 100644 index 000000000..1460e3ba6 --- /dev/null +++ b/.changeset/ratewise-production-surface.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +正式版不再輸出內部展示與測試頁面的預渲染路由 diff --git a/apps/ratewise/seo-paths.config.mjs b/apps/ratewise/seo-paths.config.mjs index 08ab29840..81e29d592 100644 --- a/apps/ratewise/seo-paths.config.mjs +++ b/apps/ratewise/seo-paths.config.mjs @@ -168,29 +168,21 @@ export const INDEXABLE_CANONICAL_PATHS = [ export const SEO_PATHS = INDEXABLE_CANONICAL_PATHS; /** - * 需要回傳 app shell 的互動頁面(app-only) - * - * 排列順序:前 3 個為使用者功能頁(noindex 處理),後 4 個為開發展示頁(Disallow 處理) - * ─ 使用者功能頁:允許爬取,由 SEOHelmet noindex 排除索引(Google 官方建議) - * ─ 開發展示頁:直接 Disallow,無使用者價值,無需 noindex + * 需要回傳 app shell 的使用者功能頁(app-only) * * 注:/seo-tech/ 已移至 CONTENT_SEO_PATHS 成為可索引頁面(2026-04-07) */ -export const APP_ONLY_PATHS = [ - '/multi/', - '/favorites/', - '/settings/', +export const APP_ONLY_NOINDEX_PATHS = ['/multi/', '/favorites/', '/settings/']; + +/** 開發 / 展示頁:正式 build 不註冊、不預渲染;robots 仍明確 Disallow。 */ +export const DEV_ONLY_PATHS = [ '/theme-showcase/', '/color-scheme/', '/update-prompt-test/', '/ui-showcase/', ]; -/** 使用者功能頁子集(前 3):允許爬取 + noindex meta */ -export const APP_ONLY_NOINDEX_PATHS = APP_ONLY_PATHS.slice(0, 3); - -/** 開發 / 展示頁子集(後 4):Disallow 爬取 */ -export const DEV_ONLY_PATHS = APP_ONLY_PATHS.slice(3); +export const APP_ONLY_PATHS = [...APP_ONLY_NOINDEX_PATHS]; /** * 需要預渲染的 app-only 路由 diff --git a/apps/ratewise/src/config/__tests__/route-surface.test.ts b/apps/ratewise/src/config/__tests__/route-surface.test.ts new file mode 100644 index 000000000..e2af5d221 --- /dev/null +++ b/apps/ratewise/src/config/__tests__/route-surface.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { APP_CONFIG, APP_ONLY_NOINDEX_PATHS, DEV_ONLY_PATHS, PRERENDER_PATHS } from '../seo-paths'; + +const internalOnlyRoutes = [ + '/theme-showcase/', + '/color-scheme/', + '/update-prompt-test/', + '/ui-showcase/', +] as const; + +describe('RateWise public route surface', () => { + it('keeps only real user app routes in public noindex app paths', () => { + expect(APP_ONLY_NOINDEX_PATHS).toEqual(['/multi/', '/favorites/', '/settings/']); + }); + + it('keeps internal-only routes out of production prerender paths', () => { + for (const route of internalOnlyRoutes) { + expect(PRERENDER_PATHS).not.toContain(route); + } + }); + + it('keeps internal-only routes out of production app shell paths', () => { + for (const route of internalOnlyRoutes) { + expect(APP_CONFIG.appShellPaths).not.toContain(route); + } + }); + + it('tracks internal-only routes separately for development tooling', () => { + expect(DEV_ONLY_PATHS).toEqual([...internalOnlyRoutes]); + }); +}); diff --git a/apps/ratewise/src/config/seo-paths.ts b/apps/ratewise/src/config/seo-paths.ts index 3003f8791..ab3b7e71e 100644 --- a/apps/ratewise/src/config/seo-paths.ts +++ b/apps/ratewise/src/config/seo-paths.ts @@ -151,23 +151,20 @@ export const INDEXABLE_CANONICAL_PATHS = [ /** 相容別名:公開可索引 SEO 路徑。 */ export const SEO_PATHS = INDEXABLE_CANONICAL_PATHS; -export const APP_ONLY_PATHS = [ - '/multi/', - '/favorites/', - '/settings/', +/** 使用者功能頁:允許爬取 + SEOHelmet noindex,但不納入 sitemap。 */ +export const APP_ONLY_NOINDEX_PATHS = ['/multi/', '/favorites/', '/settings/'] as const; + +/** 開發 / 展示頁:正式 build 不註冊、不預渲染;robots 仍明確 Disallow。 */ +export const DEV_ONLY_PATHS = [ '/theme-showcase/', '/color-scheme/', '/update-prompt-test/', '/ui-showcase/', ] as const; -/** 使用者功能頁子集(前 3):允許爬取 + SEOHelmet noindex */ -export const APP_ONLY_NOINDEX_PATHS = APP_ONLY_PATHS.slice(0, 3); - -/** 開發 / 展示頁子集(後 4):直接 Disallow 爬取 */ -export const DEV_ONLY_PATHS = APP_ONLY_PATHS.slice(3); +export const APP_ONLY_PATHS = [...APP_ONLY_NOINDEX_PATHS] as const; -export const APP_ONLY_PRERENDER_PATHS = [...APP_ONLY_PATHS] as const; +export const APP_ONLY_PRERENDER_PATHS = [...APP_ONLY_NOINDEX_PATHS] as const; export const PRERENDER_PATHS = [ ...SEO_PATHS, diff --git a/apps/ratewise/src/routes.tsx b/apps/ratewise/src/routes.tsx index 9a7969270..8ebc357b5 100644 --- a/apps/ratewise/src/routes.tsx +++ b/apps/ratewise/src/routes.tsx @@ -10,7 +10,6 @@ * - `/multi`: 多幣別轉換器 * - `/favorites`: 收藏與歷史 * - `/settings`: 應用程式設定 - * - `/theme-showcase`: 主題展示 * * - Layout 路由(SEO 落地頁,保留原有結構): * - `/faq`: FAQ 頁面 - 預渲染靜態 HTML @@ -22,7 +21,8 @@ * - `/seo-tech`: SEO 技術揭露頁面 * - `/xxx-twd` / `/twd-xxx`: 17+17 個幣別落地頁 - 預渲染靜態 HTML * - * - 工具頁面(不預渲染): + * - 內部工具頁面(僅 dev 或 VITE_ENABLE_INTERNAL_ROUTES=true 註冊): + * - `/theme-showcase`: 主題展示 * - `/color-scheme`: 內部工具 * - `/update-prompt-test`: UpdatePrompt 測試 * - `/ui-showcase`: UI 元件展示 @@ -53,6 +53,9 @@ const MultiConverter = lazyWithRetry(() => import('./pages/MultiConverter')); const Favorites = lazyWithRetry(() => import('./pages/Favorites')); const Settings = lazyWithRetry(() => import('./pages/Settings')); +const shouldEnableInternalRoutes = + import.meta.env.DEV || import.meta.env['VITE_ENABLE_INTERNAL_ROUTES'] === 'true'; + /** 帶重試機制的動態 import */ async function importWithRetry( importFn: () => Promise, @@ -181,18 +184,21 @@ export const routes: RouteRecord[] = [ ), entry: 'src/pages/Settings.tsx', }, - // 主題展示頁面 - { - path: 'theme-showcase', - lazy: async () => { - try { - const module = await import('./pages/ThemeShowcase'); - return { Component: module.default }; - } catch (error) { - return { Component: () => }; - } - }, - }, + ...(shouldEnableInternalRoutes + ? [ + { + path: 'theme-showcase', + lazy: async () => { + try { + const module = await import('./pages/ThemeShowcase'); + return { Component: module.default }; + } catch (error) { + return { Component: () => }; + } + }, + }, + ] + : []), ], }, @@ -293,18 +299,25 @@ export const routes: RouteRecord[] = [ // SEO 技術揭露頁面 createLazyRoute('/seo-tech', () => import('./pages/SeoTech'), 'src/pages/SeoTech.tsx'), - // 不預渲染內部工具頁面 - createLazyRoute( - '/color-scheme', - () => import('./pages/ColorSchemeComparison'), - 'src/pages/ColorSchemeComparison.tsx', - ), - createLazyRoute( - '/update-prompt-test', - () => import('./pages/UpdatePromptTest'), - 'src/pages/UpdatePromptTest.tsx', - ), - createLazyRoute('/ui-showcase', () => import('./pages/UIShowcase'), 'src/pages/UIShowcase.tsx'), + ...(shouldEnableInternalRoutes + ? [ + createLazyRoute( + '/color-scheme', + () => import('./pages/ColorSchemeComparison'), + 'src/pages/ColorSchemeComparison.tsx', + ), + createLazyRoute( + '/update-prompt-test', + () => import('./pages/UpdatePromptTest'), + 'src/pages/UpdatePromptTest.tsx', + ), + createLazyRoute( + '/ui-showcase', + () => import('./pages/UIShowcase'), + 'src/pages/UIShowcase.tsx', + ), + ] + : []), // 不預渲染 404 頁面(動態處理) createLazyRoute('*', () => import('./pages/NotFound'), 'src/pages/NotFound.tsx'), @@ -315,6 +328,6 @@ export const routes: RouteRecord[] = [ * * 策略: * - 預渲染:首頁、FAQ、About、Guide + 13 個幣別落地頁 - * - 不預渲染:404、color-scheme(動態處理或內部工具) + * - 不預渲染:404、內部工具頁(production 不註冊) */ export { getIncludedRoutes } from './config/seo-paths'; diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index 0d73661e7..02609820d 100644 --- a/docs/dev/002_development_reward_penalty_log.md +++ b/docs/dev/002_development_reward_penalty_log.md @@ -2,7 +2,7 @@ > 版本:outline-v2-ultra > 原則:每筆只保留日期、ID、原因、解法。 -> 本次分數變化:+1(reward 1)|累計總分:前次總分 +40 +> 本次分數變化:+1(reward 1)|累計總分:前次總分 +41 ## 新增模板(4 行) @@ -13,6 +13,11 @@ ## 條目(新→舊) +- 日期:2026-05-12 +- ID:reward-ratewise-public-surface-governance +- 原因:內部展示與測試頁仍存在於正式路由與 prerender surface +- 解法:將 internal-only routes 從 production route/prerender surface 移除並補測試 + - 日期:2026-05-12 - ID:reward-ratewise-lint-baseline - 原因:RateWise lint gate 因 test type import warning 無法通過 From 9cf44bb20567ab4740e8bf60f099faf2ac40004e Mon Sep 17 00:00:00 2001 From: haotool Date: Tue, 12 May 2026 23:29:08 +0800 Subject: [PATCH 03/20] =?UTF-8?q?fix(ratewise):=20=E6=94=B9=E5=96=84?= =?UTF-8?q?=E5=85=A8=E5=9F=9F=E9=8C=AF=E8=AA=A4=E5=8F=AF=E8=A7=80=E6=B8=AC?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 將 unhandled rejection 改為集中分類 - 限制 production hydration suppression,避免遮蔽真錯誤 - 補 chunk、history 404 與 generic fetch 分類測試 測試:errorClassification、chunkLoadRecovery、sw、pwa recovery、typecheck --- .changeset/ratewise-error-observability.md | 5 ++ apps/ratewise/src/main.tsx | 42 ++++++------- .../src/suppress-hydration-warning.ts | 26 +++++--- .../__tests__/errorClassification.test.ts | 38 ++++++++++++ .../ratewise/src/utils/errorClassification.ts | 60 +++++++++++++++++++ .../dev/002_development_reward_penalty_log.md | 7 ++- 6 files changed, 146 insertions(+), 32 deletions(-) create mode 100644 .changeset/ratewise-error-observability.md create mode 100644 apps/ratewise/src/utils/__tests__/errorClassification.test.ts create mode 100644 apps/ratewise/src/utils/errorClassification.ts diff --git a/.changeset/ratewise-error-observability.md b/.changeset/ratewise-error-observability.md new file mode 100644 index 000000000..aca0af716 --- /dev/null +++ b/.changeset/ratewise-error-observability.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +改善正式版全域錯誤分類,避免一般網路錯誤被誤判為預期歷史匯率缺檔 diff --git a/apps/ratewise/src/main.tsx b/apps/ratewise/src/main.tsx index bc35b412b..d004c8291 100644 --- a/apps/ratewise/src/main.tsx +++ b/apps/ratewise/src/main.tsx @@ -22,7 +22,12 @@ import { logger } from './utils/logger'; import { initWebVitals } from './utils/webVitals'; import { handleVersionUpdate } from './utils/versionManager'; import { APP_VERSION, BUILD_TIME } from './config/version'; -import { isChunkLoadError, recoverFromChunkLoadError } from './utils/chunkLoadRecovery'; +import { recoverFromChunkLoadError } from './utils/chunkLoadRecovery'; +import { + classifyUnhandledRejection, + getUnhandledRejectionMessage, + toError, +} from './utils/errorClassification'; import { initPWAStorageManager, primePwaColdStartRecovery } from './utils/pwaStorageManager'; import { clearPwaAppReadyMarker, @@ -222,24 +227,13 @@ export const createRoot = ViteReactSSG( // [context7:googlechrome/lighthouse-ci:2025-10-20T04:10:04+08:00] // 主要用於處理歷史匯率 404 錯誤(正常現象,資料可能尚未生成) window.addEventListener('unhandledrejection', (event) => { - // 安全地提取錯誤訊息 const reason: unknown = event.reason; - let errorMessage = ''; - - if (reason instanceof Error) { - errorMessage = reason.message; - } else if (typeof reason === 'string') { - errorMessage = reason; - } else if (reason && typeof reason === 'object' && 'message' in reason) { - const msg = (reason as { message: unknown }).message; - errorMessage = typeof msg === 'string' ? msg : JSON.stringify(reason); - } - - const errorObject = - reason instanceof Error ? reason : new Error(errorMessage || 'Unhandled rejection'); + const errorMessage = getUnhandledRejectionMessage(reason); + const errorObject = toError(reason); + const rejectionKind = classifyUnhandledRejection(reason); // 先處理 chunk 載入錯誤(避免被歷史匯率錯誤規則吞掉) - if (isChunkLoadError(errorObject)) { + if (rejectionKind === 'chunk-load') { logger.warn('Chunk load error captured by global handler', { reason: errorMessage, }); @@ -249,13 +243,7 @@ export const createRoot = ViteReactSSG( return; } - // 檢查是否為歷史匯率相關的網路錯誤 - const isHistoricalRates404 = - errorMessage.includes('history') || - errorMessage.includes('404') || - errorMessage.includes('Failed to fetch'); - - if (isHistoricalRates404) { + if (rejectionKind === 'expected-history-miss') { // 這是預期的錯誤(歷史資料可能尚未生成),阻止顯示在 console logger.debug('Historical data fetch failed (expected)', { reason: errorMessage, @@ -264,6 +252,14 @@ export const createRoot = ViteReactSSG( return; } + if (rejectionKind === 'generic-fetch-failure') { + logger.warn('Generic fetch failure captured by global handler', { + reason: errorMessage, + }); + recordPwaDiagnostic('generic-fetch-failure', errorMessage, 'warn'); + return; + } + // 其他未處理的錯誤記錄但不阻止 logger.error('Unhandled promise rejection', errorObject); recordPwaDiagnostic('unhandled-rejection', errorMessage || errorObject.message, 'error'); diff --git a/apps/ratewise/src/suppress-hydration-warning.ts b/apps/ratewise/src/suppress-hydration-warning.ts index b05f7f655..d0edafd6b 100644 --- a/apps/ratewise/src/suppress-hydration-warning.ts +++ b/apps/ratewise/src/suppress-hydration-warning.ts @@ -1,21 +1,31 @@ /** - * Suppress React Hydration Warning (#418) + * Optional React Hydration Warning (#418) suppression * * This module MUST be imported FIRST in main.tsx (before any other imports) - * to ensure the console.error override is set up before React loads. + * so the explicit non-production diagnostic suppression can run before React loads. * - * React #418 (Hydration mismatch) is an expected error in SSG environments - * with dynamic content like i18n language detection, timestamps, etc. - * - * The error is suppressed in console but React still recovers and re-renders - * the correct content on the client. + * Production must not globally hide hydration errors. Known harmless mismatches + * should be handled at component level with local suppressHydrationWarning. * * @see https://react.dev/errors/418 * @see docs/dev/002_development_reward_penalty_log.md * @created 2026-01-27 */ -if (typeof window !== 'undefined') { +import { isHydrationSuppressionEnabled } from './utils/errorClassification'; + +const hydrationSuppressionFlag = + typeof import.meta.env['VITE_SUPPRESS_HYDRATION_WARNINGS'] === 'string' + ? import.meta.env['VITE_SUPPRESS_HYDRATION_WARNINGS'] + : undefined; + +if ( + typeof window !== 'undefined' && + isHydrationSuppressionEnabled({ + prod: import.meta.env.PROD, + flag: hydrationSuppressionFlag, + }) +) { const originalConsoleError = console.error; console.error = (...args: unknown[]) => { diff --git a/apps/ratewise/src/utils/__tests__/errorClassification.test.ts b/apps/ratewise/src/utils/__tests__/errorClassification.test.ts new file mode 100644 index 000000000..58b0a789d --- /dev/null +++ b/apps/ratewise/src/utils/__tests__/errorClassification.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { classifyUnhandledRejection, isHydrationSuppressionEnabled } from '../errorClassification'; + +describe('classifyUnhandledRejection', () => { + it('classifies chunk load errors before generic fetch errors', () => { + expect( + classifyUnhandledRejection(new TypeError('Failed to fetch dynamically imported module')), + ).toBe('chunk-load'); + }); + + it('classifies verified history endpoint 404 as expected history miss', () => { + expect( + classifyUnhandledRejection( + new Error( + 'GET https://cdn.jsdelivr.net/gh/haotool/app@data/public/rates/history/2026-05-12.json 404', + ), + ), + ).toBe('expected-history-miss'); + }); + + it('does not classify generic Failed to fetch as expected history miss', () => { + expect(classifyUnhandledRejection(new TypeError('Failed to fetch'))).toBe( + 'generic-fetch-failure', + ); + }); + + it('classifies unrelated errors as unknown', () => { + expect(classifyUnhandledRejection(new Error('Unexpected application state'))).toBe('unknown'); + }); +}); + +describe('isHydrationSuppressionEnabled', () => { + it('only enables suppression for explicit non-production diagnostics', () => { + expect(isHydrationSuppressionEnabled({ prod: true, flag: 'true' })).toBe(false); + expect(isHydrationSuppressionEnabled({ prod: false, flag: 'true' })).toBe(true); + expect(isHydrationSuppressionEnabled({ prod: false, flag: undefined })).toBe(false); + }); +}); diff --git a/apps/ratewise/src/utils/errorClassification.ts b/apps/ratewise/src/utils/errorClassification.ts new file mode 100644 index 000000000..dd0bd29bf --- /dev/null +++ b/apps/ratewise/src/utils/errorClassification.ts @@ -0,0 +1,60 @@ +import { isChunkLoadError } from './chunkLoadRecovery'; + +export type UnhandledRejectionKind = + | 'chunk-load' + | 'expected-history-miss' + | 'generic-fetch-failure' + | 'unknown'; + +export function getUnhandledRejectionMessage(reason: unknown): string { + if (reason instanceof Error) { + return reason.message; + } + + if (typeof reason === 'string') { + return reason; + } + + if (reason && typeof reason === 'object' && 'message' in reason) { + const message = (reason as { message: unknown }).message; + return typeof message === 'string' ? message : JSON.stringify(reason); + } + + return ''; +} + +export function toError(reason: unknown): Error { + return reason instanceof Error + ? reason + : new Error(getUnhandledRejectionMessage(reason) || 'Unhandled rejection'); +} + +function isVerifiedHistoryMiss(message: string): boolean { + return /\/rates\/history\/[^/\s]+\.json/i.test(message) && /\b404\b/.test(message); +} + +export function classifyUnhandledRejection(reason: unknown): UnhandledRejectionKind { + const error = toError(reason); + const message = getUnhandledRejectionMessage(reason); + + if (isChunkLoadError(error)) { + return 'chunk-load'; + } + + if (isVerifiedHistoryMiss(message)) { + return 'expected-history-miss'; + } + + if (message.includes('Failed to fetch')) { + return 'generic-fetch-failure'; + } + + return 'unknown'; +} + +export function isHydrationSuppressionEnabled(input: { + prod: boolean; + flag: string | undefined; +}): boolean { + return !input.prod && input.flag === 'true'; +} diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index 02609820d..5c418f575 100644 --- a/docs/dev/002_development_reward_penalty_log.md +++ b/docs/dev/002_development_reward_penalty_log.md @@ -2,7 +2,7 @@ > 版本:outline-v2-ultra > 原則:每筆只保留日期、ID、原因、解法。 -> 本次分數變化:+1(reward 1)|累計總分:前次總分 +41 +> 本次分數變化:+1(reward 1)|累計總分:前次總分 +42 ## 新增模板(4 行) @@ -13,6 +13,11 @@ ## 條目(新→舊) +- 日期:2026-05-12 +- ID:reward-ratewise-error-observability +- 原因:hydration 與 fetch 類錯誤被全域 suppression 遮蔽 +- 解法:集中錯誤分類並限制 production hydration suppression + - 日期:2026-05-12 - ID:reward-ratewise-public-surface-governance - 原因:內部展示與測試頁仍存在於正式路由與 prerender surface From 8e3e16f011db7cb472804d63809aa987ba15231f Mon Sep 17 00:00:00 2001 From: haotool Date: Tue, 12 May 2026 23:36:45 +0800 Subject: [PATCH 04/20] =?UTF-8?q?test(ratewise):=20=E8=A3=9C=E5=BC=B7?= =?UTF-8?q?=E7=94=9F=E7=94=A2=E7=B4=9A=20QA=20=E9=96=98=E9=96=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 將核心無障礙與離線體驗納入可執行驗證 - 加入 scheduled production governance workflow - 將趨勢圖 latency budget 改為明確手動與排程 gate 測試:accessibility e2e、offline indicator e2e、trend latency e2e、typecheck、workflow prettier --- .../ratewise-production-governance.yml | 53 +++++++++++++++++++ apps/ratewise/tests/e2e/accessibility.spec.ts | 29 ++++------ apps/ratewise/tests/e2e/offline-pwa.spec.ts | 6 +-- .../tests/e2e/trend-chart-latency.spec.ts | 9 +++- .../dev/002_development_reward_penalty_log.md | 7 ++- 5 files changed, 78 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/ratewise-production-governance.yml diff --git a/.github/workflows/ratewise-production-governance.yml b/.github/workflows/ratewise-production-governance.yml new file mode 100644 index 000000000..bf0285bc4 --- /dev/null +++ b/.github/workflows/ratewise-production-governance.yml @@ -0,0 +1,53 @@ +name: RateWise Production Governance + +on: + workflow_dispatch: + schedule: + - cron: '17 20 * * *' + +permissions: + contents: read + +jobs: + production-governance: + name: Live Headers And Performance Gates + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + PLAYWRIGHT_BASE_URL: https://app.haotool.org + E2E_BASE_PATH: /ratewise + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + with: + version: 9.10.0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + run: pnpm --filter @app/ratewise exec playwright install --with-deps chromium + + - name: Verify Cloudflare headers and cache policy + run: | + RUN_PRODUCTION_TESTS=true \ + pnpm --filter @app/ratewise exec playwright test \ + tests/e2e/cloudflare-cache.spec.ts \ + --project=chromium-desktop + + - name: Verify trend chart latency budget + run: | + RUN_RATEWISE_PERFORMANCE_TESTS=true \ + pnpm --filter @app/ratewise exec playwright test \ + tests/e2e/trend-chart-latency.spec.ts \ + --project=chromium-desktop diff --git a/apps/ratewise/tests/e2e/accessibility.spec.ts b/apps/ratewise/tests/e2e/accessibility.spec.ts index 4ee2f78a2..f6e38a8d5 100644 --- a/apps/ratewise/tests/e2e/accessibility.spec.ts +++ b/apps/ratewise/tests/e2e/accessibility.spec.ts @@ -42,11 +42,10 @@ test.describe('無障礙性掃描', () => { expect(criticalViolations).toHaveLength(0); }); - // [fix:2026-01-31] 多幣別模式在 CI 環境不穩定,待 UI 統一後修復 - test.fixme('多幣別模式應該通過無障礙性掃描', async ({ rateWisePage: page }) => { + test('多幣別模式應該通過無障礙性掃描', async ({ rateWisePage: page }) => { // 切換到多幣別模式 await page.getByRole('link', { name: /多幣別/i }).click(); - await page.waitForTimeout(500); + await expect(page.getByRole('main')).toBeVisible({ timeout: 10_000 }); const accessibilityScanResults = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) @@ -94,16 +93,14 @@ test.describe('無障礙性掃描', () => { // 輸入框應該有某種形式的標籤 // 注意:如果設計上使用 placeholder 作為唯一提示,這裡會失敗 // 這是預期的,因為這不符合無障礙性最佳實踐 - if (!hasLabel) { - console.warn(`輸入框 ${i} 缺少明確的標籤(建議新增 aria-label 或 label 元素)`); - } + expect + .soft(hasLabel, `輸入框 ${i} 應有 aria-label、aria-labelledby 或 label[for]`) + .toBe(true); } }); - test.skip('按鈕應該有可識別的文字或 aria-label (過於嚴格,跳過)', async ({ - rateWisePage: page, - }) => { - const buttons = page.locator('button'); + test('可見按鈕應該有可識別的文字或 aria-label', async ({ rateWisePage: page }) => { + const buttons = page.locator('button:visible'); const buttonCount = await buttons.count(); for (let i = 0; i < buttonCount; i++) { @@ -113,16 +110,12 @@ test.describe('無障礙性掃描', () => { const ariaLabel = await button.getAttribute('aria-label'); const ariaLabelledBy = await button.getAttribute('aria-labelledby'); - const hasAccessibleName = Boolean( - (textContent && textContent.trim().length > 0) ?? ariaLabel ?? ariaLabelledBy, - ); - - if (!hasAccessibleName) { - console.warn(`按鈕 ${i} 缺少可識別的文字或 aria-label`); - } + const accessibleName = ariaLabel || ariaLabelledBy || textContent || ''; // 至少應該有某種形式的可識別名稱 - expect(hasAccessibleName).toBeTruthy(); + expect + .soft(accessibleName.trim().length, `按鈕 ${i} 應有 accessible name`) + .toBeGreaterThan(0); } }); diff --git a/apps/ratewise/tests/e2e/offline-pwa.spec.ts b/apps/ratewise/tests/e2e/offline-pwa.spec.ts index f5ea30c78..9af787a8f 100644 --- a/apps/ratewise/tests/e2e/offline-pwa.spec.ts +++ b/apps/ratewise/tests/e2e/offline-pwa.spec.ts @@ -291,11 +291,7 @@ test.describe('Offline Indicator Display/Hide', () => { // Use pwa-chromium project to allow Service Worker test.use({ serviceWorkers: 'allow' }); - // SKIP: OfflineIndicator component not rendering in E2E environment - // Root cause: React component never called despite being in App.tsx - // Unit tests (11/11) all pass, proving component logic works - // E2E tests should focus on actual offline functionality instead - test.skip('should show offline indicator when network disconnects', async ({ page }) => { + test('should show offline indicator when network disconnects', async ({ page }) => { const offlinePage = new OfflinePWAPage(page); await offlinePage.goto(); await offlinePage.waitForPrecache(); diff --git a/apps/ratewise/tests/e2e/trend-chart-latency.spec.ts b/apps/ratewise/tests/e2e/trend-chart-latency.spec.ts index 7e7b5452f..e3876188e 100644 --- a/apps/ratewise/tests/e2e/trend-chart-latency.spec.ts +++ b/apps/ratewise/tests/e2e/trend-chart-latency.spec.ts @@ -21,6 +21,7 @@ const BASE_URL = process.env['PLAYWRIGHT_BASE_URL'] || 'http://localhost:4173'; const BASE_PATH = process.env['E2E_BASE_PATH'] || process.env['VITE_RATEWISE_BASE_PATH'] || '/ratewise'; const BASE = `${BASE_URL}${BASE_PATH}/`.replace(/\/+$/, '/'); +const isPerformanceGate = process.env['RUN_RATEWISE_PERFORMANCE_TESTS'] === 'true'; // 效能門檻 const TREND_CHART_VISIBLE_TIMEOUT_MS = 20_000; // 當前預期:10s defer + 2s idle + 3s fetch @@ -286,8 +287,12 @@ test.describe('趨勢圖優化後驗證', () => { test.use({ serviceWorkers: 'block' }); test.setTimeout(60_000); - test.skip('優化後:趨勢圖應在 2.5 秒內可見', async ({ page }) => { - // 此測試在 PR2(移除 10s defer)合併後啟用 + test.skip( + !isPerformanceGate, + 'Set RUN_RATEWISE_PERFORMANCE_TESTS=true to run trend latency budget', + ); + + test('優化後:趨勢圖應在 2.5 秒內可見', async ({ page }) => { await mockRatesApi(page); const navStart = Date.now(); diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index 5c418f575..de929c3d8 100644 --- a/docs/dev/002_development_reward_penalty_log.md +++ b/docs/dev/002_development_reward_penalty_log.md @@ -2,7 +2,7 @@ > 版本:outline-v2-ultra > 原則:每筆只保留日期、ID、原因、解法。 -> 本次分數變化:+1(reward 1)|累計總分:前次總分 +42 +> 本次分數變化:+1(reward 1)|累計總分:前次總分 +43 ## 新增模板(4 行) @@ -13,6 +13,11 @@ ## 條目(新→舊) +- 日期:2026-05-12 +- ID:reward-ratewise-qa-gate-governance +- 原因:核心 accessibility/offline/performance checks 仍含未收斂 skip 或手動流程 +- 解法:恢復可執行 accessibility/offline gate 並加入 scheduled production governance + - 日期:2026-05-12 - ID:reward-ratewise-error-observability - 原因:hydration 與 fetch 類錯誤被全域 suppression 遮蔽 From 1abab9c865bb33c8914f907b9798c2de2e98f13b Mon Sep 17 00:00:00 2001 From: haotool Date: Tue, 12 May 2026 23:42:55 +0800 Subject: [PATCH 05/20] =?UTF-8?q?build(ratewise):=20=E6=94=B6=E6=96=82?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=94=A2=E7=89=A9=20SSOT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分 live data、deterministic generation 與 artifact verify scripts - 移除本機 Lighthouse 與 tsbuildinfo 產物追蹤 - 同步 README、AGENTS 與 CLAUDE 的 artifact policy 測試:build-scripts vitest、verify:artifacts、generate:deterministic、typecheck、prettier --- .changeset/ratewise-artifact-ssot.md | 5 + .../ratewise-production-governance.yml | 2 +- AGENTS.md | 8 + CLAUDE.md | 11 + README.md | 11 +- apps/ratewise/README.md | 17 +- apps/ratewise/lighthouse-report.json | 5274 ----------------- apps/ratewise/package.json | 9 +- .../config/__tests__/build-scripts.test.ts | 20 +- apps/ratewise/tsconfig.node.tsbuildinfo | 1 - apps/ratewise/tsconfig.tsbuildinfo | 1 - .../dev/002_development_reward_penalty_log.md | 7 +- 12 files changed, 83 insertions(+), 5283 deletions(-) create mode 100644 .changeset/ratewise-artifact-ssot.md delete mode 100644 apps/ratewise/lighthouse-report.json delete mode 100644 apps/ratewise/tsconfig.node.tsbuildinfo delete mode 100644 apps/ratewise/tsconfig.tsbuildinfo diff --git a/.changeset/ratewise-artifact-ssot.md b/.changeset/ratewise-artifact-ssot.md new file mode 100644 index 000000000..c2440f801 --- /dev/null +++ b/.changeset/ratewise-artifact-ssot.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +Clarify generated artifact buckets and remove local build report files from tracked source. diff --git a/.github/workflows/ratewise-production-governance.yml b/.github/workflows/ratewise-production-governance.yml index bf0285bc4..77a86f769 100644 --- a/.github/workflows/ratewise-production-governance.yml +++ b/.github/workflows/ratewise-production-governance.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@v6 with: version: 9.10.0 diff --git a/AGENTS.md b/AGENTS.md index 2df920e79..e64bf05f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -460,9 +460,17 @@ git push origin main # pre-push 自動驗證 - `public/*.md`(markdown mirrors)版本號由完整 build 更新;`pnpm changeset:version` 不觸發此步驟,故 mirrors 版本會暫時落後一個版本。 - `src/config/generated/`(build-time-rates.json、seo-rate-examples.ts)由每日 SEO 排程更新。 +- `apps/ratewise/lighthouse-report.json` 與 `apps/ratewise/*.tsbuildinfo` 屬本機工具輸出;已由 `.gitignore` 管理,必須保持 untracked。 - **MUST NOT**:把上述兩類修改單獨建立 release 後的 follow-up commit,`verify-version-ssot` 會因為 staged set 內沒有 version bump 或 changeset 而擋下。 - **MUST**:commit 失敗後必須重新執行 `git restore --staged --worktree ` 再重試;lint-staged 的 stash/restore 循環會把失敗前的 working tree 狀態還原,使已 restore 的修改重新出現。 +### RateWise Generated Artifact Buckets(SSOT) + +- `pnpm --filter @app/ratewise refresh:data`:更新 live snapshots(build-time rates、SEO rate examples、rating snapshot)。 +- `pnpm --filter @app/ratewise generate:deterministic`:由 repo SSOT 重建 sitemap、manifest、offline shell、LLMs text、Markdown mirrors、API JSON 與 OpenAPI。 +- `pnpm --filter @app/ratewise verify:artifacts`:執行 SSOT sync 與 image resource 檢查。 +- `pnpm --filter @app/ratewise prebuild`:只作為上述 buckets 的串接入口;禁止把新的 hidden side effect 直接塞回單一長命令。 + ### Release PR 自動化失敗治理 **觸發條件**:main 累積 `.changeset/*.md`,但 package version / CHANGELOG 長期未更新;或 `Release` workflow 顯示 success 但未建立 `changeset-release/main` PR。 diff --git a/CLAUDE.md b/CLAUDE.md index ad6c3a95c..d574a66d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,15 @@ git push origin main # pre-push 自動跑 typecheck + test + build 禁止:手動改版號、單獨跑 prebuild scripts、直接改 CHANGELOG 跳過 changeset。 +**RateWise generated artifact buckets**: + +- `pnpm --filter @app/ratewise refresh:data`:live snapshots + (`build-time-rates.json`、`seo-rate-examples.ts`、`rating-snapshot.ts`)。 +- `pnpm --filter @app/ratewise generate:deterministic`:repo SSOT 可重建產物 + (sitemap、manifest、offline shell、LLMs text、Markdown mirrors、API JSON、OpenAPI)。 +- `pnpm --filter @app/ratewise verify:artifacts`:SSOT sync 與 image resource 檢查。 +- `prebuild` 只串接上述 buckets;`lighthouse-report.json`、`*.tsbuildinfo` 屬本機工具輸出,必須保持 untracked。 + **Release PR 自動化控制**: - `changesets/action` 的 release commit 必須使用 commitlint 豁免格式:`chore(release): 更新版本套件` @@ -277,6 +286,8 @@ gh pr merge --squash --delete-branch=false **發版後 `public/*.md` 或 generated 檔案觸發 SSOT 守門失敗**:`pnpm changeset:version` 只更新 api/latest.json 等 SSOT 產出物,不重新生成 markdown mirrors(`public/*.md`);若這些修改殘留並另行 commit,`verify-version-ssot` 會因新 staged set 缺少 version bump 或 changeset 而擋下。修法:`git restore --staged --worktree apps/ratewise/public/*.md apps/ratewise/src/config/generated/`,讓 CI build 與每日 SEO 排程重新生成。 +**本機 build / QA 產物出現在 git status**:`apps/ratewise/lighthouse-report.json` 與 `apps/ratewise/*.tsbuildinfo` 是工具輸出,不是 source。若它們被重新建立,保持 untracked;若意外 staged,執行 `git restore --staged `。 + **lint-staged stash/restore 循環復活已 restore 的檔案**:commit 失敗時 lint-staged 會還原其 stash,可能把已 `git restore` 的 working tree 修改重新帶回。修法:每次 commit 失敗後必須重新執行 `git restore --staged --worktree ` 再重試,不可假設檔案狀態與 restore 後相同。 ### PWA diff --git a/README.md b/README.md index b9210c958..1492e1d3a 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ haotool-app/ │ ├── release.yml # 版本發布 │ ├── seo-audit.yml # SEO 審查 │ ├── seo-production.yml # 生產環境 SEO +│ ├── ratewise-production-governance.yml # RateWise 生產治理檢查 │ ├── update-committed-seo-files.yml # SEO 產出物同步 │ ├── update-historical-rates.yml # 歷史匯率更新 │ ├── update-latest-rates.yml # 最新匯率更新 @@ -270,7 +271,7 @@ haotool Apps is a professional pnpm Monorepo containing multiple high-quality Re - **Styling**: Tailwind CSS 3.4 - **Testing**: Vitest 4.1 + Playwright 1.57 - **Package Manager**: pnpm 9.10.0 (Monorepo) -- **CI/CD**: GitHub Actions (9 workflows) +- **CI/CD**: GitHub Actions (10 workflows) - **Deployment**: Docker + Zeabur / Vercel - **Security**: Gitleaks CLI + Trivy + SARIF @@ -298,6 +299,14 @@ Production is deployed by Zeabur from GitHub main. Before merging a release PR right after another main PR, confirm the earlier production deployment has finished so an older SHA cannot become active after the release SHA. +### Generated Artifacts + +RateWise generated files are bucketed by package scripts: +`refresh:data` updates live snapshots, `generate:deterministic` rebuilds +repo-derived public artifacts, and `verify:artifacts` checks SSOT/resource +sync. Local tool output such as `lighthouse-report.json` and `*.tsbuildinfo` +must remain untracked. + ### License This project is licensed under [GPL-3.0](./LICENSE). diff --git a/apps/ratewise/README.md b/apps/ratewise/README.md index 01a5d23c9..217b26cb0 100644 --- a/apps/ratewise/README.md +++ b/apps/ratewise/README.md @@ -75,10 +75,25 @@ RateWise 正式站由 Zeabur production deployment 發布,Release 完成後需 precache 驗證。若 GitHub Release 已建立但正式站仍回舊版,先查 GitHub deployments 的 active SHA,再以 app 範圍 PR 重新觸發最新 main 部署。 +## 🧱 Generated Artifact SSOT + +RateWise 將 build 產物分成三類,避免 live data、deterministic artifacts 與本機 QA +報告混在同一個 commit: + +- `pnpm --filter @app/ratewise refresh:data`:更新 live data snapshot + (`build-time-rates.json`、`seo-rate-examples.ts`、`rating-snapshot.ts`)。 +- `pnpm --filter @app/ratewise generate:deterministic`:由 repo SSOT 重建 sitemap、 + manifest、offline shell、LLMs text、Markdown mirrors、API JSON 與 OpenAPI。 +- `pnpm --filter @app/ratewise verify:artifacts`:驗證 SSOT sync 與圖片資源。 + +`prebuild` 只負責串接上述 buckets;`lighthouse-report.json` 與 `*.tsbuildinfo` 屬本機 +工具輸出,必須保持 untracked。QA 截圖集中放 `screenshots/`,正式 SEO/manifest 圖片則保留 +在 `public/screenshots/`。 + ## 📄 授權 GPL-3.0 © [haotool](https://app.haotool.org/) --- -**最後更新**: 2026-04-28 +**最後更新**: 2026-05-12 diff --git a/apps/ratewise/lighthouse-report.json b/apps/ratewise/lighthouse-report.json deleted file mode 100644 index 16a3900d5..000000000 --- a/apps/ratewise/lighthouse-report.json +++ /dev/null @@ -1,5274 +0,0 @@ -{ - "lighthouseVersion": "13.0.1", - "requestedUrl": "http://localhost:4173/", - "mainDocumentUrl": "http://localhost:4173/", - "finalDisplayedUrl": "http://localhost:4173/", - "finalUrl": "http://localhost:4173/", - "fetchTime": "2025-12-27T05:18:09.367Z", - "gatherMode": "navigation", - "runtimeError": { - "code": "NO_FCP", - "message": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)", - "artifactKey": "PageLoadError" - }, - "runWarnings": [ - "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)" - ], - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/143.0.0.0 Safari/537.36", - "environment": { - "hostUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/143.0.0.0 Safari/537.36", - "benchmarkIndex": 3027, - "credits": {} - }, - "audits": { - "is-on-https": { - "id": "is-on-https", - "title": "Uses HTTPS", - "description": "All sites should be protected with HTTPS, even ones that don't handle sensitive data. This includes avoiding [mixed content](https://developers.google.com/web/fundamentals/security/prevent-mixed-content/what-is-mixed-content), where some resources are loaded over HTTP despite the initial request being served over HTTPS. HTTPS prevents intruders from tampering with or passively listening in on the communications between your app and your users, and is a prerequisite for HTTP/2 and many new web platform APIs. [Learn more about HTTPS](https://developer.chrome.com/docs/lighthouse/pwa/is-on-https/).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "redirects-http": { - "id": "redirects-http", - "title": "Redirects HTTP traffic to HTTPS", - "description": "Make sure that you redirect all HTTP traffic to HTTPS in order to enable secure web features for all your users. [Learn more](https://developer.chrome.com/docs/lighthouse/pwa/redirects-http/).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "first-contentful-paint": { - "id": "first-contentful-paint", - "title": "First Contentful Paint", - "description": "First Contentful Paint marks the time at which the first text or image is painted. [Learn more about the First Contentful Paint metric](https://developer.chrome.com/docs/lighthouse/performance/first-contentful-paint/).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "largest-contentful-paint": { - "id": "largest-contentful-paint", - "title": "Largest Contentful Paint", - "description": "Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn more about the Largest Contentful Paint metric](https://developer.chrome.com/docs/lighthouse/performance/lighthouse-largest-contentful-paint/)", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "speed-index": { - "id": "speed-index", - "title": "Speed Index", - "description": "Speed Index shows how quickly the contents of a page are visibly populated. [Learn more about the Speed Index metric](https://developer.chrome.com/docs/lighthouse/performance/speed-index/).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "screenshot-thumbnails": { - "id": "screenshot-thumbnails", - "title": "Screenshot Thumbnails", - "description": "This is what the load of your site looked like.", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "final-screenshot": { - "id": "final-screenshot", - "title": "Final Screenshot", - "description": "The last screenshot captured of the pageload.", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "total-blocking-time": { - "id": "total-blocking-time", - "title": "Total Blocking Time", - "description": "Sum of all time periods between FCP and Time to Interactive, when task length exceeded 50ms, expressed in milliseconds. [Learn more about the Total Blocking Time metric](https://developer.chrome.com/docs/lighthouse/performance/lighthouse-total-blocking-time/).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "max-potential-fid": { - "id": "max-potential-fid", - "title": "Max Potential First Input Delay", - "description": "The maximum potential First Input Delay that your users could experience is the duration of the longest task. [Learn more about the Maximum Potential First Input Delay metric](https://developer.chrome.com/docs/lighthouse/performance/lighthouse-max-potential-fid/).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "cumulative-layout-shift": { - "id": "cumulative-layout-shift", - "title": "Cumulative Layout Shift", - "description": "Cumulative Layout Shift measures the movement of visible elements within the viewport. [Learn more about the Cumulative Layout Shift metric](https://web.dev/articles/cls).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "errors-in-console": { - "id": "errors-in-console", - "title": "No browser errors logged to the console", - "description": "Errors logged to the console indicate unresolved problems. They can come from network request failures and other browser concerns. [Learn more about this errors in console diagnostic audit](https://developer.chrome.com/docs/lighthouse/best-practices/errors-in-console/)", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "server-response-time": { - "id": "server-response-time", - "title": "Initial server response time was short", - "description": "Keep the server response time for the main document short because all other requests depend on it. [Learn more about the Time to First Byte metric](https://developer.chrome.com/docs/lighthouse/performance/time-to-first-byte/).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)", - "guidanceLevel": 1 - }, - "interactive": { - "id": "interactive", - "title": "Time to Interactive", - "description": "Time to Interactive is the amount of time it takes for the page to become fully interactive. [Learn more about the Time to Interactive metric](https://developer.chrome.com/docs/lighthouse/performance/interactive/).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "user-timings": { - "id": "user-timings", - "title": "User Timing marks and measures", - "description": "Consider instrumenting your app with the User Timing API to measure your app's real-world performance during key user experiences. [Learn more about User Timing marks](https://developer.chrome.com/docs/lighthouse/performance/user-timings/).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)", - "guidanceLevel": 2 - }, - "redirects": { - "id": "redirects", - "title": "Avoid multiple page redirects", - "description": "Redirects introduce additional delays before the page can be loaded. [Learn how to avoid page redirects](https://developer.chrome.com/docs/lighthouse/performance/redirects/).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)", - "guidanceLevel": 2 - }, - "image-aspect-ratio": { - "id": "image-aspect-ratio", - "title": "Displays images with correct aspect ratio", - "description": "Image display dimensions should match natural aspect ratio. [Learn more about image aspect ratio](https://developer.chrome.com/docs/lighthouse/best-practices/image-aspect-ratio/).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "image-size-responsive": { - "id": "image-size-responsive", - "title": "Serves images with appropriate resolution", - "description": "Image natural dimensions should be proportional to the display size and the pixel ratio to maximize image clarity. [Learn how to provide responsive images](https://web.dev/articles/serve-responsive-images).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "deprecations": { - "id": "deprecations", - "title": "Avoids deprecated APIs", - "description": "Deprecated APIs will eventually be removed from the browser. [Learn more about deprecated APIs](https://developer.chrome.com/docs/lighthouse/best-practices/deprecations/).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "third-party-cookies": { - "id": "third-party-cookies", - "title": "Avoids third-party cookies", - "description": "Third-party cookies may be blocked in some contexts. [Learn more about preparing for third-party cookie restrictions](https://privacysandbox.google.com/cookies/prepare/overview).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "mainthread-work-breakdown": { - "id": "mainthread-work-breakdown", - "title": "Minimizes main-thread work", - "description": "Consider reducing the time spent parsing, compiling and executing JS. You may find delivering smaller JS payloads helps with this. [Learn how to minimize main-thread work](https://developer.chrome.com/docs/lighthouse/performance/mainthread-work-breakdown/)", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)", - "guidanceLevel": 1 - }, - "bootup-time": { - "id": "bootup-time", - "title": "JavaScript execution time", - "description": "Consider reducing the time spent parsing, compiling, and executing JS. You may find delivering smaller JS payloads helps with this. [Learn how to reduce Javascript execution time](https://developer.chrome.com/docs/lighthouse/performance/bootup-time/).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)", - "guidanceLevel": 1 - }, - "diagnostics": { - "id": "diagnostics", - "title": "Diagnostics", - "description": "Collection of useful page vitals.", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "network-requests": { - "id": "network-requests", - "title": "Network Requests", - "description": "Lists the network requests that were made during page load.", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "network-rtt": { - "id": "network-rtt", - "title": "Network Round Trip Times", - "description": "Network round trip times (RTT) have a large impact on performance. If the RTT to an origin is high, it's an indication that servers closer to the user could improve performance. [Learn more about the Round Trip Time](https://hpbn.co/primer-on-latency-and-bandwidth/).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "network-server-latency": { - "id": "network-server-latency", - "title": "Server Backend Latencies", - "description": "Server latencies can impact web performance. If the server latency of an origin is high, it's an indication the server is overloaded or has poor backend performance. [Learn more about server response time](https://hpbn.co/primer-on-web-performance/#analyzing-the-resource-waterfall).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "main-thread-tasks": { - "id": "main-thread-tasks", - "title": "Tasks", - "description": "Lists the toplevel main thread tasks that executed during page load.", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "metrics": { - "id": "metrics", - "title": "Metrics", - "description": "Collects all available metrics.", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "resource-summary": { - "id": "resource-summary", - "title": "Resources Summary", - "description": "Aggregates all network requests and groups them by type", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "layout-shifts": { - "id": "layout-shifts", - "title": "Avoid large layout shifts", - "description": "These are the largest layout shifts observed on the page. Each table item represents a single layout shift, and shows the element that shifted the most. Below each item are possible root causes that led to the layout shift. Some of these layout shifts may not be included in the CLS metric value due to [windowing](https://web.dev/articles/cls#what_is_cls). [Learn how to improve CLS](https://web.dev/articles/optimize-cls)", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)", - "guidanceLevel": 2 - }, - "long-tasks": { - "id": "long-tasks", - "title": "Avoid long main-thread tasks", - "description": "Lists the longest tasks on the main thread, useful for identifying worst contributors to input delay. [Learn how to avoid long main-thread tasks](https://web.dev/articles/optimize-long-tasks)", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)", - "guidanceLevel": 1 - }, - "non-composited-animations": { - "id": "non-composited-animations", - "title": "Avoid non-composited animations", - "description": "Animations which are not composited can be janky and increase CLS. [Learn how to avoid non-composited animations](https://developer.chrome.com/docs/lighthouse/performance/non-composited-animations/)", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)", - "guidanceLevel": 2 - }, - "unsized-images": { - "id": "unsized-images", - "title": "Image elements have explicit `width` and `height`", - "description": "Set an explicit width and height on image elements to reduce layout shifts and improve CLS. [Learn how to set image dimensions](https://web.dev/articles/optimize-cls#images_without_dimensions)", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)", - "guidanceLevel": 4 - }, - "valid-source-maps": { - "id": "valid-source-maps", - "title": "Page has valid source maps", - "description": "Source maps translate minified code to the original source code. This helps developers debug in production. In addition, Lighthouse is able to provide further insights. Consider deploying source maps to take advantage of these benefits. [Learn more about source maps](https://developer.chrome.com/docs/devtools/javascript/source-maps/).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "csp-xss": { - "id": "csp-xss", - "title": "Ensure CSP is effective against XSS attacks", - "description": "A strong Content Security Policy (CSP) significantly reduces the risk of cross-site scripting (XSS) attacks. [Learn how to use a CSP to prevent XSS](https://developer.chrome.com/docs/lighthouse/best-practices/csp-xss/)", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "has-hsts": { - "id": "has-hsts", - "title": "Use a strong HSTS policy", - "description": "Deployment of the HSTS header significantly reduces the risk of downgrading HTTP connections and eavesdropping attacks. A rollout in stages, starting with a low max-age is recommended. [Learn more about using a strong HSTS policy.](https://developer.chrome.com/docs/lighthouse/best-practices/has-hsts)", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "origin-isolation": { - "id": "origin-isolation", - "title": "Ensure proper origin isolation with COOP", - "description": "The Cross-Origin-Opener-Policy (COOP) can be used to isolate the top-level window from other documents such as pop-ups. [Learn more about deploying the COOP header.](https://web.dev/articles/why-coop-coep#coop)", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "clickjacking-mitigation": { - "id": "clickjacking-mitigation", - "title": "Mitigate clickjacking with XFO or CSP", - "description": "The `X-Frame-Options` (XFO) header or the `frame-ancestors` directive in the `Content-Security-Policy` (CSP) header control where a page can be embedded. These can mitigate clickjacking attacks by blocking some or all sites from embedding the page. [Learn more about mitigating clickjacking](https://developer.chrome.com/docs/lighthouse/best-practices/clickjacking-mitigation).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "trusted-types-xss": { - "id": "trusted-types-xss", - "title": "Mitigate DOM-based XSS with Trusted Types", - "description": "The `require-trusted-types-for` directive in the `Content-Security-Policy` (CSP) header instructs user agents to control the data passed to DOM XSS sink functions. [Learn more about mitigating DOM-based XSS with Trusted Types](https://developer.chrome.com/docs/lighthouse/best-practices/trusted-types-xss).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "script-treemap-data": { - "id": "script-treemap-data", - "title": "Script Treemap Data", - "description": "Used for treemap app", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "accesskeys": { - "id": "accesskeys", - "title": "`[accesskey]` values are unique", - "description": "Access keys let users quickly focus a part of the page. For proper navigation, each access key must be unique. [Learn more about access keys](https://dequeuniversity.com/rules/axe/4.11/accesskeys).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-allowed-attr": { - "id": "aria-allowed-attr", - "title": "`[aria-*]` attributes match their roles", - "description": "Each ARIA `role` supports a specific subset of `aria-*` attributes. Mismatching these invalidates the `aria-*` attributes. [Learn how to match ARIA attributes to their roles](https://dequeuniversity.com/rules/axe/4.11/aria-allowed-attr).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-allowed-role": { - "id": "aria-allowed-role", - "title": "Uses ARIA roles only on compatible elements", - "description": "Many HTML elements can only be assigned certain ARIA roles. Using ARIA roles where they are not allowed can interfere with the accessibility of the web page. [Learn more about ARIA roles](https://dequeuniversity.com/rules/axe/4.11/aria-allowed-role).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-command-name": { - "id": "aria-command-name", - "title": "`button`, `link`, and `menuitem` elements have accessible names", - "description": "When an element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn how to make command elements more accessible](https://dequeuniversity.com/rules/axe/4.11/aria-command-name).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-conditional-attr": { - "id": "aria-conditional-attr", - "title": "ARIA attributes are used as specified for the element's role", - "description": "Some ARIA attributes are only allowed on an element under certain conditions. [Learn more about conditional ARIA attributes](https://dequeuniversity.com/rules/axe/4.11/aria-conditional-attr).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-deprecated-role": { - "id": "aria-deprecated-role", - "title": "Deprecated ARIA roles were not used", - "description": "Deprecated ARIA roles may not be processed correctly by assistive technology. [Learn more about deprecated ARIA roles](https://dequeuniversity.com/rules/axe/4.11/aria-deprecated-role).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-dialog-name": { - "id": "aria-dialog-name", - "title": "Elements with `role=\"dialog\"` or `role=\"alertdialog\"` have accessible names.", - "description": "ARIA dialog elements without accessible names may prevent screen readers users from discerning the purpose of these elements. [Learn how to make ARIA dialog elements more accessible](https://dequeuniversity.com/rules/axe/4.11/aria-dialog-name).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-hidden-body": { - "id": "aria-hidden-body", - "title": "`[aria-hidden=\"true\"]` is not present on the document ``", - "description": "Assistive technologies, like screen readers, work inconsistently when `aria-hidden=\"true\"` is set on the document ``. [Learn how `aria-hidden` affects the document body](https://dequeuniversity.com/rules/axe/4.11/aria-hidden-body).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-hidden-focus": { - "id": "aria-hidden-focus", - "title": "`[aria-hidden=\"true\"]` elements do not contain focusable descendents", - "description": "Focusable descendents within an `[aria-hidden=\"true\"]` element prevent those interactive elements from being available to users of assistive technologies like screen readers. [Learn how `aria-hidden` affects focusable elements](https://dequeuniversity.com/rules/axe/4.11/aria-hidden-focus).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-input-field-name": { - "id": "aria-input-field-name", - "title": "ARIA input fields have accessible names", - "description": "When an input field doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more about input field labels](https://dequeuniversity.com/rules/axe/4.11/aria-input-field-name).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-meter-name": { - "id": "aria-meter-name", - "title": "ARIA `meter` elements have accessible names", - "description": "When a meter element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn how to name `meter` elements](https://dequeuniversity.com/rules/axe/4.11/aria-meter-name).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-progressbar-name": { - "id": "aria-progressbar-name", - "title": "ARIA `progressbar` elements have accessible names", - "description": "When a `progressbar` element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn how to label `progressbar` elements](https://dequeuniversity.com/rules/axe/4.11/aria-progressbar-name).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-prohibited-attr": { - "id": "aria-prohibited-attr", - "title": "Elements use only permitted ARIA attributes", - "description": "Using ARIA attributes in roles where they are prohibited can mean that important information is not communicated to users of assistive technologies. [Learn more about prohibited ARIA roles](https://dequeuniversity.com/rules/axe/4.11/aria-prohibited-attr).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-required-attr": { - "id": "aria-required-attr", - "title": "`[role]`s have all required `[aria-*]` attributes", - "description": "Some ARIA roles have required attributes that describe the state of the element to screen readers. [Learn more about roles and required attributes](https://dequeuniversity.com/rules/axe/4.11/aria-required-attr).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-required-children": { - "id": "aria-required-children", - "title": "Elements with an ARIA `[role]` that require children to contain a specific `[role]` have all required children.", - "description": "Some ARIA parent roles must contain specific child roles to perform their intended accessibility functions. [Learn more about roles and required children elements](https://dequeuniversity.com/rules/axe/4.11/aria-required-children).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-required-parent": { - "id": "aria-required-parent", - "title": "`[role]`s are contained by their required parent element", - "description": "Some ARIA child roles must be contained by specific parent roles to properly perform their intended accessibility functions. [Learn more about ARIA roles and required parent element](https://dequeuniversity.com/rules/axe/4.11/aria-required-parent).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-roles": { - "id": "aria-roles", - "title": "`[role]` values are valid", - "description": "ARIA roles must have valid values in order to perform their intended accessibility functions. [Learn more about valid ARIA roles](https://dequeuniversity.com/rules/axe/4.11/aria-roles).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-text": { - "id": "aria-text", - "title": "Elements with the `role=text` attribute do not have focusable descendents.", - "description": "Adding `role=text` around a text node split by markup enables VoiceOver to treat it as one phrase, but the element's focusable descendents will not be announced. [Learn more about the `role=text` attribute](https://dequeuniversity.com/rules/axe/4.11/aria-text).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-toggle-field-name": { - "id": "aria-toggle-field-name", - "title": "ARIA toggle fields have accessible names", - "description": "When a toggle field doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more about toggle fields](https://dequeuniversity.com/rules/axe/4.11/aria-toggle-field-name).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-tooltip-name": { - "id": "aria-tooltip-name", - "title": "ARIA `tooltip` elements have accessible names", - "description": "When a tooltip element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn how to name `tooltip` elements](https://dequeuniversity.com/rules/axe/4.11/aria-tooltip-name).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-treeitem-name": { - "id": "aria-treeitem-name", - "title": "ARIA `treeitem` elements have accessible names", - "description": "When a `treeitem` element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more about labeling `treeitem` elements](https://dequeuniversity.com/rules/axe/4.11/aria-treeitem-name).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-valid-attr-value": { - "id": "aria-valid-attr-value", - "title": "`[aria-*]` attributes have valid values", - "description": "Assistive technologies, like screen readers, can't interpret ARIA attributes with invalid values. [Learn more about valid values for ARIA attributes](https://dequeuniversity.com/rules/axe/4.11/aria-valid-attr-value).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "aria-valid-attr": { - "id": "aria-valid-attr", - "title": "`[aria-*]` attributes are valid and not misspelled", - "description": "Assistive technologies, like screen readers, can't interpret ARIA attributes with invalid names. [Learn more about valid ARIA attributes](https://dequeuniversity.com/rules/axe/4.11/aria-valid-attr).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "button-name": { - "id": "button-name", - "title": "Buttons have an accessible name", - "description": "When a button doesn't have an accessible name, screen readers announce it as \"button\", making it unusable for users who rely on screen readers. [Learn how to make buttons more accessible](https://dequeuniversity.com/rules/axe/4.11/button-name).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "bypass": { - "id": "bypass", - "title": "The page contains a heading, skip link, or landmark region", - "description": "Adding ways to bypass repetitive content lets keyboard users navigate the page more efficiently. [Learn more about bypass blocks](https://dequeuniversity.com/rules/axe/4.11/bypass).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "color-contrast": { - "id": "color-contrast", - "title": "Background and foreground colors have a sufficient contrast ratio", - "description": "Low-contrast text is difficult or impossible for many users to read. [Learn how to provide sufficient color contrast](https://dequeuniversity.com/rules/axe/4.11/color-contrast).", - "score": null, - "scoreDisplayMode": "error", - "errorMessage": "The page did not paint any content. Please ensure you keep the browser window in the foreground during the load and try again. (NO_FCP)", - "errorStack": "LighthouseError: NO_FCP\n at Timeout. (file:///Users/azlife.eth/Tools/app/node_modules/.pnpm/lighthouse@13.0.1/node_modules/lighthouse/core/gather/driver/wait-for-condition.js:85:14)\n at listOnTimeout (node:internal/timers:608:17)\n at process.processTimers (node:internal/timers:543:7)" - }, - "definition-list": { - "id": "definition-list", - "title": "`
`'s contain only properly-ordered `
` and `
` groups, `