diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e1e098..e230127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Added +- Based on [Add `only` as an annotation for `describe`/`test`](https://github.com/scripterio-js/scripterio/issues/14): + - Exclusive execution for tests: + - `describe.only()` — Runs only this `describe` block; all others are skipped. + - `test.only()` — Runs only this test; all others are skipped. +- Based on [Add `todo` as an annotation for `describe`/`test`](https://github.com/scripterio-js/scripterio/issues/46): + - Mark tests as pending to help plan and organize future work. Pending tests are not executed. + - `describe.todo()` — Marks a test group as "to-do." + - `test.todo()` — Marks a test as "to-do." +- Added test execution time to the HTML report. +- Added a filter to the HTML report for quick navigation. +- Based on [Review & Add additional unit tests](https://github.com/scripterio-js/scripterio/issues/58): + - Added unit tests for: + - `config.mjs` + - `setup.mjs` + +- Contributors: + - [Vadym Nastoiashchyi](https://github.com/VadimNastoyashchy) + - [Oleh Babenko](https://github.com/OlehBabenkoo) + +### Changed +- Updated documentation +- Refactored core components ## 1.10.0 - 2025-06-22 ### Added diff --git a/README.md b/README.md index 437e5f9..1970348 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,10 @@ Use `expect(actual_value)` with assertions: `skip()` Declares a skipped test or test group. Test/s is/are never run. +`only()` Declares an exclusive test or test group that will be executed. If used, all other tests are skipped. + +`todo()` Declares a test or test group as "to-do." The test(s) is/are marked as pending and will not be executed. Helpful for planning and organizing future tests. + ### `Example↓` @@ -169,6 +173,24 @@ test.skip('description', () => {}) describe.skip('description', () => {}) ``` +```js +test.only('description', () => { + // Body of the only test that will be executed +}) +//or +describe.only('description', () => { + // Body of the only test group that will be executed +}) +``` + +```js +test.todo('description') +//or +describe.todo('description', () => { + // This test group is a placeholder and won't run +}) +``` + --- ## Context options diff --git a/__tests__/assertions.js b/__tests__/assertions.test.js similarity index 100% rename from __tests__/assertions.js rename to __tests__/assertions.test.js diff --git a/__tests__/config.test.js b/__tests__/config.test.js new file mode 100644 index 0000000..ebbd344 --- /dev/null +++ b/__tests__/config.test.js @@ -0,0 +1,14 @@ +import { describe, test, expect } from '../src/index.mjs' +import { getConfig } from '../src/config/config.mjs' + +describe('Unit tests for config.mjs', () => { + test('Check getConfig() returns correct configuration', () => { + const config = getConfig() + expect(config).toBeDefined() + expect(config.folder).toBeEqual('__tests__') + expect(config.reporter).toBeEqual('html') + expect(config.file).toBeEqual('') + expect(config.retry).toBeEqual('') + expect(config.timeout).toBeEqual('') + }) +}) diff --git a/__tests__/request.js b/__tests__/request.test.js similarity index 100% rename from __tests__/request.js rename to __tests__/request.test.js diff --git a/__tests__/setup.test.js b/__tests__/setup.test.js new file mode 100644 index 0000000..abf168e --- /dev/null +++ b/__tests__/setup.test.js @@ -0,0 +1,10 @@ +import { describe, test, expect } from '../src/index.mjs' +import { chooseTestFiles } from '../src/config/setup.mjs' + +describe('Unit tests for setup.mjs', async () => { + test('Check chooseTestFiles() returns correct path/s', async () => { + const filePaths = await chooseTestFiles() + expect(filePaths).toBeDefined() + expect(filePaths.length).toBeGreaterThan(1) + }) +}) diff --git a/docs/index.md b/docs/index.md index 340b166..c644327 100644 --- a/docs/index.md +++ b/docs/index.md @@ -134,6 +134,8 @@ Use `expect(actual_value)` with assertions: `skip()` Declares a skipped test or test group. Test/s is/are never run. +`only()` Declares an exclusive test or test group that will be executed. If used, all other tests are skipped. + ### `Example↓` @@ -143,6 +145,16 @@ test.skip('description', () => {}) describe.skip('description', () => {}) ``` +```js +test.only('description', () => { + // Body of the only test that will be executed +}) +//or +describe.only('description', () => { + // Body of the only test group that will be executed +}) +``` + --- ## Context options diff --git a/src/config/setup.mjs b/src/config/setup.mjs index 0a05925..ed395e7 100644 --- a/src/config/setup.mjs +++ b/src/config/setup.mjs @@ -1,14 +1,28 @@ import path from 'path' import fs from 'fs' +import { getConfig } from '../config/config.mjs' -const getAllFilePaths = (dir) => { +const config = getConfig() + +const getSingleFilePath = async (dir) => { + try { + const fullPath = dir + await fs.promises.access(fullPath) + return [fullPath] + } catch { + console.error(`File ${config.file} could not be accessed.`) + process.exit(0) + } +} + +const getMultipleFilePath = (dir) => { const fileNames = fs.readdirSync(dir) let filePaths = [] fileNames.forEach((fileName) => { const filePath = path.join(dir, fileName) const stat = fs.statSync(filePath) if (stat.isDirectory()) { - filePaths = filePaths.concat(getAllFilePaths(filePath)) + filePaths = filePaths.concat(getMultipleFilePath(filePath)) } else if (fileName.endsWith('.js')) { filePaths.push(filePath) } @@ -16,6 +30,23 @@ const getAllFilePaths = (dir) => { return filePaths } -export const getMultipleFilePath = (fileDir) => { - return getAllFilePaths(fileDir) +const hasSingleFile = () => config.file + +const getTestFile = async () => { + return getSingleFilePath(path.resolve(process.cwd(), config.file)) +} + +const getTestFiles = async () => { + return getMultipleFilePath(path.resolve(process.cwd(), config.folder)) } + +export const getTags = () => { + return config.tags ? config.tags.split(',') : '' +} + +export const getReporterType = () => { + return config.reporter || '' +} + +export const chooseTestFiles = () => + hasSingleFile() ? getTestFile() : getTestFiles() diff --git a/src/core/context.mjs b/src/core/context.mjs index 0587a4b..4626fe0 100644 --- a/src/core/context.mjs +++ b/src/core/context.mjs @@ -9,6 +9,7 @@ import { } from '../utils/transform.mjs' import { printNewLine, printSkippedMsg } from './output.mjs' import { getConfig } from '../config/config.mjs' +import { timeStamp } from '../utils/support.mjs' const config = getConfig() const failures = [] @@ -25,6 +26,7 @@ export const result = { numTests: 0, numPassed: 0, numFailed: 0, + numTodo: 0, results: [], } @@ -47,30 +49,61 @@ const makeTest = (name, body, timeout = defaultTimeout, tags = [], retry) => ({ currentDescribe = makeDescribe('root') -export const describe = (name, optionsOrBody, body) => { +const handleDescribe = (name, optionsOrBody, body, extra = {}) => { const options = typeof optionsOrBody === 'object' ? optionsOrBody : {} const actualBody = typeof optionsOrBody === 'function' ? optionsOrBody : body const parentDescribe = currentDescribe - currentDescribe = makeDescribe(name, options) - actualBody() + currentDescribe = makeDescribe(name, { ...options, ...extra }) + if (!extra.todo) actualBody?.() currentDescribe = { ...parentDescribe, children: [...parentDescribe.children, currentDescribe], } } -export const test = (name, optionsOrBody, body) => { +export const describe = (name, optionsOrBody, body) => { + handleDescribe(name, optionsOrBody, body) +} + +describe.only = (name, optionsOrBody, body) => { + handleDescribe(name, optionsOrBody, body, { focus: true }) +} +describe.todo = (name, optionsOrBody, body) => { + handleDescribe(name, optionsOrBody, body, { todo: true }) +} + +const handleTest = (name, optionsOrBody, body, extra = {}) => { const options = typeof optionsOrBody === 'object' ? optionsOrBody : {} const actualBody = typeof optionsOrBody === 'function' ? optionsOrBody : body currentDescribe = { ...currentDescribe, children: [ ...currentDescribe.children, - makeTest(name, actualBody, options.timeout, options.tags, options.retry), + { + ...makeTest( + name, + extra.todo ? () => {} : actualBody, + options.timeout, + options.tags, + options.retry + ), + ...extra, + }, ], } } +export const test = (name, optionsOrBody, body) => { + handleTest(name, optionsOrBody, body) +} + +test.only = (name, optionsOrBody, body) => { + handleTest(name, optionsOrBody, body, { focus: true }) +} +test.todo = (name, optionsOrBody, body) => { + handleTest(name, optionsOrBody, body, { todo: true }) +} + export const skip = (name) => { printSkippedMsg(name) } @@ -130,6 +163,16 @@ const runTest = async (test) => { let passed = false global.currentTest = test currentTest.describeStack = [...describeStack] + + const startTimeStamp = timeStamp() + if (test.todo) { + result.numTodo++ + result.numTests++ + console.log( + indent(applyColor(` ${currentTest.name} (TODO)`)) + ) + } + while (attempts <= maxRetries && !passed) { if (attempts > 0) { console.log( @@ -155,6 +198,8 @@ const runTest = async (test) => { } attempts++ } + + const endTimeStamp = timeStamp() if (!passed) { result.numFailed++ console.log(indent(applyColor(` ${currentTest.name}`))) @@ -166,6 +211,7 @@ const runTest = async (test) => { } result.numTests++ result.results.push(currentTest) + currentTest.duration = endTimeStamp - startTimeStamp global.currentTest = null } diff --git a/src/core/core.mjs b/src/core/core.mjs index 3e4737e..9b47565 100644 --- a/src/core/core.mjs +++ b/src/core/core.mjs @@ -1,51 +1,20 @@ import path from 'path' -import fs from 'fs' -import { applyColor, transformStackTrace } from '../utils/transform.mjs' +import { transformStackTrace } from '../utils/transform.mjs' import { runParsedBlocks } from '../core/context.mjs' -import { getConfig } from '../config/config.mjs' -import { getMultipleFilePath } from '../config/setup.mjs' +import { getTags, getReporterType, chooseTestFiles } from '../config/setup.mjs' import { timeStamp } from '../utils/support.mjs' import { EXIT_CODES } from '../core/constants.mjs' import { printExecutionTime, printRunningTestFile, - printNewLine, printTags, + printFailuresMsg, + printTestResult, } from './output.mjs' import { getReporter } from '../reporters/index.mjs' -const config = getConfig() - Error.prepareStackTrace = transformStackTrace -const hasSingleFile = () => config.file - -const getSingleFilePath = async () => { - try { - const fullPath = path.resolve(process.cwd(), config.file) - await fs.promises.access(fullPath) - return [fullPath] - } catch { - console.error(`File ${config.file} could not be accessed.`) - process.exit(0) - } -} - -const getTestFiles = async () => { - return getMultipleFilePath(path.resolve(process.cwd(), config.folder)) -} - -const getTags = () => { - return config.tags ? config.tags.split(',') : '' -} - -const getReporterType = () => { - return config.reporter || '' -} - -const chooseTestFiles = () => - hasSingleFile() ? getSingleFilePath() : getTestFiles() - export const run = async () => { const startTimeStamp = timeStamp() const tags = getTags() @@ -59,9 +28,9 @@ export const run = async () => { ) tags && printTags(tags) const { failures, successes } = await runParsedBlocks(tags) + const endTimeStamp = timeStamp() printFailuresMsg(failures) printTestResult(failures, successes) - const endTimeStamp = timeStamp() printExecutionTime(startTimeStamp, endTimeStamp) await getReporter(getReporterType()) process.exit(failures.length > 0 ? EXIT_CODES.failures : EXIT_CODES.ok) @@ -71,38 +40,3 @@ export const run = async () => { process.exit(EXIT_CODES.failures) } } - -const createFullDescription = ({ name, describeStack }) => - [...describeStack, { name }] - .map(({ name }) => `${name}`) - .join(' → ') - -const printFailureMsg = (failure) => { - console.error(applyColor(createFullDescription(failure))) - printNewLine() - failure.errors.forEach((error) => { - console.error(error.message) - console.error(error.stack) - }) - printNewLine() -} - -const printFailuresMsg = (failures) => { - if (failures.length > 0) { - printNewLine() - console.error('Failures:') - printNewLine() - } - failures.forEach(printFailureMsg) -} - -const printTestResult = (failures, successes) => { - printNewLine() - console.log( - applyColor( - `Tests: ${successes} passed, ` + - `${failures.length} failed, ` + - `${successes + failures.length} total` - ) - ) -} diff --git a/src/core/output.mjs b/src/core/output.mjs index 3725633..88bd6e6 100644 --- a/src/core/output.mjs +++ b/src/core/output.mjs @@ -53,3 +53,38 @@ export const printNewLine = () => console.log('') export const printTags = (tags) => { console.log('Using tags:' + EOL, tags) } + +const createFullDescription = ({ name, describeStack }) => + [...describeStack, { name }] + .map(({ name }) => `${name}`) + .join(' → ') + +export const printFailureMsg = (failure) => { + console.error(applyColor(createFullDescription(failure))) + printNewLine() + failure.errors.forEach((error) => { + console.error(error.message) + console.error(error.stack) + }) + printNewLine() +} + +export const printFailuresMsg = (failures) => { + if (failures.length > 0) { + printNewLine() + console.error('Failures:') + printNewLine() + } + failures.forEach(printFailureMsg) +} + +export const printTestResult = (failures, successes) => { + printNewLine() + console.log( + applyColor( + `Tests: ${successes} passed, ` + + `${failures.length} failed, ` + + `${successes + failures.length} total` + ) + ) +} diff --git a/src/index.d.ts b/src/index.d.ts index a8dacb0..687abb9 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -56,8 +56,7 @@ type Options = { * }); * ``` * or - * - * * ```js + * ```js * describe('example', () => { * test.skip('skipped test', () => { * // This test will not run @@ -67,9 +66,69 @@ type Options = { * * @param name Test title. * @param optionsOrBody (Optional) Object with options - * @param callback A callback that is run immediately when calling test(name, optionsOrBody, callback) + * @param body A callback that is linked to the skipped test + */ + skip(name: string, optionsOrBody?: {}, body?: {}): void + + /** + * Declares an exclusive test group. + * Only the tests in this group are run, and all other tests are skipped. + * - `describe.only(title)` + * - `describe.only(title, details, callback)` + * - `test.only(title, callback)` + * + * **Usage** + * + * ```js + * describe.only('focused group', () => { + * test('example', () => { + * // This test will run + * }); + * }); + * ``` + * or + * ```js + * describe('example', () => { + * test.only('focused test', () => { + * // This test will run + * }); + * }); + * ``` + * + * @param name Test title. + * @param optionsOrBody (Optional) Object with options + * @param body A callback that is linked to the exclusive test */ - skip(name: string, optionsOrBody: {}, body: {}): void + only(name: string, optionsOrBody?: {}, body?: {}): void + + /** + * Declares a test as "to-do". + * Marks the test or test group as a placeholder for future implementation, but does not execute it. + * - `test.todo(title)` + * - `describe.todo(title)` + * + * **Usage** + * + * Marking individual tests as "to-do": + * ```js + * test.todo('Test for input validation'); + * test.todo('Handle edge cases for user roles'); + * ``` + * + * Marking a test group as "to-do": + * ```js + * describe.todo('User Profile Tests', () => { + * // Placeholder for future tests + * }); + * ``` + * + * **Terminal Output** + * Tests marked as `todo` will appear in the output as pending, without causing failures. + * + * @param name Test or group title. + * @param optionsOrBody (Optional) Object with additional options + */ + todo(name: string, optionsOrBody?: {}): void } /** * Execute before each test case. @@ -302,33 +361,33 @@ export function expect(expected: any): Assertions */ export interface Response { /** Boolean indicating if the response was successful (status in the range 200-299) */ - ok: boolean; + ok: boolean /** The status code of the response (e.g., 200 for success, 404 for not found) */ - status: number; + status: number /** The status message associated with the status code */ - statusText: string; + statusText: string /** Indicates whether or not the response is the result of a redirect */ - redirected: boolean; + redirected: boolean /** The type of the response (e.g., 'basic', 'cors', 'error') */ - type: string; + type: string /** The URL of the response */ - url: string; + url: string /** The headers associated with the response */ - headers: Headers; + headers: Headers /** Indicates whether the body has been read yet */ - bodyUsed: boolean; + bodyUsed: boolean /** Returns a promise that resolves with an ArrayBuffer representation of the body */ - arrayBuffer(): Promise; + arrayBuffer(): Promise /** Returns a promise that resolves with a Blob representation of the body */ - blob(): Promise; + blob(): Promise /** Returns a promise that resolves with a FormData representation of the body */ - formData(): Promise; + formData(): Promise /** Returns a promise that resolves with the result of parsing the body text as JSON */ - json(): Promise; + json(): Promise /** Returns a promise that resolves with the body text */ - text(): Promise; + text(): Promise /** Creates a clone of the response object */ - clone(): Response; + clone(): Response } /** @@ -362,7 +421,7 @@ export interface Response { export const request: { /** * Sends a GET request to the specified URL and returns a Response object. - * + * * @param url - The URL to send the GET request to * @param config - Optional request configuration * @returns A promise that resolves to a Response object @@ -381,33 +440,33 @@ export const request: { * } * }); */ - get(url: string, config?: RequestInit): Promise; - + get(url: string, config?: RequestInit): Promise + /** * Sends a POST request with JSON body to the specified URL * @param url The URL to send the POST request to * @param config Optional request configuration */ - post(url: string, config?: RequestInit): Promise; - + post(url: string, config?: RequestInit): Promise + /** * Sends a PUT request with JSON body to the specified URL * @param url The URL to send the PUT request to * @param config Optional request configuration */ - put(url: string, config?: RequestInit): Promise; - + put(url: string, config?: RequestInit): Promise + /** * Sends a PATCH request with JSON body to the specified URL * @param url The URL to send the PATCH request to * @param config Optional request configuration */ - patch(url: string, config?: RequestInit): Promise; - + patch(url: string, config?: RequestInit): Promise + /** * Sends a DELETE request to the specified URL * @param url The URL to send the DELETE request to * @param config Optional request configuration */ - delete(url: string, config?: RequestInit): Promise; -}; + delete(url: string, config?: RequestInit): Promise +} diff --git a/src/index.mjs b/src/index.mjs index f5d6227..8900698 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -45,6 +45,60 @@ export const describe = (name, optionsOrBody, body) => */ describe.skip = (name) => core.skip(name) +/** + * Declares an exclusive test group. + * Only the tests in this group are run, and all other tests are skipped. + * - `describe.only(title)` + * - `describe.only(title, details, callback)` + * + * **Usage** + * + * ```js + * describe.only('focused group', () => { + * test('example', () => { + * // This test will run + * }); + * }); + * ``` + * + * @param name Test title. + * @param optionsOrBody (Optional) Object with options + * @param callback A callback that is run immediately when calling describe.only(name, optionsOrBody, callback) + */ +describe.only = (...args) => core.describe.only(...args) + +/** + * Declares a test group as "to-do". + * Marks the entire group of tests as a placeholder for future implementation but does not execute it. + * Useful for keeping track of large features or modules that require further testing. + * - `describe.todo(title)` + * + * **Usage** + * + * Marking a test group as "to-do": + * + * ```js + * describe.todo('Feature: User Authentication Tests', () => { + * // Placeholder for tests related to user authentication + * }); + * ``` + * + * Practical example: + * ```js + * describe.todo('API Endpoint Tests', () => { + * test.todo('Test GET /users endpoint'); + * test.todo('Test POST /users endpoint'); + * }); + * ``` + * + * **Terminal Output** + * When running the test suite, `describe.todo` groups and their respective `test.todo` entries will appear in the results as pending, without causing failures or executions. + * + * @param name Group title. + * @param optionsOrBody (Optional) Object with options + * @param callback (Optional) A callback function to define additional structure inside the group. + */ +describe.todo = (...args) => core.describe.todo(...args) /** * Test a specification or test-case with the given title, test options and callback fn. * @@ -85,6 +139,53 @@ export const test = (name, optionsOrBody, body) => */ test.skip = (name) => core.skip(name) +/** + * Declares an exclusive test. + * Only this test is executed, while all others are skipped. + * - `test.only(title, callback)` + * + * **Usage** + * + * ```js + * test.only('focused test', () => { + * // This test will run + * }); + * ``` + * + * @param name Test title. + * @param optionsOrBody (Optional) Object with options + * @param callback A callback that is run immediately when calling test.only(name, optionsOrBody, callback) + */ +test.only = (...args) => core.test.only(...args) + +/** + * Declares a test as "to-do". + * Marks the test as a placeholder for future implementation but doesn't execute it. + * This can be useful for tracking incomplete test cases or reminders for future work. + * - `test.todo(title)` + * + * **Usage** + * + * Marking individual tests as "to-do": + * + * ```js + * test.todo('Add validation for input data'); + * test.todo('Test error handling for invalid user sessions'); + * ``` + * + * Practical example: + * ```js + * test.todo('Implement edge case handling for data overflow'); + * test.todo('Add tests for login timeout scenarios'); + * ``` + * + * **Terminal Output** + * When running the test suite, `test.todo` tests will appear in the results as pending, but they will not fail or be executed. + * + * @param name Test title. + */ +test.todo = (...args) => core.test.todo(...args) + /** * Execute before each test case. * diff --git a/src/reporters/html-template.mjs b/src/reporters/html-template.mjs index 3f1c1fb..2d77e8e 100644 --- a/src/reporters/html-template.mjs +++ b/src/reporters/html-template.mjs @@ -21,7 +21,13 @@ const formatJson = (data) => { } } -export const template = ({ numTests, numPassed, numFailed, results }) => { +export const template = ({ + numTests, + numPassed, + numFailed, + numTodo, + results, +}) => { return ` @@ -115,6 +121,18 @@ export const template = ({ numTests, numPassed, numFailed, results }) => { border-radius: 4px; overflow-x: auto; } + + .test-name.todo::before { + content: '◦'; + color: #ff9800; + } + .test-name.todo { + color: #ff9800; + font-style: italic; + } + + .stat.todo { background: #fff3e0; } + .stat.todo h3, .stat.todo p { color: #ff9800; } .describe-group { margin: 0.5rem 0; padding: 0 1rem; @@ -210,6 +228,10 @@ export const template = ({ numTests, numPassed, numFailed, results }) => {

Failed

${numFailed}

+
+

Todo

+

${numTodo}

+
@@ -237,12 +259,69 @@ export const template = ({ numTests, numPassed, numFailed, results }) => { element.textContent = isShowing ? 'Show API details' : 'Hide API details'; } - document.addEventListener('DOMContentLoaded', () => { - const failedTests = document.querySelectorAll('.test-name.failed'); - failedTests.forEach(test => { + function setFilter(filter) { + document.querySelectorAll('.stat').forEach(stat => { + stat.classList.remove('active-filter'); + }); + if (filter) { + document.querySelector('.stat.' + filter).classList.add('active-filter'); + } else { + document.querySelector('.stat.total').classList.add('active-filter'); + } + + document.querySelectorAll('.test-case').forEach(tc => { + const nameDiv = tc.querySelector('.test-name'); + if (!filter || nameDiv.classList.contains(filter)) { + tc.style.display = ''; + } else { + tc.style.display = 'none'; + } }); + + function updateDescribeVisibility(describe) { + let hasVisible = false; + const content = describe.querySelector(':scope > .describe-content'); + if (content) { + content.childNodes.forEach(child => { + if (child.nodeType !== 1) return; // skip non-elements + if (child.classList.contains('describe-group')) { + if (updateDescribeVisibility(child)) { + child.style.display = ''; + hasVisible = true; + } else { + child.style.display = 'none'; + } + } else if (child.classList.contains('test-case')) { + if (child.style.display !== 'none') { + hasVisible = true; + } + } + }); + } + describe.style.display = hasVisible ? '' : 'none'; + return hasVisible; + } + document.querySelectorAll('.describe-group').forEach(group => { + updateDescribeVisibility(group); + }); + } + + document.addEventListener('DOMContentLoaded', () => { + document.querySelector('.stat.total').addEventListener('click', () => setFilter(null)); + document.querySelector('.stat.passed').addEventListener('click', () => setFilter('passed')); + document.querySelector('.stat.failed').addEventListener('click', () => setFilter('failed')); + document.querySelector('.stat.todo').addEventListener('click', () => setFilter('todo')); + document.querySelector('.stat.total').classList.add('active-filter'); }); + ` @@ -319,34 +398,37 @@ export const template = ({ numTests, numPassed, numFailed, results }) => { return content .map( (test) => ` -
-
- ${test.name} -
- ${ - test.errors.length - ? ` - Show error details -
- ${test.errors - .map( - (error) => ` -
-
${stripAnsi(error.message)}
-
${stripAnsi(error.stack)}
-
- ` - ) - .join('')} -
- ` - : '' - } - ${test.apiDetails ? renderApiDetails(test.apiDetails) : ''} +
+
+ ${test.name}${test.todo ? ' (TODO)' : ''} + ${ + typeof test.duration === 'number' ? `${test.duration} ms` : '' + } +
+ ${ + test.errors.length + ? ` + Show error details +
+ ${test.errors + .map( + (error) => ` +
+
${stripAnsi(error.message)}
+
${stripAnsi(error.stack)}
` + ) + .join('')} +
+ ` + : '' + } + ${test.apiDetails ? renderApiDetails(test.apiDetails) : ''} +
+ ` ) .join('') }