Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

ctrf

Package Overview
Dependencies
Maintainers
1
Versions
34
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

ctrf - npm Package Compare versions

Comparing version
0.0.16-next-0
to
0.0.16
+14
dist/runtime/facade.d.ts
/**
* CTRF framework-agnostic facade functions
* These functions call into the global runtime that's set by framework-specific packages
* Simplified API focusing on test.extra enrichment
*/
/**
* Test-related CTRF functions
*/
export declare const test: {
/**
* Add extra metadata to a test
*/
addExtra: (key: string, value: unknown) => Promise<void>;
};
/**
* CTRF framework-agnostic facade functions
* These functions call into the global runtime that's set by framework-specific packages
* Simplified API focusing on test.extra enrichment
*/
import { getGlobalCtrfRuntimeWithAutoconfig } from './runtime.js';
const callRuntimeMethod = (method, ...args) => {
const runtime = getGlobalCtrfRuntimeWithAutoconfig();
if (runtime && typeof runtime.then !== 'function') {
// @ts-ignore
return runtime[method](...args);
}
return runtime.then((ctrfRuntime) => {
// @ts-ignore
return ctrfRuntime[method](...args);
});
};
/**
* Test-related CTRF functions
*/
export const test = {
/**
* Add extra metadata to a test
*/
addExtra: (key, value) => {
return callRuntimeMethod('addExtra', key, value);
}
};
+0
-8

@@ -42,10 +42,2 @@ /**

/**
* @group Runtime
*/
export { CtrfRuntime, ctrfRuntime, ctrf, runtime, mergeTestData, } from './runtime/index.js';
/**
* @group Runtime
*/
export type { TestContext } from './runtime/index.js';
/**
* @group Schema

@@ -52,0 +44,0 @@ */

+0
-4

@@ -41,5 +41,1 @@ /**

export { setTestId, getTestId, setTestIdsForReport, findTestById, generateTestIdFromProperties, CTRF_NAMESPACE, } from './methods/test-id.js';
/**
* @group Runtime
*/
export { CtrfRuntime, ctrfRuntime, ctrf, runtime, mergeTestData, } from './runtime/index.js';

@@ -44,6 +44,7 @@ // This should be added to library, as not standard CTRF thing

result,
tests: summary.skipped,
tests: summary.tests,
passed: summary.passed,
failed: summary.failed,
skipped: summary.skipped,
pending: summary.pending,
flaky: flakyCount,

@@ -50,0 +51,0 @@ other: summary.other,

@@ -1,10 +0,3 @@

/**
* CTRF Runtime Module
*
* Core runtime library for CTRF reporters.
* Provides AsyncLocalStorage-based context management for test metadata.
* 100% framework-agnostic.
*/
export { CtrfRuntime, ctrfRuntime, mergeTestData } from './runtime.js';
export { ctrf, runtime } from './api.js';
export type { TestContext } from './runtime.js';
export type { CtrfRuntime } from './runtime.js';
export { setGlobalCtrfRuntime, getGlobalCtrfRuntime, getGlobalCtrfRuntimeWithAutoconfig } from './runtime.js';
export { test } from './facade.js';

@@ -1,9 +0,2 @@

