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..f5dd588e 100644 --- a/src/Commands/TestCommandRegistry.ts +++ b/src/Commands/TestCommandRegistry.ts @@ -68,6 +68,44 @@ export class TestCommandRegistry { }); } + runByGroup() { + return commands.registerCommand('phpunit.run-by-group', async () => { + 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.', + ); + 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, + this.testRunProfile, + ); + }); + } + 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..b418908b 100644 --- a/src/TestExecution/TestRunHandler.ts +++ b/src/TestExecution/TestRunHandler.ts @@ -3,8 +3,10 @@ import { type CancellationToken, debug, type TestController, + type TestItem, + type TestRunProfile, type TestRun, - type TestRunRequest, + TestRunRequest, workspace, } from 'vscode'; import { Configuration } from '../Configuration'; @@ -52,6 +54,23 @@ export class TestRunHandler { this.previousRequest = request; } + async startGroupTestRun( + group: string, + include: readonly TestItem[], + cancellation?: CancellationToken, + profile?: TestRunProfile, + ) { + const request = new TestRunRequest(include, undefined, profile); + 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 +99,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 +110,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 +121,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..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, @@ -246,11 +247,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 +370,53 @@ 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'); + const testRunRequestCalls = (TestRunRequest as unknown as Mock).mock.calls; + const [, , profile] = testRunRequestCalls[testRunRequestCalls.length - 1]; + + 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', + ]); + 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 () => { 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()); }