Skip to content

Commit 9877da5

Browse files
authored
chore: migrate test suite from JavaScript to TypeScript (#3057)
### 🎯 Goal Migrate the entire test suite from JavaScript/JSX to TypeScript/TSX to improve IDE autocompletion, refactoring safety, and catch type errors at write time. Also replace ad-hoc `as any` casts with proper SDK types and typed test helpers. ### 🛠 Implementation details **Scope:** 248 files changed, 3725 insertions, 2411 deletions #### Infrastructure - Install `@total-typescript/shoehorn` for `fromPartial<T>()` in test mocks - Update `tsconfig.test.json` with proper include patterns, path aliases, and 4 strict flags (`strictBindCallApply`, `noImplicitThis`, `alwaysStrict`, `useUnknownInCatchVariables`) - Update `eslint.config.mjs` with test-specific rule relaxations; enable `disallowTypeAnnotations: false` for `typeof import()` in `vi.mock` - Add `@vitest/expect` module augmentation for jest-dom + vitest-axe matchers (vitest 4.x compatibility) - Add `yarn types:tests` script for test type-checking - Exclude test files from i18next translation extraction (`i18next.config.ts`) #### Mock-builders (45 JS → TS) - All generators typed with SDK types (`UserResponse`, `Attachment`, `ReactionResponse`, `ChannelMemberResponse`, `LocalMessage`, `PollResponse`, `ChannelAPIResponse`, etc.) - `generateMessage()` returns `LocalMessage` (matches what components consume) and accepts `Date | string` for date fields - `generateChannel()` returns `ChannelAPIResponse` (renamed `pinnedMessages` → `pinned_messages`) - `mockClient` typed with `StreamChat`, uses `vi.spyOn` for public methods - `MockClientOverrides` interface using `StreamChat['getAppSettings']` and `StreamChat['queryReactions']` - `TokenManager` typed with `fromPartial<TokenManager>()` - `getOrCreateChannelApi` typed with `ChannelAPIResponse`, `queryChannelsApi` with `ChannelAPIResponse[]` - `sendMessageApi` typed with `MessageResponse | LocalMessage` - Event dispatchers use `fromPartial<Event>()`, accept `Channel | ChannelResponse` and `MessageResponse | LocalMessage` - `TDateTimeParser` from `i18n/types` used for translation mock - `ResizeObserverCallback` DOM type, `Partial<BlobEvent>`, `Partial<File>`, `Action` from SDK - 9 typed context builder helpers (`mockChatContext`, `mockChannelStateContext`, `mockTranslationContextValue`, etc.) #### Test files (134 JS/JSX → TS/TSX) - All 1,720 type errors fixed - `as any` reduced from ~1,107 to ~435 (61% reduction) - `Record<string, any>` reduced to 0 — all replaced with typed interfaces - Provider `value as any` replaced with context helpers (`mockChatContext()`, etc.) - `vi.spyOn(obj as any)` replaced with `@ts-expect-error` + proper spy - `{} as any` replaced with `fromPartial<Type>({})` - `importOriginal() as any` replaced with `importOriginal<typeof import('...')>()` - Private property access uses bracket notation (`obj['prop']`) instead of `(obj as any).prop` - `renderComponent` params typed with per-file interfaces using SDK types - Variables typed as `StreamChat`, `Channel`, `LocalMessage` instead of `any` - `{ children }: any` replaced with `React.PropsWithChildren` - Removed no-op `amplitudesCount` prop from WaveProgressBar tests - Deleted 19 orphan `.test.jsx.snap` snapshot files #### Remaining `as any` (~435, intentionally kept) | Category | Count | Reason | |----------|-------|--------| | Partial objects in component/hook tests | ~149 | Test data does not satisfy full type; `fromPartial` used where possible | | TFunction mock (`$TFunctionBrand`) | ~35 | i18next brand symbol cannot be satisfied by mock functions | | `LocalMessage[]` → `MessageResponse[]` | ~42 | Date vs string field mismatch in `generateChannel` calls | | `window/navigator/globalThis` | ~17 | Read-only DOM globals | | `@ts-expect-error` (explicit) | ~18 | Mock impl signature mismatches, properly documented | | Mouse event mocks | ~16 | Partial event objects | | `.next(value)` on state/subjects | ~12 | BehaviorSubject type constraints | | Misc (channel, client, renderText) | ~146 | Scattered partials and legacy patterns | ### 🎨 UI Changes No UI changes — test infrastructure only.
1 parent a55774d commit 9877da5

248 files changed

Lines changed: 3725 additions & 2411 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,4 @@ coverage.out
7878
# stream-chat-css/docusaurus files
7979
docusaurus/docs/React/theming
8080
docusaurus/docs/React/assets/stream-chat-css*
81-
shared
81+
sharedtsconfig.test.tsbuildinfo

eslint.config.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export default tseslint.config(
116116
},
117117
{
118118
name: 'vitest',
119-
files: ['src/**/__tests__/**'],
119+
files: ['src/**/__tests__/**', 'src/mock-builders/**'],
120120
plugins: { vitest: vitestPlugin },
121121
languageOptions: {
122122
globals: vitestPlugin.environments.env.globals,
@@ -130,6 +130,12 @@ export default tseslint.config(
130130
'vitest/no-hooks': 'off',
131131
'vitest/prefer-spy-on': 'warn',
132132
'@typescript-eslint/no-empty-function': 'off', // explicitly disable for tests
133+
'@typescript-eslint/no-explicit-any': 'off', // test mocks frequently need any
134+
'@typescript-eslint/no-non-null-assertion': 'off', // DOM queries in tests commonly use !
135+
'@typescript-eslint/consistent-type-imports': [
136+
'error',
137+
{ disallowTypeAnnotations: false },
138+
], // allow typeof import() in vi.mock importOriginal
133139
},
134140
},
135141
);

i18next.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export default defineConfig({
66
defaultNS: false,
77
extractFromComments: false,
88
functions: ['t', '*.t'],
9+
ignore: ['./src/**/__tests__/**', './src/mock-builders/**'],
910
input: ['./src/**/*.{tsx,ts}'],
1011
keySeparator: false,
1112
nsSeparator: false,

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@
145145
"@testing-library/dom": "^10.4.0",
146146
"@testing-library/jest-dom": "^6.6.3",
147147
"@testing-library/react": "^16.2.0",
148+
"@total-typescript/shoehorn": "^0.1.2",
148149
"@types/hast": "^2.3.4",
149150
"@types/jsdom": "^21.1.5",
150151
"@types/linkifyjs": "^2.1.7",
@@ -203,6 +204,7 @@
203204
"test": "vitest run",
204205
"test:watch": "vitest",
205206
"types": "tsc --emitDeclarationOnly false --noEmit",
207+
"types:tests": "tsc --project tsconfig.test.json --noEmit",
206208
"validate-translations": "node scripts/validate-translations.js",
207209
"validate-cjs": "concurrently 'node scripts/validate-cjs-node-bundle.cjs' 'node scripts/validate-cjs-browser-bundle.cjs'",
208210
"semantic-release": "semantic-release",

src/@types/vitest-axe.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers';
2+
3+
declare module '@vitest/expect' {
4+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
5+
interface Assertion<T>
6+
extends TestingLibraryMatchers<typeof expect.stringContaining, T> {
7+
toHaveNoViolations(): void;
8+
}
9+
}

src/components/Attachment/__tests__/Attachment.test.jsx renamed to src/components/Attachment/__tests__/Attachment.test.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import {
1818

1919
import { Attachment } from '../Attachment';
2020
import { SUPPORTED_VIDEO_FORMATS } from '../utils';
21-
import { generateScrapedVideoAttachment } from '../../../mock-builders';
21+
import {
22+
generateScrapedVideoAttachment,
23+
mockChannelStateContext,
24+
} from '../../../mock-builders';
2225
import { ChannelStateProvider } from '../../../context';
2326

2427
const UNSUPPORTED_ATTACHMENT_TEST_ID = 'attachment-unsupported';
@@ -33,8 +36,9 @@ const ModalGallery = (props) => (
3336
<div data-testid='gallery-attachment'>{props.customTestId}</div>
3437
);
3538
const Giphy = (props) => <div data-testid='giphy-attachment'>{props.customTestId}</div>;
39+
const GEOLOCATION_TEST_ID = 'geolocation-attachment';
3640
const Geolocation = (props) => (
37-
<div data-testid={'geolocation-attachment'}>{props.customTestId}</div>
41+
<div data-testid={GEOLOCATION_TEST_ID}>{props.customTestId}</div>
3842
);
3943

4044
const ATTACHMENTS = {
@@ -56,7 +60,7 @@ const ATTACHMENTS = {
5660

5761
const renderComponent = (props) =>
5862
render(
59-
<ChannelStateProvider value={{}}>
63+
<ChannelStateProvider value={mockChannelStateContext()}>
6064
<Attachment
6165
AttachmentActions={AttachmentActions}
6266
Audio={Audio}
@@ -258,13 +262,13 @@ describe('attachment', () => {
258262
});
259263

260264
it('renders shared location with Geolocation attachment', () => {
261-
renderComponent({ attachments: [generateLiveLocationResponse()] });
265+
renderComponent({ attachments: [generateLiveLocationResponse({})] });
262266
waitFor(() => {
263-
expect(screen.getByTestId(testId)).toBeInTheDocument();
267+
expect(screen.getByTestId(GEOLOCATION_TEST_ID)).toBeInTheDocument();
264268
});
265-
renderComponent({ attachments: [generateStaticLocationResponse()] });
269+
renderComponent({ attachments: [generateStaticLocationResponse({})] });
266270
waitFor(() => {
267-
expect(screen.getByTestId(testId)).toBeInTheDocument();
271+
expect(screen.getByTestId(GEOLOCATION_TEST_ID)).toBeInTheDocument();
268272
});
269273
});
270274

src/components/Attachment/__tests__/AttachmentActions.test.jsx renamed to src/components/Attachment/__tests__/AttachmentActions.test.tsx

File renamed without changes.

src/components/Attachment/__tests__/Audio.test.jsx renamed to src/components/Attachment/__tests__/Audio.test.tsx

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import React from 'react';
22
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
33

44
import { Audio } from '../Audio';
5-
import { generateAudioAttachment, generateMessage } from '../../../mock-builders';
5+
import {
6+
generateAudioAttachment,
7+
generateMessage,
8+
mockMessageContext,
9+
} from '../../../mock-builders';
610
import { prettifyFileSize } from '../../MessageComposer/hooks/utils';
711
import { WithAudioPlayback } from '../../AudioPlayback';
812
import { MessageProvider } from '../../../context';
@@ -33,10 +37,10 @@ vi.spyOn(window, 'Audio').mockImplementation(function AudioMock(...args) {
3337
});
3438

3539
const originalConsoleError = console.error;
36-
vi.spyOn(console, 'error').mockImplementationOnce((...errorOrTextorArg) => {
40+
vi.spyOn(console, 'error').mockImplementationOnce((...errorOrTextorArg: any[]) => {
3741
const msg = Array.isArray(errorOrTextorArg)
3842
? errorOrTextorArg[0]
39-
: (errorOrTextorArg.message ?? errorOrTextorArg);
43+
: (errorOrTextorArg['message'] ?? errorOrTextorArg);
4044
if (msg.match('Not implemented')) return;
4145
originalConsoleError(...errorOrTextorArg);
4246
});
@@ -79,7 +83,9 @@ describe('Audio', () => {
7983
beforeEach(() => {
8084
// jsdom doesn't define these, so mock them instead
8185
// see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement#Methods
82-
vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(() => {});
86+
vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(() =>
87+
Promise.resolve(),
88+
);
8389
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
8490
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => {});
8591
});
@@ -96,7 +102,9 @@ describe('Audio', () => {
96102
});
97103

98104
expect(getByText(audioAttachment.title)).toBeInTheDocument();
99-
expect(getByText(prettifyFileSize(audioAttachment.file_size))).toBeInTheDocument();
105+
expect(
106+
getByText(prettifyFileSize(audioAttachment.file_size as number)),
107+
).toBeInTheDocument();
100108
expect(container.querySelector('img')).not.toBeInTheDocument();
101109
});
102110

@@ -118,7 +126,7 @@ describe('Audio', () => {
118126
const { getByTestId } = renderComponent({ og: audioAttachment });
119127
await clickToPlay();
120128
vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockImplementationOnce(
121-
() => ({ width: 120, x: 0 }),
129+
() => ({ width: 120, x: 0 }) as any,
122130
);
123131

124132
vi.spyOn(HTMLAudioElement.prototype, 'currentTime', 'set').mockImplementationOnce(
@@ -265,10 +273,10 @@ describe('Audio', () => {
265273
const message = generateMessage();
266274
render(
267275
<WithAudioPlayback allowConcurrentPlayback>
268-
<MessageProvider value={{ message }}>
276+
<MessageProvider value={mockMessageContext({ message })}>
269277
<Audio attachment={audioAttachment} />
270278
</MessageProvider>
271-
<MessageProvider value={{ message, threadList: true }}>
279+
<MessageProvider value={mockMessageContext({ message, threadList: true })}>
272280
<Audio attachment={audioAttachment} />
273281
</MessageProvider>
274282
</WithAudioPlayback>,
@@ -287,10 +295,10 @@ describe('Audio', () => {
287295
const message = generateMessage();
288296
render(
289297
<WithAudioPlayback>
290-
<MessageProvider value={{ message }}>
298+
<MessageProvider value={mockMessageContext({ message })}>
291299
<Audio attachment={audioAttachment} />
292300
</MessageProvider>
293-
<MessageProvider value={{ message }}>
301+
<MessageProvider value={mockMessageContext({ message })}>
294302
<Audio attachment={audioAttachment} />
295303
</MessageProvider>
296304
</WithAudioPlayback>,

src/components/Attachment/__tests__/Card.test.jsx renamed to src/components/Attachment/__tests__/Card.test.tsx

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,45 +3,52 @@ import { cleanup, render, waitFor } from '@testing-library/react';
33

44
import { Card } from '../LinkPreview/Card';
55

6+
import { fromPartial } from '@total-typescript/shoehorn';
67
import { ChannelActionProvider, TranslationContext } from '../../../context';
78
import { ChannelStateProvider } from '../../../context/ChannelStateContext';
89
import { ChatProvider } from '../../../context/ChatContext';
910
import { ComponentProvider } from '../../../context/ComponentContext';
1011

12+
import type { ChannelActionContextValue } from '../../../context';
13+
import type { Channel, StreamChat } from 'stream-chat';
14+
1115
import {
1216
generateChannel,
1317
generateGiphyAttachment,
1418
generateMember,
1519
generateUser,
1620
getOrCreateChannelApi,
1721
getTestClientWithUser,
18-
mockTranslationContext,
22+
mockChannelStateContext,
23+
mockChatContext,
24+
mockComponentContext,
25+
mockTranslationContextValue,
1926
useMockedApis,
2027
} from '../../../mock-builders';
2128
import { WithAudioPlayback } from '../../AudioPlayback';
2229

23-
let chatClient;
24-
let channel;
30+
let chatClient: StreamChat;
31+
let channel: Channel;
2532
const user = generateUser({ id: 'userId', name: 'username' });
2633

27-
vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockImplementation();
28-
vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation();
29-
vi.spyOn(window.HTMLMediaElement.prototype, 'load').mockImplementation();
30-
const channelActionContext = {};
34+
vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockImplementation(async () => {});
35+
vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
36+
vi.spyOn(window.HTMLMediaElement.prototype, 'load').mockImplementation(() => {});
37+
const channelActionContext = fromPartial<ChannelActionContextValue>({});
3138

3239
const mockedChannel = generateChannel({
3340
members: [generateMember({ user })],
3441
messages: [],
35-
thread: [],
36-
});
42+
threads: [],
43+
} as any);
3744

38-
const renderCard = ({ cardProps, chatContext, theRenderer = render }) =>
45+
const renderCard = ({ cardProps, chatContext, theRenderer = render }: any) =>
3946
theRenderer(
40-
<ChatProvider value={chatContext}>
41-
<TranslationContext.Provider value={mockTranslationContext}>
47+
<ChatProvider value={mockChatContext(chatContext)}>
48+
<TranslationContext.Provider value={mockTranslationContextValue()}>
4249
<ChannelActionProvider value={channelActionContext}>
43-
<ChannelStateProvider value={{}}>
44-
<ComponentProvider value={{}}>
50+
<ChannelStateProvider value={mockChannelStateContext()}>
51+
<ComponentProvider value={mockComponentContext()}>
4552
<WithAudioPlayback>
4653
<Card {...cardProps} />
4754
</WithAudioPlayback>
@@ -56,7 +63,7 @@ describe('Card', () => {
5663
beforeAll(async () => {
5764
chatClient = await getTestClientWithUser({ id: user.id });
5865
useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
59-
channel = chatClient.channel('messaging', mockedChannel.id);
66+
channel = chatClient.channel('messaging', mockedChannel['id']);
6067
channel.query();
6168
});
6269

@@ -75,7 +82,7 @@ describe('Card', () => {
7582

7683
const attachmentTypes = ['audio', 'image', 'video'];
7784

78-
const cases = attachmentTypes.reduce((acc, type) => {
85+
const cases = attachmentTypes.reduce((acc: any, type) => {
7986
const attachment = { ...dummyAttachment, type };
8087
acc[type] = [
8188
{

src/components/Attachment/__tests__/File.test.jsx renamed to src/components/Attachment/__tests__/File.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import React from 'react';
22
import { render } from '@testing-library/react';
33

44
import { FileAttachment } from '../FileAttachment';
5-
import { TranslationContext } from '../../../context';
6-
import { mockTranslationContext } from '../../../mock-builders';
5+
import { TranslationProvider } from '../../../context';
6+
import { mockTranslationContextValue } from '../../../mock-builders';
77

8-
const getComponent = ({ attachment }) => (
9-
<TranslationContext.Provider value={mockTranslationContext}>
8+
const getComponent = ({ attachment }: any) => (
9+
<TranslationProvider value={mockTranslationContextValue()}>
1010
<FileAttachment attachment={attachment} />
11-
</TranslationContext.Provider>
11+
</TranslationProvider>
1212
);
1313

1414
const file = {

0 commit comments

Comments
 (0)