From 3a58ab03d70ad4b5ab2065895f30ad8099a15190 Mon Sep 17 00:00:00 2001 From: Alexander Zinn Date: Sat, 22 Nov 2025 22:42:54 -0500 Subject: [PATCH] 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. --- docs/TEAMCITY.md | 110 +++++++++++++++++++++++++++++++++++ package.json | 3 +- scripts/test-tap-teamcity.ts | 99 +++++++++++++++++++++++++++++++ scripts/test-teamcity.ts | 109 ++++++++++++++++++++++++++++++++++ tests/TeamcityReporter.ts | 60 +++++++++++++++++++ tests/setup.ts | 55 ++++++++++++++++++ 6 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 docs/TEAMCITY.md create mode 100755 scripts/test-tap-teamcity.ts create mode 100755 scripts/test-teamcity.ts create mode 100644 tests/TeamcityReporter.ts create mode 100644 tests/setup.ts diff --git a/docs/TEAMCITY.md b/docs/TEAMCITY.md new file mode 100644 index 0000000..95c6f97 --- /dev/null +++ b/docs/TEAMCITY.md @@ -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'] +``` + diff --git a/package.json b/package.json index 775410b..f710e3f 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "scripts": { "ci-install": "bun install", - "ci-test": "bun test", + "ci-test": "bun run test:teamcity", "ci-build": "bash scripts/ci-build.sh", "ci-deploy:ga": "bash scripts/ci-deploy.sh --beta false", "ci-deploy:beta": "bash scripts/ci-deploy.sh --beta true", @@ -33,6 +33,7 @@ "lint:fix": "eslint --fix", "test": "bun test", "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: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", diff --git a/scripts/test-tap-teamcity.ts b/scripts/test-tap-teamcity.ts new file mode 100755 index 0000000..23b132a --- /dev/null +++ b/scripts/test-tap-teamcity.ts @@ -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); +}); + diff --git a/scripts/test-teamcity.ts b/scripts/test-teamcity.ts new file mode 100755 index 0000000..29981ba --- /dev/null +++ b/scripts/test-teamcity.ts @@ -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); +}); diff --git a/tests/TeamcityReporter.ts b/tests/TeamcityReporter.ts new file mode 100644 index 0000000..0654b4d --- /dev/null +++ b/tests/TeamcityReporter.ts @@ -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}]`); + } +} \ No newline at end of file diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..1451bb5 --- /dev/null +++ b/tests/setup.ts @@ -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); + }, + }; +} +