From ec568542db5274c034055956ee6cc481dd54b236 Mon Sep 17 00:00:00 2001 From: rafaelpedretti-toast Date: Mon, 4 Nov 2024 17:13:28 +0000 Subject: [PATCH] Full integration with test pane --- package.json | 12 +- src/build/commands.ts | 37 -- src/common/commands.ts | 6 +- src/extension.ts | 9 +- src/testing/commands.ts | 15 +- src/testing/manager.ts | 822 ++++++++++++++++++----------------- src/testing/testPlanTypes.ts | 50 +++ src/testing/utils.ts | 68 +++ 8 files changed, 572 insertions(+), 447 deletions(-) create mode 100644 src/testing/testPlanTypes.ts diff --git a/package.json b/package.json index 4cd4154..4c839e9 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "type": "commonjs", "displayName": "SweetPad (iOS/Swift development)", "description": "Develop Swift/iOS projects in VS Code", - "version": "0.1.41", + "version": "0.1.42-beta", "publisher": "sweetpad", "icon": "images/logo.png", "license": "MIT", @@ -285,11 +285,6 @@ "title": "SweetPad: Clean", "icon": "$(clear-all)" }, - { - "command": "sweetpad.build.test", - "title": "SweetPad: Test", - "icon": "$(beaker)" - }, { "command": "sweetpad.build.resolveDependencies", "title": "SweetPad: Resolve dependencies", @@ -310,6 +305,11 @@ "title": "SweetPad: Select testing target", "icon": "$(file-code)" }, + { + "command": "sweetpad.testing.test", + "title": "SweetPad: Test", + "icon": "$(beaker)" + }, { "command": "sweetpad.testing.buildForTesting", "title": "SweetPad: Build for testing (without running tests)", diff --git a/src/build/commands.ts b/src/build/commands.ts index abc9429..1ee6e6d 100644 --- a/src/build/commands.ts +++ b/src/build/commands.ts @@ -522,43 +522,6 @@ export async function cleanCommand(execution: CommandExecution, item?: BuildTree }); } -export async function testCommand(execution: CommandExecution, item?: BuildTreeItem) { - const xcworkspace = await askXcodeWorkspacePath(execution.context); - const scheme = - item?.scheme ?? - (await askSchemeForBuild(execution.context, { title: "Select scheme to test", xcworkspace: xcworkspace })); - const configuration = await askConfiguration(execution.context, { xcworkspace: xcworkspace }); - - const buildSettings = await getBuildSettings({ - scheme: scheme, - configuration: configuration, - sdk: undefined, - xcworkspace: xcworkspace, - }); - - const destination = await askDestinationToRunOn(execution.context, buildSettings); - const destinationRaw = getXcodeBuildDestinationString({ destination: destination }); - - const sdk = destination.platform; - - await runTask(execution.context, { - name: "Test", - problemMatchers: DEFAULT_BUILD_PROBLEM_MATCHERS, - callback: async (terminal) => { - await buildApp(execution.context, terminal, { - scheme: scheme, - sdk: sdk, - configuration: configuration, - shouldBuild: false, - shouldClean: false, - shouldTest: true, - xcworkspace: xcworkspace, - destinationRaw: destinationRaw, - }); - }, - }); -} - export async function resolveDependencies(context: ExtensionContext, options: { scheme: string; xcworkspace: string }) { await runTask(context, { name: "Resolve Dependencies", diff --git a/src/common/commands.ts b/src/common/commands.ts index 988a804..f7fc65c 100644 --- a/src/common/commands.ts +++ b/src/common/commands.ts @@ -27,12 +27,12 @@ type WorkspaceStateKey = keyof WorkspaceTypes; type SessionStateKey = "NONE_KEY"; export class ExtensionContext { - private _context: vscode.ExtensionContext; + private readonly _context: vscode.ExtensionContext; public destinationsManager: DestinationsManager; public toolsManager: ToolsManager; public buildManager: BuildManager; public testingManager: TestingManager; - private _sessionState: Map = new Map(); + private readonly _sessionState: Map = new Map(); constructor(options: { context: vscode.ExtensionContext; @@ -75,7 +75,7 @@ export class ExtensionContext { /** * State local to the running instance of the extension. It is not persisted across sessions. */ - updateSessionState(key: SessionStateKey, value: unknown | undefined) { + updateSessionState(key: SessionStateKey, value?: unknown) { this._sessionState.set(key, value); } diff --git a/src/extension.ts b/src/extension.ts index 82ce053..c53c0ae 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,9 +9,8 @@ import { resolveDependenciesCommand, runCommand, selectXcodeSchemeForBuildCommand, - testCommand, + selectXcodeWorkspaceCommand, } from "./build/commands.js"; -import { selectXcodeWorkspaceCommand } from "./build/commands.js"; import { BuildManager } from "./build/manager.js"; import { XcodeBuildTaskProvider } from "./build/provider.js"; import { DefaultSchemeStatusBar } from "./build/status-bar.js"; @@ -47,6 +46,7 @@ import { selectTestingTargetCommand, selectXcodeSchemeForTestingCommand, testWithoutBuildingCommand, + testBuildingCommand } from "./testing/commands.js"; import { TestingManager } from "./testing/manager.js"; import { installToolCommand, openDocumentationCommand } from "./tools/commands.js"; @@ -93,6 +93,9 @@ export function activate(context: vscode.ExtensionContext) { destinationsManager.context = _context; testingManager.context = _context; + // Load initial data after setting the context 🚀 + testingManager.loadTestsFromDefaultScheme(); + // Trees 🎄 const buildTreeProvider = new BuildTreeProvider({ context: _context, @@ -130,7 +133,6 @@ export function activate(context: vscode.ExtensionContext) { d(command("sweetpad.build.run", runCommand)); d(command("sweetpad.build.build", buildCommand)); d(command("sweetpad.build.clean", cleanCommand)); - d(command("sweetpad.build.test", testCommand)); d(command("sweetpad.build.resolveDependencies", resolveDependenciesCommand)); d(command("sweetpad.build.removeBundleDir", removeBundleDirCommand)); d(command("sweetpad.build.genereateBuildServerConfig", generateBuildServerConfigCommand)); @@ -139,6 +141,7 @@ export function activate(context: vscode.ExtensionContext) { d(command("sweetpad.build.setDefaultScheme", selectXcodeSchemeForBuildCommand)); // Testing + d(command("sweetpad.testing.test", testBuildingCommand)); d(command("sweetpad.testing.buildForTesting", buildForTestingCommand)); d(command("sweetpad.testing.testWithoutBuilding", testWithoutBuildingCommand)); d(command("sweetpad.testing.selectTarget", selectTestingTargetCommand)); diff --git a/src/testing/commands.ts b/src/testing/commands.ts index 2fa8600..75c6b93 100644 --- a/src/testing/commands.ts +++ b/src/testing/commands.ts @@ -17,12 +17,25 @@ export async function buildForTestingCommand(execution: CommandExecution): Promi return await execution.context.testingManager.buildForTestingCommand(); } +export async function testBuildingCommand( + execution: CommandExecution, + ...items: vscode.TestItem[] +): Promise { + const actualItems = items.length ? items : [...execution.context.testingManager.controller.items].map(([, item]) => item); + const request = new vscode.TestRunRequest(actualItems, [], undefined, undefined); + const tokenSource = new vscode.CancellationTokenSource(); + + execution.context.testingManager.buildAndRunTests(request, tokenSource.token); +} + export async function testWithoutBuildingCommand( execution: CommandExecution, ...items: vscode.TestItem[] ): Promise { - const request = new vscode.TestRunRequest(items, [], undefined, undefined); + const actualItems = items.length ? items : [...execution.context.testingManager.controller.items].map(([, item]) => item); + const request = new vscode.TestRunRequest(actualItems, [], undefined, undefined); const tokenSource = new vscode.CancellationTokenSource(); + execution.context.testingManager.runTestsWithoutBuilding(request, tokenSource.token); } diff --git a/src/testing/manager.ts b/src/testing/manager.ts index 822a2d3..4a87049 100644 --- a/src/testing/manager.ts +++ b/src/testing/manager.ts @@ -1,16 +1,20 @@ -import path from "node:path"; import * as vscode from "vscode"; +import path from "path"; import { getXcodeBuildDestinationString } from "../build/commands.js"; import { askXcodeWorkspacePath, getWorkspacePath } from "../build/utils.js"; -import { getOptionalBuildSettings } from "../common/cli/scripts.js"; +import { getBuildSettings } from "../common/cli/scripts.js"; import type { ExtensionContext } from "../common/commands.js"; -import { errorReporting } from "../common/error-reporting.js"; -import { exec } from "../common/exec.js"; -import { isFileExists } from "../common/files.js"; import { commonLogger } from "../common/logger.js"; import { runTask } from "../common/tasks.js"; import type { Destination } from "../destination/types.js"; -import { askConfigurationForTesting, askDestinationToTestOn, askSchemeForTesting, askTestingTarget } from "./utils.js"; +import { + askConfigurationForTesting, + askDestinationToTestOn, + askSchemeForTesting, + extractCodeBlock, + parseDefaultTestPlanFile +} from "./utils.js"; +import { findFilesRecursive } from '../common/files.js'; type TestingInlineError = { fileName: string; @@ -24,19 +28,25 @@ type TestingInlineError = { * - methodTestId: the test method ID in the format "ClassName.methodName" */ class XcodebuildTestRunContext { - private processedMethodTests = new Set(); - private failedMethodTests = new Set(); - private inlineErrorMap = new Map(); - private methodTests: Map; + private readonly processedMethodTests = new Set(); + private readonly failedMethodTests = new Set(); + private readonly inlineErrorMap = new Map(); + private readonly methodTests: Map; + // we need this mapping to find method test by its name without target name when running all tests + private readonly methodWithoutTargetNameTests: Map; constructor(options: { methodTests: Iterable<[string, vscode.TestItem]>; }) { this.methodTests = new Map(options.methodTests); + this.methodWithoutTargetNameTests = new Map(Array.from(options.methodTests).map(([id, test]) => { + const [, className, methodName] = id.split("."); + return [`${className}.${methodName}`, test] + })) } getMethodTest(methodTestId: string): vscode.TestItem | undefined { - return this.methodTests.get(methodTestId); + return this.methodTests.get(methodTestId) ?? this.methodWithoutTargetNameTests.get(methodTestId); } addProcessedMethodTest(methodTestId: string): void { @@ -48,6 +58,10 @@ class XcodebuildTestRunContext { } addInlineError(methodTestId: string, error: TestingInlineError): void { + commonLogger.log("Adding inline error", { + methodTestId, + error, + }) this.inlineErrorMap.set(methodTestId, error); } @@ -62,88 +76,21 @@ class XcodebuildTestRunContext { getUnprocessedMethodTests(): vscode.TestItem[] { return [...this.methodTests.values()].filter((test) => !this.processedMethodTests.has(test.id)); } - - getOverallStatus(): "passed" | "failed" | "skipped" { - // Some tests failed - if (this.failedMethodTests.size > 0) { - return "failed"; - } - - // All tests passed - if (this.processedMethodTests.size === this.methodTests.size) { - return "passed"; - } - - // Some tests are still unprocessed - return "skipped"; - } -} - -/** - * Extracts a code block from the given text starting from the given index. - * - * TODO: use a proper Swift parser to find code blocks - */ -function extractCodeBlock(text: string, startIndex: number): string | null { - let braceCount = 0; - let inString = false; - for (let i = startIndex; i < text.length; i++) { - const char = text[i]; - if (char === '"' || char === "'") { - inString = !inString; - } else if (!inString) { - if (char === "{") { - braceCount++; - } else if (char === "}") { - braceCount--; - if (braceCount === 0) { - return text.substring(startIndex, i + 1); - } - } - } - } - return null; -} - -/** - * Get all ancestor paths of a childPath that are within the parentPath (including the parentPath). - */ -function* getAncestorsPaths(options: { - parentPath: string; - childPath: string; -}): Generator { - const { parentPath, childPath } = options; - - if (!childPath.startsWith(parentPath)) { - return; - } - - let currentPath = path.dirname(childPath); - while (currentPath !== parentPath) { - yield currentPath; - currentPath = path.dirname(currentPath); - } - yield parentPath; } -/* - * Custom data for test items - */ -type TestItemContext = { - type: "class" | "method"; - spmTarget?: string; -}; - export class TestingManager { controller: vscode.TestController; private _context: ExtensionContext | undefined; + private readonly documentToTargetTestItem = new Map(); + private readonly pathToTargetTestItem: [string, vscode.TestItem][] = []; + private readonly uriToTestItem = new Map(); // Inline error messages, usually is between "passed" and "failed" lines. Seems like only macOS apps have this line. // Example output: // "/Users/username/Projects/ControlRoom/ControlRoomTests/SimCtlSubCommandsTests.swift:10: error: -[ControlRoomTests.SimCtlSubCommandsTests testDeleteUnavailable] : failed: caught "NSInternalInconsistencyException", "Failed to delete unavailable device with UDID '00000000-0000-0000-0000-000000000000'." // "/Users/hyzyla/Developer/sweetpad-examples/ControlRoom/ControlRoomTests/Controllers/SimCtl+SubCommandsTests.swift:76: error: -[ControlRoomTests.SimCtlSubCommandsTests testDefaultsForApp] : XCTAssertEqual failed: ("1") is not equal to ("2")" // {filePath}:{lineNumber}: error: -[{classAndTargetName} {methodName}] : {errorMessage} - readonly INLINE_ERROR_REGEXP = /(.*):(\d+): error: -\[.* (.*)\] : (.*)/; + readonly INLINE_ERROR_REGEXP = /(.*):(\d+): error: -\[(.*) (.*)\] : (.*)/; // Find test method status lines // Example output: @@ -157,70 +104,31 @@ export class TestingManager { // "Test case 'terminal23TesMakarenko1ts.testPerformanceExample()' passed on 'Clone 1 of iPhone 14 - terminal23 (27767)' (0.254 seconds)" readonly METHOD_STATUS_REGEXP_IOS = /Test case '(.*)\.(.*)\(\)' (.*)/; - // Here we are storign additional data for test items. Weak map garanties that we - // don't keep the items in memory if they are not used anymore - readonly testItems = new WeakMap(); - - // Root folder of the workspace (VSCode, not Xcode) - readonly workspacePath: string; - constructor() { - this.workspacePath = getWorkspacePath(); - this.controller = vscode.tests.createTestController("sweetpad", "SweetPad"); - // Register event listeners for updating test items when documents change or open - vscode.workspace.onDidOpenTextDocument((document) => this.updateTestItems(document)); - vscode.workspace.onDidChangeTextDocument((event) => this.updateTestItems(event.document)); - - // Initialize test items for already open documents - for (const document of vscode.workspace.textDocuments) { - this.updateTestItems(document); - } + vscode.workspace.onDidCreateFiles(event => this.handleFileCreation(event)) + vscode.workspace.onDidDeleteFiles(event => this.handleFileDeletion(event)) + vscode.workspace.onDidChangeTextDocument(event => this.handleFileChange(event)); // Default for profile that is slow due to build step, but should work in most cases - this.createRunProfile({ - name: "Build and Run Tests", - kind: vscode.TestRunProfileKind.Run, - isDefault: true, - run: (request, token) => this.buildAndRunTests(request, token), - }); + this.controller.createRunProfile( + "Build and Run Tests", + vscode.TestRunProfileKind.Run, + (request, token) => { + return this.buildAndRunTests(request, token); + }, + true, // is default profile + ); // Profile for running tests without building, should be faster but you may need to build manually - this.createRunProfile({ - name: "Run Tests Without Building", - kind: vscode.TestRunProfileKind.Run, - isDefault: false, - run: (request, token) => this.runTestsWithoutBuilding(request, token), - }); - } - - /** - * Create run profile for the test controller with proper error handling - */ - createRunProfile(options: { - name: string; - kind: vscode.TestRunProfileKind; - isDefault?: boolean; - run: (request: vscode.TestRunRequest, token: vscode.CancellationToken) => Promise; - }) { this.controller.createRunProfile( - options.name, - options.kind, - async (request, token) => { - try { - return await options.run(request, token); - } catch (error) { - const errorMessage: string = - error instanceof Error ? error.message : (error?.toString() ?? "[unknown error]"); - commonLogger.error(errorMessage, { - error: error, - }); - errorReporting.captureException(error); - throw error; - } + "Run Tests Without Building", + vscode.TestRunProfileKind.Run, + (request, token) => { + return this.runTestsWithoutBuilding(request, token); }, - options.isDefault, + false, ); } @@ -235,6 +143,131 @@ export class TestingManager { return this._context; } + private handleFileCreation(event: vscode.FileCreateEvent) { + event.files.forEach((file) => { + if (!file.path.endsWith(".swift")) { + return + } + + const { path: documentPath } = file + const targetTestItem = this.pathToTargetTestItem.find(([path,]) => documentPath.startsWith(path))?.[1] + if (!targetTestItem) { + return + } + + this.documentToTargetTestItem.set(documentPath, targetTestItem) + vscode.workspace.openTextDocument(file).then((file) => { + this.updateTestItems(file, targetTestItem) + }) + }); + } + + private handleFileDeletion(event: vscode.FileDeleteEvent) { + event.files.forEach((file) => { + if (!file.path.endsWith(".swift")) { + return + } + + const { path: documentPath } = file + const targetTestItem = this.uriToTestItem.get(documentPath) + if (!targetTestItem) { + commonLogger.warn("No target test item found for the document", { + documentPath, + uriToTestItem: Array.from(this.uriToTestItem.keys()) + }) + return + } + + for (const testItem of targetTestItem) { + commonLogger.log("Deleting test item", { + testItem: testItem.id, + controllerItems: Array.from(this.controller.items).map(([id, item]) => item.id) + }) + + let { parent } = testItem + + if (parent) { + parent.children.delete(testItem.id) + } + this.controller.items.delete(testItem.id) + } + }); + } + + private handleFileChange(event: vscode.TextDocumentChangeEvent) { + if (!event.document.fileName.endsWith(".swift")) { + return + } + + const { path: documentPath } = event.document.uri + + const targetTestItem = this.documentToTargetTestItem.get(documentPath) ?? this.pathToTargetTestItem.find(([path,]) => documentPath.startsWith(path))?.[1] + if (!targetTestItem) { + commonLogger.warn("No target test item found for the document", { + documentPath, + pathToTargetTestItem: Array.from(this.pathToTargetTestItem.keys()), + documentToTargetTestItem: Array.from(this.documentToTargetTestItem.keys()) + }) + return + } + this.updateTestItems(event.document, targetTestItem) + } + + async loadTestsFromDefaultScheme() { + const rootPath = getWorkspacePath(); + const testPlan = parseDefaultTestPlanFile(this.context, rootPath); + const root = testPlan.defaultOptions.targetForVariableExpansion.name + + const rootTest = this.controller.createTestItem("", root, vscode.Uri.parse(rootPath)); + + for (const testTarget of testPlan.testTargets) { + const { containerPath, name: targetName, identifier } = testTarget.target; + const [, container] = containerPath.split("container:") + + let fullContainerPath: string + let testDir: string + + if (container) { + // test is not a SPM managed package + if (container.endsWith(".xcodeproj")) { + fullContainerPath = rootPath + testDir = path.join(fullContainerPath, targetName) + } else { // test is a SPM managed package + fullContainerPath = path.join(rootPath, container) + testDir = path.join(fullContainerPath, "Tests", identifier) + } + + const targetTestsParent = this.controller.createTestItem(`${targetName}`, targetName, vscode.Uri.parse(fullContainerPath)) + + this.controller.items.add(rootTest) + rootTest.children.add(targetTestsParent) + + this.pathToTargetTestItem.push([testDir, targetTestsParent]) + let swiftFiles = await findFilesRecursive({ + directory: testDir, + matcher: (file) => file.isFile() && file.name.endsWith(".swift"), + depth: 5 + }) + + swiftFiles.forEach(fileUri => { + this.documentToTargetTestItem.set(fileUri, targetTestsParent) + vscode.workspace.openTextDocument(fileUri).then((file) => { + this.updateTestItems(file, targetTestsParent) + }) + }) + } + } + } + + alltestsFrom(testItem: vscode.TestItemCollection, items: string[] = []): string[] { + testItem.forEach((item) => { + items.push(item.id) + this.alltestsFrom(item.children, items) + }) + + return items + } + dispose() { this.controller.dispose(); } @@ -247,71 +280,51 @@ export class TestingManager { return this.context.getWorkspaceState("testing.xcodeTarget"); } - /** - * Create a new test item for the given document with additional context data - */ - createTestItem(options: { - id: string; - label: string; - uri: vscode.Uri; - type: TestItemContext["type"]; - }): vscode.TestItem { - const testItem = this.controller.createTestItem(options.id, options.label, options.uri); - this.testItems.set(testItem, { - type: options.type, - }); - return testItem; - } - /** * Find all test methods in the given document and update the test items in test controller * * TODO: use a proper Swift parser to find test methods */ - updateTestItems(document: vscode.TextDocument) { + updateTestItems(document: vscode.TextDocument, parent: vscode.TestItem) { // Remove existing test items for this document - for (const testItem of this.controller.items) { + for (const testItem of parent.children) { if (testItem[1].uri?.toString() === document.uri.toString()) { - this.controller.items.delete(testItem[0]); + parent.children.delete(testItem[0]); } } - // Check if this is a Swift file - if (!document.fileName.endsWith(".swift")) { - return; - } - const text = document.getText(); - // Regex to find classes inheriting from XCTestCase - const classRegex = /class\s+(\w+)\s*:\s*XCTestCase\s*\{/g; + // Regex to find classes inheriting from XCTestCase that are not commented out + const classRegex = /^(?!\s*\/\/s*).*[^\S\r\n]*class[^\S\r\n]+(\w+)\s*:\s*XCTestCase\s*\{/gm; // let classMatch; while (true) { const classMatch = classRegex.exec(text); + if (classMatch === null) { break; } const className = classMatch[1]; - const classStartIndex = classMatch.index + classMatch[0].length; const classPosition = document.positionAt(classMatch.index); - const classTestItem = this.createTestItem({ - id: className, - label: className, - uri: document.uri, - type: "class", - }); + const classId = `${parent.id}.${className}` + + const classTestItem = this.controller.createTestItem(classId, className, document.uri); classTestItem.range = new vscode.Range(classPosition, classPosition); - this.controller.items.add(classTestItem); - const classCode = extractCodeBlock(text, classStartIndex - 1); // Start from '{' + parent.children.add(classTestItem); + + const existingTests = this.uriToTestItem.get(document.uri.path) ?? [] + this.uriToTestItem.set(document.uri.path, [...existingTests, classTestItem]) + + const classCode = extractCodeBlock(className, text); if (classCode === null) { continue; // Could not find class code block } // Find all test methods within the class - const funcRegex = /func\s+(test\w+)\s*\(/g; + const funcRegex = /^(?!\s*\/\/s*).*func[^\S\r\n]+(test\w+)\s*\(/gm; while (true) { const funcMatch = funcRegex.exec(classCode); @@ -319,16 +332,10 @@ export class TestingManager { break; } const testName = funcMatch[1]; - const testStartIndex = classStartIndex + funcMatch.index; + const testStartIndex = classMatch.index + funcMatch.index; const position = document.positionAt(testStartIndex); - const testItem = this.createTestItem({ - id: `${className}.${testName}`, - label: testName, - uri: document.uri, - type: "method", - }); - + const testItem = this.controller.createTestItem(`${classId}.${testName}`, testName, document.uri); testItem.range = new vscode.Range(position, position); classTestItem.children.add(testItem); } @@ -355,7 +362,7 @@ export class TestingManager { const configuration = await askConfigurationForTesting(this.context, { xcworkspace: xcworkspace, }); - const buildSettings = await getOptionalBuildSettings({ + const buildSettings = await getBuildSettings({ scheme: scheme, configuration: configuration, sdk: undefined, @@ -444,83 +451,79 @@ export class TestingManager { return new vscode.TestMessage("Test failed (error message is not extracted)."); } + getStatusFromOutputLine( + line: string, + runContext: XcodebuildTestRunContext, + testIdSuffix: string + ): [string?, vscode.TestItem?, string?] { + const methodStatusMatchIOS = this.METHOD_STATUS_REGEXP_IOS.exec(line); + const methodStatusMatchMacOS = this.METHOD_STATUS_REGEXP_MACOS.exec(line); + let status: string = ""; + let methodTest: vscode.TestItem | undefined + + if (methodStatusMatchIOS) { + const [, className, methodName, lineStatus] = methodStatusMatchIOS; + const methodTestId = testIdSuffix ? `${testIdSuffix}.${className}.${methodName}` : `${className}.${methodName}`; + methodTest = runContext.getMethodTest(methodTestId); + status = lineStatus + } else if (methodStatusMatchMacOS) { + // from MacOS we can extract both target and class name + const [, targetAndclassName, methodName, lineStatus] = methodStatusMatchMacOS; + const methodTestId = `${targetAndclassName}.${methodName}` + + methodTest = runContext.getMethodTest(methodTestId); + status = lineStatus + } + + if (status && methodTest) { + return [status, methodTest] + } + + return [] + } + /** * Parse each line of the `xcodebuild` output to update the test run * with the test status and any inline error messages. */ async parseOutputLine(options: { line: string; - className: string; + testIdSuffix: string; testRun: vscode.TestRun; runContext: XcodebuildTestRunContext; }) { - const { testRun, className, runContext } = options; + const { testRun, testIdSuffix, runContext } = options; const line = options.line.trim(); - const methodStatusMatchIOS = line.match(this.METHOD_STATUS_REGEXP_IOS); - if (methodStatusMatchIOS) { - const [, , methodName, status] = methodStatusMatchIOS; - const methodTestId = `${className}.${methodName}`; - - const methodTest = runContext.getMethodTest(methodTestId); - if (!methodTest) { - return; - } - - if (status.startsWith("started")) { - testRun.started(methodTest); - } else if (status.startsWith("passed")) { - testRun.passed(methodTest); - runContext.addProcessedMethodTest(methodTestId); - } else if (status.startsWith("failed")) { - const error = this.getMethodError({ - methodTestId: methodTestId, - runContext: runContext, - }); - testRun.failed(methodTest, error); - runContext.addProcessedMethodTest(methodTestId); - runContext.addFailedMethodTest(methodTestId); - } - return; - } - - const methodStatusMatchMacOS = line.match(this.METHOD_STATUS_REGEXP_MACOS); - if (methodStatusMatchMacOS) { - const [, , methodName, status] = methodStatusMatchMacOS; - const methodTestId = `${className}.${methodName}`; - - const methodTest = runContext.getMethodTest(methodTestId); - if (!methodTest) { - return; - } - + const [status, methodTest] = this.getStatusFromOutputLine(line, runContext, testIdSuffix) + if (status && methodTest) { if (status.startsWith("started")) { testRun.started(methodTest); } else if (status.startsWith("passed")) { testRun.passed(methodTest); - runContext.addProcessedMethodTest(methodTestId); + runContext.addProcessedMethodTest(methodTest.id); } else if (status.startsWith("failed")) { const error = this.getMethodError({ - methodTestId: methodTestId, + methodTestId: methodTest.id, runContext: runContext, }); testRun.failed(methodTest, error); - runContext.addProcessedMethodTest(methodTestId); - runContext.addFailedMethodTest(methodTestId); + runContext.addProcessedMethodTest(methodTest.id); + runContext.addFailedMethodTest(methodTest.id); } return; } - const inlineErrorMatch = line.match(this.INLINE_ERROR_REGEXP); + const inlineErrorMatch = this.INLINE_ERROR_REGEXP.exec(line); if (inlineErrorMatch) { - const [, filePath, lineNumber, methodName, errorMessage] = inlineErrorMatch; - const testId = `${className}.${methodName}`; + const [, filePath, lineNumber, targetAndClassName, methodName, errorMessage] = inlineErrorMatch; + const testId = `${targetAndClassName}.${methodName}`; + runContext.addInlineError(testId, { fileName: filePath, lineNumber: Number.parseInt(lineNumber, 10), message: errorMessage, }); - return; } } @@ -540,117 +543,7 @@ export class TestingManager { // when class test is runned, all its method tests are runned too, so we need to filter out // methods that should be runned as part of class test - return queue.filter((test) => { - const [className, methodName] = test.id.split("."); - if (!methodName) return true; - return !queue.some((t) => t.id === className); - }); - } - - /** - * For SPM packages we need to resolve the target name for the test file - * from the Package.swift file. For some reason it doesn't use the target name - * from xcode project - */ - async resolveSPMTestingTarget(options: { - queue: vscode.TestItem[]; - xcworkspace: string; - }) { - const { queue, xcworkspace } = options; - const workscePath = getWorkspacePath(); - - // Cache for resolved target names. Example: - // - /folder1/folder2/Tests/MyAppTests -> "" - // - /folder1/folder2/Tests -> "" - // - /folder1/folder2 -> "MyAppTests" - const pathCache = new Map(); - - for (const test of queue) { - const testPath = test.uri?.fsPath; - if (!testPath) { - continue; - } - - // In general all should have context, but check just in case - const testContext = this.testItems.get(test); - if (!testContext) { - continue; - } - - // Iterate over all ancestors of the test file path to find SPM file - // Example: - // /folder1/folder2/folder3/Tests/MyAppTests/MyAppTests.swift - // /folder1/folder2/folder3/Tests/MyAppTests/ - // /folder1/folder2/folder3/Tests - // /folder1/folder2/folder3 - for (const ancestorPath of getAncestorsPaths({ - parentPath: workscePath, - childPath: testPath, - })) { - const cachedTarget = pathCache.get(ancestorPath); - if (cachedTarget !== undefined) { - // path doesn't have "Package.swift" file, so move to the next ancestor - if (cachedTarget === "") { - continue; - } - testContext.spmTarget = cachedTarget; - } - - const packagePath = path.join(ancestorPath, "Package.swift"); - const isPackageExists = await isFileExists(packagePath); - if (!isPackageExists) { - pathCache.set(ancestorPath, ""); - continue; - } - - // stop search and try to get the target name from "Package.swift" file - try { - const stdout = await exec({ - command: "swift", - args: ["package", "dump-package"], - cwd: ancestorPath, - }); - const stdoutJson = JSON.parse(stdout); - - const targets = stdoutJson.targets; - const testTargetNames = targets - ?.filter((target: any) => target.type === "test") - .filter((target: any) => { - const targetPath = target.path - ? path.join(ancestorPath, target.path) - : path.join(ancestorPath, "Tests", target.name); - return testPath.startsWith(targetPath); - }) - .map((target: any) => target.name); - - if (testTargetNames.length === 1) { - const testTargetName = testTargetNames[0]; - pathCache.set(ancestorPath, testTargetName); - testContext.spmTarget = testTargetName; - return testTargetName; - } - } catch (error) { - // In case of error, we assume that the target name is is name name of test folder: - // - Tests/{targetName}/{testFile}.swift - commonLogger.error("Failed to get test target name", { - error: error, - }); - - const relativePath = path.relative(ancestorPath, testPath); - const match = relativePath.match(/^Tests\/([^/]+)/); - if (match) { - const testTargetName = match[1]; - pathCache.set(ancestorPath, testTargetName); - testContext.spmTarget = testTargetName; - return match[1]; - } - } - - // Package.json exists but we failed to get the target name, let's move on to the next ancestor - pathCache.set(ancestorPath, ""); - break; - } - } + return queue } /** @@ -668,17 +561,6 @@ export class TestingManager { const queue = this.prepareQueueForRun(request); - await this.resolveSPMTestingTarget({ - queue: queue, - xcworkspace: xcworkspace, - }); - - commonLogger.debug("Running tests", { - scheme: scheme, - xcworkspace: xcworkspace, - tests: queue.map((test) => test.id), - }); - for (const test of queue) { commonLogger.debug("Running single test from queue", { testId: test.id, @@ -690,29 +572,43 @@ export class TestingManager { continue; } - const defaultTarget = await askTestingTarget(this.context, { - xcworkspace: xcworkspace, - title: "Select a target to run tests", - }); + const [target, className, methodName] = test.id.split("."); - if (test.id.includes(".")) { + if (methodName) { await this.runMethodTest({ run: run, methodTest: test, xcworkspace: xcworkspace, destination: options.destination, scheme: scheme, - defaultTarget: defaultTarget, + target: `${target}/${className}/${methodName}`, }); - } else { + } else if (className) { await this.runClassTest({ run: run, classTest: test, scheme: scheme, xcworkspace: xcworkspace, destination: options.destination, - defaultTarget: defaultTarget, + target: `${target}/${className}`, + }); + } else if (target) { + await this.runTargetTests({ + run: run, + targetTest: test, + xcworkspace: xcworkspace, + destination: options.destination, + scheme: scheme, + target: target, }); + } else { + await this.runAllTests({ + run: run, + root: test, + xcworkspace: xcworkspace, + destination: options.destination, + scheme: scheme, + }) } } } @@ -757,6 +653,8 @@ export class TestingManager { xcworkspace: xcworkspace, }); + commonLogger.log("Project is built, running tests", {}); + await this.runTests({ run: run, request: request, @@ -765,6 +663,8 @@ export class TestingManager { scheme: scheme, token: token, }); + + commonLogger.log("Tests ended", {}); } finally { run.end(); } @@ -776,10 +676,10 @@ export class TestingManager { scheme: string; xcworkspace: string; destination: Destination; - defaultTarget: string | null; + target: string; }): Promise { - const { run, classTest, scheme, defaultTarget } = options; - const className = classTest.id; + const { run, classTest, scheme, target } = options; + const [targetName, ,] = classTest.id.split("."); const runContext = new XcodebuildTestRunContext({ methodTests: [...classTest.children], @@ -787,18 +687,13 @@ export class TestingManager { const destinationRaw = getXcodeBuildDestinationString({ destination: options.destination }); - // Some test items like SPM packages have a separate target for tests, in other case we use - // the same target for all selected tests - const testTarget = this.testItems.get(classTest)?.spmTarget ?? defaultTarget; - if (!testTarget) { - throw new Error("Test target is not defined"); - } - - run.started(classTest); + classTest.children.forEach((methodTest) => { + run.started(methodTest); + }); try { await runTask(this.context, { - name: "sweetpad.build.test", + name: "sweetpad.testing.test", callback: async (terminal) => { await terminal.execute({ command: "xcodebuild", @@ -810,14 +705,14 @@ export class TestingManager { destinationRaw, "-scheme", scheme, - `-only-testing:${testTarget}/${classTest.id}`, + `-only-testing:${target}`, ], onOutputLine: async (output) => { console.log("output", output); await this.parseOutputLine({ line: output.value, testRun: run, - className: className, + testIdSuffix: targetName, runContext: runContext, }); }, @@ -839,16 +734,6 @@ export class TestingManager { for (const methodTest of runContext.getUnprocessedMethodTests()) { run.skipped(methodTest); } - - // Determine the overall status of the test class - const overallStatus = runContext.getOverallStatus(); - if (overallStatus === "failed") { - run.failed(classTest, new vscode.TestMessage("One or more tests failed.")); - } else if (overallStatus === "passed") { - run.passed(classTest); - } else if (overallStatus === "skipped") { - run.skipped(classTest); - } } } @@ -858,28 +743,20 @@ export class TestingManager { xcworkspace: string; scheme: string; destination: Destination; - defaultTarget: string | null; + target: string; }): Promise { - const { run: testRun, methodTest, scheme, defaultTarget } = options; - const [className, methodName] = methodTest.id.split("."); + const { run: testRun, methodTest, scheme, target } = options; + const [targetName,] = methodTest.id.split("."); const runContext = new XcodebuildTestRunContext({ methodTests: [[methodTest.id, methodTest]], }); - // Some test items like SPM packages have a separate target for tests, in other case we use - // the same target for all selected tests - const testTarget = this.testItems.get(methodTest)?.spmTarget ?? defaultTarget; - - if (!testTarget) { - throw new Error("Test target is not defined"); - } - const destinationRaw = getXcodeBuildDestinationString({ destination: options.destination }); // Run "xcodebuild" command as a task to see the test output await runTask(this.context, { - name: "sweetpad.build.test", + name: "sweetpad.testing.test", callback: async (terminal) => { try { await terminal.execute({ @@ -892,13 +769,13 @@ export class TestingManager { destinationRaw, "-scheme", scheme, - `-only-testing:${testTarget}/${className}/${methodName}`, + `-only-testing:${target}`, ], onOutputLine: async (output) => { await this.parseOutputLine({ line: output.value, testRun: testRun, - className: className, + testIdSuffix: targetName, runContext: runContext, }); }, @@ -915,4 +792,155 @@ export class TestingManager { }, }); } + + async runTargetTests(options: { + run: vscode.TestRun; + targetTest: vscode.TestItem; + scheme: string; + xcworkspace: string; + destination: Destination; + target: string; + }): Promise { + const { run, targetTest, scheme, target } = options; + const [targetName, ,] = targetTest.id.split("."); + + let methodTests: Iterable<[string, vscode.TestItem]> = []; + + targetTest.children.forEach((classTest) => { + methodTests = [...methodTests, ...classTest.children] + }) + + const runContext = new XcodebuildTestRunContext({ + methodTests, + }); + + const destinationRaw = getXcodeBuildDestinationString({ destination: options.destination }); + + targetTest.children.forEach((classTest) => { + classTest.children.forEach((methodTest) => { + run.started(methodTest); + }) + }) + + try { + await runTask(this.context, { + name: "sweetpad.testing.test", + callback: async (terminal) => { + await terminal.execute({ + command: "xcodebuild", + args: [ + "test-without-building", + "-workspace", + options.xcworkspace, + "-destination", + destinationRaw, + "-scheme", + scheme, + `-only-testing:${target}`, + ], + onOutputLine: async (output) => { + console.log("output", output); + await this.parseOutputLine({ + line: output.value, + testRun: run, + testIdSuffix: targetName, + runContext: runContext, + }); + }, + }); + }, + }); + } catch (error) { + console.error("Test class failed due to an error", error); + // Handle any errors during test execution + const errorMessage = `Test class failed due to an error: ${error instanceof Error ? error.message : "Test failed"}`; + run.failed(targetTest, new vscode.TestMessage(errorMessage)); + + // Mark all unprocessed child tests as failed + for (const methodTest of runContext.getUnprocessedMethodTests()) { + run.failed(methodTest, new vscode.TestMessage("Test failed due to an error.")); + } + } finally { + // Mark any unprocessed tests as skipped + for (const methodTest of runContext.getUnprocessedMethodTests()) { + run.skipped(methodTest); + } + } + } + + async runAllTests(options: { + run: vscode.TestRun; + root: vscode.TestItem; + xcworkspace: string; + scheme: string; + destination: Destination; + }) { + const { run, root, scheme } = options; + + let methodTests: Iterable<[string, vscode.TestItem]> = []; + + root.children.forEach((targetTests) => { + targetTests.children.forEach((classTest) => { + methodTests = [...methodTests, ...classTest.children] + }) + }) + + const runContext = new XcodebuildTestRunContext({ + methodTests + }); + + const destinationRaw = getXcodeBuildDestinationString({ destination: options.destination }); + + root.children.forEach((targetTests) => { + targetTests.children.forEach((classTest) => { + classTest.children.forEach((methodTest) => { + run.started(methodTest); + }) + }); + }) + + try { + await runTask(this.context, { + name: "sweetpad.testing.test", + callback: async (terminal) => { + await terminal.execute({ + command: "xcodebuild", + args: [ + "test-without-building", + "-workspace", + options.xcworkspace, + "-destination", + destinationRaw, + "-scheme", + scheme + ], + onOutputLine: async (output) => { + console.log("output", output); + await this.parseOutputLine({ + line: output.value, + testRun: run, + testIdSuffix: "", + runContext: runContext, + }); + }, + }); + }, + }); + } catch (error) { + console.error("Test class failed due to an error", error); + // Handle any errors during test execution + const errorMessage = `Test class failed due to an error: ${error instanceof Error ? error.message : "Test failed"}`; + run.failed(root, new vscode.TestMessage(errorMessage)); + + // Mark all unprocessed child tests as failed + for (const methodTest of runContext.getUnprocessedMethodTests()) { + run.failed(methodTest, new vscode.TestMessage("Test failed due to an error.")); + } + } finally { + // Mark any unprocessed tests as skipped + for (const methodTest of runContext.getUnprocessedMethodTests()) { + run.skipped(methodTest); + } + } + } } diff --git a/src/testing/testPlanTypes.ts b/src/testing/testPlanTypes.ts new file mode 100644 index 0000000..943a915 --- /dev/null +++ b/src/testing/testPlanTypes.ts @@ -0,0 +1,50 @@ + +export interface Configuration { + id: string; + name: string; + options: Record; +} + +export interface EnvironmentVariableEntry { + key: string; + value: string; +} + +export interface LocationScenario { + identifier: string; + referenceType: string; +} + +export interface TargetForVariableExpansion { + containerPath: string; + identifier: string; + name: string; +} + +export interface DefaultOptions { + codeCoverage: boolean; + environmentVariableEntries: EnvironmentVariableEntry[]; + language: string; + locationScenario: LocationScenario; + region: string; + targetForVariableExpansion: TargetForVariableExpansion; +} + +export interface Target { + containerPath: string; + identifier: string; + name: string; +} + +export interface TestTarget { + parallelizable?: boolean; + skippedTests?: string[]; + target: Target; +} + +export interface TestPlan { + configurations: Configuration[]; + defaultOptions: DefaultOptions; + testTargets: TestTarget[]; + version: number; +} \ No newline at end of file diff --git a/src/testing/utils.ts b/src/testing/utils.ts index 9b54931..dd9b841 100644 --- a/src/testing/utils.ts +++ b/src/testing/utils.ts @@ -1,9 +1,15 @@ import * as vscode from "vscode"; +import path from "path"; +import fs from "fs"; import { askConfigurationBase } from "../common/askers"; import { type XcodeBuildSettings, getSchemes, getTargets } from "../common/cli/scripts"; import type { ExtensionContext } from "../common/commands"; import { showQuickPick } from "../common/quick-pick"; import type { Destination } from "../destination/types"; +import { getCurrentXcodeWorkspacePath } from '../build/utils'; +import { parseXml, XmlElement } from '@rgrove/parse-xml'; +import type { TestPlan } from './testPlanTypes'; +import { ExtensionError } from '../common/errors'; /** * Ask user to select target to build @@ -174,3 +180,65 @@ export async function askSchemeForTesting( context.buildManager.setDefaultSchemeForTesting(schemeName); return schemeName; } + +export function parseDefaultTestPlanFile(context: ExtensionContext, rootPath: string): TestPlan { + const scheme = context.buildManager.getDefaultSchemeForTesting(); + const xcworkspacePath = getCurrentXcodeWorkspacePath(context) + if (scheme && xcworkspacePath) { + const schemePath = path.join(xcworkspacePath, "../xcshareddata/xcschemes", scheme + ".xcscheme"); + const content = fs.readFileSync(schemePath, "utf-8") + const parsed = parseXml(content); + const testAction = parsed.root?. + children.find((node) => node instanceof XmlElement && node.name === "TestAction") as XmlElement; + const testPlanElement = testAction?.children.find((node) => node instanceof XmlElement && node.name === "TestPlans") as XmlElement; + const testPlanReference = testPlanElement?.children.find((node) => node instanceof XmlElement && node.name === "TestPlanReference") as XmlElement; + const testPlanReferenceContainer = testPlanReference?.attributes['reference']; + const [, testPlanPath] = testPlanReferenceContainer.split("container:"); + + return JSON.parse(fs.readFileSync(path.join(rootPath, testPlanPath), "utf-8")) as TestPlan; + } + + throw new ExtensionError("no scheme or workspace found"); +} + +/** + * Extracts a code block from the given text starting from the given class name. + * + * TODO: use a proper Swift parser to find code blocks + */ +export function extractCodeBlock(className: string, content: string): string | null { + const lines = content.split('\n'); + + let codeBlock = []; + let stack = 0; + let inBlock = false; + let foundEntry = false + + for (const line of lines) { + foundEntry = foundEntry || line.includes(className) + + if (!foundEntry) { + continue + } + + if (line.includes('{')) { + if (!inBlock) { + inBlock = true; // Start of the outermost block + } + stack++; // Increase stack count for each new block start + } + + if (inBlock) { + codeBlock.push(line); // Add the line to the code block + } + + if (line.includes('}') && inBlock) { + stack--; // Decrease stack count for each block end + if (stack === 0) { + break; // Exit loop after the entire block is captured + } + } + } + + return codeBlock.length > 0 ? codeBlock.join('\n') : null; +} \ No newline at end of file