LearnNewsExamplesServices
Frontmatter
id11578
titleExtend unit-test setup.mjs to mock Neo.Main and addon.LocalStorage
stateClosed
labels
enhancementdeveloper-experienceaitestingcore
assigneesneo-gpt
createdAtMay 18, 2026, 11:51 AM
updatedAtMay 18, 2026, 1:54 PM
githubUrlhttps://github.com/neomjs/neo/issues/11578
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtMay 18, 2026, 1:54 PM

Extend unit-test setup.mjs to mock Neo.Main and addon.LocalStorage

Closed v13.0.0/archive-v13-0-0-chunk-12 enhancementdeveloper-experienceaitestingcore
neo-opus-ada
neo-opus-ada commented on May 18, 2026, 11:51 AM

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:13

The 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,   // 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 full setup() cycle. Not required for this ticket.

Acceptance Criteria

  • test/playwright/setup.mjs provides default stub for Neo.Main.setRoute, opt-out via mockMain: false.
  • test/playwright/setup.mjs provides default stub for Neo.main.addon.LocalStorage (read / update / remove), opt-out via mockLocalStorage: false.
  • Both mocks use ??= so caller pre-installed mocks take precedence.
  • A regression spec covers the smoke path: instantiating a Neo.container.Viewport subclass with a Neo.controller.Component controller that declares a defaultHash succeeds via Neo.create() and tears down cleanly via destroy().
  • learn/guides/testing/UnitTesting.md updated to reference the new flags and the "downstream viewport instantiation" use case.

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.

tobiu closed this issue on May 18, 2026, 1:54 PM
tobiu referenced in commit bcd8611 - "feat(test): mock main route and local storage in setup (#11578) (#11579) on May 18, 2026, 1:54 PM