Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/extension/src/Observers/ObserverFactory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
AliasMap,
type IConfiguration,
PHPUnitXML,
type PresetName,
Expand Down Expand Up @@ -30,7 +31,9 @@ export class ObserverFactory {
) {}

create(queue: Map<TestDefinition, TestItem>, testRun: TestRun): TestRunnerObserver[] {
const testItemById = new Map([...queue.values()].map((item) => [item.id, item]));
const testItemById = new AliasMap<TestItem>(
[...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<PrinterFormat> | undefined,
Expand Down
79 changes: 77 additions & 2 deletions packages/extension/src/Observers/TestResultObserver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -31,8 +34,8 @@ function createTestFailed(overrides: Partial<TestFailed> = {}): TestFailed {
};
}

function buildTestItemById(items: TestItem[]): Map<string, TestItem> {
return new Map(items.map((item) => [item.id, item]));
function buildTestItemById(items: TestItem[]): AliasMap<TestItem> {
return new AliasMap(items.map((item) => [item.id, item]));
}

describe('TestResultObserver', () => {
Expand Down Expand Up @@ -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');

Expand Down
28 changes: 27 additions & 1 deletion packages/phpunit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` is a drop-in replacement for `Map<string, T>` 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<MyItem>(
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.

Expand Down
28 changes: 27 additions & 1 deletion packages/phpunit/README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,33 @@ namespace: App\Tests\Unit App
└─ with data set "two"
```

### 4. 格式化測試輸出(Printer)
### 4. 執行期間依 ID 反查測試項目(`AliasMap`)

當 Teamcity 事件帶來測試結果時,你需要依 ID 找回對應的 UI 項目。`AliasMap<T>` 是 `Map<string, T>` 的直接替代品,能自動處理 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<MyItem>(
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 與具體輸出目標解耦。

Expand Down
1 change: 1 addition & 0 deletions packages/phpunit/src/TestIdentifier/PestFixer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
42 changes: 33 additions & 9 deletions packages/phpunit/src/TestIdentifier/PestV2Fixer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -30,6 +54,6 @@ export const PestV2Fixer = {
const decoded = decodeEvaluable(methodPart);
const file = location.split('::')[0];

return decoded ? `${file}::${decoded}` : file;
return `${file}::${decoded}`;
},
};
14 changes: 14 additions & 0 deletions packages/phpunit/src/TestIdentifier/PestV3Fixer.ts
Original file line number Diff line number Diff line change
@@ -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<T> extends Map<string, T> {
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;
}
}
1 change: 1 addition & 0 deletions packages/phpunit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading