From 3d7ab62456d0f15ebeaf95561fa8491c4af47c1c Mon Sep 17 00:00:00 2001 From: recca0120 Date: Fri, 13 Mar 2026 12:06:20 +0800 Subject: [PATCH 1/9] fix: handle Pest v2 describe+dataset testSuiteStarted and Pest v3 truncated suite names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PestV2Fixer: extend decodeEvaluable to handle → separator in describe block evaluable names (e.g. __pest_evaluable__something__→_it_should_detect_OK_but_does_not) - TestResultObserver: add prefix fallback for Pest v3 testSuiteStarted/Finished whose names are truncated due to mb_strrpos+substr byte/char mismatch; restricted to suite events only to avoid false positives --- .../src/Observers/TestResultObserver.test.ts | 63 +++++++++++++++++++ .../src/Observers/TestResultObserver.ts | 37 +++++++++-- .../TestIdentifier/PestTestIdentifier.test.ts | 7 +++ .../phpunit/src/TestIdentifier/PestV2Fixer.ts | 55 +++++++++++++--- .../pest-stub/tests/Unit/SampleTests.php | 15 +++++ 5 files changed, 163 insertions(+), 14 deletions(-) create mode 100644 packages/phpunit/tests/fixtures/pest-stub/tests/Unit/SampleTests.php diff --git a/packages/extension/src/Observers/TestResultObserver.test.ts b/packages/extension/src/Observers/TestResultObserver.test.ts index adc1f70c..f522a13e 100644 --- a/packages/extension/src/Observers/TestResultObserver.test.ts +++ b/packages/extension/src/Observers/TestResultObserver.test.ts @@ -3,6 +3,8 @@ import type { TestDefinition, TestFailed, TestFinished, + TestSuiteFinished, + TestSuiteStarted, } from '@vscode-phpunit/phpunit'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -189,6 +191,67 @@ describe('TestResultObserver', () => { expect(message.stackTrace).toBeUndefined(); }); + // Pest v3 bug: Str::beforeLast uses mb_strrpos (char offset) with substr (byte offset). + // The → character (U+2192) is 3 UTF-8 bytes but 1 char, so testSuiteStarted/Finished names + // are truncated by 2 bytes per → character. + it('should find parent item via prefix match when Pest v3 truncates testSuiteStarted name', () => { + const parentItem = ctrl.createTestItem( + 'tests/Unit/SampleTests.php::`something` \u2192 it should detect OK but does not', + 'it should detect OK but does not', + Uri.file('/project/tests/SampleTests.php'), + ); + const testItemById = buildTestItemById([testItem, parentItem]); + const obs = new TestResultObserver(queue, testRun, testItemById); + + obs.testSuiteStarted({ + event: 'testSuiteStarted' as unknown as TeamcityEvent, + id: 'tests/Unit/SampleTests.php::`something` \u2192 it should detect OK but does n', + flowId: 1, + name: '`something` \u2192 it should detect OK but does n', + } as unknown as TestSuiteStarted); + + expect(testRun.started).toHaveBeenCalledWith(parentItem); + }); + + it('should mark parent passed via prefix match when Pest v3 truncates testSuiteFinished name', () => { + const parentItem = ctrl.createTestItem( + 'tests/Unit/SampleTests.php::`something` \u2192 it should detect OK but does not', + 'it should detect OK but does not', + Uri.file('/project/tests/SampleTests.php'), + ); + const testItemById = buildTestItemById([testItem, parentItem]); + const obs = new TestResultObserver(queue, testRun, testItemById); + + obs.testSuiteFinished({ + event: 'testSuiteFinished' as unknown as TeamcityEvent, + id: 'tests/Unit/SampleTests.php::`something` \u2192 it should detect OK but does n', + flowId: 1, + name: '`something` \u2192 it should detect OK but does n', + } as unknown as TestSuiteFinished); + + expect(testRun.passed).toHaveBeenCalledWith(parentItem); + }); + + it('should not use prefix match for regular testStarted events with → in id', () => { + const parentItem = ctrl.createTestItem( + 'tests/Unit/ArchTest.php::preset \u2192 php ', + 'preset \u2192 php ', + Uri.file('/project/tests/ArchTest.php'), + ); + const testItemById = buildTestItemById([testItem, parentItem]); + const obs = new TestResultObserver(queue, testRun, testItemById); + + // runtime id has no trailing space — should NOT match via prefix fallback for testStarted + obs.testStarted({ + event: 'testStarted' as unknown as TeamcityEvent, + id: 'tests/Unit/ArchTest.php::preset \u2192 php', + flowId: 1, + name: 'preset \u2192 php', + } as never); + + expect(testRun.started).not.toHaveBeenCalledWith(parentItem); + }); + it('should not use TestMessage.diff when expected/actual are missing', () => { const diffSpy = vi.spyOn(TestMessage, 'diff'); diff --git a/packages/extension/src/Observers/TestResultObserver.ts b/packages/extension/src/Observers/TestResultObserver.ts index f3bbc9bf..45891689 100644 --- a/packages/extension/src/Observers/TestResultObserver.ts +++ b/packages/extension/src/Observers/TestResultObserver.ts @@ -38,7 +38,7 @@ export class TestResultObserver implements TestRunnerObserver { } testSuiteStarted(result: TestSuiteStarted): void { - this.doRun(result, (test) => this.testRun.started(test)); + this.doRun(result, (test) => this.testRun.started(test), true); } testStarted(result: TestStarted): void { @@ -60,7 +60,7 @@ export class TestResultObserver implements TestRunnerObserver { } testSuiteFinished(result: TestSuiteFinished): void { - this.doRun(result, (test) => this.testRun.passed(test)); + this.doRun(result, (test) => this.testRun.passed(test), true); } private message(result: TestFailed | TestIgnored, test: TestItem) { @@ -100,8 +100,12 @@ export class TestResultObserver implements TestRunnerObserver { return message; } - private doRun(result: TestResult, updateTestRun: (testItem: TestItem) => void) { - const testItem = this.find(result); + private doRun( + result: TestResult, + updateTestRun: (testItem: TestItem) => void, + usePrefixFallback = false, + ) { + const testItem = this.find(result, usePrefixFallback); if (!testItem) { return; } @@ -109,11 +113,32 @@ export class TestResultObserver implements TestRunnerObserver { updateTestRun(testItem); } - private find(result: TestResult) { + private find(result: TestResult, usePrefixFallback = false) { if (!('id' in result) || typeof result.id !== 'string') { return undefined; } - return this.testItemById.get(result.id); + const exact = this.testItemById.get(result.id); + if (exact) { + return exact; + } + + // Pest v3 has a bug: Str::beforeLast uses mb_strrpos (char offset) with substr (byte offset). + // The → character (U+2192) is 3 UTF-8 bytes but 1 char, so testSuiteStarted/Finished names + // are truncated by 2 bytes per → character. The truncated ID is a prefix of the full parent ID. + // Only apply to suite events to avoid false positives on regular test events. + if ( + usePrefixFallback && + result.id.includes('\u2192') && + !result.id.includes(' with data set ') + ) { + for (const [key, item] of this.testItemById) { + if (key.startsWith(result.id) && !key.includes(' with data set ')) { + return item; + } + } + } + + return undefined; } } diff --git a/packages/phpunit/src/TestIdentifier/PestTestIdentifier.test.ts b/packages/phpunit/src/TestIdentifier/PestTestIdentifier.test.ts index 2b255d71..d7ba0b95 100644 --- a/packages/phpunit/src/TestIdentifier/PestTestIdentifier.test.ts +++ b/packages/phpunit/src/TestIdentifier/PestTestIdentifier.test.ts @@ -77,6 +77,13 @@ describe('PestTestIdentifier', () => { 'Users\\path\\to\\tests\\Unit\\DatasetTest::__pest_evaluable_it_business_closed', 'tests/Unit/DatasetTest.php::it business closed', ], + // describe('something', fn() => it('test', fn())->with([...])) + // v2 testSuiteStarted uses evaluable encoding with → separator + [ + 'file:///path/to/tests/Unit/SampleTests.php', + 'Users\\path\\to\\tests\\Unit\\SampleTests::__pest_evaluable__something__\u2192_it_should_detect_OK_but_does_not', + 'tests/Unit/SampleTests.php::`something` \u2192 it should detect OK but does not', + ], ])('fromLocationHint(%j, %j) → id: %s', (locationHint, name, expectedId) => { const result = transformer.fromLocationHint(locationHint, name); expect(result.id).toBe(expectedId); diff --git a/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts b/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts index 0eeb3f28..e500bcff 100644 --- a/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts +++ b/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts @@ -4,19 +4,58 @@ function hasPrefix(id?: string) { return id?.includes(PREFIX) ?? false; } -function decodeEvaluable(encoded: string) { - const idx = encoded.indexOf(PREFIX); - if (idx === -1) { +function decodeDescribePart(inner: string): string { + const segments: string[] = []; + let current = ''; + let i = 0; + while (i < inner.length) { + if (inner[i] === '_' && inner[i + 1] === '_') { + current += '_'; + i += 2; + } else if (inner[i] === '_') { + segments.push(current); + current = ''; + i++; + } else { + current += inner[i]; + i++; + } + } + segments.push(current); + const isFQN = segments.every((p) => /^[A-Z]/.test(p)); + return `\`${segments.join(isFQN ? '\\' : ' ')}\``; +} + +function decodeEvaluable(encoded: string): string { + const prefixIdx = encoded.indexOf(PREFIX); + if (prefixIdx === -1) { return encoded; } - const before = encoded.slice(0, idx); - let method = encoded.slice(idx + PREFIX.length); + const before = encoded.slice(0, prefixIdx); + const methodFull = encoded.slice(prefixIdx + PREFIX.length); + + const datasetIdx = methodFull.search(/\s+with\s+data\s+set\s+/); + const methodPart = datasetIdx >= 0 ? methodFull.slice(0, datasetIdx) : methodFull; + const datasetSuffix = datasetIdx >= 0 ? methodFull.slice(datasetIdx) : ''; + + const segments = methodPart.split('_\u2192_'); + if (segments.length < 2) { + const decoded = methodPart.replace(/__|_/g, (m) => (m === '__' ? '_' : ' ')); + return before + decoded + datasetSuffix; + } + + const describeParts = segments.slice(0, -1); + const testPart = segments[segments.length - 1]; + + const decodedDescribes = describeParts.map((part) => { + const inner = part.replace(/^_/, '').replace(/_$/, ''); + return decodeDescribePart(inner); + }); - // reverse: single _ → space, double __ → literal _ - method = method.replace(/__|_/g, (m) => (m === '__' ? '_' : ' ')); + const decodedTest = testPart.replace(/__|_/g, (m) => (m === '__' ? '_' : ' ')); - return before + method; + return before + [...decodedDescribes, decodedTest].join(' \u2192 ') + datasetSuffix; } export const PestV2Fixer = { diff --git a/packages/phpunit/tests/fixtures/pest-stub/tests/Unit/SampleTests.php b/packages/phpunit/tests/fixtures/pest-stub/tests/Unit/SampleTests.php new file mode 100644 index 00000000..1a338044 --- /dev/null +++ b/packages/phpunit/tests/fixtures/pest-stub/tests/Unit/SampleTests.php @@ -0,0 +1,15 @@ +toBeGreaterThan(3); + })->with([ + 4, + 8, + ]); + it('still does not detect OK', function () { + expect(6)->toBeGreaterThan(3); + })->with([ + 4, + ]); +}); From de230f9aeb4063bed56cd1f788f5a31d36fc5ecd Mon Sep 17 00:00:00 2001 From: recca0120 Date: Fri, 13 Mar 2026 12:51:14 +0800 Subject: [PATCH 2/9] refactor: move Pest v3 truncation fix from TestResultObserver to PestV3Fixer + ObserverFactory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PestV3Fixer.truncatedId() to compute v3-truncated ID aliases (2 bytes per → char) - ObserverFactory registers truncated aliases in testItemById at build time - TestResultObserver.find() simplified to exact lookup only — no version-specific logic --- .../src/Observers/ObserverFactory.ts | 10 ++++- .../src/Observers/TestResultObserver.test.ts | 17 +++++++-- .../src/Observers/TestResultObserver.ts | 37 +++---------------- .../phpunit/src/TestIdentifier/PestFixer.ts | 1 + .../phpunit/src/TestIdentifier/PestV3Fixer.ts | 13 +++++++ packages/phpunit/src/index.ts | 1 + 6 files changed, 43 insertions(+), 36 deletions(-) create mode 100644 packages/phpunit/src/TestIdentifier/PestV3Fixer.ts diff --git a/packages/extension/src/Observers/ObserverFactory.ts b/packages/extension/src/Observers/ObserverFactory.ts index 1250071d..3f76c0dd 100644 --- a/packages/extension/src/Observers/ObserverFactory.ts +++ b/packages/extension/src/Observers/ObserverFactory.ts @@ -1,5 +1,6 @@ import { type IConfiguration, + PestV3Fixer, PHPUnitXML, type PresetName, Printer, @@ -30,7 +31,14 @@ export class ObserverFactory { ) {} create(queue: Map, testRun: TestRun): TestRunnerObserver[] { - const testItemById = new Map([...queue.values()].map((item) => [item.id, item])); + const testItemById = new Map(); + for (const item of queue.values()) { + testItemById.set(item.id, item); + const truncated = PestV3Fixer.truncatedId(item.id); + if (truncated) { + testItemById.set(truncated, item); + } + } const format = resolveFormat( (this.configuration.get('output.preset') ?? 'collision') as PresetName, this.configuration.get('output.format') as Partial | undefined, diff --git a/packages/extension/src/Observers/TestResultObserver.test.ts b/packages/extension/src/Observers/TestResultObserver.test.ts index f522a13e..d25bb5b6 100644 --- a/packages/extension/src/Observers/TestResultObserver.test.ts +++ b/packages/extension/src/Observers/TestResultObserver.test.ts @@ -6,6 +6,7 @@ import type { TestSuiteFinished, TestSuiteStarted, } from '@vscode-phpunit/phpunit'; +import { PestV3Fixer } from '@vscode-phpunit/phpunit'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type TestController, @@ -194,13 +195,16 @@ describe('TestResultObserver', () => { // Pest v3 bug: Str::beforeLast uses mb_strrpos (char offset) with substr (byte offset). // The → character (U+2192) is 3 UTF-8 bytes but 1 char, so testSuiteStarted/Finished names // are truncated by 2 bytes per → character. - it('should find parent item via prefix match when Pest v3 truncates testSuiteStarted name', () => { + // ObserverFactory registers truncated aliases via PestV3Fixer.truncatedId() at build time. + it('should find parent item via truncated alias when Pest v3 truncates testSuiteStarted name', () => { const parentItem = ctrl.createTestItem( 'tests/Unit/SampleTests.php::`something` \u2192 it should detect OK but does not', 'it should detect OK but does not', Uri.file('/project/tests/SampleTests.php'), ); const testItemById = buildTestItemById([testItem, parentItem]); + const truncated = PestV3Fixer.truncatedId(parentItem.id); + if (truncated) testItemById.set(truncated, parentItem); const obs = new TestResultObserver(queue, testRun, testItemById); obs.testSuiteStarted({ @@ -213,13 +217,15 @@ describe('TestResultObserver', () => { expect(testRun.started).toHaveBeenCalledWith(parentItem); }); - it('should mark parent passed via prefix match when Pest v3 truncates testSuiteFinished name', () => { + it('should mark parent passed via truncated alias when Pest v3 truncates testSuiteFinished name', () => { const parentItem = ctrl.createTestItem( 'tests/Unit/SampleTests.php::`something` \u2192 it should detect OK but does not', 'it should detect OK but does not', Uri.file('/project/tests/SampleTests.php'), ); const testItemById = buildTestItemById([testItem, parentItem]); + const truncated = PestV3Fixer.truncatedId(parentItem.id); + if (truncated) testItemById.set(truncated, parentItem); const obs = new TestResultObserver(queue, testRun, testItemById); obs.testSuiteFinished({ @@ -232,16 +238,19 @@ describe('TestResultObserver', () => { expect(testRun.passed).toHaveBeenCalledWith(parentItem); }); - it('should not use prefix match for regular testStarted events with → in id', () => { + it('should not match arch test item when runtime id differs from truncated alias', () => { const parentItem = ctrl.createTestItem( 'tests/Unit/ArchTest.php::preset \u2192 php ', 'preset \u2192 php ', Uri.file('/project/tests/ArchTest.php'), ); const testItemById = buildTestItemById([testItem, parentItem]); + const truncated = PestV3Fixer.truncatedId(parentItem.id); + if (truncated) testItemById.set(truncated, parentItem); const obs = new TestResultObserver(queue, testRun, testItemById); - // runtime id has no trailing space — should NOT match via prefix fallback for testStarted + // truncated alias = 'tests/Unit/ArchTest.php::preset → p' + // runtime id = 'tests/Unit/ArchTest.php::preset → php' — different, should not match obs.testStarted({ event: 'testStarted' as unknown as TeamcityEvent, id: 'tests/Unit/ArchTest.php::preset \u2192 php', diff --git a/packages/extension/src/Observers/TestResultObserver.ts b/packages/extension/src/Observers/TestResultObserver.ts index 45891689..f3bbc9bf 100644 --- a/packages/extension/src/Observers/TestResultObserver.ts +++ b/packages/extension/src/Observers/TestResultObserver.ts @@ -38,7 +38,7 @@ export class TestResultObserver implements TestRunnerObserver { } testSuiteStarted(result: TestSuiteStarted): void { - this.doRun(result, (test) => this.testRun.started(test), true); + this.doRun(result, (test) => this.testRun.started(test)); } testStarted(result: TestStarted): void { @@ -60,7 +60,7 @@ export class TestResultObserver implements TestRunnerObserver { } testSuiteFinished(result: TestSuiteFinished): void { - this.doRun(result, (test) => this.testRun.passed(test), true); + this.doRun(result, (test) => this.testRun.passed(test)); } private message(result: TestFailed | TestIgnored, test: TestItem) { @@ -100,12 +100,8 @@ export class TestResultObserver implements TestRunnerObserver { return message; } - private doRun( - result: TestResult, - updateTestRun: (testItem: TestItem) => void, - usePrefixFallback = false, - ) { - const testItem = this.find(result, usePrefixFallback); + private doRun(result: TestResult, updateTestRun: (testItem: TestItem) => void) { + const testItem = this.find(result); if (!testItem) { return; } @@ -113,32 +109,11 @@ export class TestResultObserver implements TestRunnerObserver { updateTestRun(testItem); } - private find(result: TestResult, usePrefixFallback = false) { + private find(result: TestResult) { if (!('id' in result) || typeof result.id !== 'string') { return undefined; } - const exact = this.testItemById.get(result.id); - if (exact) { - return exact; - } - - // Pest v3 has a bug: Str::beforeLast uses mb_strrpos (char offset) with substr (byte offset). - // The → character (U+2192) is 3 UTF-8 bytes but 1 char, so testSuiteStarted/Finished names - // are truncated by 2 bytes per → character. The truncated ID is a prefix of the full parent ID. - // Only apply to suite events to avoid false positives on regular test events. - if ( - usePrefixFallback && - result.id.includes('\u2192') && - !result.id.includes(' with data set ') - ) { - for (const [key, item] of this.testItemById) { - if (key.startsWith(result.id) && !key.includes(' with data set ')) { - return item; - } - } - } - - return undefined; + return this.testItemById.get(result.id); } } diff --git a/packages/phpunit/src/TestIdentifier/PestFixer.ts b/packages/phpunit/src/TestIdentifier/PestFixer.ts index 55c3b7a6..f653e4a5 100644 --- a/packages/phpunit/src/TestIdentifier/PestFixer.ts +++ b/packages/phpunit/src/TestIdentifier/PestFixer.ts @@ -3,6 +3,7 @@ import type { TestResultCache } from '../TestOutput/TestResultCache'; export { PestV1Fixer } from './PestV1Fixer'; export { PestV2Fixer } from './PestV2Fixer'; +export { PestV3Fixer } from './PestV3Fixer'; export const PestFixer = { fixNoTestStarted(cache: TestResultCache, testResult: TestFailed | TestIgnored) { diff --git a/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts b/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts new file mode 100644 index 00000000..85140632 --- /dev/null +++ b/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts @@ -0,0 +1,13 @@ +// Pest v3 bug: Str::beforeLast uses mb_strrpos (char offset) with substr (byte offset). +// The → character (U+2192) is 3 UTF-8 bytes but 1 char, so names are truncated +// by 2 bytes per → character. +export const PestV3Fixer = { + truncatedId(id: string): string | undefined { + const count = (id.match(/\u2192/g) ?? []).length; + if (count === 0) { + return undefined; + } + + return id.slice(0, -count * 2); + }, +}; diff --git a/packages/phpunit/src/index.ts b/packages/phpunit/src/index.ts index df4c791f..4d54caf2 100644 --- a/packages/phpunit/src/index.ts +++ b/packages/phpunit/src/index.ts @@ -3,6 +3,7 @@ export * from './Printer'; export * from './ProcessBuilder'; export * from './TestCollection'; export * from './TestCoverage'; +export { PestV3Fixer } from './TestIdentifier/PestFixer'; export * from './TestOutput/types'; export * from './TestParser'; export * from './TestRunner'; From 7d7f412a2064cb15bb3d7fdc3afb927c9a2c6585 Mon Sep 17 00:00:00 2001 From: recca0120 Date: Fri, 13 Mar 2026 13:00:25 +0800 Subject: [PATCH 3/9] refactor: extract TestItemByIdMap class to encapsulate Pest v3 truncated ID aliases - TestItemByIdMap extends Map, overrides set() to auto-register truncated aliases - ObserverFactory uses TestItemByIdMap instead of manual loop + PestV3Fixer call - TestResultObserver.test.ts uses TestItemByIdMap via buildTestItemById helper --- .../src/Observers/ObserverFactory.ts | 13 +++---- .../src/Observers/TestResultObserver.test.ts | 35 ++++++++++--------- .../phpunit/src/TestIdentifier/PestFixer.ts | 2 +- .../phpunit/src/TestIdentifier/PestV3Fixer.ts | 12 +++++++ packages/phpunit/src/index.ts | 2 +- 5 files changed, 37 insertions(+), 27 deletions(-) diff --git a/packages/extension/src/Observers/ObserverFactory.ts b/packages/extension/src/Observers/ObserverFactory.ts index 3f76c0dd..eedeac56 100644 --- a/packages/extension/src/Observers/ObserverFactory.ts +++ b/packages/extension/src/Observers/ObserverFactory.ts @@ -1,12 +1,12 @@ import { type IConfiguration, - PestV3Fixer, PHPUnitXML, type PresetName, Printer, type PrinterFormat, resolveFormat, type TestDefinition, + TestItemByIdMap, type TestRunnerObserver, } from '@vscode-phpunit/phpunit'; import { inject, injectable } from 'inversify'; @@ -31,14 +31,9 @@ export class ObserverFactory { ) {} create(queue: Map, testRun: TestRun): TestRunnerObserver[] { - const testItemById = new Map(); - for (const item of queue.values()) { - testItemById.set(item.id, item); - const truncated = PestV3Fixer.truncatedId(item.id); - if (truncated) { - testItemById.set(truncated, item); - } - } + const testItemById = new TestItemByIdMap( + [...queue.values()].map((item) => [item.id, item]), + ); const format = resolveFormat( (this.configuration.get('output.preset') ?? 'collision') as PresetName, this.configuration.get('output.format') as Partial | undefined, diff --git a/packages/extension/src/Observers/TestResultObserver.test.ts b/packages/extension/src/Observers/TestResultObserver.test.ts index d25bb5b6..02173382 100644 --- a/packages/extension/src/Observers/TestResultObserver.test.ts +++ b/packages/extension/src/Observers/TestResultObserver.test.ts @@ -6,7 +6,7 @@ import type { TestSuiteFinished, TestSuiteStarted, } from '@vscode-phpunit/phpunit'; -import { PestV3Fixer } from '@vscode-phpunit/phpunit'; +import { TestItemByIdMap } from '@vscode-phpunit/phpunit'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type TestController, @@ -34,8 +34,8 @@ function createTestFailed(overrides: Partial = {}): TestFailed { }; } -function buildTestItemById(items: TestItem[]): Map { - return new Map(items.map((item) => [item.id, item])); +function buildTestItemById(items: TestItem[]): TestItemByIdMap { + return new TestItemByIdMap(items.map((item) => [item.id, item])); } describe('TestResultObserver', () => { @@ -195,17 +195,18 @@ describe('TestResultObserver', () => { // Pest v3 bug: Str::beforeLast uses mb_strrpos (char offset) with substr (byte offset). // The → character (U+2192) is 3 UTF-8 bytes but 1 char, so testSuiteStarted/Finished names // are truncated by 2 bytes per → character. - // ObserverFactory registers truncated aliases via PestV3Fixer.truncatedId() at build time. + // TestItemByIdMap automatically registers truncated aliases on set(). it('should find parent item via truncated alias when Pest v3 truncates testSuiteStarted name', () => { const parentItem = ctrl.createTestItem( 'tests/Unit/SampleTests.php::`something` \u2192 it should detect OK but does not', 'it should detect OK but does not', Uri.file('/project/tests/SampleTests.php'), ); - const testItemById = buildTestItemById([testItem, parentItem]); - const truncated = PestV3Fixer.truncatedId(parentItem.id); - if (truncated) testItemById.set(truncated, parentItem); - const obs = new TestResultObserver(queue, testRun, testItemById); + const obs = new TestResultObserver( + queue, + testRun, + buildTestItemById([testItem, parentItem]), + ); obs.testSuiteStarted({ event: 'testSuiteStarted' as unknown as TeamcityEvent, @@ -223,10 +224,11 @@ describe('TestResultObserver', () => { 'it should detect OK but does not', Uri.file('/project/tests/SampleTests.php'), ); - const testItemById = buildTestItemById([testItem, parentItem]); - const truncated = PestV3Fixer.truncatedId(parentItem.id); - if (truncated) testItemById.set(truncated, parentItem); - const obs = new TestResultObserver(queue, testRun, testItemById); + const obs = new TestResultObserver( + queue, + testRun, + buildTestItemById([testItem, parentItem]), + ); obs.testSuiteFinished({ event: 'testSuiteFinished' as unknown as TeamcityEvent, @@ -244,10 +246,11 @@ describe('TestResultObserver', () => { 'preset \u2192 php ', Uri.file('/project/tests/ArchTest.php'), ); - const testItemById = buildTestItemById([testItem, parentItem]); - const truncated = PestV3Fixer.truncatedId(parentItem.id); - if (truncated) testItemById.set(truncated, parentItem); - const obs = new TestResultObserver(queue, testRun, testItemById); + const obs = new TestResultObserver( + queue, + testRun, + buildTestItemById([testItem, parentItem]), + ); // truncated alias = 'tests/Unit/ArchTest.php::preset → p' // runtime id = 'tests/Unit/ArchTest.php::preset → php' — different, should not match diff --git a/packages/phpunit/src/TestIdentifier/PestFixer.ts b/packages/phpunit/src/TestIdentifier/PestFixer.ts index f653e4a5..5464ea8e 100644 --- a/packages/phpunit/src/TestIdentifier/PestFixer.ts +++ b/packages/phpunit/src/TestIdentifier/PestFixer.ts @@ -3,7 +3,7 @@ import type { TestResultCache } from '../TestOutput/TestResultCache'; export { PestV1Fixer } from './PestV1Fixer'; export { PestV2Fixer } from './PestV2Fixer'; -export { PestV3Fixer } from './PestV3Fixer'; +export { PestV3Fixer, TestItemByIdMap } from './PestV3Fixer'; export const PestFixer = { fixNoTestStarted(cache: TestResultCache, testResult: TestFailed | TestIgnored) { diff --git a/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts b/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts index 85140632..90d4e178 100644 --- a/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts +++ b/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts @@ -11,3 +11,15 @@ export const PestV3Fixer = { return id.slice(0, -count * 2); }, }; + +export class TestItemByIdMap extends Map { + override set(id: string, item: T): this { + super.set(id, item); + const truncated = PestV3Fixer.truncatedId(id); + if (truncated) { + super.set(truncated, item); + } + + return this; + } +} diff --git a/packages/phpunit/src/index.ts b/packages/phpunit/src/index.ts index 4d54caf2..d016586f 100644 --- a/packages/phpunit/src/index.ts +++ b/packages/phpunit/src/index.ts @@ -3,7 +3,7 @@ export * from './Printer'; export * from './ProcessBuilder'; export * from './TestCollection'; export * from './TestCoverage'; -export { PestV3Fixer } from './TestIdentifier/PestFixer'; +export { PestV3Fixer, TestItemByIdMap } from './TestIdentifier/PestFixer'; export * from './TestOutput/types'; export * from './TestParser'; export * from './TestRunner'; From 41294d57e8416d79a263b0e1fbb5590a364a693a Mon Sep 17 00:00:00 2001 From: recca0120 Date: Fri, 13 Mar 2026 13:13:39 +0800 Subject: [PATCH 4/9] refactor: rename TestItemByIdMap to AliasMap and remove dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename TestItemByIdMap → AliasMap: name is framework-agnostic, shared by both PHPUnit and Pest, focuses on alias registration behavior - Remove PestV3Fixer from public API (index.ts); only AliasMap is exported - Remove unreachable ternary in PestV2Fixer.fixId (decodeEvaluable always returns a non-empty string) --- packages/extension/src/Observers/ObserverFactory.ts | 4 ++-- .../extension/src/Observers/TestResultObserver.test.ts | 8 ++++---- packages/phpunit/src/TestIdentifier/PestFixer.ts | 2 +- packages/phpunit/src/TestIdentifier/PestV2Fixer.ts | 2 +- packages/phpunit/src/TestIdentifier/PestV3Fixer.ts | 2 +- packages/phpunit/src/index.ts | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/extension/src/Observers/ObserverFactory.ts b/packages/extension/src/Observers/ObserverFactory.ts index eedeac56..045f14fc 100644 --- a/packages/extension/src/Observers/ObserverFactory.ts +++ b/packages/extension/src/Observers/ObserverFactory.ts @@ -1,4 +1,5 @@ import { + AliasMap, type IConfiguration, PHPUnitXML, type PresetName, @@ -6,7 +7,6 @@ import { type PrinterFormat, resolveFormat, type TestDefinition, - TestItemByIdMap, type TestRunnerObserver, } from '@vscode-phpunit/phpunit'; import { inject, injectable } from 'inversify'; @@ -31,7 +31,7 @@ export class ObserverFactory { ) {} create(queue: Map, testRun: TestRun): TestRunnerObserver[] { - const testItemById = new TestItemByIdMap( + const testItemById = new AliasMap( [...queue.values()].map((item) => [item.id, item]), ); const format = resolveFormat( diff --git a/packages/extension/src/Observers/TestResultObserver.test.ts b/packages/extension/src/Observers/TestResultObserver.test.ts index 02173382..e4fea5e9 100644 --- a/packages/extension/src/Observers/TestResultObserver.test.ts +++ b/packages/extension/src/Observers/TestResultObserver.test.ts @@ -6,7 +6,7 @@ import type { TestSuiteFinished, TestSuiteStarted, } from '@vscode-phpunit/phpunit'; -import { TestItemByIdMap } from '@vscode-phpunit/phpunit'; +import { AliasMap } from '@vscode-phpunit/phpunit'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type TestController, @@ -34,8 +34,8 @@ function createTestFailed(overrides: Partial = {}): TestFailed { }; } -function buildTestItemById(items: TestItem[]): TestItemByIdMap { - return new TestItemByIdMap(items.map((item) => [item.id, item])); +function buildTestItemById(items: TestItem[]): AliasMap { + return new AliasMap(items.map((item) => [item.id, item])); } describe('TestResultObserver', () => { @@ -195,7 +195,7 @@ describe('TestResultObserver', () => { // Pest v3 bug: Str::beforeLast uses mb_strrpos (char offset) with substr (byte offset). // The → character (U+2192) is 3 UTF-8 bytes but 1 char, so testSuiteStarted/Finished names // are truncated by 2 bytes per → character. - // TestItemByIdMap automatically registers truncated aliases on set(). + // AliasMap automatically registers truncated aliases on set(). it('should find parent item via truncated alias when Pest v3 truncates testSuiteStarted name', () => { const parentItem = ctrl.createTestItem( 'tests/Unit/SampleTests.php::`something` \u2192 it should detect OK but does not', diff --git a/packages/phpunit/src/TestIdentifier/PestFixer.ts b/packages/phpunit/src/TestIdentifier/PestFixer.ts index 5464ea8e..81961f06 100644 --- a/packages/phpunit/src/TestIdentifier/PestFixer.ts +++ b/packages/phpunit/src/TestIdentifier/PestFixer.ts @@ -3,7 +3,7 @@ import type { TestResultCache } from '../TestOutput/TestResultCache'; export { PestV1Fixer } from './PestV1Fixer'; export { PestV2Fixer } from './PestV2Fixer'; -export { PestV3Fixer, TestItemByIdMap } from './PestV3Fixer'; +export { AliasMap, PestV3Fixer } from './PestV3Fixer'; export const PestFixer = { fixNoTestStarted(cache: TestResultCache, testResult: TestFailed | TestIgnored) { diff --git a/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts b/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts index e500bcff..0386661d 100644 --- a/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts +++ b/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts @@ -69,6 +69,6 @@ export const PestV2Fixer = { const decoded = decodeEvaluable(methodPart); const file = location.split('::')[0]; - return decoded ? `${file}::${decoded}` : file; + return `${file}::${decoded}`; }, }; diff --git a/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts b/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts index 90d4e178..6b01e0e7 100644 --- a/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts +++ b/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts @@ -12,7 +12,7 @@ export const PestV3Fixer = { }, }; -export class TestItemByIdMap extends Map { +export class AliasMap extends Map { override set(id: string, item: T): this { super.set(id, item); const truncated = PestV3Fixer.truncatedId(id); diff --git a/packages/phpunit/src/index.ts b/packages/phpunit/src/index.ts index d016586f..ea2ca14f 100644 --- a/packages/phpunit/src/index.ts +++ b/packages/phpunit/src/index.ts @@ -3,7 +3,7 @@ export * from './Printer'; export * from './ProcessBuilder'; export * from './TestCollection'; export * from './TestCoverage'; -export { PestV3Fixer, TestItemByIdMap } from './TestIdentifier/PestFixer'; +export { AliasMap } from './TestIdentifier/PestFixer'; export * from './TestOutput/types'; export * from './TestParser'; export * from './TestRunner'; From 6995969e5709f3f2dc2db766e6b472304ca319e1 Mon Sep 17 00:00:00 2001 From: recca0120 Date: Fri, 13 Mar 2026 13:27:42 +0800 Subject: [PATCH 5/9] refactor: simplify PestV2Fixer and remove PestV3Fixer indirection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove PestV3Fixer object: truncatedId() was only used internally by AliasMap.set() in the same file; replace with a module-level private function - Remove PestV3Fixer re-export from PestFixer barrel (no external consumer) - Simplify decodeDescribePart: replace 17-line while-loop tokenizer with placeholder approach (__ → \0, split _, restore \0) - Merge double ternary on datasetIdx into a single destructure --- .../phpunit/src/TestIdentifier/PestFixer.ts | 2 +- .../phpunit/src/TestIdentifier/PestV2Fixer.ts | 27 ++++++------------- .../phpunit/src/TestIdentifier/PestV3Fixer.ts | 18 ++++++------- 3 files changed, 17 insertions(+), 30 deletions(-) diff --git a/packages/phpunit/src/TestIdentifier/PestFixer.ts b/packages/phpunit/src/TestIdentifier/PestFixer.ts index 81961f06..277ada68 100644 --- a/packages/phpunit/src/TestIdentifier/PestFixer.ts +++ b/packages/phpunit/src/TestIdentifier/PestFixer.ts @@ -3,7 +3,7 @@ import type { TestResultCache } from '../TestOutput/TestResultCache'; export { PestV1Fixer } from './PestV1Fixer'; export { PestV2Fixer } from './PestV2Fixer'; -export { AliasMap, PestV3Fixer } from './PestV3Fixer'; +export { AliasMap } from './PestV3Fixer'; export const PestFixer = { fixNoTestStarted(cache: TestResultCache, testResult: TestFailed | TestIgnored) { diff --git a/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts b/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts index 0386661d..2c070a42 100644 --- a/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts +++ b/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts @@ -5,23 +5,10 @@ function hasPrefix(id?: string) { } function decodeDescribePart(inner: string): string { - const segments: string[] = []; - let current = ''; - let i = 0; - while (i < inner.length) { - if (inner[i] === '_' && inner[i + 1] === '_') { - current += '_'; - i += 2; - } else if (inner[i] === '_') { - segments.push(current); - current = ''; - i++; - } else { - current += inner[i]; - i++; - } - } - segments.push(current); + const segments = inner + .replace(/__/g, '\0') + .split('_') + .map((s) => s.replace(/\0/g, '_')); const isFQN = segments.every((p) => /^[A-Z]/.test(p)); return `\`${segments.join(isFQN ? '\\' : ' ')}\``; } @@ -36,8 +23,10 @@ function decodeEvaluable(encoded: string): string { const methodFull = encoded.slice(prefixIdx + PREFIX.length); const datasetIdx = methodFull.search(/\s+with\s+data\s+set\s+/); - const methodPart = datasetIdx >= 0 ? methodFull.slice(0, datasetIdx) : methodFull; - const datasetSuffix = datasetIdx >= 0 ? methodFull.slice(datasetIdx) : ''; + const [methodPart, datasetSuffix] = + datasetIdx >= 0 + ? [methodFull.slice(0, datasetIdx), methodFull.slice(datasetIdx)] + : [methodFull, '']; const segments = methodPart.split('_\u2192_'); if (segments.length < 2) { diff --git a/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts b/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts index 6b01e0e7..8a818b03 100644 --- a/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts +++ b/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts @@ -1,21 +1,19 @@ // Pest v3 bug: Str::beforeLast uses mb_strrpos (char offset) with substr (byte offset). // The → character (U+2192) is 3 UTF-8 bytes but 1 char, so names are truncated // by 2 bytes per → character. -export const PestV3Fixer = { - truncatedId(id: string): string | undefined { - const count = (id.match(/\u2192/g) ?? []).length; - if (count === 0) { - return undefined; - } +function truncatedId(id: string): string | undefined { + const count = (id.match(/\u2192/g) ?? []).length; + if (count === 0) { + return undefined; + } - return id.slice(0, -count * 2); - }, -}; + return id.slice(0, -count * 2); +} export class AliasMap extends Map { override set(id: string, item: T): this { super.set(id, item); - const truncated = PestV3Fixer.truncatedId(id); + const truncated = truncatedId(id); if (truncated) { super.set(truncated, item); } From 3e8fa3de4a14a2d55d4e37f5227fcb7cae5959c0 Mon Sep 17 00:00:00 2001 From: recca0120 Date: Fri, 13 Mar 2026 13:35:26 +0800 Subject: [PATCH 6/9] refactor: unify decode paths and eliminate dead code in PestV2Fixer - Extract decodeWords(): shared decode primitive used by both decodeDescribePart and the test-part decoding, removing duplication - Remove early return for segments.length < 2: the describe-less path is now handled by the unified single path (describeParts = []) - Remove `before` variable: always '' since hasPrefix() guarantees methodPart starts with PREFIX (prefixIdx === 0) --- .../phpunit/src/TestIdentifier/PestV2Fixer.ts | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts b/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts index 2c070a42..4cfafb87 100644 --- a/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts +++ b/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts @@ -4,13 +4,17 @@ function hasPrefix(id?: string) { return id?.includes(PREFIX) ?? false; } -function decodeDescribePart(inner: string): string { - const segments = inner +function decodeWords(encoded: string): string[] { + return encoded .replace(/__/g, '\0') .split('_') .map((s) => s.replace(/\0/g, '_')); - const isFQN = segments.every((p) => /^[A-Z]/.test(p)); - return `\`${segments.join(isFQN ? '\\' : ' ')}\``; +} + +function decodeDescribePart(inner: string): string { + const words = decodeWords(inner); + const isFQN = words.every((p) => /^[A-Z]/.test(p)); + return `\`${words.join(isFQN ? '\\' : ' ')}\``; } function decodeEvaluable(encoded: string): string { @@ -19,7 +23,6 @@ function decodeEvaluable(encoded: string): string { return encoded; } - const before = encoded.slice(0, prefixIdx); const methodFull = encoded.slice(prefixIdx + PREFIX.length); const datasetIdx = methodFull.search(/\s+with\s+data\s+set\s+/); @@ -29,22 +32,17 @@ function decodeEvaluable(encoded: string): string { : [methodFull, '']; const segments = methodPart.split('_\u2192_'); - if (segments.length < 2) { - const decoded = methodPart.replace(/__|_/g, (m) => (m === '__' ? '_' : ' ')); - return before + decoded + datasetSuffix; - } - - const describeParts = segments.slice(0, -1); const testPart = segments[segments.length - 1]; + const describeParts = segments.slice(0, -1); - const decodedDescribes = describeParts.map((part) => { - const inner = part.replace(/^_/, '').replace(/_$/, ''); - return decodeDescribePart(inner); - }); - - const decodedTest = testPart.replace(/__|_/g, (m) => (m === '__' ? '_' : ' ')); + const decoded = [ + ...describeParts.map((part) => + decodeDescribePart(part.replace(/^_/, '').replace(/_$/, '')), + ), + decodeWords(testPart).join(' '), + ].join(' \u2192 '); - return before + [...decodedDescribes, decodedTest].join(' \u2192 ') + datasetSuffix; + return decoded + datasetSuffix; } export const PestV2Fixer = { From db04879852c6d005677f8b5761866cb5958b4dd6 Mon Sep 17 00:00:00 2001 From: recca0120 Date: Fri, 13 Mar 2026 13:37:48 +0800 Subject: [PATCH 7/9] refactor: inline truncatedId and simplify trim regex - Inline truncatedId() into AliasMap.set(): single-use private function with simple logic, inlining removes unnecessary indirection - Simplify part.replace(/^_/, '').replace(/_$/, '') to part.replace(/^_|_$/g, '') --- .../phpunit/src/TestIdentifier/PestV2Fixer.ts | 4 +--- .../phpunit/src/TestIdentifier/PestV3Fixer.ts | 15 +++------------ 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts b/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts index 4cfafb87..8842c604 100644 --- a/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts +++ b/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts @@ -36,9 +36,7 @@ function decodeEvaluable(encoded: string): string { const describeParts = segments.slice(0, -1); const decoded = [ - ...describeParts.map((part) => - decodeDescribePart(part.replace(/^_/, '').replace(/_$/, '')), - ), + ...describeParts.map((part) => decodeDescribePart(part.replace(/^_|_$/g, ''))), decodeWords(testPart).join(' '), ].join(' \u2192 '); diff --git a/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts b/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts index 8a818b03..d8f1a756 100644 --- a/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts +++ b/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts @@ -1,21 +1,12 @@ // Pest v3 bug: Str::beforeLast uses mb_strrpos (char offset) with substr (byte offset). // The → character (U+2192) is 3 UTF-8 bytes but 1 char, so names are truncated // by 2 bytes per → character. -function truncatedId(id: string): string | undefined { - const count = (id.match(/\u2192/g) ?? []).length; - if (count === 0) { - return undefined; - } - - return id.slice(0, -count * 2); -} - export class AliasMap extends Map { override set(id: string, item: T): this { super.set(id, item); - const truncated = truncatedId(id); - if (truncated) { - super.set(truncated, item); + const count = id.match(/\u2192/g)?.length ?? 0; + if (count > 0) { + super.set(id.slice(0, -count * 2), item); } return this; From 5f221a40296cdc764ae1b4d7b5711cacabbfed39 Mon Sep 17 00:00:00 2001 From: recca0120 Date: Fri, 13 Mar 2026 13:47:57 +0800 Subject: [PATCH 8/9] chore: remove unused SampleTests.php fixture --- .../fixtures/pest-stub/tests/Unit/SampleTests.php | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 packages/phpunit/tests/fixtures/pest-stub/tests/Unit/SampleTests.php diff --git a/packages/phpunit/tests/fixtures/pest-stub/tests/Unit/SampleTests.php b/packages/phpunit/tests/fixtures/pest-stub/tests/Unit/SampleTests.php deleted file mode 100644 index 1a338044..00000000 --- a/packages/phpunit/tests/fixtures/pest-stub/tests/Unit/SampleTests.php +++ /dev/null @@ -1,15 +0,0 @@ -toBeGreaterThan(3); - })->with([ - 4, - 8, - ]); - it('still does not detect OK', function () { - expect(6)->toBeGreaterThan(3); - })->with([ - 4, - ]); -}); From 70cda8e06edb53d15f201abd506ef7f6f3807ebe Mon Sep 17 00:00:00 2001 From: recca0120 Date: Fri, 13 Mar 2026 13:53:19 +0800 Subject: [PATCH 9/9] docs: document AliasMap usage for Pest v3 truncated ID compatibility --- packages/phpunit/README.md | 28 +++++++++++++++++++++++++++- packages/phpunit/README.zh-TW.md | 28 +++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/phpunit/README.md b/packages/phpunit/README.md index 2bdb22ab..6c9cba19 100644 --- a/packages/phpunit/README.md +++ b/packages/phpunit/README.md @@ -281,7 +281,33 @@ namespace: App\Tests\Unit App └─ with data set "two" ``` -### 4. Format test output (Printer) +### 4. Resolve test items by ID at runtime (`AliasMap`) + +When test results arrive via Teamcity events, you need to look up the corresponding UI item by its ID. `AliasMap` is a drop-in replacement for `Map` that handles a Pest v3 bug automatically. + +**The problem**: Pest v3's `Str::beforeLast()` mixes `mb_strrpos` (char offset) with `substr` (byte offset). The `→` character (U+2192) is 3 UTF-8 bytes but 1 char, so `testSuiteStarted` / `testSuiteFinished` event IDs are truncated by 2 bytes per `→` — making a direct `Map.get()` miss the item. + +**The solution**: Use `AliasMap` instead of a plain `Map`. Every `set()` call automatically registers the truncated alias alongside the real ID, so `get()` finds the item regardless of which variant the event carries. + +```typescript +import { AliasMap } from '@vscode-phpunit/phpunit'; + +// Build from your test items — truncated aliases registered automatically +const testItemById = new AliasMap( + items.map((item) => [item.id, item]), +); + +// Lookup works for both the full and the Pest v3 truncated ID +const fullId = 'tests/Unit/Foo.php::`something` → it passes'; +const truncatedId = 'tests/Unit/Foo.php::`something` → it pass'; // truncated by Pest v3 + +testItemById.get(fullId); // → MyItem ✓ +testItemById.get(truncatedId); // → MyItem ✓ (alias registered automatically) +``` + +`AliasMap` is framework-agnostic — it works with VS Code `TestItem`, plain objects, or any other type. + +### 5. Format test output (Printer) `Printer` transforms structured test events into human-readable output with configurable templates and ANSI colors. Output is written through the `OutputWriter` interface, keeping the printer decoupled from any specific output target. diff --git a/packages/phpunit/README.zh-TW.md b/packages/phpunit/README.zh-TW.md index c888994a..8ee4422e 100644 --- a/packages/phpunit/README.zh-TW.md +++ b/packages/phpunit/README.zh-TW.md @@ -280,7 +280,33 @@ namespace: App\Tests\Unit App └─ with data set "two" ``` -### 4. 格式化測試輸出(Printer) +### 4. 執行期間依 ID 反查測試項目(`AliasMap`) + +當 Teamcity 事件帶來測試結果時,你需要依 ID 找回對應的 UI 項目。`AliasMap` 是 `Map` 的直接替代品,能自動處理 Pest v3 的一個已知 bug。 + +**問題根源**:Pest v3 的 `Str::beforeLast()` 混用了 `mb_strrpos`(字元偏移)與 `substr`(位元組偏移)。`→`(U+2192)是 3 個 UTF-8 位元組但只算 1 個字元,導致 `testSuiteStarted` / `testSuiteFinished` 事件的 ID 每出現一個 `→` 就被截短 2 個位元組 — 直接用 `Map.get()` 就會找不到項目。 + +**解法**:改用 `AliasMap`。每次呼叫 `set()` 時,它會自動同時登錄截短版的別名 ID,讓 `get()` 無論收到哪個版本都能命中。 + +```typescript +import { AliasMap } from '@vscode-phpunit/phpunit'; + +// 從測試項目建立 — 截短別名自動登錄 +const testItemById = new AliasMap( + items.map((item) => [item.id, item]), +); + +// 無論完整 ID 或 Pest v3 截短 ID 都能查到 +const fullId = 'tests/Unit/Foo.php::`something` → it passes'; +const truncatedId = 'tests/Unit/Foo.php::`something` → it pass'; // Pest v3 截短版 + +testItemById.get(fullId); // → MyItem ✓ +testItemById.get(truncatedId); // → MyItem ✓ (別名自動登錄) +``` + +`AliasMap` 與框架無關,可搭配 VS Code `TestItem`、純物件或任何其他型別使用。 + +### 5. 格式化測試輸出(Printer) `Printer` 將結構化測試事件轉換為可讀的輸出,支援可設定的模板與 ANSI 色彩。輸出透過 `OutputWriter` 介面寫入,讓 Printer 與具體輸出目標解耦。