Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/module-context_fix-empty-context-warning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@equinor/fusion-framework-module-context": patch
---

Fix `ContextModule.postInitialize` logging a `console.warn` for the valid "no initial context" case (no context in the path and no parent context). The default `resolveInitialContext` resolver used RxJS `first()` without a default value, so completing with no emissions threw an `EmptyError` that got logged as if resolution had actually failed. `first()` now falls back to `undefined`, so a genuinely empty result completes silently and only real resolution failures are logged.
14 changes: 12 additions & 2 deletions .changeset/react-app_render-app-component-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
"@equinor/fusion-framework-react-app": minor
---

Add `renderAppComponent` to the `/testing` entry-point, a component-level counterpart to `renderAppHook` for testing components (not just hooks) against a real, mock-backed application module scope.
Add `renderAppComponent` to the `/vitest` entry-point, a component-level counterpart to `renderAppHook` for testing components (not just hooks) against a real, mock-backed application module scope.

```tsx
import { renderAppComponent } from '@equinor/fusion-framework-react-app/testing';
import { renderAppComponent } from '@equinor/fusion-framework-react-app/vitest';
import { waitFor } from '@testing-library/react';
import { Apploader } from '../apploader/Apploader';

Expand All @@ -14,3 +14,13 @@ await waitFor(() => expect(container.textContent).toContain('mounted'));
```

`renderAppComponent` wraps `@testing-library/react`'s `render` with the same `FrameworkProvider` + `ModuleProvider` nesting `renderAppHook` uses, backed by `mockFramework` and `mockAppModules` (`@equinor/fusion-framework-app/mock`), so tests can render a real component tree without hand-wiring those mocks.

The result also carries a nested `app` object (`app.modules`, `app.fusion`) — nested rather than spread directly onto the result so `@testing-library/react`'s own return shape stays free to evolve without ever colliding with it — so a test can drive a module directly after the initial render and assert the component re-renders, instead of hand-wiring `mockAppModules`/`ModuleProvider` itself to reach the same instance:

```tsx
const { getByText, app } = await renderAppComponent<[ContextModule]>(<App />, {
configure: (configurator) => enableContextMock(configurator, (mock) => mock.setCurrentContext(projectA)),
});
await act(() => app.modules.context.setCurrentContextByIdAsync(projectB.id));
await waitFor(() => expect(getByText(/project-b/)).toBeInTheDocument());
Comment on lines 16 to +25
```
6 changes: 4 additions & 2 deletions .changeset/react-app_render-app-hook-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
"@equinor/fusion-framework-react-app": minor
---

Add a `/testing` entry-point with `renderAppHook`, a pre-wrapped `renderHook` for testing app-scoped hooks (`useAppModule`, `useAccessToken`, etc.) against a real, mock-backed module and framework instance.
Add a `/vitest` entry-point with `renderAppHook`, a pre-wrapped `renderHook` for testing app-scoped hooks (`useAppModule`, `useAccessToken`, etc.) against a real, mock-backed module and framework instance.

