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/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..1ab6d38be6 --- /dev/null +++ b/src/__tests__/ConnectWidget-test.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { render } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { of } from 'rxjs' + +import { ConnectWidget } from '../ConnectWidget' +import { useWebSocket } from '../context/WebSocketContext' + +vi.mock('src/Connect', () => ({ + default: vi.fn(() => { + // In actual implementation, it uses Context + // But for the test we just want to see if it renders without crashing + // and correctly provides the context which we can check via useWebSocket in a child if we want + return
mock-connect
+ }), +})) + +// A simple component to verify context +const ContextChecker = () => { + const ws = useWebSocket() + return
{ws ? 'has-ws' : 'no-ws'}
+} + +// We need to mock Connect to render the ContextChecker instead +vi.mock('src/Connect', () => ({ + default: () => , +})) + +describe('ConnectWidget', () => { + const defaultProps = { + clientConfig: {}, + profiles: {}, + userFeatures: {}, + language: { locale: 'en', localizedContent: {} }, + } + + it('provides webSocketConnection to children when passed as a prop', () => { + const mockWS = { + isConnected: vi.fn().mockReturnValue(true), + webSocketMessages$: of({}), + } + + const { getByTestId } = render() + + expect(getByTestId('context-checker')).toHaveTextContent('has-ws') + }) + + it('does not provide webSocketConnection when not passed', () => { + const { getByTestId } = render() + + expect(getByTestId('context-checker')).toHaveTextContent('no-ws') + }) +}) 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..9571e2328a 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,88 @@ 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() + }) }) 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..bbd793976a 100644 --- a/src/utilities/transport/MemberUpdateTransport.ts +++ b/src/utilities/transport/MemberUpdateTransport.ts @@ -1,6 +1,8 @@ -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 } 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 +14,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 +39,52 @@ 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, + ), + map((msg) => { + const member = msg.payload + const job = { + guid: member?.most_recent_job_guid, + async_account_data_ready: msg.event === 'members/priority_data_ready' || undefined, + } as JobResponseType + + return { member, job } + }), + // 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..371f27768c 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,112 @@ 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 } + wsMessages$.next({ event: 'members/updated', payload: wsMember }) + + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + member: wsMember, + job: { async_account_data_ready: undefined, guid: undefined }, + }) + + subscription.unsubscribe() + }) + + it('should signal async_account_data_ready when members/priority_data_ready is received', 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 } + wsMessages$.next({ event: 'members/priority_data_ready', payload: wsMember }) + + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + member: wsMember, + job: { async_account_data_ready: true }, + }) + + 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' } + 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() + }) }) 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