Context
Surfaced from downstream dogfooding: a Neo.mjs client application (client project test-management-tool, dogfooding the in-progress v13 dev branch) tried to write its first Playwright unit test that instantiates the app's root Neo.container.Viewport subclass via Neo.create(). The class loads and its static config verifies correctly, but Neo.create() blows up at construction time inside Neo's framework code — not in the downstream app's class — because test/playwright/setup.mjs does not mock two surfaces that any production-shaped controller will hit.
This is the second-and-third recurrence of a pattern we already know: framework test mocks that live ad-hoc in per-spec beforeEach (see Related), and never make it into the canonical setup.mjs until friction surfaces in another spec. Lifting these into setup.mjs now is cheap; the alternative is every downstream consumer rediscovering the same gap.
The Problem
Empirically reproduced via npm run test-unit against a minimal sanity spec:
TypeError: Cannot read properties of undefined (reading 'setRoute')
at ViewportController.initAsync (node_modules/neo.mjs/src/controller/Base.mjs:134:57)
at node_modules/neo.mjs/src/core/Base.mjs:306:13The failing line in src/controller/Base.mjs is:
!Neo.config.hash && defaultHash && Neo.Main.setRoute({value: defaultHash, windowId})After stubbing Neo.Main, the next failure cascades into onConstructed accessing Neo.main.addon.LocalStorage.readLocalStorageItem({...}). Neo's stock setup.mjs mocks Neo.main.addon with DragDrop, Navigator, and ResizeObserver — but no LocalStorage.
Why Neo's internal suite doesn't trip these: the framework's own unit tests deliberately instantiate stateless or controller-less components (Neo.core.Base, Progress, plain Container populated with buttons). The gap is invisible from inside the framework but immediate for any downstream app whose root viewport has a real controller — i.e., effectively every non-trivial Neo application.
The Architectural Reality
test/playwright/setup.mjs exports a setup(options) helper that mocks Neo.config, Neo.applyDeltas, Neo.main, Neo.currentWorker, Neo.worker so unit tests can run in a single Node thread without a real worker bridge.
Neo.Main (capital M) is the main-thread proxy used by Neo.controller.Base.initAsync for routing. It is structurally distinct from Neo.main (lowercase) which holds addon facades. The two share a naming root but live on separate substrate surfaces — both are runtime-only, neither is currently mocked in unitTestMode.
Neo.main.addon.LocalStorage is the addon facade for the persistent-storage main-thread service. Any controller that persists theme/route/filter state will reference it during onConstructed or async setup hooks.
- The existing
setup(options) shape already accepts an options bag (neoConfig, appConfig), so extending it with feature-flagged mocks is consistent with the established API.
The Fix
Extend test/playwright/setup.mjs to provide stock mocks for both surfaces, gated on opt-out flags so existing tests that intentionally test routing/storage behavior aren't perturbed:
export function setup(options = {}) {
const {
neoConfig = {},
appConfig = {},
mockMain = true,
mockLocalStorage = true
} = options;
if (mockMain) {
Neo.Main ??= {
setRoute: () => {}
};
}
if (mockLocalStorage) {
Neo.main.addon.LocalStorage ??= {
readLocalStorageItem : async () => ({value: null}),
updateLocalStorageItem: async () => {},
removeLocalStorageItem: async () => {}
};
}
}Both mocks use ??= so a caller that pre-installs richer mocks (e.g., a test that exercises route dispatch end-to-end) wins.
Optional follow-up: factor each mock into a named export (e.g., mockNeoMain(), mockLocalStorage()) so downstream apps can compose them à la carte without re-invoking the full setup() cycle. Not required for this ticket.
Acceptance Criteria
Out of Scope
- A full extracted "extended setup helper" for downstream consumers (e.g., a client project-side wrapper). Downstream apps can still build their own helpers on top; this ticket centralizes only the universally-needed mocks.
- Mocking
Neo.Main beyond setRoute (e.g., the full worker-bridge protocol). The opt-out flag lets downstream specs install richer mocks when they need to.
- Refactoring
Neo.controller.Base.initAsync to be defensive about a missing Neo.Main. Routing IS a runtime concern; the test environment should provide the stub, not the runtime code.
Avoided Traps
- "Mock everything by default with deeply structured stubs." Rejected — increases setup-time cost and obscures genuine missing-dep failures. Minimal stubs (no-op functions,
{value: null} returns) are sufficient for the failure modes observed; deeper behavior is downstream's responsibility.
- "Make
Neo.Main.setRoute no-op when unitTestMode is true inside controller/Base.mjs." Rejected per Out of Scope §3. Runtime code should not branch on test mode; that bleeds the test-mode boundary into runtime substrate.
- "Wait until v13 stabilizes to revisit test infra." Rejected — the friction blocks downstream v13 adopters writing their first viewport spec right now. Cheap fix, immediate ROI.
Related
- #9443 (closed) — "Stabilize Playwright Unit Tests by Centralizing Global Mocks". Originally proposed centralizing mocks for
Neo.main, Neo.currentWorker, Neo.worker, Neo.applyDeltas in setup.mjs. Pivoted mid-flight to a different root cause (trap() for delayed async imports on destroyed components) and closed without landing the centralized-mock surface this ticket targets. Neo.Main.setRoute and Neo.main.addon.LocalStorage were never on its list. This ticket explicitly fills that gap.
- #8101 (closed, v11.17.0) — Earlier per-spec mock of
Neo.main.addon.DragDrop.setDragProxyElement inside a single test's beforeEach. Second datapoint that per-spec ad-hoc mocks repeat across the suite; lifting universally-needed surfaces into setup.mjs is the substrate-correct primitive.
Handoff Retrieval Hint
Retrieval Hint: "setup.mjs mock Neo.Main setRoute LocalStorage downstream viewport unit test"
Provenance
Origin: downstream dogfooding session in the client project test-management-tool (GitLab-hosted client app, dogfooding the Neo.mjs v13 dev branch). No Neo Memory Core session ID — the surfacing context lives outside the canonical swarm.
Context
Surfaced from downstream dogfooding: a Neo.mjs client application (client project test-management-tool, dogfooding the in-progress v13 dev branch) tried to write its first Playwright unit test that instantiates the app's root
Neo.container.Viewportsubclass viaNeo.create(). The class loads and its static config verifies correctly, butNeo.create()blows up at construction time inside Neo's framework code — not in the downstream app's class — becausetest/playwright/setup.mjsdoes not mock two surfaces that any production-shaped controller will hit.This is the second-and-third recurrence of a pattern we already know: framework test mocks that live ad-hoc in per-spec
beforeEach(see Related), and never make it into the canonicalsetup.mjsuntil friction surfaces in another spec. Lifting these intosetup.mjsnow is cheap; the alternative is every downstream consumer rediscovering the same gap.The Problem
Empirically reproduced via
npm run test-unitagainst a minimal sanity spec:TypeError: Cannot read properties of undefined (reading 'setRoute') at ViewportController.initAsync (node_modules/neo.mjs/src/controller/Base.mjs:134:57) at node_modules/neo.mjs/src/core/Base.mjs:306:13The failing line in
src/controller/Base.mjsis:!Neo.config.hash && defaultHash && Neo.Main.setRoute({value: defaultHash, windowId})After stubbing
Neo.Main, the next failure cascades intoonConstructedaccessingNeo.main.addon.LocalStorage.readLocalStorageItem({...}). Neo's stocksetup.mjsmocksNeo.main.addonwithDragDrop,Navigator, andResizeObserver— but noLocalStorage.Why Neo's internal suite doesn't trip these: the framework's own unit tests deliberately instantiate stateless or controller-less components (
Neo.core.Base,Progress, plainContainerpopulated with buttons). The gap is invisible from inside the framework but immediate for any downstream app whose root viewport has a real controller — i.e., effectively every non-trivial Neo application.The Architectural Reality
test/playwright/setup.mjsexports asetup(options)helper that mocksNeo.config,Neo.applyDeltas,Neo.main,Neo.currentWorker,Neo.workerso unit tests can run in a single Node thread without a real worker bridge.Neo.Main(capital M) is the main-thread proxy used byNeo.controller.Base.initAsyncfor routing. It is structurally distinct fromNeo.main(lowercase) which holds addon facades. The two share a naming root but live on separate substrate surfaces — both are runtime-only, neither is currently mocked in unitTestMode.Neo.main.addon.LocalStorageis the addon facade for the persistent-storage main-thread service. Any controller that persists theme/route/filter state will reference it duringonConstructedor async setup hooks.setup(options)shape already accepts an options bag (neoConfig,appConfig), so extending it with feature-flagged mocks is consistent with the established API.The Fix
Extend
test/playwright/setup.mjsto provide stock mocks for both surfaces, gated on opt-out flags so existing tests that intentionally test routing/storage behavior aren't perturbed:export function setup(options = {}) { const { neoConfig = {}, appConfig = {}, mockMain = true, // new mockLocalStorage = true // new } = options; // ... existing setup ... if (mockMain) { Neo.Main ??= { setRoute: () => {} }; } if (mockLocalStorage) { Neo.main.addon.LocalStorage ??= { readLocalStorageItem : async () => ({value: null}), updateLocalStorageItem: async () => {}, removeLocalStorageItem: async () => {} }; } }Both mocks use
??=so a caller that pre-installs richer mocks (e.g., a test that exercises route dispatch end-to-end) wins.Optional follow-up: factor each mock into a named export (e.g.,
mockNeoMain(),mockLocalStorage()) so downstream apps can compose them à la carte without re-invoking the fullsetup()cycle. Not required for this ticket.Acceptance Criteria
test/playwright/setup.mjsprovides default stub forNeo.Main.setRoute, opt-out viamockMain: false.test/playwright/setup.mjsprovides default stub forNeo.main.addon.LocalStorage(read / update / remove), opt-out viamockLocalStorage: false.??=so caller pre-installed mocks take precedence.Neo.container.Viewportsubclass with aNeo.controller.Componentcontroller that declares adefaultHashsucceeds viaNeo.create()and tears down cleanly viadestroy().learn/guides/testing/UnitTesting.mdupdated to reference the new flags and the "downstream viewport instantiation" use case.Out of Scope
Neo.MainbeyondsetRoute(e.g., the full worker-bridge protocol). The opt-out flag lets downstream specs install richer mocks when they need to.Neo.controller.Base.initAsyncto be defensive about a missingNeo.Main. Routing IS a runtime concern; the test environment should provide the stub, not the runtime code.Avoided Traps
{value: null}returns) are sufficient for the failure modes observed; deeper behavior is downstream's responsibility.Neo.Main.setRouteno-op whenunitTestModeis true insidecontroller/Base.mjs." Rejected per Out of Scope §3. Runtime code should not branch on test mode; that bleeds the test-mode boundary into runtime substrate.Related
Neo.main,Neo.currentWorker,Neo.worker,Neo.applyDeltasinsetup.mjs. Pivoted mid-flight to a different root cause (trap()for delayed async imports on destroyed components) and closed without landing the centralized-mock surface this ticket targets.Neo.Main.setRouteandNeo.main.addon.LocalStoragewere never on its list. This ticket explicitly fills that gap.Neo.main.addon.DragDrop.setDragProxyElementinside a single test'sbeforeEach. Second datapoint that per-spec ad-hoc mocks repeat across the suite; lifting universally-needed surfaces intosetup.mjsis the substrate-correct primitive.Handoff Retrieval Hint
Retrieval Hint: "setup.mjs mock Neo.Main setRoute LocalStorage downstream viewport unit test"Provenance
Origin: downstream dogfooding session in the client project test-management-tool (GitLab-hosted client app, dogfooding the Neo.mjs v13 dev branch). No Neo Memory Core session ID — the surfacing context lives outside the canonical swarm.