@olton/latte
Version:
Simple test framework for JavaScript and TypeScript with DOM supports
333 lines (293 loc) • 15.8 kB
JavaScript
/**
* Повідомлення мають такий формат:
* ```
* ##teamcity[messageName 'value1' key2='value2' ...]
* ```
* Основні повідомлення для тестів:
* - `testSuiteStarted name='...'` - початок набору тестів
* - `testSuiteFinished name='...'` - завершення набору тестів
* - `testStarted name='...'` - початок тесту
* - `testFinished name='...'` - успішне завершення тесту
* - `testFailed name='...' message='...' details='...'` - невдале виконання тесту
* - `testIgnored name='...'` - пропущений тест
*/
/**
* ### Загальні параметри:
* 1. **`flowId`** - ідентифікатор потоку виконання, дозволяє розрізняти паралельні процеси
* 2. **`timestamp`** - часова мітка події в форматі Unix timestamp
* 3. **`nodeId`** - ідентифікатор вузла (використовується при розподіленому тестуванні)
* 4. **`parentNodeId`** - ідентифікатор батьківського вузла
* 5. **`name`** - назва тесту або набору тестів
*
* ### Для повідомлень про тести:
* 1. **`duration`** - тривалість виконання тесту в мілісекундах
* 2. **`locationHint`** - підказка розташування тесту, яка допомагає IDE перейти до тесту
* 3. **`captureStandardOutput`** - визначає чи перехоплювати стандартний вивід (true/false)
* 4. **`testType`** - тип тесту (unit, integration, тощо)
* 5. **`status`** - статус тесту (passed, failed, ignored)
*
* ### Для повідомлень про помилки:
* 1. **`errorDetails`** - детальна інформація про помилку
* 2. **`actual`** - фактичне значення (для порівнянь)
* 3. **`expected`** - очікуване значення (для порівнянь)
* 4. **`type`** - тип помилки (assertion, exception, тощо)
* 5. **`stackTrace`** - стек викликів помилки
* 6. **`comparisonFailure`** - вказує, що помилка є порівнянням (true/false)
*
* ### Для повідомлень про артефакти:
* 1. **`path`** - шлях до артефакту
* 2. **`size`** - розмір артефакту
*
* Не реалізовано:
* ### Для повідомлень про покриття коду:
* 1. **`coverageStats`** - статистика покриття коду
* 2. **`coverageClass`** - клас покриття
* 3. **`coverageMethod`** - метод покриття
* 4. **`coverageBlock`** - блок покриття
* 5. **`coverageLine`** - рядок покриття
*/
import { term } from '@olton/terminal'
const log = console.log
/**
* Парсить стек помилки для отримання рядка та стовпчика
* @param stack
* @param file
* @returns number[] - [row, column]
*/
const parseStack = (stack, file) => {
if (!stack) return ''
const lines = stack.split('\n')
const filteredLines = lines.filter(line => {
// Фільтруємо рядки, які не містять інформацію про файл або номер рядка
return line.includes(file)
})
const parsed = filteredLines[0].match(/at (.+):(\d+):(\d+)/)
if (parsed) {
// Повертаємо рядок у форматі "file:line:column"
return [+parsed[2], +parsed[3]]
}
return [0, 0]
}
const Console = {
log: (message) => {
if (typeof message === 'object') {
message = JSON.stringify(message)
}
console.log(`##teamcity[message '${message}']`)
},
start: () => {
log(`##teamcity[testingStarted]`)
},
finish: (duration) => {
log(`##teamcity[testingFinished duration='${duration}']`)
},
suiteStarted: (name, filePath, nodeId = 0, parentNodeId = 'root', flowId = 0) => {
const timestamp = new Date().toISOString().replace('Z', '')
log(`##teamcity[testSuiteStarted name='${name}' locationHint='${filePath}::${name}' timestamp='${timestamp}' nodeId='suite_${nodeId}' parentNodeId='root' flowId='0']`)
},
suiteFinished: (name, duration) => {
log(`##teamcity[testSuiteFinished name='${name}' duration='${duration}']`)
},
testStarted: (name, filePath, nodeId = 0, parentNodeId = 0, flowId = 0) => {
log(`##teamcity[testStarted name='${name}' locationHint='${filePath}::${name}' nodeId='test_${nodeId}' parentNodeId='${parentNodeId}' flowId='${flowId}']`)
},
testFinished: (name, filePath, duration) => {
log(`##teamcity[testFinished name='${name}' locationHint='${filePath}::${name}' duration='${duration}']`)
},
testFailed: (name, filePath, error, file = '') => {
let { received, expected, message, stack } = error
const [row, col] = parseStack(stack, file)
if (typeof received === 'object' || typeof received === 'function') {
received = typeof received
}
if (typeof expected === 'object' || typeof expected === 'function') {
expected = typeof expected
}
log(`##teamcity[testFailed name='${name}' locationHint='${filePath}:${row}:${col}' details='Source: ${filePath}:${row}:${col}' message='${message.replace(/'/g, '')}' actual='${received}' expected='${expected}' type='assertion']`)
},
testIgnored: (name, filePath, message) => {
log(`##teamcity[testIgnored name='${name}' message='${message}' locationHint='${filePath}::${name}']`)
}
}
const setupAndTeardown = async (funcs, type) => {
if (funcs && funcs.length) {
for (const fn of funcs) {
try {
await fn()
} catch (error) {
log(` The ${type} function throw error with message: ${term('🔴 ' + error.message, { color: 'red' })}`)
}
}
}
}
export const idea_runner = async (queue, options) => {
// log(`##teamcity[testingStarted]`)
Console.start()
const startTime = Date.now()
const { verbose, test: testName, suite: suiteName, skip, parallel, idea, progress, showStack = false } = options
let passedTests = 0
let failedTests = 0
let totalTests = 0
let totalTestCount = 0
for (const q of queue) {
for (const job of q[1].describes) {
totalTestCount += job.it.length
}
totalTestCount += q[1].tests.length
}
for (const [file, jobs] of queue) {
const startFileTime = process.hrtime()
let testFileStatus = true
let testFilePassed = 0
let testFileFailed = 0
const filePath = jobs.filePath || file
global.testResults[file] = {
describes: [],
tests: [],
duration: 0,
completed: true
}
if (jobs.describes.length) {
let describeId = 0
for (const describe of jobs.describes) {
if (suiteName) {
if (describe.name.includes(suiteName) === false) {
continue
}
}
// const timestamp = new Date().toISOString().replace('Z', '')
// log(`##teamcity[testSuiteStarted name='${describe.name}' locationHint='${filePath}::${describe.name}' timestamp='${timestamp}' nodeId='suite_${describeId}' parentNodeId='root' flowId='${0}']`)
Console.suiteStarted(describe.name, filePath, describeId, 'root', 0)
await setupAndTeardown(describe.beforeAll, 'beforeAll')
const describes = {
name: describe.name,
tests: [],
duration: 0
}
global.testResults[file].describes.push(describes)
const startDescribeTime = Date.now()
let testId = 0
for (const test of describe.it) {
let expect = {}
if (testName) {
if (testName && test.name.includes(testName) === false) {
// log(`##teamcity[testIgnored name='${test.name}' message='Test skipped by name filter' locationHint='${filePath}::${test.name}']`)
Console.testIgnored(test.name, filePath, 'Test skipped by name filter')
continue
}
}
if (skip) {
if (skip && test.name.includes(skip) === true) {
// log(`##teamcity[testIgnored name='${test.name}' message='Test skipped by name filter' locationHint='${filePath}::${test.name}']`)
Console.testIgnored(test.name, filePath, 'Test skipped by name filter')
continue
}
}
// log(`##teamcity[testStarted name='${test.name}' locationHint='${filePath}::${test.name}' nodeId='test_${testId}' parentNodeId='suite_${describeId}' flowId='${0}']`)
Console.testStarted(test.name, filePath, testId, `suite_${describeId}`, 0)
// Execute test function
const startTestTime = Date.now()
try {
await setupAndTeardown(test.beforeEach, 'beforeEach')
await test.fn()
expect.result = true
// log(`##teamcity[testFinished name='${test.name}' locationHint='${filePath}::${test.name}' duration='${Date.now() - startTestTime}']`)
Console.testFinished(test.name, filePath, Date.now() - startTestTime)
} catch (error) {
// const {received, expected, message, stack} = error
// const [row, col] = parseStack(stack, file)
// log(`##teamcity[testFailed name='${test.name}' locationHint='${filePath}:${row}:${col}' details='Source: ${filePath}:${row}:${col}' message='${message.replace(/'/g, '')}' actual='${received}' expected='${expected}' type='assertion' stackTrace='${showStack ? stack.replace(/'/g, '') : ''}']`)
Console.testFailed(test.name, filePath, error, file)
global.testResults[file].completed = false
expect = {
result: false,
message: error.message,
expected: error.expected,
received: error.received
}
} finally {
await setupAndTeardown(test.afterEach, 'afterEach')
}
describes.tests.push({
name: test.name,
result: expect.result,
message: expect.message || 'OK'
})
if (expect.result) {
passedTests++
testFilePassed++
} else {
failedTests++
testFileFailed++
testFileStatus = false
}
totalTests++
testId++
}
await setupAndTeardown(describe.afterAll, 'afterAll')
// log(`##teamcity[testSuiteFinished name='${describe.name}' duration='${Date.now() - startDescribeTime}']`)
Console.suiteFinished(describe.name, Date.now() - startDescribeTime)
describeId++
}
}
if (jobs.tests.length && !suiteName) {
for (const test of jobs.tests) {
// console.log(test)
let expect = {}
if (testName && test.name.includes(testName) === false) {
// log(`##teamcity[testIgnored name='${test.name}' message='Test skipped by name filter' locationHint='${filePath}::${test.name}']`)
Console.testIgnored(test.name, filePath, 'Test skipped by name filter')
continue
}
if (skip && test.name.includes(skip) === true) {
// log(`##teamcity[testIgnored name='${test.name}' message='Test skipped by name filter' locationHint='${filePath}::${test.name}']`)
Console.testIgnored(test.name, filePath, 'Test skipped by name filter')
continue
}
// log(`##teamcity[testStarted name='${test.name}' locationHint='${filePath}']`)
Console.testStarted(test.name, filePath)
// Execute test function
const startTestTime = Date.now()
await setupAndTeardown(test.beforeEach, 'beforeEach')
try {
await test.fn()
expect.result = true
// log(`##teamcity[testFinished name='${test.name}' locationHint='${filePath}::${test.name}' duration='${Date.now() - startTestTime}']`)
Console.testFinished(test.name, filePath, Date.now() - startTestTime)
} catch (error) {
// const {received, expected, message, stack} = error
// const [row, col] = parseStack(stack, file)
// log(`##teamcity[testFailed name='${test.name}' locationHint='${filePath}:${row}:${col}' details='Source: ${filePath}:${row}:${col}' message='${message.replace(/'/g, '')}' actual='${received}' expected='${expected}' type='assertion' stackTrace='${showStack ? stack.replace(/'/g, '') : ''}']`)
Console.testFailed(test.name, filePath, error, file)
global.testResults[file].completed = false
expect = {
result: false,
message: error.message,
expected: error.expected,
received: error.received
}
}
global.testResults[file].tests.push({
name: test.name,
result: expect.result,
message: expect.message || 'OK'
})
if (expect.result) {
passedTests++
testFilePassed++
} else {
failedTests++
testFileFailed++
testFileStatus = false
}
await setupAndTeardown(test.afterEach, 'afterEach')
totalTests++
}
}
const [seconds, nanoseconds] = process.hrtime(startFileTime)
global.testResults[file].duration = (seconds * 1e9 + nanoseconds) / 1e6
}
// log(`##teamcity[testingFinished deration='${Date.now() - startTime}']`)
Console.finish(Date.now() - startTime)
return failedTests
}