Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions frontend/src/test/run-tests-plan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'bun:test';

import { collectTestFiles, planFrontendTestRuns, usesMockModule } from './run-tests-plan';

describe('frontend test runner planning', () => {
let tempDir: string | null = null;

afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
}
});

it('collects test files recursively in deterministic order', () => {
tempDir = mkdtempSync(path.join(tmpdir(), 'sentris-test-plan-'));
writeFileSync(path.join(tempDir, 'z.test.ts'), '');
writeFileSync(path.join(tempDir, 'component.ts'), '');

const nested = path.join(tempDir, 'nested');
mkdirSync(nested);
writeFileSync(path.join(tempDir, 'a.spec.tsx'), '');
writeFileSync(path.join(tempDir, 'readme.md'), '');
writeFileSync(path.join(nested, 'b.test.ts'), '');

expect(collectTestFiles(tempDir).map((file) => path.relative(tempDir!, file))).toEqual([
'a.spec.tsx',
path.join('nested', 'b.test.ts'),
'z.test.ts',
]);
});

it('detects direct mock.module usage', () => {
tempDir = mkdtempSync(path.join(tmpdir(), 'sentris-test-plan-'));
const mocked = path.join(tempDir, 'mocked.test.ts');
const plain = path.join(tempDir, 'plain.test.ts');

writeFileSync(
mocked,
"import { mock } from 'bun:test';\nmock.module('@/store', () => ({}));\n",
);
writeFileSync(plain, "import { expect, it } from 'bun:test';\nit('works', () => {});\n");

expect(usesMockModule(mocked)).toBe(true);
expect(usesMockModule(plain)).toBe(false);
});

it('batches adjacent non-mocked files while isolating mock.module files', () => {
const files = [
'src/a.test.ts',
'src/b.test.ts',
'src/c.test.ts',
'src/d.test.ts',
'src/e.test.ts',
];

const runs = planFrontendTestRuns(files, (file) => file === 'src/c.test.ts');

expect(runs).toEqual([
{
label: 'batch 1 (2 files)',
files: ['src/a.test.ts', 'src/b.test.ts'],
isolated: false,
},
{
label: 'src/c.test.ts',
files: ['src/c.test.ts'],
isolated: true,
},
{
label: 'batch 2 (2 files)',
files: ['src/d.test.ts', 'src/e.test.ts'],
isolated: false,
},
]);
});
});
74 changes: 74 additions & 0 deletions frontend/src/test/run-tests-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';

const TEST_FILE_PATTERN = /\.(test|spec)\.[jt]sx?$/;
const MOCK_MODULE_PATTERN = /\bmock\.module\s*\(/;

export interface FrontendTestRun {
label: string;
files: string[];
isolated: boolean;
}

export function collectTestFiles(dir: string): string[] {
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
a.name.localeCompare(b.name),
);
const files: string[] = [];

for (const entry of entries) {
const fullPath = path.join(dir, entry.name);

if (entry.isDirectory()) {
files.push(...collectTestFiles(fullPath));
continue;
}

if (entry.isFile() && TEST_FILE_PATTERN.test(entry.name)) {
files.push(fullPath);
}
}

return files;
}

export function toPosixRelative(filePath: string, cwd = process.cwd()): string {
return path.relative(cwd, filePath).split(path.sep).join(path.posix.sep);
}

export function usesMockModule(filePath: string): boolean {
return MOCK_MODULE_PATTERN.test(readFileSync(filePath, 'utf8'));
}

export function planFrontendTestRuns(
files: string[],
isIsolatedFile: (file: string) => boolean,
): FrontendTestRun[] {
const runs: FrontendTestRun[] = [];
let batch: string[] = [];
let batchCount = 0;

const flushBatch = () => {
if (batch.length === 0) return;
batchCount += 1;
runs.push({
label: `batch ${batchCount} (${batch.length} ${batch.length === 1 ? 'file' : 'files'})`,
files: batch,
isolated: false,
});
batch = [];
};

for (const file of files) {
if (isIsolatedFile(file)) {
flushBatch();
runs.push({ label: file, files: [file], isolated: true });
continue;
}

batch.push(file);
}

flushBatch();
return runs;
}
68 changes: 28 additions & 40 deletions frontend/src/test/run-tests-serial.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,11 @@
import { readdirSync } from 'node:fs';
import path from 'node:path';

const TEST_FILE_PATTERN = /\.(test|spec)\.[jt]sx?$/;

function collectTestFiles(dir: string): string[] {
const entries = readdirSync(dir, { withFileTypes: true });
const files: string[] = [];

for (const entry of entries) {
const fullPath = path.join(dir, entry.name);

if (entry.isDirectory()) {
files.push(...collectTestFiles(fullPath));
continue;
}

if (entry.isFile() && TEST_FILE_PATTERN.test(entry.name)) {
files.push(fullPath);
}
}

return files;
}

function toPosixRelative(filePath: string): string {
return path.relative(process.cwd(), filePath).split(path.sep).join(path.posix.sep);
}
import {
collectTestFiles,
planFrontendTestRuns,
toPosixRelative,
usesMockModule,
} from './run-tests-plan';

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

const cliArgs = process.argv.slice(2);
function main(): number {
const cliArgs = process.argv.slice(2);

if (cliArgs.length > 0) {
process.exit(runBunTest(cliArgs));
}
if (cliArgs.length > 0) {
return runBunTest(cliArgs);
}

const srcDir = path.join(process.cwd(), 'src');
const files = collectTestFiles(srcDir)
.map(toPosixRelative)
.sort((a, b) => a.localeCompare(b));
const srcDir = path.join(process.cwd(), 'src');
const files = collectTestFiles(srcDir).map((file) => toPosixRelative(file));
const runs = planFrontendTestRuns(files, (file) => usesMockModule(path.resolve(file)));

for (const file of files) {
console.log(`\n[frontend:test] ${file}`);
const exitCode = runBunTest([file]);
for (const run of runs) {
console.log(`\n[frontend:test] ${run.isolated ? 'isolated' : 'batch'}: ${run.label}`);
const exitCode = runBunTest(run.files);

if (exitCode !== 0) {
process.exit(exitCode);
if (exitCode !== 0) {
return exitCode;
}
}

const isolatedCount = runs.filter((run) => run.isolated).length;
const batchedCount = files.length - isolatedCount;
Comment on lines +45 to +46
console.log(
`\n[frontend:test] Completed ${files.length} test files in ${runs.length} processes (${batchedCount} batched, ${isolatedCount} isolated).`,
);
return 0;
}

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