diff --git a/src/SwiftTaskProvider.ts b/src/SwiftTaskProvider.ts index 1746063c3..2cd7c7d49 100644 --- a/src/SwiftTaskProvider.ts +++ b/src/SwiftTaskProvider.ts @@ -187,6 +187,7 @@ export async function getBuildAllTask(folderContext: FolderContext): Promise, "children children: TestClass[]; } +export const runnableTag = new vscode.TestTag("runnable"); + /** * Update Test Controller TestItems based off array of TestClasses. * @@ -78,8 +80,10 @@ export function updateTests( ) { const collection = testItem.parent ? testItem.parent.children : testController.items; - // TODO: This needs to take in to account parameterized tests with no URI, when they're added. - if (testItem.children.size === 0) { + if ( + testItem.children.size === 0 || + testItemHasParameterizedTestResultChildren(testItem) + ) { collection.delete(testItem.id); } } @@ -96,6 +100,21 @@ export function updateTests( }); } +/** + * Returns true if all children have no URI. + * This indicates the test item is parameterized and the children are the results. + */ +function testItemHasParameterizedTestResultChildren(testItem: vscode.TestItem) { + return ( + testItem.children.size > 0 && + reduceTestItemChildren( + testItem.children, + (acc, child) => acc || child.uri !== undefined, + false + ) === false + ); +} + /** * Create a lookup of the incoming tests we can compare to the existing list of tests * to produce a list of tests that are no longer present. If a filterFile is specified we @@ -140,11 +159,11 @@ function deepMergeTestItemChildren(existingItem: vscode.TestItem, newItem: vscod * Updates the existing `vscode.TestItem` if it exists with the same ID as the `TestClass`, * otherwise creates an add a new one. The location on the returned vscode.TestItem is always updated. */ -function upsertTestItem( +export function upsertTestItem( testController: vscode.TestController, testItem: TestClass, parent?: vscode.TestItem -) { +): vscode.TestItem { const collection = parent?.children ?? testController.items; const existingItem = collection.get(testItem.id); let newItem: vscode.TestItem; @@ -161,6 +180,15 @@ function upsertTestItem( testItem.label, testItem.location?.uri ); + + // We want to keep existing children if they exist. + if (existingItem) { + const existingChildren: vscode.TestItem[] = []; + existingItem.children.forEach(child => { + existingChildren.push(child); + }); + newItem.children.replace(existingChildren); + } } else { newItem = existingItem; } @@ -174,6 +202,11 @@ function upsertTestItem( // Manually add the test style as a tag so we can filter by test type. newItem.tags = [{ id: testItem.style }, ...testItem.tags]; + + if (testItem.disabled === false) { + newItem.tags = [...newItem.tags, runnableTag]; + } + newItem.label = testItem.label; newItem.range = testItem.location?.range; @@ -192,4 +225,6 @@ function upsertTestItem( testItem.children.forEach(child => { upsertTestItem(testController, child, newItem); }); + + return newItem; } diff --git a/src/TestExplorer/TestParsers/SwiftTestingOutputParser.ts b/src/TestExplorer/TestParsers/SwiftTestingOutputParser.ts index 81e736768..2f04febe1 100644 --- a/src/TestExplorer/TestParsers/SwiftTestingOutputParser.ts +++ b/src/TestExplorer/TestParsers/SwiftTestingOutputParser.ts @@ -1,3 +1,4 @@ +import * as vscode from "vscode"; import * as readline from "readline"; import { Readable } from "stream"; import { @@ -6,8 +7,12 @@ import { WindowsNamedPipeReader, } from "./TestEventStreamReader"; import { ITestRunState } from "./TestRunState"; +import { TestClass } from "../TestDiscovery"; +import { sourceLocationToVSCodeLocation } from "../../utilities/utilities"; // All events produced by a swift-testing run will be one of these three types. +// Detailed information about swift-testing's JSON schema is available here: +// https://github.com/apple/swift-testing/blob/main/Documentation/ABI/JSON.md export type SwiftTestEvent = MetadataRecord | TestRecord | EventRecord; interface VersionedRecord { @@ -21,7 +26,7 @@ interface MetadataRecord extends VersionedRecord { interface TestRecord extends VersionedRecord { kind: "test"; - payload: Test; + payload: TestSuite | TestFunction; } export type EventRecordPayload = @@ -43,15 +48,23 @@ interface Metadata { [key: string]: object; // Currently unstructured content } -interface Test { - kind: "suite" | "function" | "parameterizedFunction"; +interface TestBase { id: string; name: string; _testCases?: TestCase[]; sourceLocation: SourceLocation; } -interface TestCase { +interface TestSuite extends TestBase { + kind: "suite"; +} + +interface TestFunction extends TestBase { + kind: "function"; + isParameterized: boolean; +} + +export interface TestCase { id: string; displayName: string; } @@ -76,6 +89,11 @@ interface BaseEvent { testID: string; } +interface TestCaseEvent { + sourceLocation: SourceLocation; + _testCase: TestCase; +} + interface TestStarted extends BaseEvent { kind: "testStarted"; } @@ -84,11 +102,11 @@ interface TestEnded extends BaseEvent { kind: "testEnded"; } -interface TestCaseStarted extends BaseEvent { +interface TestCaseStarted extends BaseEvent, TestCaseEvent { kind: "testCaseStarted"; } -interface TestCaseEnded extends BaseEvent { +interface TestCaseEnded extends BaseEvent, TestCaseEvent { kind: "testCaseEnded"; } @@ -96,7 +114,7 @@ interface TestSkipped extends BaseEvent { kind: "testSkipped"; } -interface IssueRecorded extends BaseEvent { +interface IssueRecorded extends BaseEvent, TestCaseEvent { kind: "issueRecorded"; issue: { sourceLocation: SourceLocation; @@ -115,6 +133,12 @@ export interface SourceLocation { export class SwiftTestingOutputParser { private completionMap = new Map(); + private testCaseMap = new Map>(); + + constructor( + public testRunStarted: () => void, + public addParameterizedTestCase: (testClass: TestClass, parentIndex: number) => void + ) {} /** * Watches for test events on the named pipe at the supplied path. @@ -155,31 +179,147 @@ export class SwiftTestingOutputParser { return !matches ? id : matches[1]; } + private testCaseId(testId: string, testCaseId: string): string { + const testCase = this.testCaseMap.get(testId)?.get(testCaseId); + return testCase ? `${testId}/${this.idFromTestCase(testCase)}` : testId; + } + + // Test cases do not have a unique ID if their arguments are not serializable + // with Codable. If they aren't, their id appears as `argumentIDs: nil`, and we + // fall back to using the testCase display name as the test case ID. This isn't + // ideal because its possible to have multiple test cases with the same display name, + // but until we have a better solution for identifying test cases it will have to do. + // SEE: rdar://119522099. + private idFromTestCase(testCase: TestCase): string { + return testCase.id === "argumentIDs: nil" ? testCase.displayName : testCase.id; + } + + private parameterizedFunctionTestCaseToTestClass( + testId: string, + testCase: TestCase, + location: vscode.Location, + index: number + ): TestClass { + return { + id: this.testCaseId(testId, this.idFromTestCase(testCase)), + label: testCase.displayName, + tags: [], + children: [], + style: "swift-testing", + location: location, + disabled: true, + sortText: `${index}`.padStart(8, "0"), + }; + } + + private buildTestCaseMapForParameterizedTest(record: TestRecord) { + const map = new Map(); + (record.payload._testCases ?? []).forEach(testCase => { + map.set(this.idFromTestCase(testCase), testCase); + }); + this.testCaseMap.set(record.payload.id, map); + } + + private getTestCaseIndex(runState: ITestRunState, testID: string): number { + const fullNameIndex = runState.getTestItemIndex(testID, undefined); + if (fullNameIndex === -1) { + return runState.getTestItemIndex(this.testName(testID), undefined); + } + return fullNameIndex; + } + private parse(item: SwiftTestEvent, runState: ITestRunState) { - if (item.kind === "event") { - if (item.payload.kind === "testCaseStarted" || item.payload.kind === "testStarted") { + if ( + item.kind === "test" && + item.payload.kind === "function" && + item.payload.isParameterized && + item.payload._testCases + ) { + // Store a map of [Test ID, [Test Case ID, TestCase]] so we can quickly + // map an event.payload.testID back to a test case. + this.buildTestCaseMapForParameterizedTest(item); + + const testName = this.testName(item.payload.id); + const testIndex = runState.getTestItemIndex(testName, undefined); + // If a test has test cases it is paramterized and we need to notify + // the caller that the TestClass should be added to the vscode.TestRun + // before it starts. + item.payload._testCases + .map((testCase, index) => + this.parameterizedFunctionTestCaseToTestClass( + item.payload.id, + testCase, + sourceLocationToVSCodeLocation( + item.payload.sourceLocation._filePath, + item.payload.sourceLocation.line, + item.payload.sourceLocation.column + ), + index + ) + ) + .flatMap(testClass => (testClass ? [testClass] : [])) + .forEach(testClass => this.addParameterizedTestCase(testClass, testIndex)); + } else if (item.kind === "event") { + if (item.payload.kind === "runStarted") { + // Notify the runner that we've recieved all the test cases and + // are going to start running tests now. + this.testRunStarted(); + } else if (item.payload.kind === "testStarted") { const testName = this.testName(item.payload.testID); const testIndex = runState.getTestItemIndex(testName, undefined); runState.started(testIndex, item.payload.instant.absolute); + } else if (item.payload.kind === "testCaseStarted") { + const testID = this.testCaseId( + item.payload.testID, + this.idFromTestCase(item.payload._testCase) + ); + const testIndex = this.getTestCaseIndex(runState, testID); + runState.started(testIndex, item.payload.instant.absolute); } else if (item.payload.kind === "testSkipped") { const testName = this.testName(item.payload.testID); const testIndex = runState.getTestItemIndex(testName, undefined); runState.skipped(testIndex); } else if (item.payload.kind === "issueRecorded") { - const testName = this.testName(item.payload.testID); - const testIndex = runState.getTestItemIndex(testName, undefined); + const testID = this.testCaseId( + item.payload.testID, + this.idFromTestCase(item.payload._testCase) + ); + const testIndex = this.getTestCaseIndex(runState, testID); const sourceLocation = item.payload.issue.sourceLocation; + const location = sourceLocationToVSCodeLocation( + sourceLocation._filePath, + sourceLocation.line, + sourceLocation.column + ); item.payload.messages.forEach(message => { - runState.recordIssue(testIndex, message.text, { - file: sourceLocation._filePath, - line: sourceLocation.line, - column: sourceLocation.column, - }); + runState.recordIssue(testIndex, message.text, location); }); - } else if (item.payload.kind === "testCaseEnded" || item.payload.kind === "testEnded") { + + if (testID !== item.payload.testID) { + // const testName = this.testName(item.payload.testID); + const testIndex = this.getTestCaseIndex(runState, item.payload.testID); + item.payload.messages.forEach(message => { + runState.recordIssue(testIndex, message.text, location); + }); + } + } else if (item.payload.kind === "testEnded") { const testName = this.testName(item.payload.testID); const testIndex = runState.getTestItemIndex(testName, undefined); + // When running a single test the testEnded and testCaseEnded events + // have the same ID, and so we'd end the same test twice. + if (this.completionMap.get(testIndex)) { + return; + } + this.completionMap.set(testIndex, true); + runState.completed(testIndex, { timestamp: item.payload.instant.absolute }); + } else if (item.payload.kind === "testCaseEnded") { + const testID = this.testCaseId( + item.payload.testID, + this.idFromTestCase(item.payload._testCase) + ); + const testIndex = this.getTestCaseIndex(runState, testID); + // When running a single test the testEnded and testCaseEnded events // have the same ID, and so we'd end the same test twice. if (this.completionMap.get(testIndex)) { diff --git a/src/TestExplorer/TestParsers/TestRunState.ts b/src/TestExplorer/TestParsers/TestRunState.ts index 1a9c84768..8b8ae3b0d 100644 --- a/src/TestExplorer/TestParsers/TestRunState.ts +++ b/src/TestExplorer/TestParsers/TestRunState.ts @@ -1,4 +1,4 @@ -import { MarkdownString } from "vscode"; +import * as vscode from "vscode"; /** * Interface for setting this test runs state @@ -26,11 +26,12 @@ export interface ITestRunState { // otherwise the time passed is assumed to be the duration. completed(index: number, timing: { duration: number } | { timestamp: number }): void; - // record an issue against a test + // record an issue against a test. + // If a `testCase` is provided a new TestItem will be created under the TestItem at the supplied index. recordIssue( index: number, - message: string | MarkdownString, - location?: { file: string; line: number; column?: number } + message: string | vscode.MarkdownString, + location?: vscode.Location ): void; // set test index to have been skipped diff --git a/src/TestExplorer/TestParsers/XCTestOutputParser.ts b/src/TestExplorer/TestParsers/XCTestOutputParser.ts index dde265c8c..7e295b5a7 100644 --- a/src/TestExplorer/TestParsers/XCTestOutputParser.ts +++ b/src/TestExplorer/TestParsers/XCTestOutputParser.ts @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import { ITestRunState } from "./TestRunState"; +import { sourceLocationToVSCodeLocation } from "../../utilities/utilities"; /** Regex for parsing XCTest output */ interface TestRegex { @@ -238,10 +239,11 @@ export class XCTestOutputParser { ) { // If we were already capturing an error record it and start a new one if (runState.failedTest) { - runState.recordIssue(testIndex, runState.failedTest.message, { - file: runState.failedTest.file, - line: runState.failedTest.lineNumber, - }); + const location = sourceLocationToVSCodeLocation( + runState.failedTest.file, + runState.failedTest.lineNumber + ); + runState.recordIssue(testIndex, runState.failedTest.message, location); runState.failedTest.complete = true; } runState.failedTest = { @@ -269,10 +271,11 @@ export class XCTestOutputParser { ) { if (testIndex !== -1) { if (runState.failedTest) { - runState.recordIssue(testIndex, runState.failedTest.message, { - file: runState.failedTest.file, - line: runState.failedTest.lineNumber, - }); + const location = sourceLocationToVSCodeLocation( + runState.failedTest.file, + runState.failedTest.lineNumber + ); + runState.recordIssue(testIndex, runState.failedTest.message, location); } else { runState.recordIssue(testIndex, "Failed"); } diff --git a/src/TestExplorer/TestRunArguments.ts b/src/TestExplorer/TestRunArguments.ts index 8ec21a43d..b47a301f5 100644 --- a/src/TestExplorer/TestRunArguments.ts +++ b/src/TestExplorer/TestRunArguments.ts @@ -52,12 +52,29 @@ export class TestRunArguments { private createTestLists(request: vscode.TestRunRequest): ProcessResult { const includes = request.include ?? []; return includes.reduce(this.createTestItemReducer(request.include, request.exclude), { - testItems: [], + testItems: this.createIncludeParentList(includes), xcTestArgs: [], swiftTestArgs: [], }); } + /** + * For all the included tests we want to collect up a list of their + * parents so they are included in the final testItems list. Otherwise + * we'll get testStart/End events for testItems we have no record of. + */ + private createIncludeParentList(includes: readonly vscode.TestItem[]): vscode.TestItem[] { + const parents = includes.reduce((map, include) => { + let parent = include.parent; + while (parent) { + map.set(parent.id, parent); + parent = parent.parent; + } + return map; + }, new Map()); + return Array.from(parents.values()); + } + private createTestItemReducer( include: readonly vscode.TestItem[] | undefined, exclude: readonly vscode.TestItem[] | undefined @@ -122,14 +139,19 @@ export class TestRunArguments { // If this test item is included or we are including everything if (include?.includes(testItem) || !include) { - testItems.push(testItem); - - // Only add leaf items to testArgs - if (testItem.children.size === 0) { - if (testItem.tags.find(tag => tag.id === "XCTest")) { - xcTestArgs.push(testItem.id); - } else { - swiftTestArgs.push(testItem.id); + // Collect up a list of all the test items involved in the run + // from the TestExplorer tree and store them in `testItems`. Exclude + // parameterized test result entries from this list (they don't have a uri). + if (testItem.uri !== undefined) { + testItems.push(testItem); + + // Only add leaf items to the list of arguments to pass to the test runner. + if (this.isLeafTestItem(testItem)) { + if (testItem.tags.find(tag => tag.id === "XCTest")) { + xcTestArgs.push(testItem.id); + } else { + swiftTestArgs.push(testItem.id); + } } } } @@ -144,4 +166,14 @@ export class TestRunArguments { } ); } + + private isLeafTestItem(testItem: vscode.TestItem) { + let result = true; + testItem.children.forEach(child => { + if (child.uri) { + result = false; + } + }); + return result; + } } diff --git a/src/TestExplorer/TestRunner.ts b/src/TestExplorer/TestRunner.ts index 63f75cb28..9b57e1336 100644 --- a/src/TestExplorer/TestRunner.ts +++ b/src/TestExplorer/TestRunner.ts @@ -42,6 +42,7 @@ import { TestXUnitParser, iXUnitTestState } from "./TestXUnitParser"; import { ITestRunState } from "./TestParsers/TestRunState"; import { TestRunArguments } from "./TestRunArguments"; import { TemporaryFolder } from "../utilities/tempFolder"; +import { TestClass, runnableTag, upsertTestItem } from "./TestDiscovery"; /** Workspace Folder events */ export enum TestKind { @@ -53,9 +54,147 @@ export enum TestKind { coverage = "coverage", } +class TestRunProxy { + private testRun?: vscode.TestRun; + private addedTestItems: { testClass: TestClass; parentIndex: number }[] = []; + private runStarted: boolean = false; + private completedMap = new Set(); + private queuedOutput: string[] = []; + private _testItems: vscode.TestItem[]; + + public get testItems(): vscode.TestItem[] { + return this._testItems; + } + + constructor( + private testRunRequest: vscode.TestRunRequest, + private controller: vscode.TestController, + private args: TestRunArguments, + private folderContext: FolderContext + ) { + this._testItems = args.testItems; + } + + public testRunStarted = () => { + if (this.runStarted) { + return; + } + this.runStarted = true; + + // When a test run starts we need to do several things: + // - Create new TestItems for each paramterized test that was added + // and attach them to their parent TestItem. + // - Create a new test run from the TestRunArguments + newly created TestItems. + // - Mark all of these test items as enqueued on the test run. + + const addedTestItems = this.addedTestItems + .map(({ testClass, parentIndex }) => { + const parent = this.args.testItems[parentIndex]; + // clear out the children before we add the new ones. + parent.children.replace([]); + return { + testClass, + parent, + }; + }) + .map(({ testClass, parent }) => { + // strip the location off parameterized tests so only the parent TestItem + // has one. The parent collects all the issues so they're colated on the top + // level test item and users can cycle through them with the up/down arrows in the UI. + testClass.location = undefined; + + const added = upsertTestItem(this.controller, testClass, parent); + + // If we just update leaf nodes the root test controller never realizes that + // items have updated. This may be a bug in VSCode. We can work around it by + // re-adding the existing items back up the chain to refresh all the nodes along the way. + let p = parent; + while (p?.parent) { + p.parent.children.add(p); + p = p.parent; + } + + return added; + }); + + this.testRun = this.controller.createTestRun(this.testRunRequest); + this._testItems = [...this.testItems, ...addedTestItems]; + + // Forward any output captured before the testRun was created. + for (const outputLine of this.queuedOutput) { + this.testRun.appendOutput(outputLine); + } + this.queuedOutput = []; + + for (const test of this.testItems) { + this.testRun.enqueued(test); + } + }; + + public addParameterizedTestCase = (testClass: TestClass, parentIndex: number) => { + this.addedTestItems.push({ testClass, parentIndex }); + }; + + public getTestIndex(id: string, filename?: string): number { + return this.testItemFinder.getIndex(id, filename); + } + + private get testItemFinder(): TestItemFinder { + if (process.platform === "darwin") { + return new DarwinTestItemFinder(this.testItems); + } else { + return new NonDarwinTestItemFinder(this.testItems, this.folderContext); + } + } + + public started(test: vscode.TestItem) { + this.testRun?.started(test); + } + + public skipped(test: vscode.TestItem) { + this.completedMap.add(test); + this.testRun?.skipped(test); + } + + public passed(test: vscode.TestItem, duration?: number) { + this.completedMap.add(test); + this.testRun?.passed(test, duration); + } + + public failed( + test: vscode.TestItem, + message: vscode.TestMessage | readonly vscode.TestMessage[], + duration?: number + ) { + this.completedMap.add(test); + this.testRun?.failed(test, message, duration); + } + + public errored( + test: vscode.TestItem, + message: vscode.TestMessage | readonly vscode.TestMessage[], + duration?: number + ) { + this.completedMap.add(test); + this.testRun?.errored(test, message, duration); + } + + public end() { + this.testRun?.end(); + } + + public appendOutput(output: string) { + if (this.testRun) { + this.testRun.appendOutput(output); + } else { + this.queuedOutput.push(output); + } + } +} + /** Class used to run tests */ export class TestRunner { - private testRun: vscode.TestRun; + private testRun: TestRunProxy; private testArgs: TestRunArguments; private xcTestOutputParser: XCTestOutputParser; private swiftTestOutputParser: SwiftTestingOutputParser; @@ -71,10 +210,13 @@ export class TestRunner { private folderContext: FolderContext, private controller: vscode.TestController ) { - this.testRun = this.controller.createTestRun(this.request); this.testArgs = new TestRunArguments(this.ensureRequestIncludesTests(this.request)); + this.testRun = new TestRunProxy(request, controller, this.testArgs, folderContext); this.xcTestOutputParser = new XCTestOutputParser(); - this.swiftTestOutputParser = new SwiftTestingOutputParser(); + this.swiftTestOutputParser = new SwiftTestingOutputParser( + this.testRun.testRunStarted, + this.testRun.addParameterizedTestCase + ); } /** @@ -108,7 +250,8 @@ export class TestRunner { const runner = new TestRunner(request, folderContext, controller); await runner.runHandler(false, TestKind.standard, token); }, - true + true, + runnableTag ); // Add non-debug profile controller.createRunProfile( @@ -117,7 +260,9 @@ export class TestRunner { async (request, token) => { const runner = new TestRunner(request, folderContext, controller); await runner.runHandler(false, TestKind.parallel, token); - } + }, + false, + runnableTag ); // Add coverage profile controller.createRunProfile( @@ -126,7 +271,9 @@ export class TestRunner { async (request, token) => { const runner = new TestRunner(request, folderContext, controller); await runner.runHandler(false, TestKind.coverage, token); - } + }, + false, + runnableTag ); // Add debug profile controller.createRunProfile( @@ -135,7 +282,9 @@ export class TestRunner { async (request, token) => { const runner = new TestRunner(request, folderContext, controller); await runner.runHandler(true, TestKind.standard, token); - } + }, + false, + runnableTag ); } @@ -146,7 +295,7 @@ export class TestRunner { * @returns When complete */ async runHandler(shouldDebug: boolean, testKind: TestKind, token: vscode.CancellationToken) { - const runState = new TestRunnerTestRunState(this.testItemFinder, this.testRun); + const runState = new TestRunnerTestRunState(this.testRun); try { // run associated build task // don't do this if generating code test coverage data as it @@ -169,7 +318,6 @@ export class TestRunner { return; } } - this.setTestsEnqueued(); if (shouldDebug) { await this.debugSession(token, runState); @@ -279,6 +427,9 @@ export class TestRunner { return; } + // XCTestRuns are started immediately + this.testRun.testRunStarted(); + await this.launchTests( testKind, token, @@ -409,6 +560,9 @@ export class TestRunner { testBuildConfig: vscode.DebugConfiguration ) { try { + // XCTestRuns are started immediately + this.testRun.testRunStarted(); + // TODO: This approach only covers xctests. const filterArgs = this.testArgs.xcTestArgs.flatMap(arg => ["--filter", arg]); const args = ["test", "--enable-code-coverage"]; @@ -458,6 +612,10 @@ export class TestRunner { "--xunit-output", filename, ]; + + // XCTestRuns are started immediately + this.testRun.testRunStarted(); + try { await execFileStreamOutput( this.workspaceContext.toolchain.getToolchainExecutable("swift"), @@ -640,12 +798,6 @@ export class TestRunner { }); } - setTestsEnqueued() { - for (const test of this.testArgs.testItems) { - this.testRun.enqueued(test); - } - } - /** Get TestItem finder for current platform */ get testItemFinder(): TestItemFinder { if (process.platform === "darwin") { @@ -843,10 +995,7 @@ class NonDarwinTestItemFinder implements TestItemFinder { * Store state of current test run output parse */ class TestRunnerTestRunState implements ITestRunState { - constructor( - private testItemFinder: TestItemFinder, - private testRun: vscode.TestRun - ) {} + constructor(private testRun: TestRunProxy) {} public currentTestItem?: vscode.TestItem; public lastTestItem?: vscode.TestItem; @@ -862,19 +1011,20 @@ class TestRunnerTestRunState implements ITestRunState { private issues: Map = new Map(); getTestItemIndex(id: string, filename?: string): number { - return this.testItemFinder.getIndex(id, filename); + return this.testRun.getTestIndex(id, filename); } // set test item to be started - started(index: number, startTime?: number): void { - this.testRun.started(this.testItemFinder.testItems[index]); - this.currentTestItem = this.testItemFinder.testItems[index]; + started(index: number, startTime?: number) { + const testItem = this.testRun.testItems[index]; + this.testRun.started(testItem); + this.currentTestItem = testItem; this.startTimes.set(index, startTime); } // set test item to have passed - completed(index: number, timing: { duration: number } | { timestamp: number }): void { - const test = this.testItemFinder.testItems[index]; + completed(index: number, timing: { duration: number } | { timestamp: number }) { + const test = this.testRun.testItems[index]; const startTime = this.startTimes.get(index); let duration: number; @@ -905,23 +1055,18 @@ class TestRunnerTestRunState implements ITestRunState { recordIssue( index: number, message: string | vscode.MarkdownString, - location?: { file: string; line: number; column?: number } - ): void { - const issueList = this.issues.get(index) ?? []; + location?: vscode.Location + ) { const msg = new vscode.TestMessage(message); - if (location) { - msg.location = new vscode.Location( - vscode.Uri.file(location.file), - new vscode.Position(location.line - 1, location?.column ?? 0) - ); - } + msg.location = location; + const issueList = this.issues.get(index) ?? []; issueList.push(msg); this.issues.set(index, issueList); } // set test item to have been skipped - skipped(index: number): void { - this.testRun.skipped(this.testItemFinder.testItems[index]); + skipped(index: number) { + this.testRun.skipped(this.testRun.testItems[index]); this.lastTestItem = this.currentTestItem; this.currentTestItem = undefined; } @@ -949,7 +1094,7 @@ class TestRunnerTestRunState implements ITestRunState { class TestRunnerXUnitTestState implements iXUnitTestState { constructor( private testItemFinder: TestItemFinder, - private testRun: vscode.TestRun + private testRun: TestRunProxy ) {} passTest(id: string, duration: number): void { diff --git a/src/utilities/utilities.ts b/src/utilities/utilities.ts index 90f75a408..6a297b12f 100644 --- a/src/utilities/utilities.ts +++ b/src/utilities/utilities.ts @@ -322,6 +322,21 @@ export function expandFilePathTilda(filepath: string): string { return filepath; } +/** + * Transforms a file, line and optional column in to a vscode.Location. + * The line numbers are expected to start at 1, not 0. + * @param string A file path + * @param line A line number, starting at 1 + * @param column An optional column + */ +export function sourceLocationToVSCodeLocation( + file: string, + line: number, + column?: number +): vscode.Location { + return new vscode.Location(vscode.Uri.file(file), new vscode.Position(line - 1, column ?? 0)); +} + const regexEscapedCharacters = new Set(["(", ")", "[", "]", ".", "$", "^", "?", "|", "/", ":"]); /** * Escapes regular expression special characters with a backslash. diff --git a/test/suite/testexplorer/MockTestRunState.ts b/test/suite/testexplorer/MockTestRunState.ts index 0d032d76b..15cea70a9 100644 --- a/test/suite/testexplorer/MockTestRunState.ts +++ b/test/suite/testexplorer/MockTestRunState.ts @@ -1,3 +1,4 @@ +import * as vscode from "vscode"; import { ITestRunState } from "../../../src/TestExplorer/TestParsers/TestRunState"; /** TestStatus */ @@ -13,7 +14,7 @@ export enum TestStatus { interface TestItem { name: string; status: TestStatus; - issues?: { message: string; location?: { file: string; line: number } }[]; + issues?: { message: string; location?: vscode.Location }[]; timing?: { duration: number } | { timestamp: number }; } @@ -80,7 +81,7 @@ export class TestRunState implements ITestRunState { this.testItemFinder.tests[index].timing = timing; } - recordIssue(index: number, message: string, location?: { file: string; line: number }): void { + recordIssue(index: number, message: string, location?: vscode.Location): void { this.testItemFinder.tests[index].issues = [ ...(this.testItemFinder.tests[index].issues ?? []), { message, location }, diff --git a/test/suite/testexplorer/SwiftTestingOutputParser.test.ts b/test/suite/testexplorer/SwiftTestingOutputParser.test.ts index beb94fca4..6bf53e6ef 100644 --- a/test/suite/testexplorer/SwiftTestingOutputParser.test.ts +++ b/test/suite/testexplorer/SwiftTestingOutputParser.test.ts @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import * as assert from "assert"; +import * as vscode from "vscode"; import { SwiftTestEvent, EventRecord, @@ -36,14 +37,18 @@ class TestEventStream { } suite("SwiftTestingOutputParser Suite", () => { - const outputParser = new SwiftTestingOutputParser(); + const outputParser = new SwiftTestingOutputParser( + () => {}, + () => {} + ); type ExtractPayload = T extends { payload: infer E } ? E : never; function testEvent( name: ExtractPayload["kind"], testID?: string, messages?: EventMessage[], - sourceLocation?: SourceLocation + sourceLocation?: SourceLocation, + testCaseID?: string ): EventRecord { return { kind: "event", @@ -54,6 +59,10 @@ suite("SwiftTestingOutputParser Suite", () => { messages: messages ?? [], ...{ testID, sourceLocation }, ...(messages ? { issue: { sourceLocation } } : {}), + _testCase: { + id: testCaseID ?? testID, + displayName: testCaseID ?? testID, + }, } as EventRecordPayload, }; } @@ -112,12 +121,90 @@ suite("SwiftTestingOutputParser Suite", () => { assert.deepEqual(runState.issues, [ { message: "Expectation failed: bar == foo", - location: { - file: issueLocation._filePath, - line: issueLocation.line, - column: issueLocation.column, + location: new vscode.Location( + vscode.Uri.file(issueLocation._filePath), + new vscode.Position(issueLocation.line - 1, issueLocation?.column ?? 0) + ), + }, + ]); + }); + + test("Parameterized test", async () => { + const testRunState = new TestRunState(["MyTests.MyTests/testParameterized()"], true); + const events = new TestEventStream([ + { + kind: "test", + payload: { + isParameterized: true, + _testCases: [ + { + displayName: "1", + id: "argumentIDs: Optional([Testing.Test.Case.Argument.ID(bytes: [49])])", + }, + { + displayName: "2", + id: "argumentIDs: Optional([Testing.Test.Case.Argument.ID(bytes: [50])])", + }, + ], + id: "MyTests.MyTests/testParameterized()", + kind: "function", + sourceLocation: { + _filePath: "file:///some/file.swift", + line: 1, + column: 2, + }, + name: "testParameterized(_:)", }, + version: 0, }, + testEvent("runStarted"), + testEvent( + "testCaseStarted", + "MyTests.MyTests/testParameterized()", + undefined, + undefined, + "argumentIDs: Optional([Testing.Test.Case.Argument.ID(bytes: [49])])" + ), + testEvent( + "testCaseEnded", + "MyTests.MyTests/testParameterized()", + undefined, + undefined, + "argumentIDs: Optional([Testing.Test.Case.Argument.ID(bytes: [49])])" + ), + testEvent( + "testCaseStarted", + "MyTests.MyTests/testParameterized()", + undefined, + undefined, + "argumentIDs: Optional([Testing.Test.Case.Argument.ID(bytes: [50])])" + ), + testEvent( + "testCaseEnded", + "MyTests.MyTests/testParameterized()", + undefined, + undefined, + "argumentIDs: Optional([Testing.Test.Case.Argument.ID(bytes: [50])])" + ), + testEvent("testEnded", "MyTests.MyTests/testParameterized()"), + testEvent("runEnded"), ]); + + const outputParser = new SwiftTestingOutputParser( + () => {}, + testClass => { + testRunState.testItemFinder.tests.push({ + name: testClass.id, + status: TestStatus.enqueued, + }); + } + ); + await outputParser.watch("file:///mock/named/pipe", testRunState, events); + + assert.strictEqual(testRunState.tests.length, 3); + testRunState.tests.forEach(runState => { + assert.strictEqual(runState.status, TestStatus.passed); + assert.deepEqual(runState.timing, { timestamp: 0 }); + }); }); }); diff --git a/test/suite/testexplorer/TestRunArguments.test.ts b/test/suite/testexplorer/TestRunArguments.test.ts index ea2b49478..97e40c756 100644 --- a/test/suite/testexplorer/TestRunArguments.test.ts +++ b/test/suite/testexplorer/TestRunArguments.test.ts @@ -19,6 +19,7 @@ import { TestRunArguments } from "../../../src/TestExplorer/TestRunArguments"; suite("TestRunArguments Suite", () => { let controller: vscode.TestController; + let testTarget: vscode.TestItem; let xcSuite: vscode.TestItem; let xcTest: vscode.TestItem; let swiftTestSuite: vscode.TestItem; @@ -30,27 +31,43 @@ suite("TestRunArguments Suite", () => { "" ); - const testTarget = controller.createTestItem("TestTarget", "TestTarget"); + testTarget = controller.createTestItem("TestTarget", "TestTarget"); testTarget.tags = [{ id: "test-target" }]; controller.items.add(testTarget); - xcSuite = controller.createTestItem("XCTest Suite", "XCTest Suite"); + xcSuite = controller.createTestItem( + "XCTest Suite", + "XCTest Suite", + vscode.Uri.file("/path/to/file") + ); xcSuite.tags = [{ id: "XCTest" }]; testTarget.children.add(xcSuite); - xcTest = controller.createTestItem("XCTest Item", "XCTest Item"); + xcTest = controller.createTestItem( + "XCTest Item", + "XCTest Item", + vscode.Uri.file("/path/to/file") + ); xcTest.tags = [{ id: "XCTest" }]; xcSuite.children.add(xcTest); - swiftTestSuite = controller.createTestItem("Swift Test Suite", "Swift Test Suite"); + swiftTestSuite = controller.createTestItem( + "Swift Test Suite", + "Swift Test Suite", + vscode.Uri.file("/path/to/file") + ); swiftTestSuite.tags = [{ id: "swift-testing" }]; testTarget.children.add(swiftTestSuite); - swiftTest = controller.createTestItem("Swift Test Item", "Swift Test Item"); + swiftTest = controller.createTestItem( + "Swift Test Item", + "Swift Test Item", + vscode.Uri.file("/path/to/file") + ); swiftTest.tags = [{ id: "swift-testing" }]; swiftTestSuite.children.add(swiftTest); @@ -72,7 +89,7 @@ suite("TestRunArguments Suite", () => { assert.deepEqual(testArgs.swiftTestArgs, [swiftTestSuite.id]); assert.deepEqual( testArgs.testItems.map(item => item.id), - [xcSuite.id, xcTest.id, swiftTestSuite.id, swiftTest.id] + [testTarget.id, xcSuite.id, xcTest.id, swiftTestSuite.id, swiftTest.id] ); }); @@ -86,7 +103,7 @@ suite("TestRunArguments Suite", () => { assert.deepEqual(testArgs.swiftTestArgs, [swiftTestSuite.id]); assert.deepEqual( testArgs.testItems.map(item => item.id), - [swiftTestSuite.id, swiftTest.id] + [testTarget.id, swiftTestSuite.id, swiftTest.id] ); }); @@ -100,14 +117,15 @@ suite("TestRunArguments Suite", () => { assert.deepEqual(testArgs.swiftTestArgs, [swiftTestSuite.id]); assert.deepEqual( testArgs.testItems.map(item => item.id), - [swiftTestSuite.id, swiftTest.id] + [testTarget.id, swiftTestSuite.id, swiftTest.id] ); }); test("Single Test in Suite With Multiple", () => { const anotherSwiftTest = controller.createTestItem( "Another Swift Test Item", - "Another Swift Test Item" + "Another Swift Test Item", + vscode.Uri.file("/path/to/file") ); anotherSwiftTest.tags = [{ id: "swift-testing" }]; swiftTestSuite.children.add(anotherSwiftTest); @@ -121,7 +139,7 @@ suite("TestRunArguments Suite", () => { assert.deepEqual(testArgs.swiftTestArgs, [anotherSwiftTest.id]); assert.deepEqual( testArgs.testItems.map(item => item.id), - [anotherSwiftTest.id] + [swiftTestSuite.id, testTarget.id, anotherSwiftTest.id] ); }); }); diff --git a/test/suite/testexplorer/XCTestOutputParser.test.ts b/test/suite/testexplorer/XCTestOutputParser.test.ts index bafd36a69..450fcec9f 100644 --- a/test/suite/testexplorer/XCTestOutputParser.test.ts +++ b/test/suite/testexplorer/XCTestOutputParser.test.ts @@ -19,8 +19,9 @@ import { XCTestOutputParser, } from "../../../src/TestExplorer/TestParsers/XCTestOutputParser"; import { TestRunState, TestStatus } from "./MockTestRunState"; +import { sourceLocationToVSCodeLocation } from "../../../src/utilities/utilities"; -suite.only("XCTestOutputParser Suite", () => { +suite("XCTestOutputParser Suite", () => { suite("Darwin", () => { const outputParser = new XCTestOutputParser(darwinTestRegex); @@ -51,10 +52,11 @@ Test Case '-[MyTests.MyTests testFail]' failed (0.106 seconds). assert.deepEqual(runState.issues, [ { message: `XCTAssertEqual failed: ("1") is not equal to ("2")`, - location: { - file: "/Users/user/Developer/MyTests/MyTests.swift", - line: 59, - }, + location: sourceLocationToVSCodeLocation( + "/Users/user/Developer/MyTests/MyTests.swift", + 59, + 0 + ), }, ]); }); @@ -90,10 +92,11 @@ Test Case '-[MyTests.MyTests testFail]' failed (0.571 seconds). message: `failed - Multiline fail message`, - location: { - file: "/Users/user/Developer/MyTests/MyTests.swift", - line: 59, - }, + location: sourceLocationToVSCodeLocation( + "/Users/user/Developer/MyTests/MyTests.swift", + 59, + 0 + ), }, ]); }); @@ -117,17 +120,19 @@ Test Case '-[MyTests.MyTests testFail]' failed (0.571 seconds). message: `failed - Multiline fail message`, - location: { - file: "/Users/user/Developer/MyTests/MyTests.swift", - line: 59, - }, + location: sourceLocationToVSCodeLocation( + "/Users/user/Developer/MyTests/MyTests.swift", + 59, + 0 + ), }, { message: `failed - Again`, - location: { - file: "/Users/user/Developer/MyTests/MyTests.swift", - line: 61, - }, + location: sourceLocationToVSCodeLocation( + "/Users/user/Developer/MyTests/MyTests.swift", + 61, + 0 + ), }, ]); }); @@ -147,17 +152,19 @@ Test Case '-[MyTests.MyTests testFail]' failed (0.571 seconds). assert.deepEqual(runState.issues, [ { message: `failed - Message`, - location: { - file: "/Users/user/Developer/MyTests/MyTests.swift", - line: 59, - }, + location: sourceLocationToVSCodeLocation( + "/Users/user/Developer/MyTests/MyTests.swift", + 59, + 0 + ), }, { message: `failed - Again`, - location: { - file: "/Users/user/Developer/MyTests/MyTests.swift", - line: 61, - }, + location: sourceLocationToVSCodeLocation( + "/Users/user/Developer/MyTests/MyTests.swift", + 61, + 0 + ), }, ]); }); @@ -210,10 +217,11 @@ Test Case 'MyTests.testFail' failed (0.106 seconds). assert.deepEqual(runState.issues, [ { message: `XCTAssertEqual failed: ("1") is not equal to ("2")`, - location: { - file: "/Users/user/Developer/MyTests/MyTests.swift", - line: 59, - }, + location: sourceLocationToVSCodeLocation( + "/Users/user/Developer/MyTests/MyTests.swift", + 59, + 0 + ), }, ]); });