diff --git a/packages/extension/src/Observers/ObserverFactory.ts b/packages/extension/src/Observers/ObserverFactory.ts index 1250071d..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, @@ -30,7 +31,9 @@ export class ObserverFactory { ) {} create(queue: Map, testRun: TestRun): TestRunnerObserver[] { - const testItemById = new Map([...queue.values()].map((item) => [item.id, item])); + const testItemById = new AliasMap( + [...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 adc1f70c..e4fea5e9 100644 --- a/packages/extension/src/Observers/TestResultObserver.test.ts +++ b/packages/extension/src/Observers/TestResultObserver.test.ts @@ -3,7 +3,10 @@ import type { TestDefinition, TestFailed, TestFinished, + TestSuiteFinished, + TestSuiteStarted, } from '@vscode-phpunit/phpunit'; +import { AliasMap } from '@vscode-phpunit/phpunit'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type TestController, @@ -31,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[]): AliasMap { + return new AliasMap(items.map((item) => [item.id, item])); } describe('TestResultObserver', () => { @@ -189,6 +192,78 @@ 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. + // 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', + 'it should detect OK but does not', + Uri.file('/project/tests/SampleTests.php'), + ); + const obs = new TestResultObserver( + queue, + testRun, + buildTestItemById([testItem, parentItem]), + ); + + 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 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 obs = new TestResultObserver( + queue, + testRun, + buildTestItemById([testItem, parentItem]), + ); + + 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 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 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 + 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/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 與具體輸出目標解耦。 diff --git a/packages/phpunit/src/TestIdentifier/PestFixer.ts b/packages/phpunit/src/TestIdentifier/PestFixer.ts index 55c3b7a6..277ada68 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 { AliasMap } from './PestV3Fixer'; export const PestFixer = { fixNoTestStarted(cache: TestResultCache, testResult: TestFailed | TestIgnored) { 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..8842c604 100644 --- a/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts +++ b/packages/phpunit/src/TestIdentifier/PestV2Fixer.ts @@ -4,19 +4,43 @@ function hasPrefix(id?: string) { return id?.includes(PREFIX) ?? false; } -function decodeEvaluable(encoded: string) { - const idx = encoded.indexOf(PREFIX); - if (idx === -1) { +function decodeWords(encoded: string): string[] { + return encoded + .replace(/__/g, '\0') + .split('_') + .map((s) => s.replace(/\0/g, '_')); +} + +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 { + const prefixIdx = encoded.indexOf(PREFIX); + if (prefixIdx === -1) { return encoded; } - const before = encoded.slice(0, idx); - let method = encoded.slice(idx + PREFIX.length); + const methodFull = encoded.slice(prefixIdx + PREFIX.length); + + const datasetIdx = methodFull.search(/\s+with\s+data\s+set\s+/); + const [methodPart, datasetSuffix] = + datasetIdx >= 0 + ? [methodFull.slice(0, datasetIdx), methodFull.slice(datasetIdx)] + : [methodFull, '']; + + const segments = methodPart.split('_\u2192_'); + const testPart = segments[segments.length - 1]; + const describeParts = segments.slice(0, -1); - // reverse: single _ → space, double __ → literal _ - method = method.replace(/__|_/g, (m) => (m === '__' ? '_' : ' ')); + const decoded = [ + ...describeParts.map((part) => decodeDescribePart(part.replace(/^_|_$/g, ''))), + decodeWords(testPart).join(' '), + ].join(' \u2192 '); - return before + method; + return decoded + datasetSuffix; } export const PestV2Fixer = { @@ -30,6 +54,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 new file mode 100644 index 00000000..d8f1a756 --- /dev/null +++ b/packages/phpunit/src/TestIdentifier/PestV3Fixer.ts @@ -0,0 +1,14 @@ +// 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 class AliasMap extends Map { + override set(id: string, item: T): this { + super.set(id, item); + const count = id.match(/\u2192/g)?.length ?? 0; + if (count > 0) { + super.set(id.slice(0, -count * 2), item); + } + + return this; + } +} diff --git a/packages/phpunit/src/index.ts b/packages/phpunit/src/index.ts index df4c791f..ea2ca14f 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 { AliasMap } from './TestIdentifier/PestFixer'; export * from './TestOutput/types'; export * from './TestParser'; export * from './TestRunner';