Skip to content

Commit 8e8a750

Browse files
committed
fix: Pest v2 describe+with and Pest v3 truncated suite name compatibility
2 parents a8ef29d + 70cda8e commit 8e8a750

9 files changed

Lines changed: 191 additions & 14 deletions

File tree

packages/extension/src/Observers/ObserverFactory.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
AliasMap,
23
type IConfiguration,
34
PHPUnitXML,
45
type PresetName,
@@ -30,7 +31,9 @@ export class ObserverFactory {
3031
) {}
3132

3233
create(queue: Map<TestDefinition, TestItem>, testRun: TestRun): TestRunnerObserver[] {
33-
const testItemById = new Map([...queue.values()].map((item) => [item.id, item]));
34+
const testItemById = new AliasMap<TestItem>(
35+
[...queue.values()].map((item) => [item.id, item]),
36+
);
3437
const format = resolveFormat(
3538
(this.configuration.get('output.preset') ?? 'collision') as PresetName,
3639
this.configuration.get('output.format') as Partial<PrinterFormat> | undefined,

packages/extension/src/Observers/TestResultObserver.test.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import type {
33
TestDefinition,
44
TestFailed,
55
TestFinished,
6+
TestSuiteFinished,
7+
TestSuiteStarted,
68
} from '@vscode-phpunit/phpunit';
9+
import { AliasMap } from '@vscode-phpunit/phpunit';
710
import { beforeEach, describe, expect, it, vi } from 'vitest';
811
import {
912
type TestController,
@@ -31,8 +34,8 @@ function createTestFailed(overrides: Partial<TestFailed> = {}): TestFailed {
3134
};
3235
}
3336

34-
function buildTestItemById(items: TestItem[]): Map<string, TestItem> {
35-
return new Map(items.map((item) => [item.id, item]));
37+
function buildTestItemById(items: TestItem[]): AliasMap<TestItem> {
38+
return new AliasMap(items.map((item) => [item.id, item]));
3639
}
3740

3841
describe('TestResultObserver', () => {
@@ -189,6 +192,78 @@ describe('TestResultObserver', () => {
189192
expect(message.stackTrace).toBeUndefined();
190193
});
191194

195+
// Pest v3 bug: Str::beforeLast uses mb_strrpos (char offset) with substr (byte offset).
196+
// The → character (U+2192) is 3 UTF-8 bytes but 1 char, so testSuiteStarted/Finished names
197+
// are truncated by 2 bytes per → character.
198+
// AliasMap automatically registers truncated aliases on set().
199+
it('should find parent item via truncated alias when Pest v3 truncates testSuiteStarted name', () => {
200+
const parentItem = ctrl.createTestItem(
201+
'tests/Unit/SampleTests.php::`something` \u2192 it should detect OK but does not',
202+
'it should detect OK but does not',
203+
Uri.file('/project/tests/SampleTests.php'),
204+
);
205+
const obs = new TestResultObserver(
206+
queue,
207+
testRun,
208+
buildTestItemById([testItem, parentItem]),
209+
);
210+
211+
obs.testSuiteStarted({
212+
event: 'testSuiteStarted' as unknown as TeamcityEvent,
213+
id: 'tests/Unit/SampleTests.php::`something` \u2192 it should detect OK but does n',
214+
flowId: 1,
215+
name: '`something` \u2192 it should detect OK but does n',
216+
} as unknown as TestSuiteStarted);
217+
218+
expect(testRun.started).toHaveBeenCalledWith(parentItem);
219+
});
220+
221+
it('should mark parent passed via truncated alias when Pest v3 truncates testSuiteFinished name', () => {
222+
const parentItem = ctrl.createTestItem(
223+
'tests/Unit/SampleTests.php::`something` \u2192 it should detect OK but does not',
224+
'it should detect OK but does not',
225+
Uri.file('/project/tests/SampleTests.php'),
226+
);
227+
const obs = new TestResultObserver(
228+
queue,
229+
testRun,
230+
buildTestItemById([testItem, parentItem]),
231+
);
232+
233+
obs.testSuiteFinished({
234+
event: 'testSuiteFinished' as unknown as TeamcityEvent,
235+
id: 'tests/Unit/SampleTests.php::`something` \u2192 it should detect OK but does n',
236+
flowId: 1,
237+
name: '`something` \u2192 it should detect OK but does n',
238+
} as unknown as TestSuiteFinished);
239+
240+
expect(testRun.passed).toHaveBeenCalledWith(parentItem);
241+
});
242+
243+
it('should not match arch test item when runtime id differs from truncated alias', () => {
244+
const parentItem = ctrl.createTestItem(
245+
'tests/Unit/ArchTest.php::preset \u2192 php ',
246+
'preset \u2192 php ',
247+
Uri.file('/project/tests/ArchTest.php'),
248+
);
249+
const obs = new TestResultObserver(
250+
queue,
251+
testRun,
252+
buildTestItemById([testItem, parentItem]),
253+
);
254+
255+
// truncated alias = 'tests/Unit/ArchTest.php::preset → p'
256+
// runtime id = 'tests/Unit/ArchTest.php::preset → php' — different, should not match
257+
obs.testStarted({
258+
event: 'testStarted' as unknown as TeamcityEvent,
259+
id: 'tests/Unit/ArchTest.php::preset \u2192 php',
260+
flowId: 1,
261+
name: 'preset \u2192 php',
262+
} as never);
263+
264+
expect(testRun.started).not.toHaveBeenCalledWith(parentItem);
265+
});
266+
192267
it('should not use TestMessage.diff when expected/actual are missing', () => {
193268
const diffSpy = vi.spyOn(TestMessage, 'diff');
194269

packages/phpunit/README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,33 @@ namespace: App\Tests\Unit App
281281
└─ with data set "two"
282282
```
283283

284-
### 4. Format test output (Printer)
284+
### 4. Resolve test items by ID at runtime (`AliasMap`)
285+
286+
When test results arrive via Teamcity events, you need to look up the corresponding UI item by its ID. `AliasMap<T>` is a drop-in replacement for `Map<string, T>` that handles a Pest v3 bug automatically.
287+
288+
**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.
289+
290+
**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.
291+
292+
```typescript
293+
import { AliasMap } from '@vscode-phpunit/phpunit';
294+
295+
// Build from your test items — truncated aliases registered automatically
296+
const testItemById = new AliasMap<MyItem>(
297+
items.map((item) => [item.id, item]),
298+
);
299+
300+
// Lookup works for both the full and the Pest v3 truncated ID
301+
const fullId = 'tests/Unit/Foo.php::`something` → it passes';
302+
const truncatedId = 'tests/Unit/Foo.php::`something` → it pass'; // truncated by Pest v3
303+
304+
testItemById.get(fullId); // → MyItem ✓
305+
testItemById.get(truncatedId); // → MyItem ✓ (alias registered automatically)
306+
```
307+
308+
`AliasMap` is framework-agnostic — it works with VS Code `TestItem`, plain objects, or any other type.
309+
310+
### 5. Format test output (Printer)
285311

286312
`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.
287313

packages/phpunit/README.zh-TW.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,33 @@ namespace: App\Tests\Unit App
280280
└─ with data set "two"
281281
```
282282

283-
### 4. 格式化測試輸出(Printer)
283+
### 4. 執行期間依 ID 反查測試項目(`AliasMap`
284+
285+
當 Teamcity 事件帶來測試結果時,你需要依 ID 找回對應的 UI 項目。`AliasMap<T>``Map<string, T>` 的直接替代品,能自動處理 Pest v3 的一個已知 bug。
286+
287+
**問題根源**:Pest v3 的 `Str::beforeLast()` 混用了 `mb_strrpos`(字元偏移)與 `substr`(位元組偏移)。``(U+2192)是 3 個 UTF-8 位元組但只算 1 個字元,導致 `testSuiteStarted` / `testSuiteFinished` 事件的 ID 每出現一個 `` 就被截短 2 個位元組 — 直接用 `Map.get()` 就會找不到項目。
288+
289+
**解法**:改用 `AliasMap`。每次呼叫 `set()` 時,它會自動同時登錄截短版的別名 ID,讓 `get()` 無論收到哪個版本都能命中。
290+
291+
```typescript
292+
import { AliasMap } from '@vscode-phpunit/phpunit';
293+
294+
// 從測試項目建立 — 截短別名自動登錄
295+
const testItemById = new AliasMap<MyItem>(
296+
items.map((item) => [item.id, item]),
297+
);
298+
299+
// 無論完整 ID 或 Pest v3 截短 ID 都能查到
300+
const fullId = 'tests/Unit/Foo.php::`something` → it passes';
301+
const truncatedId = 'tests/Unit/Foo.php::`something` → it pass'; // Pest v3 截短版
302+
303+
testItemById.get(fullId); // → MyItem ✓
304+
testItemById.get(truncatedId); // → MyItem ✓ (別名自動登錄)
305+
```
306+
307+
`AliasMap` 與框架無關,可搭配 VS Code `TestItem`、純物件或任何其他型別使用。
308+
309+
### 5. 格式化測試輸出(Printer)
284310

285311
`Printer` 將結構化測試事件轉換為可讀的輸出,支援可設定的模板與 ANSI 色彩。輸出透過 `OutputWriter` 介面寫入,讓 Printer 與具體輸出目標解耦。
286312

packages/phpunit/src/TestIdentifier/PestFixer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { TestResultCache } from '../TestOutput/TestResultCache';
33

44
export { PestV1Fixer } from './PestV1Fixer';
55
export { PestV2Fixer } from './PestV2Fixer';
6+
export { AliasMap } from './PestV3Fixer';
67

78
export const PestFixer = {
89
fixNoTestStarted(cache: TestResultCache, testResult: TestFailed | TestIgnored) {

packages/phpunit/src/TestIdentifier/PestTestIdentifier.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ describe('PestTestIdentifier', () => {
7777
'Users\\path\\to\\tests\\Unit\\DatasetTest::__pest_evaluable_it_business_closed',
7878
'tests/Unit/DatasetTest.php::it business closed',
7979
],
80+
// describe('something', fn() => it('test', fn())->with([...]))
81+
// v2 testSuiteStarted uses evaluable encoding with → separator
82+
[
83+
'file:///path/to/tests/Unit/SampleTests.php',
84+
'Users\\path\\to\\tests\\Unit\\SampleTests::__pest_evaluable__something__\u2192_it_should_detect_OK_but_does_not',
85+
'tests/Unit/SampleTests.php::`something` \u2192 it should detect OK but does not',
86+
],
8087
])('fromLocationHint(%j, %j) → id: %s', (locationHint, name, expectedId) => {
8188
const result = transformer.fromLocationHint(locationHint, name);
8289
expect(result.id).toBe(expectedId);

packages/phpunit/src/TestIdentifier/PestV2Fixer.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,43 @@ function hasPrefix(id?: string) {
44
return id?.includes(PREFIX) ?? false;
55
}
66

7-
function decodeEvaluable(encoded: string) {
8-
const idx = encoded.indexOf(PREFIX);
9-
if (idx === -1) {
7+
function decodeWords(encoded: string): string[] {
8+
return encoded
9+
.replace(/__/g, '\0')
10+
.split('_')
11+
.map((s) => s.replace(/\0/g, '_'));
12+
}
13+
14+
function decodeDescribePart(inner: string): string {
15+
const words = decodeWords(inner);
16+
const isFQN = words.every((p) => /^[A-Z]/.test(p));
17+
return `\`${words.join(isFQN ? '\\' : ' ')}\``;
18+
}
19+
20+
function decodeEvaluable(encoded: string): string {
21+
const prefixIdx = encoded.indexOf(PREFIX);
22+
if (prefixIdx === -1) {
1023
return encoded;
1124
}
1225

13-
const before = encoded.slice(0, idx);
14-
let method = encoded.slice(idx + PREFIX.length);
26+
const methodFull = encoded.slice(prefixIdx + PREFIX.length);
27+
28+
const datasetIdx = methodFull.search(/\s+with\s+data\s+set\s+/);
29+
const [methodPart, datasetSuffix] =
30+
datasetIdx >= 0
31+
? [methodFull.slice(0, datasetIdx), methodFull.slice(datasetIdx)]
32+
: [methodFull, ''];
33+
34+
const segments = methodPart.split('_\u2192_');
35+
const testPart = segments[segments.length - 1];
36+
const describeParts = segments.slice(0, -1);
1537

16-
// reverse: single _ → space, double __ → literal _
17-
method = method.replace(/__|_/g, (m) => (m === '__' ? '_' : ' '));
38+
const decoded = [
39+
...describeParts.map((part) => decodeDescribePart(part.replace(/^_|_$/g, ''))),
40+
decodeWords(testPart).join(' '),
41+
].join(' \u2192 ');
1842

19-
return before + method;
43+
return decoded + datasetSuffix;
2044
}
2145

2246
export const PestV2Fixer = {
@@ -30,6 +54,6 @@ export const PestV2Fixer = {
3054
const decoded = decodeEvaluable(methodPart);
3155
const file = location.split('::')[0];
3256

33-
return decoded ? `${file}::${decoded}` : file;
57+
return `${file}::${decoded}`;
3458
},
3559
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Pest v3 bug: Str::beforeLast uses mb_strrpos (char offset) with substr (byte offset).
2+
// The → character (U+2192) is 3 UTF-8 bytes but 1 char, so names are truncated
3+
// by 2 bytes per → character.
4+
export class AliasMap<T> extends Map<string, T> {
5+
override set(id: string, item: T): this {
6+
super.set(id, item);
7+
const count = id.match(/\u2192/g)?.length ?? 0;
8+
if (count > 0) {
9+
super.set(id.slice(0, -count * 2), item);
10+
}
11+
12+
return this;
13+
}
14+
}

packages/phpunit/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from './Printer';
33
export * from './ProcessBuilder';
44
export * from './TestCollection';
55
export * from './TestCoverage';
6+
export { AliasMap } from './TestIdentifier/PestFixer';
67
export * from './TestOutput/types';
78
export * from './TestParser';
89
export * from './TestRunner';

0 commit comments

Comments
 (0)