From cb5a8004450cc34d6a7dfccaab0dbd6d5cb2db80 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sun, 26 Jul 2026 23:17:58 -0700 Subject: [PATCH] test(app): add bun test infra and first unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bun test as the runner — already the toolchain, zero new deps beyond @types/bun. 54 tests across 5 files covering the already-pure logic: FastAPI error normalization (extracted verbatim to lib/api/errors.ts), clip trim clamping (extracted from StoryTrackEditor to lib/utils/trim.ts, magic 100 now MIN_CLIP_DURATION_MS), duration/size/engine formatters, engine-language map consistency, and changelog parsing. Wired into CI after typecheck. Known issues surfaced by the tests, left as-is for now: parseChangelog's heading regex uses \s* which matches newlines, so a dateless heading followed directly by a bullet swallows that bullet; formatFileSize has no TB unit; ENGINE_DISPLAY_NAMES lacks tada/kokoro. --- .github/workflows/ci.yml | 3 + app/package.json | 2 + .../StoriesTab/StoryTrackEditor.tsx | 43 ++----- app/src/lib/api/client.ts | 16 +-- app/src/lib/api/errors.test.ts | 58 ++++++++++ app/src/lib/api/errors.ts | 21 ++++ app/src/lib/constants/languages.test.ts | 70 ++++++++++++ app/src/lib/utils/format.test.ts | 71 ++++++++++++ app/src/lib/utils/parseChangelog.test.ts | 69 ++++++++++++ app/src/lib/utils/trim.test.ts | 106 ++++++++++++++++++ app/src/lib/utils/trim.ts | 57 ++++++++++ app/tsconfig.json | 2 +- bun.lock | 5 + package.json | 1 + 14 files changed, 475 insertions(+), 49 deletions(-) create mode 100644 app/src/lib/api/errors.test.ts create mode 100644 app/src/lib/api/errors.ts create mode 100644 app/src/lib/constants/languages.test.ts create mode 100644 app/src/lib/utils/format.test.ts create mode 100644 app/src/lib/utils/parseChangelog.test.ts create mode 100644 app/src/lib/utils/trim.test.ts create mode 100644 app/src/lib/utils/trim.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14149ab3..2453cdce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,9 @@ jobs: - name: Typecheck app + web run: bun run typecheck + - name: Unit tests + run: bun run test + - name: Build web smoke test run: bun run build:web diff --git a/app/package.json b/app/package.json index 56bf162a..bed30691 100644 --- a/app/package.json +++ b/app/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "bun test", "preview": "vite preview", "lint": "biome lint src", "lint:fix": "biome lint --write src", @@ -60,6 +61,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", + "@types/bun": "^1.3.4", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index 1b945d15..606cf1cb 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -38,6 +38,7 @@ import { useUpdateStoryItemVolume, } from '@/lib/hooks/useStories'; import { cn } from '@/lib/utils/cn'; +import { computeTrimValues } from '@/lib/utils/trim'; import { useGenerationStore } from '@/stores/generationStore'; import { useStoryStore } from '@/stores/storyStore'; @@ -600,41 +601,17 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const deltaMs = pixelsToMs(deltaX); // Signed delta in milliseconds const { item, initialTrimStart, initialTrimEnd } = trimStartItemRef.current; - const originalDurationMs = item.duration * 1000; - - let newTrimStart = initialTrimStart; - let newTrimEnd = initialTrimEnd; - - if (trimSide === 'start') { - // Moving right increases trim_start (trims more from start) - // Moving left decreases trim_start (restores from start) - newTrimStart = Math.round( - Math.max( - 0, - Math.min(initialTrimStart + deltaMs, originalDurationMs - initialTrimEnd - 100), - ), - ); - } else { - // Moving right decreases trim_end (restores from end) - // Moving left increases trim_end (trims more from end) - newTrimEnd = Math.round( - Math.max( - 0, - Math.min(initialTrimEnd - deltaMs, originalDurationMs - initialTrimStart - 100), - ), - ); - } - - // Validate that we don't exceed duration - if (newTrimStart + newTrimEnd >= originalDurationMs - 100) { - return; // Don't allow trimming to less than 100ms - } + const newTrimValues = computeTrimValues( + trimSide, + deltaMs, + initialTrimStart, + initialTrimEnd, + item.duration * 1000, + ); + if (!newTrimValues) return; // Update temporary trim values for visual feedback - setTempTrimValues({ - trim_start_ms: newTrimStart, - trim_end_ms: newTrimEnd, - }); + setTempTrimValues(newTrimValues); }, [trimmingItem, trimSide, trimStartX, pixelsToMs], ); diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts index f89a17a3..cf25a6f7 100644 --- a/app/src/lib/api/client.ts +++ b/app/src/lib/api/client.ts @@ -1,4 +1,5 @@ import type { LanguageCode } from '@/lib/constants/languages'; +import { formatErrorDetail } from '@/lib/api/errors'; import { useServerStore } from '@/stores/serverStore'; import type { ActiveTasksResponse, @@ -55,21 +56,6 @@ import type { CloudStatus, } from './types'; -function formatErrorDetail(detail: unknown, fallback: string): string { - if (typeof detail === 'string') return detail; - if (Array.isArray(detail)) { - return detail - .map((e: Record) => e.msg || e.message || JSON.stringify(e)) - .join('; '); - } - if (detail && typeof detail === 'object') { - const obj = detail as Record; - if (typeof obj.message === 'string') return obj.message; - return JSON.stringify(detail); - } - return fallback; -} - class ApiClient { private getBaseUrl(): string { const serverUrl = useServerStore.getState().serverUrl; diff --git a/app/src/lib/api/errors.test.ts b/app/src/lib/api/errors.test.ts new file mode 100644 index 00000000..6ba67725 --- /dev/null +++ b/app/src/lib/api/errors.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test'; +import { formatErrorDetail } from './errors'; + +const FALLBACK = 'HTTP error! status: 500'; + +describe('formatErrorDetail', () => { + test('returns string details as-is', () => { + expect(formatErrorDetail('Profile not found', FALLBACK)).toBe('Profile not found'); + }); + + test('returns empty string details as-is (not the fallback)', () => { + expect(formatErrorDetail('', FALLBACK)).toBe(''); + }); + + test('joins FastAPI validation error arrays on msg', () => { + const detail = [ + { loc: ['body', 'text'], msg: 'field required', type: 'value_error.missing' }, + { loc: ['body', 'seed'], msg: 'value is not a valid integer', type: 'type_error.integer' }, + ]; + expect(formatErrorDetail(detail, FALLBACK)).toBe( + 'field required; value is not a valid integer', + ); + }); + + test('falls back to message key within array entries', () => { + expect(formatErrorDetail([{ message: 'boom' }], FALLBACK)).toBe('boom'); + }); + + test('stringifies array entries with neither msg nor message', () => { + expect(formatErrorDetail([{ code: 42 }], FALLBACK)).toBe('{"code":42}'); + }); + + test('returns empty string for an empty array', () => { + expect(formatErrorDetail([], FALLBACK)).toBe(''); + }); + + test('uses message property of object details', () => { + expect(formatErrorDetail({ message: 'engine offline' }, FALLBACK)).toBe('engine offline'); + }); + + test('stringifies objects without a string message', () => { + expect(formatErrorDetail({ message: 42, hint: 'x' }, FALLBACK)).toBe( + '{"message":42,"hint":"x"}', + ); + expect(formatErrorDetail({ error: 'nested' }, FALLBACK)).toBe('{"error":"nested"}'); + }); + + test('falls back for null, undefined, and primitives', () => { + expect(formatErrorDetail(null, FALLBACK)).toBe(FALLBACK); + expect(formatErrorDetail(undefined, FALLBACK)).toBe(FALLBACK); + expect(formatErrorDetail(404, FALLBACK)).toBe(FALLBACK); + expect(formatErrorDetail(true, FALLBACK)).toBe(FALLBACK); + }); + + test('preserves unicode in messages', () => { + expect(formatErrorDetail('模型未加载 🎙️', FALLBACK)).toBe('模型未加载 🎙️'); + }); +}); diff --git a/app/src/lib/api/errors.ts b/app/src/lib/api/errors.ts new file mode 100644 index 00000000..4f24bcbe --- /dev/null +++ b/app/src/lib/api/errors.ts @@ -0,0 +1,21 @@ +/** + * Normalizes a FastAPI error `detail` payload into a human-readable message. + * + * FastAPI returns `detail` as a plain string for HTTPException, an array of + * validation error objects for 422 responses, or an arbitrary object for + * custom handlers. Anything unrecognized falls back to the provided default. + */ +export function formatErrorDetail(detail: unknown, fallback: string): string { + if (typeof detail === 'string') return detail; + if (Array.isArray(detail)) { + return detail + .map((e: Record) => e.msg || e.message || JSON.stringify(e)) + .join('; '); + } + if (detail && typeof detail === 'object') { + const obj = detail as Record; + if (typeof obj.message === 'string') return obj.message; + return JSON.stringify(detail); + } + return fallback; +} diff --git a/app/src/lib/constants/languages.test.ts b/app/src/lib/constants/languages.test.ts new file mode 100644 index 00000000..f28d020e --- /dev/null +++ b/app/src/lib/constants/languages.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test'; +import { + ALL_LANGUAGES, + ENGINE_LANGUAGES, + LANGUAGE_CODES, + LANGUAGE_OPTIONS, + getLanguageOptionsForEngine, +} from './languages'; + +describe('ENGINE_LANGUAGES', () => { + test('every engine maps only to codes defined in ALL_LANGUAGES', () => { + for (const [engine, codes] of Object.entries(ENGINE_LANGUAGES)) { + for (const code of codes) { + expect(ALL_LANGUAGES[code], `${engine} references unknown code "${code}"`).toBeDefined(); + } + } + }); + + test('no engine lists a language twice', () => { + for (const [engine, codes] of Object.entries(ENGINE_LANGUAGES)) { + expect(new Set(codes).size, `${engine} has duplicate codes`).toBe(codes.length); + } + }); + + test('every engine supports at least English', () => { + for (const codes of Object.values(ENGINE_LANGUAGES)) { + expect(codes).toContain('en'); + } + }); + + test('English-only engines list exactly one language', () => { + expect(ENGINE_LANGUAGES.luxtts).toEqual(['en']); + expect(ENGINE_LANGUAGES.chatterbox_turbo).toEqual(['en']); + }); + + test('qwen and qwen_custom_voice support the same languages', () => { + expect(ENGINE_LANGUAGES.qwen_custom_voice).toEqual(ENGINE_LANGUAGES.qwen); + }); +}); + +describe('getLanguageOptionsForEngine', () => { + test('builds value/label pairs from ALL_LANGUAGES', () => { + expect(getLanguageOptionsForEngine('luxtts')).toEqual([{ value: 'en', label: 'English' }]); + }); + + test('preserves the engine declaration order', () => { + const values = getLanguageOptionsForEngine('qwen').map((o) => o.value); + expect(values).toEqual([...ENGINE_LANGUAGES.qwen]); + }); + + test('falls back to qwen languages for unknown engines', () => { + expect(getLanguageOptionsForEngine('does-not-exist')).toEqual( + getLanguageOptionsForEngine('qwen'), + ); + }); +}); + +describe('language option exports', () => { + test('LANGUAGE_CODES covers every ALL_LANGUAGES key exactly once', () => { + const codes: string[] = [...LANGUAGE_CODES].sort(); + expect(codes).toEqual(Object.keys(ALL_LANGUAGES).sort()); + expect(new Set(LANGUAGE_CODES).size).toBe(LANGUAGE_CODES.length); + }); + + test('LANGUAGE_OPTIONS labels match ALL_LANGUAGES', () => { + for (const option of LANGUAGE_OPTIONS) { + expect(option.label).toBe(ALL_LANGUAGES[option.value]); + } + }); +}); diff --git a/app/src/lib/utils/format.test.ts b/app/src/lib/utils/format.test.ts new file mode 100644 index 00000000..eba7952a --- /dev/null +++ b/app/src/lib/utils/format.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test'; +import { formatDuration, formatEngineName, formatFileSize } from './format'; + +describe('formatDuration', () => { + test('formats zero', () => { + expect(formatDuration(0)).toBe('0:00'); + }); + + test('pads single-digit seconds', () => { + expect(formatDuration(65)).toBe('1:05'); + }); + + test('handles the minute boundary', () => { + expect(formatDuration(59)).toBe('0:59'); + expect(formatDuration(60)).toBe('1:00'); + }); + + test('floors fractional seconds', () => { + expect(formatDuration(89.9)).toBe('1:29'); + }); + + test('does not roll minutes into hours', () => { + expect(formatDuration(3661)).toBe('61:01'); + }); +}); + +describe('formatFileSize', () => { + test('special-cases zero', () => { + expect(formatFileSize(0)).toBe('0 Bytes'); + }); + + test('formats bytes below 1 KB', () => { + expect(formatFileSize(512)).toBe('512 Bytes'); + expect(formatFileSize(1023)).toBe('1023 Bytes'); + }); + + test('formats KB, MB, and GB boundaries', () => { + expect(formatFileSize(1024)).toBe('1 KB'); + expect(formatFileSize(1024 ** 2)).toBe('1 MB'); + expect(formatFileSize(1024 ** 3)).toBe('1 GB'); + }); + + test('rounds to two decimal places', () => { + expect(formatFileSize(1536)).toBe('1.5 KB'); + expect(formatFileSize(2_684_354_560)).toBe('2.5 GB'); + expect(formatFileSize(1_234_567)).toBe('1.18 MB'); + }); +}); + +describe('formatEngineName', () => { + test('maps known engines to display names', () => { + expect(formatEngineName('luxtts')).toBe('LuxTTS'); + expect(formatEngineName('chatterbox')).toBe('Chatterbox'); + expect(formatEngineName('chatterbox_turbo')).toBe('Chatterbox Turbo'); + }); + + test('defaults to Qwen when engine is undefined', () => { + expect(formatEngineName()).toBe('Qwen'); + expect(formatEngineName(undefined, '1.7B')).toBe('Qwen'); + }); + + test('appends the model size for qwen only', () => { + expect(formatEngineName('qwen', '1.7B')).toBe('Qwen 1.7B'); + expect(formatEngineName('qwen')).toBe('Qwen'); + expect(formatEngineName('luxtts', '1.7B')).toBe('LuxTTS'); + }); + + test('passes unknown engines through verbatim', () => { + expect(formatEngineName('kokoro')).toBe('kokoro'); + }); +}); diff --git a/app/src/lib/utils/parseChangelog.test.ts b/app/src/lib/utils/parseChangelog.test.ts new file mode 100644 index 00000000..a4decc9f --- /dev/null +++ b/app/src/lib/utils/parseChangelog.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'bun:test'; +import { parseChangelog } from './parseChangelog'; + +const SAMPLE = `# Changelog + +All notable changes to this project will be documented in this file. + +## [0.5.0] - 2026-06-01 + +### Added + +- Story track editor +- Cloud login + +## [0.4.1] + +### Fixed + +- Trim clamping + +## [0.4.0] - 2026-04-15 + +Initial public release. + +[0.5.0]: https://example.com/compare/v0.4.1...v0.5.0 +[0.4.1]: https://example.com/compare/v0.4.0...v0.4.1 +`; + +describe('parseChangelog', () => { + test('splits entries on version headings', () => { + const entries = parseChangelog(SAMPLE); + expect(entries.map((e) => e.version)).toEqual(['0.5.0', '0.4.1', '0.4.0']); + }); + + test('extracts the date when present and null otherwise', () => { + const entries = parseChangelog(SAMPLE); + expect(entries[0].date).toBe('2026-06-01'); + expect(entries[1].date).toBeNull(); + }); + + test('keeps the markdown body between headings', () => { + const entries = parseChangelog(SAMPLE); + expect(entries[0].body).toBe('### Added\n\n- Story track editor\n- Cloud login'); + expect(entries[2].body).toBe('Initial public release.'); + }); + + test('strips trailing link reference definitions from the last body', () => { + const entries = parseChangelog(SAMPLE); + expect(entries[2].body).toBe('Initial public release.'); + expect(entries[2].body).not.toContain('example.com'); + }); + + test('returns an empty array when no headings match', () => { + expect(parseChangelog('')).toEqual([]); + expect(parseChangelog('# Changelog\n\nNothing yet.')).toEqual([]); + }); + + test('handles a heading with an empty body', () => { + const entries = parseChangelog('## [1.0.0] - 2026-01-01\n'); + expect(entries).toEqual([{ version: '1.0.0', date: '2026-01-01', body: '' }]); + }); + + test('accepts non-semver headings like Unreleased', () => { + const entries = parseChangelog('## [Unreleased]\n\n### Added\n\n- WIP\n'); + expect(entries[0].version).toBe('Unreleased'); + expect(entries[0].date).toBeNull(); + expect(entries[0].body).toBe('### Added\n\n- WIP'); + }); +}); diff --git a/app/src/lib/utils/trim.test.ts b/app/src/lib/utils/trim.test.ts new file mode 100644 index 00000000..dc19f338 --- /dev/null +++ b/app/src/lib/utils/trim.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from 'bun:test'; +import { MIN_CLIP_DURATION_MS, computeTrimValues } from './trim'; + +// A 10-second clip with no existing trims unless stated otherwise. +const DURATION = 10_000; + +describe('computeTrimValues', () => { + describe('start handle', () => { + test('dragging right trims from the start', () => { + expect(computeTrimValues('start', 500, 0, 0, DURATION)).toEqual({ + trim_start_ms: 500, + trim_end_ms: 0, + }); + }); + + test('dragging left restores previously trimmed audio', () => { + expect(computeTrimValues('start', -300, 1000, 0, DURATION)).toEqual({ + trim_start_ms: 700, + trim_end_ms: 0, + }); + }); + + test('clamps at zero when restoring past the clip start', () => { + expect(computeTrimValues('start', -5000, 1000, 0, DURATION)).toEqual({ + trim_start_ms: 0, + trim_end_ms: 0, + }); + }); + + test('never trims below the minimum clip duration', () => { + const result = computeTrimValues('start', 99_999, 0, 2000, DURATION); + // Clamp lands exactly on the minimum, which the guard rejects. + expect(result).toBeNull(); + }); + + test('rounds fractional millisecond deltas', () => { + expect(computeTrimValues('start', 100.6, 0, 0, DURATION)).toEqual({ + trim_start_ms: 101, + trim_end_ms: 0, + }); + }); + + test('preserves the untouched end trim', () => { + expect(computeTrimValues('start', 250, 0, 400, DURATION)).toEqual({ + trim_start_ms: 250, + trim_end_ms: 400, + }); + }); + }); + + describe('end handle', () => { + test('dragging left trims from the end', () => { + expect(computeTrimValues('end', -500, 0, 0, DURATION)).toEqual({ + trim_start_ms: 0, + trim_end_ms: 500, + }); + }); + + test('dragging right restores previously trimmed audio', () => { + expect(computeTrimValues('end', 300, 0, 1000, DURATION)).toEqual({ + trim_start_ms: 0, + trim_end_ms: 700, + }); + }); + + test('clamps at zero when restoring past the clip end', () => { + expect(computeTrimValues('end', 5000, 0, 1000, DURATION)).toEqual({ + trim_start_ms: 0, + trim_end_ms: 0, + }); + }); + + test('never trims below the minimum clip duration', () => { + expect(computeTrimValues('end', -99_999, 3000, 0, DURATION)).toBeNull(); + }); + }); + + describe('minimum duration guard', () => { + test('rejects drags that leave less than the minimum audible clip', () => { + // 9.5s already trimmed; taking 450ms more leaves only 50ms. + expect(computeTrimValues('start', 450, 5000, 4500, DURATION)).toBeNull(); + }); + + test('allows a drag that leaves just over the minimum', () => { + expect(computeTrimValues('start', 399, 5000, 4500, DURATION)).toEqual({ + trim_start_ms: 5399, + trim_end_ms: 4500, + }); + }); + + test('boundary: exactly the minimum remaining is rejected', () => { + // trim_start + trim_end === duration - MIN_CLIP_DURATION_MS + expect( + computeTrimValues('start', 400, 5000, 4500, DURATION)?.trim_start_ms ?? null, + ).toBeNull(); + expect(MIN_CLIP_DURATION_MS).toBe(100); + }); + }); + + test('zero delta is a no-op that returns the initial trims', () => { + expect(computeTrimValues('start', 0, 1200, 800, DURATION)).toEqual({ + trim_start_ms: 1200, + trim_end_ms: 800, + }); + }); +}); diff --git a/app/src/lib/utils/trim.ts b/app/src/lib/utils/trim.ts new file mode 100644 index 00000000..506d9d0c --- /dev/null +++ b/app/src/lib/utils/trim.ts @@ -0,0 +1,57 @@ +import type { StoryItemTrim } from '@/lib/api/types'; + +/** Clips are never allowed to shrink below this effective duration. */ +export const MIN_CLIP_DURATION_MS = 100; + +/** + * Computes new trim values for a clip while a trim handle is being dragged. + * + * `deltaMs` is the signed drag distance converted to milliseconds. Dragging + * the start handle right increases `trim_start_ms` (trims more from the + * start); dragging it left restores. The end handle mirrors this for + * `trim_end_ms`. Both values are clamped so the clip keeps at least + * MIN_CLIP_DURATION_MS of audible content. + * + * Returns null when the drag would leave less than the minimum duration. + */ +export function computeTrimValues( + side: 'start' | 'end', + deltaMs: number, + initialTrimStart: number, + initialTrimEnd: number, + originalDurationMs: number, +): StoryItemTrim | null { + let newTrimStart = initialTrimStart; + let newTrimEnd = initialTrimEnd; + + if (side === 'start') { + newTrimStart = Math.round( + Math.max( + 0, + Math.min( + initialTrimStart + deltaMs, + originalDurationMs - initialTrimEnd - MIN_CLIP_DURATION_MS, + ), + ), + ); + } else { + newTrimEnd = Math.round( + Math.max( + 0, + Math.min( + initialTrimEnd - deltaMs, + originalDurationMs - initialTrimStart - MIN_CLIP_DURATION_MS, + ), + ), + ); + } + + if (newTrimStart + newTrimEnd >= originalDurationMs - MIN_CLIP_DURATION_MS) { + return null; + } + + return { + trim_start_ms: newTrimStart, + trim_end_ms: newTrimEnd, + }; +} diff --git a/app/tsconfig.json b/app/tsconfig.json index 2ec9c540..b8db0cab 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -25,7 +25,7 @@ "paths": { "@/*": ["./src/*"] }, - "types": ["vite/client"] + "types": ["vite/client", "bun"] }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/bun.lock b/bun.lock index 74d751ec..f4da76a3 100644 --- a/bun.lock +++ b/bun.lock @@ -65,6 +65,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", + "@types/bun": "^1.3.4", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", @@ -611,6 +612,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], @@ -685,6 +688,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], diff --git a/package.json b/package.json index 34ce1253..8a0868bd 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "update:icons": "./scripts/update-icons.sh", "convert:assets": "./scripts/convert-assets.sh", "lint": "biome lint .", + "test": "cd app && bun test", "typecheck": "bunx tsc -p app/tsconfig.json --noEmit && cd web && bunx tsc --noEmit", "lint:fix": "biome lint --write .", "format": "biome format --write .",