Vscode test runner support (#20645)

This commit is contained in:
Michael H
2025-07-14 14:57:44 +10:00
committed by GitHub
parent 499eac0d49
commit 8898c4c455
23 changed files with 2301 additions and 561 deletions

View File

@@ -168,4 +168,5 @@
"WebKit/WebInspectorUI": true,
},
"git.detectSubmodules": false,
"bun.test.customScript": "bun-debug test"
}

View File

@@ -2,7 +2,7 @@ option(WEBKIT_VERSION "The version of WebKit to use")
option(WEBKIT_LOCAL "If a local version of WebKit should be used instead of downloading")
if(NOT WEBKIT_VERSION)
set(WEBKIT_VERSION 9141ee4897bffa8dd020b3ac33fa81d8081d6827)
set(WEBKIT_VERSION 1098cc50652ab1eab171f58f7669e19ca6c276ae)
endif()
string(SUBSTRING ${WEBKIT_VERSION} 0 16 WEBKIT_VERSION_PREFIX)

View File

@@ -107,6 +107,8 @@ type InitializeRequest = DAP.InitializeRequest & {
supportsConfigurationDoneRequest?: boolean;
enableControlFlowProfiler?: boolean;
enableDebugger?: boolean;
enableTestReporter?: boolean;
enableConsole?: boolean | true;
} & (
| {
enableLifecycleAgentReporter?: false;
@@ -459,7 +461,10 @@ export abstract class BaseDebugAdapter<T extends Inspector = Inspector>
this.send("Inspector.enable");
this.send("Runtime.enable");
if (request.enableConsole ?? true) {
this.send("Console.enable");
}
if (request.enableControlFlowProfiler) {
this.send("Runtime.enableControlFlowProfiler");
@@ -473,6 +478,10 @@ export abstract class BaseDebugAdapter<T extends Inspector = Inspector>
}
}
if (request.enableTestReporter) {
this.send("TestReporter.enable");
}
// use !== false because by default if unspecified we want to enable the debugger
// and this option didn't exist beforehand, so we can't make it non-optional
if (request.enableDebugger !== false) {

View File

@@ -2507,7 +2507,8 @@ export namespace JSC {
export type StopTrackingResponse = {};
}
export namespace TestReporter {
export type TestStatus = "pass" | "fail" | "timeout" | "skip" | "todo";
export type TestStatus = "pass" | "fail" | "timeout" | "skip" | "todo" | "skipped_because_label";
export type TestType = "test" | "describe";
/**
* undefined
* @event `TestReporter.found`
@@ -2533,6 +2534,14 @@ export namespace JSC {
* Name of the test that started.
*/
name?: string | undefined;
/**
* Type of the item found (test or describe block).
*/
type?: TestType | undefined;
/**
* ID of the parent describe block, if any.
*/
parentId?: number | undefined;
};
/**
* undefined

View File

@@ -3014,7 +3014,12 @@
{
"id": "TestStatus",
"type": "string",
"enum": ["pass", "fail", "timeout", "skip", "todo"]
"enum": ["pass", "fail", "timeout", "skip", "todo", "skipped_because_label"]
},
{
"id": "TestType",
"type": "string",
"enum": ["test", "describe"]
}
],
"commands": [
@@ -3058,6 +3063,18 @@
"type": "string",
"description": "Name of the test that started.",
"optional": true
},
{
"name": "type",
"$ref": "TestType",
"description": "Type of the item found (test or describe block).",
"optional": true
},
{
"name": "parentId",
"type": "integer",
"description": "ID of the parent describe block, if any.",
"optional": true
}
]
},

View File

@@ -116,6 +116,9 @@ You can use the following configurations to customize the behavior of the Bun ex
"bun.debugTerminal.stopOnEntry": false,
// Glob pattern to find test files. Defaults to the value shown below.
"bun.test.filePattern": "**/*{.test.,.spec.,_test_,_spec_}{js,ts,tsx,jsx,mts,cts}",
"bun.test.filePattern": "**/*{.test.,.spec.,_test_,_spec_}{js,ts,tsx,jsx,mts,cts,cjs,mjs}",
// The custom script to call for testing instead of `bun test`
"bun.test.customScript": "bun test",
}
```

View File

@@ -102,6 +102,8 @@
"@types/ws": ["@types/ws@8.5.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ=="],
"@types/xml2js": ["@types/xml2js@0.4.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ=="],
"@vscode/debugadapter": ["@vscode/debugadapter@1.61.0", "", { "dependencies": { "@vscode/debugprotocol": "1.61.0" } }, "sha512-VDGLUFDVAdnftUebZe4uQCIFUbJ7rTc2Grps4D/CXl+qyzTZSQLv5VADEOZ6kBYG4SvlnMLql5vPQ0G6XvUCvQ=="],
"@vscode/debugadapter-testsupport": ["@vscode/debugadapter-testsupport@1.61.0", "", { "dependencies": { "@vscode/debugprotocol": "1.61.0" } }, "sha512-M/8aNX1aFvupd+SP0NLEVLKUK9y52BuCK5vKO2gzdpSoRUR2fR8oFbGkTie+/p2Yrcswnuf7hFx0xWkV9avRdg=="],

View File

@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test";
// Simple passing test
test("adds numbers correctly", () => {
expect(1 + 2).toBe(3);
});
// Simple failing test
test("subtracts numbers incorrectly", () => {
expect(5 - 2).toBe(10); // This will fail
});
describe("isEmail", () => {
test("valid emails", () => {
expect(isEmail("test@example.com")).toBe(true);
expect(isEmail("foo.bar@domain.co")).toBe(true);
});
test("invalid emails", () => {
expect(isEmail("not-an-email")).toBe(false);
expect(isEmail("missing@at")).toBe(true);
});
});
// Nested describe
describe("Array utilities", () => {
function sum(arr: number[]): number {
return arr.reduce((a, b) => a + b, 0);
}
// describe()
describe("sum()", () => {
test(
"sums positive numbers",
async () => {
await Bun.sleep(10000);
expect(sum([1, 2, 3])).toBe(7);
},
{ timeout: 10 },
); // Custom timeout
test.skip("sums negative numbers", () => {
expect(sum([-1, -2, -3])).toBe(-6);
});
test("empty array returns 0", () => {
expect(sum([])).toBe(0);
});
});
});
// test.each example
describe("multiply", () => {
function multiply(a: number, b: number) {
return a * b;
}
test.each([
[2, 3, 6],
[0, 5, 0],
[-1, 8, -8],
[7, -2, -14],
[2, 2, 5],
])("multiply(%i, %i) === %i", (a, b, expected) => {
expect(multiply(a, b)).toBe(expected);
});
});
function isEmail(str: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
}

View File

@@ -1,6 +1,6 @@
{
"name": "bun-vscode",
"version": "0.0.25",
"version": "0.0.29",
"author": "oven",
"repository": {
"type": "git",
@@ -10,13 +10,15 @@
"devDependencies": {
"@types/bun": "^1.1.10",
"@types/vscode": "^1.60.0",
"@types/xml2js": "^0.4.14",
"@vscode/debugadapter": "^1.56.0",
"@vscode/debugadapter-testsupport": "^1.56.0",
"@vscode/test-cli": "^0.0.10",
"@vscode/test-electron": "^2.4.1",
"@vscode/vsce": "^2.20.1",
"esbuild": "^0.19.2",
"typescript": "^5.0.0"
"typescript": "^5.0.0",
"xml2js": "^0.6.2"
},
"activationEvents": [
"onStartupFinished"

View File

@@ -4,7 +4,7 @@ import { registerDiagnosticsSocket } from "./features/diagnostics/diagnostics";
import { registerBunlockEditor } from "./features/lockfile";
import { registerPackageJsonProviders } from "./features/tasks/package.json";
import { registerTaskProvider } from "./features/tasks/tasks";
import { registerTestCodeLens, registerTestRunner } from "./features/tests";
import { registerTests } from "./features/tests";
async function runUnsavedCode() {
const editor = vscode.window.activeTextEditor;
@@ -47,8 +47,7 @@ export function activate(context: vscode.ExtensionContext) {
registerTaskProvider(context);
registerPackageJsonProviders(context);
registerDiagnosticsSocket(context);
registerTestRunner(context);
registerTestCodeLens(context);
registerTests(context);
// Only register for text editors
context.subscriptions.push(vscode.commands.registerTextEditorCommand("extension.bun.runUnsavedCode", runUnsavedCode));

File diff suppressed because it is too large Load Diff

View File

@@ -1,215 +1,23 @@
import ts from "typescript";
import * as vscode from "vscode";
import { BunTestController, debug } from "./bun-test-controller";
/**
* Find all matching test via ts AST
*/
function findTests(document: vscode.TextDocument): Array<{ name: string; range: vscode.Range }> {
const sourceFile = ts.createSourceFile(document.fileName, document.getText(), ts.ScriptTarget.Latest, true);
const tests: Array<{ name: string; range: vscode.Range }> = [];
// Visit all nodes in the AST
function visit(node: ts.Node) {
if (ts.isCallExpression(node)) {
const expressionText = node.expression.getText(sourceFile);
// Check if the expression is a test function
const isTest = expressionText === "test" || expressionText === "describe" || expressionText === "it";
if (!isTest) {
export async function registerTests(context: vscode.ExtensionContext) {
const workspaceFolder = (vscode.workspace.workspaceFolders || [])[0];
if (!workspaceFolder) {
return;
}
// Get the test name from the first argument
const testName = node.arguments[0] && ts.isStringLiteral(node.arguments[0]) ? node.arguments[0].text : null;
if (!testName) {
return;
}
try {
const controller = vscode.tests.createTestController("bun-tests", "Bun Tests");
context.subscriptions.push(controller);
// Get the range of the test function for the CodeLens
const start = document.positionAt(node.getStart());
const end = document.positionAt(node.getEnd());
const range = new vscode.Range(start, end);
tests.push({ name: testName, range });
}
ts.forEachChild(node, visit);
}
const bunTestController = new BunTestController(controller, workspaceFolder);
visit(sourceFile);
return tests;
}
/**
* This class provides CodeLens for test functions in the editor - find all tests in current document and provide CodeLens for them.
* It finds all test functions in the current document and provides CodeLens for them (Run Test, Watch Test buttons).
*/
class TestCodeLensProvider implements vscode.CodeLensProvider {
public provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] {
const codeLenses: vscode.CodeLens[] = [];
const tests = findTests(document);
for (const test of tests) {
const runTestCommand = {
title: "Run Test",
command: "extension.bun.runTest",
arguments: [document.fileName, test.name],
};
const watchTestCommand = {
title: "Watch Test",
command: "extension.bun.watchTest",
arguments: [document.fileName, test.name],
};
codeLenses.push(new vscode.CodeLens(test.range, runTestCommand));
codeLenses.push(new vscode.CodeLens(test.range, watchTestCommand));
}
return codeLenses;
context.subscriptions.push(bunTestController);
} catch (error) {
debug.appendLine(`Error initializing Bun Test Controller: ${error}`);
vscode.window.showErrorMessage(
"Failed to initialize Bun Test Explorer. You may need to update VS Code to version 1.59 or later.",
);
}
}
// default file pattern to search for tests
const DEFAULT_FILE_PATTERN = "**/*{.test.,.spec.,_test_,_spec_}{js,ts,tsx,jsx,mts,cts}";
/**
* This function registers a CodeLens provider for test files. It is used to display the "Run" and "Watch" buttons.
*/
export function registerTestCodeLens(context: vscode.ExtensionContext) {
const codeLensProvider = new TestCodeLensProvider();
// Get the user-defined file pattern from the settings, or use the default
// Setting is:
// bun.test.filePattern
const pattern = vscode.workspace.getConfiguration("bun.test").get("filePattern", DEFAULT_FILE_PATTERN);
const options = { scheme: "file", pattern };
context.subscriptions.push(
vscode.languages.registerCodeLensProvider({ ...options, language: "javascript" }, codeLensProvider),
);
context.subscriptions.push(
vscode.languages.registerCodeLensProvider({ ...options, language: "typescript" }, codeLensProvider),
);
context.subscriptions.push(
vscode.languages.registerCodeLensProvider({ ...options, language: "javascriptreact" }, codeLensProvider),
);
context.subscriptions.push(
vscode.languages.registerCodeLensProvider({ ...options, language: "typescriptreact" }, codeLensProvider),
);
}
// Tracking only one active terminal, so there will be only one terminal running at a time.
// Example: when user clicks "Run Test" button, the previous terminal will be disposed.
let activeTerminal: vscode.Terminal | null = null;
/**
* This function registers the test runner commands.
*/
export function registerTestRunner(context: vscode.ExtensionContext) {
// Register the "Run Test" command
const runTestCommand = vscode.commands.registerCommand(
"extension.bun.runTest",
async (filePath?: string, testName?: string, isWatchMode: boolean = false) => {
// Get custom flag
const customFlag = vscode.workspace.getConfiguration("bun.test").get("customFlag", "").trim();
const customScriptSetting = vscode.workspace.getConfiguration("bun.test").get("customScript", "bun test").trim();
const customScript = customScriptSetting.length ? customScriptSetting : "bun test";
// When this command is called from the command palette, the fileName and testName arguments are not passed (commands in package.json)
// so then fileName is taken from the active text editor and it run for the whole file.
if (!filePath) {
const editor = vscode.window.activeTextEditor;
if (!editor) {
await vscode.window.showErrorMessage("No active editor to run tests in");
return;
}
filePath = editor.document.fileName;
}
// Detect if along file path there is package.json, like in mono-repo, if so, then switch to that directory
const packageJsonPaths = await vscode.workspace.findFiles("**/package.json");
// Sort by length, so the longest path is first, so we can switch to the deepest directory
const packagesRootPaths = packageJsonPaths
.map(uri => uri.fsPath.replace("/package.json", ""))
.sort((a, b) => b.length - a.length);
const packageJsonPath: string | undefined = packagesRootPaths.find(path => filePath.includes(path));
if (activeTerminal) {
activeTerminal.dispose();
activeTerminal = null;
}
const cwd = packageJsonPath ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd();
const message = isWatchMode
? `Watching \x1b[1m\x1b[32m${testName ?? filePath}\x1b[0m test`
: `Running \x1b[1m\x1b[32m${testName ?? filePath}\x1b[0m test`;
const terminalOptions: vscode.TerminalOptions = {
cwd,
name: "Bun Test Runner",
location: vscode.TerminalLocation.Panel,
message,
hideFromUser: true,
};
activeTerminal = vscode.window.createTerminal(terminalOptions);
activeTerminal.show();
let command = customScript;
if (filePath.length !== 0) {
command += ` "${filePath}"`;
}
if (testName && testName.length) {
const escapedTestName = escapeRegex(testName);
if (customScriptSetting.length) {
// escape the quotes in the test name
command += ` -t "${escapedTestName}"`;
} else {
command += ` -t "${escapedTestName}"`;
}
}
if (isWatchMode) {
command += ` --watch`;
}
if (customFlag.length) {
command += ` ${customFlag}`;
}
activeTerminal.sendText(command);
},
);
// Register the "Watch Test" command, which just calls the "Run Test" command with the watch flag
const watchTestCommand = vscode.commands.registerCommand(
"extension.bun.watchTest",
async (fileName?: string, testName?: string) => {
vscode.commands.executeCommand("extension.bun.runTest", fileName, testName, true);
},
);
context.subscriptions.push(runTestCommand);
context.subscriptions.push(watchTestCommand);
}
/**
* Escape any special characters in the input string, so that regex-matching on it
* will work as expected.
* i.e `new RegExp(escapeRegex("hi (:").test("hi (:")` will return true, instead of throwing
* an invalid regex error.
*/
function escapeRegex(source: string) {
return source.replaceAll(/[^a-zA-Z0-9_+\-'"\ ]/g, "\\$&");
}

View File

@@ -1,4 +1,4 @@
import { ExtensionContext } from "vscode";
import type { ExtensionContext } from "vscode";
export const GLOBAL_STATE_VERSION = 1;

View File

@@ -297,14 +297,21 @@ pub const TestReporterAgent = struct {
timeout,
skip,
todo,
skipped_because_label,
};
pub const TestType = enum(u8) {
@"test" = 0,
describe = 1,
};
pub const Handle = opaque {
extern "c" fn Bun__TestReporterAgentReportTestFound(agent: *Handle, callFrame: *jsc.CallFrame, testId: c_int, name: *bun.String) void;
extern "c" fn Bun__TestReporterAgentReportTestFound(agent: *Handle, callFrame: *jsc.CallFrame, testId: c_int, name: *bun.String, item_type: TestType, parentId: c_int) void;
extern "c" fn Bun__TestReporterAgentReportTestStart(agent: *Handle, testId: c_int) void;
extern "c" fn Bun__TestReporterAgentReportTestEnd(agent: *Handle, testId: c_int, bunTestStatus: TestStatus, elapsed: f64) void;
pub fn reportTestFound(this: *Handle, callFrame: *jsc.CallFrame, testId: i32, name: *bun.String) void {
Bun__TestReporterAgentReportTestFound(this, callFrame, testId, name);
pub fn reportTestFound(this: *Handle, callFrame: *jsc.CallFrame, testId: i32, name: *bun.String, item_type: TestType, parentId: i32) void {
Bun__TestReporterAgentReportTestFound(this, callFrame, testId, name, item_type, parentId);
}
pub fn reportTestStart(this: *Handle, testId: c_int) void {
@@ -331,10 +338,10 @@ pub const TestReporterAgent = struct {
/// Caller must ensure that it is enabled first.
///
/// Since we may have to call .deinit on the name string.
pub fn reportTestFound(this: TestReporterAgent, callFrame: *jsc.CallFrame, test_id: i32, name: *bun.String) void {
pub fn reportTestFound(this: TestReporterAgent, callFrame: *jsc.CallFrame, test_id: i32, name: *bun.String, item_type: TestType, parentId: i32) void {
debug("reportTestFound", .{});
this.handle.?.reportTestFound(callFrame, test_id, name);
this.handle.?.reportTestFound(callFrame, test_id, name, item_type, parentId);
}
/// Caller must ensure that it is enabled first.

View File

@@ -13,6 +13,9 @@
#include "ModuleLoader.h"
#include <wtf/TZoneMallocInlines.h>
using namespace JSC;
using namespace Inspector;
namespace Inspector {
WTF_MAKE_TZONE_ALLOCATED_IMPL(InspectorTestReporterAgent);
@@ -23,10 +26,28 @@ extern "C" {
void Bun__TestReporterAgentEnable(Inspector::InspectorTestReporterAgent* agent);
void Bun__TestReporterAgentDisable(Inspector::InspectorTestReporterAgent* agent);
void Bun__TestReporterAgentReportTestFound(Inspector::InspectorTestReporterAgent* agent, JSC::CallFrame* callFrame, int testId, BunString* name)
enum class BunTestType : uint8_t {
Test,
Describe,
};
void Bun__TestReporterAgentReportTestFound(Inspector::InspectorTestReporterAgent* agent, JSC::CallFrame* callFrame, int testId, BunString* name, BunTestType item_type, int parentId)
{
auto str = name->toWTFString(BunString::ZeroCopy);
agent->reportTestFound(callFrame, testId, str);
Protocol::TestReporter::TestType type;
switch (item_type) {
case BunTestType::Test:
type = Protocol::TestReporter::TestType::Test;
break;
case BunTestType::Describe:
type = Protocol::TestReporter::TestType::Describe;
break;
default:
ASSERT_NOT_REACHED();
}
agent->reportTestFound(callFrame, testId, str, type, parentId);
}
void Bun__TestReporterAgentReportTestStart(Inspector::InspectorTestReporterAgent* agent, int testId)
@@ -40,6 +61,7 @@ enum class BunTestStatus : uint8_t {
Timeout,
Skip,
Todo,
SkippedBecauseLabel,
};
void Bun__TestReporterAgentReportTestEnd(Inspector::InspectorTestReporterAgent* agent, int testId, BunTestStatus bunTestStatus, double elapsed)
@@ -61,9 +83,13 @@ void Bun__TestReporterAgentReportTestEnd(Inspector::InspectorTestReporterAgent*
case BunTestStatus::Todo:
status = Protocol::TestReporter::TestStatus::Todo;
break;
case BunTestStatus::SkippedBecauseLabel:
status = Protocol::TestReporter::TestStatus::Skipped_because_label;
break;
default:
ASSERT_NOT_REACHED();
}
agent->reportTestEnd(testId, status, elapsed);
}
}
@@ -112,7 +138,7 @@ Protocol::ErrorStringOr<void> InspectorTestReporterAgent::disable()
return {};
}
void InspectorTestReporterAgent::reportTestFound(JSC::CallFrame* callFrame, int testId, const String& name)
void InspectorTestReporterAgent::reportTestFound(JSC::CallFrame* callFrame, int testId, const String& name, Protocol::TestReporter::TestType type, int parentId)
{
if (!m_enabled)
return;
@@ -179,7 +205,9 @@ void InspectorTestReporterAgent::reportTestFound(JSC::CallFrame* callFrame, int
sourceID > 0 ? String::number(sourceID) : String(),
sourceURL,
lineColumn.line,
name);
name,
type,
parentId > 0 ? parentId : std::optional<int>());
}
void InspectorTestReporterAgent::reportTestStart(int testId)

View File

@@ -33,7 +33,7 @@ public:
virtual Protocol::ErrorStringOr<void> disable() final;
// Public API for reporting test events
void reportTestFound(JSC::CallFrame*, int testId, const String& name);
void reportTestFound(JSC::CallFrame*, int testId, const String& name, Protocol::TestReporter::TestType type = Protocol::TestReporter::TestType::Test, int parentId = -1);
void reportTestStart(int testId);
void reportTestEnd(int testId, Protocol::TestReporter::TestStatus status, double elapsed);

View File

@@ -6568,3 +6568,39 @@ extern "C" double Bun__JSC__operationMathPow(double x, double y)
{
return operationMathPow(x, y);
}
CPP_DECL unsigned int Bun__CallFrame__getLineNumber(JSC::CallFrame* callFrame, JSC::JSGlobalObject* globalObject)
{
auto& vm = JSC::getVM(globalObject);
JSC::LineColumn lineColumn;
String sourceURL;
JSC::StackVisitor::visit(callFrame, vm, [&](JSC::StackVisitor& visitor) -> WTF::IterationStatus {
if (Zig::isImplementationVisibilityPrivate(visitor))
return WTF::IterationStatus::Continue;
if (visitor->hasLineAndColumnInfo()) {
String currentSourceURL = Zig::sourceURL(visitor);
if (!currentSourceURL.startsWith("builtin://"_s) && !currentSourceURL.startsWith("node:"_s)) {
lineColumn = visitor->computeLineAndColumn();
sourceURL = currentSourceURL;
return WTF::IterationStatus::Done;
}
}
return WTF::IterationStatus::Continue;
});
if (!sourceURL.isEmpty() && lineColumn.line > 0) {
ZigStackFrame remappedFrame = {};
remappedFrame.position.line_zero_based = lineColumn.line - 1;
remappedFrame.position.column_zero_based = lineColumn.column;
remappedFrame.source_url = Bun::toStringRef(sourceURL);
Bun__remapStackFramePositions(Bun::vm(globalObject), &remappedFrame, 1);
return remappedFrame.position.line_zero_based + 1;
}
return lineColumn.line;
}

View File

@@ -198,28 +198,33 @@ pub const TestRunner = struct {
onTestTodo: OnTestUpdate,
};
pub fn reportPass(this: *TestRunner, test_id: Test.ID, file: string, label: string, expectations: u32, elapsed_ns: u64, parent: ?*DescribeScope) void {
pub fn reportPass(this: *TestRunner, test_id: Test.ID, file: string, label: string, expectations: u32, elapsed_ns: u64, parent: ?*DescribeScope, line_number: u32) void {
this.tests.items(.status)[test_id] = .pass;
this.tests.items(.line_number)[test_id] = line_number;
this.callback.onTestPass(this.callback, test_id, file, label, expectations, elapsed_ns, parent);
}
pub fn reportFailure(this: *TestRunner, test_id: Test.ID, file: string, label: string, expectations: u32, elapsed_ns: u64, parent: ?*DescribeScope) void {
pub fn reportFailure(this: *TestRunner, test_id: Test.ID, file: string, label: string, expectations: u32, elapsed_ns: u64, parent: ?*DescribeScope, line_number: u32) void {
this.tests.items(.status)[test_id] = .fail;
this.tests.items(.line_number)[test_id] = line_number;
this.callback.onTestFail(this.callback, test_id, file, label, expectations, elapsed_ns, parent);
}
pub fn reportSkip(this: *TestRunner, test_id: Test.ID, file: string, label: string, parent: ?*DescribeScope) void {
pub fn reportSkip(this: *TestRunner, test_id: Test.ID, file: string, label: string, parent: ?*DescribeScope, line_number: u32) void {
this.tests.items(.status)[test_id] = .skip;
this.tests.items(.line_number)[test_id] = line_number;
this.callback.onTestSkip(this.callback, test_id, file, label, 0, 0, parent);
}
pub fn reportTodo(this: *TestRunner, test_id: Test.ID, file: string, label: string, parent: ?*DescribeScope) void {
pub fn reportTodo(this: *TestRunner, test_id: Test.ID, file: string, label: string, parent: ?*DescribeScope, line_number: u32) void {
this.tests.items(.status)[test_id] = .todo;
this.tests.items(.line_number)[test_id] = line_number;
this.callback.onTestTodo(this.callback, test_id, file, label, 0, 0, parent);
}
pub fn reportFilteredOut(this: *TestRunner, test_id: Test.ID, file: string, label: string, parent: ?*DescribeScope) void {
pub fn reportFilteredOut(this: *TestRunner, test_id: Test.ID, file: string, label: string, parent: ?*DescribeScope, line_number: u32) void {
this.tests.items(.status)[test_id] = .skip;
this.tests.items(.line_number)[test_id] = line_number;
this.callback.onTestFilteredOut(this.callback, test_id, file, label, 0, 0, parent);
}
@@ -261,6 +266,7 @@ pub const TestRunner = struct {
pub const Test = struct {
status: Status = Status.pending,
line_number: u32 = 0,
pub const ID = u32;
pub const null_id: ID = std.math.maxInt(Test.ID);
@@ -272,6 +278,7 @@ pub const TestRunner = struct {
fail,
skip,
todo,
timeout,
skipped_because_label,
/// A test marked as `.failing()` actually passed
fail_because_failing_test_passed,
@@ -551,6 +558,7 @@ pub const TestScope = struct {
task: ?*TestRunnerTask = null,
tag: Tag = .pass,
snapshot_count: usize = 0,
line_number: u32 = 0,
// null if the test does not set a timeout
timeout_millis: u32 = std.math.maxInt(u32),
@@ -832,6 +840,8 @@ pub const DescribeScope = struct {
done: bool = false,
skip_count: u32 = 0,
tag: Tag = .pass,
line_number: u32 = 0,
test_id_for_debugger: u32 = 0,
fn isWithinOnlyScope(this: *const DescribeScope) bool {
if (this.tag == .only) return true;
@@ -1154,7 +1164,7 @@ pub const DescribeScope = struct {
if (this.runCallback(globalObject, .beforeAll)) |err| {
_ = globalObject.bunVM().uncaughtException(globalObject, err, true);
while (i < end) {
Jest.runner.?.reportFailure(i + this.test_id_start, source.path.text, tests[i].label, 0, 0, this);
Jest.runner.?.reportFailure(i + this.test_id_start, source.path.text, tests[i].label, 0, 0, this, tests[i].line_number);
i += 1;
}
this.deinit(globalObject);
@@ -1393,6 +1403,9 @@ pub const TestRunnerTask = struct {
.skip => {
this.processTestResult(globalThis, .{ .skip = {} }, test_, test_id, test_id_for_debugger, describe);
},
.skipped_because_label => {
this.processTestResult(globalThis, .{ .skipped_because_label = {} }, test_, test_id, test_id_for_debugger, describe);
},
else => {},
}
this.deinit();
@@ -1408,7 +1421,7 @@ pub const TestRunnerTask = struct {
if (this.describe.runCallback(globalThis, .beforeEach)) |err| {
_ = jsc_vm.uncaughtException(globalThis, err, true);
Jest.runner.?.reportFailure(test_id, this.source_file_path, label, 0, 0, this.describe);
Jest.runner.?.reportFailure(test_id, this.source_file_path, label, 0, 0, this.describe, test_.line_number);
return false;
}
}
@@ -1450,7 +1463,7 @@ pub const TestRunnerTask = struct {
const elapsed = now.duration(&this.started_at).ms();
this.ref.unref(this.globalThis.bunVM());
this.globalThis.requestTermination();
this.handleResult(.{ .fail = expect.active_test_expectation_counter.actual }, .{ .timeout = @intCast(@max(elapsed, 0)) });
this.handleResult(.{ .timeout = {} }, .{ .timeout = @intCast(@max(elapsed, 0)) });
}
const ResultType = union(enum) {
@@ -1587,6 +1600,7 @@ pub const TestRunnerTask = struct {
count,
elapsed,
describe,
test_.line_number,
),
.fail => |count| Jest.runner.?.reportFailure(
test_id,
@@ -1595,6 +1609,7 @@ pub const TestRunnerTask = struct {
count,
elapsed,
describe,
test_.line_number,
),
.fail_because_failing_test_passed => |count| {
Output.prettyErrorln(" <d>^<r> <red>this test is marked as failing but it passed.<r> <d>Remove `.failing` if tested behavior now works", .{});
@@ -1605,6 +1620,7 @@ pub const TestRunnerTask = struct {
count,
elapsed,
describe,
test_.line_number,
);
},
.fail_because_expected_has_assertions => {
@@ -1617,6 +1633,7 @@ pub const TestRunnerTask = struct {
0,
elapsed,
describe,
test_.line_number,
);
},
.fail_because_expected_assertion_count => |counter| {
@@ -1632,11 +1649,21 @@ pub const TestRunnerTask = struct {
counter.actual,
elapsed,
describe,
test_.line_number,
);
},
.skip => Jest.runner.?.reportSkip(test_id, this.source_file_path, test_.label, describe),
.skipped_because_label => Jest.runner.?.reportFilteredOut(test_id, this.source_file_path, test_.label, describe),
.todo => Jest.runner.?.reportTodo(test_id, this.source_file_path, test_.label, describe),
.skip => Jest.runner.?.reportSkip(test_id, this.source_file_path, test_.label, describe, test_.line_number),
.skipped_because_label => Jest.runner.?.reportFilteredOut(test_id, this.source_file_path, test_.label, describe, test_.line_number),
.todo => Jest.runner.?.reportTodo(test_id, this.source_file_path, test_.label, describe, test_.line_number),
.timeout => Jest.runner.?.reportFailure(
test_id,
this.source_file_path,
test_.label,
0,
elapsed,
describe,
test_.line_number,
),
.fail_because_todo_passed => |count| {
Output.prettyErrorln(" <d>^<r> <red>this test is marked as todo but passes.<r> <d>Remove `.todo` or check that test is correct.<r>", .{});
Jest.runner.?.reportFailure(
@@ -1646,6 +1673,7 @@ pub const TestRunnerTask = struct {
count,
elapsed,
describe,
test_.line_number,
);
},
.pending => @panic("Unexpected pending test"),
@@ -1658,6 +1686,8 @@ pub const TestRunnerTask = struct {
.pass => .pass,
.skip => .skip,
.todo => .todo,
.timeout => .timeout,
.skipped_because_label => .skipped_because_label,
else => .fail,
}, @floatFromInt(elapsed));
}
@@ -1697,6 +1727,7 @@ pub const Result = union(TestRunner.Test.Status) {
fail: u32,
skip: void,
todo: void,
timeout: void,
skipped_because_label: void,
fail_because_failing_test_passed: u32,
fail_because_todo_passed: u32,
@@ -1704,14 +1735,14 @@ pub const Result = union(TestRunner.Test.Status) {
fail_because_expected_assertion_count: Counter,
pub fn isFailure(this: *const Result) bool {
return this.* == .fail or this.* == .fail_because_expected_has_assertions or this.* == .fail_because_expected_assertion_count;
return this.* == .fail or this.* == .timeout or this.* == .fail_because_expected_has_assertions or this.* == .fail_because_expected_assertion_count;
}
pub fn forceTODO(this: Result, is_todo: bool) Result {
if (is_todo and this == .pass)
return .{ .fail_because_todo_passed = this.pass };
if (is_todo and this == .fail) {
if (is_todo and (this == .fail or this == .timeout)) {
return .{ .todo = {} };
}
return this;
@@ -1850,10 +1881,6 @@ inline fn createScope(
is_skip = !regex.matches(str);
if (is_skip) {
tag_to_use = .skipped_because_label;
if (comptime is_test) {
// These won't get counted for describe scopes, which means the process will not exit with 1.
runner.summary.skipped_because_label += 1;
}
}
}
}
@@ -1883,18 +1910,18 @@ inline fn createScope(
.func_arg = function_args,
.func_has_callback = has_callback,
.timeout_millis = timeout_ms,
.line_number = captureTestLineNumber(callframe, globalThis),
.test_id_for_debugger = brk: {
if (!is_skip) {
const vm = globalThis.bunVM();
if (vm.debugger) |*debugger| {
if (debugger.test_reporter_agent.isEnabled()) {
max_test_id_for_debugger += 1;
var name = bun.String.init(label);
debugger.test_reporter_agent.reportTestFound(callframe, @intCast(max_test_id_for_debugger), &name);
const parent_id = if (parent.test_id_for_debugger > 0) @as(i32, @intCast(parent.test_id_for_debugger)) else -1;
debugger.test_reporter_agent.reportTestFound(callframe, @intCast(max_test_id_for_debugger), &name, .@"test", parent_id);
break :brk max_test_id_for_debugger;
}
}
}
break :brk 0;
},
@@ -1906,6 +1933,20 @@ inline fn createScope(
.parent = parent,
.file_id = parent.file_id,
.tag = tag_to_use,
.line_number = captureTestLineNumber(callframe, globalThis),
.test_id_for_debugger = brk: {
const vm = globalThis.bunVM();
if (vm.debugger) |*debugger| {
if (debugger.test_reporter_agent.isEnabled()) {
max_test_id_for_debugger += 1;
var name = bun.String.init(label);
const parent_id = if (parent.test_id_for_debugger > 0) @as(i32, @intCast(parent.test_id_for_debugger)) else -1;
debugger.test_reporter_agent.reportTestFound(callframe, @intCast(max_test_id_for_debugger), &name, .describe, parent_id);
break :brk max_test_id_for_debugger;
}
}
break :brk 0;
},
};
return scope.run(globalThis, function, &.{});
@@ -2032,7 +2073,11 @@ fn formatLabel(globalThis: *JSGlobalObject, label: string, function_args: []JSVa
return list.toOwnedSlice(allocator);
}
pub const EachData = struct { strong: JSC.Strong.Optional, is_test: bool };
pub const EachData = struct {
strong: JSC.Strong.Optional,
is_test: bool,
line_number: u32 = 0,
};
fn eachBind(globalThis: *JSGlobalObject, callframe: *CallFrame) bun.JSError!JSValue {
const signature = "eachBind";
@@ -2154,6 +2199,8 @@ fn eachBind(globalThis: *JSGlobalObject, callframe: *CallFrame) bun.JSError!JSVa
(tag == .todo and (function == .zero or !Jest.runner.?.run_todo)) or
(tag != .only and Jest.runner.?.only and parent.tag != .only);
var tag_to_use = tag;
if (!is_skip) {
if (Jest.runner.?.filter_regex) |regex| {
var buffer: bun.MutableString = Jest.runner.?.filter_buffer;
buffer.reset();
@@ -2162,8 +2209,7 @@ fn eachBind(globalThis: *JSGlobalObject, callframe: *CallFrame) bun.JSError!JSVa
const str = bun.String.fromBytes(buffer.slice());
is_skip = !regex.matches(str);
if (is_skip) {
if (each_data.is_test) {
Jest.runner.?.summary.skipped_because_label += 1;
tag_to_use = .skipped_because_label;
}
}
}
@@ -2171,21 +2217,46 @@ fn eachBind(globalThis: *JSGlobalObject, callframe: *CallFrame) bun.JSError!JSVa
if (is_skip) {
parent.skip_count += 1;
function.unprotect();
// lets free the formatted label
}
if (each_data.is_test) {
if (Jest.runner.?.only and tag != .only and tag_to_use != .skip and tag_to_use != .skipped_because_label) {
allocator.free(formattedLabel);
} else if (each_data.is_test) {
if (Jest.runner.?.only and tag != .only) {
return .js_undefined;
for (function_args) |arg| {
if (arg != .zero) arg.unprotect();
}
allocator.free(function_args);
} else {
if (!is_skip) {
function.protect();
} else {
for (function_args) |arg| {
if (arg != .zero) arg.unprotect();
}
allocator.free(function_args);
}
parent.tests.append(allocator, TestScope{
.label = formattedLabel,
.parent = parent,
.tag = tag,
.func = function,
.func_arg = function_args,
.tag = tag_to_use,
.func = if (is_skip) .zero else function,
.func_arg = if (is_skip) &.{} else function_args,
.func_has_callback = has_callback_function,
.timeout_millis = timeout_ms,
.line_number = each_data.line_number,
.test_id_for_debugger = brk: {
const vm = globalThis.bunVM();
if (vm.debugger) |*debugger| {
if (debugger.test_reporter_agent.isEnabled()) {
max_test_id_for_debugger += 1;
var name = bun.String.init(formattedLabel);
const parent_id = if (parent.test_id_for_debugger > 0) @as(i32, @intCast(parent.test_id_for_debugger)) else -1;
debugger.test_reporter_agent.reportTestFound(callframe, @intCast(max_test_id_for_debugger), &name, .@"test", parent_id);
break :brk max_test_id_for_debugger;
}
}
break :brk 0;
},
}) catch unreachable;
}
} else {
@@ -2195,6 +2266,19 @@ fn eachBind(globalThis: *JSGlobalObject, callframe: *CallFrame) bun.JSError!JSVa
.parent = parent,
.file_id = parent.file_id,
.tag = tag,
.test_id_for_debugger = brk: {
const vm = globalThis.bunVM();
if (vm.debugger) |*debugger| {
if (debugger.test_reporter_agent.isEnabled()) {
max_test_id_for_debugger += 1;
var name = bun.String.init(formattedLabel);
const parent_id = if (parent.test_id_for_debugger > 0) @as(i32, @intCast(parent.test_id_for_debugger)) else -1;
debugger.test_reporter_agent.reportTestFound(callframe, @intCast(max_test_id_for_debugger), &name, .describe, parent_id);
break :brk max_test_id_for_debugger;
}
}
break :brk 0;
},
};
const ret = scope.run(globalThis, function, function_args);
@@ -2234,6 +2318,7 @@ inline fn createEach(
each_data.* = EachData{
.strong = strong,
.is_test = is_test,
.line_number = captureTestLineNumber(callframe, globalThis),
};
return JSC.host_fn.NewFunctionWithData(globalThis, name, 3, eachBind, true, each_data);
@@ -2248,3 +2333,14 @@ fn callJSFunctionForTestRunner(vm: *JSC.VirtualMachine, globalObject: *JSGlobalO
}
const assert = bun.assert;
extern fn Bun__CallFrame__getLineNumber(callframe: *JSC.CallFrame, globalObject: *JSC.JSGlobalObject) u32;
fn captureTestLineNumber(callframe: *JSC.CallFrame, globalThis: *JSGlobalObject) u32 {
if (Jest.runner) |runner| {
if (runner.test_options.file_reporter == .junit) {
return Bun__CallFrame__getLineNumber(callframe, globalThis);
}
}
return 0;
}

View File

@@ -81,14 +81,14 @@ fn fmtStatusTextLine(comptime status: @Type(.enum_literal), comptime emoji_or_co
true => switch (status) {
.pass => Output.prettyFmt("<r><green>✓<r>", emoji_or_color),
.fail => Output.prettyFmt("<r><red>✗<r>", emoji_or_color),
.skip => Output.prettyFmt("<r><yellow>»<d>", emoji_or_color),
.skip, .skipped_because_label => Output.prettyFmt("<r><yellow>»<d>", emoji_or_color),
.todo => Output.prettyFmt("<r><magenta>✎<r>", emoji_or_color),
else => @compileError("Invalid status " ++ @tagName(status)),
},
else => switch (status) {
.pass => Output.prettyFmt("<r><green>(pass)<r>", emoji_or_color),
.fail => Output.prettyFmt("<r><red>(fail)<r>", emoji_or_color),
.skip => Output.prettyFmt("<r><yellow>(skip)<d>", emoji_or_color),
.skip, .skipped_because_label => Output.prettyFmt("<r><yellow>(skip)<d>", emoji_or_color),
.todo => Output.prettyFmt("<r><magenta>(todo)<r>", emoji_or_color),
else => @compileError("Invalid status " ++ @tagName(status)),
},
@@ -115,6 +115,9 @@ pub const JunitReporter = struct {
current_file: string = "",
properties_list_to_repeat_in_every_test_suite: ?[]const u8 = null,
suite_stack: std.ArrayListUnmanaged(SuiteInfo) = .{},
current_depth: u32 = 0,
hostname_value: ?string = null,
pub fn getHostname(this: *JunitReporter) ?string {
@@ -145,6 +148,20 @@ pub const JunitReporter = struct {
return null;
}
const SuiteInfo = struct {
name: string,
offset_of_attributes: usize,
metrics: Metrics = .{},
is_file_suite: bool = false,
line_number: u32 = 0,
pub fn deinit(this: *SuiteInfo, allocator: std.mem.Allocator) void {
if (!this.is_file_suite and this.name.len > 0) {
allocator.free(this.name);
}
}
};
const Metrics = struct {
test_cases: u32 = 0,
assertions: u32 = 0,
@@ -159,14 +176,36 @@ pub const JunitReporter = struct {
this.skipped += other.skipped;
}
};
pub fn init() *JunitReporter {
return JunitReporter.new(
.{ .contents = .{}, .total_metrics = .{} },
.{ .contents = .{}, .total_metrics = .{}, .suite_stack = .{} },
);
}
pub const new = bun.TrivialNew(JunitReporter);
pub fn deinit(this: *JunitReporter) void {
for (this.suite_stack.items) |*suite_info| {
suite_info.deinit(bun.default_allocator);
}
this.suite_stack.deinit(bun.default_allocator);
this.contents.deinit(bun.default_allocator);
if (this.hostname_value) |hostname| {
if (hostname.len > 0) {
bun.default_allocator.free(hostname);
}
}
if (this.properties_list_to_repeat_in_every_test_suite) |properties| {
if (properties.len > 0) {
bun.default_allocator.free(properties);
}
}
}
fn generatePropertiesList(this: *JunitReporter) !void {
const PropertiesList = struct {
ci: string,
@@ -253,7 +292,18 @@ pub const JunitReporter = struct {
this.properties_list_to_repeat_in_every_test_suite = buffer.items;
}
fn getIndent(depth: u32) []const u8 {
const spaces = " ";
const indent_size = 2;
const total_spaces = (depth + 1) * indent_size;
return spaces[0..@min(total_spaces, spaces.len)];
}
pub fn beginTestSuite(this: *JunitReporter, name: string) !void {
return this.beginTestSuiteWithLine(name, 0, true);
}
pub fn beginTestSuiteWithLine(this: *JunitReporter, name: string, line_number: u32, is_file_suite: bool) !void {
if (this.contents.items.len == 0) {
try this.contents.appendSlice(bun.default_allocator,
\\<?xml version="1.0" encoding="UTF-8"?>
@@ -265,16 +315,31 @@ pub const JunitReporter = struct {
try this.contents.appendSlice(bun.default_allocator, ">\n");
}
try this.contents.appendSlice(bun.default_allocator,
\\ <testsuite name="
);
const indent = getIndent(this.current_depth);
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.appendSlice(bun.default_allocator, "<testsuite name=\"");
try escapeXml(name, this.contents.writer(bun.default_allocator));
try this.contents.appendSlice(bun.default_allocator, "\"");
try this.contents.appendSlice(bun.default_allocator, "\" ");
this.offset_of_testsuite_value = this.contents.items.len;
if (is_file_suite) {
try this.contents.appendSlice(bun.default_allocator, " file=\"");
try escapeXml(name, this.contents.writer(bun.default_allocator));
try this.contents.appendSlice(bun.default_allocator, "\"");
} else if (this.current_file.len > 0) {
try this.contents.appendSlice(bun.default_allocator, " file=\"");
try escapeXml(this.current_file, this.contents.writer(bun.default_allocator));
try this.contents.appendSlice(bun.default_allocator, "\"");
}
if (line_number > 0) {
try this.contents.writer(bun.default_allocator).print(" line=\"{d}\"", .{line_number});
}
try this.contents.appendSlice(bun.default_allocator, " ");
const offset_of_attributes = this.contents.items.len;
try this.contents.appendSlice(bun.default_allocator, ">\n");
if (is_file_suite) {
if (this.properties_list_to_repeat_in_every_test_suite == null) {
try this.generatePropertiesList();
}
@@ -284,20 +349,34 @@ pub const JunitReporter = struct {
try this.contents.appendSlice(bun.default_allocator, properties_list);
}
}
}
try this.suite_stack.append(bun.default_allocator, SuiteInfo{
.name = if (is_file_suite) name else try bun.default_allocator.dupe(u8, name),
.offset_of_attributes = offset_of_attributes,
.is_file_suite = is_file_suite,
.line_number = line_number,
});
this.current_depth += 1;
if (is_file_suite) {
this.current_file = name;
}
}
pub fn endTestSuite(this: *JunitReporter) !void {
if (this.suite_stack.items.len == 0) return;
this.current_depth -= 1;
var suite_info = this.suite_stack.swapRemove(this.suite_stack.items.len - 1);
defer suite_info.deinit(bun.default_allocator);
var arena = std.heap.ArenaAllocator.init(bun.default_allocator);
defer arena.deinit();
var stack_fallback_allocator = std.heap.stackFallback(4096, arena.allocator());
const allocator = stack_fallback_allocator.get();
const metrics = &this.testcases_metrics;
this.total_metrics.add(metrics);
const elapsed_time_ms = metrics.elapsed_time;
const elapsed_time_ms = suite_info.metrics.elapsed_time;
const elapsed_time_ms_f64: f64 = @floatFromInt(elapsed_time_ms);
const elapsed_time_seconds = elapsed_time_ms_f64 / std.time.ms_per_s;
@@ -305,17 +384,25 @@ pub const JunitReporter = struct {
const summary = try std.fmt.allocPrint(allocator,
\\tests="{d}" assertions="{d}" failures="{d}" skipped="{d}" time="{d}" hostname="{s}"
, .{
metrics.test_cases,
metrics.assertions,
metrics.failures,
metrics.skipped,
suite_info.metrics.test_cases,
suite_info.metrics.assertions,
suite_info.metrics.failures,
suite_info.metrics.skipped,
elapsed_time_seconds,
this.getHostname() orelse "",
});
this.testcases_metrics = .{};
this.contents.insertSlice(bun.default_allocator, this.offset_of_testsuite_value, summary) catch bun.outOfMemory();
try this.contents.appendSlice(bun.default_allocator, " </testsuite>\n");
this.contents.insertSlice(bun.default_allocator, suite_info.offset_of_attributes, summary) catch bun.outOfMemory();
const indent = getIndent(this.current_depth);
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.appendSlice(bun.default_allocator, "</testsuite>\n");
if (this.suite_stack.items.len > 0) {
this.suite_stack.items[this.suite_stack.items.len - 1].metrics.add(&suite_info.metrics);
} else {
this.total_metrics.add(&suite_info.metrics);
}
}
pub fn writeTestCase(
@@ -326,13 +413,21 @@ pub const JunitReporter = struct {
class_name: string,
assertions: u32,
elapsed_ns: u64,
line_number: u32,
) !void {
const elapsed_ns_f64: f64 = @floatFromInt(elapsed_ns);
const elapsed_ms = elapsed_ns_f64 / std.time.ns_per_ms;
this.testcases_metrics.elapsed_time +|= @as(u64, @intFromFloat(elapsed_ms));
this.testcases_metrics.test_cases += 1;
try this.contents.appendSlice(bun.default_allocator, " <testcase");
if (this.suite_stack.items.len > 0) {
var current_suite = &this.suite_stack.items[this.suite_stack.items.len - 1];
current_suite.metrics.elapsed_time +|= @as(u64, @intFromFloat(elapsed_ms));
current_suite.metrics.test_cases += 1;
current_suite.metrics.assertions += assertions;
}
const indent = getIndent(this.current_depth);
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.appendSlice(bun.default_allocator, "<testcase");
try this.contents.appendSlice(bun.default_allocator, " name=\"");
try escapeXml(name, this.contents.writer(bun.default_allocator));
try this.contents.appendSlice(bun.default_allocator, "\" classname=\"");
@@ -346,65 +441,113 @@ pub const JunitReporter = struct {
try escapeXml(file, this.contents.writer(bun.default_allocator));
try this.contents.appendSlice(bun.default_allocator, "\"");
try this.contents.writer(bun.default_allocator).print(" assertions=\"{d}\"", .{assertions});
if (line_number > 0) {
try this.contents.writer(bun.default_allocator).print(" line=\"{d}\"", .{line_number});
}
this.testcases_metrics.assertions += assertions;
try this.contents.writer(bun.default_allocator).print(" assertions=\"{d}\"", .{assertions});
switch (status) {
.pass => {
try this.contents.appendSlice(bun.default_allocator, " />\n");
},
.fail => {
this.testcases_metrics.failures += 1;
try this.contents.appendSlice(bun.default_allocator, ">\n <failure type=\"AssertionError\" />\n </testcase>\n");
if (this.suite_stack.items.len > 0) {
this.suite_stack.items[this.suite_stack.items.len - 1].metrics.failures += 1;
}
// TODO: add the failure message
// if (failure_message) |msg| {
// try this.contents.appendSlice(bun.default_allocator, " message=\"");
// try escapeXml(msg, this.contents.writer(bun.default_allocator));
// try this.contents.appendSlice(bun.default_allocator, "\"");
// }
try this.contents.appendSlice(bun.default_allocator, ">\n");
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.appendSlice(bun.default_allocator, " <failure type=\"AssertionError\" />\n");
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.appendSlice(bun.default_allocator, "</testcase>\n");
},
.fail_because_failing_test_passed => {
this.testcases_metrics.failures += 1;
if (this.suite_stack.items.len > 0) {
this.suite_stack.items[this.suite_stack.items.len - 1].metrics.failures += 1;
}
try this.contents.appendSlice(bun.default_allocator, ">\n");
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.writer(bun.default_allocator).print(
\\>
\\ <failure message="test marked with .failing() did not throw" type="AssertionError"/>
\\ </testcase>
\\
, .{});
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.appendSlice(bun.default_allocator, "</testcase>\n");
},
.fail_because_expected_assertion_count => {
this.testcases_metrics.failures += 1;
// TODO: add the failure message
if (this.suite_stack.items.len > 0) {
this.suite_stack.items[this.suite_stack.items.len - 1].metrics.failures += 1;
}
try this.contents.appendSlice(bun.default_allocator, ">\n");
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.writer(bun.default_allocator).print(
\\>
\\ <failure message="Expected more assertions, but only received {d}" type="AssertionError"/>
\\ </testcase>
\\
, .{assertions});
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.appendSlice(bun.default_allocator, "</testcase>\n");
},
.fail_because_todo_passed => {
this.testcases_metrics.failures += 1;
// TODO: add the failure message
if (this.suite_stack.items.len > 0) {
this.suite_stack.items[this.suite_stack.items.len - 1].metrics.failures += 1;
}
try this.contents.appendSlice(bun.default_allocator, ">\n");
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.writer(bun.default_allocator).print(
\\>
\\ <failure message="TODO passed" type="AssertionError"/>
\\ </testcase>
\\
, .{});
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.appendSlice(bun.default_allocator, "</testcase>\n");
},
.fail_because_expected_has_assertions => {
this.testcases_metrics.failures += 1;
if (this.suite_stack.items.len > 0) {
this.suite_stack.items[this.suite_stack.items.len - 1].metrics.failures += 1;
}
try this.contents.appendSlice(bun.default_allocator, ">\n");
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.writer(bun.default_allocator).print(
\\>
\\ <failure message="Expected to have assertions, but none were run" type="AssertionError"/>
\\ </testcase>
\\
, .{});
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.appendSlice(bun.default_allocator, "</testcase>\n");
},
.skipped_because_label, .skip => {
this.testcases_metrics.skipped += 1;
try this.contents.appendSlice(bun.default_allocator, ">\n <skipped />\n </testcase>\n");
if (this.suite_stack.items.len > 0) {
this.suite_stack.items[this.suite_stack.items.len - 1].metrics.skipped += 1;
}
try this.contents.appendSlice(bun.default_allocator, ">\n");
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.appendSlice(bun.default_allocator, " <skipped />\n");
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.appendSlice(bun.default_allocator, "</testcase>\n");
},
.todo => {
this.testcases_metrics.skipped += 1;
try this.contents.appendSlice(bun.default_allocator, ">\n <skipped message=\"TODO\" />\n </testcase>\n");
if (this.suite_stack.items.len > 0) {
this.suite_stack.items[this.suite_stack.items.len - 1].metrics.skipped += 1;
}
try this.contents.appendSlice(bun.default_allocator, ">\n");
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.appendSlice(bun.default_allocator, " <skipped message=\"TODO\" />\n");
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.appendSlice(bun.default_allocator, "</testcase>\n");
},
.timeout => {
if (this.suite_stack.items.len > 0) {
this.suite_stack.items[this.suite_stack.items.len - 1].metrics.failures += 1;
}
try this.contents.appendSlice(bun.default_allocator, ">\n");
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.appendSlice(bun.default_allocator, " <failure type=\"TimeoutError\" />\n");
try this.contents.appendSlice(bun.default_allocator, indent);
try this.contents.appendSlice(bun.default_allocator, "</testcase>\n");
},
.pending => unreachable,
}
@@ -412,6 +555,11 @@ pub const JunitReporter = struct {
pub fn writeToFile(this: *JunitReporter, path: string) !void {
if (this.contents.items.len == 0) return;
while (this.suite_stack.items.len > 0) {
try this.endTestSuite();
}
{
var arena = std.heap.ArenaAllocator.init(bun.default_allocator);
defer arena.deinit();
@@ -452,10 +600,6 @@ pub const JunitReporter = struct {
},
}
}
pub fn deinit(this: *JunitReporter) void {
this.contents.deinit(bun.default_allocator);
}
};
pub const CommandLineReporter = struct {
@@ -498,6 +642,7 @@ pub const CommandLineReporter = struct {
writer: anytype,
file: string,
file_reporter: ?FileReporter,
line_number: u32,
) void {
var scopes_stack = std.BoundedArray(*jest.DescribeScope, 64).init(0) catch unreachable;
var parent_ = parent;
@@ -564,7 +709,12 @@ pub const CommandLineReporter = struct {
break :brk file;
}
};
if (!strings.eql(junit.current_file, filename)) {
while (junit.suite_stack.items.len > 0 and !junit.suite_stack.items[junit.suite_stack.items.len - 1].is_file_suite) {
junit.endTestSuite() catch bun.outOfMemory();
}
if (junit.current_file.len > 0) {
junit.endTestSuite() catch bun.outOfMemory();
}
@@ -572,6 +722,78 @@ pub const CommandLineReporter = struct {
junit.beginTestSuite(filename) catch bun.outOfMemory();
}
// To make the juint reporter generate nested suites, we need to find the needed suites and create/print them.
// This assumes that the scopes are in the correct order.
var needed_suites = std.ArrayList(*jest.DescribeScope).init(bun.default_allocator);
defer needed_suites.deinit();
for (scopes, 0..) |_, i| {
const index = (scopes.len - 1) - i;
const scope = scopes[index];
if (scope.label.len > 0) {
needed_suites.append(scope) catch bun.outOfMemory();
}
}
var current_suite_depth: u32 = 0;
if (junit.suite_stack.items.len > 0) {
for (junit.suite_stack.items) |suite_info| {
if (!suite_info.is_file_suite) {
current_suite_depth += 1;
}
}
}
while (current_suite_depth > needed_suites.items.len) {
if (junit.suite_stack.items.len > 0 and !junit.suite_stack.items[junit.suite_stack.items.len - 1].is_file_suite) {
junit.endTestSuite() catch bun.outOfMemory();
current_suite_depth -= 1;
} else {
break;
}
}
var suites_to_close: u32 = 0;
var suite_index: usize = 0;
for (junit.suite_stack.items) |suite_info| {
if (suite_info.is_file_suite) continue;
if (suite_index < needed_suites.items.len) {
const needed_scope = needed_suites.items[suite_index];
if (!strings.eql(suite_info.name, needed_scope.label)) {
suites_to_close = @as(u32, @intCast(current_suite_depth)) - @as(u32, @intCast(suite_index));
break;
}
} else {
suites_to_close = @as(u32, @intCast(current_suite_depth)) - @as(u32, @intCast(suite_index));
break;
}
suite_index += 1;
}
while (suites_to_close > 0) {
if (junit.suite_stack.items.len > 0 and !junit.suite_stack.items[junit.suite_stack.items.len - 1].is_file_suite) {
junit.endTestSuite() catch bun.outOfMemory();
current_suite_depth -= 1;
suites_to_close -= 1;
} else {
break;
}
}
var describe_suite_index: usize = 0;
for (junit.suite_stack.items) |suite_info| {
if (!suite_info.is_file_suite) {
describe_suite_index += 1;
}
}
while (describe_suite_index < needed_suites.items.len) {
const scope = needed_suites.items[describe_suite_index];
junit.beginTestSuiteWithLine(scope.label, scope.line_number, false) catch bun.outOfMemory();
describe_suite_index += 1;
}
var arena = std.heap.ArenaAllocator.init(bun.default_allocator);
defer arena.deinit();
var stack_fallback = std.heap.stackFallback(4096, arena.allocator());
@@ -591,7 +813,7 @@ pub const CommandLineReporter = struct {
}
}
junit.writeTestCase(status, filename, display_label, concatenated_describe_scopes.items, assertions, elapsed_ns) catch bun.outOfMemory();
junit.writeTestCase(status, filename, display_label, concatenated_describe_scopes.items, assertions, elapsed_ns, line_number) catch bun.outOfMemory();
},
}
}
@@ -611,7 +833,8 @@ pub const CommandLineReporter = struct {
writeTestStatusLine(.pass, &writer);
printTestLine(.pass, label, elapsed_ns, parent, expectations, false, writer, file, this.file_reporter);
const line_number = this.jest.tests.items(.line_number)[id];
printTestLine(.pass, label, elapsed_ns, parent, expectations, false, writer, file, this.file_reporter, line_number);
this.jest.tests.items(.status)[id] = TestRunner.Test.Status.pass;
this.summary().pass += 1;
@@ -628,7 +851,8 @@ pub const CommandLineReporter = struct {
var writer = this.failures_to_repeat_buf.writer(bun.default_allocator);
writeTestStatusLine(.fail, &writer);
printTestLine(.fail, label, elapsed_ns, parent, expectations, false, writer, file, this.file_reporter);
const line_number = this.jest.tests.items(.line_number)[id];
printTestLine(.fail, label, elapsed_ns, parent, expectations, false, writer, file, this.file_reporter, line_number);
// We must always reset the colors because (skip) will have set them to <d>
if (Output.enable_ansi_colors_stderr) {
@@ -663,7 +887,8 @@ pub const CommandLineReporter = struct {
var writer = this.skips_to_repeat_buf.writer(bun.default_allocator);
writeTestStatusLine(.skip, &writer);
printTestLine(.skip, label, elapsed_ns, parent, expectations, true, writer, file, this.file_reporter);
const line_number = this.jest.tests.items(.line_number)[id];
printTestLine(.skip, label, elapsed_ns, parent, expectations, true, writer, file, this.file_reporter, line_number);
writer_.writeAll(this.skips_to_repeat_buf.items[initial_length..]) catch unreachable;
Output.flush();
@@ -675,14 +900,27 @@ pub const CommandLineReporter = struct {
this.jest.tests.items(.status)[id] = TestRunner.Test.Status.skip;
}
pub fn handleTestFilteredOut(cb: *TestRunner.Callback, id: Test.ID, _: string, _: string, expectations: u32, _: u64, _: ?*jest.DescribeScope) void {
pub fn handleTestFilteredOut(cb: *TestRunner.Callback, id: Test.ID, file: string, label: string, expectations: u32, elapsed_ns: u64, parent: ?*jest.DescribeScope) void {
var this: *CommandLineReporter = @fieldParentPtr("callback", cb);
if (this.file_reporter) |_| {
var writer_ = Output.errorWriter();
const initial_length = this.skips_to_repeat_buf.items.len;
var writer = this.skips_to_repeat_buf.writer(bun.default_allocator);
writeTestStatusLine(.skipped_because_label, &writer);
const line_number = this.jest.tests.items(.line_number)[id];
printTestLine(.skipped_because_label, label, elapsed_ns, parent, expectations, true, writer, file, this.file_reporter, line_number);
writer_.writeAll(this.skips_to_repeat_buf.items[initial_length..]) catch unreachable;
Output.flush();
}
// this.updateDots();
this.summary().skipped_because_label += 1;
this.summary().skip += 1;
this.summary().expectations += expectations;
this.jest.tests.items(.status)[id] = TestRunner.Test.Status.skip;
this.jest.tests.items(.status)[id] = TestRunner.Test.Status.skipped_because_label;
}
pub fn handleTestTodo(cb: *TestRunner.Callback, id: Test.ID, file: string, label: string, expectations: u32, elapsed_ns: u64, parent: ?*jest.DescribeScope) void {
@@ -696,7 +934,8 @@ pub const CommandLineReporter = struct {
var writer = this.todos_to_repeat_buf.writer(bun.default_allocator);
writeTestStatusLine(.todo, &writer);
printTestLine(.todo, label, elapsed_ns, parent, expectations, true, writer, file, this.file_reporter);
const line_number = this.jest.tests.items(.line_number)[id];
printTestLine(.todo, label, elapsed_ns, parent, expectations, true, writer, file, this.file_reporter, line_number);
writer_.writeAll(this.todos_to_repeat_buf.items[initial_length..]) catch unreachable;
Output.flush();
@@ -1045,6 +1284,15 @@ pub const TestCommand = struct {
JSC.VirtualMachine.isBunTest = true;
var reporter = try ctx.allocator.create(CommandLineReporter);
defer {
if (reporter.file_reporter) |*file_reporter| {
switch (file_reporter.*) {
.junit => |junit_reporter| {
junit_reporter.deinit();
},
}
}
}
reporter.* = CommandLineReporter{
.jest = TestRunner{
.allocator = ctx.allocator,
@@ -1243,7 +1491,7 @@ pub const TestCommand = struct {
Output.flush();
var error_writer = Output.errorWriter();
error_writer.writeAll(reporter.skips_to_repeat_buf.items) catch unreachable;
error_writer.writeAll(reporter.skips_to_repeat_buf.items) catch {};
}
if (reporter.summary().todo > 0) {
@@ -1255,7 +1503,7 @@ pub const TestCommand = struct {
Output.flush();
var error_writer = Output.errorWriter();
error_writer.writeAll(reporter.todos_to_repeat_buf.items) catch unreachable;
error_writer.writeAll(reporter.todos_to_repeat_buf.items) catch {};
}
if (reporter.summary().fail > 0) {
@@ -1267,7 +1515,7 @@ pub const TestCommand = struct {
Output.flush();
var error_writer = Output.errorWriter();
error_writer.writeAll(reporter.failures_to_repeat_buf.items) catch unreachable;
error_writer.writeAll(reporter.failures_to_repeat_buf.items) catch {};
}
}

View File

@@ -661,7 +661,7 @@ describe("bun test", () => {
`,
});
numbers.forEach(numbers => {
expect(stderr).not.toContain(`${numbers[0]} + ${numbers[1]} = ${numbers[2]}`);
expect(stderr).not.toContain(`(pass) ${numbers[0]} + ${numbers[1]} = ${numbers[2]}`);
});
});
test("should allow tests run with test.each to be matched", () => {
@@ -683,9 +683,9 @@ describe("bun test", () => {
});
numbers.forEach(numbers => {
if (numbers[0] === 1) {
expect(stderr).toContain(`${numbers[0]} + ${numbers[1]} = ${numbers[2]}`);
expect(stderr).toContain(`(pass) ${numbers[0]} + ${numbers[1]} = ${numbers[2]}`);
} else {
expect(stderr).not.toContain(`${numbers[0]} + ${numbers[1]} = ${numbers[2]}`);
expect(stderr).not.toContain(`(pass) ${numbers[0]} + ${numbers[1]} = ${numbers[2]}`);
}
});
});

View File

@@ -34,7 +34,7 @@ const words: Record<string, { reason: string; limit?: number; regex?: boolean }>
[String.raw`: [a-zA-Z0-9_\.\*\?\[\]\(\)]+ = undefined,`]: { reason: "Do not default a struct field to undefined", limit: 242, regex: true },
"usingnamespace": { reason: "Zig 0.15 will remove `usingnamespace`" },
"catch unreachable": { reason: "For out-of-memory, prefer 'catch bun.outOfMemory()'", limit: 1865 },
"catch unreachable": { reason: "For out-of-memory, prefer 'catch bun.outOfMemory()'", limit: 1863 },
"std.fs.Dir": { reason: "Prefer bun.sys + bun.FD instead of std.fs", limit: 170 },
"std.fs.cwd": { reason: "Prefer bun.FD.cwd()", limit: 102 },

View File

@@ -5,12 +5,11 @@ import { bunEnv, bunExe, tempDirWithFiles } from "harness";
const xml2js = require("xml2js");
describe("junit reporter", () => {
for (let withCIEnvironmentVariables of [false, true]) {
it(`should generate valid junit xml for passing tests ${withCIEnvironmentVariables ? "with CI environment variables" : ""}`, async () => {
it.each([false, true])("should generate valid junit xml for passing tests %s", async withCIEnvironmentVariables => {
const tmpDir = tempDirWithFiles("junit", {
"package.json": "{}",
"passing.test.js": `
describe("root describe", () => {
it("should pass", () => {
expect(1 + 1).toBe(2);
});
@@ -38,10 +37,10 @@ describe("junit reporter", () => {
expect(1 + 1).toBe(3);
});
});
});
`,
"test-2.test.js": `
describe("root describe", () => {
it("should pass", () => {
expect(1 + 1).toBe(2);
});
@@ -59,6 +58,7 @@ describe("junit reporter", () => {
expect(1 + 1).toBe(3);
});
});
});
`,
});
@@ -76,16 +76,14 @@ describe("junit reporter", () => {
const proc = spawn([bunExe(), "test", "--reporter=junit", "--reporter-outfile", junitPath], {
cwd: tmpDir,
env,
stdout: "inherit",
"stderr": "inherit",
stdout: "pipe",
stderr: "pipe",
});
await proc.exited;
console.log(junitPath);
expect(proc.exitCode).toBe(1);
const xmlContent = await file(junitPath).text();
// Parse XML to verify structure
const result = await new Promise((resolve, reject) => {
xml2js.parseString(xmlContent, (err, result) => {
if (err) reject(err);
@@ -93,85 +91,6 @@ describe("junit reporter", () => {
});
});
/**
* ------ Vitest ------
* <?xml version="1.0" encoding="UTF-8" ?>
* <testsuites name="vitest tests" tests="11" failures="4" errors="0" time="0.176">
* <testsuite name="passing.test.js" timestamp="2024-11-18T09:21:11.933Z" hostname="Jarreds-MacBook-Pro.local" tests="7" failures="2" errors="0" skipped="2" time="0.005116208">
* <testcase classname="passing.test.js" name="should pass" time="0.000657541">
* </testcase>
* <testcase classname="passing.test.js" name="second test" time="0.000071875">
* </testcase>
* <testcase classname="passing.test.js" name="failing test" time="0.003308209">
* <failure message="expected 2 to be 3 // Object.is equality" type="AssertionError">
* AssertionError: expected 2 to be 3 // Object.is equality
*
* - Expected
* + Received
*
* - 3
* + 2
*
* passing.test.js:12:25
* </failure>
* </testcase>
<testcase classname="passing.test.js" name="skipped test" time="0">
* <skipped/>
* </testcase>
* <testcase classname="passing.test.js" name="todo test" time="0">
* <skipped/>
* </testcase>
* <testcase classname="passing.test.js" name="nested describe &gt; should pass inside nested describe" time="0.000130042">
* </testcase>
* <testcase classname="passing.test.js" name="nested describe &gt; should fail inside nested describe" time="0.000403125">
* <failure message="expected 2 to be 3 // Object.is equality" type="AssertionError">
* AssertionError: expected 2 to be 3 // Object.is equality
*
* - Expected
* + Received
*
* - 3
* + 2
*
* passing.test.js:27:27
* </failure>
* </testcase>
</testsuite>
* <testsuite name="test-2.test.js" timestamp="2024-11-18T09:21:11.936Z" hostname="Jarreds-MacBook-Pro.local" tests="4" failures="2" errors="0" skipped="0" time="0.005188916">
* <testcase classname="test-2.test.js" name="should pass" time="0.000642541">
* </testcase>
* <testcase classname="test-2.test.js" name="failing test" time="0.003380708">
* <failure message="expected 2 to be 3 // Object.is equality" type="AssertionError">
* AssertionError: expected 2 to be 3 // Object.is equality
*
* - Expected
* + Received
*
* - 3
* + 2
*
* test-2.test.js:8:25
* </failure>
* </testcase>
* <testcase classname="test-2.test.js" name="nested describe &gt; should pass inside nested describe" time="0.000140541">
* </testcase>
* <testcase classname="test-2.test.js" name="nested describe &gt; should fail inside nested describe" time="0.000306">
* <failure message="expected 2 to be 3 // Object.is equality" type="AssertionError">
* AssertionError: expected 2 to be 3 // Object.is equality
*
* - Expected
* + Received
*
* - 3
* + 2
*
* test-2.test.js:17:27
* </failure>
* </testcase>
* </testsuite>
* </testsuites>
*/
expect(result.testsuites).toBeDefined();
expect(result.testsuites.testsuite).toBeDefined();
@@ -182,46 +101,45 @@ describe("junit reporter", () => {
[firstSuite, secondSuite] = [secondSuite, firstSuite];
}
expect(firstSuite.testcase).toHaveLength(4);
expect(firstSuite.testcase[0].$.name).toBe("should pass inside nested describe");
expect(firstSuite.$.name).toBe("test-2.test.js");
expect(firstSuite.$.file).toBe("test-2.test.js");
expect(firstSuite.$.tests).toBe("4");
expect(firstSuite.$.failures).toBe("2");
expect(firstSuite.$.skipped).toBe("0");
expect(parseFloat(firstSuite.$.time)).toBeGreaterThanOrEqual(0.0);
expect(Number.parseFloat(firstSuite.$.time)).toBeGreaterThanOrEqual(0.0);
const firstNestedSuite = firstSuite.testsuite[0];
expect(firstNestedSuite.$.name).toBe("root describe");
expect(firstNestedSuite.$.file).toBe("test-2.test.js");
expect(firstNestedSuite.$.line).toBe("2");
expect(firstNestedSuite.testcase[0].$.name).toBe("should pass");
expect(firstNestedSuite.testcase[0].$.file).toBe("test-2.test.js");
expect(firstNestedSuite.testcase[0].$.line).toBe("3");
expect(secondSuite.testcase).toHaveLength(7);
expect(secondSuite.testcase[0].$.name).toBe("should pass inside nested describe");
expect(secondSuite.$.name).toBe("passing.test.js");
expect(secondSuite.$.file).toBe("passing.test.js");
expect(secondSuite.$.tests).toBe("7");
expect(secondSuite.$.failures).toBe("2");
expect(secondSuite.$.skipped).toBe("2");
expect(parseFloat(secondSuite.$.time)).toBeGreaterThanOrEqual(0.0);
expect(Number.parseFloat(secondSuite.$.time)).toBeGreaterThanOrEqual(0.0);
const secondNestedSuite = secondSuite.testsuite[0];
expect(secondNestedSuite.$.name).toBe("root describe");
expect(secondNestedSuite.$.file).toBe("passing.test.js");
expect(secondNestedSuite.$.line).toBe("2");
const nestedTestCase = secondNestedSuite.testcase[0];
expect(nestedTestCase.$.name).toBe("should pass");
expect(nestedTestCase.$.file).toBe("passing.test.js");
expect(nestedTestCase.$.line).toBe("3");
expect(result.testsuites.$.tests).toBe("11");
expect(result.testsuites.$.failures).toBe("4");
expect(result.testsuites.$.skipped).toBe("2");
expect(parseFloat(result.testsuites.$.time)).toBeGreaterThanOrEqual(0.0);
expect(Number.parseFloat(result.testsuites.$.time)).toBeGreaterThanOrEqual(0.0);
if (withCIEnvironmentVariables) {
// "properties": [
// {
// "property": [
// {
// "$": {
// "name": "ci",
// "value": "https://ci.example.com/123"
// }
// },
// {
// "$": {
// "name": "commit",
// "value": "1234567890"
// }
// }
// ]
// }
// ],
expect(firstSuite.properties).toHaveLength(1);
expect(firstSuite.properties[0].property).toHaveLength(2);
expect(firstSuite.properties[0].property[0].$.name).toBe("ci");
@@ -230,5 +148,167 @@ describe("junit reporter", () => {
expect(firstSuite.properties[0].property[1].$.value).toBe("1234567890");
}
});
}
it("more scenarios", async () => {
const tmpDir = tempDirWithFiles("junit-comprehensive", {
"package.json": "{}",
"comprehensive.test.js": `
import { test, expect, describe } from "bun:test";
describe("comprehensive test suite", () => {
test("basic passing test", () => {
expect(1 + 1).toBe(2);
});
test("basic failing test", () => {
expect(1 + 1).toBe(3);
});
test.skip("basic skipped test", () => {
expect(1 + 1).toBe(2);
});
test.todo("basic todo test");
test.each([
[1, 2, 3],
[2, 3, 5],
[4, 5, 9]
])("addition %i + %i = %i", (a, b, expected) => {
expect(a + b).toBe(expected);
});
test.each([
["hello", "world", "helloworld"],
["foo", "bar", "foobar"]
])("string concat %s + %s = %s", (a, b, expected) => {
expect(a + b).toBe(expected);
});
test.if(true)("conditional test that runs", () => {
expect(1 + 1).toBe(2);
});
test.if(false)("conditional test that skips", () => {
expect(1 + 1).toBe(2);
});
test.skipIf(true)("skip if true", () => {
expect(1 + 1).toBe(2);
});
test.skipIf(false)("skip if false", () => {
expect(1 + 1).toBe(2);
});
test.todoIf(true)("todo if true");
test.todoIf(false)("todo if false", () => {
expect(1 + 1).toBe(2);
});
test.failing("test marked as failing", () => {
expect(1 + 1).toBe(3);
});
test("should match this test", () => {
expect(2 + 2).toBe(4);
});
test("should not be matched by filter", () => {
expect(3 + 3).toBe(6);
});
describe.each([
[10, 5],
[20, 10]
])("division suite %i / %i", (dividend, divisor) => {
test("should divide correctly", () => {
expect(dividend / divisor).toBe(dividend / divisor);
});
});
describe.if(true)("conditional describe that runs", () => {
test("nested test in conditional describe", () => {
expect(2 + 2).toBe(4);
});
});
describe.if(false)("conditional describe that skips", () => {
test("nested test that gets skipped", () => {
expect(2 + 2).toBe(4);
});
});
});
`,
});
const junitPath1 = `${tmpDir}/junit-all.xml`;
const proc1 = spawn([bunExe(), "test", "--reporter=junit", "--reporter-outfile", junitPath1], {
cwd: tmpDir,
env: { ...bunEnv, BUN_DEBUG_QUIET_LOGS: "1" },
stdout: "pipe",
stderr: "pipe",
});
await proc1.exited;
const xmlContent1 = await file(junitPath1).text();
const result1 = await new Promise((resolve, reject) => {
xml2js.parseString(xmlContent1, (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
expect(result1.testsuites).toBeDefined();
expect(result1.testsuites.testsuite).toBeDefined();
const suite1 = result1.testsuites.testsuite[0];
expect(suite1.$.name).toBe("comprehensive.test.js");
expect(Number.parseInt(suite1.$.tests)).toBeGreaterThan(10);
const junitPath2 = `${tmpDir}/junit-filtered.xml`;
const proc2 = spawn(
[bunExe(), "test", "-t", "should match", "--reporter=junit", "--reporter-outfile", junitPath2],
{
cwd: tmpDir,
env: { ...bunEnv, BUN_DEBUG_QUIET_LOGS: "1" },
stdout: "pipe",
stderr: "pipe",
},
);
await proc2.exited;
const xmlContent2 = await file(junitPath2).text();
const result2 = await new Promise((resolve, reject) => {
xml2js.parseString(xmlContent2, (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
const suite2 = result2.testsuites.testsuite[0];
expect(suite2.$.name).toBe("comprehensive.test.js");
expect(Number.parseInt(suite2.$.tests)).toBeGreaterThan(5);
expect(Number.parseInt(suite2.$.skipped)).toBeGreaterThan(3);
expect(xmlContent2).toContain("should match this test");
// even though it's not matched, juint should still include it
expect(xmlContent2).toContain("should not be matched by filter");
expect(xmlContent1).toContain("addition 1 + 2 = 3");
expect(xmlContent1).toContain("addition 2 + 3 = 5");
expect(xmlContent1).toContain("addition 4 + 5 = 9");
expect(xmlContent2).toContain("addition 1 + 2 = 3");
expect(xmlContent2).toContain("conditional describe that skips");
expect(xmlContent2).toContain("division suite 10 / 5");
expect(xmlContent2).toContain("division suite 20 / 10");
expect(xmlContent1).toContain("string concat hello + world = helloworld");
expect(xmlContent1).toContain("string concat foo + bar = foobar");
expect(xmlContent1).toContain("line=");
expect(xmlContent2).toContain("line=");
});
});