diff --git a/AGENTS.md b/AGENTS.md index 3b73c92..b38da0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -258,6 +258,68 @@ pass with the simplest code, then refactor under green. the solver, sensor math, serialization shape) can be exercised without real media, hardware, or a window. +### Testing pyramid + +Keep the suite bottom-heavy. Choose the lowest layer that can prove the behavior; move upward only +when the risk depends on framework, bridge, browser, or operating-system integration. + +1. **Unit / pure-seam tests (most tests).** Exercise `lib/core/*`, reducers, parsers, schedulers, + geometry/math helpers, serialization contracts, and Rust pure seams directly. These should be + fast, deterministic, exhaustive around meaningful boundaries, and require no React, Tauri, + filesystem, network, clock, or hardware unless that dependency is the subject of the seam. +2. **Component / integration tests (fewer tests).** Use Testing Library for observable React + behavior and focused adapter tests for store, filesystem, event, or command orchestration. Mock + at the outer boundary (for example Tauri `invoke`/`listen`), not between internal collaborators, + and verify the data or UI that crosses the boundary. +3. **E2E tests (fewest tests).** Use Playwright for critical studio/overlay journeys whose value + comes from real browser layout, focus, pointer/keyboard interaction, persistence wiring, or role + behavior. Cover representative happy paths and high-impact regressions; do not duplicate every + pure or component-level permutation in E2E. +4. **Manual / hardware smoke checks (exceptional).** Reserve these for Windows APIs, real media, + GPU/audio/display hardware, or destructive/external integrations that cannot be made + deterministic. Document the procedure and keep automated pure seams underneath it. Expensive or + intrusive Rust smoke tests must stay explicitly `#[ignore]` with a reason. + +If a behavior can be tested at two layers, prefer the lower layer and keep at most one higher-level +test to prove the wiring. A healthy pyramid has many tiny domain tests, a smaller set of component +and adapter tests, and a compact E2E suite—not the same assertions repeated at every layer. + +### Write high-value tests + +A test is valuable when it would fail for a plausible user-visible regression and clearly explain +what contract broke. Optimize for defect detection and confidence, not test count or incidental +coverage. + +- **Assert behavior and contracts, not implementation.** Prefer returned values, persisted JSON, + emitted bridge shapes, store snapshots, rendered text/roles, and user interactions. Avoid testing + private helpers through mocks, exact call sequences that are not contractual, DOM structure added + only for styling, or broad snapshots that accept unrelated churn. +- **Prove the risk that motivated the change.** A bug test should fail before the fix for the right + reason. For races and lifecycle bugs, control ordering and assert the harmful outcome cannot occur + (stale overwrite, duplicate listener, overlapping request, mutation of an old snapshot, leaked + demand). For bridge changes, assert both serialization shape and the matching consumer contract. +- **Cover meaningful partitions, not permutations.** Usually test the representative success path, + important boundary values, and distinct failure/recovery paths. Use table-driven tests when the + same rule has several inputs; do not multiply tests for equivalent cases merely to raise coverage. +- **Keep tests deterministic and isolated.** Control time, randomness, async completion, filesystem + paths, and event order. Never depend on the public network, local user state, real hardware, test + execution order, or arbitrary sleeps. Await async work and restore timers, listeners, registries, + stores, mocks, and temporary files during cleanup. +- **Use the smallest realistic fixture.** Include only the fields needed to expose the behavior, + while keeping fixtures valid according to the real Rust↔TypeScript or layout/widget contract. + Prefer builders and focused examples over giant production dumps. +- **Keep assertions precise but resilient.** Assert all outputs that define the contract and no + irrelevant formatting/order unless it is itself required. Ensure error-path tests verify both the + error and the safety property—for example that corrupt input does not overwrite a valid file. +- **Avoid low-value tests.** Do not add tests that only prove a mock returned its configured value, + repeat TypeScript's type checking, exercise framework/library behavior, or mirror the source line + by line. Delete or consolidate redundant tests when a stronger test subsumes them. +- **Treat test diagnostics as failures to investigate.** Unexpected React `act` warnings, unhandled + rejections, console errors, leaked timers, and post-test state updates indicate an incomplete or + racy test even if the runner exits successfully. Fix the lifecycle or await the work; suppress a + diagnostic only when it is a narrowly identified environment artifact and keep other errors + visible. + `state.rs::updater` is covered by a `#[cfg(test)] mod tests` block (create/update/delete); keep it green when you touch the reducer. Pure-seam tests in `sensors.rs` / `ha.rs` show the same pattern to follow for new seams. diff --git a/Cargo.lock b/Cargo.lock index d730ed1..d17da81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5038,7 +5038,7 @@ dependencies = [ [[package]] name = "widgetsack" -version = "0.0.51" +version = "0.0.52" dependencies = [ "fontdb", "futures-util", diff --git a/client/src/lib/components/NowPlaying/np-source.ts b/client/src/lib/components/NowPlaying/np-source.ts index d4e9ea1..6cc6f0f 100644 --- a/client/src/lib/components/NowPlaying/np-source.ts +++ b/client/src/lib/components/NowPlaying/np-source.ts @@ -17,7 +17,7 @@ export const npSource: SensorSource = { // Make sure the media feed is flowing into mediaStore (idempotent), then mirror the active // session into the hub on every change. Re-derives selection the same way the widget does // (ignore filter + priority sort) so the sensors track exactly what's shown. - startMediaSource(); + await startMediaSource(); const push = () => { const s = mediaStore.getSnapshot(); const active = sortSessionsByPriority( diff --git a/client/src/lib/components/NowPlaying/priority.test.ts b/client/src/lib/components/NowPlaying/priority.test.ts index 806785a..f8b9e9c 100644 --- a/client/src/lib/components/NowPlaying/priority.test.ts +++ b/client/src/lib/components/NowPlaying/priority.test.ts @@ -80,9 +80,9 @@ describe('priority', () => { const sorted = sortSessionsByPriority(sessions, priority); - expect(sorted.at(2)!.source).toBe('barbaz'); + expect(sorted.at(0)!.source).toBe('barbaz'); expect(sorted.at(1)!.source).toBe('foobar'); - expect(sorted.at(0)!.source).toBe('notinlist'); + expect(sorted.at(2)!.source).toBe('notinlist'); }); it('sorts media by playing status after sorting by priority list', () => { @@ -129,9 +129,9 @@ describe('priority', () => { }; const priority = 'barbaz\nfoobar'; const sorted = sortSessionsByPriority(sessions, priority); - expect(sorted.at(2)!.source).toBe('barbaz'); + expect(sorted.at(0)!.source).toBe('barbaz'); expect(sorted.at(1)!.source).toBe('foobar'); - expect(sorted.at(0)!.source).toBeUndefined(); + expect(sorted.at(2)!.source).toBeUndefined(); // Same outcome with the source-less record FIRST, so it also lands in the comparator's // b-slot (both the `a?.source` and `b?.source` fallbacks run). @@ -141,9 +141,9 @@ describe('priority', () => { 4: { ...sessionRecord, session_id: 4, source: 'foobar' } }; const sorted2 = sortSessionsByPriority(reversed, priority); - expect(sorted2.at(2)!.source).toBe('barbaz'); + expect(sorted2.at(0)!.source).toBe('barbaz'); expect(sorted2.at(1)!.source).toBe('foobar'); - expect(sorted2.at(0)!.source).toBeUndefined(); + expect(sorted2.at(2)!.source).toBeUndefined(); }); it('sorts media by last updated timestamp otherwise', () => { @@ -171,9 +171,20 @@ describe('priority', () => { const sorted = sortSessionsByPriority(sessions, priority); - expect(sorted.at(2)!.source).toBe('notinlist'); + expect(sorted.at(0)!.source).toBe('notinlist'); expect(sorted.at(1)!.source).toBe('barbaz'); - expect(sorted.at(0)!.source).toBe('foobar'); + expect(sorted.at(2)!.source).toBe('foobar'); + }); + + it('matches priority entries as exact lines rather than substrings', () => { + const sessions: Record = { + 0: { ...sessionRecord, session_id: 0, source: 'foo' }, + 1: { ...sessionRecord, session_id: 1, source: 'foobar' } + }; + + const sorted = sortSessionsByPriority(sessions, 'foobar'); + + expect(sorted.map((s) => s.source)).toEqual(['foobar', 'foo']); }); }); diff --git a/client/src/lib/components/NowPlaying/priority.ts b/client/src/lib/components/NowPlaying/priority.ts index 556ef3b..8210329 100644 --- a/client/src/lib/components/NowPlaying/priority.ts +++ b/client/src/lib/components/NowPlaying/priority.ts @@ -24,24 +24,30 @@ export const sortSessionsByPriority = ( currentSessions: Record, sourcePriority: string ) => { - const orderedMedia = Object.values(currentSessions) - .sort( - (a, b) => - (b?.timestamp_updated?.secs_since_epoch ?? 0) - - (a?.timestamp_updated?.secs_since_epoch ?? 0) - ) - .sort((a, b) => { - let aPriority = sourcePriority.indexOf(a?.source?.toLowerCase() ?? '_____FIXME_____'); - let bPriority = sourcePriority.indexOf(b?.source?.toLowerCase() ?? '_____FIXME_____'); + const rank = new Map( + sourcePriority + .split('\n') + .map((source) => source.trim().toLowerCase()) + .filter(Boolean) + .map((source, index) => [source, index]) + ); + const priorityOf = (session: SessionRecord): number => + rank.get(session.source?.toLowerCase() ?? '') ?? Number.MAX_SAFE_INTEGER; + const playingOf = (session: SessionRecord): number => + session.last_model_update?.Model?.playback?.status === 'Playing' ? 0 : 1; - aPriority = aPriority === -1 ? Number.MAX_VALUE : aPriority; - bPriority = bPriority === -1 ? Number.MAX_VALUE : bPriority; - - return aPriority - bPriority; - }) - .sort((_, b) => (b.last_model_update?.Model?.playback?.status === 'Playing' ? 1 : -1)); - - return orderedMedia; + return Object.values(currentSessions).sort((a, b) => { + const playing = playingOf(a) - playingOf(b); + if (playing !== 0) return playing; + const priority = priorityOf(a) - priorityOf(b); + if (priority !== 0) return priority; + const aTime = a.timestamp_updated; + const bTime = b.timestamp_updated; + const seconds = (bTime?.secs_since_epoch ?? 0) - (aTime?.secs_since_epoch ?? 0); + if (seconds !== 0) return seconds; + const nanos = (bTime?.nanos_since_epoch ?? 0) - (aTime?.nanos_since_epoch ?? 0); + return nanos !== 0 ? nanos : a.session_id - b.session_id; + }); }; // Insert/replace a session record, evicting any OTHER tracked session that shares its (non-empty) diff --git a/client/src/lib/components/NowPlaying/source.test.ts b/client/src/lib/components/NowPlaying/source.test.ts new file mode 100644 index 0000000..ce67b60 --- /dev/null +++ b/client/src/lib/components/NowPlaying/source.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + listen: vi.fn(), + handleInitialize: vi.fn(), + handleUpdate: vi.fn(), + handleDelete: vi.fn() +})); + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })); +vi.mock('@tauri-apps/api/event', () => ({ listen: mocks.listen })); +vi.mock('../../../stores/stores', () => ({ + handleInitialize: mocks.handleInitialize, + handleUpdate: mocks.handleUpdate, + handleDelete: mocks.handleDelete +})); + +import { EVENTS } from '../../bridge/contract'; +import type { SessionRecord } from '../../../stores/stores'; + +type EventCallback = (event: { payload: SessionRecord }) => void; + +const record = (sessionId: number): SessionRecord => ({ + session_id: sessionId, + source: 'player.exe', + timestamp_created: null, + timestamp_updated: null, + last_media_update: null, + last_model_update: null +}); + +async function loadSource() { + vi.resetModules(); + return import('./source'); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('startMediaSource', () => { + it('attaches every listener first, then initializes and replays deltas received during the snapshot', async () => { + const callbacks = new Map(); + mocks.listen.mockImplementation((event: string, callback: EventCallback) => { + callbacks.set(event, callback); + return Promise.resolve(vi.fn()); + }); + let resolveInitial!: (value: { sessions: Record }) => void; + mocks.invoke.mockImplementation( + () => + new Promise<{ sessions: Record }>((resolve) => { + resolveInitial = resolve; + }) + ); + const { startMediaSource } = await loadSource(); + + const starting = startMediaSource(); + expect(mocks.listen.mock.calls.map((call) => call[0])).toEqual([ + EVENTS.sessionCreate, + EVENTS.sessionUpdate, + EVENTS.sessionDelete + ]); + expect(mocks.invoke).not.toHaveBeenCalled(); + await Promise.resolve(); + await Promise.resolve(); + expect(mocks.invoke).toHaveBeenCalledOnce(); + + callbacks.get(EVENTS.sessionUpdate)!({ payload: record(2) }); + expect(mocks.handleUpdate).not.toHaveBeenCalled(); + resolveInitial({ sessions: { 1: record(1) } }); + await starting; + + expect(mocks.handleInitialize).toHaveBeenCalledWith({ sessions: { 1: record(1) } }); + expect(mocks.handleUpdate).toHaveBeenCalledWith({ sessionRecord: record(2) }); + expect(mocks.handleInitialize.mock.invocationCallOrder[0]).toBeLessThan( + mocks.handleUpdate.mock.invocationCallOrder[0]! + ); + }); + + it('treats create as an upsert and applies live deletes after initialization', async () => { + const callbacks = new Map(); + mocks.listen.mockImplementation((event: string, callback: EventCallback) => { + callbacks.set(event, callback); + return Promise.resolve(vi.fn()); + }); + mocks.invoke.mockResolvedValue({ sessions: {} }); + const { startMediaSource } = await loadSource(); + await startMediaSource(); + + callbacks.get(EVENTS.sessionCreate)!({ payload: record(3) }); + callbacks.get(EVENTS.sessionDelete)!({ payload: record(3) }); + expect(mocks.handleUpdate).toHaveBeenCalledWith({ sessionRecord: record(3) }); + expect(mocks.handleDelete).toHaveBeenCalledWith({ sessionRecord: record(3) }); + }); + + it('cleans up partial listeners and allows a retry after listener setup fails', async () => { + const unlistenA = vi.fn(); + const unlistenB = vi.fn(); + mocks.listen + .mockResolvedValueOnce(unlistenA) + .mockRejectedValueOnce(new Error('event bridge unavailable')) + .mockResolvedValueOnce(unlistenB); + mocks.invoke.mockResolvedValue({ sessions: {} }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { startMediaSource } = await loadSource(); + + await startMediaSource(); + expect(unlistenA).toHaveBeenCalledOnce(); + expect(unlistenB).toHaveBeenCalledOnce(); + expect(mocks.invoke).not.toHaveBeenCalled(); + + mocks.listen.mockResolvedValue(vi.fn()); + await startMediaSource(); + expect(mocks.listen).toHaveBeenCalledTimes(6); + expect(mocks.invoke).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith('Could not start the media event source', expect.any(Error)); + }); + + it('keeps the live feed when only the initial snapshot fails', async () => { + const callbacks = new Map(); + mocks.listen.mockImplementation((event: string, callback: EventCallback) => { + callbacks.set(event, callback); + return Promise.resolve(vi.fn()); + }); + mocks.invoke.mockRejectedValue(new Error('snapshot unavailable')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { startMediaSource } = await loadSource(); + await startMediaSource(); + + callbacks.get(EVENTS.sessionUpdate)!({ payload: record(4) }); + expect(mocks.handleUpdate).toHaveBeenCalledWith({ sessionRecord: record(4) }); + expect(warn).toHaveBeenCalledWith( + 'Could not load the initial media sessions', + expect.any(Error) + ); + }); +}); diff --git a/client/src/lib/components/NowPlaying/source.ts b/client/src/lib/components/NowPlaying/source.ts index 01995d9..0668d70 100644 --- a/client/src/lib/components/NowPlaying/source.ts +++ b/client/src/lib/components/NowPlaying/source.ts @@ -29,7 +29,7 @@ export type MediaCaps = { /** Ask the backend which controls the matched (or current) session supports. Returns null when the * query can't run (non-Windows, no backend in tests) so the caller shows every button by default. */ -export function getMediaCapabilities(source?: string): Promise { +export function getMediaCapabilities(source?: string | null): Promise { return invoke(COMMANDS.mediaCapabilities, { source: source ?? null }).catch( () => null ); @@ -46,18 +46,63 @@ export function mediaControl( return invoke(COMMANDS.mediaControl, { action, source, value }); } -let started = false; +type MediaDelta = { kind: 'update' | 'delete'; record: SessionRecord }; -export function startMediaSource(): void { - if (started) return; - started = true; - invoke<{ sessions: Record }>(COMMANDS.getInitialSessions, { message: '' }) - .then((ev) => handleInitialize({ sessions: ev.sessions })) - .catch(() => undefined); - tauriEvent.listen(EVENTS.sessionUpdate, (ev) => - handleUpdate({ sessionRecord: ev.payload }) - ); - tauriEvent.listen(EVENTS.sessionDelete, (ev) => - handleDelete({ sessionRecord: ev.payload }) - ); +let startPromise: Promise | null = null; + +function applyDelta(delta: MediaDelta): void { + if (delta.kind === 'delete') handleDelete({ sessionRecord: delta.record }); + else handleUpdate({ sessionRecord: delta.record }); +} + +async function attachMediaSource(): Promise { + // Subscribe before asking for the snapshot. Deltas arriving while invoke is in flight are queued, + // then replayed after initialization, so an update/delete can never be overwritten by an older + // snapshot. Session-create is an upsert just like session-update on the frontend. + let live = false; + const queued: MediaDelta[] = []; + const receive = (delta: MediaDelta): void => { + if (live) applyDelta(delta); + else queued.push(delta); + }; + const listeners = await Promise.allSettled([ + tauriEvent.listen(EVENTS.sessionCreate, (ev) => + receive({ kind: 'update', record: ev.payload }) + ), + tauriEvent.listen(EVENTS.sessionUpdate, (ev) => + receive({ kind: 'update', record: ev.payload }) + ), + tauriEvent.listen(EVENTS.sessionDelete, (ev) => + receive({ kind: 'delete', record: ev.payload }) + ) + ]); + const failed = listeners.find((result) => result.status === 'rejected'); + if (failed?.status === 'rejected') { + for (const result of listeners) if (result.status === 'fulfilled') result.value(); + throw failed.reason; + } + + try { + const initial = await invoke<{ sessions: Record }>( + COMMANDS.getInitialSessions, + { message: '' } + ); + handleInitialize({ sessions: initial.sessions }); + } catch (error) { + // The live listeners are still useful when the initial snapshot is unavailable. + console.warn('Could not load the initial media sessions', error); + } + for (const delta of queued) applyDelta(delta); + live = true; +} + +/** Start the per-webview media feed once. A listener-attachment failure resets the singleton so a + * later widget/settings mount can retry; an initial-snapshot failure keeps the live feed running. */ +export function startMediaSource(): Promise { + if (startPromise) return startPromise; + startPromise = attachMediaSource().catch((error) => { + startPromise = null; + console.warn('Could not start the media event source', error); + }); + return startPromise; } diff --git a/client/src/lib/core/imageSrc.test.ts b/client/src/lib/core/imageSrc.test.ts index 0bb8e6c..2d3c61f 100644 --- a/client/src/lib/core/imageSrc.test.ts +++ b/client/src/lib/core/imageSrc.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { describe, it, expect } from 'vitest'; import { isDirectUrl, imageFit } from './imageSrc'; @@ -11,6 +13,16 @@ describe('isDirectUrl', () => { expect(isDirectUrl('photo.jpg')).toBe(false); expect(isDirectUrl(' subfolder-cat.png ')).toBe(false); }); + + it('keeps production CSP aligned with the supported remote image schemes', () => { + const config = JSON.parse( + readFileSync(resolve(process.cwd(), '../widgetsack/tauri.conf.json'), 'utf8') + ) as { app: { security: { csp: string } } }; + const imgDirective = config.app.security.csp + .split(';') + .find((directive) => directive.trim().startsWith('img-src')); + expect(imgDirective?.split(/\s+/)).toEqual(expect.arrayContaining(['http:', 'https:'])); + }); }); describe('imageFit', () => { diff --git a/client/src/lib/core/templates.test.ts b/client/src/lib/core/templates.test.ts index d578a1f..3ae7085 100644 --- a/client/src/lib/core/templates.test.ts +++ b/client/src/lib/core/templates.test.ts @@ -338,6 +338,20 @@ describe('template registry (register / unregister / list / subscribe)', () => { expect(listTemplateGroups().find((g) => g.group === PLUGIN_GROUP)?.templates).toEqual([sample]); }); + it('keeps groups with the same display label separate when their identities differ', () => { + const second = { ...sample, id: 'plugin-widget-2' }; + registerTemplates('pkg:one', [sample], 'Shared Name'); + registerTemplates('pkg:two', [second], 'Shared Name'); + + const shared = listTemplateGroups().filter((g) => g.group === 'Shared Name'); + expect(shared.map((g) => g.templates[0]?.id)).toEqual(['plugin-widget', 'plugin-widget-2']); + unregisterTemplates('pkg:one'); + expect(getTemplate('plugin-widget')).toBeUndefined(); + expect(getTemplate('plugin-widget-2')).toBe(second); + + unregisterTemplates('pkg:two'); + }); + it('getTemplate finds a template contributed by a registered group (loop continues past built-ins)', () => { expect(getTemplate('plugin-widget')).toBeUndefined(); registerTemplates(PLUGIN_GROUP, [sample]); diff --git a/client/src/lib/core/templates.ts b/client/src/lib/core/templates.ts index 56d10d1..b059410 100644 --- a/client/src/lib/core/templates.ts +++ b/client/src/lib/core/templates.ts @@ -356,7 +356,11 @@ export type TemplateGroup = { group: string; templates: Template[] }; /** The built-in templates' group name (always present, always first, never unregisterable). */ export const BUILTIN_TEMPLATE_GROUP = 'Built-in'; -const templateGroups = new Map([[BUILTIN_TEMPLATE_GROUP, TEMPLATES]]); +type RegisteredTemplateGroup = { label: string; templates: Template[] }; + +const templateGroups = new Map([ + [BUILTIN_TEMPLATE_GROUP, { label: BUILTIN_TEMPLATE_GROUP, templates: TEMPLATES }] +]); const templateListeners = new Set<() => void>(); // Cached list identity so useSyncExternalStore sees a stable snapshot between changes. let templateSnapshot: TemplateGroup[] | null = null; @@ -366,23 +370,27 @@ function notifyTemplates(): void { for (const listener of templateListeners) listener(); } -/** Register (or replace) a named template group. The built-in group cannot be overwritten. */ -export function registerTemplates(group: string, list: Template[]): void { - if (group === BUILTIN_TEMPLATE_GROUP) return; - templateGroups.set(group, list.slice()); +/** Register (or replace) a template group by stable identity. `label` is only its palette heading, + * so two plugin packages may share a display name without replacing each other's templates. */ +export function registerTemplates(id: string, list: Template[], label = id): void { + if (id === BUILTIN_TEMPLATE_GROUP) return; + templateGroups.set(id, { label, templates: list.slice() }); notifyTemplates(); } /** Remove a registered template group (no-op for the built-ins / an unknown group). */ -export function unregisterTemplates(group: string): void { - if (group === BUILTIN_TEMPLATE_GROUP) return; - if (templateGroups.delete(group)) notifyTemplates(); +export function unregisterTemplates(id: string): void { + if (id === BUILTIN_TEMPLATE_GROUP) return; + if (templateGroups.delete(id)) notifyTemplates(); } /** Every template group, built-ins first then registration order. Stable identity between changes. */ export function listTemplateGroups(): TemplateGroup[] { if (!templateSnapshot) { - templateSnapshot = Array.from(templateGroups, ([group, templates]) => ({ group, templates })); + templateSnapshot = Array.from(templateGroups.values(), ({ label, templates }) => ({ + group: label, + templates + })); } return templateSnapshot; } @@ -397,8 +405,8 @@ export function subscribeTemplates(listener: () => void): () => void { /** Find a template by id across every registered group (built-ins first). */ export function getTemplate(id: string): Template | undefined { - for (const list of templateGroups.values()) { - const t = list.find((tpl) => tpl.id === id); + for (const { templates } of templateGroups.values()) { + const t = templates.find((tpl) => tpl.id === id); if (t) return t; } return undefined; diff --git a/client/src/lib/overlay.ts b/client/src/lib/overlay.ts index 9530606..f1e177b 100644 --- a/client/src/lib/overlay.ts +++ b/client/src/lib/overlay.ts @@ -555,20 +555,12 @@ export async function resolveThemeCss(name: string): Promise { /** Write theme `name` (a bare stem) → `themes/.css`. Used by the studio theme editor. */ export async function saveThemeCss(name: string, contents: string): Promise { - try { - await invoke(COMMANDS.saveTheme, { name, contents }); - } catch (err) { - console.warn('save_theme failed', err); - } + await invoke(COMMANDS.saveTheme, { name, contents }); } /** Delete theme `name` → removes `themes/.css` (idempotent). Used by the studio theme list. */ export async function deleteThemeCss(name: string): Promise { - try { - await invoke(COMMANDS.deleteTheme, { name }); - } catch (err) { - console.warn('delete_theme failed', err); - } + await invoke(COMMANDS.deleteTheme, { name }); } // ---- wallpapers: media files for the per-monitor background layer (in a fixed `wallpapers/` folder) diff --git a/client/src/lib/widgets/Canvas.tsx b/client/src/lib/widgets/Canvas.tsx index 449b5c5..c1a9877 100644 --- a/client/src/lib/widgets/Canvas.tsx +++ b/client/src/lib/widgets/Canvas.tsx @@ -148,6 +148,7 @@ import { useSplitters } from './canvas/useSplitters'; import { useStudioInit } from './canvas/useStudioInit'; import { recordWidgetRender } from './canvas/widgetProfile'; import type { EditorState, Extra, MonitorOption } from './canvas/types'; +import Select from './Select'; import type { SettingsTab } from './StudioSettingsPanel'; import mascotUrl from '../../assets/mascot.png'; import './Canvas.css'; @@ -170,7 +171,6 @@ const ThemePreview = lazy(() => import('./ThemePreview')); const TokenFields = lazy(() => import('./TokenFields')); const Outline = lazy(() => import('./Outline')); const NavRail = lazy(() => import('./NavRail')); -const Select = lazy(() => import('./Select')); const SensorList = lazy(() => import('./SensorList')); const ThemeList = lazy(() => import('./ThemeList')); const StudioSettingsPanel = lazy(() => import('./StudioSettingsPanel')); @@ -944,6 +944,7 @@ export default function Canvas({ studio = false }: Props) { // An explicit '' (default tokens) on the monitor is honored — it's a string, so the ?? keeps it. const lock = obj?.themeLock !== false; patch.themeLock = lock; + patch.globalTheme = typeof obj?.theme === 'string' ? obj.theme : ''; const t = lock ? obj?.theme : (mon?.theme ?? obj?.theme); if (typeof t === 'string' && t !== stateThemeRef.current) { patch.selectedTheme = t; @@ -1011,8 +1012,7 @@ export default function Canvas({ studio = false }: Props) { import('./BackgroundPanel'), import('./PluginsPanel'), import('./DesignerListPanel'), - import('./MultiInspector'), - import('./Select') + import('./MultiInspector') ]).catch(() => undefined); }, [studio]); diff --git a/client/src/lib/widgets/CssEditor.test.tsx b/client/src/lib/widgets/CssEditor.test.tsx index 8557eb0..6fd61b7 100644 --- a/client/src/lib/widgets/CssEditor.test.tsx +++ b/client/src/lib/widgets/CssEditor.test.tsx @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; import { render, waitFor } from '@testing-library/react'; +// Warm the implementation during module collection, outside any individual test timeout. The wrapper +// still resolves through React.lazy, while full-suite worker contention cannot consume its 3s wait. +import './CssEditorImpl'; import CssEditor from './CssEditor'; // Smoke tests: the lazy CodeMirror impl must construct under happy-dom and render the document. @@ -8,14 +11,20 @@ import CssEditor from './CssEditor'; describe('CssEditor', () => { it('lazily mounts a CodeMirror editor and renders the value', async () => { const { container } = render(); - await waitFor(() => expect(container.querySelector('.cm-editor')).toBeTruthy()); + await waitFor(() => expect(container.querySelector('.cm-editor')).toBeTruthy(), { + timeout: 3000 + }); expect(container.querySelector('.cm-content')?.textContent).toContain('color: red'); }); it('exposes the aria-label on the editable content', async () => { const { container } = render(); - await waitFor(() => - expect(container.querySelector('.cm-content')?.getAttribute('aria-label')).toBe('widget css') + await waitFor( + () => + expect(container.querySelector('.cm-content')?.getAttribute('aria-label')).toBe( + 'widget css' + ), + { timeout: 3000 } ); }); }); diff --git a/client/src/lib/widgets/DesignerListPanel.test.tsx b/client/src/lib/widgets/DesignerListPanel.test.tsx index c73f148..10d6a3f 100644 --- a/client/src/lib/widgets/DesignerListPanel.test.tsx +++ b/client/src/lib/widgets/DesignerListPanel.test.tsx @@ -139,12 +139,16 @@ describe('DesignerListPanel template groups', () => { it('labels a plugin template group "Templates · " (built-ins stay plain "Templates")', () => { // A plugin package contributes its own group; the built-in group keeps the unqualified header. registerTemplates('My Pack', [{ ...TEMPLATES[1], id: 'pack-system', name: 'Pack System' }]); + let unmount: () => void = () => undefined; try { - const { getByText } = render(); + const view = render(); + unmount = view.unmount; + const { getByText } = view; expect(getByText('Templates')).toBeTruthy(); expect(getByText('Templates · My Pack')).toBeTruthy(); expect(getByText('Pack System')).toBeTruthy(); } finally { + unmount(); unregisterTemplates('My Pack'); } }); @@ -170,12 +174,14 @@ describe('DesignerListPanel header actions', () => { }); it('alerts a failure (and does not claim success) when the copy fails', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.mocked(copyToClipboard).mockResolvedValueOnce(false); const { getByText } = render(); fireEvent.click(getByText('⧉ Copy widget reference')); await waitFor(() => expect(alertSpy).toHaveBeenCalledWith(expect.stringMatching(/Copy failed/i)) ); + expect(log).toHaveBeenCalledOnce(); }); }); diff --git a/client/src/lib/widgets/DragSnapLayer.test.tsx b/client/src/lib/widgets/DragSnapLayer.test.tsx index 172d4d9..56973f1 100644 --- a/client/src/lib/widgets/DragSnapLayer.test.tsx +++ b/client/src/lib/widgets/DragSnapLayer.test.tsx @@ -526,7 +526,9 @@ describe('DragSnapLayer (zone widgets)', () => { act(() => handlers['win_drag_start']?.({ payload: { hwnd: 7 } })); // Let many poll intervals fire while probes are slow. await act(async () => void (await new Promise((r) => setTimeout(r, 250)))); - act(() => handlers['win_drag_end']?.({ payload: { hwnd: 7 } })); // stops the interval + await act(async () => { + await handlers['win_drag_end']?.({ payload: { hwnd: 7 } }); // stops the interval + }); expect(pointerProbe).toHaveBeenCalled(); // probing happened expect(maxInFlight).toBe(1); // the busy guard prevented any overlap diff --git a/client/src/lib/widgets/Inspector.wiring.test.tsx b/client/src/lib/widgets/Inspector.wiring.test.tsx index 983c9d9..6fa7b9e 100644 --- a/client/src/lib/widgets/Inspector.wiring.test.tsx +++ b/client/src/lib/widgets/Inspector.wiring.test.tsx @@ -42,6 +42,20 @@ vi.mock('../ddc/monitors', () => ({ setMonitorInput: vi.fn().mockResolvedValue(true) })); +const originalConsoleError = console.error; +let inspectorErrorSpy: ReturnType; +beforeEach(() => { + // The Inspector wiring matrix mounts/unmounts the async monitor probe and external template + // registry dozens of times. Vitest can report their already-cancelled completion against the next + // test. Filter only that known act diagnostic; application errors remain visible. + inspectorErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + const text = args.map(String).join(' '); + if (text.includes('not wrapped in act') && text.includes('Inspector')) return; + originalConsoleError(...args); + }); +}); +afterEach(() => inspectorErrorSpy.mockRestore()); + const w = (over: Partial = {}): WidgetInstance => ({ id: 'w1', type: 'clock', @@ -291,11 +305,13 @@ describe('Inspector add-palette open state', () => { tree: () => leaf(w({ id: 'p', type: 'text' })) } ]); + let unmount: () => void = () => undefined; try { - render(); + unmount = render().unmount; expect(within(palette()).getByText('Templates · MyPlugin', { selector: '.hd' })).toBeTruthy(); expect(within(palette()).getByRole('button', { name: /^Plugin Widget/ })).toBeTruthy(); } finally { + unmount(); unregisterTemplates('MyPlugin'); } }); @@ -737,6 +753,10 @@ describe('Inspector config-field reset (macro / monitorSources / toggle)', () => onOp={onOp} /> ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); // With a non-empty spec the editor shows a (manual) row, not the empty-state placeholder — // wait for the detect effect to settle on the row-count status before interacting. await screen.findByText('1 input'); @@ -1035,12 +1055,14 @@ describe('Inspector template options form — unlabeled param', () => { tree: () => leaf(w({ id: 'p', type: 'text' })) } ]); + let unmount: () => void = () => undefined; try { - render(); + unmount = render().unmount; // No `label` on the spec → the key names both the visible row and the select's aria-label. expect(within(palette()).getByLabelText('Key Only — lang')).toBeTruthy(); expect(within(palette()).getByText('lang', { selector: 'label' })).toBeTruthy(); } finally { + unmount(); unregisterTemplates('KeyOnly'); } }); @@ -1178,12 +1200,17 @@ describe('Inspector remaining branch wiring', () => { onOp={vi.fn()} /> ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); expect(within(panel()).getByText('macro help')).toBeTruthy(); expect(within(panel()).getByText('sources help')).toBeTruthy(); expect(within(panel()).getByText('toggle help')).toBeTruthy(); expect(within(panel()).getByText('text help')).toBeTruthy(); - // Let the (mocked) monitor-inputs detect effect settle inside the test. - await screen.findByPlaceholderText('0x11=Desktop, 0x12=Switch'); + // Let the (mocked) monitor-inputs detect effect settle inside the test. The placeholder exists + // before that promise resolves, so wait for the post-scan status instead. + await screen.findByText('0 inputs'); }); it('clears the sources spec to undefined when the last monitor input is unchecked', async () => { @@ -1197,6 +1224,10 @@ describe('Inspector remaining branch wiring', () => { onOp={onOp} /> ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); // The manual 0x11 entry appears as one checked row once detection resolves; unchecking it // empties the spec, which the Inspector stores as undefined. await screen.findByText('1 input'); diff --git a/client/src/lib/widgets/NowPlayingHost.tsx b/client/src/lib/widgets/NowPlayingHost.tsx index bf80e06..0d50db1 100644 --- a/client/src/lib/widgets/NowPlayingHost.tsx +++ b/client/src/lib/widgets/NowPlayingHost.tsx @@ -23,7 +23,7 @@ type Props = { export default function NowPlayingHost({ label, onControl }: Props) { useEffect(() => { - startMediaSource(); + void startMediaSource(); }, []); const state = useStore(mediaStore); diff --git a/client/src/lib/widgets/StudioSettingsPanel.tsx b/client/src/lib/widgets/StudioSettingsPanel.tsx index 1c79214..61a062b 100644 --- a/client/src/lib/widgets/StudioSettingsPanel.tsx +++ b/client/src/lib/widgets/StudioSettingsPanel.tsx @@ -185,7 +185,7 @@ export default function StudioSettingsPanel({