@equinor/fusion-framework-vitest-plugin-react-app 0.2.0-next.0

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 (78) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/LICENSE +21 -0
  3. package/README.md +349 -0
  4. package/dist/esm/app-test.js +9 -0
  5. package/dist/esm/app-test.js.map +1 -0
  6. package/dist/esm/define-project.js +68 -0
  7. package/dist/esm/define-project.js.map +1 -0
  8. package/dist/esm/index.js +99 -0
  9. package/dist/esm/index.js.map +1 -0
  10. package/dist/esm/render-app-component.js +52 -0
  11. package/dist/esm/render-app-component.js.map +1 -0
  12. package/dist/esm/render-app-hook.js +63 -0
  13. package/dist/esm/render-app-hook.js.map +1 -0
  14. package/dist/esm/render.js +49 -0
  15. package/dist/esm/render.js.map +1 -0
  16. package/dist/esm/resolve-app-test-env.js +85 -0
  17. package/dist/esm/resolve-app-test-env.js.map +1 -0
  18. package/dist/esm/scope/create-app-scope-wrapper.js +15 -0
  19. package/dist/esm/scope/create-app-scope-wrapper.js.map +1 -0
  20. package/dist/esm/scope/default-app-env.js +12 -0
  21. package/dist/esm/scope/default-app-env.js.map +1 -0
  22. package/dist/esm/scope/index.js +5 -0
  23. package/dist/esm/scope/index.js.map +1 -0
  24. package/dist/esm/scope/resolve-app-scope.js +21 -0
  25. package/dist/esm/scope/resolve-app-scope.js.map +1 -0
  26. package/dist/esm/scope/resolve-fusion.js +18 -0
  27. package/dist/esm/scope/resolve-fusion.js.map +1 -0
  28. package/dist/esm/test-app.js +59 -0
  29. package/dist/esm/test-app.js.map +1 -0
  30. package/dist/esm/test.js +33 -0
  31. package/dist/esm/test.js.map +1 -0
  32. package/dist/esm/version.js +3 -0
  33. package/dist/esm/version.js.map +1 -0
  34. package/dist/tsconfig.tsbuildinfo +1 -0
  35. package/dist/types/app-test.d.ts +6 -0
  36. package/dist/types/define-project.d.ts +41 -0
  37. package/dist/types/index.d.ts +45 -0
  38. package/dist/types/render-app-component.d.ts +86 -0
  39. package/dist/types/render-app-hook.d.ts +99 -0
  40. package/dist/types/render.d.ts +37 -0
  41. package/dist/types/resolve-app-test-env.d.ts +56 -0
  42. package/dist/types/scope/create-app-scope-wrapper.d.ts +14 -0
  43. package/dist/types/scope/default-app-env.d.ts +5 -0
  44. package/dist/types/scope/index.d.ts +4 -0
  45. package/dist/types/scope/resolve-app-scope.d.ts +31 -0
  46. package/dist/types/scope/resolve-fusion.d.ts +13 -0
  47. package/dist/types/test-app.d.ts +68 -0
  48. package/dist/types/test.d.ts +66 -0
  49. package/dist/types/version.d.ts +1 -0
  50. package/docs/advanced.md +140 -0
  51. package/docs/configuration.md +109 -0
  52. package/docs/getting-started.md +66 -0
  53. package/docs/migrating-an-existing-app.md +197 -0
  54. package/docs/module-mocks.md +120 -0
  55. package/docs/overview.md +49 -0
  56. package/docs/troubleshooting.md +64 -0
  57. package/docs/why-browser-mode.md +113 -0
  58. package/package.json +87 -0
  59. package/src/__tests__/app-test-vite-plugin.test.ts +109 -0
  60. package/src/__tests__/resolve-app-test-env.test.ts +83 -0
  61. package/src/app-test.ts +18 -0
  62. package/src/define-project.ts +84 -0
  63. package/src/index.ts +124 -0
  64. package/src/render-app-component.tsx +116 -0
  65. package/src/render-app-hook.tsx +135 -0
  66. package/src/render.tsx +64 -0
  67. package/src/resolve-app-test-env.ts +132 -0
  68. package/src/scope/create-app-scope-wrapper.tsx +26 -0
  69. package/src/scope/default-app-env.ts +13 -0
  70. package/src/scope/index.ts +4 -0
  71. package/src/scope/resolve-app-scope.ts +46 -0
  72. package/src/scope/resolve-fusion.ts +29 -0
  73. package/src/test-app.tsx +72 -0
  74. package/src/test.tsx +35 -0
  75. package/src/version.ts +2 -0
  76. package/src/virtual-modules.d.ts +12 -0
  77. package/tsconfig.json +24 -0
  78. package/vitest.config.ts +10 -0
