diff --git a/README.md b/README.md index 01cc244..02c0353 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,14 @@ npm install ctrf - [organizeTestsBySuite](docs/functions/organizeTestsBySuite.md) - [traverseTree](docs/functions/traverseTree.md) +### Test Operations Methods + +- [findTestById](docs/functions/findTestById.md) +- [generateTestIdFromProperties](docs/functions/generateTestIdFromProperties.md) +- [getTestId](docs/functions/getTestId.md) +- [setTestId](docs/functions/setTestId.md) +- [setTestIdsForReport](docs/functions/setTestIdsForReport.md) + ### Utility Types - [SortOrder](docs/enumerations/SortOrder.md) (enumeration) @@ -89,6 +97,7 @@ npm install ctrf - [TreeOptions](docs/interfaces/TreeOptions.md) - [ValidationResult](docs/interfaces/ValidationResult.md) - [TreeTest](docs/type-aliases/TreeTest.md) +- [CTRF\_NAMESPACE](docs/variables/CTRF_NAMESPACE.md) ## TypeScript Types diff --git a/docs/README.md b/docs/README.md index eb91f39..5ce34eb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,6 +50,14 @@ - [organizeTestsBySuite](functions/organizeTestsBySuite.md) - [traverseTree](functions/traverseTree.md) +## Test Operations + +- [findTestById](functions/findTestById.md) +- [generateTestIdFromProperties](functions/generateTestIdFromProperties.md) +- [getTestId](functions/getTestId.md) +- [setTestId](functions/setTestId.md) +- [setTestIdsForReport](functions/setTestIdsForReport.md) + ## Enumerations - [SortOrder](enumerations/SortOrder.md) @@ -64,3 +72,7 @@ ## Type Aliases - [TreeTest](type-aliases/TreeTest.md) + +## Variables + +- [CTRF\_NAMESPACE](variables/CTRF_NAMESPACE.md) diff --git a/docs/functions/findTestById.md b/docs/functions/findTestById.md new file mode 100644 index 0000000..65c32ab --- /dev/null +++ b/docs/functions/findTestById.md @@ -0,0 +1,33 @@ +[**CTRF v0.0.16**](../README.md) + +*** + +[CTRF](../README.md) / findTestById + +# Function: findTestById() + +> **findTestById**(`report`, `testId`): `undefined` \| [`Test`](../interfaces/Test.md) + +Defined in: src/methods/test-id.ts:84 + +Finds a test by its ID in a report + +## Parameters + +### report + +[`Report`](../interfaces/Report.md) + +The CTRF report + +### testId + +`string` + +The test ID to search for + +## Returns + +`undefined` \| [`Test`](../interfaces/Test.md) + +The test object if found, undefined otherwise diff --git a/docs/functions/generateTestIdFromProperties.md b/docs/functions/generateTestIdFromProperties.md new file mode 100644 index 0000000..a5ccf83 --- /dev/null +++ b/docs/functions/generateTestIdFromProperties.md @@ -0,0 +1,39 @@ +[**CTRF v0.0.16**](../README.md) + +*** + +[CTRF](../README.md) / generateTestIdFromProperties + +# Function: generateTestIdFromProperties() + +> **generateTestIdFromProperties**(`name`, `suite?`, `filePath?`): `string` + +Defined in: src/methods/test-id.ts:95 + +Generates a new test ID based on test properties (exposed utility) + +## Parameters + +### name + +`string` + +Test name + +### suite? + +`string`[] + +Test suite path + +### filePath? + +`string` + +Test file path + +## Returns + +`string` + +A deterministic UUID v5 string based on the properties diff --git a/docs/functions/getTestId.md b/docs/functions/getTestId.md new file mode 100644 index 0000000..5b72f86 --- /dev/null +++ b/docs/functions/getTestId.md @@ -0,0 +1,27 @@ +[**CTRF v0.0.16**](../README.md) + +*** + +[CTRF](../README.md) / getTestId + +# Function: getTestId() + +> **getTestId**(`test`): `string` + +Defined in: src/methods/test-id.ts:61 + +Gets the test ID from a test object, generating one if it doesn't exist + +## Parameters + +### test + +[`Test`](../interfaces/Test.md) + +The test object to get the ID from + +## Returns + +`string` + +The test ID diff --git a/docs/functions/setTestId.md b/docs/functions/setTestId.md new file mode 100644 index 0000000..a16d36a --- /dev/null +++ b/docs/functions/setTestId.md @@ -0,0 +1,27 @@ +[**CTRF v0.0.16**](../README.md) + +*** + +[CTRF](../README.md) / setTestId + +# Function: setTestId() + +> **setTestId**(`test`): [`Test`](../interfaces/Test.md) + +Defined in: src/methods/test-id.ts:49 + +Sets a test ID for a test object based on its properties + +## Parameters + +### test + +[`Test`](../interfaces/Test.md) + +The test object to add an ID to + +## Returns + +[`Test`](../interfaces/Test.md) + +The test object with the ID set diff --git a/docs/functions/setTestIdsForReport.md b/docs/functions/setTestIdsForReport.md new file mode 100644 index 0000000..c3f70ae --- /dev/null +++ b/docs/functions/setTestIdsForReport.md @@ -0,0 +1,27 @@ +[**CTRF v0.0.16**](../README.md) + +*** + +[CTRF](../README.md) / setTestIdsForReport + +# Function: setTestIdsForReport() + +> **setTestIdsForReport**(`report`): [`Report`](../interfaces/Report.md) + +Defined in: src/methods/test-id.ts:73 + +Sets test IDs for all tests in a report + +## Parameters + +### report + +[`Report`](../interfaces/Report.md) + +The CTRF report + +## Returns + +[`Report`](../interfaces/Report.md) + +The report with test IDs set for all tests diff --git a/docs/variables/CTRF_NAMESPACE.md b/docs/variables/CTRF_NAMESPACE.md new file mode 100644 index 0000000..67b34a7 --- /dev/null +++ b/docs/variables/CTRF_NAMESPACE.md @@ -0,0 +1,15 @@ +[**CTRF v0.0.16**](../README.md) + +*** + +[CTRF](../README.md) / CTRF\_NAMESPACE + +# Variable: CTRF\_NAMESPACE + +> `const` **CTRF\_NAMESPACE**: `"6ba7b810-9dad-11d1-80b4-00c04fd430c8"` = `'6ba7b810-9dad-11d1-80b4-00c04fd430c8'` + +Defined in: src/methods/test-id.ts:11 + +The CTRF namespace UUID used for generating deterministic test IDs. +This namespace ensures that all CTRF test IDs are generated consistently +across different implementations and tools. diff --git a/examples/test-id-example.md b/examples/test-id-example.md new file mode 100644 index 0000000..ec9019d --- /dev/null +++ b/examples/test-id-example.md @@ -0,0 +1,99 @@ +# Test ID Operations Example + +This example demonstrates how to use the new deterministic test ID functionality. + +```typescript +import { + setTestId, + getTestId, + setTestIdsForReport, + findTestById, + generateTestIdFromProperties +} from 'ctrf' +import type { Test, Report } from 'ctrf' + +// Example test object +const test: Test = { + name: 'should authenticate user', + status: 'passed', + duration: 150, + suite: ['auth', 'login'], + filePath: 'src/auth/login.test.ts' +} + +// Set a test ID (generates deterministic UUID based on properties) +setTestId(test) +console.log('Test ID:', test.id) // Always the same UUID for these properties! + +// Get a test ID (generates one if not present) +const testId = getTestId(test) +console.log('Test ID:', testId) // Same as above + +// Generate a test ID from properties - always deterministic! +const customId = generateTestIdFromProperties( + 'my test', + ['suite1', 'suite2'], + 'my-test.ts' +) +console.log('Generated ID:', customId) // Always the same for these inputs + +// Demonstrate deterministic behavior +const sameId = generateTestIdFromProperties( + 'my test', + ['suite1', 'suite2'], + 'my-test.ts' +) +console.log('Same ID?', customId === sameId) // true! + +// Set IDs for all tests in a report +const report: Report = { + reportFormat: 'CTRF', + specVersion: '1.0.0', + results: { + tool: { name: 'vitest' }, + summary: { + tests: 2, + passed: 2, + failed: 0, + skipped: 0, + pending: 0, + other: 0, + start: Date.now(), + stop: Date.now() + 1000 + }, + tests: [ + { + name: 'test 1', + status: 'passed', + duration: 100, + suite: ['unit'], + filePath: 'test1.ts' + }, + { + name: 'test 2', + status: 'passed', + duration: 200, + suite: ['integration'], + filePath: 'test2.ts' + } + ] + } +} + +// Set IDs for all tests +setTestIdsForReport(report) + +// Find a test by its ID +const foundTest = findTestById(report, report.results.tests[0].id!) +console.log('Found test:', foundTest?.name) +``` + +## Key Features + +1. **Deterministic UUIDs**: Same test properties always generate the same UUID +2. **Proper UUID format**: Valid UUIDs that follow the standard format +3. **Non-destructive**: Won't overwrite existing IDs +4. **Property-based**: Uses test name, suite, and filePath for generation +5. **Report-level operations**: Can process entire reports at once +6. **Search functionality**: Find tests by their deterministic IDs +7. **Consistent across runs**: Same test will always have the same ID diff --git a/src/index.ts b/src/index.ts index 046cdf5..8241366 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,17 @@ export { getAllTests, getSuiteStats, } from './methods/tree-hierarchical-structure.js' +/** + * @group Test Operations + */ +export { + setTestId, + getTestId, + setTestIdsForReport, + findTestById, + generateTestIdFromProperties, + CTRF_NAMESPACE, +} from './methods/test-id.js' /** * @group Schema diff --git a/src/methods/test-id.test.ts b/src/methods/test-id.test.ts new file mode 100644 index 0000000..798791b --- /dev/null +++ b/src/methods/test-id.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect } from 'vitest' +import { + setTestId, + getTestId, + setTestIdsForReport, + findTestById, + generateTestIdFromProperties, + CTRF_NAMESPACE, +} from './test-id.js' +import type { Test, Report } from '../../types/ctrf.js' + +describe('test-id', () => { + const mockTest: Test = { + name: 'should pass', + status: 'passed', + duration: 100, + suite: ['unit', 'auth'], + filePath: 'src/auth.test.ts', + } + + const mockReport: Report = { + reportFormat: 'CTRF', + specVersion: '1.0.0', + results: { + tool: { name: 'vitest' }, + summary: { + tests: 2, + passed: 2, + failed: 0, + skipped: 0, + pending: 0, + other: 0, + start: 1234567890, + stop: 1234567990, + }, + tests: [ + { + name: 'test 1', + status: 'passed', + duration: 50, + suite: ['unit'], + filePath: 'test1.ts', + }, + { + name: 'test 2', + status: 'passed', + duration: 75, + suite: ['integration'], + filePath: 'test2.ts', + }, + ], + }, + } + + describe('setTestId', () => { + it('should add a deterministic UUID to a test without one', () => { + const test = { ...mockTest } + const result = setTestId(test) + + expect(result.id).toBeDefined() + expect(typeof result.id).toBe('string') + expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + + const test2 = { ...mockTest } + const result2 = setTestId(test2) + expect(result.id).toBe(result2.id) + }) + + it('should not overwrite existing ID', () => { + const test = { ...mockTest, id: 'existing-id' } + const result = setTestId(test) + + expect(result.id).toBe('existing-id') + }) + }) + + describe('getTestId', () => { + it('should return existing ID', () => { + const test = { ...mockTest, id: 'existing-id' } + const result = getTestId(test) + + expect(result).toBe('existing-id') + }) + + it('should generate and return deterministic UUID if none exists', () => { + const test = { ...mockTest } + const result = getTestId(test) + + expect(result).toBeDefined() + expect(typeof result).toBe('string') + expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + expect(test.id).toBe(result) + + const test2 = { ...mockTest } + const result2 = getTestId(test2) + expect(result).toBe(result2) + }) + }) + + describe('setTestIdsForReport', () => { + it('should set IDs for all tests in a report', () => { + const report = JSON.parse(JSON.stringify(mockReport)) + const result = setTestIdsForReport(report) + + expect(result.results.tests).toHaveLength(2) + expect(result.results.tests[0].id).toBeDefined() + expect(result.results.tests[1].id).toBeDefined() + expect(result.results.tests[0].id).not.toBe(result.results.tests[1].id) + }) + + it('should not overwrite existing IDs', () => { + const report = JSON.parse(JSON.stringify(mockReport)) + report.results.tests[0].id = 'existing-id' + + const result = setTestIdsForReport(report) + + expect(result.results.tests[0].id).toBe('existing-id') + expect(result.results.tests[1].id).toBeDefined() + expect(result.results.tests[1].id).not.toBe('existing-id') + }) + }) + + describe('findTestById', () => { + it('should find test by ID', () => { + const report = JSON.parse(JSON.stringify(mockReport)) + report.results.tests[0].id = 'test-id-1' + report.results.tests[1].id = 'test-id-2' + + const result = findTestById(report, 'test-id-1') + + expect(result).toBeDefined() + expect(result?.name).toBe('test 1') + }) + + it('should return undefined for non-existent ID', () => { + const report = JSON.parse(JSON.stringify(mockReport)) + + const result = findTestById(report, 'non-existent-id') + + expect(result).toBeUndefined() + }) + }) + + describe('generateTestIdFromProperties', () => { + it('should generate deterministic UUID from properties', () => { + const result = generateTestIdFromProperties( + 'test name', + ['suite1', 'suite2'], + 'test.ts' + ) + + expect(result).toBeDefined() + expect(typeof result).toBe('string') + expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + + const result2 = generateTestIdFromProperties( + 'test name', + ['suite1', 'suite2'], + 'test.ts' + ) + expect(result).toBe(result2) + }) + + it('should handle missing optional parameters', () => { + const result = generateTestIdFromProperties('test name') + + expect(result).toBeDefined() + expect(typeof result).toBe('string') + expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + }) + + it('should generate different UUIDs for different properties', () => { + const result1 = generateTestIdFromProperties('test1', ['suite1'], 'file1.ts') + const result2 = generateTestIdFromProperties('test2', ['suite2'], 'file2.ts') + + expect(result1).not.toBe(result2) + }) + }) + + describe('CTRF_NAMESPACE', () => { + it('should be a valid UUID', () => { + expect(CTRF_NAMESPACE).toBeDefined() + expect(typeof CTRF_NAMESPACE).toBe('string') + expect(CTRF_NAMESPACE).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) + }) + + it('should be stable and not change', () => { + expect(CTRF_NAMESPACE).toBe('6ba7b810-9dad-11d1-80b4-00c04fd430c8') + }) + }) +}) \ No newline at end of file diff --git a/src/methods/test-id.ts b/src/methods/test-id.ts new file mode 100644 index 0000000..4dcfc87 --- /dev/null +++ b/src/methods/test-id.ts @@ -0,0 +1,101 @@ +import { createHash } from 'crypto' +import type { Test, Report } from '../../types/ctrf.js' + +/** + * The CTRF namespace UUID used for generating deterministic test IDs. + * This namespace ensures that all CTRF test IDs are generated consistently + * across different implementations and tools. + * + * @public + */ +export const CTRF_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8' + +/** + * Generates a deterministic UUID v5 based on test properties + * @param name - Test name + * @param suite - Test suite path + * @param filePath - Test file path + * @returns A deterministic UUID v5 string based on the properties + */ +function generateTestId(name: string, suite?: string[], filePath?: string): string { + const suiteString = suite ? suite.join('/') : '' + const identifier = `${name}|${suiteString}|${filePath || ''}` + + const namespaceBytes = CTRF_NAMESPACE.replace(/-/g, '').match(/.{2}/g)!.map(byte => parseInt(byte, 16)) + + const input = Buffer.concat([ + Buffer.from(namespaceBytes), + Buffer.from(identifier, 'utf8') + ]) + + const hash = createHash('sha1').update(input).digest('hex') + + const uuid = [ + hash.substring(0, 8), + hash.substring(8, 12), + '5' + hash.substring(13, 16), + ((parseInt(hash.substring(16, 17), 16) & 0x3) | 0x8).toString(16) + hash.substring(17, 20), + hash.substring(20, 32) + ].join('-') + + return uuid +} + +/** + * Sets a test ID for a test object based on its properties + * @param test - The test object to add an ID to + * @returns The test object with the ID set + */ +export function setTestId(test: Test): Test { + if (!test.id) { + test.id = generateTestId(test.name, test.suite, test.filePath) + } + return test +} + +/** + * Gets the test ID from a test object, generating one if it doesn't exist + * @param test - The test object to get the ID from + * @returns The test ID + */ +export function getTestId(test: Test): string { + if (!test.id) { + test.id = generateTestId(test.name, test.suite, test.filePath) + } + return test.id +} + +/** + * Sets test IDs for all tests in a report + * @param report - The CTRF report + * @returns The report with test IDs set for all tests + */ +export function setTestIdsForReport(report: Report): Report { + report.results.tests.forEach(test => setTestId(test)) + return report +} + +/** + * Finds a test by its ID in a report + * @param report - The CTRF report + * @param testId - The test ID to search for + * @returns The test object if found, undefined otherwise + */ +export function findTestById(report: Report, testId: string): Test | undefined { + return report.results.tests.find(test => test.id === testId) +} + +/** + * Generates a new test ID based on test properties (exposed utility) + * @param name - Test name + * @param suite - Test suite path + * @param filePath - Test file path + * @returns A deterministic UUID v5 string based on the properties + */ +export function generateTestIdFromProperties( + name: string, + suite?: string[], + filePath?: string +): string { + return generateTestId(name, suite, filePath) +} \ No newline at end of file diff --git a/typedoc.json b/typedoc.json index 87335d1..57d701b 100644 --- a/typedoc.json +++ b/typedoc.json @@ -13,6 +13,7 @@ "Report Processing", "Validation", "Tree Operations", + "Test Operations", "Utility Types", "*" ], @@ -22,6 +23,7 @@ "Report Processing", "Validation", "Tree Operations", + "Test Operations", "Utility Types", "*" ], diff --git a/update-readme.ts b/update-readme.ts index 84fccad..fa21d80 100644 --- a/update-readme.ts +++ b/update-readme.ts @@ -13,9 +13,11 @@ function extractCategorizedSections(docsContent: string): string { { name: 'Report Processing', newName: 'Report Processing Methods' }, { name: 'Validation', newName: 'Validation Methods' }, { name: 'Tree Operations', newName: 'Tree Operations Methods' }, + { name: 'Test Operations', newName: 'Test Operations Methods' }, { name: 'Enumerations', newName: 'Utility Types' }, { name: 'Interfaces', newName: 'Utility Types' }, { name: 'Type Aliases', newName: 'Utility Types' }, + { name: 'Variables', newName: 'Utility Types' }, ] let apiContent = '## API Reference\n' @@ -38,7 +40,7 @@ function extractCategorizedSections(docsContent: string): string { return `](docs/${url})` }) - if (['Enumerations', 'Interfaces', 'Type Aliases'].includes(section.name)) { + if (['Enumerations', 'Interfaces', 'Type Aliases', 'Variables'].includes(section.name)) { if (!utilityTypesAdded) { apiContent += '\n### Utility Types\n\n' utilityTypesAdded = true