diff --git a/packages/core/src/registry/registry.test.ts b/packages/core/src/registry/registry.test.ts index 1b53ca093..91a1db63c 100644 --- a/packages/core/src/registry/registry.test.ts +++ b/packages/core/src/registry/registry.test.ts @@ -105,6 +105,35 @@ describe('nodeRegistry', () => { registerNode(b) expect(nodeRegistry.schemas()).toEqual([a.schema, b.schema]) }) + + test('_snapshot() restores definitions and plugin bookkeeping', async () => { + const kept = makeDefinition('kept') + registerNode(kept) + await loadPlugin({ + id: 'test:kept-plugin', + apiVersion: 1, + nodes: [makeDefinition('kept-plugin-kind')], + } as Plugin) + + const restore = nodeRegistry._snapshot() + + // Mutate every kind of registry state a test can leak: a throwaway + // definition, a full reset, and a plugin load with its kind bookkeeping. + registerNode(makeDefinition('leaked')) + nodeRegistry._reset() + await loadPlugin({ + id: 'test:leaked-plugin', + apiVersion: 1, + nodes: [makeDefinition('leaked-plugin-kind')], + } as Plugin) + + restore() + + expect(Array.from(nodeRegistry.entries(), ([k]) => k)).toEqual(['kept', 'kept-plugin-kind']) + expect(nodeRegistry.get('kept')).toBe(kept) + expect(getNodePluginId('kept-plugin-kind')).toBe('test:kept-plugin') + expect(getNodePluginId('leaked-plugin-kind')).toBeUndefined() + }) }) describe('isPresettable', () => { diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 7cd86f02d..1d6e35ed4 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -131,11 +131,37 @@ class NodeRegistryImpl implements NodeRegistry { inspectorExtensionsByKind.clear() notifyRegistryChanged() } + + // Test-only — captures the registry (definitions + plugin bookkeeping) and + // returns a restore function. The registry is a module singleton and bun + // runs a package's test files sequentially in ONE process, so a test that + // registers a throwaway kind (or `_reset()`s) without restoring leaks that + // state into every later test FILE — and file order varies by platform + // (macOS vs CI Linux), which turns the leak into an order-dependent flake. + // Wrap registry mutations in `const restore = nodeRegistry._snapshot()` + // + `restore()` in `afterEach`/`finally`. + _snapshot(): () => void { + const defs = new Map(this.defs) + const pluginIds = new Map(pluginIdsByKind) + const extensions = new Map( + Array.from(inspectorExtensionsByKind, ([kind, list]) => [kind, [...list]] as const), + ) + return () => { + this.defs.clear() + for (const [kind, def] of defs) this.defs.set(kind, def) + pluginIdsByKind.clear() + for (const [kind, id] of pluginIds) pluginIdsByKind.set(kind, id) + inspectorExtensionsByKind.clear() + for (const [kind, list] of extensions) inspectorExtensionsByKind.set(kind, [...list]) + notifyRegistryChanged() + } + } } export const nodeRegistry: NodeRegistry & { _register: (def: AnyNodeDefinition) => void _reset: () => void + _snapshot: () => () => void } = new NodeRegistryImpl() export function registerNode(def: AnyNodeDefinition): void { diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts index 5ef69dc98..e416ad0df 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import type { AnyNode, AnyNodeId, @@ -447,12 +447,20 @@ describe('floorplan annotation overlay routing', () => { }) describe('computeAffectedSiblingIds', () => { + // The cabinet fixture definitions have no `capabilities` — leaking them + // past this describe crashes any later test FILE that enumerates the + // registry (night-8 CI: pointer-support-cap.test.ts, run 32580694134). + let restoreRegistry: () => void + beforeEach(() => { + restoreRegistry = nodeRegistry._snapshot() nodeRegistry._reset() registerCabinetFloorplanDefinition('cabinet') registerCabinetFloorplanDefinition('cabinet-module') }) + afterEach(() => restoreRegistry()) + test('propagates cabinet live overrides through the cabinet family', () => { const run = cabinetRun('cabinet_run', ['cabinet-module_main', 'cabinet-module_corner']) const module = cabinetModule('cabinet-module_main', run.id) @@ -545,6 +553,16 @@ describe('collectFloorplanDependencyNodes', () => { }) describe('collectFloorplanLinkedLevelNodes', () => { + // Same containment as computeAffectedSiblingIds above: the fixture + // definition has no `capabilities`, so it must not outlive this describe. + let restoreRegistry: () => void + + beforeEach(() => { + restoreRegistry = nodeRegistry._snapshot() + }) + + afterEach(() => restoreRegistry()) + test('projects a node onto a linked destination level with its real children', () => { nodeRegistry._reset() registerNode({ diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts index c8c508509..5b2fea075 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts @@ -241,4 +241,39 @@ describe('resolvePointerSupportSurface node tops', () => { wallSupport.baseSegments.every((segment) => Math.abs(segment.elevation - 2) < 1e-6), ).toBe(true) }) + + test('tolerates a registered definition without capabilities', () => { + // Gates the pollution class behind the night-8 CI flake (run + // 32580694134): `capabilities` is typed required, but a minimal plugin + // definition (or a leaked test fixture) can ship without it at runtime. + // The resolver enumerates every registered kind, so one capabilities-less + // entry must read as "no top surface" — not crash the election. + const restoreRegistry = nodeRegistry._snapshot() + try { + registerNode({ + kind: 'plugin-minimal-capless', + schemaVersion: 1, + schema: z.object({}), + category: 'structure', + defaults: () => ({}), + // No `capabilities` — deliberately. + } as unknown as AnyNodeDefinition) + addPluginPlatform() + + const camera = new PerspectiveCamera() + camera.position.set(0, 5, 0) + camera.updateMatrixWorld(true) + + const support = resolvePointerSupportSurface(camera, [0, 0, 0], { + includeNodeTopSurfaces: true, + }) + + // No throw, and the election still works: the capabilities-less kind is + // skipped while the platform's declared top is found as usual. + expect(support?.sourceNodeId).toBe(PLATFORM_ID) + expect(support?.elevation).toBeCloseTo(2) + } finally { + restoreRegistry() + } + }) }) diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.ts index 3af19b7b2..9b10855b1 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.ts @@ -158,9 +158,14 @@ export function resolvePointerSupportSurface( // lifts anything placed inside a finished room. Only the tools that build ON // a surface (wall / column / fence / stair / block) mean that, and they say // so. Everything else places against the floor the pointer indicates. + // + // `capabilities` is typed required on NodeDefinition, but this enumerates + // EVERY registered kind — including plugin bundles that bypass the type at + // runtime. A minimal definition without `capabilities` must read as "no top + // surface", not crash the resolver (night-8 CI, run 32580694134). const nodeTopSurfaceKinds = options?.includeNodeTopSurfaces ? Array.from(nodeRegistry.entries()) - .filter(([, definition]) => definition.capabilities.surfaces?.top !== undefined) + .filter(([, definition]) => definition.capabilities?.surfaces?.top !== undefined) .map(([kind]) => kind) : [] if (nodeTopSurfaceKinds.some((kind) => (sceneRegistry.byType[kind]?.size ?? 0) > 0)) { diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index eaf7b662d..718d537cd 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeId, @@ -143,6 +143,15 @@ function levelWalls(): WallNode[] { } describe('createWallOnCurrentLevel', () => { + // Set by tests that mutate the process-wide node registry; restored here + // so the mutation can't leak into later test files (order-dependent flakes). + let restoreRegistry: (() => void) | undefined + + afterEach(() => { + restoreRegistry?.() + restoreRegistry = undefined + }) + beforeEach(() => { useViewer.setState({ selection: { @@ -314,6 +323,10 @@ describe('createWallOnCurrentLevel', () => { }) test('pins an existing construction source before a generated room slab can lift it', () => { + // The reset + throwaway `block` registration is scoped to this test — + // the registry is a process-wide singleton, so leaking it would leave + // later test FILES with a stripped registry (order-dependent flakes). + restoreRegistry = nodeRegistry._snapshot() nodeRegistry._reset() spatialGridManager.clear() registerNode({ diff --git a/packages/editor/src/lib/floorplan/apply-alignment.test.ts b/packages/editor/src/lib/floorplan/apply-alignment.test.ts index d9355b4b2..d10036a5c 100644 --- a/packages/editor/src/lib/floorplan/apply-alignment.test.ts +++ b/packages/editor/src/lib/floorplan/apply-alignment.test.ts @@ -1,8 +1,23 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import useAlignmentGuides from '../../store/use-alignment-guides' import { applyFloorplanAlignment } from './apply-alignment' describe('applyFloorplanAlignment', () => { + beforeEach(() => { + // These tests assume no active building (alignment runs on world axes). + // The scene/viewer stores are process-wide singletons, so an earlier test + // FILE can leak a selected building fixture into them — under bun's + // platform-dependent file order that turned into an order-dependent + // failure (getActiveBuildingPose reading a fixture building without a + // rotation array). Pin the empty-scene context explicitly. + useScene.setState({ nodes: {} } as never) + useViewer.setState({ + selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, + } as never) + }) + afterEach(() => { useAlignmentGuides.getState().clear() })