Skip to content

Commit 1e5469d

Browse files
Add concurrency tests (#13)
* add concurrency tests * add test to ensure no console log leakage between RSC rendered components * add test to ensure that console logs are not captured from outside the component * add a specific test for a known bug at React * add a test to ensure that onError callback doesn't have log leakage bug * add concurrency tests for html generation * update test:non-rsc script * add concurrent tests * add unit tests workflow * install pnpm at the tests worflow * use yarn instead of pnpm * install yarn packages * Empty commit * fix syntax error at the workflow file * make tests compatible with react v19.2.1 * downgrade jest to 29.7.0 * tiny changes to make tests more resilliance
1 parent 7d10a0e commit 1e5469d

12 files changed

+2852
-4
lines changed

.github/workflows/unit-tests.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: Run unit tests (jest)
2+
on: [pull_request]
3+
4+
jobs:
5+
unit-tests:
6+
runs-on: ubuntu-latest
7+
steps:
8+
- uses: actions/checkout@v5
9+
- name: Use Node.js
10+
uses: actions/setup-node@v4
11+
with:
12+
node-version: '20.x'
13+
- name: Install packages using yarn
14+
run: yarn
15+
- run: yarn test

jest.config.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
const { createDefaultPreset } = require("ts-jest");
2+
3+
const tsJestTransformCfg = createDefaultPreset().transform;
4+
5+
/** @type {import("jest").Config} **/
6+
module.exports = {
7+
testEnvironment: "node",
8+
transform: {
9+
...tsJestTransformCfg,
10+
},
11+
// Override: Package-specific test directory
12+
testMatch: ['<rootDir>/tests/**/?(*.)+(spec|test).[jt]s?(x)'],
13+
testEnvironmentOptions: {
14+
customExportConditions: process.env.NODE_CONDITIONS?.split(',') ?? [],
15+
},
16+
};

package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@
6161
"README.md"
6262
],
6363
"scripts": {
64+
"test": "yarn test:rsc && yarn test:non-rsc",
65+
"test:rsc": "NODE_CONDITIONS=react-server jest tests/*.rsc.test.*",
66+
"test:non-rsc": "jest tests --testPathIgnorePatterns=\".*\\.rsc\\.test\\..*\"",
6467
"build": "rm -rf dist tsconfig.tsbuildinfo && yarn run tsc",
6568
"prepublishOnly": "yarn run build",
6669
"build-if-needed": "[ -f dist/client.js ] || (yarn run build >/dev/null 2>&1 || true) && [ -f dist/client.js ] || { echo 'Build failed'; }",
@@ -69,6 +72,13 @@
6972
},
7073
"devDependencies": {
7174
"@tsconfig/node14": "^14.1.2",
75+
"@types/jest": "^30.0.0",
76+
"@types/react": "^19.2.2",
77+
"@types/react-dom": "^19.2.2",
78+
"jest": "^29.7.0",
79+
"react": "^19.2.0",
80+
"react-dom": "^19.2.0",
81+
"ts-jest": "^29.4.5",
7282
"typescript": "^5.4.3",
7383
"webpack": "^5.98.0"
7484
},

