Skip to content

Commit 256c733

Browse files
committed
test(frontend): batch non-mocked test files
Signed-off-by: zebbern <185730623+zebbern@users.noreply.github.com>
1 parent ed16bc6 commit 256c733

3 files changed

Lines changed: 182 additions & 40 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import path from 'node:path';
4+
import { afterEach, describe, expect, it } from 'bun:test';
5+
6+
import { collectTestFiles, planFrontendTestRuns, usesMockModule } from './run-tests-plan';
7+
8+
describe('frontend test runner planning', () => {
9+
let tempDir: string | null = null;
10+
11+
afterEach(() => {
12+
if (tempDir) {
13+
rmSync(tempDir, { recursive: true, force: true });
14+
tempDir = null;
15+
}
16+
});
17+
18+
it('collects test files recursively in deterministic order', () => {
19+
tempDir = mkdtempSync(path.join(tmpdir(), 'sentris-test-plan-'));
20+
writeFileSync(path.join(tempDir, 'z.test.ts'), '');
21+
writeFileSync(path.join(tempDir, 'component.ts'), '');
22+
23+
const nested = path.join(tempDir, 'nested');
24+
mkdirSync(nested);
25+
writeFileSync(path.join(tempDir, 'a.spec.tsx'), '');
26+
writeFileSync(path.join(tempDir, 'readme.md'), '');
27+
writeFileSync(path.join(nested, 'b.test.ts'), '');
28+
29+
expect(collectTestFiles(tempDir).map((file) => path.relative(tempDir!, file))).toEqual([
30+
'a.spec.tsx',
31+
path.join('nested', 'b.test.ts'),
32+
'z.test.ts',
33+
]);
34+
});
35+
36+
it('detects direct mock.module usage', () => {
37+
tempDir = mkdtempSync(path.join(tmpdir(), 'sentris-test-plan-'));
38+
const mocked = path.join(tempDir, 'mocked.test.ts');
39+
const plain = path.join(tempDir, 'plain.test.ts');
40+
41+
writeFileSync(
42+
mocked,
43+
"import { mock } from 'bun:test';\nmock.module('@/store', () => ({}));\n",
44+
);
45+
writeFileSync(plain, "import { expect, it } from 'bun:test';\nit('works', () => {});\n");
46+
47+
expect(usesMockModule(mocked)).toBe(true);
48+
expect(usesMockModule(plain)).toBe(false);
49+
});
50+
51+
it('batches adjacent non-mocked files while isolating mock.module files', () => {
52+
const files = [
53+
'src/a.test.ts',
54+
'src/b.test.ts',
55+
'src/c.test.ts',
56+
'src/d.test.ts',
57+
'src/e.test.ts',
58+
];
59+
60+
const runs = planFrontendTestRuns(files, (file) => file === 'src/c.test.ts');
61+
62+
expect(runs).toEqual([
63+
{
64+
label: 'batch 1 (2 files)',
65+
files: ['src/a.test.ts', 'src/b.test.ts'],
66+
isolated: false,
67+
},
68+
{
69+
label: 'src/c.test.ts',
70+
files: ['src/c.test.ts'],
71+
isolated: true,
72+
},
73+
{
74+
label: 'batch 2 (2 files)',
75+
files: ['src/d.test.ts', 'src/e.test.ts'],
76+
isolated: false,
77+
},
78+
]);
79+
});
80+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { readFileSync, readdirSync } from 'node:fs';
2+
import path from 'node:path';
3+
4+
const TEST_FILE_PATTERN = /\.(test|spec)\.[jt]sx?$/;
5+
const MOCK_MODULE_PATTERN = /\bmock\.module\s*\(/;
6+
7+
export interface FrontendTestRun {
8+
label: string;
9+
files: string[];
10+
isolated: boolean;
11+
}
12+
13+
export function collectTestFiles(dir: string): string[] {
14+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
15+
a.name.localeCompare(b.name),
16+
);
17+
const files: string[] = [];
18+
19+
for (const entry of entries) {
20+
const fullPath = path.join(dir, entry.name);
21+
22+
if (entry.isDirectory()) {
23+
files.push(...collectTestFiles(fullPath));
24+
continue;
25+
}
26+
27+
if (entry.isFile() && TEST_FILE_PATTERN.test(entry.name)) {
28+
files.push(fullPath);
29+
}
30+
}
31+
32+
return files;
33+
}
34+
35+
export function toPosixRelative(filePath: string, cwd = process.cwd()): string {
36+
return path.relative(cwd, filePath).split(path.sep).join(path.posix.sep);
37+
}
38+
39+
export function usesMockModule(filePath: string): boolean {
40+
return MOCK_MODULE_PATTERN.test(readFileSync(filePath, 'utf8'));
41+
}
42+
43+
export function planFrontendTestRuns(
44+
files: string[],
45+
isIsolatedFile: (file: string) => boolean,
46+
): FrontendTestRun[] {
47+
const runs: FrontendTestRun[] = [];
48+
let batch: string[] = [];
49+
let batchCount = 0;
50+
51+
const flushBatch = () => {
52+
if (batch.length === 0) return;
53+
batchCount += 1;
54+
runs.push({
55+
label: `batch ${batchCount} (${batch.length} ${batch.length === 1 ? 'file' : 'files'})`,
56+
files: batch,
57+
isolated: false,
58+
});
59+
batch = [];
60+
};
61+
62+
for (const file of files) {
63+
if (isIsolatedFile(file)) {
64+
flushBatch();
65+
runs.push({ label: file, files: [file], isolated: true });
66+
continue;
67+
}
68+
69+
batch.push(file);
70+
}
71+
72+
flushBatch();
73+
return runs;
74+
}
Lines changed: 28 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,11 @@
1-
import { readdirSync } from 'node:fs';
21
import path from 'node:path';
32

4-
const TEST_FILE_PATTERN = /\.(test|spec)\.[jt]sx?$/;
5-
6-
function collectTestFiles(dir: string): string[] {
7-
const entries = readdirSync(dir, { withFileTypes: true });
8-
const files: string[] = [];
9-
10-
for (const entry of entries) {
11-
const fullPath = path.join(dir, entry.name);
12-
13-
if (entry.isDirectory()) {
14-
files.push(...collectTestFiles(fullPath));
15-
continue;
16-
}
17-
18-
if (entry.isFile() && TEST_FILE_PATTERN.test(entry.name)) {
19-
files.push(fullPath);
20-
}
21-
}
22-
23-
return files;
24-
}
25-
26-
function toPosixRelative(filePath: string): string {
27-
return path.relative(process.cwd(), filePath).split(path.sep).join(path.posix.sep);
28-
}
3+
import {
4+
collectTestFiles,
5+
planFrontendTestRuns,
6+
toPosixRelative,
7+
usesMockModule,
8+
} from './run-tests-plan';
299

3010
function runBunTest(args: string[]): number {
3111
const result = Bun.spawnSync(
@@ -42,24 +22,32 @@ function runBunTest(args: string[]): number {
4222
return result.exitCode ?? 1;
4323
}
4424

45-
const cliArgs = process.argv.slice(2);
25+
function main(): number {
26+
const cliArgs = process.argv.slice(2);
4627

47-
if (cliArgs.length > 0) {
48-
process.exit(runBunTest(cliArgs));
49-
}
28+
if (cliArgs.length > 0) {
29+
return runBunTest(cliArgs);
30+
}
5031

51-
const srcDir = path.join(process.cwd(), 'src');
52-
const files = collectTestFiles(srcDir)
53-
.map(toPosixRelative)
54-
.sort((a, b) => a.localeCompare(b));
32+
const srcDir = path.join(process.cwd(), 'src');
33+
const files = collectTestFiles(srcDir).map((file) => toPosixRelative(file));
34+
const runs = planFrontendTestRuns(files, (file) => usesMockModule(path.resolve(file)));
5535

56-
for (const file of files) {
57-
console.log(`\n[frontend:test] ${file}`);
58-
const exitCode = runBunTest([file]);
36+
for (const run of runs) {
37+
console.log(`\n[frontend:test] ${run.isolated ? 'isolated' : 'batch'}: ${run.label}`);
38+
const exitCode = runBunTest(run.files);
5939

60-
if (exitCode !== 0) {
61-
process.exit(exitCode);
40+
if (exitCode !== 0) {
41+
return exitCode;
42+
}
6243
}
44+
45+
const isolatedCount = runs.filter((run) => run.isolated).length;
46+
const batchedCount = files.length - isolatedCount;
47+
console.log(
48+
`\n[frontend:test] Completed ${files.length} test files in ${runs.length} processes (${batchedCount} batched, ${isolatedCount} isolated).`,
49+
);
50+
return 0;
6351
}
6452

65-
console.log(`\n[frontend:test] Completed ${files.length} test files serially.`);
53+
process.exit(main());

0 commit comments

Comments
 (0)