Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sweetpad-docs/docs/vscode/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions sweetpad-docs/docs/vscode/tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions sweetpad-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions sweetpad-vscode/src/common/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
72 changes: 72 additions & 0 deletions sweetpad-vscode/src/testing/manager.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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<Subject>: 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("{"),
},
]);
});
});
80 changes: 66 additions & 14 deletions sweetpad-vscode/src/testing/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string> {
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<string>): 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).
*/
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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}`,
Expand Down