diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml deleted file mode 100644 index e81a64a2c5..0000000000 --- a/.github/workflows/audit.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: NPM Audit - -on: - pull_request: - -jobs: - NPM-Audit: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: "lts/*" - - - name: Run npm audit - run: npm audit --audit-level=high diff --git a/README.md b/README.md index dbd64f87cd..028f18fcab 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ const App = () => { | `profiles` | [`ProfilesTypes`](./typings/connectProps.d.ts) | The connect widget uses the profiles to set the initial state of the widget. [More details](./docs/PROFILES.md) | See more details | | `userFeatures` | [`UserFeaturesType`](./typings/connectProps.d.ts) | The connect widget uses user features to determine the behavior of the widget. [More details](./docs/USER_FEATURES.md) | See more details | | `showTooSmallDialog` | `boolean` | The connect widget can show a warning when the widget size is below the supported 320px. | `true` | +| `webSocketConnection` | `object` | An object containing `isConnected()` function and `webSocketMessages$` observable for real-time updates. | `null` | +| `experimentalFeatures` | `object` | An object to enable or disable experimental features like `useWebSockets: true`. | `null` | ## ApiProvider diff --git a/architectureDecisionRecords/0003AutomatedTestingFrontend.md b/architectureDecisionRecords/0003AutomatedTestingFrontend.md index 42f35724d8..713d76e956 100644 --- a/architectureDecisionRecords/0003AutomatedTestingFrontend.md +++ b/architectureDecisionRecords/0003AutomatedTestingFrontend.md @@ -18,7 +18,7 @@ We will use the following technologies to test: 1. [Vitest](https://vitest.dev/) for unit/integration tests 1. [MSW](https://mswjs.io/docs/getting-started/) for api mocking in unit/integration tests -We will mock as little as possible in our tests and [prefer integration tests over unit tests](https://kentcdodds.com/blog/write-tests). The bulk of our tests will be integration tests, because they provide the best performance to confidence ratio. +We will mock as little as possible in our tests and [prefer integration tests over unit tests](https://kentcdodds.com/blog/write-tests). The bulk of our tests will be integration tests, because they provide the best performance to confidence ratio. We also prefer to render real components rather than using mocks (e.g., `vi.mock`) in integration tests to ensure that context and side-effects are correctly wired. The purpose of our end to end tests will be to validate that the frontend is working with the backend properly, and that apis are working together properly. We don't need to test every edge case in our end to end tests, because using MSW allows us to test those edge cases in our integration tests. diff --git a/package-lock.json b/package-lock.json index 5220338274..c8dc1eb303 100644 --- a/package-lock.json +++ b/package-lock.json @@ -847,6 +847,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -863,6 +864,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -879,6 +881,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -895,6 +898,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -911,6 +915,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -926,6 +931,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -942,6 +948,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -958,6 +965,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -974,6 +982,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -990,6 +999,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1006,6 +1016,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1022,6 +1033,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1038,6 +1050,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1054,6 +1067,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1070,6 +1084,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1086,6 +1101,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1102,6 +1118,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1118,6 +1135,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1134,6 +1152,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1150,6 +1169,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1166,6 +1186,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1182,6 +1203,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1198,6 +1220,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1214,6 +1237,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1230,6 +1254,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1246,6 +1271,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3234,6 +3260,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3247,6 +3274,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3260,6 +3288,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3273,6 +3302,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3286,6 +3316,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3299,6 +3330,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3312,9 +3344,7 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3328,9 +3358,7 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3344,9 +3372,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3360,9 +3386,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3376,9 +3400,7 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3392,9 +3414,7 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3408,9 +3428,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3424,9 +3442,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3440,9 +3456,7 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3456,9 +3470,7 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3472,9 +3484,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3488,9 +3498,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3504,9 +3512,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3520,6 +3526,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3533,6 +3540,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3546,6 +3554,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3559,6 +3568,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3572,6 +3582,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3585,6 +3596,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5536,13 +5548,14 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-plugin-macros": { @@ -8233,6 +8246,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -9653,6 +9667,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, "optional": true, "bin": { "jiti": "lib/jiti-cli.mjs" @@ -13278,7 +13293,7 @@ } }, "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", + "version": "4.0.3", "dev": true, "inBundle": true, "license": "MIT", @@ -14200,9 +14215,13 @@ "dev": true }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/psl": { "version": "1.15.0", @@ -16745,10 +16764,11 @@ "integrity": "sha512-m6EXlCAMetKztO1ppBhGU1/1MR3IiEevO6ESq6rcrSQ3Q77xYSW13jkfXW88o4xMrkXJhy/U7j4wFR/twMB0Eg==" }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "devOptional": true, + "license": "MIT", "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -17319,7 +17339,7 @@ "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/src/ConnectWidget.tsx b/src/ConnectWidget.tsx index 389a40e163..97acf4bf03 100644 --- a/src/ConnectWidget.tsx +++ b/src/ConnectWidget.tsx @@ -9,6 +9,7 @@ import { initGettextLocaleData } from 'src/utilities/Personalization' import { ConnectedTokenProvider } from 'src/ConnectedTokenProvider' import { TooSmallDialog } from 'src/components/app/TooSmallDialog' import { setLocalizedContent } from 'src/redux/reducers/localizedContentSlice' +import { WebSocketProvider } from 'src/context/WebSocketContext' import './sharedVariables.css' interface PostMessageContextType { @@ -27,6 +28,7 @@ export const ConnectWidget = ({ onAnalyticPageview = () => {}, postMessageEventOverrides, showTooSmallDialog = true, + webSocketConnection, ...props }: any) => { initGettextLocaleData(props.language) @@ -38,12 +40,14 @@ export const ConnectWidget = ({ return ( - - - {showTooSmallDialog && } - - - + + + + {showTooSmallDialog && } + + + + ) diff --git a/src/__tests__/ConnectWidget-test.tsx b/src/__tests__/ConnectWidget-test.tsx new file mode 100644 index 0000000000..6bb41a5ad5 --- /dev/null +++ b/src/__tests__/ConnectWidget-test.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { Subject } from 'rxjs' +import { act } from '@testing-library/react' + +import { ConnectWidget } from '../ConnectWidget' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' +import { member, JOB_DATA } from 'src/services/mockedData' +import { ReadableStatuses } from 'src/const/Statuses' + +// Mock react-confetti to avoid Canvas issues in JSDOM +vi.mock('react-confetti', () => ({ + default: () =>
, +})) + +describe('ConnectWidget', () => { + const defaultProps = { + clientConfig: {}, + profiles: {}, + userFeatures: {}, + language: { locale: 'en', localizedContent: {} }, + } + + it('renders the real Connect widget and handles WebSocket messages correctly', async () => { + const webSocketMessages$ = new Subject() + const mockWS = { + isConnected: vi.fn().mockReturnValue(true), + webSocketMessages$, + } + + const aggregatingMember = { + ...member.member, + is_being_aggregated: true, + connection_status: ReadableStatuses.CREATED, + } + + const mockApiValue = { + ...apiValueMock, + loadMemberByGuid: vi.fn().mockResolvedValue(aggregatingMember), + loadJob: vi.fn().mockResolvedValue(JOB_DATA), + } + + const onSuccessfulAggregation = vi.fn() + const clientConfig = { mode: 'aggregation', current_member_guid: 'MBR-123' } + + render( + , + { apiValue: mockApiValue }, + ) + + // The widget should enter the Connecting state + expect(await screen.findByText(/Connecting to/i)).toBeInTheDocument() + + // Send a WebSocket message indicating the member finished aggregating successfully + const successMember = { + ...aggregatingMember, + is_being_aggregated: false, + connection_status: ReadableStatuses.CONNECTED, + } + + act(() => { + webSocketMessages$.next({ + event: 'members/updated', + payload: successMember, + }) + }) + + // The widget should receive the update and trigger the success callback (by mounting Connected view) + await waitFor(() => { + expect(onSuccessfulAggregation).toHaveBeenCalledWith( + expect.objectContaining({ guid: 'MBR-123' }), + ) + }) + }) +}) diff --git a/src/context/WebSocketContext.tsx b/src/context/WebSocketContext.tsx new file mode 100644 index 0000000000..04f24ec9f3 --- /dev/null +++ b/src/context/WebSocketContext.tsx @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React, { createContext, useContext } from 'react' +import { Observable } from 'rxjs' + +export interface WebSocketConnection { + isConnected: () => boolean + webSocketMessages$: Observable +} + +const WebSocketContext = createContext(undefined) + +export const WebSocketProvider: React.FC<{ + value?: WebSocketConnection + children: React.ReactNode +}> = ({ value, children }) => ( + {children} +) + +export const useWebSocket = () => useContext(WebSocketContext) diff --git a/src/hooks/__tests__/usePollMember-test.tsx b/src/hooks/__tests__/usePollMember-test.tsx index 41869a1fda..024f97ad0d 100644 --- a/src/hooks/__tests__/usePollMember-test.tsx +++ b/src/hooks/__tests__/usePollMember-test.tsx @@ -1,21 +1,29 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import React from 'react' import { renderHook, waitFor } from '@testing-library/react' import { vi } from 'vitest' import { usePollMember, PollingState } from 'src/hooks/usePollMember' import { ApiProvider, ApiContextTypes } from 'src/context/ApiContext' +import { WebSocketProvider, WebSocketConnection } from 'src/context/WebSocketContext' import { Provider } from 'react-redux' import { createReduxStore, RootState } from 'src/redux/Store' import { member, JOB_DATA } from 'src/services/mockedData' import { ReadableStatuses } from 'src/const/Statuses' import { CONNECTING_MESSAGES } from 'src/utilities/pollers' import { take } from 'rxjs/operators' +import { Subject } from 'rxjs' -const createWrapper = (apiValue: Partial, preloadedState?: Partial) => { +const createWrapper = ( + apiValue: Partial, + preloadedState?: Partial, + webSocketValue?: WebSocketConnection, +) => { const store = createReduxStore(preloadedState) const Wrapper = ({ children }: { children: React.ReactNode }) => ( - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - {children} + + {children} + ) Wrapper.displayName = 'TestWrapper' @@ -27,6 +35,10 @@ describe('usePollMember', () => { document.documentElement.setAttribute('lang', 'en') }) + afterEach(() => { + vi.restoreAllMocks() + }) + it('should return a pollMember function', () => { const apiValue = { loadMemberByGuid: vi.fn().mockResolvedValue(member.member), @@ -303,9 +315,12 @@ describe('usePollMember', () => { }) it('should increment pollingCount on each poll', async () => { + const member1 = { ...member.member, guid: 'MBR-1', most_recent_job_guid: 'JOB-1' } + const member2 = { ...member.member, guid: 'MBR-2', most_recent_job_guid: 'JOB-2' } + const apiValue = { - loadMemberByGuid: vi.fn().mockResolvedValue(member.member), - loadJob: vi.fn().mockResolvedValue(JOB_DATA), + loadMemberByGuid: vi.fn().mockResolvedValueOnce(member1).mockResolvedValue(member2), + loadJob: vi.fn().mockImplementation((guid) => Promise.resolve({ ...JOB_DATA, guid })), } const preloadedState = { @@ -446,15 +461,23 @@ describe('usePollMember', () => { async_account_data_ready: true, } - const memberWithJob = { + const member1 = { ...member.member, + guid: 'MBR-1', + most_recent_job_guid: 'JOB-1', is_being_aggregated: false, connection_status: ReadableStatuses.CONNECTED, } + const member2 = { ...member1, guid: 'MBR-2', most_recent_job_guid: 'JOB-2' } + const member3 = { ...member1, guid: 'MBR-3', most_recent_job_guid: 'JOB-3' } const apiValue = { - loadMemberByGuid: vi.fn().mockResolvedValue(memberWithJob), - loadJob: vi.fn().mockResolvedValue(jobWithAsyncData), + loadMemberByGuid: vi + .fn() + .mockResolvedValueOnce(member1) + .mockResolvedValueOnce(member2) + .mockResolvedValue(member3), + loadJob: vi.fn().mockImplementation((guid) => Promise.resolve({ ...jobWithAsyncData, guid })), } const preloadedState = { @@ -622,12 +645,12 @@ describe('usePollMember', () => { }, 10000) it('should correctly update previousResponse and currentResponse over multiple polls', async () => { - const member1 = { ...member.member, guid: 'MBR-1' } - const member2 = { ...member.member, guid: 'MBR-2' } + const member1 = { ...member.member, guid: 'MBR-1', most_recent_job_guid: 'JOB-1' } + const member2 = { ...member.member, guid: 'MBR-2', most_recent_job_guid: 'JOB-2' } const apiValue = { loadMemberByGuid: vi.fn().mockResolvedValueOnce(member1).mockResolvedValue(member2), - loadJob: vi.fn().mockResolvedValue(JOB_DATA), + loadJob: vi.fn().mockImplementation((guid) => Promise.resolve({ ...JOB_DATA, guid })), } const preloadedState = { @@ -658,25 +681,34 @@ describe('usePollMember', () => { // First poll expect(states[0].previousResponse).toEqual({}) - expect(states[0].currentResponse).toEqual({ member: member1, job: JOB_DATA }) + expect(states[0].currentResponse).toEqual({ + member: member1, + job: { ...JOB_DATA, guid: 'JOB-1' }, + }) // Second poll - expect(states[1].previousResponse).toEqual({ member: member1, job: JOB_DATA }) - expect(states[1].currentResponse).toEqual({ member: member2, job: JOB_DATA }) + expect(states[1].previousResponse).toEqual({ + member: member1, + job: { ...JOB_DATA, guid: 'JOB-1' }, + }) + expect(states[1].currentResponse).toEqual({ + member: member2, + job: { ...JOB_DATA, guid: 'JOB-2' }, + }) subscription.unsubscribe() }, 10000) it('should preserve previousResponse and currentResponse when an intermediate poll fails', async () => { - const member1 = { ...member.member, guid: 'MBR-1' } + const member1 = { ...member.member, guid: 'MBR-1', most_recent_job_guid: 'JOB-1' } const apiValue = { loadMemberByGuid: vi .fn() .mockResolvedValueOnce(member1) .mockRejectedValueOnce(new Error('Intermediate Error')) - .mockResolvedValue(member1), - loadJob: vi.fn().mockResolvedValue(JOB_DATA), + .mockResolvedValue({ ...member1, guid: 'MBR-1-new', most_recent_job_guid: 'JOB-1-new' }), + loadJob: vi.fn().mockImplementation((guid) => Promise.resolve({ ...JOB_DATA, guid })), } const preloadedState = { @@ -707,18 +739,136 @@ describe('usePollMember', () => { // First poll: Success expect(states[0].isError).toBe(false) - expect(states[0].currentResponse).toEqual({ member: member1, job: JOB_DATA }) + expect(states[0].currentResponse).toEqual({ + member: member1, + job: { ...JOB_DATA, guid: 'JOB-1' }, + }) // Second poll: Error expect(states[1].isError).toBe(true) expect(states[1].previousResponse).toEqual({}) // Should be preserved from acc - expect(states[1].currentResponse).toEqual({ member: member1, job: JOB_DATA }) // Should be preserved from acc + expect(states[1].currentResponse).toEqual({ + member: member1, + job: { ...JOB_DATA, guid: 'JOB-1' }, + }) // Should be preserved from acc // Third poll: Success again expect(states[2].isError).toBe(false) - expect(states[2].previousResponse).toEqual({ member: member1, job: JOB_DATA }) // acc.currentResponse was preserved - expect(states[2].currentResponse).toEqual({ member: member1, job: JOB_DATA }) + expect(states[2].previousResponse).toEqual({ + member: member1, + job: { ...JOB_DATA, guid: 'JOB-1' }, + }) // acc.currentResponse was preserved + expect(states[2].currentResponse).toEqual({ + member: { ...member1, guid: 'MBR-1-new', most_recent_job_guid: 'JOB-1-new' }, + job: { ...JOB_DATA, guid: 'JOB-1-new' }, + }) subscription.unsubscribe() }, 10000) + + it('should receive updates from WebSockets when enabled', async () => { + const wsMessages$ = new Subject() + const mockWS = { + isConnected: vi.fn().mockReturnValue(true), + webSocketMessages$: wsMessages$.asObservable(), + } + + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(member.member), + loadJob: vi.fn().mockResolvedValue(JOB_DATA), + } + + const preloadedState = { + experimentalFeatures: { + useWebSockets: true, + memberPollingMilliseconds: 10000, // Long interval to avoid poll interference + }, + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue, preloadedState, mockWS), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123').subscribe((state: PollingState) => { + states.push(state) + }) + + // Emit from WebSocket + const wsMember = { guid: 'MBR-123', connection_status: 1 } + wsMessages$.next({ event: 'members/updated', payload: wsMember }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThan(0) + }, + { timeout: 4000 }, + ) + + expect(states[0].currentResponse?.member).toEqual(wsMember) + + // Emit priority data ready + wsMessages$.next({ event: 'members/priority_data_ready', payload: wsMember }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThan(1) + }, + { timeout: 4000 }, + ) + + expect(states[1].initialDataReady).toBe(true) + + subscription.unsubscribe() + }) + + it('should handle members/priority_data_ready even if payload is minimal', async () => { + const wsMessages$ = new Subject() + const mockWS = { + isConnected: vi.fn().mockReturnValue(true), + webSocketMessages$: wsMessages$.asObservable(), + } + + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(member.member), + loadJob: vi.fn().mockResolvedValue(JOB_DATA), + } + + const preloadedState = { + experimentalFeatures: { + useWebSockets: true, + memberPollingMilliseconds: 10000, + }, + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue, preloadedState, mockWS), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123').subscribe((state: PollingState) => { + states.push(state) + }) + + // 1. Emit full member data + const fullMember = { guid: 'MBR-123', connection_status: 1, most_recent_job_guid: 'JOB-123' } + wsMessages$.next({ event: 'members/updated', payload: fullMember }) + + await waitFor(() => expect(states.length).toBe(1)) + + // 2. Emit priority_data_ready with minimal payload + wsMessages$.next({ event: 'members/priority_data_ready', payload: { guid: 'MBR-123' } }) + + await waitFor(() => expect(states.length).toBe(2)) + + expect(states[1].initialDataReady).toBe(true) + // Verify it used the member data from previous message + expect(states[1].currentResponse?.member).toEqual(fullMember) + + subscription.unsubscribe() + }) }) diff --git a/src/hooks/usePollMember.tsx b/src/hooks/usePollMember.tsx index 9c571569da..d889905eba 100644 --- a/src/hooks/usePollMember.tsx +++ b/src/hooks/usePollMember.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { DEFAULT_POLLING_STATE, handlePollingResponse } from 'src/utilities/pollers' import { useApi } from 'src/context/ApiContext' +import { useWebSocket } from 'src/context/WebSocketContext' import { useSelector } from 'react-redux' import { getExperimentalFeatures } from 'src/redux/reducers/experimentalFeaturesSlice' @@ -22,12 +23,13 @@ export interface PollingState { export function usePollMember() { const { api } = useApi() + const webSocket = useWebSocket() const clientLocale = useMemo(() => { return document.querySelector('html')?.getAttribute('lang') || 'en' }, [document.querySelector('html')?.getAttribute('lang')]) - const { optOutOfEarlyUserRelease, memberPollingMilliseconds } = + const { optOutOfEarlyUserRelease, memberPollingMilliseconds, useWebSockets } = useSelector(getExperimentalFeatures) const pollingInterval = memberPollingMilliseconds || 3000 @@ -46,7 +48,9 @@ export function usePollMember() { { pollingInterval, clientLocale, + useWebSockets, }, + webSocket, ) return updateStream$.pipe( @@ -72,7 +76,6 @@ export function usePollMember() { if ( !isError && !acc.initialDataReady && - // @ts-expect-error response might be undefined or an error response?.job?.async_account_data_ready && !optOutOfEarlyUserRelease ) { diff --git a/src/redux/reducers/__tests__/experimentalFeaturesSlice-test.ts b/src/redux/reducers/__tests__/experimentalFeaturesSlice-test.ts new file mode 100644 index 0000000000..a5eb020f39 --- /dev/null +++ b/src/redux/reducers/__tests__/experimentalFeaturesSlice-test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import reducer, { initialState, loadExperimentalFeatures } from '../experimentalFeaturesSlice' + +describe('experimentalFeaturesSlice', () => { + it('should return the initial state', () => { + expect(reducer(undefined, { type: 'unknown' })).toEqual(initialState) + }) + + it('should handle loadExperimentalFeatures', () => { + const payload = { + unavailableInstitutions: [{ guid: '123', name: 'Test' }], + optOutOfEarlyUserRelease: true, + memberPollingMilliseconds: 5000, + useWebSockets: true, + } + const nextState = reducer(initialState, loadExperimentalFeatures(payload)) + expect(nextState.unavailableInstitutions).toEqual(payload.unavailableInstitutions) + expect(nextState.optOutOfEarlyUserRelease).toBe(true) + expect(nextState.memberPollingMilliseconds).toBe(5000) + expect(nextState.useWebSockets).toBe(true) + }) + + it('should default useWebSockets to false if not provided', () => { + const payload = {} + const nextState = reducer(initialState, loadExperimentalFeatures(payload)) + expect(nextState.useWebSockets).toBe(false) + }) +}) diff --git a/src/redux/reducers/experimentalFeaturesSlice.ts b/src/redux/reducers/experimentalFeaturesSlice.ts index 5b957ff583..a1c9a063e4 100644 --- a/src/redux/reducers/experimentalFeaturesSlice.ts +++ b/src/redux/reducers/experimentalFeaturesSlice.ts @@ -5,12 +5,14 @@ type ExperimentalFeaturesSlice = { optOutOfEarlyUserRelease?: boolean unavailableInstitutions?: { guid: string; name: string }[] memberPollingMilliseconds?: number + useWebSockets?: boolean } export const initialState: ExperimentalFeaturesSlice = { optOutOfEarlyUserRelease: false, unavailableInstitutions: [], memberPollingMilliseconds: undefined, + useWebSockets: false, } const experimentalFeaturesSlice = createSlice({ @@ -21,6 +23,7 @@ const experimentalFeaturesSlice = createSlice({ state.unavailableInstitutions = action.payload?.unavailableInstitutions || [] state.optOutOfEarlyUserRelease = action.payload?.optOutOfEarlyUserRelease || false state.memberPollingMilliseconds = action.payload?.memberPollingMilliseconds || undefined + state.useWebSockets = action.payload?.useWebSockets || false }, }, }) diff --git a/src/utilities/transport/MemberUpdateTransport.ts b/src/utilities/transport/MemberUpdateTransport.ts index e81d7fc63b..c4f492ef36 100644 --- a/src/utilities/transport/MemberUpdateTransport.ts +++ b/src/utilities/transport/MemberUpdateTransport.ts @@ -1,6 +1,16 @@ -import { Observable, defer, interval, of } from 'rxjs' -import { catchError, map, mergeMap, exhaustMap } from 'rxjs/operators' +import { Observable, defer, interval, of, merge } from 'rxjs' +import { + catchError, + map, + mergeMap, + exhaustMap, + filter, + distinctUntilChanged, + scan, +} from 'rxjs/operators' +import _isEqual from 'lodash/isEqual' import type { ApiContextTypes } from 'src/context/ApiContext' +import { WebSocketConnection } from 'src/context/WebSocketContext' type MemberUpdateApi = Required> @@ -12,17 +22,20 @@ export interface MemberUpdate { export interface MemberUpdateTransportOptions { pollingInterval?: number clientLocale?: string + useWebSockets?: boolean } export function createMemberUpdateTransport( api: MemberUpdateApi, memberGuid: string, options: MemberUpdateTransportOptions = {}, + webSocket?: WebSocketConnection, ): Observable { const pollingInterval = options.pollingInterval || 3000 const clientLocale = options.clientLocale || 'en' + const useWebSockets = options.useWebSockets || false - return interval(pollingInterval).pipe( + const polling$ = interval(pollingInterval).pipe( exhaustMap(() => defer(() => api.loadMemberByGuid(memberGuid, clientLocale)).pipe( mergeMap((member: MemberResponseType) => @@ -34,4 +47,62 @@ export function createMemberUpdateTransport( ), ), ) + + let transport$: Observable = polling$ + + if (useWebSockets && webSocket?.webSocketMessages$ && webSocket?.isConnected?.()) { + const socket$ = webSocket.webSocketMessages$.pipe( + filter( + (msg) => + (msg.event === 'members/updated' || msg.event === 'members/priority_data_ready') && + msg.payload?.guid === memberGuid, + ), + scan((previousUpdate: MemberUpdate, msg) => { + // The priority_data_ready event does not send full member data, + // so we need to use the previous member data and adjust async_account_data_ready + const isMembersUpdated = msg.event === 'members/updated' + const member = isMembersUpdated ? msg.payload : previousUpdate?.member + + const job = { + guid: member?.most_recent_job_guid, + async_account_data_ready: + // The priority_data_ready event fires once, keep the flag true once it is received + previousUpdate.job?.async_account_data_ready || + msg.event === 'members/priority_data_ready', + } as JobResponseType + + return { member, job } + }, {} as MemberUpdate), + // If we don't have member data yet, wait for the next members/updated event to populate it. + filter((update) => !!update.member), + + // If the websocket errors out, we don't want to kill the polling stream. + // We just want to stop receiving messages from the socket and let polling continue. + catchError(() => of()), + ) + transport$ = merge(polling$, socket$) + } + + return transport$.pipe( + distinctUntilChanged((prev, curr) => { + // Don't deduplicate errors + if (prev instanceof Error || curr instanceof Error) return false + + const prevMember = prev.member + const currMember = curr.member + + // Compare the relevant fields to determine if we should emit an update + // Return true to *prevent* emitting the event + // Return false to emit the event + return ( + prevMember?.connection_status === currMember?.connection_status && + _isEqual(prevMember?.mfa, currMember?.mfa) && + prev.job?.guid === curr.job?.guid && + prev.job?.async_account_data_ready === curr.job?.async_account_data_ready && + prevMember?.is_being_aggregated === currMember?.is_being_aggregated && + prevMember?.most_recent_job_detail_code === currMember?.most_recent_job_detail_code && + prevMember?.error?.error_code === currMember?.error?.error_code + ) + }), + ) } diff --git a/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts b/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts index 32e9e59584..e65405710f 100644 --- a/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts +++ b/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts @@ -1,6 +1,11 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { vi } from 'vitest' import { take } from 'rxjs/operators' -import { createMemberUpdateTransport, MemberUpdate } from '../MemberUpdateTransport' +import { Subject } from 'rxjs' +import { + createMemberUpdateTransport, + MemberUpdate, +} from 'src/utilities/transport/MemberUpdateTransport' describe('MemberUpdateTransport', () => { const mockMemberGuid = 'MBR-123' @@ -47,7 +52,7 @@ describe('MemberUpdateTransport', () => { subscription.unsubscribe() }) - it('should continue emitting updates on each interval', async () => { + it('should continue emitting updates on each interval when data changes', async () => { const transport$ = createMemberUpdateTransport(mockApi, mockMemberGuid, { pollingInterval: 1000, clientLocale: mockClientLocale, @@ -59,12 +64,22 @@ describe('MemberUpdateTransport', () => { results.push(val) }) - // Fast-forward 3 intervals - await vi.advanceTimersByTimeAsync(3000) + // Fast-forward 1 interval + await vi.advanceTimersByTimeAsync(1000) + expect(results).toHaveLength(1) + + // Change the mock to return a different status + mockApi.loadMemberByGuid.mockResolvedValue({ ...mockMember, connection_status: 1 }) + await vi.advanceTimersByTimeAsync(1000) + expect(results).toHaveLength(2) + + // Change it back + mockApi.loadMemberByGuid.mockResolvedValue(mockMember) + await vi.advanceTimersByTimeAsync(1000) + expect(results).toHaveLength(3) expect(mockApi.loadMemberByGuid).toHaveBeenCalledTimes(3) expect(mockApi.loadJob).toHaveBeenCalledTimes(3) - expect(results).toHaveLength(3) subscription.unsubscribe() }) @@ -134,4 +149,197 @@ describe('MemberUpdateTransport', () => { subscription.unsubscribe() }) + + it('should emit WebSocket updates immediately when enabled', async () => { + const wsMessages$ = new Subject() + const mockWS = { + isConnected: vi.fn().mockReturnValue(true), + webSocketMessages$: wsMessages$.asObservable(), + } + + const transport$ = createMemberUpdateTransport( + mockApi, + mockMemberGuid, + { useWebSockets: true }, + mockWS, + ) + + const results: (MemberUpdate | Error)[] = [] + const subscription = transport$.subscribe((val) => { + results.push(val) + }) + + const wsMember = { guid: mockMemberGuid, connection_status: 1, most_recent_job_guid: 'JOB-123' } + wsMessages$.next({ event: 'members/updated', payload: wsMember }) + + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + member: wsMember, + job: { async_account_data_ready: false, guid: 'JOB-123' }, + }) + + subscription.unsubscribe() + }) + + it('should signal async_account_data_ready when members/priority_data_ready is received after member data is available', async () => { + const wsMessages$ = new Subject() + const mockWS = { + isConnected: vi.fn().mockReturnValue(true), + webSocketMessages$: wsMessages$.asObservable(), + } + + const transport$ = createMemberUpdateTransport( + mockApi, + mockMemberGuid, + { useWebSockets: true }, + mockWS, + ) + + const results: (MemberUpdate | Error)[] = [] + const subscription = transport$.subscribe((val) => { + results.push(val) + }) + + const wsMember = { guid: mockMemberGuid, connection_status: 1, most_recent_job_guid: 'JOB-123' } + + // 1. Send member update first + wsMessages$.next({ event: 'members/updated', payload: wsMember }) + expect(results).toHaveLength(1) + + // 2. Send priority data ready event (payload might be minimal) + wsMessages$.next({ event: 'members/priority_data_ready', payload: { guid: mockMemberGuid } }) + + expect(results).toHaveLength(2) + expect(results[1]).toEqual({ + member: wsMember, + job: { async_account_data_ready: true, guid: 'JOB-123' }, + }) + + subscription.unsubscribe() + }) + + it('should deduplicate identical updates from polling and WebSockets', async () => { + const wsMessages$ = new Subject() + const mockWS = { + isConnected: vi.fn().mockReturnValue(true), + webSocketMessages$: wsMessages$.asObservable(), + } + + // Configure polling to return same data + const jobWithGuid = { ...mockJob, guid: 'JOB-123', async_account_data_ready: false } + mockApi.loadMemberByGuid.mockResolvedValue(mockMember) + mockApi.loadJob.mockResolvedValue(jobWithGuid) + + const transport$ = createMemberUpdateTransport( + mockApi, + mockMemberGuid, + { useWebSockets: true, pollingInterval: 1000 }, + mockWS, + ) + + const results: (MemberUpdate | Error)[] = [] + const subscription = transport$.subscribe((val) => { + results.push(val) + }) + + // 1. Trigger first poll + await vi.advanceTimersByTimeAsync(1000) + expect(results).toHaveLength(1) + + // 2. Emit identical data from WebSocket + wsMessages$.next({ event: 'members/updated', payload: mockMember }) + expect(results).toHaveLength(1) // Still 1 + + // 3. Trigger second poll + await vi.advanceTimersByTimeAsync(1000) + expect(results).toHaveLength(1) + + // 4. Emit a DIFFERENT update from WebSocket + const updatedMember = { ...mockMember, connection_status: 3 } + wsMessages$.next({ event: 'members/updated', payload: updatedMember }) + + expect(results).toHaveLength(2) + expect((results[1] as MemberUpdate).member?.connection_status).toBe(3) + + subscription.unsubscribe() + }) + + it('should ignore members/priority_data_ready if no member data has been received yet', async () => { + const wsMessages$ = new Subject() + const mockWS = { + isConnected: vi.fn().mockReturnValue(true), + webSocketMessages$: wsMessages$.asObservable(), + } + + const transport$ = createMemberUpdateTransport( + mockApi, + mockMemberGuid, + { useWebSockets: true }, + mockWS, + ) + + const results: (MemberUpdate | Error)[] = [] + const subscription = transport$.subscribe((val) => { + results.push(val) + }) + + // 1. Send priority data ready event first - should be filtered out because no member data yet + wsMessages$.next({ event: 'members/priority_data_ready', payload: { guid: mockMemberGuid } }) + expect(results).toHaveLength(0) + + // 2. Send member update - should finally emit + const wsMember = { guid: mockMemberGuid, connection_status: 1, most_recent_job_guid: 'JOB-123' } + wsMessages$.next({ event: 'members/updated', payload: wsMember }) + + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + member: wsMember, + job: { async_account_data_ready: true, guid: 'JOB-123' }, + }) + + subscription.unsubscribe() + }) + + it('should keep async_account_data_ready true once it has been set', async () => { + const wsMessages$ = new Subject() + const mockWS = { + isConnected: vi.fn().mockReturnValue(true), + webSocketMessages$: wsMessages$.asObservable(), + } + + const transport$ = createMemberUpdateTransport( + mockApi, + mockMemberGuid, + { useWebSockets: true }, + mockWS, + ) + + const results: (MemberUpdate | Error)[] = [] + const subscription = transport$.subscribe((val) => { + results.push(val) + }) + + const wsMember1 = { + guid: mockMemberGuid, + connection_status: 1, + most_recent_job_guid: 'JOB-123', + } + wsMessages$.next({ event: 'members/updated', payload: wsMember1 }) + + wsMessages$.next({ event: 'members/priority_data_ready', payload: { guid: mockMemberGuid } }) + expect((results[1] as MemberUpdate).job?.async_account_data_ready).toBe(true) + + // Send another member update, async_account_data_ready should remain true + const wsMember2 = { + guid: mockMemberGuid, + connection_status: 2, + most_recent_job_guid: 'JOB-123', + } + wsMessages$.next({ event: 'members/updated', payload: wsMember2 }) + + expect(results).toHaveLength(3) + expect((results[2] as MemberUpdate).job?.async_account_data_ready).toBe(true) + + subscription.unsubscribe() + }) }) diff --git a/typings/apiTypes.d.ts b/typings/apiTypes.d.ts index 30c1e4fcc3..6919ce3f75 100644 --- a/typings/apiTypes.d.ts +++ b/typings/apiTypes.d.ts @@ -112,12 +112,12 @@ type MemberResponseType = { name?: string process_status?: number revision?: number + use_cases?: [string] | null user_guid: string - verification_is_enabled: boolean - oauth_window_uri?: string | null verification_is_enabled?: boolean + oauth_window_uri?: string | null tax_statement_is_enabled?: boolean - successfully_aggreagted_at?: number + successfully_aggregated_at?: number } // Institution types @@ -287,6 +287,7 @@ type JobResponseType = { finished_at: number started_at: number updated_at: number + async_account_data_ready?: boolean } // user types diff --git a/typings/connectProps.d.ts b/typings/connectProps.d.ts index 65a9b06c7e..86356e91f9 100644 --- a/typings/connectProps.d.ts +++ b/typings/connectProps.d.ts @@ -4,6 +4,7 @@ interface ConnectWidgetPropTypes extends ConnectProps { language?: LanguageType onPostMessage: (event: string, data?: object) => void showTooSmallDialog: boolean + webSocketConnection?: any } interface PostMessageEventOverrides { @@ -36,10 +37,12 @@ interface ConnectProps { postMessageEventOverrides?: PostMessageEventOverrides profiles: ProfilesTypes userFeatures?: object + webSocketConnection?: any experimentalFeatures?: null | { unavailableInstitutions?: { guid: string; name: string }[] optOutOfEarlyUserRelease?: boolean memberPollingMilliseconds?: number + useWebSockets?: boolean } } interface ClientConfigType { diff --git a/typings/mxTypes.d.ts b/typings/mxTypes.d.ts index f3c34fe12c..eb30575362 100644 --- a/typings/mxTypes.d.ts +++ b/typings/mxTypes.d.ts @@ -46,7 +46,8 @@ type MemberResponseType = { last_job_status?: number last_update_time?: string metadata?: { [key: string]: unknown } - most_recent_job_detail_code?: number + mfa?: MfaCredentialType | object + most_recent_job_detail_code?: number | null most_recent_job_guid?: string needs_updated_credentials?: boolean name?: string @@ -54,7 +55,10 @@ type MemberResponseType = { revision?: number use_cases?: [string] | null user_guid: string - verification_is_enabled: boolean + verification_is_enabled?: boolean + oauth_window_uri?: string | null + tax_statement_is_enabled?: boolean + successfully_aggregated_at?: number } // Institution types @@ -172,6 +176,7 @@ type JobResponseType = { job_type: number status: number finished_at: number + async_account_data_ready?: boolean } // user types