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('