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
9 changes: 5 additions & 4 deletions packages/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ The extension activates automatically when your workspace contains Rstest config

## Configuration

| Setting | Type | Default | Description |
| ------------------------------ | -------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `rstest.rstestPackagePath` | string | `undefined` | The path to a `package.json` file of a Rstest executable (it's usually inside `node_modules`) in case the extension cannot find it. It will be used to resolve Rstest API paths. This should be used as a last resort fix. Supports `${workspaceFolder}` placeholder. |
| `rstest.configFileGlobPattern` | string[] | `["**/rstest.config.{mjs,ts,js,cjs,mts,cts}"]` | Glob patterns used to discover config files. |
| Setting | Type | Default | Description |
| ------------------------------ | -------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `rstest.rstestPackagePath` | `string` | `undefined` | The path to a `package.json` file of a Rstest executable (it's usually inside `node_modules`) in case the extension cannot find it. It will be used to resolve Rstest API paths. This should be used as a last resort fix. Supports `${workspaceFolder}` placeholder. |
| `rstest.configFileGlobPattern` | `string[]` | `["**/rstest.config.{mjs,ts,js,cjs,mts,cts}"]` | Glob patterns used to discover config files. |
| `rstest.testCaseCollectMethod` | `"ast" \| "runtime"` | `"ast"` | `"ast"`: Fast, only supports basic test cases. <br /> `"runtime"`: Slow, supports all test cases, including dynamic test generation methods (each/for/extend). |

## How it works

Expand Down
16 changes: 16 additions & 0 deletions packages/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@
"default": [
"**/rstest.config.{mjs,ts,js,cjs,mts,cts}"
]
},
"rstest.testCaseCollectMethod": {
"type": "string",
"default": "ast",
"enum": [
"ast",
"runtime"
],
"enumItemLabels": [
"Static AST Analyze",
"Run Test File"
],
"enumDescriptions": [
"Fast, only supports basic test cases.",
"Slow, supports all test cases, including dynamic test generation methods (each/for/extend)."
]
}
}
},
Expand Down
4 changes: 4 additions & 0 deletions packages/vscode/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const configSchema = v.object({
configFileGlobPattern: v.fallback(v.array(v.string()), [
'**/rstest.config.{mjs,ts,js,cjs,mts,cts}',
]),
testCaseCollectMethod: v.fallback(
v.union([v.literal('ast'), v.literal('runtime')]),
'ast',
),
});

export type ExtensionConfig = v.InferOutput<typeof configSchema>;
Expand Down
13 changes: 13 additions & 0 deletions packages/vscode/src/master.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,18 @@ export class RstestApi {
return config;
}

public async listTests(include?: string[]) {
const worker = await this.createChildProcess();
const tests = await worker.listTests({
rstestPath: this.resolveRstestPath(),
configFilePath: this.configFilePath,
include,
includeTaskLocation: true,
});
worker.$close();
return tests;
}

