From 41193b25a41bcffcc06a7fddfc382ee701e8c056 Mon Sep 17 00:00:00 2001 From: jedarden Date: Sun, 24 May 2026 23:04:58 -0400 Subject: [PATCH] fix(web): distinguish 404 from generic errors in replay viewer Updates showLoadError to display a friendly "not available yet" message for HTTP 404 errors (common for unuploaded replays) vs generic "could not load" for other HTTP errors. Adds the URL to error output and maintains HTML escaping. Also adds vitest testing infrastructure with 5 tests covering: - 404 not-found message - Generic HTTP error message - Parse error handling - HTML escaping (XSS protection) - 404 vs error distinction Closes: bf-5cwi --- web/package.json | 9 +- web/src/pages/replay.test.ts | 242 +++++++++++++++++++++++++++++++++++ web/src/pages/replay.ts | 26 +++- web/src/test-setup.ts | 17 +++ web/tsconfig.json | 3 +- web/vitest.config.ts | 10 ++ 6 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 web/src/pages/replay.test.ts create mode 100644 web/src/test-setup.ts create mode 100644 web/vitest.config.ts diff --git a/web/package.json b/web/package.json index c9f8f41..bf2459b 100644 --- a/web/package.json +++ b/web/package.json @@ -5,15 +5,20 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run" }, "devDependencies": { "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "agentation": "^3.0.2", + "jsdom": "^25.0.1", "react": "^19.2.5", "react-dom": "^19.2.5", "typescript": "^5.0.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^3.0.5" } } diff --git a/web/src/pages/replay.test.ts b/web/src/pages/replay.test.ts new file mode 100644 index 0000000..4592d04 --- /dev/null +++ b/web/src/pages/replay.test.ts @@ -0,0 +1,242 @@ +/** + * Unit tests for replay page error handling. + * Tests the 404 vs generic error distinction in the URL load flow. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +declare global { + // eslint-disable-next-line no-var + var fetch: any; +} + +describe('replay.ts error handling (URL load button)', () => { + beforeEach(() => { + // Setup DOM environment + document.body.innerHTML = '
'; + globalThis.fetch = vi.fn(); + + // Mock canvas context to avoid ReplayViewer errors + HTMLCanvasElement.prototype.getContext = vi.fn(() => ({ + fillRect: vi.fn(), + clearRect: vi.fn(), + getImageData: vi.fn(), + putImageData: vi.fn(), + createImageData: vi.fn(), + setTransform: vi.fn(), + resetTransform: vi.fn(), + drawImage: vi.fn(), + save: vi.fn(), + fillText: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + closePath: vi.fn(), + stroke: vi.fn(), + translate: vi.fn(), + scale: vi.fn(), + rotate: vi.fn(), + arc: vi.fn(), + fill: vi.fn(), + measureText: vi.fn(() => ({ width: 0 })), + transform: vi.fn(), + rect: vi.fn(), + clip: vi.fn(), + })) as any; + }); + + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('should show friendly 404 message when replay not found', async () => { + const testUrl = 'https://example.com/replays/missing.json'; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}), + } as Response); + + // Import and initialize WITHOUT URL (to enable manual loading) + const { renderReplayPage } = await import('./replay'); + renderReplayPage({}); + + // Wait for async module loading + await new Promise(resolve => setTimeout(resolve, 100)); + + // Get the URL input and load button + const urlInput = document.getElementById('url-input') as HTMLInputElement; + const loadBtn = document.getElementById('load-url-btn') as HTMLButtonElement; + + expect(urlInput).toBeTruthy(); + expect(loadBtn).toBeTruthy(); + + // Enter URL and click load + urlInput.value = testUrl; + loadBtn.click(); + + // Wait for fetch + await new Promise(resolve => setTimeout(resolve, 50)); + + const noReplayDiv = document.getElementById('no-replay') as HTMLElement; + expect(noReplayDiv).toBeTruthy(); + const html = noReplayDiv.innerHTML; + expect(html).toContain('not available yet'); + expect(html).toContain('may not have been uploaded'); + expect(html).toContain(testUrl); + }); + + it('should show generic error message for non-404 HTTP errors', async () => { + const testUrl = 'https://example.com/replays/error.json'; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({}), + } as Response); + + const { renderReplayPage } = await import('./replay'); + renderReplayPage({}); + + await new Promise(resolve => setTimeout(resolve, 100)); + + const urlInput = document.getElementById('url-input') as HTMLInputElement; + const loadBtn = document.getElementById('load-url-btn') as HTMLButtonElement; + urlInput.value = testUrl; + loadBtn.click(); + + await new Promise(resolve => setTimeout(resolve, 50)); + + const noReplayDiv = document.getElementById('no-replay') as HTMLElement; + expect(noReplayDiv).toBeTruthy(); + const html = noReplayDiv.innerHTML; + expect(html).toContain('Could not load this replay'); + expect(html).toContain('HTTP 500'); + expect(html).toContain(testUrl); + }); + + it('should show parse error for invalid JSON', async () => { + const testUrl = 'https://example.com/replays/bad.json'; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError('Unexpected token < in JSON'); + }, + headers: new Headers(), + redirected: false, + statusText: 'OK', + type: 'basic', + url: testUrl, + clone: () => ({} as Response), + body: null, + bodyUsed: false, + arrayBuffer: async () => new ArrayBuffer(0), + blob: async () => ({} as Blob), + formData: async () => ({} as FormData), + text: async () => '', + } as unknown as Response); + + const { renderReplayPage } = await import('./replay'); + renderReplayPage({}); + + await new Promise(resolve => setTimeout(resolve, 100)); + + const urlInput = document.getElementById('url-input') as HTMLInputElement; + const loadBtn = document.getElementById('load-url-btn') as HTMLButtonElement; + urlInput.value = testUrl; + loadBtn.click(); + + await new Promise(resolve => setTimeout(resolve, 50)); + + const noReplayDiv = document.getElementById('no-replay') as HTMLElement; + expect(noReplayDiv).toBeTruthy(); + const html = noReplayDiv.innerHTML; + // Check for parse-related message + expect(html).toMatch(/parse|json/i); + }); + + it('should escape HTML in error messages', async () => { + const testUrl = 'https://example.com/replays/.json'; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}), + } as Response); + + const { renderReplayPage } = await import('./replay'); + renderReplayPage({}); + + await new Promise(resolve => setTimeout(resolve, 100)); + + const urlInput = document.getElementById('url-input') as HTMLInputElement; + const loadBtn = document.getElementById('load-url-btn') as HTMLButtonElement; + urlInput.value = testUrl; + loadBtn.click(); + + await new Promise(resolve => setTimeout(resolve, 50)); + + const noReplayDiv = document.getElementById('no-replay') as HTMLElement; + const html = noReplayDiv.innerHTML; + expect(html).toContain('<script>'); + expect(html).not.toContain('