diff --git a/sweetpad-docs/docs/vscode/settings.md b/sweetpad-docs/docs/vscode/settings.md index 526708a0..c9c09f4b 100644 --- a/sweetpad-docs/docs/vscode/settings.md +++ b/sweetpad-docs/docs/vscode/settings.md @@ -77,6 +77,7 @@ Covered in depth in [Tests](./tests.md). | Setting | Default | What it does | | --------------------------------- | ------- | ----------------------------------------------------------- | | `sweetpad.testing.configuration` | — | Build configuration used when running tests (e.g. `Testing`). Unset = same flow as building. | +| `sweetpad.testing.baseClasses` | `[]` | Extra class names to recognize as XCTest base classes, so test classes inheriting from a shared base are discovered. `XCTestCase` is always recognized. | ## Formatting diff --git a/sweetpad-docs/docs/vscode/tests.md b/sweetpad-docs/docs/vscode/tests.md index 81783406..9e4f1b6b 100644 --- a/sweetpad-docs/docs/vscode/tests.md +++ b/sweetpad-docs/docs/vscode/tests.md @@ -20,6 +20,20 @@ failure from the Problems list. The first run for a target builds the test bundle (this can take a moment). Subsequent runs reuse the build. +## Tests that inherit from a custom base class + +SweetPad reads the inheritance clause to decide what's a test class, so `class FooTests: XCTestCase` is picked up on +its own. If your tests inherit from a shared base instead — `class FooTests: BaseTestCase` — name that base class: + +```json title=".vscode/settings.json" +{ + "sweetpad.testing.baseClasses": ["BaseTestCase", "QuickSpec"] +} +``` + +The names are matched as written rather than resolved, so a longer chain needs every level listed: +`FooTests: UITestCase: BaseTestCase: XCTestCase` needs both `UITestCase` and `BaseTestCase`. + ## Pick a different test target If your project has multiple test targets (for example one for the app and a separate one for an SPM module), pin the diff --git a/sweetpad-vscode/package.json b/sweetpad-vscode/package.json index f911f3a7..04d906ac 100644 --- a/sweetpad-vscode/package.json +++ b/sweetpad-vscode/package.json @@ -1154,6 +1154,20 @@ "default": null, "description": "Configuration to build for testing." }, + "sweetpad.testing.baseClasses": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "examples": [ + [ + "BaseTestCase", + "QuickSpec" + ] + ], + "description": "Extra class names to recognize as XCTest base classes, so test classes that inherit from a shared base show up in the Testing panel. XCTestCase is always recognized. Names are matched textually, so list every level of a longer chain." + }, "sweetpad.system.taskExecutor": { "type": "string", "default": "v3", diff --git a/sweetpad-vscode/src/common/config.ts b/sweetpad-vscode/src/common/config.ts index 6e571172..3526ba5c 100644 --- a/sweetpad-vscode/src/common/config.ts +++ b/sweetpad-vscode/src/common/config.ts @@ -53,6 +53,7 @@ type Config = { "tuist.autogenerate": boolean; "tuist.generate.env": { [key: string]: string | null }; "testing.configuration": string; + "testing.baseClasses": string[]; "cliServer.enabled": boolean; "hotReload.enabled": boolean; "hotReload.dylibPath": string | null; diff --git a/sweetpad-vscode/src/testing/manager.spec.ts b/sweetpad-vscode/src/testing/manager.spec.ts new file mode 100644 index 00000000..bb7d41e6 --- /dev/null +++ b/sweetpad-vscode/src/testing/manager.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { findTestClasses } from "./manager"; + +const DEFAULT_BASES = new Set(["XCTestCase"]); + +/** Class names found in `src`, in source order. */ +function names(src: string, bases: Set = DEFAULT_BASES): string[] { + return findTestClasses(src, bases).map((match) => match.className); +} + +describe("findTestClasses", () => { + it("finds a class inheriting from XCTestCase directly", () => { + expect(names("class FooTests: XCTestCase {\n}")).toEqual(["FooTests"]); + }); + + it("finds a class inheriting from a configured base class", () => { + const src = "class FooTests: BaseTestCase {\n}"; + expect(names(src)).toEqual([]); + expect(names(src, new Set(["XCTestCase", "BaseTestCase"]))).toEqual(["FooTests"]); + }); + + it("ignores a class inheriting from an unknown type", () => { + expect(names("class ViewModel: ObservableObject {\n}")).toEqual([]); + }); + + it("finds a class that also conforms to protocols", () => { + expect(names("final class FooTests: XCTestCase, Sendable {\n}")).toEqual(["FooTests"]); + }); + + it("finds a class whose inheritance clause is wrapped across lines", () => { + expect(names("class FooTests:\n XCTestCase\n{\n}")).toEqual(["FooTests"]); + }); + + it("finds a generic test class", () => { + expect(names("class FooTests: XCTestCase {\n}")).toEqual(["FooTests"]); + }); + + it("accepts a module-qualified base class", () => { + expect(names("class FooTests: XCTest.XCTestCase {\n}")).toEqual(["FooTests"]); + }); + + it("looks past attributes and access modifiers", () => { + expect(names("@MainActor\npublic final class FooTests: XCTestCase {\n}")).toEqual(["FooTests"]); + }); + + it("does not match a type whose name merely ends in 'class'", () => { + expect(names("let subclass: XCTestCase\n")).toEqual([]); + }); + + it("skips a declaration with no body", () => { + expect(names("class FooTests: XCTestCase")).toEqual([]); + }); + + it("finds every test class in a file, leaving other classes out", () => { + const src = ["class Helper: NSObject {}", "class FooTests: XCTestCase {}", "class BarTests: BaseTestCase {}"].join( + "\n", + ); + expect(names(src, new Set(["XCTestCase", "BaseTestCase"]))).toEqual(["FooTests", "BarTests"]); + }); + + it("reports the declaration and body offsets", () => { + const src = "// header\nclass FooTests: XCTestCase, Sendable {\n}"; + expect(findTestClasses(src, DEFAULT_BASES)).toEqual([ + { + className: "FooTests", + declarationIndex: src.indexOf("class FooTests"), + bodyIndex: src.indexOf("{"), + }, + ]); + }); +}); diff --git a/sweetpad-vscode/src/testing/manager.ts b/sweetpad-vscode/src/testing/manager.ts index 6af6c939..140242bd 100644 --- a/sweetpad-vscode/src/testing/manager.ts +++ b/sweetpad-vscode/src/testing/manager.ts @@ -11,6 +11,7 @@ import { getXcodeBuildDestinationString, } from "../build/utils.js"; import { getBuildSettingsToAskDestination, getXcodeBuildCommand } from "../common/cli/scripts.js"; +import { getWorkspaceConfig } from "../common/config.js"; import { errorReporting } from "../common/error-reporting.js"; import { exec } from "../common/exec.js"; import type { ExecutionScopeService } from "../common/execution-scope.js"; @@ -116,6 +117,66 @@ function extractCodeBlock(text: string, startIndex: number): string | null { return null; } +/** + * Class names whose subclasses hold XCTest test methods. `XCTestCase` is always + * recognized; `sweetpad.testing.baseClasses` adds project-specific ones, which + * is the only way to reach a base class that isn't Swift source in the open + * workspace — one from a test-support package, a pod, an Objective-C harness or + * a binary framework. Matching is textual, so each level of a longer chain + * (`FooTests: UITestCase: BaseTestCase: XCTestCase`) needs its own entry. + */ +function getTestBaseClasses(): Set { + return new Set(["XCTestCase", ...(getWorkspaceConfig("testing.baseClasses") ?? [])]); +} + +export type TestClassMatch = { + className: string; + /** Offset of the `class` keyword, used to place the class item's range. */ + declarationIndex: number; + /** Offset of the `{` that opens the class body. */ + bodyIndex: number; +}; + +/** + * Find every class in `text` that inherits from one of `baseClasses`. + * + * TODO: use a proper Swift parser to find test classes + */ +export function findTestClasses(text: string, baseClasses: Set): TestClassMatch[] { + // A class declaration followed by the first type in its inheritance clause. + // Swift requires the superclass to come first, so that entry is the only one + // that can name a test base class — anything after it is a protocol. + const declarationRegexp = /\bclass\s+(\w+)\s*(?:<[^<>{]*>)?\s*:\s*([\w.]+)/g; + const matches: TestClassMatch[] = []; + + while (true) { + const match = declarationRegexp.exec(text); + if (match === null) { + break; + } + + // `XCTest.XCTestCase` names the same class as `XCTestCase`. + const superclass = match[2].split(".").at(-1) ?? ""; + if (!baseClasses.has(superclass)) { + continue; + } + + // Protocols and a `where` clause can sit between the superclass and the body. + const bodyIndex = text.indexOf("{", match.index + match[0].length); + if (bodyIndex === -1) { + continue; + } + + matches.push({ + className: match[1], + declarationIndex: match.index, + bodyIndex: bodyIndex, + }); + } + + return matches; +} + /** * Get all ancestor paths of a childPath that are within the parentPath (including the parentPath). */ @@ -294,18 +355,10 @@ export class TestingManager { } const text = document.getText(); + const baseClasses = getTestBaseClasses(); - // Regex to find classes inheriting from XCTestCase - const classRegex = /class\s+(\w+)\s*:\s*XCTestCase\s*\{/g; - // 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); + for (const { className, declarationIndex, bodyIndex } of findTestClasses(text, baseClasses)) { + const classPosition = document.positionAt(declarationIndex); const classTestItem = this.createTestItem({ id: className, @@ -316,7 +369,7 @@ export class TestingManager { classTestItem.range = new vscode.Range(classPosition, classPosition); this.controller.items.add(classTestItem); - const classCode = extractCodeBlock(text, classStartIndex - 1); // Start from '{' + const classCode = extractCodeBlock(text, bodyIndex); if (classCode === null) { continue; // Could not find class code block @@ -331,8 +384,7 @@ export class TestingManager { break; } const testName = funcMatch[1]; - const testStartIndex = classStartIndex + funcMatch.index; - const position = document.positionAt(testStartIndex); + const position = document.positionAt(bodyIndex + funcMatch.index); const testItem = this.createTestItem({ id: `${className}.${testName}`,