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:
110
docs/TEAMCITY.md
Normal file
110
docs/TEAMCITY.md
Normal 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']
|
||||||
|
```
|
||||||
|
|
||||||
@@ -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
99
scripts/test-tap-teamcity.ts
Executable 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
109
scripts/test-teamcity.ts
Executable 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
60
tests/TeamcityReporter.ts
Normal 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
55
tests/setup.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user