From fc020dc1bf1639b9389c0fe67e7541891f440b8a Mon Sep 17 00:00:00 2001 From: Igor Savin Date: Sun, 5 Jul 2026 23:43:11 +0300 Subject: [PATCH 1/3] refactor(journeys): extract framework-neutral @modular-frontend/journeys-engine PR-02 of the Vue-support initiative. Split the framework-neutral guts of @modular-react/journeys into a new @modular-frontend/journeys-engine package so a future Vue binding can reuse them, mirroring the @modular-frontend/core extraction from #54. - New packages/journeys-engine (@modular-frontend/journeys-engine): the runtime, validation, persistence, authoring helpers (defineJourney/defineTransition/ selectModule), handles, simulate-journey, the test harness, and the full type surface, plus their non-React tests. Depends only on @modular-frontend/core (happy-dom is a dev dep for the storage-backed persistence tests). Two entries: index + /testing. - @modular-react/journeys keeps its React files (outlet, module-tab, provider, plugin, instance-hooks, use-journey-state, use-wait-for-exit, mount-adapter) and re-exports the engine, so its public export surface is unchanged. A thin testing.ts re-exports the engine's /testing entry, keeping the existing @modular-react/journeys/testing import path working. Two forced deviations from the tracker's PR-02 plan: - mount-adapter.ts stays in the binding, not the engine: createJourneyMountAdapter supplies Outlet: JourneyOutlet (a React component), so it is binding-specific glue over the neutral RuntimeMountAdapter seam rather than engine logic. - JourneyNavContribution.icon used the React.ComponentType namespace; it moves to the neutral UiComponent seam, matching how NavigationItem.icon was neutralized in #54. Source-compatible for authors (a React component still satisfies UiComponent). Error-message prefixes stay [@modular-react/journeys] / [@modular-react/journeys/ testing] on purpose: they name the package users import and point at real import paths, so the moved tests pass unmodified. Verification: engine 346 tests (26 files) + journeys 72 tests (8 files) = the pre-split total; @modular-react/compositions (the createJourneyMountAdapter consumer) 115 tests pass; full workspace typecheck (110 tasks) and pnpm lint clean. The only failing tests are the pre-existing Windows EPERM .test-output flake in the two router CLIs, unrelated to this change. --- docs/vue-support-tracker.md | 38 ++-- packages/journeys-engine/README.md | 31 ++++ packages/journeys-engine/package.json | 48 +++++ .../src/build-input.test.ts | 8 +- .../src/define-journey.ts | 4 +- .../src/define-transition.test-d.ts | 8 +- .../src/define-transition.test.ts | 0 .../src/define-transition.ts | 2 +- .../src/handle.test-d.ts | 2 +- .../src/handle.test.ts | 2 +- .../src/handle.ts | 4 +- packages/journeys-engine/src/index.ts | 100 +++++++++++ .../src/invoke-cycle-safety.test.ts | 2 +- .../src/invoke.test.ts | 2 +- .../src/mount-kinds.test-d.ts | 10 +- .../src/persistence.test-d.ts | 0 .../src/persistence.test.ts | 2 +- .../src/persistence.ts | 0 .../src/register-options.test-d.ts | 2 +- .../src/register-options.test.ts | 2 +- .../src/runtime-go-back.test.ts | 2 +- .../src/runtime-go-forward.test.ts | 2 +- .../src/runtime-rewind-to.test.ts | 2 +- .../src/runtime.test-d.ts | 2 +- .../src/runtime.test.ts | 2 +- .../src/runtime.ts | 4 +- .../src/select-module.test-d.ts | 2 +- .../src/select-module.test.ts | 2 +- .../src/select-module.ts | 2 +- .../src/simulate-journey-invoke.test.ts | 2 +- .../src/simulate-journey.test-d.ts | 2 +- .../src/simulate-journey.test.ts | 2 +- .../src/simulate-journey.ts | 4 +- .../src/testing.test.ts | 2 +- packages/journeys-engine/src/testing.ts | 158 +++++++++++++++++ .../src/types.ts | 11 +- .../src/validation.test.ts | 2 +- .../src/validation.ts | 4 +- .../src/wildcard-transitions.test-d.ts | 2 +- .../src/wildcard-transitions.test.ts | 2 +- packages/journeys-engine/tsconfig.json | 9 + packages/journeys-engine/vite.config.ts | 19 ++ packages/journeys-engine/vitest.config.ts | 15 ++ packages/journeys/package.json | 3 + packages/journeys/src/index.ts | 34 ++-- packages/journeys/src/mount-adapter.ts | 2 +- .../journeys/src/mount-kinds-runtime.test.tsx | 4 +- packages/journeys/src/outlet-invoke.test.tsx | 6 +- packages/journeys/src/outlet-preload.test.tsx | 6 +- packages/journeys/src/outlet.test.tsx | 4 +- packages/journeys/src/outlet.tsx | 6 +- packages/journeys/src/plugin.tsx | 6 +- packages/journeys/src/provider.test.tsx | 4 +- packages/journeys/src/provider.tsx | 2 +- packages/journeys/src/testing.ts | 164 ++---------------- .../journeys/src/use-journey-state.test.tsx | 8 +- packages/journeys/vite.config.ts | 2 + pnpm-lock.yaml | 32 ++++ 58 files changed, 558 insertions(+), 246 deletions(-) create mode 100644 packages/journeys-engine/README.md create mode 100644 packages/journeys-engine/package.json rename packages/{journeys => journeys-engine}/src/build-input.test.ts (99%) rename packages/{journeys => journeys-engine}/src/define-journey.ts (94%) rename packages/{journeys => journeys-engine}/src/define-transition.test-d.ts (99%) rename packages/{journeys => journeys-engine}/src/define-transition.test.ts (100%) rename packages/{journeys => journeys-engine}/src/define-transition.ts (99%) rename packages/{journeys => journeys-engine}/src/handle.test-d.ts (99%) rename packages/{journeys => journeys-engine}/src/handle.test.ts (99%) rename packages/{journeys => journeys-engine}/src/handle.ts (93%) create mode 100644 packages/journeys-engine/src/index.ts rename packages/{journeys => journeys-engine}/src/invoke-cycle-safety.test.ts (99%) rename packages/{journeys => journeys-engine}/src/invoke.test.ts (99%) rename packages/{journeys => journeys-engine}/src/mount-kinds.test-d.ts (97%) rename packages/{journeys => journeys-engine}/src/persistence.test-d.ts (100%) rename packages/{journeys => journeys-engine}/src/persistence.test.ts (99%) rename packages/{journeys => journeys-engine}/src/persistence.ts (100%) rename packages/{journeys => journeys-engine}/src/register-options.test-d.ts (99%) rename packages/{journeys => journeys-engine}/src/register-options.test.ts (99%) rename packages/{journeys => journeys-engine}/src/runtime-go-back.test.ts (99%) rename packages/{journeys => journeys-engine}/src/runtime-go-forward.test.ts (99%) rename packages/{journeys => journeys-engine}/src/runtime-rewind-to.test.ts (99%) rename packages/{journeys => journeys-engine}/src/runtime.test-d.ts (99%) rename packages/{journeys => journeys-engine}/src/runtime.test.ts (99%) rename packages/{journeys => journeys-engine}/src/runtime.ts (99%) rename packages/{journeys => journeys-engine}/src/select-module.test-d.ts (99%) rename packages/{journeys => journeys-engine}/src/select-module.test.ts (99%) rename packages/{journeys => journeys-engine}/src/select-module.ts (99%) rename packages/{journeys => journeys-engine}/src/simulate-journey-invoke.test.ts (99%) rename packages/{journeys => journeys-engine}/src/simulate-journey.test-d.ts (99%) rename packages/{journeys => journeys-engine}/src/simulate-journey.test.ts (99%) rename packages/{journeys => journeys-engine}/src/simulate-journey.ts (98%) rename packages/{journeys => journeys-engine}/src/testing.test.ts (99%) create mode 100644 packages/journeys-engine/src/testing.ts rename packages/{journeys => journeys-engine}/src/types.ts (98%) rename packages/{journeys => journeys-engine}/src/validation.test.ts (99%) rename packages/{journeys => journeys-engine}/src/validation.ts (99%) rename packages/{journeys => journeys-engine}/src/wildcard-transitions.test-d.ts (99%) rename packages/{journeys => journeys-engine}/src/wildcard-transitions.test.ts (99%) create mode 100644 packages/journeys-engine/tsconfig.json create mode 100644 packages/journeys-engine/vite.config.ts create mode 100644 packages/journeys-engine/vitest.config.ts diff --git a/docs/vue-support-tracker.md b/docs/vue-support-tracker.md index 2c61f536..5e5f48c3 100644 --- a/docs/vue-support-tracker.md +++ b/docs/vue-support-tracker.md @@ -1,6 +1,6 @@ # Vue support initiative: plan and tracker -Status: **not started**. Last updated: 2026-07-05. +Status: **Phase 0 in progress** (PR-01, PR-02 landed). Last updated: 2026-07-05. Background and feasibility reasoning: [vue-port-analysis.md](./vue-port-analysis.md). This document is the single source of truth for the multi-PR effort to bring the framework to Vue 3, including full Journeys and Compositions support. Update the status board and per-PR checkboxes as PRs land; record decision outcomes in the Decisions section. @@ -36,12 +36,14 @@ Ship a Vue 3 + vue-router package family with feature parity to `@react-router-m | `@vue-router-modules/testing` | `@react-router-modules/testing` | `renderModule`, `renderJourney`, mock store | | `@vue-router-modules/cli` | `@react-router-modules/cli` | `cli-core` preset + SFC templates | -Shared engine packages extracted in Phase 0 (exact scope name is decision D2): +Shared engine packages extracted in Phase 0 under the `@modular-frontend` scope (decision D2, resolved): -| New package | Extracted from | Contents | -| ------------------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/journeys-engine` | `@modular-react/journeys` | `runtime.ts`, `validation.ts`, `define-journey.ts`, `define-transition.ts`, `persistence.ts`, `select-module.ts`, `simulate-journey.ts`, `handle.ts`, `mount-adapter.ts`, `types.ts`, `testing.ts` | -| `/compositions-engine` | `@modular-react/compositions` | `runtime.ts`, `stores.ts`, `validation.ts`, `define-composition.ts`, `types.ts` | +| New package | Extracted from | Contents | +| -------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@modular-frontend/journeys-engine` | `@modular-react/journeys` | `runtime.ts`, `validation.ts`, `define-journey.ts`, `define-transition.ts`, `persistence.ts`, `select-module.ts`, `simulate-journey.ts`, `handle.ts`, `types.ts`, `testing.ts` | +| `@modular-frontend/compositions-engine` | `@modular-react/compositions` | `runtime.ts`, `stores.ts`, `validation.ts`, `define-composition.ts`, `types.ts` | + +`mount-adapter.ts` stays in `@modular-react/journeys`, not the engine: `createJourneyMountAdapter` supplies `Outlet: JourneyOutlet` (a React component), so it is binding-specific glue over the neutral `RuntimeMountAdapter` seam rather than engine logic. `@modular-react/journeys` and `@modular-react/compositions` keep their public API by re-exporting the engine, so existing React users see no breaking change. @@ -52,7 +54,7 @@ Record the outcome inline when made. Blockers are marked per PR. | ID | Decision | Recommendation | Status | | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | | D1 | npm scopes for the Vue family (`@modular-vue`, `@vue-router-modules`) and repo positioning (does the `modular-react` repo host Vue packages, or does it get a neutral name?) | Keep this repo, add the scopes, soften the README tagline. Reserve both scopes on npm before any code PR. | open | -| D2 | Scope and name for the extracted engines | A neutral scope shared by both families, e.g. `@modular-frontend`, holding `journeys-engine` and `compositions-engine`. Avoid putting "react" or "vue" in the name. | open | +| D2 | Scope and name for the extracted engines | A neutral scope shared by both families, e.g. `@modular-frontend`, holding `journeys-engine` and `compositions-engine`. Avoid putting "react" or "vue" in the name. | resolved: `@modular-frontend` (core extracted in #54; `journeys-engine` in PR-02) | | D3 | Store story for Vue templates: core store vs Pinia | Scaffold with the core `createStore` (zustand-shaped, already the framework contract) and document Pinia interop in a guide section. Do not take a Pinia dependency in runtime packages. | open | | D4 | Authoring style inside Vue library packages: SFC vs `defineComponent` + render functions | `defineComponent` + `h()` for library internals (no `@vitejs/plugin-vue` needed in package builds, better generics); SFCs in CLI templates and the example app, since that is what users write. | open | | D5 | Minimum supported versions | Vue ^3.5, vue-router ^4.5, Node 22+, aligned with the React 19 / Node 22 baseline. | open | @@ -66,13 +68,19 @@ Sizes: S (under ~300 LOC changed), M (~300-1000), L (over 1000, mostly mechanica Everything here is useful to the existing React packages on its own and ships as normal releases. -**PR-01 (S): Neutralize renderable types in `@modular-react/core`.** -Replace the `ComponentType` / `ReactNode` type imports in `plugin.ts` and `runtime-mount.ts` with neutral aliases (a `Renderable` type parameter or `unknown`-based alias), keeping the existing React-facing types as aliases so nothing breaks. Demote or drop the `@types/react` optional peer. -Acceptance: zero React references in core, including types; all core tests and both router families' type tests pass unchanged. +**PR-01 (S): Neutralize renderable types in `@modular-react/core`.** Done in #54. +Landed as a full extraction rather than in-place aliasing: the framework-neutral guts of `@modular-react/core` moved to a new `@modular-frontend/core` package, with the `ComponentType` / `ReactNode` references collapsed to a two-line `UiComponent` / `UiNode` seam in `ui-types.ts` and `@types/react` dropped. `@modular-react/core` is now a thin facade re-exporting the neutral surface, so consumers are unchanged. This also resolves D2 (scope = `@modular-frontend`). +Acceptance: met. Zero React references in `@modular-frontend/core`; all core tests and both router families' type tests pass unchanged. + +**PR-02 (L, mostly moves): Extract `@modular-frontend/journeys-engine`.** Done. +Moved the pure files plus their tests (`runtime*.test.ts`, `validation.test.ts`, `define-transition.test*`, `persistence.test*`, `select-module.test*`, `simulate-journey*.test*`, `handle.test*`, `wildcard-transitions.test*`, `register-options.test*`, `invoke*.test.ts`, `build-input.test.ts`, `mount-kinds.test-d.ts`). `@modular-react/journeys` re-exports the engine and keeps the React files (`outlet.tsx`, `module-tab.tsx`, `provider.tsx`, `plugin.tsx`, `mount-adapter.ts`, `instance-hooks.ts`, `use-journey-state.ts`, `use-wait-for-exit.ts`), plus a thin `testing.ts` re-export of the engine's `/testing` entry. +Deviations from the original plan, both forced by the code: + +- `mount-adapter.ts` stayed in the binding (see the package-map note): it supplies the React `JourneyOutlet` to the mount adapter. +- `JourneyNavContribution.icon` used the `React.ComponentType` namespace; it moved to the neutral `UiComponent` seam, matching how `NavigationItem.icon` was already neutralized in #54. Source-compatible for authors (a React component still satisfies `UiComponent`). -**PR-02 (L, mostly moves): Extract `/journeys-engine`.** Blocked by D2. -Move the pure files listed in the package map, plus their tests (`runtime*.test.ts`, `validation.test.ts`, `define-transition.test*`, `persistence.test*`, `select-module.test*`, `simulate-journey*.test*`, `handle.test*`, `wildcard-transitions.test*`, `register-options.test*`, `invoke*.test.ts`, `build-input.test.ts`). `@modular-react/journeys` re-exports everything and keeps only the React files (`outlet.tsx`, `module-tab.tsx`, `provider.tsx`, `plugin.tsx`, `instance-hooks.ts`, `use-journey-state.ts`, `use-wait-for-exit.ts`). -Acceptance: the engine package has no React peer or dev dependency; `@modular-react/journeys` public API is byte-identical (verify via its `index.ts` exports and existing tests passing unmodified); test counts preserved across the two packages. +The engine keeps error-message prefixes as `[@modular-react/journeys]` / `[@modular-react/journeys/testing]` on purpose: they name the package users import and the guidance strings point at real import paths, so the moved tests pass unmodified. +Acceptance: met. The engine has no React peer or dev dependency (deps: `@modular-frontend/core`; `happy-dom` for the storage-backed persistence tests). `@modular-react/journeys` public export surface is unchanged. Test counts preserved: 346 in the engine + 72 in the binding = the pre-split total. **PR-03 (L, mostly moves): Extract `/compositions-engine`.** Blocked by D2. Same treatment for `runtime.ts`, `stores.ts`, `validation.ts`, `define-composition.ts`, `types.ts` and their non-`.tsx` tests. Note `stores.ts` contains `useSyncExternalStore`-motivated referential-stability logic; keep it as-is in the engine (it is still pure), and let the Vue binding ignore the parts it does not need. @@ -210,8 +218,8 @@ Update the Status column as PRs move: `todo` → `in progress` → `in review` | PR | Title | Size | Depends on | Status | | ----- | ------------------------------------------- | ---- | ------------------- | ------ | -| PR-01 | Neutralize renderable types in core | S | — | todo | -| PR-02 | Extract journeys engine | L | D2 | todo | +| PR-01 | Neutralize renderable types in core | S | — | done (#54) | +| PR-02 | Extract journeys engine | L | D2 | done | | PR-03 | Extract compositions engine | L | D2 | todo | | PR-04 | cli-core framework-pluggable templates | M | — | todo | | PR-05 | CI/publish plumbing, scope reservation | S | D1 | todo | diff --git a/packages/journeys-engine/README.md b/packages/journeys-engine/README.md new file mode 100644 index 00000000..7750a136 --- /dev/null +++ b/packages/journeys-engine/README.md @@ -0,0 +1,31 @@ +# @modular-frontend/journeys-engine + +The framework-neutral engine behind [Journeys](https://github.com/kibertoad/modular-react#readme): typed, serializable workflows that compose several modules. It owns the journey runtime, validation, persistence, authoring helpers, handles, and the type surface, with no UI-framework dependency. + +This is the shared foundation the framework bindings build on. The React binding (`@modular-react/journeys`) re-exports this package and adds the UI layer (outlet, provider, hooks); a future `@modular-vue/journeys` will do the same over the same engine. + +## Installation + +```bash +npm install @modular-frontend/journeys-engine +``` + +Most apps depend on a binding (`@modular-react/journeys`) rather than on this package directly. Use `@modular-frontend/journeys-engine` when building a new framework binding or framework-agnostic tooling over journeys. + +## What's included + +- **Runtime**: `createJourneyRuntime`, `getInternals` (low-level accessor the outlet and test harness drive), `JourneyRuntimeOptions`. +- **Validation**: `validateJourneyContracts`, `validateJourneyDefinition`, `validateJourneyGraph`, and the `JourneyValidationError` / `JourneyHydrationError` / `UnknownJourneyError` classes. +- **Persistence**: `defineJourneyPersistence`, `createWebStoragePersistence`, `createMemoryPersistence`. +- **Authoring helpers**: `defineJourney`, `defineTransition`, `isAnnotatedTransition`, `isTerminalSentinel`, `selectModule`, `selectModuleOrDefault`. +- **Handles**: `defineJourneyHandle`, `invoke`. +- **Mount adapter**: `createJourneyMountAdapter` (embed a journey runtime through the generic `RuntimeMountAdapter` shape). +- **Types**: the full journey type surface (`JourneyDefinition`, `JourneyInstance`, `JourneyStep`, `TransitionMap`, wildcard maps, and the rest). + +### `@modular-frontend/journeys-engine/testing` + +Framework-neutral test helpers: `createTestHarness` (drive a live runtime from a test without mounting an outlet) and `simulateJourney` (pure-logic transition simulation). + +## Full documentation + +See the [main documentation](https://github.com/kibertoad/modular-react#readme) for the full guide. diff --git a/packages/journeys-engine/package.json b/packages/journeys-engine/package.json new file mode 100644 index 00000000..d46ba5fc --- /dev/null +++ b/packages/journeys-engine/package.json @@ -0,0 +1,48 @@ +{ + "name": "@modular-frontend/journeys-engine", + "version": "1.7.1", + "description": "Framework-neutral journey engine: runtime, validation, persistence, authoring helpers, handles, and types for typed, serializable multi-module workflows. No UI-framework dependency — the outlet and hooks live in a binding package.", + "repository": { + "type": "git", + "url": "git://github.com/kibertoad/modular-react.git", + "directory": "packages/journeys-engine" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "import": "./dist/testing.js" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "test": "vitest run", + "prepublishOnly": "pnpm build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modular-frontend/core": "workspace:*" + }, + "devDependencies": { + "happy-dom": "^20.9.0", + "oxfmt": "^0.51.0", + "oxlint": "^1.66.0", + "typescript": "^6.0.3", + "vite": "^8.0.11", + "vite-plugin-dts": "^4.5.4", + "vitest": "^4.1.5" + } +} diff --git a/packages/journeys/src/build-input.test.ts b/packages/journeys-engine/src/build-input.test.ts similarity index 99% rename from packages/journeys/src/build-input.test.ts rename to packages/journeys-engine/src/build-input.test.ts index 73e0f995..33d937a3 100644 --- a/packages/journeys/src/build-input.test.ts +++ b/packages/journeys-engine/src/build-input.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { buildInputFor, defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { + buildInputFor, + defineEntry, + defineExit, + defineModule, + schema, +} from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { defineJourneyHandle } from "./handle.js"; diff --git a/packages/journeys/src/define-journey.ts b/packages/journeys-engine/src/define-journey.ts similarity index 94% rename from packages/journeys/src/define-journey.ts rename to packages/journeys-engine/src/define-journey.ts index a874a418..29065125 100644 --- a/packages/journeys/src/define-journey.ts +++ b/packages/journeys-engine/src/define-journey.ts @@ -1,4 +1,4 @@ -import type { CatalogMeta } from "@modular-react/core"; +import type { CatalogMeta } from "@modular-frontend/core"; import type { JourneyDefinition, ModuleTypeMap } from "./types.js"; /** @@ -34,7 +34,7 @@ import type { JourneyDefinition, ModuleTypeMap } from "./types.js"; * * Zero runtime cost — the definition is returned unchanged. * - * @see `buildInputFor` in `@modular-react/core` — same visual two-call + * @see `buildInputFor` in `@modular-frontend/core` — same visual two-call * curry, used for the entry-side `buildInput` factory. */ export const defineJourney = diff --git a/packages/journeys/src/define-transition.test-d.ts b/packages/journeys-engine/src/define-transition.test-d.ts similarity index 99% rename from packages/journeys/src/define-transition.test-d.ts rename to packages/journeys-engine/src/define-transition.test-d.ts index 9b52d882..27d63f1a 100644 --- a/packages/journeys/src/define-transition.test-d.ts +++ b/packages/journeys-engine/src/define-transition.test-d.ts @@ -6,7 +6,13 @@ // returned handler shape. import { expectTypeOf, test } from "vitest"; -import { buildInputFor, defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { + buildInputFor, + defineEntry, + defineExit, + defineModule, + schema, +} from "@modular-frontend/core"; import { type AnnotatedTransitionHandler, diff --git a/packages/journeys/src/define-transition.test.ts b/packages/journeys-engine/src/define-transition.test.ts similarity index 100% rename from packages/journeys/src/define-transition.test.ts rename to packages/journeys-engine/src/define-transition.test.ts diff --git a/packages/journeys/src/define-transition.ts b/packages/journeys-engine/src/define-transition.ts similarity index 99% rename from packages/journeys/src/define-transition.ts rename to packages/journeys-engine/src/define-transition.ts index d5dd8e77..65366792 100644 --- a/packages/journeys/src/define-transition.ts +++ b/packages/journeys-engine/src/define-transition.ts @@ -5,7 +5,7 @@ import type { ModuleTypeMap, StepInputSlot, TransitionResult, -} from "@modular-react/core"; +} from "@modular-frontend/core"; /** * Sentinel value declaring a non-`next` outcome on a wrapped transition diff --git a/packages/journeys/src/handle.test-d.ts b/packages/journeys-engine/src/handle.test-d.ts similarity index 99% rename from packages/journeys/src/handle.test-d.ts rename to packages/journeys-engine/src/handle.test-d.ts index 0789ac73..92a14c82 100644 --- a/packages/journeys/src/handle.test-d.ts +++ b/packages/journeys-engine/src/handle.test-d.ts @@ -8,7 +8,7 @@ // `@ts-expect-error` directives and the explicit `expectTypeOf` checks. import { expectTypeOf, test } from "vitest"; -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import type { InstanceId } from "./types.js"; import { defineJourney } from "./define-journey.js"; import { defineJourneyHandle, type JourneyHandle } from "./handle.js"; diff --git a/packages/journeys/src/handle.test.ts b/packages/journeys-engine/src/handle.test.ts similarity index 99% rename from packages/journeys/src/handle.test.ts rename to packages/journeys-engine/src/handle.test.ts index 7225220e..e20a3212 100644 --- a/packages/journeys/src/handle.test.ts +++ b/packages/journeys-engine/src/handle.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { createJourneyRuntime, getInternals } from "./runtime.js"; import { defineJourneyHandle } from "./handle.js"; diff --git a/packages/journeys/src/handle.ts b/packages/journeys-engine/src/handle.ts similarity index 93% rename from packages/journeys/src/handle.ts rename to packages/journeys-engine/src/handle.ts index 1b643fcc..bd005cfe 100644 --- a/packages/journeys/src/handle.ts +++ b/packages/journeys-engine/src/handle.ts @@ -1,11 +1,11 @@ -import type { InvokeSpec, JourneyHandleRef } from "@modular-react/core"; +import type { InvokeSpec, JourneyHandleRef } from "@modular-frontend/core"; import type { JourneyDefinition, ModuleTypeMap } from "./types.js"; /** * Lightweight token a journey exports so modules and shells can open it * with a typed `input` (and a typed `outcome.payload` when invoked from a * parent journey) without pulling in the journey's runtime code. - * Structurally identical to `JourneyHandleRef` in `@modular-react/core` — + * Structurally identical to `JourneyHandleRef` in `@modular-frontend/core` — * re-exported here so authors have a single canonical name to import. * * The `__input` and `__output` fields are phantom: they never hold values diff --git a/packages/journeys-engine/src/index.ts b/packages/journeys-engine/src/index.ts new file mode 100644 index 00000000..b9a0491b --- /dev/null +++ b/packages/journeys-engine/src/index.ts @@ -0,0 +1,100 @@ +// Public surface of @modular-frontend/journeys-engine. +// +// The framework-neutral journey engine: runtime, validation, persistence, +// authoring helpers, handles, and the full type surface. Nothing here depends +// on a UI framework. Framework bindings (@modular-react/journeys, a future +// @modular-vue/journeys) re-export the pieces their users need and add the UI +// layer (outlet, provider, hooks) on top. + +export { defineJourney } from "./define-journey.js"; +export { + defineJourneyPersistence, + createWebStoragePersistence, + createMemoryPersistence, +} from "./persistence.js"; +export type { + WebStoragePersistenceOptions, + MemoryPersistenceOptions, + MemoryPersistence, + SyncJourneyPersistence, +} from "./persistence.js"; + +// `createJourneyRuntime` is the public factory; `getInternals` is the +// low-level accessor the outlet and the test harness drive. Bindings that +// render an outlet import `getInternals` from here; they do not re-export it. +export { createJourneyRuntime, getInternals, type JourneyRuntimeOptions } from "./runtime.js"; +export { + JourneyValidationError, + JourneyHydrationError, + UnknownJourneyError, + validateJourneyContracts, + validateJourneyDefinition, + validateJourneyGraph, +} from "./validation.js"; + +// Authoring helpers — exhaustive (and fallback) state-driven dispatch. +export { selectModule, selectModuleOrDefault } from "./select-module.js"; +export type { SelectModuleCases, SelectModuleCasesPartial } from "./select-module.js"; + +// Authoring helpers — annotate a transition handler with the entry points it +// can advance into. +export { + defineTransition, + isAnnotatedTransition, + isTerminalSentinel, +} from "./define-transition.js"; +export type { AnnotatedTransitionHandler, StepRef, TerminalSentinel } from "./define-transition.js"; + +// Handles — open a journey with typed `input` without importing its runtime. +export { defineJourneyHandle, invoke } from "./handle.js"; +export type { JourneyHandle } from "./handle.js"; + +export type { + AbandonCtx, + AnyJourneyDefinition, + ChildOutcome, + EntryExitWildcardMap, + EntryInputOf, + EntryNamesOf, + EntryTransitions, + ExitCtx, + ExitNamesOf, + ExitNamesPairedWithEntry, + ExitOnlyWildcardMap, + ExitOutputOf, + InstanceId, + InvokeSpec, + JourneyDefinition, + JourneyDefinitionSummary, + JourneyInstance, + JourneyNavContribution, + JourneyPersistence, + JourneyRegisterOptions, + JourneyRuntime, + JourneyStatus, + JourneyStep, + JourneyStepFor, + JourneySystemAbortReason, + JourneySystemAbortReasonCode, + MaybePromise, + ModuleTypeMap, + ParentLink, + PendingInvoke, + RegisteredJourney, + ResumeBounceCounter, + ResumeHandler, + ResumeMap, + SerializedJourney, + StepSpec, + TerminalCtx, + TerminalOutcome, + TransitionEvent, + TransitionMap, + TransitionResult, + WildcardEntryInputOf, + WildcardEntryNamesOf, + WildcardExitNamesOf, + WildcardExitOutputForEntry, + WildcardExitOutputOf, + WildcardTransitionMap, +} from "./types.js"; diff --git a/packages/journeys/src/invoke-cycle-safety.test.ts b/packages/journeys-engine/src/invoke-cycle-safety.test.ts similarity index 99% rename from packages/journeys/src/invoke-cycle-safety.test.ts rename to packages/journeys-engine/src/invoke-cycle-safety.test.ts index cd9de0a8..480774dc 100644 --- a/packages/journeys/src/invoke-cycle-safety.test.ts +++ b/packages/journeys-engine/src/invoke-cycle-safety.test.ts @@ -10,7 +10,7 @@ import { defineModule, isJourneySystemAbort, schema, -} from "@modular-react/core"; +} from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { defineJourneyHandle, invoke } from "./handle.js"; import { createJourneyRuntime } from "./runtime.js"; diff --git a/packages/journeys/src/invoke.test.ts b/packages/journeys-engine/src/invoke.test.ts similarity index 99% rename from packages/journeys/src/invoke.test.ts rename to packages/journeys-engine/src/invoke.test.ts index 0f321a90..cf956d08 100644 --- a/packages/journeys/src/invoke.test.ts +++ b/packages/journeys-engine/src/invoke.test.ts @@ -14,7 +14,7 @@ import { defineModule, isJourneySystemAbort, schema, -} from "@modular-react/core"; +} from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { defineJourneyHandle, invoke } from "./handle.js"; import { createJourneyRuntime } from "./runtime.js"; diff --git a/packages/journeys/src/mount-kinds.test-d.ts b/packages/journeys-engine/src/mount-kinds.test-d.ts similarity index 97% rename from packages/journeys/src/mount-kinds.test-d.ts rename to packages/journeys-engine/src/mount-kinds.test-d.ts index cc991f1f..305a9795 100644 --- a/packages/journeys/src/mount-kinds.test-d.ts +++ b/packages/journeys-engine/src/mount-kinds.test-d.ts @@ -17,7 +17,13 @@ */ import { describe, expectTypeOf, it } from "vitest"; -import { defineEntry, defineExit, defineModule, schema, type StepSpec } from "@modular-react/core"; +import { + defineEntry, + defineExit, + defineModule, + schema, + type StepSpec, +} from "@modular-frontend/core"; // --------------------------------------------------------------------------- // Fixture: same shape as the compositions test, exercised against @@ -159,5 +165,5 @@ describe("StepSpec mirrors the composition filter (across the boundary)", () => * * To re-verify, flip any negative assertion (.not.toExtend ↔ .toExtend, * .toEqualTypeOf ↔ .toEqualTypeOf) and rerun - * `pnpm -F @modular-react/journeys test`. Restore afterward. + * `pnpm -F @modular-frontend/journeys-engine test`. Restore afterward. * ============================================================================ */ diff --git a/packages/journeys/src/persistence.test-d.ts b/packages/journeys-engine/src/persistence.test-d.ts similarity index 100% rename from packages/journeys/src/persistence.test-d.ts rename to packages/journeys-engine/src/persistence.test-d.ts diff --git a/packages/journeys/src/persistence.test.ts b/packages/journeys-engine/src/persistence.test.ts similarity index 99% rename from packages/journeys/src/persistence.test.ts rename to packages/journeys-engine/src/persistence.test.ts index 89c5d9cf..df142d84 100644 --- a/packages/journeys/src/persistence.test.ts +++ b/packages/journeys-engine/src/persistence.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import type { SerializedJourney } from "./types.js"; import { defineJourney } from "./define-journey.js"; import { createJourneyRuntime } from "./runtime.js"; diff --git a/packages/journeys/src/persistence.ts b/packages/journeys-engine/src/persistence.ts similarity index 100% rename from packages/journeys/src/persistence.ts rename to packages/journeys-engine/src/persistence.ts diff --git a/packages/journeys/src/register-options.test-d.ts b/packages/journeys-engine/src/register-options.test-d.ts similarity index 99% rename from packages/journeys/src/register-options.test-d.ts rename to packages/journeys-engine/src/register-options.test-d.ts index 2252b68f..fe559149 100644 --- a/packages/journeys/src/register-options.test-d.ts +++ b/packages/journeys-engine/src/register-options.test-d.ts @@ -1,5 +1,5 @@ import { describe, expectTypeOf, test } from "vitest"; -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { defineJourneyPersistence } from "./persistence.js"; import type { JourneyRegisterOptions } from "./types.js"; diff --git a/packages/journeys/src/register-options.test.ts b/packages/journeys-engine/src/register-options.test.ts similarity index 99% rename from packages/journeys/src/register-options.test.ts rename to packages/journeys-engine/src/register-options.test.ts index be6291bf..33584f32 100644 --- a/packages/journeys/src/register-options.test.ts +++ b/packages/journeys-engine/src/register-options.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { createJourneyRuntime, getInternals } from "./runtime.js"; diff --git a/packages/journeys/src/runtime-go-back.test.ts b/packages/journeys-engine/src/runtime-go-back.test.ts similarity index 99% rename from packages/journeys/src/runtime-go-back.test.ts rename to packages/journeys-engine/src/runtime-go-back.test.ts index 0f32f635..a462bfcd 100644 --- a/packages/journeys/src/runtime-go-back.test.ts +++ b/packages/journeys-engine/src/runtime-go-back.test.ts @@ -1,4 +1,4 @@ -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { describe, expect, it } from "vitest"; import { defineJourney } from "./define-journey.js"; diff --git a/packages/journeys/src/runtime-go-forward.test.ts b/packages/journeys-engine/src/runtime-go-forward.test.ts similarity index 99% rename from packages/journeys/src/runtime-go-forward.test.ts rename to packages/journeys-engine/src/runtime-go-forward.test.ts index 1d9a2bad..ddd6c95f 100644 --- a/packages/journeys/src/runtime-go-forward.test.ts +++ b/packages/journeys-engine/src/runtime-go-forward.test.ts @@ -1,4 +1,4 @@ -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { describe, expect, it, vi } from "vitest"; import { defineJourney } from "./define-journey.js"; diff --git a/packages/journeys/src/runtime-rewind-to.test.ts b/packages/journeys-engine/src/runtime-rewind-to.test.ts similarity index 99% rename from packages/journeys/src/runtime-rewind-to.test.ts rename to packages/journeys-engine/src/runtime-rewind-to.test.ts index 106667e1..5b33efa8 100644 --- a/packages/journeys/src/runtime-rewind-to.test.ts +++ b/packages/journeys-engine/src/runtime-rewind-to.test.ts @@ -1,4 +1,4 @@ -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { describe, expect, it, vi } from "vitest"; import { defineJourney } from "./define-journey.js"; diff --git a/packages/journeys/src/runtime.test-d.ts b/packages/journeys-engine/src/runtime.test-d.ts similarity index 99% rename from packages/journeys/src/runtime.test-d.ts rename to packages/journeys-engine/src/runtime.test-d.ts index 0cab7eaf..9ec60f4b 100644 --- a/packages/journeys/src/runtime.test-d.ts +++ b/packages/journeys-engine/src/runtime.test-d.ts @@ -1,5 +1,5 @@ import { describe, expectTypeOf, test } from "vitest"; -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { defineJourneyHandle } from "./handle.js"; import { createJourneyRuntime } from "./runtime.js"; diff --git a/packages/journeys/src/runtime.test.ts b/packages/journeys-engine/src/runtime.test.ts similarity index 99% rename from packages/journeys/src/runtime.test.ts rename to packages/journeys-engine/src/runtime.test.ts index e4fb7090..d2fb1038 100644 --- a/packages/journeys/src/runtime.test.ts +++ b/packages/journeys-engine/src/runtime.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { createJourneyRuntime, getInternals } from "./runtime.js"; import type { RegisteredJourney } from "./types.js"; diff --git a/packages/journeys/src/runtime.ts b/packages/journeys-engine/src/runtime.ts similarity index 99% rename from packages/journeys/src/runtime.ts rename to packages/journeys-engine/src/runtime.ts index 12a41d7a..449b3498 100644 --- a/packages/journeys/src/runtime.ts +++ b/packages/journeys-engine/src/runtime.ts @@ -1,11 +1,11 @@ -import { isDevEnv, isExitContract } from "@modular-react/core"; +import { isDevEnv, isExitContract } from "@modular-frontend/core"; import type { ExitContract, JourneyHandleRef, ModuleDescriptor, StandardSchemaIssue, StandardSchemaResult, -} from "@modular-react/core"; +} from "@modular-frontend/core"; import type { AnyJourneyDefinition, ChildOutcome, diff --git a/packages/journeys/src/select-module.test-d.ts b/packages/journeys-engine/src/select-module.test-d.ts similarity index 99% rename from packages/journeys/src/select-module.test-d.ts rename to packages/journeys-engine/src/select-module.test-d.ts index db0afe52..8a3a4a12 100644 --- a/packages/journeys/src/select-module.test-d.ts +++ b/packages/journeys-engine/src/select-module.test-d.ts @@ -14,7 +14,7 @@ import { defineModule, schema, type StepSpec, -} from "@modular-react/core"; +} from "@modular-frontend/core"; import { selectModule, selectModuleOrDefault } from "./select-module.js"; // ----------------------------------------------------------------------------- diff --git a/packages/journeys/src/select-module.test.ts b/packages/journeys-engine/src/select-module.test.ts similarity index 99% rename from packages/journeys/src/select-module.test.ts rename to packages/journeys-engine/src/select-module.test.ts index f8758df3..c8816e0e 100644 --- a/packages/journeys/src/select-module.test.ts +++ b/packages/journeys-engine/src/select-module.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { simulateJourney } from "./simulate-journey.js"; import { selectModule, selectModuleOrDefault } from "./select-module.js"; diff --git a/packages/journeys/src/select-module.ts b/packages/journeys-engine/src/select-module.ts similarity index 99% rename from packages/journeys/src/select-module.ts rename to packages/journeys-engine/src/select-module.ts index 08238d9b..def9e17d 100644 --- a/packages/journeys/src/select-module.ts +++ b/packages/journeys-engine/src/select-module.ts @@ -1,4 +1,4 @@ -import type { EntryNamesOf, ModuleTypeMap, StepInputSlot, StepSpec } from "@modular-react/core"; +import type { EntryNamesOf, ModuleTypeMap, StepInputSlot, StepSpec } from "@modular-frontend/core"; /** * One case in a `selectModule` map: an entry name on module `M` plus the diff --git a/packages/journeys/src/simulate-journey-invoke.test.ts b/packages/journeys-engine/src/simulate-journey-invoke.test.ts similarity index 99% rename from packages/journeys/src/simulate-journey-invoke.test.ts rename to packages/journeys-engine/src/simulate-journey-invoke.test.ts index 6cc9f831..fe0746d4 100644 --- a/packages/journeys/src/simulate-journey-invoke.test.ts +++ b/packages/journeys-engine/src/simulate-journey-invoke.test.ts @@ -4,7 +4,7 @@ // to unit-test the parent's resume handler in isolation. import { describe, expect, it } from "vitest"; -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { defineJourneyHandle, invoke } from "./handle.js"; import { simulateJourney } from "./simulate-journey.js"; diff --git a/packages/journeys/src/simulate-journey.test-d.ts b/packages/journeys-engine/src/simulate-journey.test-d.ts similarity index 99% rename from packages/journeys/src/simulate-journey.test-d.ts rename to packages/journeys-engine/src/simulate-journey.test-d.ts index 81ba6c33..54f2ecbd 100644 --- a/packages/journeys/src/simulate-journey.test-d.ts +++ b/packages/journeys-engine/src/simulate-journey.test-d.ts @@ -1,5 +1,5 @@ import { describe, expectTypeOf, test } from "vitest"; -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { simulateJourney, type JourneySimulator } from "./simulate-journey.js"; diff --git a/packages/journeys/src/simulate-journey.test.ts b/packages/journeys-engine/src/simulate-journey.test.ts similarity index 99% rename from packages/journeys/src/simulate-journey.test.ts rename to packages/journeys-engine/src/simulate-journey.test.ts index 4f5cc3c7..d29d90cb 100644 --- a/packages/journeys/src/simulate-journey.test.ts +++ b/packages/journeys-engine/src/simulate-journey.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { simulateJourney } from "./simulate-journey.js"; diff --git a/packages/journeys/src/simulate-journey.ts b/packages/journeys-engine/src/simulate-journey.ts similarity index 98% rename from packages/journeys/src/simulate-journey.ts rename to packages/journeys-engine/src/simulate-journey.ts index e62ce322..243772d2 100644 --- a/packages/journeys/src/simulate-journey.ts +++ b/packages/journeys-engine/src/simulate-journey.ts @@ -1,4 +1,4 @@ -import type { AnyModuleDescriptor } from "@modular-react/core"; +import type { AnyModuleDescriptor } from "@modular-frontend/core"; import { createJourneyRuntime, getInternals } from "./runtime.js"; import { createTestHarness } from "./testing.js"; @@ -140,7 +140,7 @@ export interface SimulateJourneyOptions { * a heterogeneous map of `{ name: typeof nameModule, email: * typeof emailModule }` passes structurally — including when the host * app narrows `TNavItem` to a custom action shape. The bivariance is - * localized to one definition site in `@modular-react/core` instead of + * localized to one definition site in `@modular-frontend/core` instead of * being repeated at every call site. * * ```ts diff --git a/packages/journeys/src/testing.test.ts b/packages/journeys-engine/src/testing.test.ts similarity index 99% rename from packages/journeys/src/testing.test.ts rename to packages/journeys-engine/src/testing.test.ts index 0659cdbd..4fcbbe1b 100644 --- a/packages/journeys/src/testing.test.ts +++ b/packages/journeys-engine/src/testing.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { createJourneyRuntime } from "./runtime.js"; import { createTestHarness } from "./testing.js"; diff --git a/packages/journeys-engine/src/testing.ts b/packages/journeys-engine/src/testing.ts new file mode 100644 index 00000000..382f4f46 --- /dev/null +++ b/packages/journeys-engine/src/testing.ts @@ -0,0 +1,158 @@ +import type { + InstanceId, + JourneyRuntime, + JourneyStatus, + JourneyStep, +} from "@modular-frontend/core"; + +import { getInternals } from "./runtime.js"; + +export { simulateJourney } from "./simulate-journey.js"; +export type { JourneySimulator } from "./simulate-journey.js"; + +/** + * Snapshot of the mutable runtime record for a single instance. Returned by + * `JourneyTestHarness.inspect` so tests can assert on fields that the + * public `JourneyInstance` surface intentionally does not expose (stepToken, + * retryCount). Everything else is also available via `runtime.getInstance`. + */ +export interface InstanceSnapshot { + readonly status: JourneyStatus; + readonly step: JourneyStep | null; + readonly state: TState; + readonly history: readonly JourneyStep[]; + readonly stepToken: number; + readonly retryCount: number; +} + +/** + * Test-only accessor that drives a runtime's internals from the outside — + * fire exits, walk back, peek at per-instance state. Prefer + * {@link simulateJourney} for pure-logic transition tests; use this when you + * already have a live runtime (e.g. one produced by the registry) and need + * to poke it from a test without mounting the outlet. + * + * The harness is the supported replacement for directly importing the + * runtime's `__`-prefixed internals, which are kept off the public export + * surface intentionally. + */ +export interface JourneyTestHarness { + fireExit(id: InstanceId, name: string, output?: unknown): void; + goBack(id: InstanceId): void; + goForward(id: InstanceId): void; + inspect(id: InstanceId): InstanceSnapshot; +} + +export function createTestHarness(runtime: JourneyRuntime): JourneyTestHarness { + const internals = getInternals(runtime); + + function recordOrThrow(id: InstanceId) { + const record = internals.__getRecord(id); + if (!record) { + throw new Error( + `[@modular-react/journeys/testing] No instance with id "${id}". Pass the id returned by runtime.start(...).`, + ); + } + return record; + } + + return { + fireExit(id, name, output) { + const record = recordOrThrow(id); + const reg = internals.__getRegistered(record.journeyId); + if (!reg) { + throw new Error( + `[@modular-react/journeys/testing] Journey "${record.journeyId}" is not registered with this runtime.`, + ); + } + // Calling fireExit on a loading instance is a silent no-op at the + // runtime level (the runtime has no step to resolve against yet). + // In tests this almost always indicates the caller forgot to await + // the persistence load probe. Throw early so the test fails on the + // offending call instead of on a later `expect(step?.entry)` read. + if (record.status === "loading") { + throw new Error( + `[@modular-react/journeys/testing] fireExit("${name}") called on instance "${id}" while status=loading. ` + + `Await the runtime's async load probe (typically \`await Promise.resolve()\` a few times, or expose a subscribe hook in your test) before dispatching exits.`, + ); + } + if (record.status !== "active") { + throw new Error( + `[@modular-react/journeys/testing] fireExit("${name}") called on terminal instance "${id}" (status=${record.status}).`, + ); + } + internals.__bindStepCallbacks(record, reg).exit(name, output); + }, + goBack(id) { + const record = recordOrThrow(id); + const reg = internals.__getRegistered(record.journeyId); + if (!reg) { + throw new Error( + `[@modular-react/journeys/testing] Journey "${record.journeyId}" is not registered with this runtime.`, + ); + } + if (record.status === "loading") { + throw new Error( + `[@modular-react/journeys/testing] goBack() called on instance "${id}" while status=loading. ` + + `Await the runtime's async load probe before dispatching.`, + ); + } + const callbacks = internals.__bindStepCallbacks(record, reg); + if (!callbacks.goBack) { + // Silently no-oping here would quietly "pass" a test that expects + // back navigation to work — the common `goBack walks back…` pattern + // asserts state *after* the call, so a no-op masks the wiring bug. + // Throw with context so the test fails on the offending call instead. + const stepLabel = record.step + ? `${record.step.moduleId}.${record.step.entry}` + : "(no step)"; + throw new Error( + `[@modular-react/journeys/testing] goBack is unavailable on instance "${id}" (step=${stepLabel}). ` + + `The journey's transition must declare allowBack: true AND the current step must have at least one history entry.`, + ); + } + callbacks.goBack(); + }, + goForward(id) { + const record = recordOrThrow(id); + const reg = internals.__getRegistered(record.journeyId); + if (!reg) { + throw new Error( + `[@modular-react/journeys/testing] Journey "${record.journeyId}" is not registered with this runtime.`, + ); + } + if (record.status === "loading") { + throw new Error( + `[@modular-react/journeys/testing] goForward() called on instance "${id}" while status=loading. ` + + `Await the runtime's async load probe before dispatching.`, + ); + } + const callbacks = internals.__bindStepCallbacks(record, reg); + if (!callbacks.goForward) { + // Empty future stack is the common case — fail loudly with a + // hint so the test author doesn't silently no-op past a + // missing `goBack` setup or a future-clearing exit between + // the rewind and the redo attempt. + throw new Error( + `[@modular-react/journeys/testing] goForward is unavailable on instance "${id}". ` + + `The runtime's future stack is empty — call goBack first, and don't fire a new exit between the rewind and the redo.`, + ); + } + callbacks.goForward(); + }, + inspect(id: InstanceId): InstanceSnapshot { + const record = recordOrThrow(id); + // Snapshot — `history` is a live array on the runtime record and will + // grow as the journey advances. Copy so assertions captured by the + // caller stay stable when the next `fireExit` runs. + return { + status: record.status, + step: record.step, + state: record.state as TState, + history: [...record.history], + stepToken: record.stepToken, + retryCount: record.retryCount, + }; + }, + }; +} diff --git a/packages/journeys/src/types.ts b/packages/journeys-engine/src/types.ts similarity index 98% rename from packages/journeys/src/types.ts rename to packages/journeys-engine/src/types.ts index 2c97418a..0521614f 100644 --- a/packages/journeys/src/types.ts +++ b/packages/journeys-engine/src/types.ts @@ -1,5 +1,5 @@ // The structural helpers, step shapes, runtime-facing types, and related -// contracts now live in `@modular-react/core` so the router-runtime packages +// contracts now live in `@modular-frontend/core` so the router-runtime packages // can describe a manifest that optionally includes a journey runtime without // taking a hard dependency on this package. We re-export those types here to // preserve the existing import surface for journey authors and shells. @@ -18,8 +18,9 @@ import type { TransitionEvent, TransitionMap, TransitionResult, + UiComponent, WildcardTransitionMap, -} from "@modular-react/core"; +} from "@modular-frontend/core"; export type { AbandonCtx, @@ -64,7 +65,7 @@ export type { WildcardExitOutputForEntry, WildcardExitOutputOf, WildcardTransitionMap, -} from "@modular-react/core"; +} from "@modular-frontend/core"; // ----------------------------------------------------------------------------- // Journey definition — stays in this package (authoring shape) @@ -236,8 +237,8 @@ export type AnyJourneyDefinition = JourneyDefinition; export interface JourneyNavContribution { /** Display label. Apps that type-narrow labels should reshape via `buildNavItem`. */ readonly label: string; - /** Icon — string identifier or React component (matches `NavigationItem.icon`). */ - readonly icon?: string | React.ComponentType<{ className?: string }>; + /** Icon — string identifier or component (matches `NavigationItem.icon`). */ + readonly icon?: string | UiComponent<{ className?: string }>; /** Grouping key for the navbar, same semantics as `NavigationItem.group`. */ readonly group?: string; /** Sort order within the group (lower wins). */ diff --git a/packages/journeys/src/validation.test.ts b/packages/journeys-engine/src/validation.test.ts similarity index 99% rename from packages/journeys/src/validation.test.ts rename to packages/journeys-engine/src/validation.test.ts index 0318b70f..9415b167 100644 --- a/packages/journeys/src/validation.test.ts +++ b/packages/journeys-engine/src/validation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { JourneyValidationError, diff --git a/packages/journeys/src/validation.ts b/packages/journeys-engine/src/validation.ts similarity index 99% rename from packages/journeys/src/validation.ts rename to packages/journeys-engine/src/validation.ts index 92f3cc58..b9b8a843 100644 --- a/packages/journeys/src/validation.ts +++ b/packages/journeys-engine/src/validation.ts @@ -4,8 +4,8 @@ import { parseVersion, satisfiesParsed, SemverParseError, -} from "@modular-react/core"; -import type { ExitContract, ModuleDescriptor } from "@modular-react/core"; +} from "@modular-frontend/core"; +import type { ExitContract, ModuleDescriptor } from "@modular-frontend/core"; import type { AnyJourneyDefinition, RegisteredJourney } from "./types.js"; /** diff --git a/packages/journeys/src/wildcard-transitions.test-d.ts b/packages/journeys-engine/src/wildcard-transitions.test-d.ts similarity index 99% rename from packages/journeys/src/wildcard-transitions.test-d.ts rename to packages/journeys-engine/src/wildcard-transitions.test-d.ts index a0153723..9c21dc2c 100644 --- a/packages/journeys/src/wildcard-transitions.test-d.ts +++ b/packages/journeys-engine/src/wildcard-transitions.test-d.ts @@ -5,7 +5,7 @@ import { defineExitContract, defineModule, schema, -} from "@modular-react/core"; +} from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; // Shared contracts and per-module exits for the type-level checks. diff --git a/packages/journeys/src/wildcard-transitions.test.ts b/packages/journeys-engine/src/wildcard-transitions.test.ts similarity index 99% rename from packages/journeys/src/wildcard-transitions.test.ts rename to packages/journeys-engine/src/wildcard-transitions.test.ts index ec023346..b0aae1d7 100644 --- a/packages/journeys/src/wildcard-transitions.test.ts +++ b/packages/journeys-engine/src/wildcard-transitions.test.ts @@ -7,7 +7,7 @@ import { isJourneySystemAbort, schema, type StandardSchemaLike, -} from "@modular-react/core"; +} from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { createJourneyRuntime, getInternals } from "./runtime.js"; import type { RegisteredJourney } from "./types.js"; diff --git a/packages/journeys-engine/tsconfig.json b/packages/journeys-engine/tsconfig.json new file mode 100644 index 00000000..cf9697d5 --- /dev/null +++ b/packages/journeys-engine/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.*"] +} diff --git a/packages/journeys-engine/vite.config.ts b/packages/journeys-engine/vite.config.ts new file mode 100644 index 00000000..184042e4 --- /dev/null +++ b/packages/journeys-engine/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vite"; +import dts from "vite-plugin-dts"; + +export default defineConfig({ + plugins: [dts({ rollupTypes: true })], + build: { + lib: { + entry: { + index: "src/index.ts", + testing: "src/testing.ts", + }, + formats: ["es"], + }, + rollupOptions: { + external: ["@modular-frontend/core"], + }, + sourcemap: true, + }, +}); diff --git a/packages/journeys-engine/vitest.config.ts b/packages/journeys-engine/vitest.config.ts new file mode 100644 index 00000000..4893ddc4 --- /dev/null +++ b/packages/journeys-engine/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "happy-dom", + // Pick up type-level assertions (`expectTypeOf(...)`, `assertType(...)`) + // from `*.test-d.ts` files. Runtime behavior tests continue to live in + // `*.test.ts`. Both are run by `pnpm test`. + typecheck: { + enabled: true, + include: ["src/**/*.test-d.ts"], + tsconfig: "./tsconfig.json", + }, + }, +}); diff --git a/packages/journeys/package.json b/packages/journeys/package.json index 4c38dd98..c0616276 100644 --- a/packages/journeys/package.json +++ b/packages/journeys/package.json @@ -34,6 +34,9 @@ "prepublishOnly": "pnpm build", "typecheck": "tsc --noEmit" }, + "dependencies": { + "@modular-frontend/journeys-engine": "workspace:*" + }, "devDependencies": { "@modular-react/core": "workspace:*", "@modular-react/react": "workspace:*", diff --git a/packages/journeys/src/index.ts b/packages/journeys/src/index.ts index 819ec7a0..5c823c58 100644 --- a/packages/journeys/src/index.ts +++ b/packages/journeys/src/index.ts @@ -3,23 +3,26 @@ // Authors import `defineJourney` (and types) from here; shells import the // runtime factory + `JourneyOutlet`. Modules import nothing from this package. -export { defineJourney } from "./define-journey.js"; +export { defineJourney } from "@modular-frontend/journeys-engine"; export { defineJourneyPersistence, createWebStoragePersistence, createMemoryPersistence, -} from "./persistence.js"; +} from "@modular-frontend/journeys-engine"; export type { WebStoragePersistenceOptions, MemoryPersistenceOptions, MemoryPersistence, SyncJourneyPersistence, -} from "./persistence.js"; +} from "@modular-frontend/journeys-engine"; // `getInternals` intentionally omitted from the public surface — test code // that used to reach through it should migrate to `createTestHarness` in // `@modular-react/journeys/testing`. The symbol is still exported from // `./runtime.js` for internal use (the outlet, the test harness itself). -export { createJourneyRuntime, type JourneyRuntimeOptions } from "./runtime.js"; +export { + createJourneyRuntime, + type JourneyRuntimeOptions, +} from "@modular-frontend/journeys-engine"; export { JourneyValidationError, JourneyHydrationError, @@ -27,7 +30,7 @@ export { validateJourneyContracts, validateJourneyDefinition, validateJourneyGraph, -} from "./validation.js"; +} from "@modular-frontend/journeys-engine"; // Public semver surface. The implementation now lives in // `@modular-react/core` so both journeys and compositions can share it // without one depending on the other; the journeys package re-exports @@ -81,12 +84,15 @@ export type { // Handles — export a handle from each journey package so modules and shells // open journeys with typed `input` without importing the journey's runtime. -export { defineJourneyHandle, invoke } from "./handle.js"; -export type { JourneyHandle } from "./handle.js"; +export { defineJourneyHandle, invoke } from "@modular-frontend/journeys-engine"; +export type { JourneyHandle } from "@modular-frontend/journeys-engine"; // Authoring helpers — exhaustive (and fallback) state-driven dispatch. -export { selectModule, selectModuleOrDefault } from "./select-module.js"; -export type { SelectModuleCases, SelectModuleCasesPartial } from "./select-module.js"; +export { selectModule, selectModuleOrDefault } from "@modular-frontend/journeys-engine"; +export type { + SelectModuleCases, + SelectModuleCasesPartial, +} from "@modular-frontend/journeys-engine"; // Authoring helpers — annotate a transition handler with the entry points it // can advance into. Read by `` (the default) @@ -95,8 +101,12 @@ export { defineTransition, isAnnotatedTransition, isTerminalSentinel, -} from "./define-transition.js"; -export type { AnnotatedTransitionHandler, StepRef, TerminalSentinel } from "./define-transition.js"; +} from "@modular-frontend/journeys-engine"; +export type { + AnnotatedTransitionHandler, + StepRef, + TerminalSentinel, +} from "@modular-frontend/journeys-engine"; export type { AbandonCtx, @@ -146,6 +156,6 @@ export type { WildcardExitOutputForEntry, WildcardExitOutputOf, WildcardTransitionMap, -} from "./types.js"; +} from "@modular-frontend/journeys-engine"; export { isJourneySystemAbort } from "@modular-react/core"; diff --git a/packages/journeys/src/mount-adapter.ts b/packages/journeys/src/mount-adapter.ts index d265216e..2ec3a52f 100644 --- a/packages/journeys/src/mount-adapter.ts +++ b/packages/journeys/src/mount-adapter.ts @@ -1,6 +1,6 @@ import type { RuntimeMountAdapter } from "@modular-react/core"; import { JourneyOutlet } from "./outlet.js"; -import type { JourneyRuntime } from "./types.js"; +import type { JourneyRuntime } from "@modular-frontend/journeys-engine"; /** * Adapt a {@link JourneyRuntime} to the generic diff --git a/packages/journeys/src/mount-kinds-runtime.test.tsx b/packages/journeys/src/mount-kinds-runtime.test.tsx index f1340826..09ceb7af 100644 --- a/packages/journeys/src/mount-kinds-runtime.test.tsx +++ b/packages/journeys/src/mount-kinds-runtime.test.tsx @@ -11,8 +11,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen } from "@testing-library/react"; import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; -import { defineJourney } from "./define-journey.js"; -import { createJourneyRuntime } from "./runtime.js"; +import { defineJourney } from "@modular-frontend/journeys-engine"; +import { createJourneyRuntime } from "@modular-frontend/journeys-engine"; import { JourneyOutlet } from "./outlet.js"; afterEach(() => { diff --git a/packages/journeys/src/outlet-invoke.test.tsx b/packages/journeys/src/outlet-invoke.test.tsx index 200a64d1..766c9120 100644 --- a/packages/journeys/src/outlet-invoke.test.tsx +++ b/packages/journeys/src/outlet-invoke.test.tsx @@ -12,9 +12,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createElement } from "react"; import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; import type { ModuleEntryProps } from "@modular-react/core"; -import { defineJourney } from "./define-journey.js"; -import { defineJourneyHandle, invoke } from "./handle.js"; -import { createJourneyRuntime } from "./runtime.js"; +import { defineJourney } from "@modular-frontend/journeys-engine"; +import { defineJourneyHandle, invoke } from "@modular-frontend/journeys-engine"; +import { createJourneyRuntime } from "@modular-frontend/journeys-engine"; import { JourneyOutlet, useJourneyCallStack } from "./outlet.js"; afterEach(() => { diff --git a/packages/journeys/src/outlet-preload.test.tsx b/packages/journeys/src/outlet-preload.test.tsx index f57f7609..4a3e82af 100644 --- a/packages/journeys/src/outlet-preload.test.tsx +++ b/packages/journeys/src/outlet-preload.test.tsx @@ -6,9 +6,9 @@ import type { Mock } from "vitest"; import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; import type { ModuleEntryProps } from "@modular-react/core"; -import { defineJourney } from "./define-journey.js"; -import { defineTransition } from "./define-transition.js"; -import { createJourneyRuntime } from "./runtime.js"; +import { defineJourney } from "@modular-frontend/journeys-engine"; +import { defineTransition } from "@modular-frontend/journeys-engine"; +import { createJourneyRuntime } from "@modular-frontend/journeys-engine"; import { JourneyOutlet } from "./outlet.js"; // Capture once at import time so afterEach can restore whatever the host diff --git a/packages/journeys/src/outlet.test.tsx b/packages/journeys/src/outlet.test.tsx index 6f44c9e5..ac55aae4 100644 --- a/packages/journeys/src/outlet.test.tsx +++ b/packages/journeys/src/outlet.test.tsx @@ -2,8 +2,8 @@ import { act, cleanup, render } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; import type { ModuleEntryProps } from "@modular-react/core"; -import { defineJourney } from "./define-journey.js"; -import { createJourneyRuntime, getInternals } from "./runtime.js"; +import { defineJourney } from "@modular-frontend/journeys-engine"; +import { createJourneyRuntime, getInternals } from "@modular-frontend/journeys-engine"; import { JourneyOutlet } from "./outlet.js"; afterEach(() => { diff --git a/packages/journeys/src/outlet.tsx b/packages/journeys/src/outlet.tsx index 4f01106a..983a376b 100644 --- a/packages/journeys/src/outlet.tsx +++ b/packages/journeys/src/outlet.tsx @@ -3,9 +3,9 @@ import type { ComponentType, ReactNode } from "react"; import type { ModuleDescriptor, ModuleEntryPoint } from "@modular-react/core"; import { resolveEntryComponent } from "@modular-react/react"; -import { getInternals } from "./runtime.js"; +import { getInternals } from "@modular-frontend/journeys-engine"; import { useJourneyContext } from "./provider.js"; -import { isAnnotatedTransition } from "./define-transition.js"; +import { isAnnotatedTransition } from "@modular-frontend/journeys-engine"; import { useCallChain, useInstanceSnapshot, useLeafId } from "./instance-hooks.js"; import type { AnyJourneyDefinition, @@ -13,7 +13,7 @@ import type { JourneyRuntime, JourneyStep, TerminalOutcome, -} from "./types.js"; +} from "@modular-frontend/journeys-engine"; export type JourneyStepErrorPolicy = "abort" | "retry" | "ignore"; diff --git a/packages/journeys/src/plugin.tsx b/packages/journeys/src/plugin.tsx index 84fc3710..a78bcb4c 100644 --- a/packages/journeys/src/plugin.tsx +++ b/packages/journeys/src/plugin.tsx @@ -5,12 +5,12 @@ import type { NavigationItemBase, RegistryPlugin, } from "@modular-react/core"; -import { createJourneyRuntime } from "./runtime.js"; +import { createJourneyRuntime } from "@modular-frontend/journeys-engine"; import { JourneyValidationError, validateJourneyContracts, validateJourneyDefinition, -} from "./validation.js"; +} from "@modular-frontend/journeys-engine"; import { JourneyProvider } from "./provider.js"; import type { AnyJourneyDefinition, @@ -18,7 +18,7 @@ import type { JourneyNavContribution, JourneyRegisterOptions, RegisteredJourney, -} from "./types.js"; +} from "@modular-frontend/journeys-engine"; /** * Methods the journeys plugin contributes to the registry. Registered diff --git a/packages/journeys/src/provider.test.tsx b/packages/journeys/src/provider.test.tsx index 11f13a08..520cde41 100644 --- a/packages/journeys/src/provider.test.tsx +++ b/packages/journeys/src/provider.test.tsx @@ -3,8 +3,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; import type { ModuleEntryProps } from "@modular-react/core"; -import { defineJourney } from "./define-journey.js"; -import { createJourneyRuntime } from "./runtime.js"; +import { defineJourney } from "@modular-frontend/journeys-engine"; +import { createJourneyRuntime } from "@modular-frontend/journeys-engine"; import { JourneyOutlet } from "./outlet.js"; import { ModuleTab } from "./module-tab.js"; import { JourneyProvider } from "./provider.js"; diff --git a/packages/journeys/src/provider.tsx b/packages/journeys/src/provider.tsx index 34cbbadf..f6724cd8 100644 --- a/packages/journeys/src/provider.tsx +++ b/packages/journeys/src/provider.tsx @@ -2,7 +2,7 @@ import { createContext, createElement, useContext } from "react"; import type { ReactNode } from "react"; import { ModuleExitProvider, type ModuleExitEvent } from "@modular-react/react"; -import type { JourneyRuntime } from "./types.js"; +import type { JourneyRuntime } from "@modular-frontend/journeys-engine"; /** * Shell-level context read by `` so callers don't have to diff --git a/packages/journeys/src/testing.ts b/packages/journeys/src/testing.ts index d4dc66f9..39a989c9 100644 --- a/packages/journeys/src/testing.ts +++ b/packages/journeys/src/testing.ts @@ -1,153 +1,11 @@ -import type { InstanceId, JourneyRuntime, JourneyStatus, JourneyStep } from "@modular-react/core"; - -import { getInternals } from "./runtime.js"; - -export { simulateJourney } from "./simulate-journey.js"; -export type { JourneySimulator } from "./simulate-journey.js"; - -/** - * Snapshot of the mutable runtime record for a single instance. Returned by - * `JourneyTestHarness.inspect` so tests can assert on fields that the - * public `JourneyInstance` surface intentionally does not expose (stepToken, - * retryCount). Everything else is also available via `runtime.getInstance`. - */ -export interface InstanceSnapshot { - readonly status: JourneyStatus; - readonly step: JourneyStep | null; - readonly state: TState; - readonly history: readonly JourneyStep[]; - readonly stepToken: number; - readonly retryCount: number; -} - -/** - * Test-only accessor that drives a runtime's internals from the outside — - * fire exits, walk back, peek at per-instance state. Prefer - * {@link simulateJourney} for pure-logic transition tests; use this when you - * already have a live runtime (e.g. one produced by the registry) and need - * to poke it from a test without mounting the outlet. - * - * The harness is the supported replacement for directly importing the - * runtime's `__`-prefixed internals, which are kept off the public export - * surface intentionally. - */ -export interface JourneyTestHarness { - fireExit(id: InstanceId, name: string, output?: unknown): void; - goBack(id: InstanceId): void; - goForward(id: InstanceId): void; - inspect(id: InstanceId): InstanceSnapshot; -} - -export function createTestHarness(runtime: JourneyRuntime): JourneyTestHarness { - const internals = getInternals(runtime); - - function recordOrThrow(id: InstanceId) { - const record = internals.__getRecord(id); - if (!record) { - throw new Error( - `[@modular-react/journeys/testing] No instance with id "${id}". Pass the id returned by runtime.start(...).`, - ); - } - return record; - } - - return { - fireExit(id, name, output) { - const record = recordOrThrow(id); - const reg = internals.__getRegistered(record.journeyId); - if (!reg) { - throw new Error( - `[@modular-react/journeys/testing] Journey "${record.journeyId}" is not registered with this runtime.`, - ); - } - // Calling fireExit on a loading instance is a silent no-op at the - // runtime level (the runtime has no step to resolve against yet). - // In tests this almost always indicates the caller forgot to await - // the persistence load probe. Throw early so the test fails on the - // offending call instead of on a later `expect(step?.entry)` read. - if (record.status === "loading") { - throw new Error( - `[@modular-react/journeys/testing] fireExit("${name}") called on instance "${id}" while status=loading. ` + - `Await the runtime's async load probe (typically \`await Promise.resolve()\` a few times, or expose a subscribe hook in your test) before dispatching exits.`, - ); - } - if (record.status !== "active") { - throw new Error( - `[@modular-react/journeys/testing] fireExit("${name}") called on terminal instance "${id}" (status=${record.status}).`, - ); - } - internals.__bindStepCallbacks(record, reg).exit(name, output); - }, - goBack(id) { - const record = recordOrThrow(id); - const reg = internals.__getRegistered(record.journeyId); - if (!reg) { - throw new Error( - `[@modular-react/journeys/testing] Journey "${record.journeyId}" is not registered with this runtime.`, - ); - } - if (record.status === "loading") { - throw new Error( - `[@modular-react/journeys/testing] goBack() called on instance "${id}" while status=loading. ` + - `Await the runtime's async load probe before dispatching.`, - ); - } - const callbacks = internals.__bindStepCallbacks(record, reg); - if (!callbacks.goBack) { - // Silently no-oping here would quietly "pass" a test that expects - // back navigation to work — the common `goBack walks back…` pattern - // asserts state *after* the call, so a no-op masks the wiring bug. - // Throw with context so the test fails on the offending call instead. - const stepLabel = record.step - ? `${record.step.moduleId}.${record.step.entry}` - : "(no step)"; - throw new Error( - `[@modular-react/journeys/testing] goBack is unavailable on instance "${id}" (step=${stepLabel}). ` + - `The journey's transition must declare allowBack: true AND the current step must have at least one history entry.`, - ); - } - callbacks.goBack(); - }, - goForward(id) { - const record = recordOrThrow(id); - const reg = internals.__getRegistered(record.journeyId); - if (!reg) { - throw new Error( - `[@modular-react/journeys/testing] Journey "${record.journeyId}" is not registered with this runtime.`, - ); - } - if (record.status === "loading") { - throw new Error( - `[@modular-react/journeys/testing] goForward() called on instance "${id}" while status=loading. ` + - `Await the runtime's async load probe before dispatching.`, - ); - } - const callbacks = internals.__bindStepCallbacks(record, reg); - if (!callbacks.goForward) { - // Empty future stack is the common case — fail loudly with a - // hint so the test author doesn't silently no-op past a - // missing `goBack` setup or a future-clearing exit between - // the rewind and the redo attempt. - throw new Error( - `[@modular-react/journeys/testing] goForward is unavailable on instance "${id}". ` + - `The runtime's future stack is empty — call goBack first, and don't fire a new exit between the rewind and the redo.`, - ); - } - callbacks.goForward(); - }, - inspect(id: InstanceId): InstanceSnapshot { - const record = recordOrThrow(id); - // Snapshot — `history` is a live array on the runtime record and will - // grow as the journey advances. Copy so assertions captured by the - // caller stay stable when the next `fireExit` runs. - return { - status: record.status, - step: record.step, - state: record.state as TState, - history: [...record.history], - stepToken: record.stepToken, - retryCount: record.retryCount, - }; - }, - }; -} +// Public surface of @modular-react/journeys/testing. +// +// The journey test helpers are framework-neutral, so they live in +// @modular-frontend/journeys-engine. This entry keeps the existing +// "@modular-react/journeys/testing" import path working by re-exporting them. +export { createTestHarness, simulateJourney } from "@modular-frontend/journeys-engine/testing"; +export type { + InstanceSnapshot, + JourneyTestHarness, + JourneySimulator, +} from "@modular-frontend/journeys-engine/testing"; diff --git a/packages/journeys/src/use-journey-state.test.tsx b/packages/journeys/src/use-journey-state.test.tsx index af1244f4..c2661109 100644 --- a/packages/journeys/src/use-journey-state.test.tsx +++ b/packages/journeys/src/use-journey-state.test.tsx @@ -2,11 +2,11 @@ import { act, cleanup, render } from "@testing-library/react"; import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; import { afterEach, describe, expect, it } from "vitest"; -import { defineJourney } from "./define-journey.js"; -import { defineJourneyHandle } from "./handle.js"; +import { defineJourney } from "@modular-frontend/journeys-engine"; +import { defineJourneyHandle } from "@modular-frontend/journeys-engine"; import { JourneyProvider } from "./provider.js"; -import { createJourneyRuntime } from "./runtime.js"; -import { createTestHarness } from "./testing.js"; +import { createJourneyRuntime } from "@modular-frontend/journeys-engine"; +import { createTestHarness } from "@modular-frontend/journeys-engine/testing"; import { useActiveLeafJourneyInstance, useActiveLeafJourneyState, diff --git a/packages/journeys/vite.config.ts b/packages/journeys/vite.config.ts index ce4b2f66..64b10c6e 100644 --- a/packages/journeys/vite.config.ts +++ b/packages/journeys/vite.config.ts @@ -18,6 +18,8 @@ export default defineConfig({ "react-dom", "@modular-react/core", "@modular-react/react", + "@modular-frontend/journeys-engine", + "@modular-frontend/journeys-engine/testing", ], }, sourcemap: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44b0a09d..4613e669 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2320,6 +2320,10 @@ importers: version: 4.1.5(@types/node@25.6.2)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)) packages/journeys: + dependencies: + '@modular-frontend/journeys-engine': + specifier: workspace:* + version: link:../journeys-engine devDependencies: '@modular-react/core': specifier: workspace:* @@ -2367,6 +2371,34 @@ importers: specifier: ^4.1.5 version: 4.1.5(@types/node@25.6.2)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)) + packages/journeys-engine: + dependencies: + '@modular-frontend/core': + specifier: workspace:* + version: link:../frontend-core + devDependencies: + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 + oxfmt: + specifier: ^0.51.0 + version: 0.51.0 + oxlint: + specifier: ^1.66.0 + version: 1.66.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.0.11 + version: 8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0) + vite-plugin-dts: + specifier: ^4.5.4 + version: 4.5.4(@types/node@25.6.2)(rollup@4.60.3)(typescript@6.0.3)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)) + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@25.6.2)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)) + packages/react: devDependencies: '@modular-react/core': From 7ceff3f759cba9f86885979361428925f9aabe38 Mon Sep 17 00:00:00 2001 From: Igor Savin Date: Sun, 5 Jul 2026 23:55:31 +0300 Subject: [PATCH 2/3] docs(journeys-engine): drop mount-adapter from the engine README createJourneyMountAdapter stays in the @modular-react/journeys binding (it supplies the React JourneyOutlet), so the engine does not ship it. Listing it under "What's included" advertised an export this package does not provide. --- packages/journeys-engine/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/journeys-engine/README.md b/packages/journeys-engine/README.md index 7750a136..2e7ce60b 100644 --- a/packages/journeys-engine/README.md +++ b/packages/journeys-engine/README.md @@ -19,7 +19,6 @@ Most apps depend on a binding (`@modular-react/journeys`) rather than on this pa - **Persistence**: `defineJourneyPersistence`, `createWebStoragePersistence`, `createMemoryPersistence`. - **Authoring helpers**: `defineJourney`, `defineTransition`, `isAnnotatedTransition`, `isTerminalSentinel`, `selectModule`, `selectModuleOrDefault`. - **Handles**: `defineJourneyHandle`, `invoke`. -- **Mount adapter**: `createJourneyMountAdapter` (embed a journey runtime through the generic `RuntimeMountAdapter` shape). - **Types**: the full journey type surface (`JourneyDefinition`, `JourneyInstance`, `JourneyStep`, `TransitionMap`, wildcard maps, and the rest). ### `@modular-frontend/journeys-engine/testing` From d24c763a40ec43221b06a0e8c0f655de4c5fcc7f Mon Sep 17 00:00:00 2001 From: Igor Savin Date: Sun, 5 Jul 2026 23:57:51 +0300 Subject: [PATCH 3/3] docs(journeys): address PR review comments on the engine extraction - vue-support-tracker: soften the shared-engines header so it no longer reads as if compositions-engine is already extracted, and add the #55 reference to the PR-02 status-board row. - journeys/index.ts: point the getInternals note at @modular-frontend/journeys-engine; runtime.ts no longer lives in this package. - journeys-engine/testing.ts: extract the repeated record + registration + loading guard shared by fireExit/goBack/goForward into one activeRecordAndReg helper. --- docs/vue-support-tracker.md | 74 ++++++++++++------------- packages/journeys-engine/src/testing.ts | 71 +++++++++--------------- packages/journeys/src/index.ts | 3 +- 3 files changed, 66 insertions(+), 82 deletions(-) diff --git a/docs/vue-support-tracker.md b/docs/vue-support-tracker.md index 5e5f48c3..fcfab794 100644 --- a/docs/vue-support-tracker.md +++ b/docs/vue-support-tracker.md @@ -36,12 +36,12 @@ Ship a Vue 3 + vue-router package family with feature parity to `@react-router-m | `@vue-router-modules/testing` | `@react-router-modules/testing` | `renderModule`, `renderJourney`, mock store | | `@vue-router-modules/cli` | `@react-router-modules/cli` | `cli-core` preset + SFC templates | -Shared engine packages extracted in Phase 0 under the `@modular-frontend` scope (decision D2, resolved): +Shared engine packages under the `@modular-frontend` scope (decision D2, resolved). `journeys-engine` is extracted; `compositions-engine` is still planned (PR-03): -| New package | Extracted from | Contents | -| -------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@modular-frontend/journeys-engine` | `@modular-react/journeys` | `runtime.ts`, `validation.ts`, `define-journey.ts`, `define-transition.ts`, `persistence.ts`, `select-module.ts`, `simulate-journey.ts`, `handle.ts`, `types.ts`, `testing.ts` | -| `@modular-frontend/compositions-engine` | `@modular-react/compositions` | `runtime.ts`, `stores.ts`, `validation.ts`, `define-composition.ts`, `types.ts` | +| New package | Extracted from | Contents | +| --------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@modular-frontend/journeys-engine` | `@modular-react/journeys` | `runtime.ts`, `validation.ts`, `define-journey.ts`, `define-transition.ts`, `persistence.ts`, `select-module.ts`, `simulate-journey.ts`, `handle.ts`, `types.ts`, `testing.ts` | +| `@modular-frontend/compositions-engine` | `@modular-react/compositions` | `runtime.ts`, `stores.ts`, `validation.ts`, `define-composition.ts`, `types.ts` | `mount-adapter.ts` stays in `@modular-react/journeys`, not the engine: `createJourneyMountAdapter` supplies `Outlet: JourneyOutlet` (a React component), so it is binding-specific glue over the neutral `RuntimeMountAdapter` seam rather than engine logic. @@ -51,14 +51,14 @@ Shared engine packages extracted in Phase 0 under the `@modular-frontend` scope Record the outcome inline when made. Blockers are marked per PR. -| ID | Decision | Recommendation | Status | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| D1 | npm scopes for the Vue family (`@modular-vue`, `@vue-router-modules`) and repo positioning (does the `modular-react` repo host Vue packages, or does it get a neutral name?) | Keep this repo, add the scopes, soften the README tagline. Reserve both scopes on npm before any code PR. | open | +| ID | Decision | Recommendation | Status | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| D1 | npm scopes for the Vue family (`@modular-vue`, `@vue-router-modules`) and repo positioning (does the `modular-react` repo host Vue packages, or does it get a neutral name?) | Keep this repo, add the scopes, soften the README tagline. Reserve both scopes on npm before any code PR. | open | | D2 | Scope and name for the extracted engines | A neutral scope shared by both families, e.g. `@modular-frontend`, holding `journeys-engine` and `compositions-engine`. Avoid putting "react" or "vue" in the name. | resolved: `@modular-frontend` (core extracted in #54; `journeys-engine` in PR-02) | -| D3 | Store story for Vue templates: core store vs Pinia | Scaffold with the core `createStore` (zustand-shaped, already the framework contract) and document Pinia interop in a guide section. Do not take a Pinia dependency in runtime packages. | open | -| D4 | Authoring style inside Vue library packages: SFC vs `defineComponent` + render functions | `defineComponent` + `h()` for library internals (no `@vitejs/plugin-vue` needed in package builds, better generics); SFCs in CLI templates and the example app, since that is what users write. | open | -| D5 | Minimum supported versions | Vue ^3.5, vue-router ^4.5, Node 22+, aligned with the React 19 / Node 22 baseline. | open | -| D6 | Is Nuxt in scope for 1.0? | No. Ship SPA-first, gauge demand, then decide on PR-52. | open | +| D3 | Store story for Vue templates: core store vs Pinia | Scaffold with the core `createStore` (zustand-shaped, already the framework contract) and document Pinia interop in a guide section. Do not take a Pinia dependency in runtime packages. | open | +| D4 | Authoring style inside Vue library packages: SFC vs `defineComponent` + render functions | `defineComponent` + `h()` for library internals (no `@vitejs/plugin-vue` needed in package builds, better generics); SFCs in CLI templates and the example app, since that is what users write. | open | +| D5 | Minimum supported versions | Vue ^3.5, vue-router ^4.5, Node 22+, aligned with the React 19 / Node 22 baseline. | open | +| D6 | Is Nuxt in scope for 1.0? | No. Ship SPA-first, gauge demand, then decide on PR-52. | open | ## Phase plan @@ -216,32 +216,32 @@ Parallelizable tracks once Phase 0 lands: (a) PR-10..12 binding layer, (b) PR-30 Update the Status column as PRs move: `todo` → `in progress` → `in review` → `done` (link the PR). -| PR | Title | Size | Depends on | Status | -| ----- | ------------------------------------------- | ---- | ------------------- | ------ | +| PR | Title | Size | Depends on | Status | +| ----- | ------------------------------------------- | ---- | ------------------- | ---------- | | PR-01 | Neutralize renderable types in core | S | — | done (#54) | -| PR-02 | Extract journeys engine | L | D2 | done | -| PR-03 | Extract compositions engine | L | D2 | todo | -| PR-04 | cli-core framework-pluggable templates | M | — | todo | -| PR-05 | CI/publish plumbing, scope reservation | S | D1 | todo | -| PR-10 | @modular-vue/vue: stores and context | M | PR-01 | todo | -| PR-11 | @modular-vue/vue: rendering pieces | M | PR-10 | todo | -| PR-12 | @modular-vue/testing | S | PR-11 | todo | -| PR-20 | @vue-router-modules/core | M | PR-10 | todo | -| PR-21 | runtime: registry | M | PR-20 | todo | -| PR-22 | runtime: route building, app plugin, guards | M | PR-21 | todo | -| PR-23 | runtime: zones and route data | M | PR-22 | todo | -| PR-24 | @vue-router-modules/testing | S | PR-23 | todo | -| PR-30 | vue journeys: provider and composables | M | PR-02, PR-10 | todo | -| PR-31 | vue journeys: outlet | L | PR-30 | todo | -| PR-32 | journeys wired into runtime + renderJourney | M | PR-22, PR-31 | todo | -| PR-33 | vue compositions: provider and composables | M | PR-03, PR-10 | todo | -| PR-34 | vue compositions: outlet | L | PR-33 | todo | -| PR-40 | examples/vue-router | L | PR-23, PR-32, PR-34 | todo | -| PR-41 | Documentation | M | PR-40 | todo | -| PR-42 | Parity audit, promote to 1.0 | S | PR-40 | todo | -| PR-50 | @vue-router-modules/cli | M | PR-04, PR-40 | todo | -| PR-51 | Catalog Vue support | S | PR-40 | todo | -| PR-52 | Nuxt module (stretch) | L | D6, 1.0 | todo | +| PR-02 | Extract journeys engine | L | D2 | done (#55) | +| PR-03 | Extract compositions engine | L | D2 | todo | +| PR-04 | cli-core framework-pluggable templates | M | — | todo | +| PR-05 | CI/publish plumbing, scope reservation | S | D1 | todo | +| PR-10 | @modular-vue/vue: stores and context | M | PR-01 | todo | +| PR-11 | @modular-vue/vue: rendering pieces | M | PR-10 | todo | +| PR-12 | @modular-vue/testing | S | PR-11 | todo | +| PR-20 | @vue-router-modules/core | M | PR-10 | todo | +| PR-21 | runtime: registry | M | PR-20 | todo | +| PR-22 | runtime: route building, app plugin, guards | M | PR-21 | todo | +| PR-23 | runtime: zones and route data | M | PR-22 | todo | +| PR-24 | @vue-router-modules/testing | S | PR-23 | todo | +| PR-30 | vue journeys: provider and composables | M | PR-02, PR-10 | todo | +| PR-31 | vue journeys: outlet | L | PR-30 | todo | +| PR-32 | journeys wired into runtime + renderJourney | M | PR-22, PR-31 | todo | +| PR-33 | vue compositions: provider and composables | M | PR-03, PR-10 | todo | +| PR-34 | vue compositions: outlet | L | PR-33 | todo | +| PR-40 | examples/vue-router | L | PR-23, PR-32, PR-34 | todo | +| PR-41 | Documentation | M | PR-40 | todo | +| PR-42 | Parity audit, promote to 1.0 | S | PR-40 | todo | +| PR-50 | @vue-router-modules/cli | M | PR-04, PR-40 | todo | +| PR-51 | Catalog Vue support | S | PR-40 | todo | +| PR-52 | Nuxt module (stretch) | L | D6, 1.0 | todo | ## Working agreements diff --git a/packages/journeys-engine/src/testing.ts b/packages/journeys-engine/src/testing.ts index 382f4f46..c10dc504 100644 --- a/packages/journeys-engine/src/testing.ts +++ b/packages/journeys-engine/src/testing.ts @@ -56,26 +56,33 @@ export function createTestHarness(runtime: JourneyRuntime): JourneyTestHarness { return record; } + // Shared guard for every dispatch/inspect entry point: resolve the record + // and its registration, and reject a loading instance. Dispatching to a + // loading instance is a silent no-op at the runtime level (no step to + // resolve against yet), which in tests almost always means the caller + // forgot to await the async load probe. Throw here so the test fails on the + // offending call instead of on a later `expect(step?.entry)` read. `op` + // labels the caller for the message. + function activeRecordAndReg(id: InstanceId, op: string) { + const record = recordOrThrow(id); + const reg = internals.__getRegistered(record.journeyId); + if (!reg) { + throw new Error( + `[@modular-react/journeys/testing] Journey "${record.journeyId}" is not registered with this runtime.`, + ); + } + if (record.status === "loading") { + throw new Error( + `[@modular-react/journeys/testing] ${op} called on instance "${id}" while status=loading. ` + + `Await the runtime's async load probe (typically \`await Promise.resolve()\` a few times, or expose a subscribe hook in your test) before dispatching.`, + ); + } + return { record, reg }; + } + return { fireExit(id, name, output) { - const record = recordOrThrow(id); - const reg = internals.__getRegistered(record.journeyId); - if (!reg) { - throw new Error( - `[@modular-react/journeys/testing] Journey "${record.journeyId}" is not registered with this runtime.`, - ); - } - // Calling fireExit on a loading instance is a silent no-op at the - // runtime level (the runtime has no step to resolve against yet). - // In tests this almost always indicates the caller forgot to await - // the persistence load probe. Throw early so the test fails on the - // offending call instead of on a later `expect(step?.entry)` read. - if (record.status === "loading") { - throw new Error( - `[@modular-react/journeys/testing] fireExit("${name}") called on instance "${id}" while status=loading. ` + - `Await the runtime's async load probe (typically \`await Promise.resolve()\` a few times, or expose a subscribe hook in your test) before dispatching exits.`, - ); - } + const { record, reg } = activeRecordAndReg(id, `fireExit("${name}")`); if (record.status !== "active") { throw new Error( `[@modular-react/journeys/testing] fireExit("${name}") called on terminal instance "${id}" (status=${record.status}).`, @@ -84,19 +91,7 @@ export function createTestHarness(runtime: JourneyRuntime): JourneyTestHarness { internals.__bindStepCallbacks(record, reg).exit(name, output); }, goBack(id) { - const record = recordOrThrow(id); - const reg = internals.__getRegistered(record.journeyId); - if (!reg) { - throw new Error( - `[@modular-react/journeys/testing] Journey "${record.journeyId}" is not registered with this runtime.`, - ); - } - if (record.status === "loading") { - throw new Error( - `[@modular-react/journeys/testing] goBack() called on instance "${id}" while status=loading. ` + - `Await the runtime's async load probe before dispatching.`, - ); - } + const { record, reg } = activeRecordAndReg(id, "goBack()"); const callbacks = internals.__bindStepCallbacks(record, reg); if (!callbacks.goBack) { // Silently no-oping here would quietly "pass" a test that expects @@ -114,19 +109,7 @@ export function createTestHarness(runtime: JourneyRuntime): JourneyTestHarness { callbacks.goBack(); }, goForward(id) { - const record = recordOrThrow(id); - const reg = internals.__getRegistered(record.journeyId); - if (!reg) { - throw new Error( - `[@modular-react/journeys/testing] Journey "${record.journeyId}" is not registered with this runtime.`, - ); - } - if (record.status === "loading") { - throw new Error( - `[@modular-react/journeys/testing] goForward() called on instance "${id}" while status=loading. ` + - `Await the runtime's async load probe before dispatching.`, - ); - } + const { record, reg } = activeRecordAndReg(id, "goForward()"); const callbacks = internals.__bindStepCallbacks(record, reg); if (!callbacks.goForward) { // Empty future stack is the common case — fail loudly with a diff --git a/packages/journeys/src/index.ts b/packages/journeys/src/index.ts index 5c823c58..764a8960 100644 --- a/packages/journeys/src/index.ts +++ b/packages/journeys/src/index.ts @@ -18,7 +18,8 @@ export type { // `getInternals` intentionally omitted from the public surface — test code // that used to reach through it should migrate to `createTestHarness` in // `@modular-react/journeys/testing`. The symbol is still exported from -// `./runtime.js` for internal use (the outlet, the test harness itself). +// `@modular-frontend/journeys-engine` for internal use (the outlet, the test +// harness itself). export { createJourneyRuntime, type JourneyRuntimeOptions,