@equinor/fusion-framework-vitest-plugin-react-app 0.2.0-next.3 → 1.0.0-next.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +14 -7
  3. package/dist/esm/app-test.js +1 -0
  4. package/dist/esm/app-test.js.map +1 -1
  5. package/dist/esm/define-project.js +4 -2
  6. package/dist/esm/define-project.js.map +1 -1
  7. package/dist/esm/merge-env-config.js +37 -0
  8. package/dist/esm/merge-env-config.js.map +1 -0
  9. package/dist/esm/resolve-app-test-env.js +5 -5
  10. package/dist/esm/scope/resolve-app-scope.js +1 -1
  11. package/dist/esm/scope/resolve-app-scope.js.map +1 -1
  12. package/dist/esm/scope/resolve-fusion.js +40 -5
  13. package/dist/esm/scope/resolve-fusion.js.map +1 -1
  14. package/dist/esm/test-app.js +74 -7
  15. package/dist/esm/test-app.js.map +1 -1
  16. package/dist/esm/test.js +12 -5
  17. package/dist/esm/test.js.map +1 -1
  18. package/dist/esm/version.js +1 -1
  19. package/dist/tsconfig.tsbuildinfo +1 -1
  20. package/dist/types/app-test.d.ts +1 -0
  21. package/dist/types/merge-env-config.d.ts +33 -0
  22. package/dist/types/resolve-app-test-env.d.ts +5 -5
  23. package/dist/types/scope/resolve-fusion.d.ts +16 -3
  24. package/dist/types/test-app.d.ts +79 -12
  25. package/dist/types/test.d.ts +25 -14
  26. package/dist/types/version.d.ts +1 -1
  27. package/docs/advanced.md +81 -12
  28. package/docs/configuration.md +1 -1
  29. package/docs/migrating-an-existing-app.md +14 -9
  30. package/docs/module-mocks.md +2 -2
  31. package/docs/troubleshooting.md +3 -0
  32. package/package.json +15 -7
  33. package/src/__tests__/merge-env-config.test.ts +67 -0
  34. package/src/app-test.ts +1 -0
  35. package/src/define-project.ts +4 -2
  36. package/src/merge-env-config.ts +58 -0
  37. package/src/resolve-app-test-env.ts +5 -5
  38. package/src/scope/resolve-app-scope.ts +1 -1
  39. package/src/scope/resolve-fusion.ts +55 -10
  40. package/src/test-app.tsx +84 -8
  41. package/src/test.tsx +12 -5
  42. package/src/version.ts +1 -1
  43. package/tsconfig.json +2 -0
@@ -18,6 +18,9 @@ resolve the app, or behaves differently in Vitest Browser Mode.
18
18
  | The app starts signed in unexpectedly | The MSAL mock defaults to `Test User` | Set `configurator.msal.setAccount(null)` before rendering |
19
19
  | State configured in one test is missing in another | Framework and app fixtures are test-scoped | Seed the required state per test or publish reusable fixture declarations with `test.extend` |
20
20
  | `vi.spyOn` fails on an imported module in Browser Mode | Native ESM module namespace objects are sealed | Use `vi.mock('./module.js', { spy: true })` as documented by Vitest Browser Mode |
21
+ | A `<Router>` route with a catch-all or parameterized `path` never renders; every query on it times out | The navigation module's current location does not match that path yet | Push the target location through the navigation module (`app.modules.navigation.push(path)`) before rendering, not after |
22
+ | A virtualized list or popover (for example an EDS `Autocomplete` built on `@tanstack/react-virtual`) never renders its rows | `window.ResizeObserver` was stubbed as a no-op in test setup | Remove the stub; real Chromium's `ResizeObserver` is required for virtualized layout to measure correctly |
23
+ | `test.each` runs but a fixture in its per-case callback is `undefined` | `test.each` does not forward `test.extend` fixture context to each case | Use Vitest's `test.for` instead, which does receive fixture context |
21
24
 
22
25
  ## Verify the project wiring