public async runTest({
run,
token,
Expand Down Expand Up @@ -160,6 +172,7 @@ export class RstestApi {
kind === vscode.TestRunProfileKind.Coverage
? { enabled: true }
: undefined,
includeTaskLocation: true,
})
.finally(() => {
worker.$close();
Expand Down
151 changes: 100 additions & 51 deletions packages/vscode/src/project.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from 'node:path';
import type { TestInfo } from '@rstest/core';
import picomatch from 'picomatch';
import { glob } from 'tinyglobby';
import * as vscode from 'vscode';
Expand Down Expand Up @@ -212,68 +213,116 @@ export class Project implements vscode.Disposable {
return matchInclude(relativePath) && !matchExclude(relativePath);
};

const files = await glob(this.include, {
cwd: root.fsPath,
ignore: this.exclude,
absolute: true,
dot: true,
expandDirectories: false,
}).then((files) => files.map((file) => vscode.Uri.file(file)));
const watcher = watchConfigValue(
'testCaseCollectMethod',
this.workspaceFolder,
async (method, token) => {
if (this.testItem) {
this.testItem.busy = true;
}
const files: { uri: vscode.Uri; tests?: TestInfo[] }[] =
method === 'ast'
? // ast
await glob(this.include, {
cwd: root.fsPath,
ignore: this.exclude,
absolute: true,
dot: true,
expandDirectories: false,
}).then((files) =>
files.map((file) => ({ uri: vscode.Uri.file(file) })),
)
: // runtime
await this.api.listTests().then((files) =>
files.map((file) => ({
uri: vscode.Uri.file(file.testPath),
tests: file.tests,
})),
);

if (this.cancellationSource.token.isCancellationRequested) return;
if (token.isCancellationRequested) return;

const visited = new Set<string>();
for (const uri of files) {
if (matchExclude(uri.fsPath)) continue;
this.updateOrCreateFile(uri);
visited.add(uri.toString());
}
if (this.testItem) {
this.testItem.busy = false;
}

// remove outdated items after glob configuration changed
for (const file of this.testFiles.keys()) {
if (!visited.has(file)) {
this.testFiles.delete(file);
}
}
this.buildTree();
const visited = new Set<string>();
for (const { uri, tests } of files) {
this.updateOrCreateFile(uri, tests);
visited.add(uri.toString());
}

// remove outdated items after glob configuration changed
for (const file of this.testFiles.keys()) {
if (!visited.has(file)) {
this.testFiles.delete(file);
}
}
this.buildTree();

// start watching test file change
// while createFileSystemWatcher don't support same glob syntax with tinyglobby
// we can watch all files and filter with picomatch later
const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(root, '**'),
);
token.onCancellationRequested(() => watcher.dispose());

// TODO delay and batch run multiple files
const updateOrCreateByRuntime = (uri: vscode.Uri) => {
this.api.listTests([uri.fsPath]).then((files) => {
if (token.isCancellationRequested) return;
for (const { testPath, tests } of files) {
const uri = vscode.Uri.file(testPath);
this.updateOrCreateFile(uri, tests);
}
this.buildTree();
});
};

// start watching test file change
// while createFileSystemWatcher don't support same glob syntax with tinyglobby
// we can watch all files and filter with picomatch later
const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(root, '**'),
watcher.onDidCreate((uri) => {
if (isInclude(uri)) {
if (method === 'ast') {
this.updateOrCreateFile(uri);
this.buildTree();
} else {
updateOrCreateByRuntime(uri);
}
}
});
watcher.onDidChange((uri) => {
if (isInclude(uri)) {
if (method === 'ast') {
this.updateOrCreateFile(uri);
this.buildTree();
} else {
updateOrCreateByRuntime(uri);
}
}
});
watcher.onDidDelete((uri) => {
if (isInclude(uri)) {
this.testFiles.delete(uri.toString());
this.buildTree();
}
});
},
);
this.cancellationSource.token.onCancellationRequested(() =>
watcher.dispose(),
);
watcher.onDidCreate((uri) => {
if (isInclude(uri)) {
this.updateOrCreateFile(uri);
this.buildTree();
}
});
watcher.onDidChange((uri) => {
if (isInclude(uri)) {
this.updateOrCreateFile(uri);
this.buildTree();
}
});
watcher.onDidDelete((uri) => {
if (isInclude(uri)) {
this.testFiles.delete(uri.toString());
this.buildTree();
}
});
}
// TODO pass cancellation token to updateFromDisk
private updateOrCreateFile(uri: vscode.Uri) {
const existing = this.testFiles.get(uri.toString());
if (existing) {
existing.updateFromDisk(this.testController);
} else {
const data = new TestFile(this.api, uri);
private updateOrCreateFile(uri: vscode.Uri, tests?: TestInfo[]) {
let data = this.testFiles.get(uri.toString());
if (!data) {
data = new TestFile(this.api, uri, this.testController);
this.testFiles.set(uri.toString(), data);
data.updateFromDisk(this.testController);
}
if (tests) {
data.updateFromList(tests);
} else {
data.updateFromDisk();
}
}

Expand Down
12 changes: 12 additions & 0 deletions packages/vscode/src/testRunReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { parseErrorStacktrace } from '../../core/src/utils/error';
import { logger } from './logger';
import type { Project } from './project';
import type { LogLevel } from './shared/logger';
import { TestFile, testData } from './testTree';

export class TestRunReporter implements Reporter {
constructor(
Expand Down Expand Up @@ -61,6 +62,17 @@ export class TestRunReporter implements Reporter {

this.run?.started(fileItem);
}
onTestFileReady(test: TestFileInfo) {
const fileTestItem = this.project?.testFiles.get(
vscode.Uri.file(test.testPath).toString(),
)?.testItem;
if (fileTestItem) {
const data = testData.get(fileTestItem);
if (data instanceof TestFile) {
data.updateFromList(test.tests);
}
}
}
onTestFileResult(test: TestFileResult) {
// only update test file result when explicit run itself or parent
if (this.path.length) return;
Expand Down
Loading
Loading