From 221f729e49f00fff56b4d5b45f38bc0f9726f1e9 Mon Sep 17 00:00:00 2001 From: Joe Huntenburg Date: Sat, 14 Feb 2026 19:23:18 -0500 Subject: [PATCH 1/2] feat: add "Run tests by group" command with interactive group selection Add phpunit.run-by-group command that displays a quick pick menu of all available PHPUnit groups (from @group annotations or #[Group] attributes) and runs the selected group's tests. - Add TestCollection.findGroups() to collect unique group tags - Add TestCollection.findTestsByGroup() to filter tests by group tag - Add TestRunHandler.startGroupTestRun() to execute group-filtered runs with --group flag and force single process mode - Add show --- __mocks__/vscode.ts | 2 ++ package.json | 4 +++ src/Commands/TestCommandRegistry.ts | 29 +++++++++++++++++++++ src/TestCollection/TestCollection.test.ts | 24 ++++++++++++++++++ src/TestCollection/TestCollection.ts | 31 +++++++++++++++++++++++ src/TestExecution/TestRunHandler.ts | 31 ++++++++++++++++++++--- src/extension.test.ts | 24 +++++++++++++++++- src/extension.ts | 1 + 8 files changed, 141 insertions(+), 5 deletions(-) diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts index 869fa694..3c010a6f 100644 --- a/__mocks__/vscode.ts +++ b/__mocks__/vscode.ts @@ -304,6 +304,8 @@ const window = { }; }), showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + showQuickPick: vi.fn(), }; const commands = (function () { diff --git a/package.json b/package.json index 5916919c..64e14531 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,10 @@ "command": "phpunit.run-test-at-cursor", "title": "PHPUnit: Run the test at the current cursor position" }, + { + "command": "phpunit.run-by-group", + "title": "PHPUnit: Run tests by group" + }, { "command": "phpunit.rerun", "title": "PHPUnit: Repeat the last test run" diff --git a/src/Commands/TestCommandRegistry.ts b/src/Commands/TestCommandRegistry.ts index 9d1f645d..a59e150a 100644 --- a/src/Commands/TestCommandRegistry.ts +++ b/src/Commands/TestCommandRegistry.ts @@ -68,6 +68,35 @@ export class TestCommandRegistry { }); } + runByGroup() { + return commands.registerCommand('phpunit.run-by-group', async () => { + const groups = this.testCollection.findGroups(); + if (groups.length === 0) { + window.showInformationMessage( + 'No PHPUnit groups found. Add @group annotations or #[Group] attributes to your tests.', + ); + return; + } + + const selectedGroup = await window.showQuickPick(groups, { + placeHolder: 'Select a PHPUnit group to run', + title: 'Run Tests by Group', + }); + if (!selectedGroup) { + return; + } + + const tests = this.testCollection.findTestsByGroup(selectedGroup); + if (tests.length === 0) { + window.showInformationMessage(`No tests found for group "${selectedGroup}".`); + return; + } + + const cancellation = new CancellationTokenSource().token; + await this.handler.startGroupTestRun(selectedGroup, tests, cancellation); + }); + } + rerun() { return commands.registerCommand('phpunit.rerun', () => { return this.run( diff --git a/src/TestCollection/TestCollection.test.ts b/src/TestCollection/TestCollection.test.ts index 8a526338..4dfb9f91 100644 --- a/src/TestCollection/TestCollection.test.ts +++ b/src/TestCollection/TestCollection.test.ts @@ -100,6 +100,30 @@ describe('Extension TestCollection', () => { ]); }); + it('find groups and tests by group', async () => { + const collection = givenTestCollection(` + + + tests + + `); + + await collection.add(URI.file(phpUnitProject('tests/AssertionsTest.php'))); + await collection.add(URI.file(phpUnitProject('tests/AttributeTest.php'))); + + expect(collection.findGroups()).toEqual(['integration']); + expect(collection.findTestsByGroup('integration')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'Assertions (Tests\\Assertions)::Passed', + }), + expect.objectContaining({ + id: 'Attribute (Tests\\Attribute)::Hi', + }), + ]), + ); + }); + it('with testsuites', async () => { const collection = givenTestCollection(` diff --git a/src/TestCollection/TestCollection.ts b/src/TestCollection/TestCollection.ts index 0a2b91ee..50711fb7 100644 --- a/src/TestCollection/TestCollection.ts +++ b/src/TestCollection/TestCollection.ts @@ -64,6 +64,37 @@ export class TestCollection extends BaseTestCollection { return tests.length > 0 ? tests : undefined; } + findGroups(): string[] { + const groups = new Set(); + for (const [, testData] of this.getTestData()) { + testData.forEach((_, testItem: TestItem) => { + (testItem.tags ?? []) + .filter((tag) => tag.id.startsWith('group:')) + .forEach((tag) => groups.add(tag.id.replace(/^group:/, ''))); + }); + } + + return [...groups].sort(); + } + + findTestsByGroup(group: string): TestItem[] { + const groupTagId = `group:${group}`; + const tests: TestItem[] = []; + for (const [, testData] of this.getTestData()) { + for (const [testItem, testCase] of testData) { + if (testCase.type !== TestType.method) { + continue; + } + + if ((testItem.tags ?? []).some((tag) => tag.id === groupTagId)) { + tests.push(testItem); + } + } + } + + return tests; + } + reset() { for (const [, testData] of this.getTestData()) { for (const [testItem] of testData) { diff --git a/src/TestExecution/TestRunHandler.ts b/src/TestExecution/TestRunHandler.ts index 7a34315c..5a5dd2d3 100644 --- a/src/TestExecution/TestRunHandler.ts +++ b/src/TestExecution/TestRunHandler.ts @@ -3,8 +3,9 @@ import { type CancellationToken, debug, type TestController, + type TestItem, type TestRun, - type TestRunRequest, + TestRunRequest, workspace, } from 'vscode'; import { Configuration } from '../Configuration'; @@ -52,6 +53,22 @@ export class TestRunHandler { this.previousRequest = request; } + async startGroupTestRun( + group: string, + include: readonly TestItem[], + cancellation?: CancellationToken, + ) { + const request = new TestRunRequest(include); + const builder = await this.createProcessBuilder(request); + const xdebug = builder.getXdebug()!; + builder.setArguments(`--group=${group}`); + + await this.manageDebugSession(xdebug, async () => { + const testRun = this.ctrl.createTestRun(request); + await this.runTestQueue(builder, testRun, request, cancellation, true); + }); + } + private async createProcessBuilder(request: TestRunRequest): Promise { const builder = new ProcessBuilder(this.configuration, { cwd: this.phpUnitXML.root() }); const xdebug = new Xdebug(this.configuration); @@ -80,6 +97,7 @@ export class TestRunHandler { testRun: TestRun, request: TestRunRequest, cancellation?: CancellationToken, + forceSingleProcess = false, ) { const queue = await this.testQueueBuilder.build( request.include ?? this.testQueueBuilder.collectItems(this.ctrl.items), @@ -90,7 +108,7 @@ export class TestRunHandler { const runner = this.testRunnerBuilder.build(queue, testRun, request); runner.emit(TestRunnerEvent.start, undefined); - const processes = this.createProcesses(runner, builder, request); + const processes = this.createProcesses(runner, builder, request, forceSingleProcess); cancellation?.onCancellationRequested(() => processes.forEach((process) => process.abort()), ); @@ -101,8 +119,13 @@ export class TestRunHandler { runner.emit(TestRunnerEvent.done, undefined); } - private createProcesses(runner: TestRunner, builder: ProcessBuilder, request: TestRunRequest) { - if (!request.include) { + private createProcesses( + runner: TestRunner, + builder: ProcessBuilder, + request: TestRunRequest, + forceSingleProcess = false, + ) { + if (!request.include || forceSingleProcess) { return [runner.run(builder)]; } diff --git a/src/extension.test.ts b/src/extension.test.ts index abfe664b..b23fc7f6 100644 --- a/src/extension.test.ts +++ b/src/extension.test.ts @@ -246,11 +246,12 @@ describe('Extension Test', () => { 'phpunit.run-all', 'phpunit.run-file', 'phpunit.run-test-at-cursor', + 'phpunit.run-by-group', 'phpunit.rerun', ]) { expect(commands.registerCommand).toHaveBeenCalledWith(cmd, expect.any(Function)); } - expect(context.subscriptions.push).toHaveBeenCalledTimes(7); + expect(context.subscriptions.push).toHaveBeenCalledTimes(8); }); it('should only update configuration when phpunit settings change', async () => { @@ -368,6 +369,27 @@ describe('Extension Test', () => { await configuration.update('args', []); }); + it('should run tests by selected group', async () => { + await activate(context); + (window.showQuickPick as Mock).mockResolvedValue('integration'); + + await commands.executeCommand('phpunit.run-by-group'); + + expect(window.showQuickPick).toHaveBeenCalledWith( + expect.arrayContaining(['integration']), + expect.objectContaining({ + placeHolder: 'Select a PHPUnit group to run', + title: 'Run Tests by Group', + }), + ); + expectSpawnCalled([ + binary, + '--group=integration', + '--colors=never', + '--teamcity', + ]); + }); + it('should run class with group', async () => { await activateAndRun({ include: 'Attribute (Tests\\Attribute)' }); diff --git a/src/extension.ts b/src/extension.ts index d49aa5d0..7ac7ad9a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -108,6 +108,7 @@ function registerCommands( context.subscriptions.push(testCommandRegistry.runAll()); context.subscriptions.push(testCommandRegistry.runFile()); context.subscriptions.push(testCommandRegistry.runTestAtCursor()); + context.subscriptions.push(testCommandRegistry.runByGroup()); context.subscriptions.push(testCommandRegistry.rerun()); } From 57231e179e418e2dedc6766b1c634d96e5ce1531 Mon Sep 17 00:00:00 2001 From: Joe Huntenburg Date: Sat, 14 Feb 2026 20:19:58 -0500 Subject: [PATCH 2/2] feat: auto-discover tests in run-by-group command when collection is empty Reload test files before showing group picker if findGroups() returns empty, ensuring groups are available even when tests haven't been preloaded. Pass TestRunProfile to startGroupTestRun() so the request uses the correct profile context. - Add reloadAll() call in runByGroup() when groups.length === 0 - Add profile parameter to TestRunHandler.startGroupTestRun() - Pass profile to TestRunRequest constructor in startGroupTestRun() --- src/Commands/TestCommandRegistry.ts | 13 +++++++++++-- src/TestExecution/TestRunHandler.ts | 4 +++- src/extension.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/Commands/TestCommandRegistry.ts b/src/Commands/TestCommandRegistry.ts index a59e150a..f5dd588e 100644 --- a/src/Commands/TestCommandRegistry.ts +++ b/src/Commands/TestCommandRegistry.ts @@ -70,7 +70,11 @@ export class TestCommandRegistry { runByGroup() { return commands.registerCommand('phpunit.run-by-group', async () => { - const groups = this.testCollection.findGroups(); + let groups = this.testCollection.findGroups(); + if (groups.length === 0) { + await this.testFileDiscovery.reloadAll(); + groups = this.testCollection.findGroups(); + } if (groups.length === 0) { window.showInformationMessage( 'No PHPUnit groups found. Add @group annotations or #[Group] attributes to your tests.', @@ -93,7 +97,12 @@ export class TestCommandRegistry { } const cancellation = new CancellationTokenSource().token; - await this.handler.startGroupTestRun(selectedGroup, tests, cancellation); + await this.handler.startGroupTestRun( + selectedGroup, + tests, + cancellation, + this.testRunProfile, + ); }); } diff --git a/src/TestExecution/TestRunHandler.ts b/src/TestExecution/TestRunHandler.ts index 5a5dd2d3..b418908b 100644 --- a/src/TestExecution/TestRunHandler.ts +++ b/src/TestExecution/TestRunHandler.ts @@ -4,6 +4,7 @@ import { debug, type TestController, type TestItem, + type TestRunProfile, type TestRun, TestRunRequest, workspace, @@ -57,8 +58,9 @@ export class TestRunHandler { group: string, include: readonly TestItem[], cancellation?: CancellationToken, + profile?: TestRunProfile, ) { - const request = new TestRunRequest(include); + const request = new TestRunRequest(include, undefined, profile); const builder = await this.createProcessBuilder(request); const xdebug = builder.getXdebug()!; builder.setArguments(`--group=${group}`); diff --git a/src/extension.test.ts b/src/extension.test.ts index b23fc7f6..bc8fe2fa 100644 --- a/src/extension.test.ts +++ b/src/extension.test.ts @@ -11,6 +11,7 @@ import { type TestController, type TestItem, type TestItemCollection, + TestRunRequest, TestRunProfileKind, type TextDocument, tests, @@ -374,6 +375,8 @@ describe('Extension Test', () => { (window.showQuickPick as Mock).mockResolvedValue('integration'); await commands.executeCommand('phpunit.run-by-group'); + const testRunRequestCalls = (TestRunRequest as unknown as Mock).mock.calls; + const [, , profile] = testRunRequestCalls[testRunRequestCalls.length - 1]; expect(window.showQuickPick).toHaveBeenCalledWith( expect.arrayContaining(['integration']), @@ -388,6 +391,30 @@ describe('Extension Test', () => { '--colors=never', '--teamcity', ]); + expect(profile).toBe(getTestRunProfile(getTestController())); + }); + + it('should discover tests before running selected group when tests are not preloaded', async () => { + setTextDocuments([]); + await activate(context); + (window.showQuickPick as Mock).mockResolvedValue('integration'); + + await commands.executeCommand('phpunit.run-by-group'); + + expect(workspace.findFiles).toHaveBeenCalled(); + expect(window.showQuickPick).toHaveBeenCalledWith( + expect.arrayContaining(['integration']), + expect.objectContaining({ + placeHolder: 'Select a PHPUnit group to run', + title: 'Run Tests by Group', + }), + ); + expectSpawnCalled([ + binary, + '--group=integration', + '--colors=never', + '--teamcity', + ]); }); it('should run class with group', async () => {