diff --git a/CLAUDE.md b/CLAUDE.md index 7cfdb23..5d45bc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,7 +261,7 @@ into `configurePreviewServer` so `yarn preview` keeps working. | God-hook split (state + actions + shim) | `doorbell`, `poll`, `furni-chooser`, `user-chooser`, `friend-request`, `chat-input` | | God-hook split (`useBetween` singleton + state filter + actions filter + shim) | `wired-tools`, `translation`, `notification`, `friends` | | `WidgetErrorBoundary` | `RoomWidgetsView` umbrella | -| Vitest | 113/113 cases on pure helpers + the Zustand store | +| Vitest | 124/124 cases — 113 on pure helpers + Zustand store, plus the first 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the new renderer-SDK mock at `tests/mocks/renderer-mock.ts` | | Form Actions | Login / Register / Forgot (LoginView.tsx) | | Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating | @@ -271,7 +271,7 @@ into `configurePreviewServer` so `yarn preview` keeps working. | Split `useChatWidget` / `useAvatarInfoWidget` | Both state-driven via events with no clean imperative actions to extract — skip-motivated. Already touched today for the InfoStand listener move. | | Split `usePetPackageWidget` / `useWordQuizWidget` / `useChatCommandSelector` | Their "actions" mutate internal state or are tightly interdependent — skip-motivated. | | Hoist Wired Creator Tools shared state to a Zustand slice | Would remove ~25 props passed to the 3 tab sub-components. (Wired-tools split done as singleton-filter; Zustand slice is the next step.) | -| Wider Vitest coverage (React components) | `@testing-library/*` is installed; needs a small renderer-SDK mock layer first. | +| Widen the component / hook test coverage | Mock layer is in place (`tests/mocks/renderer-mock.ts`) and the first 2 pilots pass. Good follow-up targets: other `*State` hooks built on event reducers, `LoginView` Form Actions happy/error paths, OfferView with `useNitroQuery`. | ## Known open logic bugs @@ -323,3 +323,11 @@ Fix shapes documented; both are reasonable PRs on their own. - Asset middleware: `nitroAssetsServer()` in `vite.config.mjs` - Configuration pre-init: `src/bootstrap.ts` (`await GetConfiguration().init()` before `import('./index')`) +- Renderer-SDK mock for Vitest: `tests/mocks/renderer-mock.ts` + (aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`). + Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` / + `clearMockEventDispatcher` helpers used by hook tests, the + `RoomSessionDoorbellEvent` stub, and a long list of placeholder + classes/enums kept around just so the `src/api/*` barrel cascade + imports without throwing. **Grow this file when a new test needs a + symbol; prefer real deterministic stubs over `vi.fn()`.** diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7b4d277..57d2cb6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -484,7 +484,7 @@ Status after this round of work: - Vitest 3 + jsdom + `@testing-library/react` + `@testing-library/jest-dom` configured. Separate `vitest.config.mts` so the runner doesn't drag in the renderer SDK aliases from `vite.config.mjs`. -- **113 cases passing** across 8 test files: +- **124 cases passing** across 10 test files. Pure-module suites: - `WiredCreatorTools.helpers.test.ts` (18) — formatters + snapshot factory. - `navigatorRoomCreatorStore.test.ts` (4) — Zustand store invariants @@ -504,13 +504,40 @@ Status after this round of work: bail-out branches (state-not-AvatarInfoUser, mismatched user/roomIndex, equal-after-dedup) + the figure / favorite-group apply paths. -- **Pure-module convention**: tests live in `tests/` and import from - concrete file paths (e.g. `../src/api/catalog/CatalogType`) rather - than the api barrel, so jsdom doesn't transitively load the renderer - SDK's Pixi-bound modules. Renderer event type imports use - `import type { … }` so they're erased at compile time and don't - trigger the runtime module load either. -- `yarn test` + `yarn test:watch` scripts added. + + Component-/hook-level suites (on the new renderer-SDK mock): + - `WidgetErrorBoundary.test.tsx` (4) — happy path + caught render + error logged via `NitroLogger.error` + custom fallback + + `unknown` default name. + - `useDoorbellState.test.tsx` (7) — initial empty state, append on + `DOORBELL`, dedup duplicates, remove on `RSDE_ACCEPTED` / + `RSDE_REJECTED`, ignore stale events, unsubscribe on unmount. + +- **Renderer-SDK mock at `tests/mocks/renderer-mock.ts`** — + `vitest.config.mts` aliases `@nitrots/nitro-renderer` over this file + so jsdom-hosted tests never load Pixi or the message + parser/composer registry. The mock exports: + - Explicit, behavioral stubs for the symbols tests actually + exercise: `NitroLogger`, `GetEventDispatcher`, + `mockEventDispatcher` / `clearMockEventDispatcher` helpers, the + `RoomSessionDoorbellEvent` class (signature mirrors the real + `(type, session, userName)` so `tsgo` stays happy). + - String-keyed `Proxy` enums for every `*EventType` / + `*FigurePartType` / `RoomObjectCategory` etc. — each access + returns a stable unique string so dispatch + listener agree. + - Lightweight `class StubClass {}` placeholders for the ~30 Pixi + and gameplay classes the `src/api/*` barrel touches at import + time (`NitroAlphaFilter`, `NitroContainer`, `EventDispatcher`, + etc.). Keeps the cascade from throwing without simulating + behavior tests don't care about. + - Singleton getters (`GetAssetManager`, `GetCommunication`, + `GetSessionDataManager`, …) returning a chainable proxy so + `GetX().y.z` evaluates to a no-op proxy instead of crashing. +- **Pure-module convention** (still applies for non-component tests): + import from concrete file paths so jsdom doesn't transitively load + the renderer SDK; use `import type { … }` for type-only renderer + imports. +- `yarn test` + `yarn test:watch` scripts. ### Logic bug fixes - Doorbell close button didn't close while users were pending @@ -648,11 +675,15 @@ Remaining order of value/risk for the next contributor: token; the `LayoutFurniImageView` / `LayoutAvatarImageView` async fetch race needs a request-id ref (or is solved by migrating the image fetch to `useNitroQuery` keyed on props). -6. **Wider Vitest coverage** — next worthwhile targets: the - `useNitroQuery` adapter (timeout + cleanup + accept-filter - behavior, needs a stub for `@nitrots/nitro-renderer`), - `useDoorbellState`/`useUserChooserState` event-reducer logic - (needs the same renderer stub). +6. **Widen the component/hook Vitest coverage.** The renderer-SDK + mock layer is in place (`tests/mocks/renderer-mock.ts`) and the + first two pilots — `WidgetErrorBoundary` and `useDoorbellState` — + pass. Good follow-up targets: other `*State` hooks built on event + reducers (`useFurniChooserState`, `useUserChooserState`, + `useFriendRequestState`, `useChatInputState`), the `useNitroQuery` + adapter (timeout + cleanup + accept-filter behavior), and the + `LoginView` Form Actions happy/error paths. Each new test will + likely need to add 1-3 named exports to the renderer mock. Skipped intentionally and documented in commit messages: diff --git a/tests/WidgetErrorBoundary.test.tsx b/tests/WidgetErrorBoundary.test.tsx new file mode 100644 index 0000000..3d6b4a9 --- /dev/null +++ b/tests/WidgetErrorBoundary.test.tsx @@ -0,0 +1,96 @@ +/* @vitest-environment jsdom */ + +import { NitroLogger } from '@nitrots/nitro-renderer'; +import { cleanup, render, screen } from '@testing-library/react'; +import { FC } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WidgetErrorBoundary } from '../src/common/error-boundary/WidgetErrorBoundary'; + +// `import { NitroLogger } from '@nitrots/nitro-renderer'` resolves to +// `tests/mocks/renderer-mock.ts` via the alias in vitest.config.mts. +// The SUT imports the same path, so both reach the same vi.fn instance. + +describe('WidgetErrorBoundary', () => +{ + beforeEach(() => + { + vi.clearAllMocks(); + // react-error-boundary lets React's "uncaught error" log through + // by default — silence it so jsdom doesn't dump a stack trace + // every time we deliberately throw below. + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => + { + cleanup(); + vi.restoreAllMocks(); + }); + + it('renders its children when nothing throws', () => + { + render( + + visible + + ); + + expect(screen.getByTestId('child')).toHaveTextContent('visible'); + }); + + it('swallows a render-time error to a silent fallback and logs it', () => + { + const Boom: FC = () => + { + throw new Error('kaboom'); + }; + + const { container } = render( + + + + ); + + // Default fallback is `() => null` → boundary subtree is empty. + expect(container).toBeEmptyDOMElement(); + + expect(NitroLogger.error).toHaveBeenCalledTimes(1); + const [ message, err ] = (NitroLogger.error as ReturnType).mock.calls[0]; + expect(message).toBe('[Widget:Boom] crashed'); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe('kaboom'); + }); + + it('renders a custom fallback node when provided', () => + { + const Boom: FC = () => + { + throw new Error('explode'); + }; + + render( + offline }> + + + ); + + expect(screen.getByTestId('fb')).toHaveTextContent('offline'); + }); + + it('uses "unknown" as the widget name when the prop is omitted', () => + { + const Boom: FC = () => + { + throw new Error('anonymous'); + }; + + render( + + + + ); + + expect(NitroLogger.error).toHaveBeenCalledTimes(1); + expect((NitroLogger.error as ReturnType).mock.calls[0][0]).toBe('[Widget:unknown] crashed'); + }); +}); diff --git a/tests/mocks/renderer-mock.ts b/tests/mocks/renderer-mock.ts new file mode 100644 index 0000000..acd410e --- /dev/null +++ b/tests/mocks/renderer-mock.ts @@ -0,0 +1,260 @@ +/** + * Stub of `@nitrots/nitro-renderer` for Vitest. + * + * The real package eagerly loads Pixi v8 + a few hundred Habbo message + * parsers/composers at module import time, which jsdom cannot host: + * any `tests/**` file that transitively pulls a renderer symbol throws + * before a single assertion runs. + * + * This module replaces it via `resolve.alias` in `vitest.config.mts`. + * We provide explicit named exports for the symbols a test currently + * needs (logger, event dispatcher, doorbell event class); everything + * else mentioned in the comments below is a generic stub kept just to + * keep the side-effectful imports happy as `src/api/index.ts` and + * friends are pulled in transitively by the barrel cascade. + * + * Grow this file as new tests require additional symbols. Prefer adding + * a real (deterministic) stub over wiring `vi.fn()` — it keeps the + * mocks readable and avoids state bleed between cases. + */ + +import { vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Logger +// --------------------------------------------------------------------------- + +export const NitroLogger = { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + enableContexts: vi.fn(), + setDebug: vi.fn() +}; + +// --------------------------------------------------------------------------- +// Event dispatcher +// --------------------------------------------------------------------------- +// +// `GetEventDispatcher()` in the real SDK returns the renderer-wide event +// bus. Tests use `mockEventDispatcher.dispatchEvent(event)` to simulate +// a server push. `clearMockEventDispatcher()` resets the listener map +// between cases so subscriptions from a previous test don't leak. + +type Listener = (event: any) => void; + +const listeners = new Map>(); + +export const mockEventDispatcher = { + addEventListener(type: string, handler: Listener) + { + let bucket = listeners.get(type); + + if(!bucket) + { + bucket = new Set(); + listeners.set(type, bucket); + } + + bucket.add(handler); + }, + removeEventListener(type: string, handler: Listener) + { + listeners.get(type)?.delete(handler); + }, + dispatchEvent(event: { type: string }) + { + const bucket = listeners.get(event.type); + + if(!bucket) return; + + for(const handler of bucket) handler(event); + }, + hasListeners(type: string) + { + return (listeners.get(type)?.size ?? 0) > 0; + } +}; + +export const clearMockEventDispatcher = () => +{ + listeners.clear(); +}; + +export const GetEventDispatcher = vi.fn(() => mockEventDispatcher); + +// --------------------------------------------------------------------------- +// Event type enums (string-keyed Proxies) +// --------------------------------------------------------------------------- +// +// The real `*EventType` is a `class { static readonly FOO = '...'; … }` +// with stable wire strings. Tests only need each constant to be a +// unique string so dispatch + listener agree. + +const makeEnumProxy = (label: string) => new Proxy({}, { + get: (_, prop) => typeof prop === 'string' ? `mock:${ label }:${ prop }` : undefined +}) as Record; + +export const NitroEventType = makeEnumProxy('NitroEventType'); +export const MouseEventType = makeEnumProxy('MouseEventType'); +export const TouchEventType = makeEnumProxy('TouchEventType'); +export const RoomObjectCategory = makeEnumProxy('RoomObjectCategory'); +export const RoomObjectPlacementSource = makeEnumProxy('RoomObjectPlacementSource'); +export const RoomObjectType = makeEnumProxy('RoomObjectType'); +export const RoomObjectVariable = makeEnumProxy('RoomObjectVariable'); +export const RoomControllerLevel = makeEnumProxy('RoomControllerLevel'); +export const RoomTradingLevelEnum = makeEnumProxy('RoomTradingLevelEnum'); +export const HabboClubLevelEnum = makeEnumProxy('HabboClubLevelEnum'); +export const FurnitureType = makeEnumProxy('FurnitureType'); +export const PetType = makeEnumProxy('PetType'); +export const AvatarFigurePartType = makeEnumProxy('AvatarFigurePartType'); +export const AvatarScaleType = makeEnumProxy('AvatarScaleType'); +export const AvatarSetType = makeEnumProxy('AvatarSetType'); +export const AvatarAction = makeEnumProxy('AvatarAction'); +export const RoomWidgetEnumItemExtradataParameter = makeEnumProxy('RoomWidgetEnumItemExtradataParameter'); + +// --------------------------------------------------------------------------- +// Doorbell event class +// --------------------------------------------------------------------------- + +export class RoomSessionDoorbellEvent +{ + // Wire strings copied from the real class so any consumer that + // ignores the indirection through the `.DOORBELL` static still + // matches. + static readonly DOORBELL = 'RSDE_DOORBELL'; + static readonly RSDE_ACCEPTED = 'RSDE_ACCEPTED'; + static readonly RSDE_REJECTED = 'RSDE_REJECTED'; + + // Mirrors the real constructor signature `(type, session, userName)` + // so `tsgo` is happy. Tests can pass `null` for the session: the + // SUT only reads `.userName` and `.type`. + constructor(public readonly type: string, public readonly _session: unknown, public readonly userName: string) {} +} + +// --------------------------------------------------------------------------- +// Generic classes — placeholders for symbols that need to exist as +// constructors so module-level `new X(...)` calls don't crash during +// the barrel cascade, but whose behavior tests don't yet exercise. +// --------------------------------------------------------------------------- + +class StubClass +{ + constructor(..._args: unknown[]) {} +} + +export class NitroAlphaFilter extends StubClass {} +export class NitroContainer extends StubClass {} +export class NitroRectangle extends StubClass {} +export class NitroSprite extends StubClass {} +export class NitroTexture extends StubClass {} +export class NitroSoundEvent extends StubClass {} +export class NitroEvent extends StubClass {} +export class MessageEvent extends StubClass {} +export class RoomEngineObjectEvent extends StubClass {} +export class CreateLinkEvent extends StubClass {} +export class EventDispatcher extends StubClass {} +export class AdvancedMap extends StubClass {} +export class AvatarFigureContainer extends StubClass {} +export class Vector3d extends StubClass {} +export class ObjectDataFactory extends StubClass {} +export class RoomDataParser extends StubClass {} +export class RoomModerationSettings extends StubClass {} +export class StringDataType extends StubClass {} +export class SellablePetPaletteData extends StubClass {} +export class PetFigureData extends StubClass {} +export class PetData extends StubClass {} +export class NodeData extends StubClass {} +export class ItemDataStructure extends StubClass {} +export class HabboGroupEntryData extends StubClass {} +export class FriendParser extends StubClass {} +export class FriendCategoryData extends StubClass {} +export class FriendRequestData extends StubClass {} +export class FurnitureListItemParser extends StubClass {} +export class BotData extends StubClass {} +export class AchievementData extends StubClass {} +export class CatalogPageMessageProductData extends StubClass {} +export class GiftWrappingConfigurationParser extends StubClass {} +export class WiredFilter extends StubClass {} +export class HabboWebTools extends StubClass {} + +// Composers — symbol-only constructors; only their identity matters in the +// codebase ("did the SUT call SendMessageComposer(new FooComposer(args))"). +export class AddFavouriteRoomMessageComposer extends StubClass {} +export class DeleteFavouriteRoomMessageComposer extends StubClass {} +export class DesktopViewComposer extends StubClass {} +export class FurniturePlacePaintComposer extends StubClass {} +export class GetGuestRoomMessageComposer extends StubClass {} +export class GetProductOfferComposer extends StubClass {} +export class GroupFavoriteComposer extends StubClass {} +export class GroupInformationComposer extends StubClass {} +export class GroupJoinComposer extends StubClass {} +export class GroupUnfavoriteComposer extends StubClass {} +export class UserProfileComposer extends StubClass {} + +// `ChooserSelectionFilter` is used as a string enum in some call sites. +export const ChooserSelectionFilter = makeEnumProxy('ChooserSelectionFilter'); + +// --------------------------------------------------------------------------- +// Singleton getters +// --------------------------------------------------------------------------- + +const stubManager = () => +{ + const sentinel = new Proxy(() => undefined, { + get(target, prop) + { + if(prop === 'then') return undefined; + const cached = (target as any)[prop]; + if(cached !== undefined) return cached; + // Most fields read from a real manager are either methods + // (return functions) or sub-objects (return proxies). We + // return another callable proxy so chained access works. + const value = stubManager(); + (target as any)[prop] = value; + return value; + }, + apply() + { + return undefined; + } + }); + + return sentinel; +}; + +export const GetAssetManager = vi.fn(stubManager); +export const GetAvatarRenderManager = vi.fn(stubManager); +export const GetCommunication = vi.fn(stubManager); +export const GetConfiguration = vi.fn(stubManager); +export const GetLocalizationManager = vi.fn(stubManager); +export const GetRoomEngine = vi.fn(stubManager); +export const GetRoomSessionManager = vi.fn(stubManager); +export const GetSessionDataManager = vi.fn(stubManager); +export const GetTickerTime = vi.fn(() => 0); +export const TextureUtils = stubManager(); +export const NitroVersion = stubManager(); + +// --------------------------------------------------------------------------- +// Type-only re-exports (interfaces erase at compile time, but listing them +// here documents what the codebase imports through the type channel). +// +// IAvatarFigureContainer · IEventDispatcher · IFigurePart · IFigurePartSet · +// IFurnitureData · IFurnitureItemData · IGraphicAsset · IMessageComposer · +// IMessageEvent · IObjectData · IPartColor · IPollQuestion · IProductData · +// IRoomEngine · IRoomModerationSettings · IRoomObject · IRoomObjectController · +// IRoomObjectSpriteVisualization · IRoomPetData · IRoomSession · IRoomUserData +// +// No need to alias them — TypeScript only consults them in the type +// system, and the production `tsconfig.json` resolves them against the +// real renderer via `node_modules/@nitrots/nitro-renderer/src`. + +// --------------------------------------------------------------------------- +// Catch-all +// --------------------------------------------------------------------------- +// +// Anything else still resolves to `undefined`. If a test fails with +// "X is not a constructor" / "X.SOMETHING is not a function", add the +// missing symbol above with a real stub. Avoid the temptation to +// blanket-mock everything — explicit stubs surface intent and let +// failing tests pinpoint what behavior they actually rely on. diff --git a/tests/useDoorbellState.test.tsx b/tests/useDoorbellState.test.tsx new file mode 100644 index 0000000..d04be03 --- /dev/null +++ b/tests/useDoorbellState.test.tsx @@ -0,0 +1,102 @@ +/* @vitest-environment jsdom */ + +import { RoomSessionDoorbellEvent } from '@nitrots/nitro-renderer'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { useDoorbellState } from '../src/hooks/rooms/widgets/useDoorbellState'; +import { clearMockEventDispatcher, mockEventDispatcher } from './mocks/renderer-mock'; + +// Server push helper — mirrors the renderer wire by emitting the same +// constants the SUT listens to. The real constructor takes a session +// reference too; pass null since the SUT only reads `.userName`. +const dispatchDoorbell = (type: string, userName: string) => +{ + act(() => + { + mockEventDispatcher.dispatchEvent(new RoomSessionDoorbellEvent(type, null as any, userName)); + }); +}; + +describe('useDoorbellState', () => +{ + beforeEach(() => + { + clearMockEventDispatcher(); + }); + + afterEach(() => + { + cleanup(); + }); + + it('starts with no users pending', () => + { + const { result } = renderHook(() => useDoorbellState()); + + expect(result.current).toEqual([]); + }); + + it('appends the username from a DOORBELL event', () => + { + const { result } = renderHook(() => useDoorbellState()); + + dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice'); + + expect(result.current).toEqual([ 'Alice' ]); + }); + + it('ignores duplicate DOORBELL events for the same username', () => + { + const { result } = renderHook(() => useDoorbellState()); + + dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice'); + dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice'); + + expect(result.current).toEqual([ 'Alice' ]); + }); + + it('removes a user on RSDE_ACCEPTED while keeping the others', () => + { + const { result } = renderHook(() => useDoorbellState()); + + dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice'); + dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Bob'); + dispatchDoorbell(RoomSessionDoorbellEvent.RSDE_ACCEPTED, 'Alice'); + + expect(result.current).toEqual([ 'Bob' ]); + }); + + it('removes a user on RSDE_REJECTED', () => + { + const { result } = renderHook(() => useDoorbellState()); + + dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Carol'); + dispatchDoorbell(RoomSessionDoorbellEvent.RSDE_REJECTED, 'Carol'); + + expect(result.current).toEqual([]); + }); + + it('ignores accept/reject events for users that were never pending', () => + { + const { result } = renderHook(() => useDoorbellState()); + + dispatchDoorbell(RoomSessionDoorbellEvent.RSDE_ACCEPTED, 'Ghost'); + + expect(result.current).toEqual([]); + }); + + it('unsubscribes from all three events on unmount', () => + { + const { unmount } = renderHook(() => useDoorbellState()); + + expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.DOORBELL)).toBe(true); + expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_ACCEPTED)).toBe(true); + expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_REJECTED)).toBe(true); + + unmount(); + + expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.DOORBELL)).toBe(false); + expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_ACCEPTED)).toBe(false); + expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_REJECTED)).toBe(false); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index b6cddc3..7c217c6 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -5,8 +5,12 @@ import { resolve } from 'path'; * Test runner config — kept separate from vite.config.mjs because the * dev/build config wires up the renderer SDK via filesystem aliases that * point at sibling working trees (`../renderer`, `../Nitro_Render_V3`). - * Tests are deliberately written against pure modules (helpers, stores) - * that don't pull in the renderer. + * + * Test files were originally written against pure modules (helpers, + * stores) that don't pull in the renderer. We now also support + * component-level tests by aliasing `@nitrots/nitro-renderer` to a + * hand-written stub at `tests/mocks/renderer-mock.ts` so jsdom doesn't + * try to evaluate Pixi + the full message parser/composer registry. */ export default defineConfig({ test: { @@ -18,6 +22,7 @@ export default defineConfig({ }, resolve: { alias: { + '@nitrots/nitro-renderer': resolve(__dirname, 'tests/mocks/renderer-mock.ts'), '@': resolve(__dirname, 'src') } }