tests/AsyncQueue.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import * as EventEmitter from 'node:events';
2+
3+
const debounce = <T extends unknown[]>(
4+
callback: (...args: T) => void,
5+
delay: number,
6+
) => {
7+
let timeoutTimer: ReturnType<typeof setTimeout>;
8+
9+
return (...args: T) => {
10+
clearTimeout(timeoutTimer);
11+
12+
timeoutTimer = setTimeout(() => {
13+
callback(...args);
14+
}, delay);
15+
};
16+
};
17+
18+
class AsyncQueue {
19+
private eventEmitter = new EventEmitter<{ data: any, end: any }>();
20+
private buffer: string = '';
21+
private isEnded = false;
22+
23+
enqueue(value: string) {
24+
if (this.isEnded) {
25+
throw new Error("Queue Ended");
26+
}
27+
28+
this.buffer += value;
29+
this.eventEmitter.emit('data', value);
30+
}
31+
32+
end() {
33+
this.isEnded = true;
34+
this.eventEmitter.emit('end');
35+
}
36+
37+
dequeue() {
38+
return new Promise<string>((resolve, reject) => {
39+
if (this.isEnded) {
40+
reject(new Error("Queue Ended"));
41+
return;
42+
}
43+
44+
const checkBuffer = debounce(() => {
45+
const teardown = () => {
46+
this.eventEmitter.off('data', checkBuffer);
47+
this.eventEmitter.off('end', checkBuffer);
48+
}
49+
50+
if (this.buffer.length > 0) {
51+
resolve(this.buffer);
52+
this.buffer = '';
53+
teardown();
54+
} else if (this.isEnded) {
55+
reject(new Error('Queue Ended'));
56+
teardown();
57+
}
58+
}, 250);
59+
60+
if (this.buffer.length > 0) {
61+
checkBuffer();
62+
}
63+
this.eventEmitter.on('data', checkBuffer);
64+
this.eventEmitter.on('end', checkBuffer);
65+
})
66+
}
67+
68+
toString() {
69+
return ""
70+
}
71+
}
72+
73+
export default AsyncQueue;

tests/AsyncQueueContainer.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as React from 'react';
2+
import { Suspense, PropsWithChildren } from 'react';
3+
4+
import AsyncQueue from './AsyncQueue';
5+
6+
const AsyncQueueItem = async ({ asyncQueue, children }: PropsWithChildren<{asyncQueue: AsyncQueue}>) => {
7+
const value = await asyncQueue.dequeue();
8+
9+
return (
10+
<>
11+
<p>Data: {value}</p>
12+
{children}
13+
</>
14+
)
15+
}
16+
17+
const AsyncQueueContainer = ({ asyncQueue }: { asyncQueue: AsyncQueue }) => {
18+
return (
19+
<div>
20+
<h1>Async Queue</h1>
21+
<Suspense fallback={<p>Loading Item1</p>}>
22+
<AsyncQueueItem asyncQueue={asyncQueue}>
23+
<Suspense fallback={<p>Loading Item2</p>}>
24+
<AsyncQueueItem asyncQueue={asyncQueue}>
25+
<Suspense fallback={<p>Loading Item3</p>}>
26+
<AsyncQueueItem asyncQueue={asyncQueue} />
27+
</Suspense>
28+
</AsyncQueueItem>
29+
</Suspense>
30+
</AsyncQueueItem>
31+
</Suspense>
32+
</div>
33+
)
34+
}
35+
36+
export default AsyncQueueContainer;

tests/StreamReader.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { PassThrough, Readable } from 'node:stream';
2+
import AsyncQueue from './AsyncQueue';
3+
4+
class StreamReader {
5+
private asyncQueue: AsyncQueue;
6+
7+
constructor(pipeableStream: Pick<Readable, 'pipe'>) {
8+
this.asyncQueue = new AsyncQueue();
9+
const decoder = new TextDecoder();
10+
11+
const readableStream = new PassThrough();
12+
pipeableStream.pipe(readableStream);
13+
14+
readableStream.on('data', (chunk) => {
15+
const decodedChunk = decoder.decode(chunk, { stream: true });
16+
this.asyncQueue.enqueue(decodedChunk);
17+
});
18+
19+
readableStream.on('end', () => {
20+
// Flush any remaining bytes in the decoder
21+
const remaining = decoder.decode();
22+
if (remaining) {
23+
this.asyncQueue.enqueue(remaining);
24+
}
25+
this.asyncQueue.end();
26+
});
27+
}
28+
29+
nextChunk() {
30+
return this.asyncQueue.dequeue();
31+
}
32+
}
33+
34+
export default StreamReader;

0 commit comments

Comments
 (0)