diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts index d93dbaf..e78abbb 100644 --- a/src/cli/commands/run.ts +++ b/src/cli/commands/run.ts @@ -16,7 +16,10 @@ import type { import type { CliContext } from '../index.js'; import { ErrorCodes, ExitCodes } from '../../constants.js'; -import { resolveOutputPath } from '../../core/output-path-resolver.js'; +import { + generateTimestampedFilename, + resolveOutputPath, +} from '../../core/output-path-resolver.js'; import { type BudgetExceededError, InvalidArgumentError, @@ -430,7 +433,7 @@ const setupReporters = async ( const outputPath = resolveOutputPath( outputDir, explicitOutputFile, - 'results.csv', + generateTimestampedFilename('csv'), ); reporter = new CsvReporter({ includeHeaders: true, @@ -455,7 +458,7 @@ const setupReporters = async ( const outputPath = resolveOutputPath( outputDir, explicitOutputFile, - 'results.json', + generateTimestampedFilename('json'), ); // Precedence: CLI flag > config file > default (false) const prettyPrint = diff --git a/src/core/output-path-resolver.ts b/src/core/output-path-resolver.ts index 41c672e..413bcf0 100644 --- a/src/core/output-path-resolver.ts +++ b/src/core/output-path-resolver.ts @@ -1,5 +1,19 @@ import { extname, isAbsolute, join, resolve } from 'node:path'; +/** + * Generates a timestamped filename for benchmark output files. + * + * @param extension - File extension without the dot (e.g., 'json', 'csv') + * @returns Filename in format `benchmarks-YYYY-MM-DD-HH-MM-SS.{extension}` (UTC + * time) + */ +export const generateTimestampedFilename = (extension: string): string => { + const now = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + const timestamp = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}-${pad(now.getUTCHours())}-${pad(now.getUTCMinutes())}-${pad(now.getUTCSeconds())}`; + return `benchmarks-${timestamp}.${extension}`; +}; + /** * Resolves the final output path for a reporter * diff --git a/test/integration/quiet-mode.test.ts b/test/integration/quiet-mode.test.ts index 6af8b80..2ec4150 100644 --- a/test/integration/quiet-mode.test.ts +++ b/test/integration/quiet-mode.test.ts @@ -12,7 +12,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; -import { runCommand } from '../util.js'; +import { findFileByPattern, runCommand } from '../util.js'; import { fixtures } from './fixture-paths.js'; describe('Quiet Mode Integration Tests', () => { @@ -93,8 +93,8 @@ describe('Quiet Mode Integration Tests', () => { '--quiet', '--reporter', 'json', - '--output', - outputDir, + '--output-file', + outputFile, '--iterations', '5', ]); @@ -117,8 +117,8 @@ describe('Quiet Mode Integration Tests', () => { '--quiet', '--reporter', 'csv', - '--output', - outputDir, + '--output-file', + outputFile, '--iterations', '5', ]); @@ -155,17 +155,21 @@ describe('Quiet Mode Integration Tests', () => { expect(result.stderr, 'to be empty'); expect(result.exitCode, 'to equal', 0); - // Both JSON and CSV files should be written - const jsonContent = await readFile( - join(outputDir, 'results.json'), - 'utf-8', + // Both JSON and CSV files should be written with timestamped names + const jsonFile = await findFileByPattern( + outputDir, + /^benchmarks-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.json$/, ); + expect(jsonFile, 'to be truthy'); + const jsonContent = await readFile(jsonFile!, 'utf-8'); expect(jsonContent, 'to match', /"meta":/); - const csvContent = await readFile( - join(outputDir, 'results.csv'), - 'utf-8', + const csvFile = await findFileByPattern( + outputDir, + /^benchmarks-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.csv$/, ); + expect(csvFile, 'to be truthy'); + const csvContent = await readFile(csvFile!, 'utf-8'); expect(csvContent, 'to contain', 'file'); }); }); @@ -220,9 +224,13 @@ describe('Quiet Mode Integration Tests', () => { expect(result.stderr, 'to be empty'); expect(result.exitCode, 'to equal', 0); - // Verify file was written - const outputFile = join(outputDir, 'results.json'); - const fileExists = existsSync(outputFile); + // Verify file was written with timestamped name + const outputFile = await findFileByPattern( + outputDir, + /^benchmarks-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.json$/, + ); + expect(outputFile, 'to be truthy'); + const fileExists = existsSync(outputFile!); expect(fileExists, 'to be true'); }); }); diff --git a/test/integration/reporters.test.ts b/test/integration/reporters.test.ts index 133c793..c236b76 100644 --- a/test/integration/reporters.test.ts +++ b/test/integration/reporters.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; -import { runCommand } from '../util.js'; +import { findFileByPattern, runCommand } from '../util.js'; import { fixtures } from './fixture-paths.js'; /** @@ -79,8 +79,8 @@ describe('Multiple reporter output formats', () => { fixtures.simple, '--reporter', 'json', - '--output', - join(tempDir, 'results'), + '--output-file', + outputFile, ]); expect(result.exitCode, 'to equal', 0); @@ -106,19 +106,19 @@ describe('Multiple reporter output formats', () => { it('should include all benchmark metadata in JSON', async () => { const outputDir = join(tempDir, 'metadata-output'); + const jsonFile = join(outputDir, 'results.json'); const result = await runCommand([ 'run', fixtures.withMetadataTags, '--reporter', 'json', - '--output', - outputDir, + '--output-file', + jsonFile, ]); expect(result.exitCode, 'to equal', 0); // Read JSON output file - const jsonFile = join(outputDir, 'results.json'); const jsonContent = await readFile(jsonFile, 'utf-8'); const data = JSON.parse(jsonContent); @@ -150,8 +150,8 @@ describe('Multiple reporter output formats', () => { fixtures.csvTasks, '--reporter', 'csv', - '--output', - join(tempDir, 'results'), + '--output-file', + outputFile, ]); expect(result.exitCode, 'to equal', 0); @@ -176,19 +176,20 @@ describe('Multiple reporter output formats', () => { }); it('should include all required CSV columns', async () => { + const outputFile = join(tempDir, 'results.csv'); const result = await runCommand([ 'run', fixtures.simple, '--reporter', 'csv', - '--output', - tempDir, + '--output-file', + outputFile, ]); expect(result.exitCode, 'to equal', 0); // Read CSV from output file - const csvContent = await readFile(join(tempDir, 'results.csv'), 'utf-8'); + const csvContent = await readFile(outputFile, 'utf-8'); const lines = csvContent.trim().split('\n'); expect(lines.length, 'to be greater than', 0); @@ -222,6 +223,7 @@ describe('Multiple reporter output formats', () => { describe('multiple reporters simultaneously', () => { it('should output to multiple formats at once', async () => { + const outputDir = join(tempDir, 'results'); const result = await runCommand([ 'run', fixtures.simple, @@ -232,7 +234,7 @@ describe('Multiple reporter output formats', () => { '--reporter', 'csv', '--output', - join(tempDir, 'results'), + outputDir, ]); expect(result.exitCode, 'to equal', 0); @@ -240,12 +242,21 @@ describe('Multiple reporter output formats', () => { // Should have human output in stdout expect(result.stdout, 'to match', /Test Suite|ops/); - // Should create json and csv files - const jsonFile = join(tempDir, 'results', 'results.json'); - const csvFile = join(tempDir, 'results', 'results.csv'); + // Should create json and csv files with timestamped names + const jsonFile = await findFileByPattern( + outputDir, + /^benchmarks-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.json$/, + ); + const csvFile = await findFileByPattern( + outputDir, + /^benchmarks-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.csv$/, + ); - const jsonContent = await readFile(jsonFile, 'utf-8'); - const csvContent = await readFile(csvFile, 'utf-8'); + expect(jsonFile, 'to be truthy'); + expect(csvFile, 'to be truthy'); + + const jsonContent = await readFile(jsonFile!, 'utf-8'); + const csvContent = await readFile(csvFile!, 'utf-8'); expect(jsonContent.length, 'to be greater than', 0); expect(csvContent.length, 'to be greater than', 0); @@ -291,30 +302,33 @@ describe('Multiple reporter output formats', () => { expect(result.exitCode, 'to equal', 0); - // Should create nested directories - const jsonContent = await readFile( - join(outputDir, 'results.json'), - 'utf-8', + // Should create nested directories and timestamped files + const jsonFile = await findFileByPattern( + outputDir, + /^benchmarks-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.json$/, ); + expect(jsonFile, 'to be truthy'); + const jsonContent = await readFile(jsonFile!, 'utf-8'); expect(jsonContent.length, 'to be greater than', 0); }); it('should handle file naming conflicts', async () => { - // Create existing file + // Create existing file with explicit name const existingFile = join(tempDir, 'results', 'results.json'); await mkdir(join(tempDir, 'results'), { recursive: true }); await writeFile(existingFile, '{"existing": true}'); + // Use explicit --output-file to test overwrite behavior const result = await runCommand([ 'run', fixtures.simple, '--reporter', 'json', - '--output', - join(tempDir, 'results'), + '--output-file', + existingFile, ]); - // Should handle existing files (overwrite or append) + // Should handle existing files (overwrite) expect(result.exitCode, 'to equal', 0); }); diff --git a/test/integration/verbose-mode.test.ts b/test/integration/verbose-mode.test.ts index 393e488..5329aa2 100644 --- a/test/integration/verbose-mode.test.ts +++ b/test/integration/verbose-mode.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; -import { runCommand } from '../util.js'; +import { findFileByPattern, runCommand } from '../util.js'; import { fixtures } from './fixture-paths.js'; describe('Verbose Mode Integration Tests', () => { @@ -248,11 +248,13 @@ describe('Verbose Mode Integration Tests', () => { expect(result.stderr, 'to contain', 'Setting up reporters...'); expect(result.exitCode, 'to equal', 0); - // JSON data should be written to file - const jsonContent = await readFile( - join(outputDir, 'results.json'), - 'utf-8', + // JSON data should be written to file with timestamped name + const jsonFile = await findFileByPattern( + outputDir, + /^benchmarks-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.json$/, ); + expect(jsonFile, 'to be truthy'); + const jsonContent = await readFile(jsonFile!, 'utf-8'); expect(jsonContent, 'to contain', '"meta":'); }); @@ -272,11 +274,13 @@ describe('Verbose Mode Integration Tests', () => { expect(result.stderr, 'to be empty'); expect(result.exitCode, 'to equal', 0); - // JSON data should be written to file - const jsonContent = await readFile( - join(outputDir, 'results.json'), - 'utf-8', + // JSON data should be written to file with timestamped name + const jsonFile = await findFileByPattern( + outputDir, + /^benchmarks-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.json$/, ); + expect(jsonFile, 'to be truthy'); + const jsonContent = await readFile(jsonFile!, 'utf-8'); expect(jsonContent, 'to contain', '"meta":'); }); }); @@ -300,11 +304,13 @@ describe('Verbose Mode Integration Tests', () => { expect(result.stderr, 'to contain', 'Setting up reporters...'); expect(result.exitCode, 'to equal', 0); - // CSV data should be written to file - const csvContent = await readFile( - join(outputDir, 'results.csv'), - 'utf-8', + // CSV data should be written to file with timestamped name + const csvFile = await findFileByPattern( + outputDir, + /^benchmarks-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.csv$/, ); + expect(csvFile, 'to be truthy'); + const csvContent = await readFile(csvFile!, 'utf-8'); expect(csvContent, 'to contain', 'file'); }); }); diff --git a/test/unit/output-path-resolver.test.ts b/test/unit/output-path-resolver.test.ts index 6532784..0217c7d 100644 --- a/test/unit/output-path-resolver.test.ts +++ b/test/unit/output-path-resolver.test.ts @@ -2,7 +2,10 @@ import { expect } from 'bupkis'; import { resolve } from 'node:path'; import { describe, it } from 'node:test'; -import { resolveOutputPath } from '../../src/core/output-path-resolver.js'; +import { + generateTimestampedFilename, + resolveOutputPath, +} from '../../src/core/output-path-resolver.js'; describe('resolveOutputPath', () => { it('should return undefined when no paths provided', () => { @@ -44,3 +47,90 @@ describe('resolveOutputPath', () => { expect(result, 'to equal', '/tmp/subdir/custom.json'); }); }); + +describe('generateTimestampedFilename', () => { + it('should generate filename with json extension', () => { + const result = generateTimestampedFilename('json'); + expect( + result, + 'to match', + /^benchmarks-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.json$/, + ); + }); + + it('should generate filename with csv extension', () => { + const result = generateTimestampedFilename('csv'); + expect( + result, + 'to match', + /^benchmarks-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.csv$/, + ); + }); + + it('should generate filename with arbitrary extension', () => { + const result = generateTimestampedFilename('xml'); + expect( + result, + 'to match', + /^benchmarks-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.xml$/, + ); + }); + + it('should generate different filenames at different times', () => { + const RealDate = globalThis.Date; + + const fixedTime1 = new RealDate('2020-01-01T00:00:00Z').getTime(); + const fixedTime2 = new RealDate('2020-01-01T00:00:01Z').getTime(); + + const mockDate = (ms: number) => { + const MockDate = class extends RealDate { + constructor(...args: Parameters) { + if (args.length === 0) { + super(ms); + } else { + super(...args); + } + } + + static override now() { + return ms; + } + }; + globalThis.Date = MockDate as typeof Date; + }; + + try { + mockDate(fixedTime1); + const result1 = generateTimestampedFilename('json'); + + mockDate(fixedTime2); + const result2 = generateTimestampedFilename('json'); + + expect(result1, 'not to equal', result2); + expect(result1, 'to equal', 'benchmarks-2020-01-01-00-00-00.json'); + expect(result2, 'to equal', 'benchmarks-2020-01-01-00-00-01.json'); + } finally { + globalThis.Date = RealDate; + } + }); + + it('should use zero-padded date and time components', () => { + const result = generateTimestampedFilename('json'); + // The format should be: benchmarks-YYYY-MM-DD-HH-MM-SS.ext + // All numeric parts should be properly padded (year 4 digits, rest 2 digits) + const match = result.match( + /^benchmarks-(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})\.json$/, + ); + expect(match, 'to be truthy'); + if (match) { + // Verify all parts are numbers within valid ranges + const [, year, month, day, hour, minute, second] = match; + expect(Number(year), 'to be greater than', 2020); + expect(Number(month), 'to be within', 1, 12); + expect(Number(day), 'to be within', 1, 31); + expect(Number(hour), 'to be within', 0, 23); + expect(Number(minute), 'to be within', 0, 59); + expect(Number(second), 'to be within', 0, 59); + } + }); +}); diff --git a/test/util.ts b/test/util.ts index 038a56c..9f9838c 100644 --- a/test/util.ts +++ b/test/util.ts @@ -1,4 +1,6 @@ import { type ChildProcess, spawn } from 'node:child_process'; +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; import { Writable } from 'node:stream'; import { fileURLToPath } from 'node:url'; @@ -66,3 +68,21 @@ export const nullStream = new Writable({ callback(); }, }); + +/** + * Find the first file in a directory matching a pattern. + * + * Useful for finding timestamped output files like `benchmarks-*.json`. + * + * @param dir - Directory to search + * @param pattern - RegExp pattern to match filenames + * @returns Full path to the first matching file, or undefined if none found + */ +export const findFileByPattern = async ( + dir: string, + pattern: RegExp, +): Promise => { + const files = await readdir(dir); + const match = files.find((f) => pattern.test(f)); + return match ? join(dir, match) : undefined; +};