diff --git a/.vscode/settings.json b/.vscode/settings.json index 167a601132..651bf4004d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -168,4 +168,5 @@ "WebKit/WebInspectorUI": true, }, "git.detectSubmodules": false, + "bun.test.customScript": "bun-debug test" } diff --git a/cmake/tools/SetupWebKit.cmake b/cmake/tools/SetupWebKit.cmake index 0bb8b909ac..b572088db5 100644 --- a/cmake/tools/SetupWebKit.cmake +++ b/cmake/tools/SetupWebKit.cmake @@ -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) diff --git a/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts b/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts index a823de2873..a67ec21774 100644 --- a/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts +++ b/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts @@ -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 this.send("Inspector.enable"); this.send("Runtime.enable"); - this.send("Console.enable"); + + if (request.enableConsole ?? true) { + this.send("Console.enable"); + } if (request.enableControlFlowProfiler) { this.send("Runtime.enableControlFlowProfiler"); @@ -473,6 +478,10 @@ export abstract class BaseDebugAdapter } } + 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) { diff --git a/packages/bun-inspector-protocol/src/protocol/jsc/index.d.ts b/packages/bun-inspector-protocol/src/protocol/jsc/index.d.ts index cc731f116a..e4c69554cb 100644 --- a/packages/bun-inspector-protocol/src/protocol/jsc/index.d.ts +++ b/packages/bun-inspector-protocol/src/protocol/jsc/index.d.ts @@ -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 diff --git a/packages/bun-inspector-protocol/src/protocol/jsc/protocol.json b/packages/bun-inspector-protocol/src/protocol/jsc/protocol.json index 8fb868df32..0b2b4d7d07 100644 --- a/packages/bun-inspector-protocol/src/protocol/jsc/protocol.json +++ b/packages/bun-inspector-protocol/src/protocol/jsc/protocol.json @@ -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 } ] }, diff --git a/packages/bun-vscode/README.md b/packages/bun-vscode/README.md index 18a72b9464..f373c5a4b8 100644 --- a/packages/bun-vscode/README.md +++ b/packages/bun-vscode/README.md @@ -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", } ``` diff --git a/packages/bun-vscode/bun.lock b/packages/bun-vscode/bun.lock index f1f4f941cc..904850b580 100644 --- a/packages/bun-vscode/bun.lock +++ b/packages/bun-vscode/bun.lock @@ -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=="], diff --git a/packages/bun-vscode/example/demo.test.ts b/packages/bun-vscode/example/demo.test.ts new file mode 100644 index 0000000000..9e7405802f --- /dev/null +++ b/packages/bun-vscode/example/demo.test.ts @@ -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); +} diff --git a/packages/bun-vscode/package.json b/packages/bun-vscode/package.json index eab9bcfcef..6128d9bdd6 100644 --- a/packages/bun-vscode/package.json +++ b/packages/bun-vscode/package.json @@ -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" diff --git a/packages/bun-vscode/src/extension.ts b/packages/bun-vscode/src/extension.ts index 8917a36f8d..94e1c0f9cf 100644 --- a/packages/bun-vscode/src/extension.ts +++ b/packages/bun-vscode/src/extension.ts @@ -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)); diff --git a/packages/bun-vscode/src/features/tests/bun-test-controller.ts b/packages/bun-vscode/src/features/tests/bun-test-controller.ts new file mode 100644 index 0000000000..5137996581 --- /dev/null +++ b/packages/bun-vscode/src/features/tests/bun-test-controller.ts @@ -0,0 +1,1325 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as net from "node:net"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import { + getAvailablePort, + NodeSocketDebugAdapter, + TCPSocketSignal, + UnixSignal, +} from "../../../../bun-debug-adapter-protocol"; +import type { JSC } from "../../../../bun-inspector-protocol"; + +const DEFAULT_TEST_PATTERN = "**/*{.test.,.spec.,_test_,_spec_}{js,ts,tsx,jsx,mts,cts,cjs,mjs}"; + +export const debug = vscode.window.createOutputChannel("Bun - Test Runner"); + +export type TestNode = { + name: string; + type: "describe" | "test" | "it"; + line: number; + children: TestNode[]; + parent?: TestNode; + startIdx: number; +}; + +export interface TestError { + message: string; + file: string; + line: number; + column: number; +} + +export class BunTestController implements vscode.Disposable { + private disposables: vscode.Disposable[] = []; + private activeProcesses: Set = new Set(); + private debugAdapter: NodeSocketDebugAdapter | null = null; + private signal: UnixSignal | TCPSocketSignal | null = null; + + private inspectorToVSCode = new Map(); + private vscodeToInspector = new Map(); + + private testErrors = new Map(); + private lastStartedTestId: number | null = null; + private currentRun: vscode.TestRun | null = null; + + private testResultHistory = new Map< + string, + { status: "passed" | "failed" | "skipped"; message?: vscode.TestMessage; duration?: number } + >(); + private currentRunType: "file" | "individual" = "file"; + private requestedTestIds: Set = new Set(); + private discoveredTestIds: Set = new Set(); + + constructor( + private readonly testController: vscode.TestController, + private readonly workspaceFolder: vscode.WorkspaceFolder, + ) { + this.setupTestController(); + this.setupWatchers(); + this.setupOpenDocumentListener(); + this.discoverInitialTests(); + this.initializeSignal(); + } + + private async initializeSignal(): Promise { + try { + this.signal = await this.createSignal(); + await this.signal.ready; + debug.appendLine(`Signal initialized at: ${this.signal.url}`); + + this.signal.on("Signal.Socket.connect", (socket: net.Socket) => { + debug.appendLine("Bun connected to signal socket"); + this.handleSocketConnection(socket, this.currentRun!); + }); + + this.signal.on("Signal.error", (error: Error) => { + debug.appendLine(`Signal error: ${error.message}`); + }); + } catch (error) { + debug.appendLine(`Failed to initialize signal: ${error}`); + } + } + + private setupTestController(): void { + this.testController.resolveHandler = async testItem => { + if (!testItem) return; + return this.discoverTests(testItem); + }; + + this.testController.refreshHandler = async token => { + const files = await this.discoverInitialTests(token); + if (!files?.length) return; + + const filePaths = new Set(files.map(f => f.fsPath)); + for (const [, testItem] of this.testController.items) { + if (testItem.uri && !filePaths.has(testItem.uri.fsPath)) { + this.testController.items.delete(testItem.id); + } + } + }; + + this.testController.createRunProfile( + "Run Test", + vscode.TestRunProfileKind.Run, + (request, token) => this.runHandler(request, token, false), + true, + ); + + this.testController.createRunProfile( + "Debug", + vscode.TestRunProfileKind.Debug, + (request, token) => this.runHandler(request, token, true), + true, + ); + } + + private setupOpenDocumentListener(): void { + vscode.window.visibleTextEditors.forEach(editor => { + this.handleOpenDocument(editor.document); + }); + + vscode.workspace.textDocuments.forEach(doc => { + this.handleOpenDocument(doc); + }); + + vscode.workspace.onDidOpenTextDocument(this.handleOpenDocument.bind(this), null, this.disposables); + } + + private handleOpenDocument(document: vscode.TextDocument): void { + if (this.isTestFile(document) && !this.testController.items.get(windowsVscodeUri(document.uri.fsPath))) { + this.discoverTests(false, windowsVscodeUri(document.uri.fsPath)); + } + } + + private isTestFile(document: vscode.TextDocument): boolean { + return document?.uri?.scheme === "file" && /\.(test|spec)\.(js|jsx|ts|tsx|cjs|mts)$/.test(document.uri.fsPath); + } + + private async discoverInitialTests(cancellationToken?: vscode.CancellationToken): Promise { + try { + const tests = await this.findTestFiles(cancellationToken); + this.createFileTestItems(tests); + return tests; + } catch { + return undefined; + } + } + + private customFilePattern(): string { + return vscode.workspace.getConfiguration("bun.test").get("filePattern", DEFAULT_TEST_PATTERN); + } + + private async findTestFiles(cancellationToken?: vscode.CancellationToken): Promise { + const ignoreGlobs = await this.buildIgnoreGlobs(cancellationToken); + const tests = await vscode.workspace.findFiles( + this.customFilePattern(), + "node_modules", + undefined, + cancellationToken, + ); + + return tests.filter(test => { + const normalizedTestPath = test.fsPath.replace(/\\/g, "/"); + return !ignoreGlobs.some(glob => { + const normalizedGlob = glob.replace(/\\/g, "/").replace(/^\.\//, ""); + return normalizedTestPath.includes(normalizedGlob); + }); + }); + } + + private async buildIgnoreGlobs(cancellationToken?: vscode.CancellationToken): Promise { + const ignores = await vscode.workspace.findFiles( + "**/.gitignore", + "**/node_modules/**", + undefined, + cancellationToken, + ); + const ignoreGlobs = new Set(["**/node_modules/**"]); + + for (const ignore of ignores) { + try { + const content = await fs.readFile(ignore.fsPath, { encoding: "utf8" }); + const lines = content + .split("\n") + .map(line => line.trim()) + .filter(line => line && !line.startsWith("#")); + + const cwd = path.relative(this.workspaceFolder.uri.fsPath, path.dirname(ignore.fsPath)); + + for (const line of lines) { + if (!cwd || cwd === "" || cwd === ".") { + ignoreGlobs.add(line.trim()); + } else { + ignoreGlobs.add(path.join(cwd.trim(), line.trim())); + } + } + } catch {} + } + + return [...ignoreGlobs.values()]; + } + + private createFileTestItems(files: vscode.Uri[]): void { + if (files.length === 0) { + return; + } + + for (const file of files) { + let fileTestItem = this.testController.items.get(windowsVscodeUri(file.fsPath)); + if (!fileTestItem) { + fileTestItem = this.testController.createTestItem( + file.toString(), + path.relative(this.workspaceFolder.uri.fsPath, file.fsPath) || file.fsPath, + file, + ); + fileTestItem.children.replace([]); + fileTestItem.canResolveChildren = true; + this.testController.items.add(fileTestItem); + } + } + } + + private async setupWatchers(): Promise { + const fileWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(this.workspaceFolder, this.customFilePattern()), + ); + + const refreshTestsForFile = (uri: vscode.Uri) => { + if (uri.toString().includes("node_modules")) return; + + const existing = this.testController.items.get(windowsVscodeUri(uri.fsPath)); + if (existing) { + existing.children.replace([]); + this.discoverTests(existing); + } else { + this.discoverTests(false, uri.fsPath); + } + }; + + fileWatcher.onDidChange(refreshTestsForFile); + fileWatcher.onDidCreate(refreshTestsForFile); + fileWatcher.onDidDelete(uri => { + const existing = this.testController.items.get(windowsVscodeUri(uri.fsPath)); + if (existing) { + existing.children.replace([]); + this.testController.items.delete(existing.id); + } + }); + + this.disposables.push(fileWatcher); + } + + private getBunExecutionConfig() { + 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"; + + const [cmd, ...args] = customScript.split(/\s+/); + + let bunCommand = "bun"; + if (cmd === "bun") { + const bunRuntime = vscode.workspace.getConfiguration("bun").get("runtime", "bun"); + bunCommand = bunRuntime || "bun"; + } else { + bunCommand = cmd; + } + + const testArgs = args.length ? args : ["test"]; + if (customFlag) { + testArgs.push(customFlag); + } + + return { bunCommand, testArgs }; + } + + private async discoverTests(testItem?: vscode.TestItem | false, filePath?: string): Promise { + let targetPath = filePath; + if (!targetPath && testItem) { + targetPath = testItem?.uri?.fsPath || this.workspaceFolder.uri.fsPath; + } + if (!targetPath) { + return; + } + + try { + const fileContent = await fs.readFile(targetPath, "utf8"); + const testNodes = this.parseTestBlocks(fileContent); + + const fileUri = vscode.Uri.file(windowsVscodeUri(targetPath)); + let fileTestItem = testItem || this.testController.items.get(windowsVscodeUri(targetPath)); + if (!fileTestItem) { + fileTestItem = this.testController.createTestItem( + fileUri.toString(), + path.relative(this.workspaceFolder.uri.fsPath, targetPath), + fileUri, + ); + this.testController.items.add(fileTestItem); + } + fileTestItem.children.replace([]); + fileTestItem.canResolveChildren = false; + + this.addTestNodes(testNodes, fileTestItem, targetPath); + } catch {} + } + + private parseTestBlocks(fileContent: string): TestNode[] { + const cleanContent = fileContent + .replace(/\/\*[\s\S]*?\*\//g, match => match.replace(/[^\n\r]/g, " ")) + .replace(/\/\/.*$/gm, match => " ".repeat(match.length)); + + const testRegex = + /\b(describe|test|it)(?:\.(?:skip|todo|failing|only))?(?:\.(?:if|todoIf|skipIf)\s*\([^)]*\))?(?:\.each\s*\([^)]*\))?\s*\(\s*(['"`])((?:\\\2|.)*?)\2\s*(?:,|\))/g; + + const stack: TestNode[] = []; + const root: TestNode[] = []; + let match: RegExpExecArray | null; + + match = testRegex.exec(cleanContent); + while (match !== null) { + const [full, type, , name] = match; + const line = cleanContent.slice(0, match.index).split("\n").length - 1; + + while ( + stack.length > 0 && + match.index > stack[stack.length - 1].startIdx && + this.getBraceDepth(cleanContent, stack[stack.length - 1].startIdx, match.index) <= 0 + ) { + stack.pop(); + } + + const expandedNodes = this.expandEachTests(full, name, cleanContent, match.index, type as TestNode["type"], line); + + for (const node of expandedNodes) { + if (stack.length === 0) { + root.push(node); + } else { + stack[stack.length - 1].children.push(node); + } + + if (type === "describe") { + stack.push(node); + } + } + match = testRegex.exec(cleanContent); + } + + return root; + } + + private getBraceDepth(content: string, start: number, end: number): number { + const section = content.slice(start, end); + let depth = 0; + let inString = false; + let inTemplate = false; + let stringChar = ""; + let escaped = false; + + for (let i = 0; i < section.length; i++) { + const char = section[i]; + + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + + if (!inTemplate && (char === '"' || char === "'")) { + if (!inString) { + inString = true; + stringChar = char; + } else if (char === stringChar) { + inString = false; + } + continue; + } + + if (char === "`") { + inTemplate = !inTemplate; + continue; + } + + if (!inString && !inTemplate) { + if (char === "{") depth++; + else if (char === "}") depth--; + } + } + + return depth; + } + + private expandEachTests( + fullMatch: string, + name: string, + content: string, + index: number, + type: TestNode["type"], + line: number, + ): TestNode[] { + if (!fullMatch.includes(".each")) { + return [ + { + name: name.replace(/\\/g, ""), + type, + line, + children: [], + startIdx: index, + }, + ]; + } + + const eachMatch = content.slice(index).match(/\.each\s*\(\s*(\[[\s\S]*?\])\s*\)/); + if (!eachMatch) { + return [ + { + name: name.replace(/\\/g, ""), + type, + line, + children: [], + startIdx: index, + }, + ]; + } + + const arrayString = eachMatch[1].replace(/,\s*(?=[\]\}])/g, ""); + + try { + const eachValues = JSON.parse(arrayString); + if (!Array.isArray(eachValues)) { + throw new Error("Not an array"); + } + + return eachValues.map(val => { + let testName = name; + if (Array.isArray(val)) { + let idx = 0; + testName = testName.replace(/%[isfd]/g, () => { + const v = val[idx++]; + return typeof v === "object" ? JSON.stringify(v) : String(v); + }); + } else { + testName = testName.replace(/%[isfd]/g, () => { + return typeof val === "object" ? JSON.stringify(val) : String(val); + }); + } + + return { + name: testName, + type, + line, + children: [], + startIdx: index, + }; + }); + } catch { + return [ + { + name: name.replace(/\\/g, ""), + type, + line, + children: [], + startIdx: index, + }, + ]; + } + } + + private addTestNodes(nodes: TestNode[], parent: vscode.TestItem, filePath: string, parentPath = ""): void { + for (const node of nodes) { + const nodePath = parentPath + ? `${parentPath} > ${this.escapeTestName(node.name)}` + : this.escapeTestName(node.name); + const testId = `${filePath}#${nodePath}`; + + const testItem = this.testController.createTestItem(testId, this.stripAnsi(node.name), vscode.Uri.file(filePath)); + + testItem.tags = [new vscode.TestTag(node.type === "describe" ? "describe" : "test")]; + + if (typeof node.line === "number") { + testItem.range = new vscode.Range( + new vscode.Position(node.line, 0), + new vscode.Position(node.line, node.name.length), + ); + } + + parent.children.add(testItem); + + if (node.children.length > 0) { + this.addTestNodes(node.children, testItem, filePath, nodePath); + } + testItem.canResolveChildren = false; + } + } + + private stripAnsi(source: string): string { + return source.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nqry=><]/g, ""); + } + + private escapeTestName(source: string): string { + return source.replace(/[^a-zA-Z0-9_\ ]/g, "\\$&"); + } + + private async createSignal(): Promise { + if (process.platform === "win32") { + const port = await getAvailablePort(); + return new TCPSocketSignal(port); + } else { + return new UnixSignal(); + } + } + + private async runHandler( + request: vscode.TestRunRequest, + token: vscode.CancellationToken, + isDebug: boolean, + ): Promise { + const run = this.testController.createTestRun(request); + + token.onCancellationRequested(() => { + run.end(); + this.closeAllActiveProcesses(); + this.disconnectInspector(); + }); + + const queue: vscode.TestItem[] = []; + + if (request.include) { + for (const test of request.include) { + queue.push(test); + } + } else { + for (const [, test] of this.testController.items) { + queue.push(test); + } + } + + if (isDebug) { + await this.debugTests(queue, request, run); + run.end(); + return; + } + + try { + await this.runTestsWithInspector(queue, run, token); + } catch (error) { + for (const test of queue) { + run.errored(test, new vscode.TestMessage(`Error: ${error}`)); + } + } finally { + run.end(); + } + } + + private async runTestsWithInspector( + tests: vscode.TestItem[], + run: vscode.TestRun, + _token: vscode.CancellationToken, + ): Promise { + this.disconnectInspector(); + + const allFiles = new Set(); + for (const test of tests) { + if (!test.uri) continue; + const filePath = windowsVscodeUri(test.uri.fsPath); + allFiles.add(filePath); + } + + if (allFiles.size === 0) { + run.appendOutput("No test files found to run.\n"); + return; + } + + for (const test of tests) { + if (test.uri && test.canResolveChildren) { + await this.discoverTests(test); + } + } + + const isIndividualTestRun = this.shouldUseTestNamePattern(tests); + this.currentRunType = isIndividualTestRun ? "individual" : "file"; + + this.requestedTestIds.clear(); + this.discoveredTestIds.clear(); + for (const test of tests) { + this.requestedTestIds.add(test.id); + } + + if (!this.signal) { + await this.initializeSignal(); + if (!this.signal) { + throw new Error("Failed to initialize signal"); + } + } + + this.currentRun = run; + + const socketPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timeout waiting for Bun to connect")); + }, 10000); + + const handleConnect = () => { + clearTimeout(timeout); + resolve(); + }; + + this.signal!.once("Signal.Socket.connect", handleConnect); + }); + + const { bunCommand, testArgs } = this.getBunExecutionConfig(); + let args = [...testArgs, ...Array.from(allFiles)]; + + if (isIndividualTestRun) { + const pattern = this.buildTestNamePattern(tests); + if (pattern) { + args.push("--test-name-pattern", process.platform === "win32" ? `"${pattern}"` : pattern); + } + } + + run.appendOutput(`\r\n\x1b[34m>\x1b[0m \x1b[2m${bunCommand} ${args.join(" ")}\x1b[0m\r\n\r\n`); + args.push(`--inspect-wait=${this.signal!.url}`); + + for (const test of tests) { + if (isIndividualTestRun || tests.length === 1) { + run.started(test); + } else { + run.enqueued(test); + } + } + + const proc = spawn(bunCommand, args, { + cwd: this.workspaceFolder.uri.fsPath, + env: { + ...process.env, + BUN_DEBUG_QUIET_LOGS: "1", + FORCE_COLOR: "1", + NO_COLOR: "0", + }, + }); + + this.activeProcesses.add(proc); + + proc.on("exit", (code, signal) => { + debug.appendLine(`Process exited with code ${code}, signal ${signal}`); + }); + + proc.on("error", error => { + debug.appendLine(`Process error: ${error.message}`); + }); + + proc.stdout?.on("data", data => { + const dataStr = data.toString(); + const formattedOutput = dataStr.replace(/\n/g, "\r\n"); + run.appendOutput(formattedOutput); + }); + + proc.stderr?.on("data", data => { + const dataStr = data.toString(); + const formattedOutput = dataStr.replace(/\n/g, "\r\n"); + run.appendOutput(formattedOutput); + }); + + try { + await socketPromise; + } catch (error) { + debug.appendLine(`Failed to establish inspector connection: ${error}`); + debug.appendLine(`Signal URL was: ${this.signal!.url}`); + debug.appendLine(`Command was: ${bunCommand} ${args.join(" ")}`); + throw error; + } + + await new Promise((resolve, reject) => { + proc.on("close", code => { + this.activeProcesses.delete(proc); + if (code === 0 || code === 1) { + resolve(); + } else { + reject(new Error(`Process exited with code ${code}`)); + } + }); + + proc.on("error", error => { + this.activeProcesses.delete(proc); + reject(error); + }); + }).finally(() => { + if (isIndividualTestRun) { + this.applyPreviousResults(tests, run); + } + + if (isIndividualTestRun) { + this.cleanupUndiscoveredTests(tests); + } else { + this.cleanupStaleTests(tests); + } + + if (this.activeProcesses.has(proc)) { + proc.kill("SIGKILL"); + this.activeProcesses.delete(proc); + } + + this.disconnectInspector(); + this.currentRun = null; + }); + } + + private applyPreviousResults(requestedTests: vscode.TestItem[], run: vscode.TestRun): void { + for (const file of new Set(requestedTests.map(t => t.uri?.toString()).filter(Boolean))) { + const fileItem = this.testController.items.get(file!); + if (fileItem) { + this.applyPreviousResultsToItem(fileItem, run, this.requestedTestIds); + } + } + } + + private applyPreviousResultsToItem(item: vscode.TestItem, run: vscode.TestRun, requestedTestIds: Set): void { + if (!requestedTestIds.has(item.id)) { + const previousResult = this.testResultHistory.get(item.id); + if (previousResult) { + switch (previousResult.status) { + case "passed": + run.passed(item, previousResult.duration); + break; + case "failed": + run.failed(item, previousResult.message || new vscode.TestMessage("Test failed"), previousResult.duration); + break; + case "skipped": + run.skipped(item); + break; + } + } + } + + for (const [, child] of item.children) { + this.applyPreviousResultsToItem(child, run, requestedTestIds); + } + } + + private async handleSocketConnection(socket: net.Socket, run: vscode.TestRun): Promise { + if (this.debugAdapter) { + this.debugAdapter.close(); + this.debugAdapter = null; + } + + this.debugAdapter = new NodeSocketDebugAdapter(socket); + + this.debugAdapter.on("TestReporter.found", event => { + this.handleTestFound(event, run); + }); + + this.debugAdapter.on("TestReporter.start", event => { + this.handleTestStart(event, run); + }); + + this.debugAdapter.on("TestReporter.end", event => { + this.handleTestEnd(event, run); + }); + + this.debugAdapter.on("LifecycleReporter.error", event => { + this.handleLifecycleError(event, run); + }); + + this.debugAdapter.on("Inspector.event", e => { + debug.appendLine(`Received inspector event: ${e.method}`); + }); + + this.debugAdapter.on("Inspector.error", e => { + debug.appendLine(`Inspector error: ${e}`); + }); + + socket.on("close", () => { + debug.appendLine("Inspector connection closed"); + this.debugAdapter = null; + }); + + const ok = await this.debugAdapter.start(); + if (!ok) { + throw new Error("Failed to start debug adapter"); + } + + this.debugAdapter.initialize({ + adapterID: "bun-vsc-test-runner", + pathFormat: "path", + linesStartAt1: true, + columnsStartAt1: true, + supportsConfigurationDoneRequest: false, + enableDebugger: false, + enableLifecycleAgentReporter: true, + enableTestReporter: true, + enableConsole: false, + sendImmediatePreventExit: false, + }); + } + + private handleTestFound(params: JSC.TestReporter.FoundEvent, _run: vscode.TestRun): void { + const { id: inspectorTestId, url: sourceURL, name, type, parentId, line } = params; + + if (!sourceURL) { + debug.appendLine(`Warning: Test found without URL: ${name}`); + return; + } + + const filePath = windowsVscodeUri(sourceURL); + let testItem = this.findTestByPath(name!, filePath, parentId); + + if (!testItem && type) { + testItem = this.createTestItem(name!, filePath, type, parentId, line); + } + + if (testItem) { + this.inspectorToVSCode.set(inspectorTestId, testItem); + this.vscodeToInspector.set(testItem.id, inspectorTestId); + this.discoveredTestIds.add(testItem.id); + } else { + debug.appendLine(`Could not find VS Code test item for: ${name} in ${path.basename(filePath)}`); + } + } + + private findTestByPath(testName: string, filePath: string, parentId?: number): vscode.TestItem | undefined { + const fileUri = vscode.Uri.file(filePath); + const fileTestItem = this.testController.items.get(fileUri.toString()); + + if (!fileTestItem) { + return undefined; + } + + let searchRoot = fileTestItem; + if (parentId !== undefined) { + const parentItem = this.inspectorToVSCode.get(parentId); + if (parentItem) { + searchRoot = parentItem; + } + } + + return this.findTestByName(searchRoot, testName); + } + + private findTestByName(parent: vscode.TestItem, name: string): vscode.TestItem | undefined { + const strippedName = this.stripAnsi(name); + + for (const [, child] of parent.children) { + if (child.label === strippedName) { + return child; + } + } + + const escapedName = this.escapeTestName(strippedName); + for (const [, child] of parent.children) { + if (child.label === escapedName || this.escapeTestName(child.label) === escapedName) { + return child; + } + } + + for (const [, child] of parent.children) { + const found = this.findTestByName(child, name); + if (found) { + return found; + } + } + + return undefined; + } + + private createTestItem( + name: string, + filePath: string, + type: "test" | "describe", + parentId?: number, + line?: number, + ): vscode.TestItem | undefined { + const fileUri = vscode.Uri.file(filePath); + + let fileTestItem = this.testController.items.get(fileUri.toString()); + if (!fileTestItem) { + fileTestItem = this.testController.createTestItem( + fileUri.toString(), + path.relative(this.workspaceFolder.uri.fsPath, filePath) || filePath, + fileUri, + ); + this.testController.items.add(fileTestItem); + } + + let parentItem = fileTestItem; + if (parentId !== undefined) { + const parent = this.inspectorToVSCode.get(parentId); + if (parent) { + parentItem = parent; + } + } + + const parentPath = parentItem === fileTestItem ? "" : parentItem.id.split("#")[1] || ""; + const testPath = parentPath ? `${parentPath} > ${this.escapeTestName(name)}` : this.escapeTestName(name); + const testId = `${filePath}#${testPath}`; + + const existing = this.findTestByName(parentItem, name); + if (existing) { + return existing; + } + + const testItem = this.testController.createTestItem(testId, this.stripAnsi(name), fileUri); + testItem.tags = [new vscode.TestTag(type)]; + testItem.canResolveChildren = false; + + if (typeof line === "number" && line > 0) { + testItem.range = new vscode.Range(new vscode.Position(line - 1, 0), new vscode.Position(line - 1, name.length)); + } + + parentItem.children.add(testItem); + + return testItem; + } + + private handleTestStart(params: JSC.TestReporter.StartEvent, run: vscode.TestRun): void { + const { id: testId } = params; + const testItem = this.inspectorToVSCode.get(testId); + + this.lastStartedTestId = testId; + + if (testItem) { + run.started(testItem); + } + } + + private handleTestEnd(params: JSC.TestReporter.EndEvent, run: vscode.TestRun): void { + const { id, status, elapsed } = params; + const testItem = this.inspectorToVSCode.get(id); + + if (!testItem) return; + + const duration = elapsed / 1000000; + + if ( + this.currentRunType === "individual" && + status === "skipped_because_label" && + !this.requestedTestIds.has(testItem.id) + ) { + return; + } + + switch (status) { + case "pass": + run.passed(testItem, duration); + this.testResultHistory.set(testItem.id, { status: "passed", duration }); + break; + case "fail": + const errorInfo = this.testErrors.get(id); + if (errorInfo) { + const errorMessage = this.createErrorMessage(errorInfo, testItem); + run.failed(testItem, errorMessage, duration); + this.testResultHistory.set(testItem.id, { status: "failed", message: errorMessage, duration }); + } else { + const message = new vscode.TestMessage(`Test "${testItem.label}" failed - check output for details`); + run.failed(testItem, message, duration); + this.testResultHistory.set(testItem.id, { status: "failed", message, duration }); + } + break; + case "skip": + case "todo": + case "skipped_because_label": + run.skipped(testItem); + this.testResultHistory.set(testItem.id, { status: "skipped" }); + break; + case "timeout": + const timeoutMsg = new vscode.TestMessage( + duration > 0 ? `Test timed out after ${duration.toFixed(0)}ms` : "Test timed out", + ); + run.failed(testItem, timeoutMsg, duration); + this.testResultHistory.set(testItem.id, { status: "failed", message: timeoutMsg, duration }); + break; + } + } + + private handleLifecycleError(params: JSC.LifecycleReporter.ErrorEvent, _run: vscode.TestRun): void { + const { message, urls, lineColumns } = params; + + if (!urls || urls.length === 0 || !urls[0]) { + return; + } + + const filePath = windowsVscodeUri(urls[0]); + const line = lineColumns && lineColumns.length > 0 ? lineColumns[0] : 1; + const column = lineColumns && lineColumns.length > 1 ? lineColumns[1] : 1; + + const errorInfo: TestError = { + message, + file: filePath, + line, + column, + }; + + if (this.lastStartedTestId !== null) { + this.testErrors.set(this.lastStartedTestId, errorInfo); + } + } + + private cleanupUndiscoveredTests(requestedTests: vscode.TestItem[]): void { + if (this.currentRunType !== "individual" || this.discoveredTestIds.size === 0) { + return; + } + + const filesToCheck = new Set(); + for (const test of requestedTests) { + if (test.uri) { + filesToCheck.add(test.uri.toString()); + } + } + + for (const fileUri of filesToCheck) { + const fileItem = this.testController.items.get(fileUri); + if (fileItem) { + this.cleanupTestItem(fileItem); + } + } + } + + private cleanupTestItem(item: vscode.TestItem): void { + const childrenToRemove: vscode.TestItem[] = []; + + for (const [, child] of item.children) { + if (!this.discoveredTestIds.has(child.id)) { + childrenToRemove.push(child); + } else { + this.cleanupTestItem(child); + } + } + + for (const child of childrenToRemove) { + item.children.delete(child.id); + } + } + + private cleanupStaleTests(requestedTests: vscode.TestItem[]): void { + if (this.discoveredTestIds.size === 0) { + return; + } + + const filesToCheck = new Set(); + for (const test of requestedTests) { + if (test.uri) { + filesToCheck.add(test.uri.toString()); + } + } + + for (const fileUri of filesToCheck) { + const fileItem = this.testController.items.get(fileUri); + if (fileItem) { + const hasTestsInThisFile = Array.from(this.discoveredTestIds).some(id => + id.startsWith(fileItem.uri?.fsPath || ""), + ); + if (hasTestsInThisFile) { + this.cleanupTestItem(fileItem); + } + } + } + } + + private createErrorMessage(errorInfo: TestError, _testItem: vscode.TestItem): vscode.TestMessage { + const cleanMessage = errorInfo.message.replace( + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRFZcf-nqry=><]/g, + "", + ); + + const errorMessage = this.processErrorData( + cleanMessage, + new vscode.Location( + vscode.Uri.file(errorInfo.file), + new vscode.Position(errorInfo.line - 1, errorInfo.column - 1), + ), + ); + return errorMessage; + } + + private processErrorData(message: string, location: vscode.Location): vscode.TestMessage { + const messageLinesRaw = message.split("\n"); + const lines = messageLinesRaw; + + const errorLine = lines[0].trim(); + const messageLines = lines.slice(1).join("\n"); + + const errorType = errorLine.replace(/^(E|e)rror: /, "").trim(); + + switch (errorType) { + case "expect(received).toMatchInlineSnapshot(expected)": + case "expect(received).toMatchSnapshot(expected)": + case "expect(received).toEqual(expected)": + case "expect(received).toBe(expected)": { + const regex = /^Expected:\s*([\s\S]*?)\nReceived:\s*([\s\S]*?)$/; + let testMessage = vscode.TestMessage.diff( + errorLine, + messageLines.match(regex)?.[1].trim() || "", + messageLines.match(regex)?.[2].trim() || "", + ); + if (!messageLines.match(regex)) { + const code = messageLines + .replace(/(?:\r?\n)+(- Expected\s+- \d+|\+ Received\s+\+ \d+)\s*$/g, "") + .replace(/(?:\r?\n)+(- Expected\s+- \d+|\+ Received\s+\+ \d+)\s*$/g, "") + .trim(); + testMessage = new vscode.TestMessage( + new vscode.MarkdownString("Values did not match:\n").appendCodeblock(code, "diff"), + ); + } + testMessage.location = location; + return testMessage; + } + + case "expect(received).toBeInstanceOf(expected)": { + const regex = /^Expected constructor:\s*([\s\S]*?)\nReceived value:\s*([\s\S]*?)$/; + let testMessage = vscode.TestMessage.diff( + errorLine, + messageLines.match(regex)?.[1].trim() || "", + messageLines.match(regex)?.[2].trim() || "", + ); + if (!messageLines.match(regex)) { + testMessage = new vscode.TestMessage(messageLines); + } + testMessage.location = location; + return testMessage; + } + + case "expect(received).not.toBe(expected)": + case "expect(received).not.toEqual(expected)": { + const testMessage = new vscode.TestMessage(messageLines); + testMessage.location = location; + return testMessage; + } + + case "expect(received).toBeNull()": { + const actualValue = messageLines.replace("Received:", "").trim(); + const testMessage = vscode.TestMessage.diff(errorLine, "null", actualValue); + testMessage.location = location; + return testMessage; + } + + case "expect(received).toMatchObject(expected)": { + const line = messageLines + .replace(/(?:\r?\n)+(- Expected\s+- \d+|\+ Received\s+\+ \d+)\s*$/g, "") + .replace(/(?:\r?\n)+(- Expected\s+- \d+|\+ Received\s+\+ \d+)\s*$/g, ""); + + const formatted = new vscode.MarkdownString("Values did not match:"); + formatted.appendCodeblock(line, "diff"); + const testMessage = new vscode.TestMessage(formatted); + testMessage.location = location; + return testMessage; + } + } + + let lastEffortMsg = messageLines.split("\n"); + const lastLine = lastEffortMsg?.at(-1); + if (lastLine?.startsWith("Received ") || lastLine?.startsWith("Received: ")) { + lastEffortMsg = lastEffortMsg.reverse(); + } + + const msg = errorLine.startsWith("error: expect") + ? `${lastEffortMsg.join("\n")}\n${errorLine.trim()}`.trim() + : `${errorLine.trim()}\n${messageLines}`.trim(); + + const testMessage = new vscode.TestMessage(msg); + testMessage.location = location; + return testMessage; + } + + private shouldUseTestNamePattern(tests: vscode.TestItem[]): boolean { + const testUriString = tests[0]?.uri?.toString(); + const testIdEndsWithFileName = tests[0]?.uri && tests[0].label === tests[0].uri.fsPath.split("/").pop(); + + const isFileOnly = + tests.length === 1 && + tests[0].uri && + (testIdEndsWithFileName || !tests[0].id.includes("#") || tests[0].id === testUriString); + + function hasManyTests() { + if (tests.length === 0) return false; + let current = tests[0]; + while (current.parent) { + if (current.parent.children.size > 1) { + return true; + } + current = current.parent; + } + return false; + } + + return !isFileOnly && hasManyTests(); + } + + private buildTestNamePattern(tests: vscode.TestItem[]): string | null { + const testNames: string[] = []; + + for (const test of tests) { + if (!test.id.includes("#")) { + continue; + } + + let t = test.id + .slice(test.id.indexOf("#") + 1) + .split(" > ") + .join(" "); + + t = t.replaceAll(/\$\{[^}]+\}/g, ".*?"); + t = t.replaceAll(/\\\$\\\{[^}]+\\\}/g, ".*?"); + t = t.replaceAll(/\\%[isfd]/g, ".*?"); + + if (test.tags.some(tag => tag.id === "test" || tag.id === "it")) { + testNames.push(`^ ${t}$`); + } else { + testNames.push(`^ ${t} `); + } + } + + if (testNames.length === 0) { + return null; + } + + return testNames.map(e => `(${e})`).join("|"); + } + + private disconnectInspector(): void { + if (this.debugAdapter) { + this.debugAdapter.close(); + this.debugAdapter = null; + } + this.inspectorToVSCode.clear(); + this.vscodeToInspector.clear(); + this.requestedTestIds.clear(); + } + + private async debugTests( + tests: vscode.TestItem[], + _request: vscode.TestRunRequest, + run: vscode.TestRun, + ): Promise { + const testFiles = new Set(); + for (const test of tests) { + if (test.uri) { + testFiles.add(test.uri.fsPath); + } + } + + const isIndividualTestRun = this.shouldUseTestNamePattern(tests); + + if (testFiles.size === 0) { + run.appendOutput("No test files found to debug.\n"); + run.end(); + return; + } + + const { bunCommand, testArgs } = this.getBunExecutionConfig(); + const args = [...testArgs, ...testFiles]; + + if (!isIndividualTestRun) { + args.push("--inspect-brk"); + } else { + const breakpoints: vscode.SourceBreakpoint[] = []; + for (const test of tests) { + if (test.uri) { + breakpoints.push( + new vscode.SourceBreakpoint( + new vscode.Location(test.uri, new vscode.Position((test.range?.end.line ?? 0) + 1, 0)), + true, + ), + ); + } + } + vscode.debug.addBreakpoints(breakpoints); + + const pattern = this.buildTestNamePattern(tests); + if (pattern) { + args.push("--test-name-pattern", process.platform === "win32" ? `"${pattern}"` : pattern); + } + } + + const debugConfiguration: vscode.DebugConfiguration = { + args: args.slice(1), + console: "integratedTerminal", + cwd: "${workspaceFolder}", + internalConsoleOptions: "neverOpen", + name: "Bun Test Debug", + program: args.at(1), + request: "launch", + runtime: bunCommand, + type: "bun", + }; + + try { + const res = await vscode.debug.startDebugging(this.workspaceFolder, debugConfiguration); + if (!res) throw new Error("Failed to start debugging session"); + } catch (error) { + for (const test of tests) { + run.errored(test, new vscode.TestMessage(`Error starting debugger: ${error}`)); + } + } + run.end(); + } + + private closeAllActiveProcesses(): void { + for (const p of this.activeProcesses) { + p.kill(); + } + this.activeProcesses.clear(); + } + + public dispose(): void { + this.closeAllActiveProcesses(); + if (this.signal) { + this.signal.close(); + this.signal.removeAllListeners(); + this.signal = null; + } + if (this.debugAdapter) { + this.debugAdapter.close(); + this.debugAdapter = null; + } + for (const disposable of this.disposables) { + disposable.dispose(); + } + this.disposables = []; + } +} + +function windowsVscodeUri(uri: string): string { + return process.platform === "win32" ? uri.replace("c:\\", "C:\\") : uri; +} diff --git a/packages/bun-vscode/src/features/tests/index.ts b/packages/bun-vscode/src/features/tests/index.ts index 341f91e360..290d8f247f 100644 --- a/packages/bun-vscode/src/features/tests/index.ts +++ b/packages/bun-vscode/src/features/tests/index.ts @@ -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) { - 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; - } - - // 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); +export async function registerTests(context: vscode.ExtensionContext) { + const workspaceFolder = (vscode.workspace.workspaceFolders || [])[0]; + if (!workspaceFolder) { + return; } - visit(sourceFile); - return tests; -} + try { + const controller = vscode.tests.createTestController("bun-tests", "Bun Tests"); + context.subscriptions.push(controller); -/** - * 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); + const bunTestController = new BunTestController(controller, workspaceFolder); - 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, "\\$&"); -} diff --git a/packages/bun-vscode/src/features/tests/types.ts b/packages/bun-vscode/src/features/tests/types.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/bun-vscode/src/global-state.ts b/packages/bun-vscode/src/global-state.ts index 9ee8f170b5..dc768f15d8 100644 --- a/packages/bun-vscode/src/global-state.ts +++ b/packages/bun-vscode/src/global-state.ts @@ -1,4 +1,4 @@ -import { ExtensionContext } from "vscode"; +import type { ExtensionContext } from "vscode"; export const GLOBAL_STATE_VERSION = 1; diff --git a/src/bun.js/Debugger.zig b/src/bun.js/Debugger.zig index 617a3afaef..b427cd4c67 100644 --- a/src/bun.js/Debugger.zig +++ b/src/bun.js/Debugger.zig @@ -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. diff --git a/src/bun.js/bindings/InspectorTestReporterAgent.cpp b/src/bun.js/bindings/InspectorTestReporterAgent.cpp index ef3838ac03..7543dde846 100644 --- a/src/bun.js/bindings/InspectorTestReporterAgent.cpp +++ b/src/bun.js/bindings/InspectorTestReporterAgent.cpp @@ -13,6 +13,9 @@ #include "ModuleLoader.h" #include +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 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()); } void InspectorTestReporterAgent::reportTestStart(int testId) diff --git a/src/bun.js/bindings/InspectorTestReporterAgent.h b/src/bun.js/bindings/InspectorTestReporterAgent.h index 374553edd0..3f5b22d0e3 100644 --- a/src/bun.js/bindings/InspectorTestReporterAgent.h +++ b/src/bun.js/bindings/InspectorTestReporterAgent.h @@ -33,7 +33,7 @@ public: virtual Protocol::ErrorStringOr 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); diff --git a/src/bun.js/bindings/bindings.cpp b/src/bun.js/bindings/bindings.cpp index 9e5fa2b705..2cf43fc203 100644 --- a/src/bun.js/bindings/bindings.cpp +++ b/src/bun.js/bindings/bindings.cpp @@ -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; +} diff --git a/src/bun.js/test/jest.zig b/src/bun.js/test/jest.zig index 8def3c0735..b3bbfc3503 100644 --- a/src/bun.js/test/jest.zig +++ b/src/bun.js/test/jest.zig @@ -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(" ^ this test is marked as failing but it passed. 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(" ^ this test is marked as todo but passes. Remove `.todo` or check that test is correct.", .{}); 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,16 +1910,16 @@ 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); - break :brk max_test_id_for_debugger; - } + 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, .@"test", parent_id); + break :brk max_test_id_for_debugger; } } @@ -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,16 +2199,17 @@ 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); - if (Jest.runner.?.filter_regex) |regex| { - var buffer: bun.MutableString = Jest.runner.?.filter_buffer; - buffer.reset(); - appendParentLabel(&buffer, parent) catch @panic("Bun ran out of memory while filtering tests"); - buffer.append(formattedLabel) catch unreachable; - 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; + var tag_to_use = tag; + if (!is_skip) { + if (Jest.runner.?.filter_regex) |regex| { + var buffer: bun.MutableString = Jest.runner.?.filter_buffer; + buffer.reset(); + appendParentLabel(&buffer, parent) catch @panic("Bun ran out of memory while filtering tests"); + buffer.append(formattedLabel) catch unreachable; + const str = bun.String.fromBytes(buffer.slice()); + is_skip = !regex.matches(str); + if (is_skip) { + 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 - allocator.free(formattedLabel); - } else if (each_data.is_test) { - if (Jest.runner.?.only and tag != .only) { - return .js_undefined; + } + + 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); + for (function_args) |arg| { + if (arg != .zero) arg.unprotect(); + } + allocator.free(function_args); } else { - function.protect(); + 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; +} diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index d2e73e588d..cdc208805a 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -81,14 +81,14 @@ fn fmtStatusTextLine(comptime status: @Type(.enum_literal), comptime emoji_or_co true => switch (status) { .pass => Output.prettyFmt("", emoji_or_color), .fail => Output.prettyFmt("", emoji_or_color), - .skip => Output.prettyFmt("»", emoji_or_color), + .skip, .skipped_because_label => Output.prettyFmt("»", emoji_or_color), .todo => Output.prettyFmt("", emoji_or_color), else => @compileError("Invalid status " ++ @tagName(status)), }, else => switch (status) { .pass => Output.prettyFmt("(pass)", emoji_or_color), .fail => Output.prettyFmt("(fail)", emoji_or_color), - .skip => Output.prettyFmt("(skip)", emoji_or_color), + .skip, .skipped_because_label => Output.prettyFmt("(skip)", emoji_or_color), .todo => Output.prettyFmt("(todo)", 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, \\ @@ -265,39 +315,68 @@ pub const JunitReporter = struct { try this.contents.appendSlice(bun.default_allocator, ">\n"); } - try this.contents.appendSlice(bun.default_allocator, - \\ \n"); - - if (this.properties_list_to_repeat_in_every_test_suite == null) { - try this.generatePropertiesList(); + 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 (this.properties_list_to_repeat_in_every_test_suite) |properties_list| { - if (properties_list.len > 0) { - try this.contents.appendSlice(bun.default_allocator, properties_list); + 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(); + } + + if (this.properties_list_to_repeat_in_every_test_suite) |properties_list| { + if (properties_list.len > 0) { + try this.contents.appendSlice(bun.default_allocator, properties_list); + } } } - this.current_file = name; + 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, " \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, "\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, " 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, " 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 \n \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, " \n"); + try this.contents.appendSlice(bun.default_allocator, indent); + try this.contents.appendSlice(bun.default_allocator, "\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( - \\> - \\ - \\ + \\ + \\ , .{}); + try this.contents.appendSlice(bun.default_allocator, indent); + try this.contents.appendSlice(bun.default_allocator, "\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( - \\> - \\ - \\ + \\ + \\ , .{assertions}); + try this.contents.appendSlice(bun.default_allocator, indent); + try this.contents.appendSlice(bun.default_allocator, "\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( - \\> - \\ - \\ + \\ + \\ , .{}); + try this.contents.appendSlice(bun.default_allocator, indent); + try this.contents.appendSlice(bun.default_allocator, "\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( - \\> - \\ - \\ + \\ + \\ , .{}); + try this.contents.appendSlice(bun.default_allocator, indent); + try this.contents.appendSlice(bun.default_allocator, "\n"); }, .skipped_because_label, .skip => { - this.testcases_metrics.skipped += 1; - try this.contents.appendSlice(bun.default_allocator, ">\n \n \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, " \n"); + try this.contents.appendSlice(bun.default_allocator, indent); + try this.contents.appendSlice(bun.default_allocator, "\n"); }, .todo => { - this.testcases_metrics.skipped += 1; - try this.contents.appendSlice(bun.default_allocator, ">\n \n \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, " \n"); + try this.contents.appendSlice(bun.default_allocator, indent); + try this.contents.appendSlice(bun.default_allocator, "\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, " \n"); + try this.contents.appendSlice(bun.default_allocator, indent); + try this.contents.appendSlice(bun.default_allocator, "\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 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 {}; } } diff --git a/test/cli/test/bun-test.test.ts b/test/cli/test/bun-test.test.ts index b32c0fdfab..6df6735802 100644 --- a/test/cli/test/bun-test.test.ts +++ b/test/cli/test/bun-test.test.ts @@ -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]}`); } }); }); diff --git a/test/internal/ban-words.test.ts b/test/internal/ban-words.test.ts index 37bf95e59f..9c775d65cf 100644 --- a/test/internal/ban-words.test.ts +++ b/test/internal/ban-words.test.ts @@ -34,7 +34,7 @@ const words: Record [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 }, diff --git a/test/js/junit-reporter/junit.test.js b/test/js/junit-reporter/junit.test.js index c4e0eb53d1..96b8fa8b74 100644 --- a/test/js/junit-reporter/junit.test.js +++ b/test/js/junit-reporter/junit.test.js @@ -5,230 +5,310 @@ 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 () => { - const tmpDir = tempDirWithFiles("junit", { - "package.json": "{}", - "passing.test.js": ` - - it("should pass", () => { - expect(1 + 1).toBe(2); - }); - - it("second test", () => { - expect(1 + 1).toBe(2); - }); - - it("failing test", () => { - expect(1 + 1).toBe(3); - }); - - it.skip("skipped test", () => { - expect(1 + 1).toBe(2); - }); - - it.todo("todo test"); - - describe("nested describe", () => { - it("should pass inside nested describe", () => { + 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); }); - it("should fail inside nested describe", () => { - expect(1 + 1).toBe(3); - }); - }); - `, - - "test-2.test.js": ` - - it("should pass", () => { - expect(1 + 1).toBe(2); - }); - - it("failing test", () => { - expect(1 + 1).toBe(3); - }); - - describe("nested describe", () => { - it("should pass inside nested describe", () => { + it("second test", () => { expect(1 + 1).toBe(2); }); - it("should fail inside nested describe", () => { + it("failing test", () => { expect(1 + 1).toBe(3); }); + + it.skip("skipped test", () => { + expect(1 + 1).toBe(2); + }); + + it.todo("todo test"); + + describe("nested describe", () => { + it("should pass inside nested describe", () => { + expect(1 + 1).toBe(2); + }); + + it("should fail inside nested describe", () => { + expect(1 + 1).toBe(3); + }); + }); }); `, - }); + "test-2.test.js": ` + describe("root describe", () => { + it("should pass", () => { + expect(1 + 1).toBe(2); + }); - let env = bunEnv; + it("failing test", () => { + expect(1 + 1).toBe(3); + }); - if (withCIEnvironmentVariables) { - env = { - ...env, - CI_JOB_URL: "https://ci.example.com/123", - CI_COMMIT_SHA: "1234567890", - }; - } + describe("nested describe", () => { + it("should pass inside nested describe", () => { + expect(1 + 1).toBe(2); + }); - const junitPath = `${tmpDir}/junit.xml`; - const proc = spawn([bunExe(), "test", "--reporter=junit", "--reporter-outfile", junitPath], { - cwd: tmpDir, - env, - stdout: "inherit", - "stderr": "inherit", - }); - 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); - else resolve(result); + it("should fail inside nested describe", () => { + expect(1 + 1).toBe(3); + }); + }); }); - }); - - /** - * ------ Vitest ------ - * - * - * - * - * - * - * - * - * - * AssertionError: expected 2 to be 3 // Object.is equality - * - * - Expected - * + Received - * - * - 3 - * + 2 - * - * ❯ passing.test.js:12:25 - * - * - - * - * - * - * - * - * - * - * - * - * AssertionError: expected 2 to be 3 // Object.is equality - * - * - Expected - * + Received - * - * - 3 - * + 2 - * - * ❯ passing.test.js:27:27 - * - * - - * - * - * - * - * - * AssertionError: expected 2 to be 3 // Object.is equality - * - * - Expected - * + Received - * - * - 3 - * + 2 - * - * ❯ test-2.test.js:8:25 - * - * - * - * - * - * - * AssertionError: expected 2 to be 3 // Object.is equality - * - * - Expected - * + Received - * - * - 3 - * + 2 - * - * ❯ test-2.test.js:17:27 - * - * - * - * - */ - - expect(result.testsuites).toBeDefined(); - expect(result.testsuites.testsuite).toBeDefined(); - - let firstSuite = result.testsuites.testsuite[0]; - let secondSuite = result.testsuites.testsuite[1]; - - if (firstSuite.$.name === "passing.test.js") { - [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.$.tests).toBe("4"); - expect(firstSuite.$.failures).toBe("2"); - expect(firstSuite.$.skipped).toBe("0"); - expect(parseFloat(firstSuite.$.time)).toBeGreaterThanOrEqual(0.0); - - 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.$.tests).toBe("7"); - expect(secondSuite.$.failures).toBe("2"); - expect(secondSuite.$.skipped).toBe("2"); - expect(parseFloat(secondSuite.$.time)).toBeGreaterThanOrEqual(0.0); - - 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); - - 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"); - expect(firstSuite.properties[0].property[0].$.value).toBe("https://ci.example.com/123"); - expect(firstSuite.properties[0].property[1].$.name).toBe("commit"); - expect(firstSuite.properties[0].property[1].$.value).toBe("1234567890"); - } + `, }); - } + + let env = bunEnv; + + if (withCIEnvironmentVariables) { + env = { + ...env, + CI_JOB_URL: "https://ci.example.com/123", + CI_COMMIT_SHA: "1234567890", + }; + } + + const junitPath = `${tmpDir}/junit.xml`; + const proc = spawn([bunExe(), "test", "--reporter=junit", "--reporter-outfile", junitPath], { + cwd: tmpDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + await proc.exited; + + expect(proc.exitCode).toBe(1); + const xmlContent = await file(junitPath).text(); + + const result = await new Promise((resolve, reject) => { + xml2js.parseString(xmlContent, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); + + expect(result.testsuites).toBeDefined(); + expect(result.testsuites.testsuite).toBeDefined(); + + let firstSuite = result.testsuites.testsuite[0]; + let secondSuite = result.testsuites.testsuite[1]; + + if (firstSuite.$.name === "passing.test.js") { + [firstSuite, secondSuite] = [secondSuite, firstSuite]; + } + + 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(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.$.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(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(Number.parseFloat(result.testsuites.$.time)).toBeGreaterThanOrEqual(0.0); + + if (withCIEnvironmentVariables) { + expect(firstSuite.properties).toHaveLength(1); + expect(firstSuite.properties[0].property).toHaveLength(2); + expect(firstSuite.properties[0].property[0].$.name).toBe("ci"); + expect(firstSuite.properties[0].property[0].$.value).toBe("https://ci.example.com/123"); + expect(firstSuite.properties[0].property[1].$.name).toBe("commit"); + 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="); + }); });