Skip to content
Merged
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
34 changes: 33 additions & 1 deletion .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { dirname, join } from 'path';
import type { StorybookConfig } from '@storybook/react-webpack5';
import { mergeWithCustomize } from 'webpack-merge';
import { RuleSetRule } from 'webpack';
import { NormalModuleReplacementPlugin, RuleSetRule } from 'webpack';

const config: StorybookConfig = {
stories: [
Expand Down Expand Up @@ -165,6 +165,38 @@ const config: StorybookConfig = {
};
}

// Renderer app components (startup-wizard shell/steps, dialogs, overlays) import
// `useLocalizedStrings` from the `@renderer/hooks/papi-hooks` barrel. The real hook opens a PAPI
// WebSocket that has no backend in Storybook, rejecting unhandled after ~10s ("Timeout reached
// when waiting for websocket connected to settle") and crashing every startup-wizard story. The
// stub re-exports the real hooks and overrides only `useLocalizedStrings` to resolve strings
// without a connection.
//
// This MUST use NormalModuleReplacementPlugin, not `resolve.alias`: the base renderer webpack
// config resolves `@renderer/*` via `TsconfigPathsPlugin` (webpack.config.base.ts), which wins
// over `resolve.alias`, so an alias entry is silently ignored (that is why the `@papi/*` aliases
// above work — `@papi` is not a tsconfig path — but a `@renderer/...` alias would not). The
// replacement rewrites the request in `beforeResolve`, before TsconfigPaths runs. The `$`-anchored
// regex matches only the exact barrel, so deep-path `@renderer/hooks/papi-hooks/*` imports (used
// by the stub itself to re-export the real hooks) still resolve normally.
webpackConfig.plugins = webpackConfig.plugins ?? [];
webpackConfig.plugins.push(
new NormalModuleReplacementPlugin(
/^@renderer\/hooks\/papi-hooks$/,
join(__dirname, 'papi-stubs/renderer-papi-hooks.ts'),
),
// Stop `networkService.initialize()` from constructing a real renderer RpcClient, which tries
// to open a PAPI WebSocket that has no backend in Storybook and rejects unhandled after ~10s
// ("Timeout reached when waiting for websocket connected to settle"), crashing renderer
// stories via the dev overlay. The inert handler makes initialize() succeed with no socket, so
// no connection is attempted. Same reasoning as above re: replacement plugin vs `resolve.alias`
// (`@shared/*` is a TsconfigPathsPlugin path).
new NormalModuleReplacementPlugin(
/^@shared\/services\/rpc-handler\.factory$/,
join(__dirname, 'papi-stubs/rpc-handler.factory.ts'),
),
);

// Remove the Storybook Webpack rules that we already have our own rules for
return mergeWithCustomize({
customizeObject(wpConfig: object, rConfig: object, key: string) {
Expand Down
53 changes: 53 additions & 0 deletions .storybook/papi-stubs/renderer-papi-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Storybook stub for the renderer PAPI-hooks barrel (`@renderer/hooks/papi-hooks`).
*
* Renderer app components (e.g. the first-run / startup-wizard shell and steps) import
* `useLocalizedStrings` from this barrel. The real hook subscribes to the localization data
* provider, which calls `networkService.initialize()` → opens a PAPI WebSocket. There is no PAPI
* backend in Storybook, so that connection never settles and rejects unhandled after ~10 s with
* "Timeout reached when waiting for websocket connected to settle", surfacing as the webpack error
* overlay on every startup-wizard story.
*
* This stub is aliased in over the barrel (see `.storybook/main.ts`). It re-exports every real hook
* unchanged and overrides ONLY `useLocalizedStrings` to resolve strings synchronously from the
* Storybook localization helper (real English text, no network). Other hooks stay real: they are
* inert unless actually called, so aliasing the barrel does not change any story that does not use
* them. This mirrors the `useLocalizedStrings` override already in `papi-stubs/frontend-react.ts`
* for the `@papi/frontend/react` web-view barrel.
*/

import type { LocalizeKey } from 'platform-bible-utils';
import type { DataProviderSubscriberOptions } from '@shared/models/data-provider.model';
import type { LocalizationData } from '@shared/services/localization.service-model';
import { getLocalizedStrings } from '../localization.utils';

// Re-export every real hook except useLocalizedStrings (overridden below). These remain the genuine
// implementations; they only touch the network when invoked, which startup-wizard stories do not.
export { default as useDataProvider } from '@renderer/hooks/papi-hooks/use-data-provider.hook';
export { default as useData } from '@renderer/hooks/papi-hooks/use-data.hook';
export { default as useScrollGroupScrRef } from '@renderer/hooks/papi-hooks/use-scroll-group-scr-ref.hook';
export { default as useSetting } from '@renderer/hooks/papi-hooks/use-setting.hook';
export { default as useProjectData } from '@renderer/hooks/papi-hooks/use-project-data.hook';
export { default as useProjectDataProvider } from '@renderer/hooks/papi-hooks/use-project-data-provider.hook';
export { default as useProjectSetting } from '@renderer/hooks/papi-hooks/use-project-setting.hook';
export { default as useDialogCallback } from '@renderer/hooks/papi-hooks/use-dialog-callback.hook';
export { default as useDataProviderMulti } from '@renderer/hooks/papi-hooks/use-data-provider-multi.hook';
export { default as useWebViewController } from '@renderer/hooks/papi-hooks/use-web-view-controller.hook';
export { default as useRecentScriptureRefs } from '@renderer/hooks/papi-hooks/use-recent-scripture-refs.hook';

/**
* Storybook override of the real `useLocalizedStrings`. Resolves the requested keys to English
* strings synchronously (never loading, never networked). Signature matches the real hook so
* consumers destructure `[localizedStrings, isLoading]` exactly as they do at runtime.
*/
export function useLocalizedStrings(
localizationKeys: LocalizeKey[],
// Unused in this stub; required to match the real hook's signature for drop-in compatibility.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
localizationLocales?: string[],
// Unused in this stub; required to match the real hook's signature for drop-in compatibility.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
subscriberOptions?: DataProviderSubscriberOptions,
): [localizedStrings: LocalizationData, isLoading: boolean] {
return [getLocalizedStrings(localizationKeys ?? []), false];
}
42 changes: 42 additions & 0 deletions .storybook/papi-stubs/rpc-handler.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Storybook stub for `@shared/services/rpc-handler.factory`.
*
* In the app, `networkService.initialize()` calls `createRpcHandler()`, which (in the renderer)
* constructs an `RpcClient`. That client immediately arms a 10s `AsyncVariable('websocket
* connected')` and tries to open `ws://localhost:<port>`. Storybook has no PAPI backend, so the
* socket never opens and the AsyncVariable rejects unhandled with "Timeout reached when waiting for
* websocket connected to settle" — which the webpack/react-refresh dev overlay surfaces as a crash
* on any story whose component tree touches the network service (the whole startup-wizard set, plus
* dialogs/overlays).
*
* This inert handler makes `initialize()` succeed instantly with no socket and no timer, so no
* connection is ever attempted and nothing rejects. Requests resolve to a JSON-RPC error (there is
* no backend to answer them); event registration/emission are no-ops. Data hooks that reach the
* network therefore render their loading/offline state instead of hanging — which is the correct
* Storybook behavior. Wired via NormalModuleReplacementPlugin in `.storybook/main.ts` (not
* `resolve.alias`, which `TsconfigPathsPlugin` overrides for `@shared/*`).
*/

import { ConnectionStatus } from '@shared/data/rpc.model';
import type { IRpcMethodRegistrar } from '@shared/models/rpc.interface';
import type { SerializedRequestType } from '@shared/utils/util';
import type { JSONRPCResponse } from 'json-rpc-2.0';

export const createRpcHandler = async (): Promise<IRpcMethodRegistrar> => ({
connectionStatus: ConnectionStatus.Connected,
connect: async () => true,
disconnect: async () => {},
request: async (requestType: SerializedRequestType): Promise<JSONRPCResponse> => ({
jsonrpc: '2.0',
// json-rpc requires an id; 0 is fine for these unanswerable Storybook requests.
id: 0,
error: { code: -32601, message: `Storybook: no PAPI backend to handle "${requestType}"` },
}),
emitEventOnNetwork: () => {},
registerMethod: async () => true,
unregisterMethod: async () => true,
registerEvent: async () => true,
unregisterEvent: async () => true,
});

export default createRpcHandler;
12 changes: 11 additions & 1 deletion src/renderer/components/notification-display.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { toast } from 'sonner';
import '@testing-library/jest-dom';
import type { CommandHandlers } from 'papi-shared-types';
import type { ThemeDefinitionExpanded } from 'platform-bible-utils';
Expand Down Expand Up @@ -95,6 +96,15 @@ describe('NotificationDisplay with real Sonner', () => {
await startNotificationService();
});

afterEach(() => {
// Sonner auto-dismiss timers fire after jsdom tears down if toasts are still alive,
// causing a "window is not defined" unhandled error. Dismissing all toasts here
// triggers Sonner's internal clearTimeout calls before the environment is torn down.
act(() => {
toast.dismiss();
});
});

it('sends the secondary command when the user clicks the rendered cancel-slot button', async () => {
render(<NotificationDisplay />);

Expand Down
Loading