23
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@equinor/fusion-framework-vitest-plugin-react-app",
3
- "version": "0.2.0-next.3",
3
+ "version": "1.0.0-next.5",
4
4
  "description": "Vite plugin resolving an application's manifest, config, and module-configurator as virtual modules for testing.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,13 +47,14 @@
47
47
  "access": "public"
48
48
  },
49
49
  "dependencies": {
50
- "@equinor/fusion-framework-cli": "15.2.8-next.2",
51
50
  "@equinor/fusion-framework": "8.1.0-next.1",
52
- "@equinor/fusion-framework-module-app": "8.1.0-next.0",
51
+ "@equinor/fusion-framework-app": "13.0.3-next.0",
52
+ "@equinor/fusion-framework-module": "6.1.3-next.0",
53
+ "@equinor/fusion-framework-cli": "15.2.11-next.0",
54
+ "@equinor/fusion-framework-module-app": "8.0.6-next.0",
55
+ "@equinor/fusion-framework-module-navigation": "7.0.9-next.0",
53
56
  "@equinor/fusion-framework-react": "9.0.0-next.0",
54
57
  "@equinor/fusion-framework-react-module": "4.0.3-next.0",
55
- "@equinor/fusion-framework-module": "6.1.3-next.0",
56
- "@equinor/fusion-framework-app": "14.0.0-next.1",
57
58
  "@equinor/fusion-imports": "2.0.3-next.0"
58
59
  },
59
60
  "devDependencies": {
@@ -67,7 +68,8 @@
67
68
  "typescript": "^7.0.2",
68
69
  "vite": "^8.0.0",
69
70
  "vitest": "^4.1.10",
70
- "vitest-browser-react": "^2.2.0"
71
+ "vitest-browser-react": "^2.2.0",
72
+ "@equinor/fusion-framework-module-feature-flag": "2.1.0-next.0"
71
73
  },
72
74
  "peerDependencies": {
73
75
  "@types/react": "^18.0.0 || ^19.0.0",
@@ -78,7 +80,13 @@
78
80
  "rxjs": "^7.0.0",
79
81
  "vite": "^8.0.0",
80
82
  "vitest": "^4.0.0",
81
- "vitest-browser-react": "^2.2.0"
83
+ "vitest-browser-react": "^2.2.0",
84
+ "@equinor/fusion-framework-module-feature-flag": "2.1.0-next.0"
85
+ },
86
+ "peerDependenciesMeta": {
87
+ "@equinor/fusion-framework-module-feature-flag": {
88
+ "optional": true
89
+ }
82
90
  },