```tsx
import { renderAppHook } from '@equinor/fusion-framework-react-app/testing';
import { renderAppHook } from '@equinor/fusion-framework-react-app/vitest';
import { waitFor } from '@testing-library/react';

const { result } = await renderAppHook(() => useAccessToken({ scopes: ['User.Read'] }));
Expand All @@ -14,4 +14,6 @@ await waitFor(() => expect(result.current.pending).toBe(false));

`renderAppHook` wraps the hook with the same `FrameworkProvider` + `ModuleProvider` nesting `renderApp` uses in production, backed by `mockFramework` and `mockAppModules` (`@equinor/fusion-framework-app/mock`), so app teams no longer need to hand-wire those mocks in every test.

The result also carries a nested `app` object (`app.modules`, `app.fusion`) — the same instances the hook rendered against, for driving a module the hook itself doesn't return — nested rather than spread directly onto the result so `@testing-library/react`'s own return shape stays free to evolve without ever colliding with it.

Requires `@testing-library/react` (added as an optional peer dependency).
Comment on lines +17 to 19
5 changes: 5 additions & 0 deletions .changeset/react_fix-usemodule-warning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@equinor/fusion-framework-react": patch
---

Fix `useFrameworkModule`'s console warning to name the actually-requested module key instead of always printing `undefined`.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ typings/
# Optional eslint cache
.eslintcache

# Vitest browser mode failure screenshots
__screenshots__/
.vitest-attachments/

# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
Expand Down
1 change: 1 addition & 0 deletions packages/modules/context/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"@equinor/fusion-framework-module-http": "workspace:^",
"@equinor/fusion-framework-module-navigation": "workspace:^",
"@equinor/fusion-framework-module-services": "workspace:^",
"@equinor/fusion-framework-module-telemetry": "workspace:^",
"@faker-js/faker": "^10.1.0",
"rxjs": "^7.8.1",
"typescript": "^7.0.2"
Expand Down
1 change: 0 additions & 1 deletion packages/modules/context/src/ContextProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,6 @@ export class ContextProvider
.pipe(
// resolve context item from queue
switchMap((next) => next),
tap((x) => console.debug('ContextProvider::#contextQueue', x)),
)
.subscribe((context) => {
// set context from resolved context item from queue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,4 +309,15 @@ describe('ContextProvider through the real module system (http mocked at the net

expect(resolved.id).toBe('ctx-3');
});

it('resolves silently, without warning, when there is no initial context to resolve', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

const provider = await initializeContextWith();

expect(provider.currentContext).toBeUndefined();
expect(warnSpy).not.toHaveBeenCalled();
Comment on lines +313 to +319

warnSpy.mockRestore();
});
});
45 changes: 31 additions & 14 deletions packages/modules/context/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import type { Module, ModulesInstance } from '@equinor/fusion-framework-module';
import type { EventModule } from '@equinor/fusion-framework-module-event';
import type { ServicesModule } from '@equinor/fusion-framework-module-services';
import type { NavigationModule } from '@equinor/fusion-framework-module-navigation';
import {
TelemetryLevel,
TelemetryScope,
type TelemetryModule,
} from '@equinor/fusion-framework-module-telemetry';

import {
type IContextModuleConfigurator,
Expand Down Expand Up @@ -34,15 +39,15 @@ export const moduleKey: ContextModuleKey = 'context';
* @typeParam ContextModuleKey - The unique key identifying the context module.
* @typeParam IContextProvider - The provider interface for context-related services.
* @typeParam IContextModuleConfigurator - The configurator interface for customizing the context module.
* @typeParam [ServicesModule, EventModule, NavigationModule] - The tuple of dependent modules required by the context module.
* @typeParam [ServicesModule, EventModule, NavigationModule, TelemetryModule] - The tuple of dependent modules required by the context module.
*
* @see Module
*/
export type ContextModule = Module<
ContextModuleKey,
IContextProvider,
IContextModuleConfigurator,
[ServicesModule, EventModule, NavigationModule]
[ServicesModule, EventModule, NavigationModule, TelemetryModule]
>;

/**
Expand Down Expand Up @@ -76,11 +81,16 @@ export const module: ContextModule = {
// get event module if available
const event = args.hasModule('event') ? await args.requireInstance('event') : undefined;

// get telemetry module if available, for tracking context resolution outcomes
const telemetry = args.hasModule('telemetry')
? await args.requireInstance('telemetry')
: undefined;

// get parent context provider if available
const parentProvider = (args.ref as ModulesInstance<[ContextModule]>)?.context;

// create context provider
const provider = new ContextProvider({ config, event, parentContext: parentProvider });
// create context provider; parent context is wired up later via connectParentContext, not the deprecated ctor arg
const provider = new ContextProvider({ config, event });

// create subscription for disposing the provider
const subscription = new Subscription(() => provider.dispose());
Expand Down Expand Up @@ -109,27 +119,34 @@ export const module: ContextModule = {
resolveInitialContext$
.pipe(
catchError((err) => {
console.warn(
'ContextModule.postInitialize',
'failed to resolve initial context',
err,
);
telemetry?.trackException({
name: 'Context::postInitialize.resolveInitialContext',
exception: err instanceof Error ? err : new Error(String(err)),
level: TelemetryLevel.Warning,
Comment on lines 121 to +125
scope: ['context', TelemetryScope.Framework],
});
// failed to resolve initial context, complete immediately
return EMPTY;
}),
)
.subscribe({
next: (item) => {
console.debug(
'ContextModule.postInitialize',
`initial context was resolved to [${item ? item.id : 'none'}]`,
item,
);
telemetry?.trackEvent({
name: 'Context::postInitialize.initialContextResolved',
level: TelemetryLevel.Debug,
scope: ['context', TelemetryScope.Framework],
properties: { contextId: item ? item.id : 'none' },
});
},
complete: () => {
// connect parent context if available when stream completes
if (config.connectParentContext !== false && parentProvider) {
provider.connectParentContext(parentProvider);
telemetry?.trackEvent({
name: 'Context::postInitialize.parentContextConnected',
level: TelemetryLevel.Debug,
scope: ['context', TelemetryScope.Framework],
});
}
subscriber.complete();
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ export const resolveInitialContext =
return concat(
pathname ? pathResolver(pathname) : EMPTY,
resolveContextFromParent({ ref, modules }),
).pipe(first());
).pipe(
// having no initial context is valid; a default avoids first() throwing EmptyError for it
first(undefined, undefined),
);
};

export default resolveInitialContext;
3 changes: 3 additions & 0 deletions packages/modules/context/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
},
{
"path": "../services"
},
{
"path": "../telemetry"
}
],
"include": ["src/**/*"],
Expand Down
1 change: 0 additions & 1 deletion packages/modules/msal/src/versioning/resolve-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import { version as latestVersionString } from '../version';
* ```
*/
function mapVersionToEnumVersion(version: string | SemVer): MsalModuleVersion {
console.log('Resolving version:', version);
const coercedVersion = semver.coerce(version);
Comment on lines 31 to 32
// An uncoercible version string cannot be mapped to a module version
if (!coercedVersion) {
Expand Down
4 changes: 2 additions & 2 deletions packages/react/app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ production, just with the network boundary faked for requests a seeded middlewar
network.

```tsx
import { renderAppHook } from '@equinor/fusion-framework-react-app/testing';
import { renderAppHook } from '@equinor/fusion-framework-react-app/vitest';
import { waitFor } from '@testing-library/react';
import { useAccessToken } from '@equinor/fusion-framework-react-app/msal';
Comment on lines 177 to 180

Expand Down Expand Up @@ -218,7 +218,7 @@ test('resolves an access token', async () => {
| `/apploader` | `Apploader`, `useApploader` |
| `/framework` | `useFramework`, `useCurrentUser`, `useFrameworkHttpClient` |
| `/widget` | Widget entry-point |
| `/testing` | `renderAppHook`, `renderAppComponent` — pre-wrapped `renderHook`/`render` for testing app-scoped hooks and components |
| `/vitest` | `renderAppHook`, `renderAppComponent` — pre-wrapped `renderHook`/`render` for testing app-scoped hooks and components |

## Configuration

Expand Down
43 changes: 34 additions & 9 deletions packages/react/app/docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ scope using `renderAppHook` and `renderAppComponent`.
**Import:**

```ts
import { renderAppHook, renderAppComponent } from '@equinor/fusion-framework-react-app/testing';
import { renderAppHook, renderAppComponent } from '@equinor/fusion-framework-react-app/vitest';
```

> [!IMPORTANT]
Expand Down Expand Up @@ -52,7 +52,7 @@ function renderAppHook<Result, Props = undefined, TModules = unknown, TEnv exten
env?: TEnv;
fusion?: Fusion;
} & Omit<RenderHookOptions<Props>, 'wrapper'>,
): Promise<RenderHookResult<Result, Props>>;
): Promise<RenderHookResult<Result, Props> & { fusion: { framework: Fusion; app: AppModulesInstance<TModules> } }>;
```

| Option | Description |
Expand All @@ -61,12 +61,14 @@ function renderAppHook<Result, Props = undefined, TModules = unknown, TEnv exten
| `env` | The application environment (manifest); defaults to a generic standalone `test-app` |
| `fusion` | The parent Fusion instance; defaults to a fresh `mockFramework` instance serving this app's own manifest |

Any other `renderHook` option (e.g. `initialProps`) is forwarded as-is.
Any other `renderHook` option (e.g. `initialProps`) is forwarded as-is. The result carries the
usual `renderHook` return values (`result`, `rerender`, `unmount`) plus `modules` and `fusion` —
the same instances the hook rendered against — for driving a module the hook itself doesn't return.
Comment on lines +64 to +66

### Basic Usage

```tsx
import { renderAppHook } from '@equinor/fusion-framework-react-app/testing';
import { renderAppHook } from '@equinor/fusion-framework-react-app/vitest';
import { waitFor } from '@testing-library/react';
import { useAccessToken } from '@equinor/fusion-framework-react-app/msal';

Expand All @@ -82,7 +84,7 @@ test('resolves an access token', async () => {
Pass `configure` to reach the msal mock's builder before the hook renders:

```tsx
import { renderAppHook } from '@equinor/fusion-framework-react-app/testing';
import { renderAppHook } from '@equinor/fusion-framework-react-app/vitest';
import { useCurrentAccount } from '@equinor/fusion-framework-react-app/msal';

test('reads the configured account', async () => {
Expand All @@ -103,7 +105,7 @@ calls:
import { mockFramework } from '@equinor/fusion-framework/mock';
import { enableAppManifestMock } from '@equinor/fusion-framework-app/mock';
import type { AppModule } from '@equinor/fusion-framework-module-app';
import { renderAppHook } from '@equinor/fusion-framework-react-app/testing';
import { renderAppHook } from '@equinor/fusion-framework-react-app/vitest';
import { useAccessToken } from '@equinor/fusion-framework-react-app/msal';
import { useCurrentAccount } from '@equinor/fusion-framework-react-app/msal';

Expand Down Expand Up @@ -135,11 +137,34 @@ function renderAppComponent<TModules = unknown, TEnv extends AppEnv = AppEnv>(
env?: TEnv;
fusion?: Fusion;
} & Omit<RenderOptions, 'wrapper'>,
): Promise<RenderResult>;
): Promise<RenderResult & { fusion: { framework: Fusion; app: AppModulesInstance<TModules> } }>;
```

Options are the same shape as `renderAppHook`'s — `configure`, `env`, `fusion` — plus any
other `@testing-library/react` `render` option.
other `@testing-library/react` `render` option. The result carries the usual `render` return
values (`getByText`, `container`, `unmount`, ...) plus `fusion` — nested rather than spread
directly onto the result, so `@testing-library/react`'s own return shape stays free to evolve
without ever colliding with it. `fusion.app` is the same application module instance the
rendered component reads through `useAppModule`/`useAppModules`, and `fusion.framework` is the
parent Fusion instance. Drive a module directly through `fusion.app` to exercise a state
change after the initial render, without hand-wiring `mockAppModules`/`ModuleProvider`:

```tsx
import { enableContextMock } from '@equinor/fusion-framework-module-context/mock';
import type { ContextModule } from '@equinor/fusion-framework-module-context';
import { act, waitFor } from '@testing-library/react';
import { renderAppComponent } from '@equinor/fusion-framework-react-app/vitest';

test('reacts when the current context switches', async () => {
const { getByText, fusion } = await renderAppComponent<[ContextModule]>(<App />, {
configure: (configurator) =>
enableContextMock(configurator, (mock) => mock.setCurrentContext(projectA)),
});

await act(() => fusion.app.context.setCurrentContextByIdAsync(projectB.id));
await waitFor(() => expect(getByText(/project-b/)).toBeInTheDocument());
});
```

### Example: Asserting Loading and Error States

Expand All @@ -148,7 +173,7 @@ import { waitFor } from '@testing-library/react';
import { mockFramework } from '@equinor/fusion-framework/mock';
import { enableAppManifestMock } from '@equinor/fusion-framework-app/mock';
import type { AppManifest, AppModule } from '@equinor/fusion-framework-module-app';
import { renderAppComponent } from '@equinor/fusion-framework-react-app/testing';
import { renderAppComponent } from '@equinor/fusion-framework-react-app/vitest';
import { Apploader } from '@equinor/fusion-framework-react-app/apploader';

test('mounts the child app once its script loads', async () => {
Expand Down
Loading
Loading