Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
785fa1d
feat(core): stabilize programmatic JS API with createRstest + /api su…
fi3ework Jun 8, 2026
f47b7f3
feat(core): add watch() and harden programmatic JS API host-safety
fi3ework Jul 1, 2026
96ebcc5
fix(core): address programmatic API review follow-ups
fi3ework Jul 1, 2026
e54ab1f
fix(core): restore host env when createRstest creation fails
fi3ework Jul 1, 2026
d1e08fa
fix(core): drop explicit changed:false before building runCli options
fi3ework Jul 1, 2026
6e26e54
fix(vscode): keep default coverage reporters when workspace sets none
fi3ework Jul 2, 2026
24e0511
fix(core): reflect exit-code-only failures in programmatic run `ok`
fi3ework Jul 2, 2026
604521b
fix(core): ignore pre-existing host exitCode in programmatic run `ok`
fi3ework Jul 2, 2026
704ab7d
docs(core): add programmatic API reference page
fi3ework Jul 2, 2026
16f5be6
feat(core): align programmatic API with rsbuild, add watch onResult
fi3ework Jul 2, 2026
a289f78
fix(core): keep resolved reporter config in programmatic eager context
fi3ework Jul 2, 2026
93eb261
refactor(core): make programmatic runCLI a CLI passthrough
fi3ework Jul 3, 2026
f892711
fix(core): carry cwd through watch restart and CLI runners
fi3ework Jul 3, 2026
0c6ee98
docs(core): resolve programmatic config against the documented cwd
fi3ework Jul 3, 2026
1c0ee63
fix(core): ignore execution-only shard in programmatic listTests
fi3ework Jul 3, 2026
f23926b
fix(core): track loaded config file path in programmatic API
fi3ework Jul 3, 2026
1473b73
test(core): normalize path separators in buildCache config-file e2e
fi3ework Jul 3, 2026
e3ae967
test(core): use pathe.resolve for cross-platform buildCache path assert
fi3ework Jul 3, 2026
b0ef91d
feat(core): expose a curated public context on the programmatic instance
fi3ework Jul 6, 2026
84be0f0
fix(core): exempt watch reruns from the no-tests failure in run result
fi3ework Jul 7, 2026
bd75fc3
fix(vscode): honor an explicit empty coverage.reporters in the worker
fi3ework Jul 7, 2026
080cce8
fix(core): address programmatic JS API review findings
fi3ework Jul 7, 2026
2fef6d3
fix(vscode): release continuous-run watch resources and cache instances
fi3ework Jul 7, 2026
948a410
fix(core): resolve merged TestRunResult from programmatic mergeReports
fi3ework Jul 8, 2026
1f8144e
test(core): write merge-reports fixture blobs under gitignored .rstes…
fi3ework Jul 8, 2026
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
23 changes: 9 additions & 14 deletions benchmarks/memorySuiteRun.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { withCodSpeed } from '@codspeed/tinybench-plugin';
import { Bench } from 'tinybench';
import { createFrontendMemoryFixture } from './createFrontendMemoryFixture.mjs';

const { initCli, createRstest } = await import('@rstest/core');
const { createRstest } = await import('@rstest/core/api');