package/src/index.ts ADDED
@@ -0,0 +1,124 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ import { FileNotFoundError } from '@equinor/fusion-imports';
5
+ import type { Plugin } from 'vite';
6
+
7
+ import { resolveAppTestEnv, type ResolveAppTestEnvOptions } from './resolve-app-test-env.js';
8
+
9
+ const ENV_MODULE_ID = 'virtual:fusion-app-test-env';
10
+ const CONFIGURE_MODULE_ID = 'virtual:fusion-app-test-configure';
11
+ const RESOLVED_ENV_MODULE_ID = `\0${ENV_MODULE_ID}`;
12
+ const RESOLVED_CONFIGURE_MODULE_ID = `\0${CONFIGURE_MODULE_ID}`;
13
+
14
+ const DEFAULT_CONFIGURE_CANDIDATES = ['src/config.ts', 'src/config.tsx', 'src/config.js'];
15
+
16
+ /**
17
+ * Options for {@link appTestVitePlugin}.
18
+ */
19
+ export type AppTestVitePluginOptions = ResolveAppTestEnvOptions & {
20
+ /**
21
+ * Path (relative to `entrypoint`) to the app's module-configurator export, mirroring the
22
+ * `configure` argument passed to `makeComponent` in the app's own entry point. Defaults to the
23
+ * first of `src/config.ts`, `src/config.tsx`, `src/config.js` that exists.
24
+ *
25
+ * @remarks
26
+ * Unlike `manifest`/`config`, this can't be an inline function: it's live application code
27
+ * (with its own imports and closures) re-exported as-is into the test bundle rather than
28
+ * JSON-serialized data, so Vite needs a real file on disk to resolve and transform.
29
+ */
30
+ configure?: string;
31
+ };
32
+
33
+ /**
34
+ * Vite plugin serving an application's manifest/config (resolved the same way `ffc app build`/
35
+ * `ffc app dev` do) and its own module-configurator export as virtual modules, so
36
+ * `@equinor/fusion-framework-vitest-plugin-react-app/test`'s `test`/`render` need no per-test
37
+ * `env`/`configure` wiring.
38
+ *
39
+ * @remarks
40
+ * A plain Vite plugin — Vitest configs are Vite configs, so this registers directly in your
41
+ * own `vitest.config.ts`, no CLI command required:
42
+ * ```ts
43
+ * import { defineConfig } from 'vitest/config';
44
+ * import { appTestVitePlugin } from '@equinor/fusion-framework-vitest-plugin-react-app';
45
+ *
46
+ * export default defineConfig({
47
+ * plugins: [appTestVitePlugin()],
48
+ * // ...your own browser-mode config
49
+ * });
50
+ * ```
51
+ * Exposes two virtual modules: `virtual:fusion-app-test-env` (`manifest`/`config`, as JSON) and
52
+ * `virtual:fusion-app-test-configure` (a re-export of the resolved `configure` module, or
53
+ * `undefined` if none exists). Not intended to be imported directly by application code.
54
+ *
55
+ * @param options - Resolution options; `entrypoint` defaults to the current working directory.
56
+ * @returns A Vite plugin instance.
57
+ */
58
+ export const appTestVitePlugin = (options?: AppTestVitePluginOptions): Plugin => {
59
+ const explicitEntrypoint = options?.entrypoint;
60
+ let entrypoint = explicitEntrypoint;
61
+ let configureModulePath = explicitEntrypoint
62
+ ? resolveConfigureModulePath(explicitEntrypoint, options?.configure)
63
+ : undefined;
64
+
65
+ return {
66
+ name: 'fusion:app-test',
67
+ configResolved(config) {
68
+ // Vitest resolves each workspace project's root from its config file, which identifies the app containing src/index.ts.
69
+ entrypoint ??= config.root;
70
+ configureModulePath ??= resolveConfigureModulePath(entrypoint, options?.configure);
71
+ },
72
+ resolveId(id) {
73
+ // claim only our two virtual specifiers, leave everything else to the normal resolvers
74
+ if (id === ENV_MODULE_ID) return RESOLVED_ENV_MODULE_ID;
75
+ // second virtual specifier, same rule as above
76
+ if (id === CONFIGURE_MODULE_ID) return RESOLVED_CONFIGURE_MODULE_ID;
77
+ return null;
78
+ },
79
+ async load(id) {
80
+ // serves manifest/config resolved lazily here, not at plugin-creation time, so options.entrypoint changes between test runs are respected
81
+ if (id === RESOLVED_ENV_MODULE_ID) {
82
+ const { manifest, config } = await resolveAppTestEnv({ ...options, entrypoint });
83
+ return [
84
+ `export const manifest = ${JSON.stringify(manifest)};`,
85
+ `export const config = ${JSON.stringify(config)};`,
86
+ ].join('\n');
87
+ }
88
+ // no conventional module means the app registers no extra modules, same as omitting `configure` from `makeComponent`
89
+ if (id === RESOLVED_CONFIGURE_MODULE_ID) {
90
+ return configureModulePath
91
+ ? `export { default as configure } from ${JSON.stringify(configureModulePath)};`
92
+ : 'export const configure = undefined;';
93
+ }
94
+ return null;
95
+ },
96
+ };
97
+ };
98
+
99
+ /**
100
+ * Resolves the app's module-configurator file: the explicit `file` if given, otherwise the
101
+ * first existing candidate in {@link DEFAULT_CONFIGURE_CANDIDATES}.
102
+ *
103
+ * @throws {@link FileNotFoundError} If an explicitly requested `file` does not exist — unlike
104
+ * the convention-based lookup, a typo here should fail loudly instead of silently running the
105
+ * test suite without the application's modules.
106
+ */
107
+ const resolveConfigureModulePath = (cwd: string, file?: string): string | undefined => {
108
+ // an explicit path is a user request, not a convention lookup, so a typo must fail loudly
109
+ if (file) {
110
+ const resolved = resolve(cwd, file);
111
+ // fail fast rather than silently falling back to "no configurator"
112
+ if (!existsSync(resolved)) {
113
+ throw new FileNotFoundError(`Configure module not found: ${resolved}`);
114
+ }
115
+ return resolved;
116
+ }
117
+ // first candidate that exists wins; none existing is a valid "no configurator" state
118
+ const found = DEFAULT_CONFIGURE_CANDIDATES.find((candidate) =>
119
+ existsSync(resolve(cwd, candidate)),
120
+ );
121
+ return found ? resolve(cwd, found) : undefined;
122
+ };
123
+
124
+ export default appTestVitePlugin;
@@ -0,0 +1,116 @@
1
+ import type { ReactElement } from 'react';
2
+ import { render } from 'vitest-browser-react';
3
+ import type { RenderOptions, RenderResult } from 'vitest-browser-react';
4
+
5
+ import type { AppMockConfigureFn } from '@equinor/fusion-framework-app/mock';
6
+ import type { AppEnv, AppModulesInstance } from '@equinor/fusion-framework-app';
7
+ import type { Fusion } from '@equinor/fusion-framework';
8
+ import type { AnyModule } from '@equinor/fusion-framework-module';
9
+
10
+ import { resolveAppScope, createAppScopeWrapper } from './scope';
11
+
12
+ /**
13
+ * Options for {@link renderAppComponent}.
14
+ *
15
+ * @template TModules - Module descriptors beyond the default set.
16
+ * @template TEnv - The application environment descriptor.
17
+ */
18
+ export interface RenderAppComponentOptions<
19
+ TModules extends Array<AnyModule> | unknown = unknown,
20
+ TEnv extends AppEnv = AppEnv,
21
+ > extends Omit<RenderOptions, 'wrapper'> {
22
+ /** Configuration callback forwarded to {@link mockAppModules}. */
23
+ configure?: AppMockConfigureFn<TModules, TEnv>;
24
+ /** The application environment; defaults to a generic standalone test app. */
25
+ env?: TEnv;
26
+ /**
27
+ * The parent Fusion instance; defaults to a fresh {@link mockFramework} instance with
28
+ * this app's own manifest served. Pass one built beforehand to reuse a single instance
29
+ * across multiple render calls, or to pre-configure parent-level modules (e.g. `http`,
30
+ * `context`, `serviceDiscovery`, or `app` itself for a component that loads another app).
31
+ */
32
+ fusion?: Fusion;
33
+ }
34
+
35
+ /**
36
+ * The result of {@link renderAppComponent}: the `vitest-browser-react` render result,
37
+ * plus the resolved application module scope and its parent Fusion instance.
38
+ *
39
+ * @template TModules - Module descriptors beyond the default set.
40
+ */
41
+ export interface RenderAppComponentResult<TModules extends Array<AnyModule> | unknown = unknown>
42
+ extends RenderResult {
43
+ /**
44
+ * The Fusion instances backing the rendered component, nested under this single key so
45
+ * `vitest-browser-react`'s own `RenderResult` fields stay free to evolve without ever
46
+ * colliding with it.
47
+ */
48
+ fusion: {
49
+ /** The parent Fusion instance the component's `FrameworkProvider` was given. */
50
+ framework: Fusion;
51
+ /**
52
+ * The resolved application module instance backing the rendered component — the same
53
+ * instance a real app would read via `useAppModule`/`useAppModules`. Drive a module
54
+ * directly (e.g. `fusion.app.context.setCurrentContextByIdAsync(id)`) to exercise a
55
+ * state change after the initial render, then assert the component re-rendered accordingly.
56
+ */
57
+ app: AppModulesInstance<TModules>;
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Renders a component inside a real, mock-backed application module scope.
63
+ *
64
+ * @remarks
65
+ * Wraps `vitest-browser-react`'s `render` with the same provider nesting
66
+ * `createComponent` uses in production — a `FrameworkProvider` (the parent Fusion
67
+ * instance, from `mockFramework`) around a `ModuleProvider` (this app's own modules,
68
+ * from `mockAppModules`, `@equinor/fusion-framework-app/mock`). See {@link renderAppHook}
69
+ * for the equivalent helper when testing a hook in isolation, rather than a component.
70
+ *
71
+ * @template TModules - Module descriptors beyond the default set.
72
+ * @template TEnv - The application environment descriptor.
73
+ * @param ui - The component to render.
74
+ * @param options - A `configure` callback and `env` for `mockAppModules`, plus any other `render` option.
75
+ * @returns The `render` result plus `fusion.framework` and `fusion.app`, once the mocked application module scope resolves.
76
+ *
77
+ * @example
78
+ * ```tsx
79
+ * const { getByText } = await renderAppComponent(<Apploader appKey="child-app" />, {
80
+ * fusion: await mockFramework<[AppModule]>((configurator) => {
81
+ * // register the child app's own manifest against the `app` module
82
+ * }),
83
+ * });
84
+ * await expect.element(getByText(/mounted/)).toBeInTheDocument();
85
+ * ```
86
+ *
87
+ * @example Drive a module directly and assert the re-render
88
+ * ```tsx
89
+ * const { getByText, fusion } = await renderAppComponent<[ContextModule]>(<App />, {
90
+ * configure: (configurator) => enableContextMock(configurator, (mock) => mock.setCurrentContext(projectA)),
91
+ * });
92
+ * await fusion.app.context.setCurrentContextByIdAsync(projectB.id);
93
+ * await expect.element(getByText(/project-b/)).toBeInTheDocument();
94
+ * ```
95
+ */
96
+ export async function renderAppComponent<
97
+ TModules extends Array<AnyModule> | unknown = unknown,
98
+ TEnv extends AppEnv = AppEnv,
99
+ >(
100
+ ui: ReactElement,
101
+ options?: RenderAppComponentOptions<TModules, TEnv>,
102
+ ): Promise<RenderAppComponentResult<TModules>> {
103
+ const { configure, env, fusion: providedFusion, ...renderOptions } = options ?? {};
104
+ const { framework, app } = await resolveAppScope<TModules, TEnv>({
105
+ configure,
106
+ env,
107
+ fusion: providedFusion,
108
+ });
109
+ const result = await render(ui, {
110
+ ...renderOptions,
111
+ wrapper: createAppScopeWrapper<TModules>({ framework, app }),
112
+ });
113
+ return { ...result, fusion: { framework, app } };
114
+ }
115
+
116
+ export default renderAppComponent;
@@ -0,0 +1,135 @@
1
+ import { renderHook } from 'vitest-browser-react';
2
+ import type { RenderHookOptions, RenderHookResult } from 'vitest-browser-react';
3
+
4
+ import type { AppMockConfigureFn } from '@equinor/fusion-framework-app/mock';
5
+ import type { AppEnv, AppModulesInstance } from '@equinor/fusion-framework-app';
6
+ import type { Fusion } from '@equinor/fusion-framework';
7
+ import type { AnyModule } from '@equinor/fusion-framework-module';
8
+
9
+ import { resolveAppScope, createAppScopeWrapper } from './scope';
10
+
11
+ /**
12
+ * Options for {@link renderAppHook}.
13
+ *
14
+ * @template TModules - Module descriptors beyond the default set.
15
+ * @template TEnv - The application environment descriptor.
16
+ * @template Props - The props type accepted by the rendered hook.
17
+ */
18
+ export interface RenderAppHookOptions<
19
+ TModules extends Array<AnyModule> | unknown = unknown,
20
+ TEnv extends AppEnv = AppEnv,
21
+ Props = undefined,
22
+ > extends Omit<RenderHookOptions<Props>, 'wrapper'> {
23
+ /** Configuration callback forwarded to {@link mockAppModules}. */
24
+ configure?: AppMockConfigureFn<TModules, TEnv>;
25
+ /** The application environment; defaults to a generic standalone test app. */
26
+ env?: TEnv;
27
+ /**
28
+ * The parent Fusion instance; defaults to a fresh {@link mockFramework} instance with
29
+ * this app's own manifest served. Pass one built beforehand to reuse a single instance
30
+ * across multiple `renderAppHook` calls, or to pre-configure parent-level modules (e.g.
31
+ * `http`, `context`, `serviceDiscovery`) the app reads through `useFramework`.
32
+ */
33
+ fusion?: Fusion;
34
+ }
35
+
36
+ /**
37
+ * The result of {@link renderAppHook}: the `vitest-browser-react` `renderHook` result,
38
+ * plus the resolved application module scope and its parent Fusion instance.
39
+ *
40
+ * @template Result - The value returned by the rendered hook.
41
+ * @template Props - The props accepted by the rendered hook.
42
+ * @template TModules - Module descriptors beyond the default set.
43
+ */
44
+ export interface RenderAppHookResult<
45
+ Result,
46
+ Props,
47
+ TModules extends Array<AnyModule> | unknown = unknown,
48
+ > extends RenderHookResult<Result, Props> {
49
+ /**
50
+ * The Fusion instances backing the rendered hook, nested under this single key so
51
+ * `vitest-browser-react`'s own `RenderHookResult` fields stay free to evolve without
52
+ * ever colliding with it.
53
+ */
54
+ fusion: {
55
+ /** The parent Fusion instance the hook's `FrameworkProvider` was given. */
56
+ framework: Fusion;
57
+ /**
58
+ * The resolved application module instance backing the rendered hook — the same
59
+ * instance a real app would read via `useAppModule`/`useAppModules`. Drive a module
60
+ * not returned by the hook itself (e.g. `fusion.app.context.setCurrentContextByIdAsync(id)`)
61
+ * to exercise a state change after the initial render.
62
+ */
63
+ app: AppModulesInstance<TModules>;
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Renders a hook inside a real, mock-backed application module scope.
69
+ *
70
+ * @remarks
71
+ * Wraps `vitest-browser-react`'s `renderHook` with the same provider nesting
72
+ * `createComponent` uses in production — a `FrameworkProvider` (the parent Fusion
73
+ * instance, from `mockFramework`) around a `ModuleProvider` (this app's own modules,
74
+ * from `mockAppModules`, `@equinor/fusion-framework-app/mock`) — the real
75
+ * `event`/`http`/`msal` module pipeline. Only requests a seeded middleware answers are
76
+ * faked; a request with no matching middleware still reaches the real network. Use this
77
+ * for any hook that reads from the application module scope or the parent framework
78
+ * (e.g. `useAppModule`, `useAccessToken`, `useFramework`), instead of hand-wiring
79
+ * `mockFramework`, `mockAppModules`, `FrameworkProvider` and `ModuleProvider` in every test.
80
+ *
81
+ * @template Result - The value returned by the rendered hook.
82
+ * @template Props - The props accepted by the rendered hook.
83
+ * @template TModules - Module descriptors beyond the default set.
84
+ * @template TEnv - The application environment descriptor.
85
+ * @param render - The hook to render, receiving `initialProps`.
86
+ * @param options - A `configure` callback and `env` for `mockAppModules`, plus any other
87
+ * `renderHook` option.
88
+ * @returns The `renderHook` result plus `fusion.framework` and `fusion.app`, once the mocked application module scope resolves.
89
+ *
90
+ * @example
91
+ * ```tsx
92
+ * const { result } = await renderAppHook(() => useAccessToken({ scopes: ['User.Read'] }));
93
+ * await vi.waitFor(() => expect(result.current.pending).toBe(false));
94
+ * ```
95
+ *
96
+ * @example Sign in a named user
97
+ * ```tsx
98
+ * const { result } = await renderAppHook(() => useCurrentAccount(), {
99
+ * configure: (configurator) => configurator.msal.setAccount({ name: 'Ada Lovelace' }),
100
+ * });
101
+ * ```
102
+ *
103
+ * @example Reuse a pre-built parent Fusion instance
104
+ * ```tsx
105
+ * const fusion = await mockFramework<[AppModule]>((configurator) =>
106
+ * enableAppManifestMock(configurator, env),
107
+ * );
108
+ *
109
+ * const { result: a } = await renderAppHook(() => useAccessToken({ scopes: ['User.Read'] }), { fusion });
110
+ * const { result: b } = await renderAppHook(() => useCurrentAccount(), { fusion });
111
+ * ```
112
+ */
113
+ export async function renderAppHook<
114
+ Result,
115
+ Props = undefined,
116
+ TModules extends Array<AnyModule> | unknown = unknown,
117
+ TEnv extends AppEnv = AppEnv,
118
+ >(
119
+ render: (initialProps?: Props) => Result,
120
+ options?: RenderAppHookOptions<TModules, TEnv, Props>,
121
+ ): Promise<RenderAppHookResult<Result, Props, TModules>> {
122
+ const { configure, env, fusion: providedFusion, ...renderHookOptions } = options ?? {};
123
+ const { framework, app } = await resolveAppScope<TModules, TEnv>({
124
+ configure,
125
+ env,
126
+ fusion: providedFusion,
127
+ });
128
+ const result = await renderHook(render, {
129
+ ...renderHookOptions,
130
+ wrapper: createAppScopeWrapper<TModules>({ framework, app }),
131
+ });
132
+ return { ...result, fusion: { framework, app } };
133
+ }
134
+
135
+ export default renderAppHook;
package/src/render.tsx ADDED
@@ -0,0 +1,64 @@
1
+ import type { ReactElement } from 'react';
2
+
3
+ import type { AnyModule } from '@equinor/fusion-framework-module';
4
+ import type { AppEnv } from '@equinor/fusion-framework-app';
5
+ import type { AppMockConfigureFn } from '@equinor/fusion-framework-app/mock';
6
+
7
+ import {
8
+ renderAppComponent,
9
+ type RenderAppComponentOptions,
10
+ type RenderAppComponentResult,
11
+ } from './render-app-component';
12
+
13
+ // resolved at test-time by `appTestVitePlugin` (@equinor/fusion-framework-vitest-plugin-react-app);
14
+ // see virtual-modules.d.ts for the ambient module declarations
15
+ import { manifest, config } from 'virtual:fusion-app-test-env';
16
+ import { configure } from 'virtual:fusion-app-test-configure';
17
+
18
+ /**
19
+ * Renders a component inside the application's own module scope for use in plain `describe`/`it`
20
+ * tests, using the manifest, config, and module-configurator resolved for this application — no
21
+ * per-test wiring, and no custom `test` fixture required.
22
+ *
23
+ * @remarks
24
+ * Requires `appTestVitePlugin` (`@equinor/fusion-framework-vitest-plugin-react-app`) registered in
25
+ * your `vitest.config.ts` `plugins`, which serves the virtual modules backing the resolved
26
+ * `env`/`configure`. Running the same test file without the plugin registered fails to resolve
27
+ * those imports.
28
+ *
29
+ * Pass `env` or `configure` in `options` to override the resolved values for a single render.
30
+ *
31
+ * @template TModules - Module descriptors beyond the default set.
32
+ * @param ui - The component to render.
33
+ * @param options - Overrides for `env`/`configure`, plus any other `renderAppComponent` option.
34
+ * @returns The `render` result plus `fusion.framework` and `fusion.app`, once the mocked application module scope resolves.
35
+ * @example
36
+ * ```tsx
37
+ * import { describe, expect, it } from 'vitest';
38
+ * import { render } from '@equinor/fusion-framework-vitest-plugin-react-app/test';
39
+ * import { App } from '../App';
40
+ *
41
+ * describe('App', () => {
42
+ * it('renders the app', async () => {
43
+ * const { getByRole } = await render(<App />);
44
+ * await expect.element(getByRole('heading')).toBeVisible();
45
+ * });
46
+ * });
47
+ * ```
48
+ */
49
+ export async function render<TModules extends Array<AnyModule> | unknown = unknown>(
50
+ ui: ReactElement,
51
+ options?: RenderAppComponentOptions<TModules, AppEnv>,
52
+ ): Promise<RenderAppComponentResult<TModules>> {
53
+ const { configure: configureOverride, env: envOverride, ...renderOptions } = options ?? {};
54
+ return renderAppComponent<TModules, AppEnv>(ui, {
55
+ ...renderOptions,
56
+ env: envOverride ?? { manifest, config },
57
+ // the resolved `configure` is generic over the app's own module set (`unknown`), while
58
+ // `TModules` here is caller-supplied *extra* modules — safe to widen for the common case
59
+ // where callers don't override it with a `TModules`-specific configurator
60
+ configure: (configureOverride ?? configure) as AppMockConfigureFn<TModules, AppEnv> | undefined,
61
+ });
62
+ }
63
+
64
+ export default render;
@@ -0,0 +1,132 @@
1
+ import { FileNotFoundError } from '@equinor/fusion-imports';
2
+ import type { AppManifest } from '@equinor/fusion-framework-module-app';
3
+ import type { RuntimeEnv } from '@equinor/fusion-framework-cli';
4
+ import {
5
+ createAppManifestFromPackage,
6
+ loadAppManifest,
7
+ loadAppConfig,
8
+ mergeAppManifests,
9
+ ApiAppConfigSchema,
10
+ type AppManifestFn,
11
+ type AppConfigFn,
12
+ type ApiAppConfig,
13
+ } from '@equinor/fusion-framework-cli/app';
14
+ import { resolvePackage } from '@equinor/fusion-framework-cli/utils';
15
+
16
+ /**
17
+ * The application manifest and config resolved for a test run.
18
+ */
19
+ export type AppTestEnv = {
20
+ manifest: AppManifest;
21
+ config: ApiAppConfig;
22
+ };
23
+
24
+ /**
25
+ * Options for {@link resolveAppTestEnv}.
26
+ */
27
+ export type ResolveAppTestEnvOptions = {
28
+ /** Directory to resolve the package, manifest, and config from. Defaults to `process.cwd()`. */
29
+ entrypoint?: string;
30
+ /**
31
+ * An explicit manifest file to load instead of the default `app.manifest(.*)?` lookup, or a
32
+ * manifest function (same shape as `defineAppManifest`'s argument) applied directly.
33
+ */
34
+ manifest?: string | AppManifestFn;
35
+ /**
36
+ * An explicit config file to load instead of the default `app.config(.*)?` lookup, or a
37
+ * config function (same shape as `defineAppConfig`'s argument) applied directly.
38
+ */
39
+ config?: string | AppConfigFn;
40
+ };
41
+
42
+ /**
43
+ * Resolves an application's manifest and config using the same pipeline `ffc app build`/
44
+ * `ffc app dev` use: a base manifest generated from `package.json`, merged with a local
45
+ * `app.manifest.ts` if one exists; and `app.config.ts` for endpoints/environment, falling
46
+ * back to an empty config if none exists.
47
+ *
48
+ * @remarks
49
+ * Intended to seed `@equinor/fusion-framework-vitest-plugin-react-app/test`'s `testApp` `env` fixture, so
50
+ * a test suite exercises the application's real manifest/config instead of a hand-maintained
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', ...)`.
54
+ *
55
+ * @param options - Resolution options; `entrypoint` defaults to the current working directory.
56
+ * @returns The resolved application manifest and config.
57
+ * @throws If no `package.json` can be found from `entrypoint` upward, or an explicitly requested
58
+ * `manifest`/`config` file does not exist.
59
+ * @example
60
+ * ```ts
61
+ * import { resolveAppTestEnv } from '@equinor/fusion-framework-vitest-plugin-react-app';
62
+ * import { testApp } from '@equinor/fusion-framework-vitest-plugin-react-app/test';
63
+ * import { configure } from '../config';
64
+ *
65
+ * const test = testApp
66
+ * .extend('env', { injected: true }, () => resolveAppTestEnv())
67
+ * .extend('configure', { injected: true }, () => configure);
68
+ * ```
69
+ */
70
+ export const resolveAppTestEnv = async (
71
+ options?: ResolveAppTestEnvOptions,
72
+ ): Promise<AppTestEnv> => {
73
+ const pkg = await resolvePackage({ cwd: options?.entrypoint });
74
+ const env: RuntimeEnv = { command: 'build', mode: 'test', root: pkg.root, environment: 'test' };
75
+
76
+ const baseManifest = createAppManifestFromPackage(env, pkg.packageJson);
77
+ const [manifest, config] = await Promise.all([
78
+ resolveManifest(env, baseManifest, options?.manifest),
79
+ resolveConfig(env, options?.config),
80
+ ]);
81
+
82
+ return { manifest, config };
83
+ };
84
+
85
+ /**
86
+ * Loads `app.manifest(.*)?` (or applies an inline {@link AppManifestFn}), falling back to the
87
+ * package-derived manifest when none exists — mirrors `ffc app build`'s own fallback so a test
88
+ * env matches a real build with zero manifest file.
89
+ */
90
+ const resolveManifest = async (
91
+ env: RuntimeEnv,
92
+ base: AppManifest,
93
+ manifest?: string | AppManifestFn,
94
+ ): Promise<AppManifest> => {
95
+ // an inline function is applied directly, the same way a loaded `app.manifest.ts`'s default export would be
96
+ if (typeof manifest === 'function') {
97
+ const result = await manifest(env, { base });
98
+ return mergeAppManifests(base, result ?? {});
99
+ }
100
+ try {
101
+ return (await loadAppManifest(env, { base, file: manifest })).manifest;
102
+ } catch (err) {
103
+ // an explicitly requested manifest that's missing is a real error; only the default lookup falls back
104
+ if (err instanceof FileNotFoundError && !manifest) return base;
105
+ throw err;
106
+ }
107
+ };
108
+
109
+ /**
110
+ * Loads `app.config(.*)?` (or applies an inline {@link AppConfigFn}), falling back to an empty
111
+ * config when none exists — mirrors `ffc app build`'s own fallback so a test env matches a real
112
+ * build with zero config file.
113
+ */
114
+ const resolveConfig = async (
115
+ env: RuntimeEnv,
116
+ config?: string | AppConfigFn,
117
+ ): Promise<ApiAppConfig> => {
118
+ // an inline function is applied directly, the same way a loaded `app.config.ts`'s default export would be
119
+ if (typeof config === 'function') {
120
+ const result = await config(env, { base: { environment: {} } });
121
+ return ApiAppConfigSchema.parse(result ?? { environment: {} });
122
+ }
123
+ try {
124
+ return (await loadAppConfig(env, { file: config })).config;
125
+ } catch (err) {
126
+ // an explicitly requested config that's missing is a real error; only the default lookup falls back
127
+ if (err instanceof FileNotFoundError && !config) return { environment: {} };
128
+ throw err;
129
+ }
130
+ };
131
+
132
+ export default resolveAppTestEnv;
@@ -0,0 +1,26 @@
1
+ import type { ReactElement, ReactNode } from 'react';
2
+
3
+ import { FrameworkProvider } from '@equinor/fusion-framework-react';
4
+ import { ModuleProvider } from '@equinor/fusion-framework-react-module';
5
+ import type { AnyModule } from '@equinor/fusion-framework-module';
6
+
7
+ import type { AppScope } from './resolve-app-scope';
8
+
9
+ /**
10
+ * The provider nesting `createComponent` uses in production, for wrapping a component or
11
+ * hook under test in its resolved {@link AppScope}.
12
+ *
13
+ * @template TModules - Module descriptors beyond the default set.
14
+ * @param scope - The resolved parent Fusion instance and application module scope.
15
+ * @returns A wrapper component nesting a `FrameworkProvider` around a `ModuleProvider`.
16
+ */
17
+ export function createAppScopeWrapper<TModules extends Array<AnyModule> | unknown = unknown>({
18
+ framework,
19
+ app,
20
+ }: AppScope<TModules>): (props: { children: ReactNode }) => ReactElement {
21
+ return ({ children }: { children: ReactNode }) => (
22
+ <FrameworkProvider value={framework}>
23
+ <ModuleProvider value={app}>{children}</ModuleProvider>
24
+ </FrameworkProvider>
25
+ );
26
+ }
@@ -0,0 +1,13 @@
1
+ import type { AppEnv } from '@equinor/fusion-framework-app';
2
+
3
+ /**
4
+ * The application environment used when a test does not care about its own app identity.
5
+ */
6
+ export const defaultAppEnv: AppEnv = {
7
+ manifest: {
8
+ appKey: 'test-app',
9
+ displayName: 'Test App',
10
+ description: 'A test application',
11
+ type: 'standalone',
12
+ },
13
+ };
@@ -0,0 +1,4 @@
1
+ export { defaultAppEnv } from './default-app-env';
2
+ export { resolveFusion } from './resolve-fusion';
3
+ export { resolveAppScope, type AppScope } from './resolve-app-scope';
4
+ export { createAppScopeWrapper } from './create-app-scope-wrapper';
@@ -0,0 +1,46 @@
1
+ import { mockAppModules } from '@equinor/fusion-framework-app/mock';
2
+ import type { AppMockConfigureFn } from '@equinor/fusion-framework-app/mock';
3
+ import type { AppEnv, AppModulesInstance } from '@equinor/fusion-framework-app';
4
+ import type { Fusion } from '@equinor/fusion-framework';
5
+ import type { AnyModule } from '@equinor/fusion-framework-module';
6
+
7
+ import { resolveFusion } from './resolve-fusion';
8
+ import { defaultAppEnv } from './default-app-env';
9
+
10
+ /**
11
+ * The parent Fusion instance and resolved application module scope shared by every
12
+ * `packages/react/app` testing helper.
13
+ *
14
+ * @template TModules - Module descriptors beyond the default set.
15
+ */
16
+ export interface AppScope<TModules extends Array<AnyModule> | unknown = unknown> {
17
+ /** The parent Fusion instance the application scope was resolved against. */
18
+ framework: Fusion;
19
+ /** The resolved application module instance. */
20
+ app: AppModulesInstance<TModules>;
21
+ }
22
+
23
+ /**
24
+ * Resolves the parent Fusion instance and application module scope shared by every
25
+ * `packages/react/app` testing helper.
26
+ *
27
+ * @template TModules - Module descriptors beyond the default set.
28
+ * @template TEnv - The application environment descriptor.
29
+ * @param options - A `configure` callback and `env` for {@link mockAppModules}, plus an
30
+ * already-built `fusion` instance to reuse instead of a fresh one.
31
+ * @returns The resolved {@link AppScope}.
32
+ */
33
+ export async function resolveAppScope<
34
+ TModules extends Array<AnyModule> | unknown = unknown,
35
+ TEnv extends AppEnv = AppEnv,
36
+ >(options?: {
37
+ configure?: AppMockConfigureFn<TModules, TEnv>;
38
+ env?: TEnv;
39
+ fusion?: Fusion;
40
+ }): Promise<AppScope<TModules>> {
41
+ const { configure, env, fusion: providedFusion } = options ?? {};
42
+ const resolvedEnv = env ?? (defaultAppEnv as TEnv);
43
+ const framework = await resolveFusion(resolvedEnv, providedFusion);
44
+ const app = await mockAppModules(configure, resolvedEnv, framework);
45
+ return { framework, app };
46
+ }