Implement TeamCity test reporting integration by adding new scripts and documentation. Update package.json to include a new test command for TeamCity. Introduce TeamcityReporter class for formatting service messages and enhance test setup for compatibility with TeamCity environment.

This commit is contained in:
2025-11-22 22:42:54 -05:00
parent a050c02dc1
commit 3a58ab03d7
6 changed files with 435 additions and 1 deletions

110
docs/TEAMCITY.md Normal file
View File

@@ -0,0 +1,110 @@
# TeamCity Test Integration
This project includes TeamCity test reporting integration using the `TeamcityReporter` class.
## Usage
### In TeamCity CI
When running tests in TeamCity, use the `test:teamcity` script:
```bash
bun run test:teamcity
```
This will automatically detect the TeamCity environment (via `TEAMCITY_VERSION` env variable) and output test results in TeamCity's service message format.
### Local Development
For local testing, simply use:
```bash
bun test
```
The TeamCity reporter will be disabled automatically when not running in TeamCity.
## How It Works
The integration uses a TAP (Test Anything Protocol) to TeamCity converter:
1. **TAP Reporter**: Bun's built-in TAP reporter outputs test results in TAP format
2. **Converter**: `scripts/test-tap-teamcity.ts` parses TAP output and converts it to TeamCity service messages
3. **TeamcityReporter**: `tests/TeamcityReporter.ts` formats and outputs TeamCity service messages
## TeamCity Service Messages
The reporter outputs the following TeamCity service messages:
- `##teamcity[testSuiteStarted name='...']` - When a test suite starts
- `##teamcity[testSuiteFinished name='...']` - When a test suite ends
- `##teamcity[testStarted name='...']` - When a test starts
- `##teamcity[testFinished name='...']` - When a test completes successfully
- `##teamcity[testFailed name='...' message='...' details='...']` - When a test fails
- `##teamcity[testIgnored name='...']` - When a test is skipped
## Special Character Escaping
The reporter properly escapes special characters according to [TeamCity's specification](https://www.jetbrains.com/help/teamcity/service-messages.html#Escaped+values):
- `|``||`
- `'``|'`
- `\n``|n`
- `\r``|r`
- `[``|[`
- `]``|]`
## Available Test Scripts
| Script | Description |
|--------|-------------|
| `bun test` | Run tests normally (local development) |
| `bun test --watch` | Run tests in watch mode |
| `bun run test:teamcity` | Run tests with TeamCity reporting |
| `bun run ci-test` | Alias for `test:teamcity` (used in CI) |
## TeamCity Build Configuration
Add this build step to your TeamCity build configuration:
```bash
#!/bin/bash
set -e
# Install dependencies
bun install
# Run tests with TeamCity reporting
bun run ci-test
```
## Files
- `tests/TeamcityReporter.ts` - TeamCity service message formatter
- `scripts/test-tap-teamcity.ts` - TAP to TeamCity converter
- `scripts/test-teamcity.ts` - Alternative direct output parser (not currently used)
- `tests/setup.ts` - Test setup utilities
## Testing the Integration
To test TeamCity reporting locally:
```bash
# Simulate TeamCity environment
export TEAMCITY_VERSION="2024.1"
bun run test:teamcity
# Unset when done
unset TEAMCITY_VERSION
```
You should see output like:
```
##teamcity[testSuiteStarted name='HashMap']
##teamcity[testStarted name='HashMap > constructor > should create an empty map with default capacity']
##teamcity[testFinished name='HashMap > constructor > should create an empty map with default capacity']
...
##teamcity[testSuiteFinished name='HashMap']
```

View File

@@ -23,7 +23,7 @@
}, },
"scripts": { "scripts": {
"ci-install": "bun install", "ci-install": "bun install",
"ci-test": "bun test", "ci-test": "bun run test:teamcity",
"ci-build": "bash scripts/ci-build.sh", "ci-build": "bash scripts/ci-build.sh",
"ci-deploy:ga": "bash scripts/ci-deploy.sh --beta false", "ci-deploy:ga": "bash scripts/ci-deploy.sh --beta false",
"ci-deploy:beta": "bash scripts/ci-deploy.sh --beta true", "ci-deploy:beta": "bash scripts/ci-deploy.sh --beta true",
@@ -33,6 +33,7 @@
"lint:fix": "eslint --fix", "lint:fix": "eslint --fix",
"test": "bun test", "test": "bun test",
"test:watch": "bun test --watch", "test:watch": "bun test --watch",
"test:teamcity": "bun run scripts/test-teamcity.ts",
"build:node:debug": "bun build ./src/index.ts --target=node --sourcemap=none --format=esm --sourcemap=inline --outdir=dist/node", "build:node:debug": "bun build ./src/index.ts --target=node --sourcemap=none --format=esm --sourcemap=inline --outdir=dist/node",
"build:browser:debug": "bun build ./src/index.ts --target=browser --sourcemap=none --format=esm --sourcemap=inline --outdir=dist/browser", "build:browser:debug": "bun build ./src/index.ts --target=browser --sourcemap=none --format=esm --sourcemap=inline --outdir=dist/browser",
"build:node": "bun build ./src/index.ts --target=node --sourcemap=none --format=esm --splitting --minify --outdir=dist/node", "build:node": "bun build ./src/index.ts --target=node --sourcemap=none --format=esm --splitting --minify --outdir=dist/node",

99
scripts/test-tap-teamcity.ts Executable file
View File

@@ -0,0 +1,99 @@
#!/usr/bin/env bun
import { TeamcityReporter } from "../tests/TeamcityReporter.ts";
const isTeamCity = process.env.TEAMCITY_VERSION !== undefined;
async function convertTAPToTeamCity() {
if (!isTeamCity) {
// Just pass through if not in TeamCity
const proc = Bun.spawn(["bun", "test", ...process.argv.slice(2)], {
stdout: "inherit",
stderr: "inherit",
});
const exitCode = await proc.exited;
process.exit(exitCode);
return;
}
const proc = Bun.spawn(["bun", "test", "--reporter=tap", ...process.argv.slice(2)], {
stdout: "pipe",
stderr: "pipe",
});
const decoder = new TextDecoder();
let currentSuite = "Tests";
let buffer = "";
let testCount = 0;
TeamcityReporter.reportSuiteStart(currentSuite);
// Read TAP output
for await (const chunk of proc.stdout) {
const text = decoder.decode(chunk);
buffer += text;
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
console.log(line); // Pass through original output
// Parse TAP format
// ok 1 - test description
// not ok 2 - failed test
// # describe block
if (line.startsWith("# ")) {
// Suite/describe block
const suiteName = line.substring(2).trim();
if (suiteName && !suiteName.startsWith("tests") && !suiteName.startsWith("pass")) {
if (testCount > 0) {
TeamcityReporter.reportSuiteEnd(currentSuite);
}
currentSuite = suiteName;
testCount = 0;
TeamcityReporter.reportSuiteStart(currentSuite);
}
} else if (line.match(/^(not )?ok \d+/)) {
// Test result
const match = line.match(/^(not )?ok (\d+)\s*-?\s*(.*)$/);
if (match) {
const [, not, id, description] = match;
const testName = description.trim() || `Test ${id}`;
const fullName = `${currentSuite} > ${testName}`;
testCount++;
TeamcityReporter.reportTestStart(fullName);
if (not) {
// Test failed
TeamcityReporter.reportTestFailed(
fullName,
"Test failed",
line,
{ comparisonFailure: false, expected: "", actual: "" }
);
}
TeamcityReporter.reportTestEnd(fullName);
}
}
}
}
// Process remaining buffer
if (buffer) {
console.log(buffer);
}
const exitCode = await proc.exited;
TeamcityReporter.reportSuiteEnd(currentSuite);
process.exit(exitCode);
}
convertTAPToTeamCity().catch((error) => {
console.error("Error:", error);
process.exit(1);
});

109
scripts/test-teamcity.ts Executable file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env bun
import { TeamcityReporter } from "../tests/TeamcityReporter.ts";
const isTeamCity = process.env.TEAMCITY_VERSION !== undefined;
// Strip ANSI color codes
function stripAnsi(str: string): string {
return str.replace(/\x1b\[[0-9;]*m/g, "");
}
async function runTests() {
if (!isTeamCity) {
// Just pass through if not in TeamCity
const proc = Bun.spawn(["bun", "test", ...process.argv.slice(2)], {
stdout: "inherit",
stderr: "inherit",
env: { ...process.env },
});
const exitCode = await proc.exited;
process.exit(exitCode);
return;
}
console.error("TeamCity reporter enabled");
// Run tests and capture output
const proc = Bun.spawn(["bun", "test", ...process.argv.slice(2)], {
stdout: "pipe",
stderr: "pipe",
env: { ...process.env },
});
const suiteStack: string[] = [];
// Read stdout as text
const output = await new Response(proc.stdout).text();
const lines = output.split("\n");
for (const rawLine of lines) {
// Pass through original output (with colors)
console.log(rawLine);
// Parse without colors
const line = stripAnsi(rawLine).trim();
// Check for test suite/describe blocks (files ending with .test.ts)
if (line.match(/tests\/.+\.test\.ts:$/)) {
const fileName = line.replace(":", "").replace("tests/", "").replace(".test.ts", "");
// End previous suite if exists
while (suiteStack.length > 0) {
const oldSuite = suiteStack.pop();
if (oldSuite) {
TeamcityReporter.reportSuiteEnd(oldSuite);
}
}
suiteStack.push(fileName);
TeamcityReporter.reportSuiteStart(fileName);
}
// Check for test pass: (pass) HashMap > constructor > should create an empty map...
const passMatch = line.match(/^\(pass\)\s+(.+?)(?:\s+\[[\d.]+m?s\])?$/);
if (passMatch) {
const testName = passMatch[1].trim();
TeamcityReporter.reportTestStart(testName);
TeamcityReporter.reportTestEnd(testName);
}
// Check for test fail: (fail) HashMap > constructor > should...
const failMatch = line.match(/^\(fail\)\s+(.+?)(?:\s+\[[\d.]+m?s\])?$/);
if (failMatch) {
const testName = failMatch[1].trim();
TeamcityReporter.reportTestStart(testName);
TeamcityReporter.reportTestFailed(
testName,
"Test failed",
line,
{ comparisonFailure: false, expected: "", actual: "" }
);
TeamcityReporter.reportTestEnd(testName);
}
}
// Capture stderr
const errorOutput = await new Response(proc.stderr).text();
if (errorOutput) {
console.error(errorOutput);
}
const exitCode = await proc.exited;
// Close all open suites
while (suiteStack.length > 0) {
const suite = suiteStack.pop();
if (suite) {
TeamcityReporter.reportSuiteEnd(suite);
}
}
console.error("TeamCity reporter finished");
process.exit(exitCode);
}
runTests().catch((error) => {
console.error("Error running tests:", error);
process.exit(1);
});

60
tests/TeamcityReporter.ts Normal file
View File

@@ -0,0 +1,60 @@
export class TeamcityReporter {
/**
* Escape special characters for TeamCity service messages
* https://www.jetbrains.com/help/teamcity/service-messages.html#Escaped+values
*/
private static escape(str: string): string {
return str
.replace(/\|/g, "||")
.replace(/'/g, "|'")
.replace(/\n/g, "|n")
.replace(/\r/g, "|r")
.replace(/\[/g, "|[")
.replace(/]/g, "|]");
}
public static reportSuiteStart(suiteName: string): void {
console.log(`##teamcity[testSuiteStarted name='${this.escape(suiteName)}']`);
}
public static reportSuiteEnd(suiteName: string): void {
console.log(`##teamcity[testSuiteFinished name='${this.escape(suiteName)}']`);
}
public static reportTestStart(testName: string): void {
console.log(`##teamcity[testStarted name='${this.escape(testName)}']`);
}
public static reportTestFailed(testName: string, failureMessage: string, details: string, {comparisonFailure, expected, actual}: {comparisonFailure: boolean, expected: string, actual: string}): void {
const attrs = [
`name='${this.escape(testName)}'`,
`message='${this.escape(failureMessage)}'`,
`details='${this.escape(details)}'`
];
if (comparisonFailure) {
attrs.push(`type='comparisonFailure'`);
attrs.push(`expected='${this.escape(expected)}'`);
attrs.push(`actual='${this.escape(actual)}'`);
}
console.log(`##teamcity[testFailed ${attrs.join(' ')}]`);
}
public static reportTestEnd(testName: string, duration?: number): void {
const durationAttr = duration !== undefined ? ` duration='${duration}'` : '';
console.log(`##teamcity[testFinished name='${this.escape(testName)}'${durationAttr}]`);
}
public static reportTestError(testName: string, error: Error): void {
const message = this.escape(error.message || 'Unknown error');
const details = this.escape(error.stack || '');
console.log(`##teamcity[testFailed name='${this.escape(testName)}' message='${message}' details='${details}']`);
}
public static reportTestIgnored(testName: string, message?: string): void {
const msgAttr = message ? ` message='${this.escape(message)}'` : '';
console.log(`##teamcity[testIgnored name='${this.escape(testName)}'${msgAttr}]`);
}
}

55
tests/setup.ts Normal file
View File

@@ -0,0 +1,55 @@
import { TeamcityReporter } from "./TeamcityReporter.ts";
// Check if running in TeamCity environment
const isTeamCity = process.env.TEAMCITY_VERSION !== undefined;
// Override console methods to capture test output if needed
if (isTeamCity) {
// You can add global beforeAll/afterAll if needed
console.log("TeamCity reporter enabled");
}
// Export wrapper functions for test suites
export function setupTeamCityReporting() {
if (!isTeamCity) {
return {
reportSuiteStart: () => {},
reportSuiteEnd: () => {},
reportTestStart: () => {},
reportTestEnd: () => {},
reportTestFailed: () => {},
reportTestError: () => {},
};
}
return {
reportSuiteStart: (name: string) => {
TeamcityReporter.reportSuiteStart(name);
},
reportSuiteEnd: (name: string) => {
TeamcityReporter.reportSuiteEnd(name);
},
reportTestStart: (name: string) => {
TeamcityReporter.reportTestStart(name);
},
reportTestEnd: (name: string) => {
TeamcityReporter.reportTestEnd(name);
},
reportTestFailed: (
name: string,
message: string,
details: string,
comparison?: { expected: string; actual: string }
) => {
TeamcityReporter.reportTestFailed(name, message, details, {
comparisonFailure: !!comparison,
expected: comparison?.expected || "",
actual: comparison?.actual || "",
});
},
reportTestError: (name: string, error: Error) => {
TeamcityReporter.reportTestError(name, error);
},
};
}