| /** | ||
| * 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'; |
+14
-156
| /** | ||
| * 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'; |
+41
-163
@@ -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'; |
+1
-1
| { | ||
| "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; | ||
| } |
| export {}; |
| 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(); | ||
| } | ||
| } |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
12
-20%213547
-25.3%48
-20%5069
-28.25%