const bench = withCodSpeed(
new Bench({
Expand All @@ -21,23 +21,18 @@ const bench = withCodSpeed(
);

const fixture = await createFrontendMemoryFixture();
const rstest = await createRstest({
config: { reporters: [], root: fixture.root },
});

async function runSyntheticFrontendProject() {
const { config, configFilePath, projects } = await initCli({
reporter: [],
root: fixture.root,
});

const rstest = createRstest({ config, configFilePath, projects }, 'run', []);
await rstest.runTests();
// `createRstest().run()` is host-safe: it resolves a structured result instead
// of setting `process.exitCode`, so no per-iteration exit-code reset is needed.
const result = await rstest.run();

if (process.exitCode && process.exitCode !== 0) {
throw new Error(
`Synthetic frontend memory benchmark failed with exit code ${process.exitCode}`,
);
if (!result.ok) {
throw new Error('Synthetic frontend memory benchmark failed');
}

process.exitCode = undefined;
}

bench.add('frontend-memory-full-run', async () => {
Expand Down
45 changes: 16 additions & 29 deletions benchmarks/suiteRun.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ import { Bench } from 'tinybench';
const __dirname = dirname(fileURLToPath(import.meta.url));
const fixturesRoot = resolve(__dirname, 'fixtures');

const { initCli, createRstest } = await import('@rstest/core');
const { createRstest } = await import('@rstest/core/api');

const benchmarkOptions = {
reporter: [],
reporters: [],
};

const bench = withCodSpeed(
Expand All @@ -31,38 +31,25 @@ const bench = withCodSpeed(
}),
);

async function runFixture(fixtureName) {
const { config, configFilePath, projects } = await initCli({
root: resolve(fixturesRoot, fixtureName),
...benchmarkOptions,
// Create one reusable instance per fixture up front so the eager creation build
// is amortized out of the measured iterations; each iteration then measures a
// single host-safe `run()`, which resolves a structured result instead of
// setting `process.exitCode` (no per-iteration exit-code reset needed).
const fixtureNames = ['compile', 'runner', 'integration'];
for (const fixtureName of fixtureNames) {
const instance = await createRstest({
config: { root: resolve(fixturesRoot, fixtureName), ...benchmarkOptions },
});

const rstest = createRstest({ config, configFilePath, projects }, 'run', []);
await rstest.runTests();
bench.add(fixtureName, async () => {
const result = await instance.run();

if (process.exitCode && process.exitCode !== 0) {
throw new Error(
`CPU benchmark fixture "${fixtureName}" failed with exit code ${process.exitCode}`,
);
}

// Reset process.exitCode between iterations because runTests() uses it to
// signal failures to the CLI.
process.exitCode = undefined;
if (!result.ok) {
throw new Error(`CPU benchmark fixture "${fixtureName}" failed`);
}
});
}

bench.add('compile', async () => {
await runFixture('compile');
});

bench.add('runner', async () => {
await runFixture('runner');
});

bench.add('integration', async () => {
await runFixture('integration');
});

await bench.run();

const failedTask = bench.tasks.find((task) => task.result?.error);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineConfig } from '@rstest/core';

// `buildDependencies` uses a config-relative path so the test can prove the
// resolved path is anchored at this file's directory (nested/), not the run root.
export default defineConfig({
reporters: [],
performance: {
buildCache: {
buildDependencies: ['./extra.js'],
},
},
});
5 changes: 5 additions & 0 deletions e2e/programmatic/fixtures/config-fn/a.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { expect, it } from '@rstest/core';

it('a passes', () => {
expect(1).toBe(1);
});
5 changes: 5 additions & 0 deletions e2e/programmatic/fixtures/config-fn/b.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { expect, it } from '@rstest/core';

it('b passes', () => {
expect(2).toBe(2);
});
8 changes: 8 additions & 0 deletions e2e/programmatic/fixtures/config-fn/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from '@rstest/core';

// Disk config includes BOTH test files; the programmatic `config` callback
// receives this resolved object and narrows `include` to a single file.
export default defineConfig({
include: ['a.test.ts', 'b.test.ts'],
reporters: [],
});
8 changes: 8 additions & 0 deletions e2e/programmatic/fixtures/coverage-threshold/partial.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { expect, it } from '@rstest/core';
import { classify } from './src';

// Exercises only the positive branch, so `src.ts` stays below a 100% threshold
// while the test itself passes.
it('covers only the positive branch', () => {
expect(classify(1)).toBe('positive');
});
2 changes: 2 additions & 0 deletions e2e/programmatic/fixtures/coverage-threshold/src.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const classify = (n: number): string =>
n > 0 ? 'positive' : 'non-positive';
41 changes: 41 additions & 0 deletions e2e/programmatic/fixtures/create-context-reporters.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRstest } from '@rstest/core/api';

const __dirname = dirname(fileURLToPath(import.meta.url));
const cwd = join(__dirname, 'disk');

// The eager creation build must not clobber the resolved reporter config: a
// caller inspecting `context` before any run should still see the reporters it
// configured, not an empty list.
const rstest = await createRstest({
cwd,
config: {
include: ['sum.test.ts'],
reporters: ['dot'],
},
});

const rootReporters = rstest.context.normalizedConfig.reporters;
const projectNames = rstest.context.projects.map((project) => project.name);

// `context` is a plain projection of the internal engine context, so it must be
// structured-clonable. The raw engine object would throw a DataCloneError on its
// reporter functions / manager instances — this is the observable contract of
// the projection.
let cloneOk = false;
try {
structuredClone(rstest.context);
cloneOk = true;
} catch {
// cloneOk stays false
}

console.log(
`__RSTEST_API_RESULT__${JSON.stringify({
rootReporters,
projectNames,
cloneOk,
hostExitCode: process.exitCode ?? 0,
})}__END__`,
);
12 changes: 12 additions & 0 deletions e2e/programmatic/fixtures/extends-once/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineConfig } from '@rstest/core';

// The preset contributes a setupFiles entry. `loadConfig` already resolves this
// `extends` on disk; the programmatic `config` factory then resolves the same
// object again — which must NOT re-apply the preset and duplicate setup.ts.
export default defineConfig({
extends: {
setupFiles: ['./setup.ts'],
},
include: ['setup-count.test.ts'],
reporters: [],
});
6 changes: 6 additions & 0 deletions e2e/programmatic/fixtures/extends-once/setup-count.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { expect, it } from '@rstest/core';

it('runs the extends-contributed setup file exactly once', () => {
const g = globalThis as { __SETUP_COUNT__?: number };
expect(g.__SETUP_COUNT__).toBe(1);
});
5 changes: 5 additions & 0 deletions e2e/programmatic/fixtures/extends-once/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Each time this setup file runs it bumps a per-worker counter. If `extends`
// were applied twice (duplicating this entry in `setupFiles`), the file would
// be listed — and executed — twice, and the counter would read 2.
const g = globalThis as { __SETUP_COUNT__?: number };
g.__SETUP_COUNT__ = (g.__SETUP_COUNT__ ?? 0) + 1;
5 changes: 5 additions & 0 deletions e2e/programmatic/fixtures/merge-reports/fail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { expect, it } from '@rstest/core';

it('fails on purpose', () => {
expect(1 + 1).toBe(3);
});
5 changes: 5 additions & 0 deletions e2e/programmatic/fixtures/merge-reports/pass.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { expect, it } from '@rstest/core';

it('passes', () => {
expect(1 + 1).toBe(2);
});
8 changes: 8 additions & 0 deletions e2e/programmatic/fixtures/related/math.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { describe, expect, it } from '@rstest/core';
import { add } from './src/math';

describe('math', () => {
it('adds', () => {
expect(add(1, 2)).toBe(3);
});
});
6 changes: 6 additions & 0 deletions e2e/programmatic/fixtures/related/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineConfig } from '@rstest/core';

export default defineConfig({
include: ['**/*.test.ts'],
reporters: [],
});
1 change: 1 addition & 0 deletions e2e/programmatic/fixtures/related/src/math.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const add = (a: number, b: number): number => a + b;
7 changes: 7 additions & 0 deletions e2e/programmatic/fixtures/related/unrelated.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { describe, expect, it } from '@rstest/core';

describe('unrelated', () => {
it('does not import the math source', () => {
expect(true).toBe(true);
});
});
34 changes: 34 additions & 0 deletions e2e/programmatic/fixtures/run-config-file-buildcache.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
// `pathe.resolve` emits forward slashes on every platform — the same form
// Rstest normalizes resolved buildDependency paths to — so the assertion below
// compares like-for-like on Windows too.
import { resolve } from 'pathe';
import { loadConfig } from '@rstest/core';
import { createRstest } from '@rstest/core/api';

const __dirname = dirname(fileURLToPath(import.meta.url));
const cwd = join(__dirname, 'config-file-buildcache');
// The config lives in a nested dir so its directory differs from the run root
// (cwd). `loadConfig` records the resolved path on the config it returns, and
// returning that config carries it into `createRstest` — no explicit option.
const configPath = join('nested', 'rstest.config.ts');

const rstest = await createRstest({
cwd,
config: async () => (await loadConfig({ cwd, path: configPath })).content,
});

const buildCache = rstest.context.normalizedConfig.performance?.buildCache;
const deps =
buildCache && buildCache !== true ? buildCache.buildDependencies : undefined;

console.log(
`__RSTEST_API_RESULT__${JSON.stringify({
// The config-relative `./extra.js` must resolve against the config file's
// directory (nested/), proving the config file path threaded through.
resolvedDep: deps?.find((d) => d.endsWith('extra.js')) ?? null,
expected: resolve(cwd, 'nested', 'extra.js'),
hostExitCode: process.exitCode ?? 0,
})}__END__`,
);
35 changes: 35 additions & 0 deletions e2e/programmatic/fixtures/run-config-fn.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig } from '@rstest/core';
import { createRstest } from '@rstest/core/api';

const __dirname = dirname(fileURLToPath(import.meta.url));
const cwd = join(__dirname, 'config-fn');

// Function-form `config`: a zero-arg factory that owns config loading. It reads
// the disk config itself via `loadConfig` (the programmatic API never reads one
// for you), then transforms it — here narrowing `include` from two files to one.
const rstest = await createRstest({
cwd,
config: async () => {
const { content } = await loadConfig({ cwd, path: 'rstest.config.ts' });
// Prove the disk config was loaded before transforming it.
if (!content.include?.includes('b.test.ts')) {
throw new Error(
`config factory did not load the disk config; got include=${JSON.stringify(content.include)}`,
);
}
return { ...content, include: ['a.test.ts'] };
},
});
const result = await rstest.run();

console.log(
`__RSTEST_API_RESULT__${JSON.stringify({
ok: result.ok,
stats: result.stats,
files: result.files
.map((f) => ({ status: f.status, testPath: f.testPath.split('/').pop() }))
.sort((a, b) => a.testPath.localeCompare(b.testPath)),
})}__END__`,
);
37 changes: 37 additions & 0 deletions e2e/programmatic/fixtures/run-coverage-threshold.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRstest } from '@rstest/core/api';

const __dirname = dirname(fileURLToPath(import.meta.url));
const cwd = join(__dirname, 'coverage-threshold');

const rstest = await createRstest({
cwd,
config: {
include: ['*.test.ts'],
reporters: [],
coverage: {
enabled: true,
provider: 'v8',
reporters: [],
// The single test covers only one branch, so a 100% threshold can never
// be met — coverage fails while every test passes.
thresholds: {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
},
},
},
});
const result = await rstest.run();

console.log(
`__RSTEST_API_RESULT__${JSON.stringify({
ok: result.ok,
stats: result.stats,
// run() restores the host exit code the coverage failure set during the run.
hostExitCode: process.exitCode ?? 0,
})}__END__`,
);
Loading
Loading