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