From 30eee3fe8657b357125022f0c18dba1d06e29ccc Mon Sep 17 00:00:00 2001 From: Tim Kindberg Date: Fri, 27 Feb 2026 23:25:49 -0500 Subject: [PATCH 1/3] add CI workflow for running tests Co-authored-by: Claude --- .github/workflows/build.yaml | 18 ++++++++++++++++++ .gitignore | 1 + 2 files changed, 19 insertions(+) create mode 100644 .github/workflows/build.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..eae2698 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,18 @@ +name: Build + +on: + push: + branches: [main, master] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - run: bun install --frozen-lockfile + + - run: bun run test:run diff --git a/.gitignore b/.gitignore index 4e559e5..0cf4070 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ test-results/ playwright-report/ .vercel .env*.local +screenshots/ From 75cc5c389b2e0493d2e747a2c06c5f69d25e23a8 Mon Sep 17 00:00:00 2001 From: Tim Kindberg Date: Fri, 27 Feb 2026 23:32:03 -0500 Subject: [PATCH 2/3] add missing getDisplayName function to fix CI Co-authored-by: Claude --- src/lib/blobGenerator.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/lib/blobGenerator.ts b/src/lib/blobGenerator.ts index e6324dc..6bd5c87 100644 --- a/src/lib/blobGenerator.ts +++ b/src/lib/blobGenerator.ts @@ -92,6 +92,15 @@ function pick(arr: readonly T[], random: () => number): T { return arr[Math.floor(random() * arr.length)]!; } +/** + * Get a display-safe name: trimmed and truncated with ellipsis if over 10 chars + */ +export function getDisplayName(name: string, maxLength = 10): string { + const trimmed = name.trim(); + if (trimmed.length <= maxLength) return trimmed; + return trimmed.slice(0, maxLength - 1) + "…"; +} + /** * Generate a blob config from a player name */ From 62fc3da4624e6301a716a75d796f1b3209ec951b Mon Sep 17 00:00:00 2001 From: Tim Kindberg Date: Fri, 27 Feb 2026 23:47:16 -0500 Subject: [PATCH 3/3] fix CI: add missing exports and hoist vitest mocks - Add missing cleanup() to soundManager.ts - Add missing createErrorState() to errorMessages.ts - Use vi.hoisted() in soundManager tests for proper mock ordering Co-authored-by: Claude --- src/lib/errorMessages.ts | 13 ++++ src/lib/soundManager.ts | 13 ++++ tests/unit/soundManager.test.ts | 123 ++++++++++++++++---------------- 3 files changed, 89 insertions(+), 60 deletions(-) diff --git a/src/lib/errorMessages.ts b/src/lib/errorMessages.ts index d524c3b..cc61554 100644 --- a/src/lib/errorMessages.ts +++ b/src/lib/errorMessages.ts @@ -88,3 +88,16 @@ export function getFriendlyErrorMessage(error: unknown): string { // Otherwise, return the original message (it might already be readable) return rawMessage; } + +/** + * Creates a timestamped error state object from an error. + * Useful for storing error state in React components. + */ +export function createErrorState(error: unknown): { message: string; timestamp: number } { + return { + message: getFriendlyErrorMessage(error), + timestamp: Date.now(), + }; +} + + diff --git a/src/lib/soundManager.ts b/src/lib/soundManager.ts index 931cd69..fb72d99 100644 --- a/src/lib/soundManager.ts +++ b/src/lib/soundManager.ts @@ -43,6 +43,19 @@ const state: SoundManagerState = { // Event listeners for mute state changes const muteListeners = new Set<(muted: boolean) => void>(); +/** + * Reset the sound manager state. Useful for testing. + */ +export function cleanup(): void { + if (state.audioContext) { + state.audioContext.close().catch(() => {}); + state.audioContext = null; + } + state.isMuted = loadMuteState(); + state.isInitialized = false; + muteListeners.clear(); +} + /** * Load mute state from localStorage */ diff --git a/tests/unit/soundManager.test.ts b/tests/unit/soundManager.test.ts index c27c080..30cfc9e 100644 --- a/tests/unit/soundManager.test.ts +++ b/tests/unit/soundManager.test.ts @@ -8,72 +8,75 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; -// Mock localStorage -const localStorageMock = (() => { - let store: Record = {}; - return { - getItem: (key: string) => store[key] || null, - setItem: (key: string, value: string) => { - store[key] = value; - }, - removeItem: (key: string) => { - delete store[key]; - }, - clear: () => { - store = {}; - }, - }; -})(); - -// Mock AudioContext -class MockOscillator { - connect() { return this; } - start() {} - stop() {} - frequency = { setValueAtTime() {}, exponentialRampToValueAtTime() {}, linearRampToValueAtTime() {} }; - type = "sine"; -} - -class MockGainNode { - connect() { return this; } - gain = { setValueAtTime() {}, exponentialRampToValueAtTime() {}, linearRampToValueAtTime() {} }; -} - -class MockBiquadFilterNode { - connect() { return this; } - frequency = { setValueAtTime() {}, exponentialRampToValueAtTime() {} }; - type = "lowpass"; -} - -class MockAudioBufferSourceNode { - connect() { return this; } - start() {} - buffer: AudioBuffer | null = null; -} - -class MockAudioContext { - state = "running"; - currentTime = 0; - sampleRate = 44100; - destination = {}; - createOscillator() { return new MockOscillator(); } - createGain() { return new MockGainNode(); } - createBiquadFilter() { return new MockBiquadFilterNode(); } - createBufferSource() { return new MockAudioBufferSourceNode(); } - createBuffer(channels: number, length: number, sampleRate: number) { +// Mocks must be hoisted above imports — vi.hoisted runs before module evaluation +const { localStorageMock, MockAudioContext } = vi.hoisted(() => { + const localStorageMock = (() => { + let store: Record = {}; return { - getChannelData: () => new Float32Array(length), - } as unknown as AudioBuffer; + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + }; + })(); + + class MockOscillator { + connect() { return this; } + start() {} + stop() {} + frequency = { setValueAtTime() {}, exponentialRampToValueAtTime() {}, linearRampToValueAtTime() {} }; + type = "sine"; } - resume() { return Promise.resolve(); } - close() { return Promise.resolve(); } -} -// Setup mocks before importing the module + class MockGainNode { + connect() { return this; } + gain = { setValueAtTime() {}, exponentialRampToValueAtTime() {}, linearRampToValueAtTime() {} }; + } + + class MockBiquadFilterNode { + connect() { return this; } + frequency = { setValueAtTime() {}, exponentialRampToValueAtTime() {} }; + type = "lowpass"; + } + + class MockAudioBufferSourceNode { + connect() { return this; } + start() {} + buffer: AudioBuffer | null = null; + } + + class MockAudioContext { + state = "running"; + currentTime = 0; + sampleRate = 44100; + destination = {}; + createOscillator() { return new MockOscillator(); } + createGain() { return new MockGainNode(); } + createBiquadFilter() { return new MockBiquadFilterNode(); } + createBufferSource() { return new MockAudioBufferSourceNode(); } + createBuffer(channels: number, length: number, sampleRate: number) { + return { + getChannelData: () => new Float32Array(length), + } as unknown as AudioBuffer; + } + resume() { return Promise.resolve(); } + close() { return Promise.resolve(); } + } + + return { localStorageMock, MockAudioContext }; +}); + +// These run in hoisted order, before the import below vi.stubGlobal("localStorage", localStorageMock); vi.stubGlobal("AudioContext", MockAudioContext); -// Now import the module +// Now the import sees the mocked globals during module evaluation import { isMuted, setMuted,