83
91
  "scripts": {
84
92
  "build": "tsc -b",
@@ -0,0 +1,67 @@
1
+ import { AppConfig } from '@equinor/fusion-framework-module-app';
2
+ import type { AppEnv } from '@equinor/fusion-framework-app';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { mergeEnvConfig } from '../merge-env-config.js';
6
+
7
+ describe('mergeEnvConfig', () => {
8
+ const baseEnv: AppEnv = {
9
+ manifest: {
10
+ appKey: 'test-app',
11
+ displayName: 'Test App',
12
+ description: 'A test application',
13
+ type: 'standalone',
14
+ },
15
+ config: new AppConfig({
16
+ environment: { foo: 'bar' },
17
+ endpoints: { api: { url: 'https://api.example.com', scopes: ['api.read'] } },
18
+ }),
19
+ };
20
+
21
+ it('adds a new endpoint without dropping existing ones', () => {
22
+ const merged = mergeEnvConfig(baseEnv, {
23
+ endpoints: { 'cpr-api': { url: 'https://cpr.example.com' } },
24
+ });
25
+
26
+ expect(merged.config?.endpoints).toEqual({
27
+ api: { url: 'https://api.example.com', scopes: ['api.read'] },
28
+ 'cpr-api': { url: 'https://cpr.example.com', scopes: [] },
29
+ });
30
+ });
31
+
32
+ it('overrides one field of an existing endpoint, keeping the rest', () => {
33
+ const merged = mergeEnvConfig(baseEnv, {
34
+ endpoints: { api: { url: 'https://fake.example.com' } },
35
+ });
36
+
37
+ expect(merged.config?.endpoints.api).toEqual({
38
+ url: 'https://fake.example.com',
39
+ scopes: ['api.read'],
40
+ });
41
+ });
42
+
43
+ it('merges environment overrides over the existing environment', () => {
44
+ const merged = mergeEnvConfig(baseEnv, { environment: { baz: 'qux' } });
45
+
46
+ expect(merged.config?.environment).toEqual({ foo: 'bar', baz: 'qux' });
47
+ });
48
+
49
+ it('leaves the original env and its config untouched', () => {
50
+ mergeEnvConfig(baseEnv, { endpoints: { api: { url: 'https://fake.example.com' } } });
51
+
52
+ expect(baseEnv.config?.endpoints.api).toEqual({
53
+ url: 'https://api.example.com',
54
+ scopes: ['api.read'],
55
+ });
56
+ });
57
+
58
+ it('handles an env with no existing config', () => {
59
+ const env: AppEnv = { manifest: baseEnv.manifest };
60
+ const merged = mergeEnvConfig(env, { endpoints: { api: { url: 'https://api.example.com' } } });
61
+
62
+ expect(merged.config?.endpoints).toEqual({
63
+ api: { url: 'https://api.example.com', scopes: [] },
64
+ });
65
+ expect(merged.config?.environment).toEqual({});
66
+ });
67
+ });
package/src/app-test.ts CHANGED
@@ -9,6 +9,7 @@ export {
9
9
  type RenderAppComponentResult,
10
10
  } from './render-app-component';
11
11
  export { testApp } from './test-app';
12
+ export { mergeEnvConfig, type MergeEnvConfigOverrides } from './merge-env-config.js';
12
13
  export type { AppMockConfigureFn } from '@equinor/fusion-framework-app/mock';
13
14
 
14
15
  // `test`/`render` import virtual modules only served once `appTestVitePlugin`
@@ -68,13 +68,15 @@ export const defineProject = (override?: AppTestConfigOverride): UserWorkspaceCo
68
68
  // pre-transforms all source up front so deps only reached via lazy/code-split imports
69
69
  // (e.g. route components) are discovered before the first test request, not mid-run —
70
70
  // the latter forces Vite to reload the page and fails the in-flight test file import
71
- server: { warmup: { clientFiles: ['src/**/*.{ts,tsx}'] } },
71
+ // `.d.ts` files are excluded: a CJS-style declaration file (e.g. one using `export =`)
72
+ // fails the warmup scan when loaded as ESM
73
+ server: { warmup: { clientFiles: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'] } },
72
74
  // same reasoning as `server.warmup` above, but for the esbuild dep scanner: without this,
73
75
  // its default entry detection can miss code-split route files entirely, so a package only
74
76
  // ever imported from one of those (e.g. react-router's own deps) is discovered mid-run
75
77
  // instead of up front — statically crawling every source file (which esbuild's scanner
76
78
  // follows through dynamic imports too) avoids that with no per-package name needed
77
- optimizeDeps: { entries: ['src/**/*.{ts,tsx}'] },
79
+ optimizeDeps: { entries: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'] },
78
80
  };
79
81
  // a function replaces the config outright; a plain object deep-merges onto it via Vite's own mergeConfig
80
82
  const resolved =
@@ -0,0 +1,58 @@
1
+ import { AppConfig } from '@equinor/fusion-framework-module-app';
2
+ import type { ConfigEnvironment } from '@equinor/fusion-framework-module-app';
3
+ import type { AppEnv } from '@equinor/fusion-framework-app';
4
+
5
+ type EndpointOverride = Partial<NonNullable<AppEnv['config']>['endpoints'][string]>;
6
+
7
+ /**
8
+ * Overrides accepted by {@link mergeEnvConfig}.
9
+ */
10
+ export type MergeEnvConfigOverrides<TConfig extends ConfigEnvironment = ConfigEnvironment> = {
11
+ environment?: Partial<TConfig>;
12
+ endpoints?: Record<string, EndpointOverride>;
13
+ };
14
+
15
+ /**
16
+ * Merges `environment`/`endpoints` overrides into an `AppEnv`'s `config`.
17
+ *
18
+ * @remarks
19
+ * `AppConfig` stores both behind private fields exposed only through getters, so
20
+ * `{ ...env.config, endpoints: {...} }` silently drops everything it doesn't explicitly
21
+ * restate — a plain object spread copies no own enumerable properties off an `AppConfig`
22
+ * instance. Reach for this instead of hand-rolling that merge in a test fixture.
23
+ *
24
+ * @template TEnv - The `AppEnv` shape being merged into.
25
+ * @param env - The `AppEnv` to merge overrides into; left untouched, `config` may be omitted.
26
+ * @param overrides - Partial `environment`/`endpoints` values, merged over any existing config.
27
+ * @returns A new `AppEnv` with a new `AppConfig` reflecting the merge.
28
+ * @example
29
+ * ```ts
30
+ * const test = testApp.extend('appEnv', ({ appEnv }) =>
31
+ * mergeEnvConfig(appEnv, { endpoints: { 'cpr-api': { url: backendBaseUrl } } }),
32
+ * );
33
+ * ```
34
+ */
35
+ export function mergeEnvConfig<TEnv extends AppEnv = AppEnv>(
36
+ env: TEnv,
37
+ overrides: MergeEnvConfigOverrides<
38
+ TEnv['config'] extends AppConfig<infer TConfig> ? TConfig : ConfigEnvironment
39
+ >,
40
+ ): TEnv {
41
+ // each overridden endpoint keeps any field the caller didn't explicitly override
42
+ const endpoints = Object.entries(overrides.endpoints ?? {}).reduce(
43
+ // defaults, then any existing endpoint, then the override (override wins)
44
+ (acc, [key, override]) =>
45
+ Object.assign(acc, { [key]: Object.assign({ url: '', scopes: [] }, acc[key], override) }),
46
+ { ...env.config?.endpoints },
47
+ );
48
+ // caller-supplied environment values win over the existing ones
49
+ return {
50
+ ...env,
51
+ config: new AppConfig({
52
+ environment: { ...env.config?.environment, ...overrides.environment },
53
+ endpoints,
54
+ }),
55
+ };
56
+ }
57
+
58
+ export default mergeEnvConfig;
@@ -46,11 +46,11 @@ export type ResolveAppTestEnvOptions = {
46
46
  * back to an empty config if none exists.
47
47
  *
48
48
  * @remarks
49
- * Intended to seed `@equinor/fusion-framework-vitest-plugin-react-app/test`'s `testApp` `env` fixture, so
49
+ * Intended to seed `@equinor/fusion-framework-vitest-plugin-react-app/test`'s `testApp` `appEnv` fixture, so
50
50
  * a test suite exercises the application's real manifest/config instead of a hand-maintained
51
51
  * duplicate. Anything a specific test still needs faked (a missing endpoint, a different
52
- * `appKey`) can be layered on top with `testApp.extend('env', ...)` or a per-test
53
- * `test.override('env', ...)`.
52
+ * `appKey`) can be layered on top with `testApp.extend('appEnv', ...)` or a per-test
53
+ * `test.override('appEnv', ...)`.
54
54
  *
55
55
  * @param options - Resolution options; `entrypoint` defaults to the current working directory.
56
56
  * @returns The resolved application manifest and config.
@@ -63,8 +63,8 @@ export type ResolveAppTestEnvOptions = {
63
63
  * import { configure } from '../config';
64
64
  *
65
65
  * const test = testApp
66
- * .extend('env', { injected: true }, () => resolveAppTestEnv())
67
- * .extend('configure', { injected: true }, () => configure);
66
+ * .extend('appEnv', { injected: true }, () => resolveAppTestEnv())
67
+ * .extend('configureApp', { injected: true }, () => configure);
68
68
  * ```
69
69
  */
70
70
  export const resolveAppTestEnv = async (
@@ -40,7 +40,7 @@ export async function resolveAppScope<
40
40
  }): Promise<AppScope<TModules>> {
41
41
  const { configure, env, fusion: providedFusion } = options ?? {};
42
42
  const resolvedEnv = env ?? (defaultAppEnv as TEnv);
43
- const framework = await resolveFusion(resolvedEnv, providedFusion);
43
+ const framework = await resolveFusion({ env: resolvedEnv, fusion: providedFusion });
44
44
  const app = await mockAppModules(configure, resolvedEnv, framework);
45
45
  return { framework, app };
46
46
  }
@@ -1,29 +1,74 @@
1
1
  import type { AppEnv } from '@equinor/fusion-framework-app';
2
2
  import type { Fusion } from '@equinor/fusion-framework';
3
- import { mockFramework } from '@equinor/fusion-framework/mock';
3
+ import {
4
+ mockFramework,
5
+ type FrameworkMockConfigureFn,
6
+ type FrameworkMockConfigurator,
7
+ } from '@equinor/fusion-framework/mock';
4
8
  import { enableAppManifestMock } from '@equinor/fusion-framework-app/mock';
5
9
  import type { AppModule } from '@equinor/fusion-framework-module-app';
10
+ import { enableNavigation, createHistory } from '@equinor/fusion-framework-module-navigation';
11
+ import type { NavigationModule } from '@equinor/fusion-framework-module-navigation';
6
12
 
7
13
  import { defaultAppEnv } from './default-app-env';
8
14
 
15
+ /**
16
+ * Enables the feature-flag mock only when `@equinor/fusion-framework-module-feature-flag` — an
17
+ * optional peer dependency — is actually installed, so apps that don't use feature flags aren't
18
+ * forced to install a module they never reference.
19
+ */
20
+ async function enableFeatureFlagMockIfAvailable(
21
+ configurator: FrameworkMockConfigurator<[AppModule, NavigationModule]>,
22
+ ): Promise<void> {
23
+ try {
24
+ const { enableFeatureFlagMock } = await import(
25
+ '@equinor/fusion-framework-module-feature-flag/mock'
26
+ );
27
+ enableFeatureFlagMock(configurator);
28
+ } catch (error) {
29
+ // re-throw anything other than the optional peer being missing, so a real
30
+ // registration failure isn't silently swallowed
31
+ if (
32
+ error instanceof Error &&
33
+ (error.message.includes('Cannot find module') || error.message.includes('MODULE_NOT_FOUND'))
34
+ ) {
35
+ return;
36
+ }
37
+ throw error;
38
+ }
39
+ }
40
+
9
41
  /**
10
42
  * Resolves the parent Fusion instance backing an application module scope, building a
11
43
  * fresh {@link mockFramework} instance with this app's own manifest served when none is
12
44
  * given.
13
45
  *
14
46
  * @template TEnv - The application environment descriptor.
15
- * @param env - The application environment; defaults to {@link defaultAppEnv}.
16
- * @param fusion - An already-built parent Fusion instance to reuse instead.
47
+ * @param options - `env` seeds the built-in app manifest mock; navigation defaults to in-memory
48
+ * history, so tests don't leak URL/history state between runs. Feature flags default to the
49
+ * in-memory feature-flag mock, with none enabled, when the optional
50
+ * `@equinor/fusion-framework-module-feature-flag` peer dependency is installed. `configure`
51
+ * runs afterwards on the same configurator — e.g. call `enableNavigation` or
52
+ * `enableFeatureFlagMock` again to override either, or register extra modules and service
53
+ * discovery entries. `fusion`, an already-built parent instance, is reused as-is and skips
54
+ * `configure` entirely.
17
55
  * @returns The given `fusion`, or a fresh mocked parent Fusion instance.
18
56
  */
19
- export async function resolveFusion<TEnv extends AppEnv = AppEnv>(
20
- env?: TEnv,
21
- fusion?: Fusion,
22
- ): Promise<Fusion> {
57
+ export async function resolveFusion<TEnv extends AppEnv = AppEnv>(options?: {
58
+ env?: TEnv;
59
+ fusion?: Fusion;
60
+ configure?: FrameworkMockConfigureFn<[AppModule, NavigationModule]>;
61
+ }): Promise<Fusion> {
62
+ const { env, fusion, configure } = options ?? {};
23
63
  return (
24
64
  fusion ??
25
- mockFramework<[AppModule]>((configurator) =>
26
- enableAppManifestMock(configurator, env ?? (defaultAppEnv as TEnv)),
27
- )
65
+ mockFramework<[AppModule, NavigationModule]>(async (configurator) => {
66
+ enableAppManifestMock(configurator, env ?? (defaultAppEnv as TEnv));
67
+ enableNavigation(configurator, {
68
+ configure: (config) => config.setHistory(createHistory('memory')),
69
+ });
70
+ await enableFeatureFlagMockIfAvailable(configurator);
71
+ await configure?.(configurator);
72
+ })
28
73
  );
29
74
  }
package/src/test-app.tsx CHANGED
@@ -6,6 +6,9 @@ import type { RenderOptions, RenderHookOptions } from 'vitest-browser-react';
6
6
  import { mockAppModules } from '@equinor/fusion-framework-app/mock';
7
7
  import type { AppMockConfigureFn } from '@equinor/fusion-framework-app/mock';
8
8
  import type { AppEnv } from '@equinor/fusion-framework-app';
9
+ import type { AppModule } from '@equinor/fusion-framework-module-app';
10
+ import type { NavigationModule } from '@equinor/fusion-framework-module-navigation';
11
+ import type { FrameworkMockConfigureFn } from '@equinor/fusion-framework/mock';
9
12
 
10
13
  import { defaultAppEnv, resolveFusion, createAppScopeWrapper } from './scope';
11
14
 
@@ -14,7 +17,7 @@ import { defaultAppEnv, resolveFusion, createAppScopeWrapper } from './scope';
14
17
  *
15
18
  * @remarks
16
19
  * An alternative to {@link renderAppComponent}/{@link renderAppHook} for a test file whose
17
- * cases share seeded fixture defaults: `env`/`configure` become suite-level concerns,
20
+ * cases share seeded fixture defaults: `appEnv`/`configureApp` become suite-level concerns,
18
21
  * overridden once per file (or per `describe` block) with `testApp.extend(...)`, rather than
19
22
  * an options object repeated on every call. `fusion`/`app` are still instantiated fresh per
20
23
  * test — only the seeded defaults are shared, not state between tests. They also resolve
@@ -25,6 +28,19 @@ import { defaultAppEnv, resolveFusion, createAppScopeWrapper } from './scope';
25
28
  * one-off test whose configuration is not shared by the rest of the file; reach for
26
29
  * `testApp` when several cases in a file share one set of seeded fixture defaults.
27
30
  *
31
+ * @remarks `configureApp`/`configureFusion` default to `undefined` here
32
+ * Unlike `@equinor/fusion-framework-vitest-plugin-react-app/test`'s `test`, this `testApp` does
33
+ * not resolve the app's real `src/config.ts` — it requires `appTestVitePlugin`'s Vite virtual
34
+ * modules to load that file as live code, which `testApp` (no Vite dependency) cannot do. Extend
35
+ * `test` from `/test` instead when a suite needs to compose with the app's real configuration.
36
+ *
37
+ * @remarks Overriding `fusion` bypasses `configureFusion`
38
+ * `fusion` and `configureFusion` are not independent: `fusion`'s default resolver is what
39
+ * calls `configureFusion`. `.override('fusion', ...)` replaces that resolver outright, so a
40
+ * `configureFusion` override on the same test/suite is silently never called. Use
41
+ * `configureFusion` to extend the base framework mock; use `fusion` only to replace it
42
+ * entirely (e.g. with a fully custom or non-mocked instance).
43
+ *
28
44
  * @example
29
45
  * ```tsx
30
46
  * testApp('resolves current context', async ({ app, render }) => {
@@ -36,8 +52,12 @@ import { defaultAppEnv, resolveFusion, createAppScopeWrapper } from './scope';
36
52
  * @example Seed a module for every test in a suite
37
53
  * ```tsx
38
54
  * describe('with a seeded context module', () => {
39
- * const test = testApp.extend('configure', { injected: true }, () =>
40
- * (configurator) => enableContextMock(configurator, (mock) => mock.setCurrentContext(projectA)),
55
+ * const test = testApp.extend(
56
+ * 'configureApp',
57
+ * { injected: true },
58
+ * (): AppMockConfigureFn =>
59
+ * (configurator) =>
60
+ * enableContextMock(configurator, (mock) => mock.setCurrentContext(projectA)),
41
61
  * );
42
62
  *
43
63
  * test('starts on the seeded context', async ({ render }) => {
@@ -46,15 +66,71 @@ import { defaultAppEnv, resolveFusion, createAppScopeWrapper } from './scope';
46
66
  * });
47
67
  * });
48
68
  * ```
69
+ *
70
+ * @example Type `app`'s registered modules for consumers
71
+ * ```tsx
72
+ * // `configureApp`'s `TModules` generic only types that callback's own `configurator` parameter.
73
+ * // `app`'s type was already fixed (to `unknown`) when the base `testApp` chain defined it, so a
74
+ * // later `configureApp` override does not retroactively change what `app` is typed as. Re-extend
75
+ * // `app` too, with the same `TModules`, to get a typed `app.navigation` in tests.
76
+ * const test = testApp
77
+ * .extend(
78
+ * 'configureApp',
79
+ * { injected: true },
80
+ * ({ configureApp }): AppMockConfigureFn<[NavigationModule]> =>
81
+ * async (configurator, args) => {
82
+ * await configureApp?.(configurator, args);
83
+ * enableNavigation(configurator, '/app');
84
+ * },
85
+ * )
86
+ * .extend('app', ({ configureApp, appEnv, fusion }) =>
87
+ * mockAppModules<[NavigationModule], AppEnv>(configureApp, appEnv as AppEnv, fusion),
88
+ * );
89
+ *
90
+ * test('exposes the navigation module', async ({ app }) => {
91
+ * expect(app.navigation).toBeDefined();
92
+ * });
93
+ * ```
94
+ *
95
+ * @example Extend the parent framework mock with an application module
96
+ * ```tsx
97
+ * const test = testApp.extend(
98
+ * 'configureFusion',
99
+ * { injected: true },
100
+ * (): FrameworkMockConfigureFn<[AppModule, NavigationModule]> =>
101
+ * (configurator) => {
102
+ * // seed a specific flag rather than merely enabling the mock — that's already the
103
+ * // default when the optional feature-flag peer dependency is installed
104
+ * enableFeatureFlagMock(configurator, (mock) => mock.addFeature({ key: 'new-search', enabled: true }));
105
+ * configurator.serviceDiscovery.addServices([
106
+ * { key: 'people', uri: baseUrl('people') },
107
+ * { key: 'context', uri: baseUrl('context') },
108
+ * ]);
109
+ * },
110
+ * );
111
+ * ```
49
112
  */
50
113
  export const testApp = baseTest
51
- .extend('env', { injected: true }, defaultAppEnv)
114
+ .extend('appEnv', { injected: true }, defaultAppEnv)
52
115
  // `test.extend`'s plain-`value` overload rejects function types (ambiguous with the
53
116
  // resolver-`fn` overload), so a function-typed fixture default must go through `fn` instead.
54
- .extend('configure', { injected: true }, () => undefined as AppMockConfigureFn | undefined)
55
- .extend('fusion', async ({ env }) => resolveFusion(env))
56
- .extend('app', async ({ configure, env, fusion }) =>
57
- mockAppModules<unknown, AppEnv>(configure, env as AppEnv, fusion),
117
+ .extend('configureApp', { injected: true }, () => undefined as AppMockConfigureFn | undefined)
118
+ // Runs after the built-in app manifest/navigation/feature-flag setup, so a test can register
119
+ // extra framework modules, service discovery entries, or call `enableNavigation`/
120
+ // `enableFeatureFlagMock` again to override either, without reimplementing the base setup.
121
+ .extend(
122
+ 'configureFusion',
123
+ { injected: true },
124
+ () => undefined as FrameworkMockConfigureFn<[AppModule, NavigationModule]> | undefined,
125
+ )
126
+ // IMPORTANT: `.override('fusion', ...)` replaces this resolver entirely, so `configureFusion`
127
+ // is never called — reach for `configureFusion` to extend the base mock, `fusion` only to
128
+ // replace it outright (e.g. with a fully custom or non-mocked instance).
129
+ .extend('fusion', async ({ appEnv, configureFusion }) =>
130
+ resolveFusion({ env: appEnv, configure: configureFusion }),
131
+ )
132
+ .extend('app', async ({ configureApp, appEnv, fusion }) =>
133
+ mockAppModules<unknown, AppEnv>(configureApp, appEnv as AppEnv, fusion),
58
134
  )
59
135
  .extend('render', ({ fusion, app }) => {
60
136
  const wrapper = createAppScopeWrapper({ framework: fusion, app });
package/src/test.tsx CHANGED
@@ -3,7 +3,7 @@ import { testApp as baseTestApp } from './test-app';
3
3
  // resolved at test-time by `appTestVitePlugin` (@equinor/fusion-framework-vitest-plugin-react-app);
4
4
  // see virtual-modules.d.ts for the ambient module declarations
5
5
  import { manifest, config } from 'virtual:fusion-app-test-env';
6
- import { configure } from 'virtual:fusion-app-test-configure';
6
+ import { configure as configureApp } from 'virtual:fusion-app-test-configure';
7
7
 
8
8
  /**
9
9
  * `vitest`'s `test`, pre-seeded with the application's own manifest, config, and
@@ -14,8 +14,15 @@ import { configure } from 'virtual:fusion-app-test-configure';
14
14
  * your `vitest.config.ts` `plugins`, which serves the virtual modules backing this fixture.
15
15
  * Running the same test file without the plugin registered fails to resolve those imports.
16
16
  *
17
- * Per-test mocking still works exactly like the base `testApp`: `.extend('configure', ...)` or
18
- * a per-case `test.override('env', ...)` layers on top of the resolved values.
17
+ * Per-test mocking still works exactly like the base `testApp`: `.extend('configureApp', ...)` or
18
+ * a per-case `test.override('appEnv', ...)` layers on top of the resolved values.
19
+ *
20
+ * @remarks `.override('configureApp', ...)` replaces the app's real `configure`
21
+ * This fixture's default value *is* the app's real `src/config.ts` `configure` export.
22
+ * `.override('configureApp', ...)` replaces that default outright, so an override that doesn't
23
+ * itself call the real `configure(configurator, args)` skips the app's production module setup
24
+ * entirely, rather than composing with it. See [Advanced usage](../docs/advanced.md) for the
25
+ * compose-safely pattern.
19
26
  *
20
27
  * @example
21
28
  * ```tsx
@@ -29,7 +36,7 @@ import { configure } from 'virtual:fusion-app-test-configure';
29
36
  * ```
30
37
  */
31
38
  export const test = baseTestApp
32
- .extend('env', { injected: true }, { manifest, config })
33
- .extend('configure', { injected: true }, () => configure);
39
+ .extend('appEnv', { injected: true }, { manifest, config })
40
+ .extend('configureApp', { injected: true }, () => configureApp);
34
41
 
35
42
  export default test;
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '0.2.0-next.3';
2
+ export const version = '1.0.0-next.5';
package/tsconfig.json CHANGED
@@ -15,6 +15,8 @@
15
15
  { "path": "../../app" },
16
16
  { "path": "../../modules/module" },
17
17
  { "path": "../../modules/app" },
18
+ { "path": "../../modules/navigation" },
19
+ { "path": "../../modules/feature-flag" },
18
20
  { "path": "../../react/framework" },
19
21
  { "path": "../../react/modules/module" },
20
22
  { "path": "../../utils/imports" }