From ce7bdd62f81f910c1b2c257464d86caf689f80b9 Mon Sep 17 00:00:00 2001 From: Joe Huntenburg Date: Fri, 2 Jan 2026 15:39:01 -0500 Subject: [PATCH 1/2] feat/group-filter adds the ability to filter test by group and run group tests from command. --- package.json | 4 + src/CommandHandler.ts | 24 +++++- src/Handler.ts | 25 +++++++ src/PHPUnit/TestParser/AnnotationParser.ts | 2 +- src/PHPUnit/TestParser/PHPUnitParser.test.ts | 78 ++++++++++++++++++++ src/PHPUnit/types.ts | 1 + src/TestCollection/TestCase.ts | 4 + src/TestCollection/TestHierarchyBuilder.ts | 45 ++++++++++- src/extension.ts | 1 + 9 files changed, 181 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 103b2dab..5c5241bb 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,10 @@ { "command": "phpunit.rerun", "title": "PHPUnit: Repeat the last test run" + }, + { + "command": "phpunit.run-by-group", + "title": "PHPUnit: Run tests by group" } ], "keybindings": [ diff --git a/src/CommandHandler.ts b/src/CommandHandler.ts index 8ecc7132..4c779be3 100644 --- a/src/CommandHandler.ts +++ b/src/CommandHandler.ts @@ -1,6 +1,6 @@ import { CancellationTokenSource, commands, TestItem, TestRunProfile, TestRunRequest, window } from 'vscode'; import { Handler } from './Handler'; -import { TestCollection } from './TestCollection'; +import { GroupRegistry, TestCollection } from './TestCollection'; export class CommandHandler { constructor(private testCollection: TestCollection, private testRunProfile: TestRunProfile) {} @@ -53,6 +53,28 @@ export class CommandHandler { }); } + runByGroup(handler: Handler) { + return commands.registerCommand('phpunit.run-by-group', async () => { + const groups = GroupRegistry.getInstance().getAll(); + 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 || !handler) { + return; + } + + const cancellation = new CancellationTokenSource().token; + await handler.startGroupTestRun(selectedGroup, cancellation); + }); + } + private async run(include: readonly TestItem[] | undefined) { const cancellation = new CancellationTokenSource().token; diff --git a/src/Handler.ts b/src/Handler.ts index 6541765c..b9a4e2d3 100644 --- a/src/Handler.ts +++ b/src/Handler.ts @@ -51,6 +51,31 @@ export class Handler { this.previousRequest = request; } + async startGroupTestRun(group: string, cancellation?: CancellationToken) { + const builder = new Builder(this.configuration, { cwd: this.phpUnitXML.root() }); + builder.setArguments(`--group ${group}`); + + const request = new TestRunRequest(); + const testRun = this.ctrl.createTestRun(request); + + const runner = new TestRunner(); + const queue = await this.discoverTests(this.gatherTestItems(this.ctrl.items), request); + queue.forEach((testItem) => testRun.enqueued(testItem)); + + runner.observe(new TestResultObserver(queue, testRun)); + runner.observe(new OutputChannelObserver(this.outputChannel, this.configuration, this.printer, request)); + runner.observe(new MessageObserver(this.configuration)); + + runner.emit(TestRunnerEvent.start, undefined); + + const process = runner.run(builder); + cancellation?.onCancellationRequested(() => process.abort()); + + await process.run(); + runner.emit(TestRunnerEvent.done, undefined); + testRun.end(); + } + private async runTestQueue(builder: Builder, testRun: TestRun, request: TestRunRequest, cancellation?: CancellationToken) { const queue = await this.discoverTests(request.include ?? this.gatherTestItems(this.ctrl.items), request); queue.forEach((testItem) => testRun.enqueued(testItem)); diff --git a/src/PHPUnit/TestParser/AnnotationParser.ts b/src/PHPUnit/TestParser/AnnotationParser.ts index 7b406615..ff3103f9 100644 --- a/src/PHPUnit/TestParser/AnnotationParser.ts +++ b/src/PHPUnit/TestParser/AnnotationParser.ts @@ -1,7 +1,7 @@ import { Declaration, Method } from 'php-parser'; import { Annotations } from '../types'; -const lookup = ['depends', 'dataProvider', 'testdox']; +const lookup = ['depends', 'dataProvider', 'testdox', 'group']; export class AttributeParser { public parse(declaration: Declaration) { diff --git a/src/PHPUnit/TestParser/PHPUnitParser.test.ts b/src/PHPUnit/TestParser/PHPUnitParser.test.ts index 22203ca2..46ff0655 100644 --- a/src/PHPUnit/TestParser/PHPUnitParser.test.ts +++ b/src/PHPUnit/TestParser/PHPUnitParser.test.ts @@ -489,4 +489,82 @@ final class TestDoxTest extends TestCase { depth: 2, })); }); + + it('parse @group annotation', () => { + const file = phpUnitProject('tests/GroupTest.php'); + const content = `assertTrue(true); + } +} +`; + expect(givenTest(file, content, 'test_with_groups')).toEqual(expect.objectContaining({ + type: TestType.method, + file, + id: 'Group::With groups', + classFQN: 'GroupTest', + className: 'GroupTest', + methodName: 'test_with_groups', + annotations: { group: ['integration', 'slow'] }, + depth: 2, + })); + }); + + it('parse #[Group] attribute', () => { + const file = phpUnitProject('tests/GroupAttributeTest.php'); + const content = `assertTrue(true); + } +} +`; + expect(givenTest(file, content, 'test_with_group_attributes')).toEqual(expect.objectContaining({ + type: TestType.method, + file, + id: 'Group Attribute::With group attributes', + classFQN: 'GroupAttributeTest', + className: 'GroupAttributeTest', + methodName: 'test_with_group_attributes', + annotations: { group: ['plaid', 'api'] }, + depth: 2, + })); + }); + + it('parse single @group annotation', () => { + const file = phpUnitProject('tests/SingleGroupTest.php'); + const content = `assertTrue(true); + } +} +`; + expect(givenTest(file, content, 'test_unit')).toEqual(expect.objectContaining({ + type: TestType.method, + file, + methodName: 'test_unit', + annotations: { group: ['unit'] }, + })); + }); }); diff --git a/src/PHPUnit/types.ts b/src/PHPUnit/types.ts index 9158c4c6..93868206 100644 --- a/src/PHPUnit/types.ts +++ b/src/PHPUnit/types.ts @@ -15,6 +15,7 @@ export type Annotations = { depends?: string[]; dataProvider?: string[]; testdox?: string[]; + group?: string[]; }; export type TestDefinition = { type: TestType; diff --git a/src/TestCollection/TestCase.ts b/src/TestCollection/TestCase.ts index 3fa6314d..98ca14c2 100644 --- a/src/TestCollection/TestCase.ts +++ b/src/TestCollection/TestCase.ts @@ -9,6 +9,10 @@ export class TestCase { return this.testDefinition.type; } + get groups(): string[] { + return (this.testDefinition.annotations?.group as string[]) ?? []; + } + update(builder: Builder, index: number) { return builder.clone() .setXdebug(builder.getXdebug()?.clone().setIndex(index)) diff --git a/src/TestCollection/TestHierarchyBuilder.ts b/src/TestCollection/TestHierarchyBuilder.ts index 37f9acf8..bfa40a02 100644 --- a/src/TestCollection/TestHierarchyBuilder.ts +++ b/src/TestCollection/TestHierarchyBuilder.ts @@ -1,7 +1,35 @@ -import { Position, Range, TestController, TestItem, Uri } from 'vscode'; +import { Position, Range, TestController, TestItem, TestTag, Uri } from 'vscode'; import { CustomWeakMap, TestDefinition, TestParser, TestType, TransformerFactory } from '../PHPUnit'; import { TestCase } from './TestCase'; +export class GroupRegistry { + private static instance: GroupRegistry; + private groups = new Set(); + + static getInstance(): GroupRegistry { + if (!GroupRegistry.instance) { + GroupRegistry.instance = new GroupRegistry(); + } + return GroupRegistry.instance; + } + + add(group: string) { + this.groups.add(group); + } + + addAll(groups: string[]) { + groups.forEach(g => this.groups.add(g)); + } + + getAll(): string[] { + return Array.from(this.groups).sort(); + } + + clear() { + this.groups.clear(); + } +} + export class TestHierarchyBuilder { private icons = { [TestType.namespace]: '$(symbol-namespace)', @@ -83,6 +111,15 @@ export class TestHierarchyBuilder { const parent = this.ancestors[this.ancestors.length - 1]; parent.children.push(testItem); + // Inherit group tags from parent class to methods for proper filter inheritance + if (testDefinition.type === TestType.method && parent.type === TestType.class) { + const parentTags = parent.item.tags.filter(t => t.id.startsWith('group:')); + if (parentTags.length > 0) { + const ownTags = testItem.tags; + testItem.tags = [...ownTags, ...parentTags.filter(pt => !ownTags.some(ot => ot.id === pt.id))]; + } + } + if (testDefinition.type !== TestType.method) { this.ancestors.push({ item: testItem, type: testDefinition.type, children: [] }); } @@ -96,6 +133,12 @@ export class TestHierarchyBuilder { testItem.sortText = sortText; testItem.range = this.createRange(testDefinition); + const groups = (testDefinition.annotations?.group as string[]) ?? []; + if (groups.length > 0) { + GroupRegistry.getInstance().addAll(groups); + testItem.tags = groups.map(g => new TestTag(`group:${g}`)); + } + return testItem; } diff --git a/src/extension.ts b/src/extension.ts index 8e011f13..21614c2b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -113,6 +113,7 @@ export async function activate(context: ExtensionContext) { context.subscriptions.push(commandHandler.runFile()); context.subscriptions.push(commandHandler.runTestAtCursor()); context.subscriptions.push(commandHandler.rerun(handler)); + context.subscriptions.push(commandHandler.runByGroup(handler)); } async function getWorkspaceTestPatterns() { From a21e3c5a74e5ea33acffbac60637d1c77e4c94bb Mon Sep 17 00:00:00 2001 From: Joe Huntenburg Date: Sat, 3 Jan 2026 13:52:49 -0500 Subject: [PATCH 2/2] feat/group-filter fix's for test --- src/TestCollection/TestHierarchyBuilder.ts | 9 ++++++--- src/extension.test.ts | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/TestCollection/TestHierarchyBuilder.ts b/src/TestCollection/TestHierarchyBuilder.ts index bfa40a02..bafff6a8 100644 --- a/src/TestCollection/TestHierarchyBuilder.ts +++ b/src/TestCollection/TestHierarchyBuilder.ts @@ -113,10 +113,13 @@ export class TestHierarchyBuilder { // Inherit group tags from parent class to methods for proper filter inheritance if (testDefinition.type === TestType.method && parent.type === TestType.class) { - const parentTags = parent.item.tags.filter(t => t.id.startsWith('group:')); + const parentTags = (parent.item.tags ?? []).filter(t => t.id.startsWith('group:')); if (parentTags.length > 0) { - const ownTags = testItem.tags; - testItem.tags = [...ownTags, ...parentTags.filter(pt => !ownTags.some(ot => ot.id === pt.id))]; + const ownTags = testItem.tags ?? []; + testItem.tags = [ + ...ownTags, + ...parentTags.filter(pt => !ownTags.some(ot => ot.id === pt.id)), + ]; } } diff --git a/src/extension.test.ts b/src/extension.test.ts index b04857b8..a27b9fb1 100644 --- a/src/extension.test.ts +++ b/src/extension.test.ts @@ -11,7 +11,8 @@ import { Configuration } from './Configuration'; import { activate } from './extension'; import { getPhpUnitVersion, getPhpVersion, normalPath, pestProject, phpUnitProject } from './PHPUnit/__tests__/utils'; -jest.mock('child_process'); +//updated to match spawn for tests +jest.mock('node:child_process'); const setTextDocuments = (textDocuments: TextDocument[]) => { Object.defineProperty(workspace, 'textDocuments', {