/**
* CTRF Runtime Module
*
* Core runtime library for CTRF reporters.
* Provides AsyncLocalStorage-based context management for test metadata.
* 100% framework-agnostic.
*/
export { CtrfRuntime, ctrfRuntime, mergeTestData } from './runtime.js';
export { ctrf, runtime } from './api.js';
export { setGlobalCtrfRuntime, getGlobalCtrfRuntime, getGlobalCtrfRuntimeWithAutoconfig } from './runtime.js';
export { test } from './facade.js';
/**
* Test context data structure returned by runtime operations.
*
* Fields are provided for enrichment only. Reporter implementations must
* treat their framework data as authoritative for all non-extra fields.
*
* @example
* ```typescript
* const context: TestContext = {
* name: 'should authenticate user',
* suite: ['auth', 'integration'],
* filePath: '/tests/auth.spec.ts',
* id: 'auth-001',
* extra: { priority: 'high', tags: ['smoke'] }
* }
* ```
* CTRF runtime system
*/
export interface TestContext {
/** Test identifier - framework name takes precedence */
name: string;
/** Suite hierarchy array - framework suite takes precedence */
suite?: string[];
/** Source file path - framework filePath takes precedence */
filePath?: string;
/** Unique test identifier for cross-referencing */
id?: string;
/** User-defined metadata for report enrichment */
extra?: Record<string, unknown>;
}
/**
* AsyncLocalStorage-based test context manager.
*
* Provides isolated execution contexts for concurrent test scenarios using
* Node.js AsyncLocalStorage. Context data persists across async boundaries
* within the same logical test execution.
*
* @example
* ```typescript
* const runtime = new CtrfRuntime()
* runtime.startTestContext('test-name')
* runtime.addExtra('key', 'value')
* const context = runtime.endTestContext()
* ```
* Interface that framework-specific runtimes must implement
*/
export declare class CtrfRuntime {
private storage;
/**
* Creates new AsyncLocalStorage context and enters it synchronously.
*
* @param name - Test identifier string
* @param options - Optional context metadata
* @param options.suite - Suite hierarchy array
* @param options.filePath - Absolute path to test file
* @param options.id - Unique test identifier
*
* @throws None - always succeeds
*
* @example
* ```typescript
* runtime.startTestContext('user login test', {
* suite: ['auth', 'integration'],
* filePath: '/tests/auth.spec.ts',
* id: 'declarative-uuid'
* })
* ```
*/
startTestContext(name: string, options?: {
suite?: string[];
filePath?: string;
id?: string;
}): void;
/**
* Adds key-value pair to current context's extra object.
*
* Mutates the active context's extra property. Creates extra object if
* it doesn't exist. All values must be JSON-serializable.
*
* @param key - Property key for metadata
* @param value - JSON-serializable value
*
* @throws {Error} When no active AsyncLocalStorage context exists
*
* @example
* ```typescript
* runtime.addExtra('priority', 'high')
* runtime.addExtra('tags', ['smoke', 'regression'])
* runtime.addExtra('metadata', { browser: 'chrome', viewport: '1920x1080' })
* ```
*/
addExtra(key: string, value: unknown): void;
/**
* Returns shallow copy of current context and exits AsyncLocalStorage scope.
*
* Context data is intended for enrichment only. Reporter implementations
* should merge only the `extra` field with their test data.
*
* @returns Shallow copy of TestContext or undefined if no active context
*
* @example
* ```typescript
* const context = runtime.endTestContext()
* if (context) {
* const enriched = { ...reporterData, extra: { ...context.extra, ...reporterData.extra } }
* }
* ```
*/
endTestContext(): TestContext | undefined;
/**
* Returns current AsyncLocalStorage context without modifying it.
*
* @returns Current TestContext or undefined if no active context
*
* @example
* ```typescript
* const current = runtime.getCurrentContext()
* if (current?.extra?.skipCleanup) {
* // conditional logic based on context
* }
* ```
*/
getCurrentContext(): TestContext | undefined;
/**
* Checks for active AsyncLocalStorage context existence.
*
* @returns true if context exists, false otherwise
*
* @example
* ```typescript
* if (runtime.hasActiveContext()) {
* runtime.addExtra('key', 'value')
* }
* ```
*/
hasActiveContext(): boolean;
export interface CtrfRuntime {
addExtra(key: string, value: unknown): Promise<void>;
}
/**
* Singleton CtrfRuntime instance for global access.
*
* @example
* ```typescript
* import { ctrfRuntime } from 'ctrf'
* ctrfRuntime.startTestContext('test')
* ```
* Set the global CTRF runtime (called by framework-specific packages)
*/
export declare const ctrfRuntime: CtrfRuntime;
export declare const setGlobalCtrfRuntime: (runtime: CtrfRuntime) => void;
/**
* Merges reporter test data with runtime context metadata.
*
* Reporter data takes precedence. Runtime context enriches only the `extra`
* field. In case of key conflicts in `extra`, reporter values win.
*
* @param reporterTest - Test data from framework reporter (source of truth)
* @param runtimeContext - Context from ctrfRuntime.endTestContext()
* @returns Merged test object with enriched extra metadata
*
* @example
* ```typescript
* const merged = mergeTestData(
* { name: 'test', status: 'passed', extra: { framework: 'data' } },
* { name: 'test', extra: { runtime: 'metadata', framework: 'ignored' } }
* )
* // Result: { name: 'test', status: 'passed', extra: { runtime: 'metadata', framework: 'data' } }
* ```
* Get the current global CTRF runtime
*/
export declare function mergeTestData<T extends Record<string, any>>(reporterTest: T, runtimeContext: TestContext | undefined): T;
export declare const getGlobalCtrfRuntime: () => CtrfRuntime;
/**
* Get the global CTRF runtime with auto-configuration attempt
* This tries to auto-detect and configure framework-specific runtimes
*/
export declare const getGlobalCtrfRuntimeWithAutoconfig: () => CtrfRuntime | Promise<CtrfRuntime>;
export { test } from './facade.js';

@@ -1,173 +0,51 @@

import { AsyncLocalStorage } from 'node:async_hooks';
/**
* AsyncLocalStorage-based test context manager.
*
* Provides isolated execution contexts for concurrent test scenarios using
* Node.js AsyncLocalStorage. Context data persists across async boundaries
* within the same logical test execution.
*
* @example
* ```typescript
* const runtime = new CtrfRuntime()
* runtime.startTestContext('test-name')
* runtime.addExtra('key', 'value')
* const context = runtime.endTestContext()
* ```
* CTRF runtime system
*/
export class CtrfRuntime {
storage = new AsyncLocalStorage();
/**
* Creates new AsyncLocalStorage context and enters it synchronously.
*
* @param name - Test identifier string
* @param options - Optional context metadata
* @param options.suite - Suite hierarchy array
* @param options.filePath - Absolute path to test file
* @param options.id - Unique test identifier
*
* @throws None - always succeeds
*
* @example
* ```typescript
* runtime.startTestContext('user login test', {
* suite: ['auth', 'integration'],
* filePath: '/tests/auth.spec.ts',
* id: 'declarative-uuid'
* })
* ```
*/
startTestContext(name, options) {
const testData = {
name,
extra: {},
...(options?.suite && { suite: options.suite }),
...(options?.filePath && { filePath: options.filePath }),
...(options?.id && { id: options.id }),
};
this.storage.enterWith(testData);
/**
* No-op runtime that logs warnings when no framework runtime is available
*/
class NoopCtrfRuntime {
warn(method) {
console.warn(`CTRF: ${method} called but no framework runtime is available. Make sure you have configured a CTRF reporter for your test framework.`);
}
/**
* Adds key-value pair to current context's extra object.
*
* Mutates the active context's extra property. Creates extra object if
* it doesn't exist. All values must be JSON-serializable.
*
* @param key - Property key for metadata
* @param value - JSON-serializable value
*
* @throws {Error} When no active AsyncLocalStorage context exists
*
* @example
* ```typescript
* runtime.addExtra('priority', 'high')
* runtime.addExtra('tags', ['smoke', 'regression'])
* runtime.addExtra('metadata', { browser: 'chrome', viewport: '1920x1080' })
* ```
*/
addExtra(key, value) {
const ctx = this.storage.getStore();
if (!ctx) {
throw new Error('No active CTRF test context. Call startTestContext() first.');
}
if (!ctx.extra) {
ctx.extra = {};
}
ctx.extra[key] = value;
async addExtra() {
this.warn('addExtra');
}
/**
* Returns shallow copy of current context and exits AsyncLocalStorage scope.
*
* Context data is intended for enrichment only. Reporter implementations
* should merge only the `extra` field with their test data.
*
* @returns Shallow copy of TestContext or undefined if no active context
*
* @example
* ```typescript
* const context = runtime.endTestContext()
* if (context) {
* const enriched = { ...reporterData, extra: { ...context.extra, ...reporterData.extra } }
* }
* ```
*/
endTestContext() {
const ctx = this.storage.getStore();
if (!ctx) {
return undefined;
}
// Return a copy of the context data for the reporter to use
return { ...ctx };
}
/**
* Returns current AsyncLocalStorage context without modifying it.
*
* @returns Current TestContext or undefined if no active context
*
* @example
* ```typescript
* const current = runtime.getCurrentContext()
* if (current?.extra?.skipCleanup) {
* // conditional logic based on context
* }
* ```
*/
getCurrentContext() {
return this.storage.getStore();
}
/**
* Checks for active AsyncLocalStorage context existence.
*
* @returns true if context exists, false otherwise
*
* @example
* ```typescript
* if (runtime.hasActiveContext()) {
* runtime.addExtra('key', 'value')
* }
* ```
*/
hasActiveContext() {
return this.storage.getStore() !== undefined;
}
}
const CTRF_RUNTIME_KEY = 'ctrfRuntime';
const noopRuntime = new NoopCtrfRuntime();
/**
* Singleton CtrfRuntime instance for global access.
*
* @example
* ```typescript
* import { ctrfRuntime } from 'ctrf'
* ctrfRuntime.startTestContext('test')
* ```
* Set the global CTRF runtime (called by framework-specific packages)
*/
export const ctrfRuntime = new CtrfRuntime();
export const setGlobalCtrfRuntime = (runtime) => {
;
globalThis[CTRF_RUNTIME_KEY] = runtime;
};
/**
* Merges reporter test data with runtime context metadata.
*
* Reporter data takes precedence. Runtime context enriches only the `extra`
* field. In case of key conflicts in `extra`, reporter values win.
*
* @param reporterTest - Test data from framework reporter (source of truth)
* @param runtimeContext - Context from ctrfRuntime.endTestContext()
* @returns Merged test object with enriched extra metadata
*
* @example
* ```typescript
* const merged = mergeTestData(
* { name: 'test', status: 'passed', extra: { framework: 'data' } },
* { name: 'test', extra: { runtime: 'metadata', framework: 'ignored' } }
* )
* // Result: { name: 'test', status: 'passed', extra: { runtime: 'metadata', framework: 'data' } }
* ```
* Get the current global CTRF runtime
*/
export function mergeTestData(reporterTest, runtimeContext) {
if (!runtimeContext?.extra) {
return reporterTest;
export const getGlobalCtrfRuntime = () => {
const runtime = globalThis?.[CTRF_RUNTIME_KEY];
return runtime ?? noopRuntime;
};
/**
* Get the global CTRF runtime with auto-configuration attempt
* This tries to auto-detect and configure framework-specific runtimes
*/
export const getGlobalCtrfRuntimeWithAutoconfig = () => {
const runtime = getGlobalCtrfRuntime();
if (runtime !== noopRuntime) {
return runtime;
}
return {
...reporterTest,
extra: {
...(runtimeContext.extra || {}), // Runtime extra as base
...(reporterTest.extra || {}), // Reporter extra wins conflicts
},
};
}
// protection from bundlers tree-shaking visiting (webpack, rollup)
const pwAutoconfigModuleName = 'playwright-ctrf-json-reporter/autoconfig';
return import(pwAutoconfigModuleName)
.then(() => {
return getGlobalCtrfRuntime();
})
.catch(() => {
return noopRuntime;
});
};
// Export facade functions (framework-agnostic API)
export { test } from './facade.js';
{
"name": "ctrf",
"version": "0.0.16-next-0",
"version": "0.0.16",
"description": "Common library for working with CTRF reports",

@@ -5,0 +5,0 @@ "type": "module",

import type { Test, TestStatus, Summary } from '../../types/ctrf.js';
/**
* Tree test extends CTRF Test with a nodeType field for tree traversal
*/
export type TreeTest = Test & {
/** Node type identifier - always "test" for tree traversal */
nodeType: 'test';
};
/**
* Represents a tree node (suite) that can contain tests and child suites
* Following the CTRF Suite Tree schema specification
*/
export interface TreeNode {
/** The name of this suite */
name: string;
/** The status of this suite (derived from child test results) */
status: TestStatus;
/** Total duration of all tests in this suite and children */
duration: number;
/** Aggregated statistics for this suite (only present when includeSummary is true) */
summary?: Summary;
/** Tests directly contained in this suite */
tests: TreeTest[];
/** Child suites contained within this suite */
suites: TreeNode[];
/** Additional properties */
extra?: Record<string, unknown>;
}
/**
* Options for controlling tree structure creation
*/
export interface TreeOptions {
/** Whether to include summary statistics aggregation (default: true) */
includeSummary?: boolean;
}
/**
* Result of converting tests to tree structure
*/
export interface TestTree {
/** Root nodes of the tree (top-level suites) */
roots: TreeNode[];
/** Overall statistics for all tests (only present when includeSummary is true) */
summary?: Summary;
}
/**
* Organizes CTRF tests into a hierarchical tree structure based on the suite property.
*
* The function handles array format (['suite1', 'suite2', 'suite3']) for the suite property
* as defined in the CTRF schema. The output follows the CTRF Suite Tree schema specification.
*
* @param tests - Array of CTRF test objects
* @param options - Options for controlling tree creation
* @returns TestTree object containing the hierarchical structure and statistics
*
* @example
* ```typescript
* import { organizeTestsBySuite } from 'ctrf-js-common'
*
* const tests = [
* {
* name: 'should login successfully',
* status: 'passed',
* duration: 150,
* suite: ['Authentication', 'Login']
* },
* {
* name: 'should logout successfully',
* status: 'passed',
* duration: 100,
* suite: ['Authentication', 'Logout']
* }
* ]
*
* const tree = organizeTestsBySuite(tests)
*
* // For structure-only without summary statistics:
* // const tree = organizeTestsBySuite(tests, { includeSummary: false })
*
* // Convert to JSON for machine consumption
* const treeJson = JSON.stringify(tree, null, 2)
*
* console.log(tree.roots[0].name) // 'Authentication'
* console.log(tree.roots[0].suites.length) // 2 (Login, Logout)
* ```
*/
export declare function organizeTestsBySuite(tests: Test[], options?: TreeOptions): TestTree;
/**
* Utility function to traverse the tree and apply a function to each node
*
* @param nodes - Array of tree nodes to traverse
* @param callback - Function to call for each node (suites and tests)
* @param depth - Current depth in the tree (starts at 0)
*/
export declare function traverseTree(nodes: TreeNode[], callback: (node: TreeNode | TreeTest, depth: number, nodeType: 'suite' | 'test') => void, depth?: number): void;
/**
* Utility function to find a suite by name in the tree
*
* @param nodes - Array of tree nodes to search
* @param name - Name of the suite to find
* @returns The found suite node or undefined
*/
export declare function findSuiteByName(nodes: TreeNode[], name: string): TreeNode | undefined;
/**
* Utility function to find a test by name in the tree
*
* @param nodes - Array of tree nodes to search
* @param name - Name of the test to find
* @returns The found test or undefined
*/
export declare function findTestByName(nodes: TreeNode[], name: string): TreeTest | undefined;
/**
* Utility function to convert tree to a flat array with indentation information
* Useful for displaying the tree in a linear format
*
* @param nodes - Array of tree nodes to flatten
* @returns Array of objects containing node, depth, and nodeType information
*/
export declare function flattenTree(nodes: TreeNode[]): Array<{
node: TreeNode | TreeTest;
depth: number;
nodeType: 'suite' | 'test';
}>;
/**
* Utility function to get all tests from the tree structure as a flat array
*
* @param nodes - Array of tree nodes to extract tests from
* @returns Array of all tests in the tree
*/
export declare function getAllTests(nodes: TreeNode[]): TreeTest[];
/**
* Utility function to get statistics for a specific suite path
*
* @param nodes - Array of tree nodes to search
* @param suitePath - Array representing the path to the suite
* @returns Summary statistics for the suite or undefined if not found
*/
export declare function getSuiteStats(nodes: TreeNode[], suitePath: string[]): Summary | undefined;
import { isTestFlaky } from './run-insights.js';
/**
* Organizes CTRF tests into a hierarchical tree structure based on the suite property.
*
* The function handles array format (['suite1', 'suite2', 'suite3']) for the suite property
* as defined in the CTRF schema. The output follows the CTRF Suite Tree schema specification.
*
* @param tests - Array of CTRF test objects
* @param options - Options for controlling tree creation
* @returns TestTree object containing the hierarchical structure and statistics
*
* @example
* ```typescript
* import { organizeTestsBySuite } from 'ctrf-js-common'
*
* const tests = [
* {
* name: 'should login successfully',
* status: 'passed',
* duration: 150,
* suite: ['Authentication', 'Login']
* },
* {
* name: 'should logout successfully',
* status: 'passed',
* duration: 100,
* suite: ['Authentication', 'Logout']
* }
* ]
*
* const tree = organizeTestsBySuite(tests)
*
* // For structure-only without summary statistics:
* // const tree = organizeTestsBySuite(tests, { includeSummary: false })
*
* // Convert to JSON for machine consumption
* const treeJson = JSON.stringify(tree, null, 2)
*
* console.log(tree.roots[0].name) // 'Authentication'
* console.log(tree.roots[0].suites.length) // 2 (Login, Logout)
* ```
*/
export function organizeTestsBySuite(tests, options = {}) {
const { includeSummary = true } = options;
const nodeMap = new Map();
const rootNodes = new Map();
const createEmptySummary = () => ({
tests: 0,
passed: 0,
failed: 0,
skipped: 0,
pending: 0,
other: 0,
flaky: 0,
start: 0,
stop: 0,
duration: 0
});
const createTreeTest = (test) => ({
...test,
nodeType: 'test',
id: test.id || crypto.randomUUID()
});
const parseSuitePath = (suite) => {
if (!suite)
return [];
if (Array.isArray(suite)) {
return suite.filter(s => s && s.trim().length > 0);
}
return [];
};
const calculateSuiteStatus = (summary) => {
if (summary.tests === 0)
return 'other';
if (summary.failed > 0)
return 'failed';
if (summary.pending > 0)
return 'pending';
if (summary.skipped === summary.tests)
return 'skipped';
if (summary.passed === summary.tests)
return 'passed';
return 'other';
};
const getOrCreateSuite = (path) => {
const fullPath = path.join('/');
if (nodeMap.has(fullPath)) {
return nodeMap.get(fullPath);
}
const name = path[path.length - 1];
const node = {
name,
status: 'other',
duration: 0,
tests: [],
suites: []
};
if (includeSummary) {
node.summary = createEmptySummary();
}
nodeMap.set(fullPath, node);
if (path.length === 1) {
rootNodes.set(name, node);
}
else {
const parentPath = path.slice(0, -1);
const parent = getOrCreateSuite(parentPath);
parent.suites.push(node);
}
return node;
};
for (const test of tests) {
const suitePath = parseSuitePath(test.suite);
const treeTest = createTreeTest(test);
if (suitePath.length === 0) {
const testNode = {
name: treeTest.name,
status: treeTest.status,
duration: treeTest.duration,
tests: [treeTest],
suites: []
};
if (includeSummary) {
testNode.summary = createEmptySummary();
}
rootNodes.set(treeTest.name, testNode);
nodeMap.set(treeTest.name, testNode);
}
else {
const parentSuite = getOrCreateSuite(suitePath);
parentSuite.tests.push(treeTest);
}
}
if (includeSummary) {
const aggregateStats = (node) => {
if (!node.summary) {
node.summary = createEmptySummary();
}
for (const test of node.tests) {
node.summary.tests++;
node.summary.duration = (node.summary.duration || 0) + test.duration;
if (isTestFlaky(test)) {
node.summary.flaky = (node.summary.flaky || 0) + 1;
}
switch (test.status) {
case 'passed':
node.summary.passed++;
break;
case 'failed':
node.summary.failed++;
break;
case 'skipped':
node.summary.skipped++;
break;
case 'pending':
node.summary.pending++;
break;
case 'other':
node.summary.other++;
break;
}
}
for (const suite of node.suites) {
aggregateStats(suite);
if (suite.summary) {
node.summary.tests += suite.summary.tests;
node.summary.passed += suite.summary.passed;
node.summary.failed += suite.summary.failed;
node.summary.skipped += suite.summary.skipped;
node.summary.pending += suite.summary.pending;
node.summary.other += suite.summary.other;
node.summary.flaky = (node.summary.flaky || 0) + (suite.summary.flaky || 0);
node.summary.duration = (node.summary.duration || 0) + (suite.summary.duration || 0);
}
}
node.duration = node.summary.duration || 0;
node.status = calculateSuiteStatus(node.summary);
};
for (const rootNode of rootNodes.values()) {
aggregateStats(rootNode);
}
}
else {
const setDefaultValues = (node) => {
node.status = 'other';
node.duration = 0;
for (const suite of node.suites) {
setDefaultValues(suite);
}
};
for (const rootNode of rootNodes.values()) {
setDefaultValues(rootNode);
}
}
if (includeSummary) {
const overallSummary = createEmptySummary();
for (const rootNode of rootNodes.values()) {
if (rootNode.summary) {
overallSummary.tests += rootNode.summary.tests;
overallSummary.passed += rootNode.summary.passed;
overallSummary.failed += rootNode.summary.failed;
overallSummary.skipped += rootNode.summary.skipped;
overallSummary.pending += rootNode.summary.pending;
overallSummary.other += rootNode.summary.other;
overallSummary.flaky = (overallSummary.flaky || 0) + (rootNode.summary.flaky || 0);
overallSummary.duration = (overallSummary.duration || 0) + (rootNode.summary.duration || 0);
}
}
return {
roots: Array.from(rootNodes.values()),
summary: overallSummary
};
}
else {
return {
roots: Array.from(rootNodes.values())
};
}
}
/**
* Utility function to traverse the tree and apply a function to each node
*
* @param nodes - Array of tree nodes to traverse
* @param callback - Function to call for each node (suites and tests)
* @param depth - Current depth in the tree (starts at 0)
*/
export function traverseTree(nodes, callback, depth = 0) {
for (const node of nodes) {
callback(node, depth, 'suite');
if (node.suites.length > 0) {
traverseTree(node.suites, callback, depth + 1);
}
for (const test of node.tests) {
callback(test, depth + 1, 'test');
}
}
}
/**
* Utility function to find a suite by name in the tree
*
* @param nodes - Array of tree nodes to search
* @param name - Name of the suite to find
* @returns The found suite node or undefined
*/
export function findSuiteByName(nodes, name) {
for (const node of nodes) {
if (node.name === name) {
return node;
}
const found = findSuiteByName(node.suites, name);
if (found)
return found;
}
return undefined;
}
/**
* Utility function to find a test by name in the tree
*
* @param nodes - Array of tree nodes to search
* @param name - Name of the test to find
* @returns The found test or undefined
*/
export function findTestByName(nodes, name) {
for (const node of nodes) {
for (const test of node.tests) {
if (test.name === name) {
return test;
}
}
const found = findTestByName(node.suites, name);
if (found)
return found;
}
return undefined;
}
/**
* Utility function to convert tree to a flat array with indentation information
* Useful for displaying the tree in a linear format
*
* @param nodes - Array of tree nodes to flatten
* @returns Array of objects containing node, depth, and nodeType information
*/
export function flattenTree(nodes) {
const result = [];
traverseTree(nodes, (node, depth, nodeType) => {
result.push({ node, depth, nodeType });
});
return result;
}
/**
* Utility function to get all tests from the tree structure as a flat array
*
* @param nodes - Array of tree nodes to extract tests from
* @returns Array of all tests in the tree
*/
export function getAllTests(nodes) {
const tests = [];
traverseTree(nodes, (node, depth, nodeType) => {
if (nodeType === 'test') {
tests.push(node);
}
});
return tests;
}
/**
* Utility function to get statistics for a specific suite path
*
* @param nodes - Array of tree nodes to search
* @param suitePath - Array representing the path to the suite
* @returns Summary statistics for the suite or undefined if not found
*/
export function getSuiteStats(nodes, suitePath) {
let current = nodes;
for (const suiteName of suitePath) {
const found = current.find(node => node.name === suiteName);
if (!found)
return undefined;
if (suitePath.indexOf(suiteName) === suitePath.length - 1) {
return found.summary;
}
current = found.suites;
}
return undefined;
}
import { describe, it, expect } from 'vitest';
import { organizeTestsBySuite, traverseTree, findSuiteByName, findTestByName, flattenTree } from './tree-structure.js';
describe('Tree Structure Functions', () => {
// Sample test data - create test with proper CTRF array suite format
const createTest = (name, status, duration, suite, flaky, id) => ({
id,
name,
status,
duration,
suite,
flaky
});
describe('organizeTestsBySuite', () => {
it('should create a tree structure from tests with array suite paths', () => {
const tests = [
createTest('should login successfully', 'passed', 150, ['Authentication', 'Login'], false, 'test-1'),
createTest('should logout successfully', 'passed', 100, ['Authentication', 'Logout'], false, 'test-2'),
createTest('should handle invalid credentials', 'failed', 200, ['Authentication', 'Login'], false, 'test-3'),
createTest('should validate user permissions', 'passed', 120, ['Authorization', 'Permissions'], false, 'test-4')
];
const tree = organizeTestsBySuite(tests);
expect(tree.roots).toHaveLength(2);
// Check Authentication suite
const authSuite = tree.roots.find(r => r.name === 'Authentication');
expect(authSuite).toBeDefined();
expect(authSuite?.suites).toHaveLength(2); // Login, Logout
expect(authSuite?.summary.tests).toBe(3);
expect(authSuite?.summary.passed).toBe(2);
expect(authSuite?.summary.failed).toBe(1);
expect(authSuite?.summary.duration).toBe(450);
expect(authSuite?.status).toBe('failed'); // Has failed tests
expect(authSuite?.duration).toBe(450);
// Check Login subsuite
const loginSuite = authSuite?.suites?.find(s => s.name === 'Login');
expect(loginSuite).toBeDefined();
expect(loginSuite?.tests).toHaveLength(2);
expect(loginSuite?.summary.tests).toBe(2);
expect(loginSuite?.summary.passed).toBe(1);
expect(loginSuite?.summary.failed).toBe(1);
// Check Authorization suite
const authzSuite = tree.roots.find(r => r.name === 'Authorization');
expect(authzSuite).toBeDefined();
expect(authzSuite?.suites).toHaveLength(1); // Permissions
expect(authzSuite?.summary.tests).toBe(1);
expect(authzSuite?.summary.passed).toBe(1);
expect(authzSuite?.status).toBe('passed'); // All tests passed
});
it('should handle tests with array suite paths', () => {
const tests = [
createTest('test1', 'passed', 100, ['Suite1', 'SubSuite1']),
createTest('test2', 'failed', 200, ['Suite1', 'SubSuite2']),
createTest('test3', 'passed', 150, ['Suite2'])
];
const tree = organizeTestsBySuite(tests);
expect(tree.roots).toHaveLength(2);
const suite1 = tree.roots.find(r => r.name === 'Suite1');
expect(suite1?.suites).toHaveLength(2);
const suite2 = tree.roots.find(r => r.name === 'Suite2');
expect(suite2?.tests).toHaveLength(1);
});
it('should handle tests without suite paths', () => {
const tests = [
createTest('standalone test 1', 'passed', 100, undefined, false, 'test-1'),
createTest('standalone test 2', 'failed', 200, undefined, false, 'test-2'),
createTest('suite test', 'passed', 150, ['Suite1'], false, 'test-3')
];
const tree = organizeTestsBySuite(tests);
expect(tree.roots).toHaveLength(3); // 2 standalone tests + "Suite1"
// Check standalone tests are individual root nodes
const standalone1 = tree.roots.find(r => r.name === 'standalone test 1');
expect(standalone1).toBeDefined();
expect(standalone1?.tests).toHaveLength(1);
expect(standalone1?.tests[0].name).toBe('standalone test 1');
const standalone2 = tree.roots.find(r => r.name === 'standalone test 2');
expect(standalone2).toBeDefined();
expect(standalone2?.tests[0].name).toBe('standalone test 2');
// Check regular suite test
const suite1 = tree.roots.find(r => r.name === 'Suite1');
expect(suite1).toBeDefined();
expect(suite1?.tests).toHaveLength(1);
});
it('should handle flaky test statistics correctly', () => {
const tests = [
createTest('flaky test', 'passed', 100, ['Suite1'], true, 'test-1'),
createTest('normal test', 'passed', 100, ['Suite1'], false, 'test-2'),
createTest('another flaky', 'failed', 200, ['Suite1'], true, 'test-3')
];
const tree = organizeTestsBySuite(tests);
const suite1 = tree.roots.find(r => r.name === 'Suite1');
expect(suite1?.summary.tests).toBe(3);
expect(suite1?.summary.flaky).toBe(2); // Two tests marked as flaky
expect(suite1?.summary.passed).toBe(2);
expect(suite1?.summary.failed).toBe(1);
// Check overall tree summary
expect(tree.summary.tests).toBe(3);
expect(tree.summary.flaky).toBe(2); // Two flaky tests total
});
it('should handle flaky tests with retries correctly', () => {
const tests = [
// Test with retries that passed (considered flaky)
{ ...createTest('retry test', 'passed', 100, ['Suite1'], false, 'test-1'), retries: 2 },
// Test with retries that failed (not considered flaky)
{ ...createTest('failed retry test', 'failed', 100, ['Suite1'], false, 'test-2'), retries: 1 },
// Normal test without retries
createTest('normal test', 'passed', 100, ['Suite1'], false, 'test-3')
];
const tree = organizeTestsBySuite(tests);
const suite1 = tree.roots.find(r => r.name === 'Suite1');
expect(suite1?.summary.tests).toBe(3);
expect(suite1?.summary.flaky).toBe(1); // Only the passed test with retries
expect(suite1?.summary.passed).toBe(2);
expect(suite1?.summary.failed).toBe(1);
expect(tree.summary.flaky).toBe(1);
});
it('should handle malformed suite paths gracefully', () => {
const tests = [
createTest('test1', 'passed', 100, [], false, 'test-1'),
createTest('test2', 'passed', 100, ['', ''], false, 'test-2'),
createTest('test3', 'passed', 100, ['Valid', 'Suite'], false, 'test-3')
];
const tree = organizeTestsBySuite(tests);
// Empty/malformed suites should create standalone tests
expect(tree.roots).toHaveLength(3); // test1 + test2 + "Valid"
const validSuite = tree.roots.find(r => r.name === 'Valid');
expect(validSuite).toBeDefined();
const test1Node = tree.roots.find(r => r.name === 'test1');
expect(test1Node).toBeDefined();
const test2Node = tree.roots.find(r => r.name === 'test2');
expect(test2Node).toBeDefined();
});
it('should handle empty test array', () => {
const tree = organizeTestsBySuite([]);
expect(tree.roots).toHaveLength(0);
expect(tree.summary.tests).toBe(0);
});
it('should disable summary when includeSummary is false', () => {
const tests = [
createTest('test1', 'passed', 100, ['Suite1'], false, 'test-1'),
createTest('test2', 'failed', 200, ['Suite1'], false, 'test-2')
];
const options = { includeSummary: false };
const tree = organizeTestsBySuite(tests, options);
// Tree should not have summary property when summary disabled
expect(tree.summary).toBeUndefined();
const suite1 = tree.roots.find(r => r.name === 'Suite1');
expect(suite1).toBeDefined();
// Suite should not have summary property when summary disabled
expect(suite1?.summary).toBeUndefined();
// But should still have default status and duration
expect(suite1?.status).toBe('other');
expect(suite1?.duration).toBe(0);
});
it('should calculate overall statistics correctly', () => {
const tests = [
createTest('test1', 'passed', 100, ['Suite1'], false, 'test-1'),
createTest('test2', 'failed', 200, ['Suite1'], false, 'test-2'),
createTest('test3', 'skipped', 50, ['Suite2'], false, 'test-3'),
createTest('test4', 'pending', 0, ['Suite2'], false, 'test-4'),
createTest('test5', 'other', 25, ['Suite3'], false, 'test-5')
];
const tree = organizeTestsBySuite(tests);
// Stats are enabled by default, so summary should exist
expect(tree.summary).toBeDefined();
expect(tree.summary.tests).toBe(5);
expect(tree.summary.passed).toBe(1);
expect(tree.summary.failed).toBe(1);
expect(tree.summary.skipped).toBe(1);
expect(tree.summary.pending).toBe(1);
expect(tree.summary.other).toBe(1);
expect(tree.summary.duration).toBe(375);
});
it('should handle deep nesting correctly', () => {
const tests = [
createTest('deep test', 'passed', 100, ['Level1', 'Level2', 'Level3', 'Level4', 'Level5'])
];
const tree = organizeTestsBySuite(tests);
expect(tree.roots).toHaveLength(1);
let current = tree.roots[0];
expect(current.name).toBe('Level1');
for (let i = 2; i <= 5; i++) {
expect(current.suites).toHaveLength(1);
current = current.suites[0];
expect(current.name).toBe(`Level${i}`);
}
expect(current.tests).toHaveLength(1);
expect(current.tests[0].name).toBe('deep test');
});
});
describe('traverseTree', () => {
it('should traverse all nodes in the correct order', () => {
const tests = [
createTest('test1', 'passed', 100, ['Suite1', 'SubSuite1']),
createTest('test2', 'passed', 100, ['Suite1', 'SubSuite2']),
createTest('test3', 'passed', 100, ['Suite2'])
];
const tree = organizeTestsBySuite(tests);
const visitedNodes = [];
traverseTree(tree.roots, (node, depth) => {
visitedNodes.push({ name: node.name, depth });
});
expect(visitedNodes).toContainEqual({ name: 'Suite1', depth: 0 });
expect(visitedNodes).toContainEqual({ name: 'SubSuite1', depth: 1 });
expect(visitedNodes).toContainEqual({ name: 'test1', depth: 2 });
expect(visitedNodes).toContainEqual({ name: 'Suite2', depth: 0 });
expect(visitedNodes).toContainEqual({ name: 'test3', depth: 1 });
});
});
describe('findSuiteByName', () => {
it('should find suite by name in root', () => {
const tests = [
createTest('test1', 'passed', 100, ['Suite1'], false, 'test-1'),
createTest('test2', 'passed', 100, ['Suite2'], false, 'test-2')
];
const tree = organizeTestsBySuite(tests);
const found = findSuiteByName(tree.roots, 'Suite1');
expect(found).toBeDefined();
expect(found?.name).toBe('Suite1');
});
it('should find nested suite by name', () => {
const tests = [
createTest('test1', 'passed', 100, ['Parent', 'Child', 'Grandchild'], false, 'test-1')
];
const tree = organizeTestsBySuite(tests);
const found = findSuiteByName(tree.roots, 'Grandchild');
expect(found).toBeDefined();
expect(found?.name).toBe('Grandchild');
});
it('should return undefined for non-existent suite', () => {
const tests = [
createTest('test1', 'passed', 100, ['Suite1'], false, 'test-1')
];
const tree = organizeTestsBySuite(tests);
const found = findSuiteByName(tree.roots, 'NonExistent');
expect(found).toBeUndefined();
});
});
describe('findTestByName', () => {
it('should find test by name in suite', () => {
const tests = [
createTest('test1', 'passed', 100, ['Suite1'], false, 'test-1'),
createTest('test2', 'passed', 100, ['Suite1'], false, 'test-2')
];
const tree = organizeTestsBySuite(tests);
const found = findTestByName(tree.roots, 'test1');
expect(found).toBeDefined();
expect(found?.name).toBe('test1');
});
it('should find test by name in nested suite', () => {
const tests = [
createTest('nested-test', 'passed', 100, ['Parent', 'Child'], false, 'nested-test')
];
const tree = organizeTestsBySuite(tests);
const found = findTestByName(tree.roots, 'nested-test');
expect(found).toBeDefined();
expect(found?.name).toBe('nested-test');
});
it('should return undefined for non-existent test', () => {
const tests = [
createTest('test1', 'passed', 100, ['Suite1'], false, 'test-1')
];
const tree = organizeTestsBySuite(tests);
const found = findTestByName(tree.roots, 'non-existent-test');
expect(found).toBeUndefined();
});
});
describe('flattenTree', () => {
it('should flatten tree structure with correct depth information', () => {
const tests = [
createTest('test1', 'passed', 100, ['Suite1', 'SubSuite1']),
createTest('test2', 'passed', 100, ['Suite1']),
createTest('standalone', 'passed', 100)
];
const tree = organizeTestsBySuite(tests);
const flattened = flattenTree(tree.roots);
expect(flattened.length).toBeGreaterThan(0);
// Check that depth information is correct
const suite1 = flattened.find(item => item.node.name === 'Suite1');
expect(suite1?.depth).toBe(0);
const subSuite1 = flattened.find(item => item.node.name === 'SubSuite1');
expect(subSuite1?.depth).toBe(1);
const test1 = flattened.find(item => item.node.name === 'test1');
expect(test1?.depth).toBe(2);
const standalone = flattened.find(item => item.node.name === 'standalone');
expect(standalone?.depth).toBe(0);
});
});
describe('Real-world test data compatibility', () => {
it('should work with CTRF array suite format', () => {
const tests = [
createTest("run-insights utility functions isTestFlaky should return true for explicitly flaky tests", "passed", 1, ["run-insights.test.ts", "run-insights utility functions", "isTestFlaky"]),
createTest("enrichReportWithInsights - Main API basic functionality should enrich a report with run-level insights when no previous reports", "passed", 1, ["run-insights.test.ts", "enrichReportWithInsights - Main API", "basic functionality"]),
createTest("read-reports readSingleReport should read and parse a valid CTRF report file", "passed", 1, ["read-reports.test.ts", "read-reports", "readSingleReport"])
];
const tree = organizeTestsBySuite(tests);
expect(tree.roots).toHaveLength(2); // run-insights.test.ts and read-reports.test.ts
const runInsightsFile = tree.roots.find(r => r.name === 'run-insights.test.ts');
expect(runInsightsFile?.suites).toHaveLength(2); // "run-insights utility functions" and "enrichReportWithInsights - Main API"
const utilityFunctions = runInsightsFile?.suites?.find(s => s.name === 'run-insights utility functions');
expect(utilityFunctions?.suites).toHaveLength(1); // "isTestFlaky"
const isTestFlaky = utilityFunctions?.suites?.find(s => s.name === 'isTestFlaky');
expect(isTestFlaky?.tests).toHaveLength(1);
const readReportsFile = tree.roots.find(r => r.name === 'read-reports.test.ts');
expect(readReportsFile?.suites).toHaveLength(1); // "read-reports"
});
});
});
/**
* Lightweight helper API for CTRF runtime operations
*
* Provides simple functions for adding metadata during test execution.
* Framework-agnostic and designed to work with any test runner.
*
* @example
* ```typescript
* import { ctrf } from 'ctrf'
*
* // In your test
* await ctrf.test.addExtra('env', 'staging')
* await ctrf.test.addExtra('buildNumber', '2025.10.001')
* ```
*/
export declare const ctrf: {
/**
* Test-related operations
*/
test: {
/**
* Adds extra metadata to the current test
* @param key The metadata key
* @param value The metadata value
* @example
* ```typescript
* await ctrf.test.addExtra('env', 'staging')
* await ctrf.test.addExtra('buildNumber', '2025.10.001')
* await ctrf.test.addExtra('browser', 'chromium')
* ```
*/
addExtra: (key: string, value: unknown) => Promise<void>;
/**
* Gets the current test context (read-only)
* @returns The current test context or undefined
* @example
* ```typescript
* const context = await ctrf.test.getCurrentContext()
* if (context) {
* console.log('Current test:', context.name)
* }
* ```
*/
getCurrentContext: () => Promise<import("./runtime.js").TestContext | undefined>;
/**
* Checks if there's an active test context
* @returns true if there's an active context
* @example
* ```typescript
* if (await ctrf.test.hasActiveContext()) {
* await ctrf.test.addExtra('contextFound', true)
* }
* ```
*/
hasActiveContext: () => Promise<boolean>;
};
};
/**
* Direct access to the runtime for advanced usage
* Most users should use the `ctrf` convenience API instead
*/
export declare const runtime: import("./runtime.js").CtrfRuntime;
import { ctrfRuntime } from './runtime.js';
/**
* Lightweight helper API for CTRF runtime operations
*
* Provides simple functions for adding metadata during test execution.
* Framework-agnostic and designed to work with any test runner.
*
* @example
* ```typescript
* import { ctrf } from 'ctrf'
*
* // In your test
* await ctrf.test.addExtra('env', 'staging')
* await ctrf.test.addExtra('buildNumber', '2025.10.001')
* ```
*/
export const ctrf = {
/**
* Test-related operations
*/
test: {
/**
* Adds extra metadata to the current test
* @param key The metadata key
* @param value The metadata value
* @example
* ```typescript
* await ctrf.test.addExtra('env', 'staging')
* await ctrf.test.addExtra('buildNumber', '2025.10.001')
* await ctrf.test.addExtra('browser', 'chromium')
* ```
*/
addExtra: async (key, value) => {
ctrfRuntime.addExtra(key, value);
},
/**
* Gets the current test context (read-only)
* @returns The current test context or undefined
* @example
* ```typescript
* const context = await ctrf.test.getCurrentContext()
* if (context) {
* console.log('Current test:', context.name)
* }
* ```
*/
getCurrentContext: async () => {
return ctrfRuntime.getCurrentContext();
},
/**
* Checks if there's an active test context
* @returns true if there's an active context
* @example
* ```typescript
* if (await ctrf.test.hasActiveContext()) {
* await ctrf.test.addExtra('contextFound', true)
* }
* ```
*/
hasActiveContext: async () => {
return ctrfRuntime.hasActiveContext();
},
},
// Future: ctrf.environment.addExtra, ctrf.report.addExtra, etc.
};
/**
* Direct access to the runtime for advanced usage
* Most users should use the `ctrf` convenience API instead
*/
export const runtime = ctrfRuntime;
/**
* Lightweight helper API for CTRF runtime operations
*
* Provides simple functions for adding metadata during test execution.
* Framework-agnostic and designed to work with any test runner.
*
* @example
* ```typescript
* import { ctrf } from 'ctrf'
*
* // In your test
* await ctrf.addExtra('env', 'staging')
* await ctrf.addExtra('buildNumber', '2025.10.001')
* ```
*/
export declare const ctrf: {
/**
* Adds extra metadata to the current test
* @param key The metadata key
* @param value The metadata value
* @example
* ```typescript
* await ctrf.addExtra('env', 'staging')
* await ctrf.addExtra('buildNumber', '2025.10.001')
* await ctrf.addExtra('browser', 'chromium')
* ```
*/
addExtra: (key: string, value: unknown) => Promise<void>;
/**
* Gets the current test context (read-only)
* @returns The current test context or undefined
* @example
* ```typescript
* const context = await ctrf.getCurrentContext()
* if (context) {
* console.log('Current test:', context.name)
* }
* ```
*/
getCurrentContext: () => Promise<import("./ctrf-runtime.js").TestContext | undefined>;
/**
* Checks if there's an active test context
* @returns true if there's an active context
* @example
* ```typescript
* if (await ctrf.hasActiveContext()) {
* await ctrf.addExtra('contextFound', true)
* }
* ```
*/
hasActiveContext: () => Promise<boolean>;
};
/**
* Direct access to the runtime for advanced usage
* Most users should use the `ctrf` convenience API instead
*/
export declare const runtime: import("./ctrf-runtime.js").CtrfRuntime;
import { ctrfRuntime } from './ctrf-runtime.js';
/**
* Lightweight helper API for CTRF runtime operations
*
* Provides simple functions for adding metadata during test execution.
* Framework-agnostic and designed to work with any test runner.
*
* @example
* ```typescript
* import { ctrf } from 'ctrf'
*
* // In your test
* await ctrf.addExtra('env', 'staging')
* await ctrf.addExtra('buildNumber', '2025.10.001')
* ```
*/
export const ctrf = {
/**
* Adds extra metadata to the current test
* @param key The metadata key
* @param value The metadata value
* @example
* ```typescript
* await ctrf.addExtra('env', 'staging')
* await ctrf.addExtra('buildNumber', '2025.10.001')
* await ctrf.addExtra('browser', 'chromium')
* ```
*/
addExtra: async (key, value) => {
ctrfRuntime.addExtra(key, value);
},
/**
* Gets the current test context (read-only)
* @returns The current test context or undefined
* @example
* ```typescript
* const context = await ctrf.getCurrentContext()
* if (context) {
* console.log('Current test:', context.name)
* }
* ```
*/
getCurrentContext: async () => {
return ctrfRuntime.getCurrentContext();
},
/**
* Checks if there's an active test context
* @returns true if there's an active context
* @example
* ```typescript
* if (await ctrf.hasActiveContext()) {
* await ctrf.addExtra('contextFound', true)
* }
* ```
*/
hasActiveContext: async () => {
return ctrfRuntime.hasActiveContext();
},
};
/**
* Direct access to the runtime for advanced usage
* Most users should use the `ctrf` convenience API instead
*/
export const runtime = ctrfRuntime;
import type { Test, TestStatus, Report, Tool } from '../../types/ctrf.js';
/**
* Interface for the runtime test context data
*/
export interface TestContext {
name: string;
status?: TestStatus;
duration?: number;
start?: number;
stop?: number;
suite?: string[];
message?: string;
trace?: string;
snippet?: string;
line?: number;
ai?: string;
rawStatus?: string;
tags?: string[];
type?: string;
filePath?: string;
retries?: number;
flaky?: boolean;
stdout?: string[];
stderr?: string[];
threadId?: string;
browser?: string;
device?: string;
screenshot?: string;
parameters?: Record<string, unknown>;
extra?: Record<string, unknown>;
}
/**
* CTRF Runtime Manager
*
* Core runtime library for CTRF reporters.
* Provides AsyncLocalStorage-based context management for test metadata.
* 100% framework-agnostic.
*/
export declare class CtrfRuntime {
private storage;
private results;
/**
* Starts a new test context
* @param name The name of the test
*/
startTestContext(name: string): void;
/**
* Adds extra metadata to the current test
* @param key The metadata key
* @param value The metadata value
*/
addExtra(key: string, value: unknown): void;
/**
* Ends the current test context
* @param status Optional final status to set before ending
* @returns The completed test data in CTRF format
*/
endTestContext(status?: TestStatus): Test | undefined;
/**
* Writes all results as a complete CTRF report
* @param tool Tool information
* @param additionalData Additional report data
* @returns Complete CTRF report
*/
writeResult(tool: Tool, additionalData?: {
reportId?: string;
timestamp?: string;
generatedBy?: string;
extra?: Record<string, unknown>;
}): Report;
/**
* Gets the current test context
* @returns The current test context or undefined if none exists
*/
getCurrentContext(): TestContext | undefined;
/**
* Checks if there's an active test context
* @returns true if there's an active context, false otherwise
*/
hasActiveContext(): boolean;
/**
* Gets all completed test results
* @returns Array of completed tests
*/
getResults(): Test[];
/**
* Clears all stored results
*/
clearResults(): void;
/**
* Generates summary statistics from collected test results
*/
private generateSummary;
}
/**
* Global CTRF runtime instance
*/
export declare const ctrfRuntime: CtrfRuntime;
import { AsyncLocalStorage } from 'node:async_hooks';
/**
* CTRF Runtime Manager
*
* Core runtime library for CTRF reporters.
* Provides AsyncLocalStorage-based context management for test metadata.
* 100% framework-agnostic.
*/
export class CtrfRuntime {
storage = new AsyncLocalStorage();
results = [];
/**
* Starts a new test context
* @param name The name of the test
*/
startTestContext(name) {
const testData = {
name,
extra: {},
start: Date.now(),
};
this.storage.enterWith(testData);
}
/**
* Adds extra metadata to the current test
* @param key The metadata key
* @param value The metadata value
*/
addExtra(key, value) {
const ctx = this.storage.getStore();
if (!ctx) {
throw new Error('No active CTRF test context. Call startTestContext() first.');
}
if (!ctx.extra) {
ctx.extra = {};
}
ctx.extra[key] = value;
}
/**
* Ends the current test context
* @param status Optional final status to set before ending
* @returns The completed test data in CTRF format
*/
endTestContext(status) {
const ctx = this.storage.getStore();
if (!ctx) {
return undefined;
}
// Set final status if provided
if (status) {
ctx.status = status;
}
// Set stop time and calculate duration
ctx.stop = Date.now();
if (ctx.start) {
ctx.duration = ctx.stop - ctx.start;
}
// Convert to CTRF Test format
const test = {
name: ctx.name,
status: ctx.status || 'other',
duration: ctx.duration || 0,
...(ctx.start && { start: ctx.start }),
...(ctx.stop && { stop: ctx.stop }),
...(ctx.suite && { suite: ctx.suite }),
...(ctx.message && { message: ctx.message }),
...(ctx.trace && { trace: ctx.trace }),
...(ctx.snippet && { snippet: ctx.snippet }),
...(ctx.line && { line: ctx.line }),
...(ctx.ai && { ai: ctx.ai }),
...(ctx.rawStatus && { rawStatus: ctx.rawStatus }),
...(ctx.tags && ctx.tags.length > 0 && { tags: ctx.tags }),
...(ctx.type && { type: ctx.type }),
...(ctx.filePath && { filePath: ctx.filePath }),
...(ctx.retries && { retries: ctx.retries }),
...(ctx.flaky !== undefined && { flaky: ctx.flaky }),
...(ctx.stdout && ctx.stdout.length > 0 && { stdout: ctx.stdout }),
...(ctx.stderr && ctx.stderr.length > 0 && { stderr: ctx.stderr }),
...(ctx.threadId && { threadId: ctx.threadId }),
...(ctx.browser && { browser: ctx.browser }),
...(ctx.device && { device: ctx.device }),
...(ctx.screenshot && { screenshot: ctx.screenshot }),
...(ctx.parameters && Object.keys(ctx.parameters).length > 0 && { parameters: ctx.parameters }),
...(ctx.extra && Object.keys(ctx.extra).length > 0 && { extra: ctx.extra }),
};
// Store the result
this.results.push(test);
return test;
}
/**
* Writes all results as a complete CTRF report
* @param tool Tool information
* @param additionalData Additional report data
* @returns Complete CTRF report
*/
writeResult(tool, additionalData) {
const summary = this.generateSummary();
const results = {
tool,
summary,
tests: [...this.results],
};
const report = {
reportFormat: 'CTRF',
specVersion: '1.0.0',
reportId: additionalData?.reportId,
timestamp: additionalData?.timestamp || new Date().toISOString(),
generatedBy: additionalData?.generatedBy || 'ctrf-runtime',
results,
...(additionalData?.extra && { extra: additionalData.extra }),
};
return report;
}
/**
* Gets the current test context
* @returns The current test context or undefined if none exists
*/
getCurrentContext() {
return this.storage.getStore();
}
/**
* Checks if there's an active test context
* @returns true if there's an active context, false otherwise
*/
hasActiveContext() {
return this.storage.getStore() !== undefined;
}
/**
* Gets all completed test results
* @returns Array of completed tests
*/
getResults() {
return [...this.results];
}
/**
* Clears all stored results
*/
clearResults() {
this.results = [];
}
/**
* Generates summary statistics from collected test results
*/
generateSummary() {
const tests = this.results.length;
const passed = this.results.filter(t => t.status === 'passed').length;
const failed = this.results.filter(t => t.status === 'failed').length;
const skipped = this.results.filter(t => t.status === 'skipped').length;
const pending = this.results.filter(t => t.status === 'pending').length;
const other = this.results.filter(t => t.status === 'other').length;
const flaky = this.results.filter(t => t.flaky === true).length;
const starts = this.results.map(t => t.start).filter(Boolean);
const stops = this.results.map(t => t.stop).filter(Boolean);
const start = starts.length > 0 ? Math.min(...starts) : Date.now();
const stop = stops.length > 0 ? Math.max(...stops) : Date.now();
const duration = stop - start;
return {
tests,
passed,
failed,
skipped,
pending,
other,
...(flaky > 0 && { flaky }),
start,
stop,
duration,
};
}
}
/**
* Global CTRF runtime instance
*/
export const ctrfRuntime = new CtrfRuntime();
/**
* Tests for CTRF Runtime Core Functionality
*/
export {};
/**
* Tests for CTRF Runtime Core Functionality
*/
import { describe, test, expect, beforeEach } from 'vitest';
import { CtrfRuntime, ctrfRuntime, mergeTestData } from './runtime.js';
import { ctrf } from './api.js';
describe('CTRF Runtime Core', () => {
let runtime;
beforeEach(() => {
runtime = new CtrfRuntime();
});
describe('Core API Methods', () => {
test('startTestContext should create active context', () => {
expect(runtime.hasActiveContext()).toBe(false);
runtime.startTestContext('My Test');
expect(runtime.hasActiveContext()).toBe(true);
expect(runtime.getCurrentContext()?.name).toBe('My Test');
});
test('startTestContext should accept optional identification data', () => {
runtime.startTestContext('My Test', {
suite: ['unit', 'auth'],
filePath: '/tests/auth.test.ts',
id: 'test-123',
});
const context = runtime.getCurrentContext();
expect(context).toEqual({
name: 'My Test',
suite: ['unit', 'auth'],
filePath: '/tests/auth.test.ts',
id: 'test-123',
extra: {},
});
});
test('addExtra should add metadata to current test', () => {
runtime.startTestContext('Test');
runtime.addExtra('env', 'staging');
runtime.addExtra('buildNumber', '2025.10.001');
const context = runtime.getCurrentContext();
expect(context?.extra).toEqual({
env: 'staging',
buildNumber: '2025.10.001',
});
});
test('endTestContext should return test context data', () => {
runtime.startTestContext('Test 1', {
suite: ['integration'],
filePath: '/tests/integration.test.ts',
});
runtime.addExtra('env', 'test');
const result = runtime.endTestContext();
expect(result).toEqual({
name: 'Test 1',
suite: ['integration'],
filePath: '/tests/integration.test.ts',
extra: { env: 'test' },
});
});
test('endTestContext should return undefined when no active context', () => {
const result = runtime.endTestContext();
expect(result).toBeUndefined();
});
});
describe('Error Handling', () => {
test('should throw error when no active context', () => {
expect(() => runtime.addExtra('key', 'value')).toThrow('No active CTRF test context');
});
});
describe('AsyncLocalStorage Context Isolation', () => {
test('should maintain separate contexts in concurrent scenarios', async () => {
const results = [];
const promise1 = new Promise(resolve => {
runtime.startTestContext('Test 1');
runtime.addExtra('testId', 1);
setTimeout(() => {
results.push(runtime.getCurrentContext()?.name || 'unknown');
resolve();
}, 10);
});
const promise2 = new Promise(resolve => {
runtime.startTestContext('Test 2');
runtime.addExtra('testId', 2);
setTimeout(() => {
results.push(runtime.getCurrentContext()?.name || 'unknown');
resolve();
}, 5);
});
await Promise.all([promise1, promise2]);
// Both contexts should have maintained their identity
expect(results).toEqual(['Test 2', 'Test 1']);
});
});
describe('Global Runtime Instance', () => {
test('should provide global ctrfRuntime instance', () => {
expect(ctrfRuntime).toBeInstanceOf(CtrfRuntime);
});
});
});
describe('CTRF Helper API', () => {
describe('ctrf helper functions', () => {
test('ctrf.test.addExtra should work with active context', async () => {
ctrfRuntime.startTestContext('Helper Test');
await ctrf.test.addExtra('env', 'staging');
await ctrf.test.addExtra('buildNumber', '123');
const context = await ctrf.test.getCurrentContext();
expect(context?.extra).toEqual({
env: 'staging',
buildNumber: '123',
});
});
test('ctrf.test.hasActiveContext should return correct state', async () => {
expect(await ctrf.test.hasActiveContext()).toBe(false);
ctrfRuntime.startTestContext('Test');
expect(await ctrf.test.hasActiveContext()).toBe(true);
});
test('ctrf.test.getCurrentContext should return current context', async () => {
expect(await ctrf.test.getCurrentContext()).toBeUndefined();
ctrfRuntime.startTestContext('Context Test');
const context = await ctrf.test.getCurrentContext();
expect(context?.name).toBe('Context Test');
});
});
});
describe('mergeTestData Utility', () => {
test('should return reporter test unchanged when no runtime context', () => {
const reporterTest = { name: 'Test', status: 'passed', duration: 100 };
const result = mergeTestData(reporterTest, undefined);
expect(result).toEqual(reporterTest);
});
test('should merge extra data with runtime extra as base', () => {
const reporterTest = {
name: 'Test',
status: 'passed',
duration: 100,
extra: { reporter: 'data', conflict: 'reporter-wins' },
};
const runtimeContext = {
name: 'Runtime Test',
extra: { runtime: 'metadata', conflict: 'runtime-value' },
};
const result = mergeTestData(reporterTest, runtimeContext);
expect(result).toEqual({
name: 'Test',
status: 'passed',
duration: 100,
extra: {
runtime: 'metadata', // From runtime
conflict: 'reporter-wins', // Reporter wins conflicts
reporter: 'data', // From reporter
},
});
});
test('should use runtime extra when reporter has no extra', () => {
const reporterTest = { name: 'Test', status: 'passed', duration: 100 };
const runtimeContext = {
name: 'Runtime Test',
extra: { runtime: 'metadata', env: 'test' },
};
const result = mergeTestData(reporterTest, runtimeContext);
expect(result).toEqual({
name: 'Test',
status: 'passed',
duration: 100,
extra: {
runtime: 'metadata',
env: 'test',
},
});
});
test('should handle empty runtime extra', () => {
const reporterTest = {
name: 'Test',
status: 'passed',
extra: { reporter: 'data' },
};
const runtimeContext = { name: 'Runtime Test', extra: {} };
const result = mergeTestData(reporterTest, runtimeContext);
expect(result).toEqual({
name: 'Test',
status: 'passed',
extra: { reporter: 'data' },
});
});
});
import { Report, Test } from "../../types/ctrf.js";
/**
* Test helper for creating and testing report enrichment scenarios
*/
export declare class ReportEnrichmentTestHelper {
private tempDir;
private reportPaths;
constructor();
/**
* Create a mock CTRF test
*/
createMockTest(overrides?: Partial<Test>): Test;
/**
* Create a mock CTRF report
*/
createMockReport(tests: Test[], startTime?: number, toolName?: string): Report;
/**
* Add a report to the test scenario
*/
addReport(report: Report, filename?: string): string;
/**
* Add multiple reports with timestamps spaced apart
*/
addReportsWithTimestamps(reportsData: Array<{
tests: Test[];
toolName?: string;
}>, intervalMs?: number): string[];
/**
* Create a typical flaky test scenario
*/
createFlakyTestScenario(): {
currentReport: Report;
previousReports: Report[];
reportPaths: string[];
};
/**
* Run enrichment on the collected reports
*/
enrichReports(outputFilename?: string, verbose?: boolean): Promise<Report>;
/**
* Get the path to the temporary directory
*/
getTempDir(): string;
/**
* Get all report paths
*/
getReportPaths(): string[];
/**
* Read a file from the temp directory
*/
readFile(filename: string): string;
/**
* Check if a file exists in the temp directory
*/
fileExists(filename: string): boolean;
/**
* Clean up all temporary files
*/
cleanup(): void;
}
/**
* Simple helper function for quick testing
*/
export declare function testReportEnrichment(reports: Report[], outputPath?: string): Promise<Report>;
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { enrichReportsWithInsights, } from "../../scripts/enrich-reports";
import { CTRF_REPORT_FORMAT, CTRF_SPEC_VERSION } from "../constants";
/**
* Test helper for creating and testing report enrichment scenarios
*/
export class ReportEnrichmentTestHelper {
tempDir;
reportPaths = [];
constructor() {
// Create a temporary directory for this test session
this.tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ctrf-test-"));
}
/**
* Create a mock CTRF test
*/
createMockTest(overrides = {}) {
return {
name: "test-name",
status: "passed",
duration: 100,
...overrides,
};
}
/**
* Create a mock CTRF report
*/
createMockReport(tests, startTime = Date.now(), toolName = "jest") {
return {
reportFormat: CTRF_REPORT_FORMAT,
specVersion: CTRF_SPEC_VERSION,
results: {
tool: { name: toolName },
summary: {
tests: tests.length,
passed: tests.filter((t) => t.status === "passed").length,
failed: tests.filter((t) => t.status === "failed").length,
skipped: tests.filter((t) => t.status === "skipped").length,
pending: tests.filter((t) => t.status === "pending").length,
other: tests.filter((t) => !["passed", "failed", "skipped", "pending"].includes(t.status)).length,
start: startTime,
stop: startTime + 5000,
},
tests,
},
};
}
/**
* Add a report to the test scenario
*/
addReport(report, filename) {
const reportPath = path.join(this.tempDir, filename || `report-${this.reportPaths.length + 1}.json`);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
this.reportPaths.push(reportPath);
return reportPath;
}
/**
* Add multiple reports with timestamps spaced apart
*/
addReportsWithTimestamps(reportsData, intervalMs = 10000) {
const baseTime = Date.now();
const paths = [];
reportsData.forEach((reportData, index) => {
const report = this.createMockReport(reportData.tests, baseTime - index * intervalMs, // Earlier reports have smaller timestamps
reportData.toolName);
const path = this.addReport(report, `report-${index + 1}.json`);
paths.push(path);
});
return paths;
}
/**
* Create a typical flaky test scenario
*/
createFlakyTestScenario() {
const currentTests = [
this.createMockTest({
name: "stable-test",
status: "passed",
duration: 100,
}),
this.createMockTest({
name: "flaky-test",
status: "passed",
retries: 2,
flaky: true,
duration: 150,
}),
this.createMockTest({
name: "failing-test",
status: "failed",
duration: 200,
}),
];
const previousTests1 = [
this.createMockTest({
name: "stable-test",
status: "passed",
duration: 95,
}),
this.createMockTest({
name: "flaky-test",
status: "failed",
duration: 180,
}),
this.createMockTest({
name: "failing-test",
status: "passed",
duration: 190,
}),
];
const previousTests2 = [
this.createMockTest({
name: "stable-test",
status: "passed",
duration: 105,
}),
this.createMockTest({
name: "flaky-test",
status: "passed",
retries: 1,
flaky: true,
duration: 160,
}),
this.createMockTest({
name: "failing-test",
status: "failed",
duration: 210,
}),
];
const reportPaths = this.addReportsWithTimestamps([
{ tests: currentTests },
{ tests: previousTests1 },
{ tests: previousTests2 },
]);
const currentReport = this.createMockReport(currentTests);
const previousReports = [
this.createMockReport(previousTests1, Date.now() - 10000),
this.createMockReport(previousTests2, Date.now() - 20000),
];
return { currentReport, previousReports, reportPaths };
}
/**
* Run enrichment on the collected reports
*/
async enrichReports(outputFilename = "enriched-report.json", verbose = false) {
const config = {
inputReports: this.reportPaths,
outputPath: path.join(this.tempDir, outputFilename),
verbose,
};
return await enrichReportsWithInsights(config);
}
/**
* Get the path to the temporary directory
*/
getTempDir() {
return this.tempDir;
}
/**
* Get all report paths
*/
getReportPaths() {
return [...this.reportPaths];
}
/**
* Read a file from the temp directory
*/
readFile(filename) {
return fs.readFileSync(path.join(this.tempDir, filename), "utf8");
}
/**
* Check if a file exists in the temp directory
*/
fileExists(filename) {
return fs.existsSync(path.join(this.tempDir, filename));
}
/**
* Clean up all temporary files
*/
cleanup() {
if (fs.existsSync(this.tempDir)) {
fs.rmSync(this.tempDir, { recursive: true, force: true });
}
}
}
/**
* Simple helper function for quick testing
*/
export async function testReportEnrichment(reports, outputPath) {
const helper = new ReportEnrichmentTestHelper();
try {
// Add all reports
reports.forEach((report, index) => {
helper.addReport(report, `input-${index + 1}.json`);
});
// Run enrichment
const result = await helper.enrichReports(outputPath, false);
return result;
}
finally {
helper.cleanup();
}
}