/* eslint-disable no-console */ import { styleText } from 'util'; import { type WriteStream } from 'fs'; import * as React from 'react'; import { render, Text, useApp, useInput, useStdout, } from 'ink'; import { TaskList, Task } from 'ink-task-list'; import { BarChart } from '@pppp606/ink-chart'; import { link, type Test } from './base.mts'; const { createElement: h } = React; export const isCI = process.env.CI || process.env.CONTINUOUS_INTEGRATION; export const supportColor = !isCI && styleText('red', 'test') !== 'test'; function Fragment(...children: (React.JSX.Element | null)[]) { return h(React.Fragment, null, ...children); } // from https://github.com/sindresorhus/cli-spinners const spinner = { interval: 80, frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], }; const quitMessage = [styleText('gray', 'Press '), styleText('yellow', 'q'), styleText('gray', ' to exit.')].join(''); function TerminalUI({ runner }: { runner: TerminalUIReporter }) { useFlushStdout(runner); const exiting = useExit(runner); const tasks = useWorkers(runner); const runtime = JSON.parse(useRuntimeUpdate(runner)) as number[]; const needPad = runtime.some((time) => time >= runner.slowThreshold); const runtimePadLength = needPad ? String(Math.max(...runtime)).length : 0; const previous = React.useRef([]); return Fragment( h(ProgressBar, { runner, running: !exiting, exiting }), h( TaskList, null, ...(exiting ? [] : tasks).map((test, index) => { previous.current.length = tasks.length; previous.current[index] = test?.id ?? previous.current[index]; let label: string; let padding = ''; let status = ''; let state: 'loading' | 'success' = 'loading'; if (test) { label = test.file; status = test.currentTestFlag; if (runtime[index] >= runner.slowThreshold) { padding = styleText('red', `[${String(runtime[index]).padStart(runtimePadLength)}s] `); } } else { let test = runner.tests.get(previous.current[index]); if (test && (Date.now() - test.endTime! > 200)) { test = undefined; } label = styleText('dim', test?.file ?? 'Idle'); status = test?.currentTestFlag ?? ''; state = test ? 'loading' : 'success'; if (!test && needPad) { padding = ' '.repeat(runtimePadLength + 3); } } if (!padding && needPad) { padding = ' '.repeat(runtimePadLength + 4); } label = `${padding}${label}`; return h(Task, { key: index, label, state, spinner, status, }); }), ), ); } function ProgressBar({ runner, running, exiting }: { runner: TestReporter, running: boolean, exiting: boolean }) { const stats = useStats(runner); if (!stats) { return null; } const { failed, passed, pending, skipped, total, ready, } = stats; if (!ready && running) { return h( Text, null, `Discovering tests... ${total} found so far. ${passed} passed, ${skipped} skipped, ${failed} failed, ${pending} pending. `, exiting ? '' : quitMessage, ); } return Fragment( h(Text, null, `${total} tests in total. `, exiting ? '' : quitMessage), h( BarChart, { data: [ { label: `${passed} passed`, value: passed, color: 'green' }, { label: `${skipped} skipped`, value: skipped, color: 'yellow' }, { label: `${failed} failed`, value: failed, color: 'red' }, running || pending ? { label: `${pending} pending`, value: pending, color: 'cyan' } : null!, ].filter(Boolean), width: 'full', max: total, }, ), ); } function useStats(runner: TestReporter) { return React.useSyncExternalStore( (onUpdate) => { runner.addEventListener('stats', onUpdate); return () => runner.removeEventListener('stats', onUpdate); }, () => runner.getStats(), ); } function useWorkers(runner: TestReporter) { return React.useSyncExternalStore( (onUpdate) => { runner.addEventListener('update', onUpdate); return () => runner.removeEventListener('update', onUpdate); }, () => runner.workers, ); } function useRuntimeUpdate(runner: TestReporter) { return React.useSyncExternalStore( (onUpdate) => { runner.addEventListener('update', onUpdate); return () => runner.removeEventListener('update', onUpdate); }, () => JSON.stringify( runner.workers.map((test) => test?.getRuntimeSeconds()), ), ); } function useExit(runner: TestReporter) { const exitRequested = React.useSyncExternalStore( (onExit) => { runner.addEventListener('exit', onExit); return () => runner.removeEventListener('exit', onExit); }, () => runner.exited, ); const { exit } = useApp(); const [previousExitState, setExitState] = React.useState(false); if (exitRequested && !previousExitState) { setExitState(true); setTimeout(exit, 10); } useInput((input, key) => { if (input === 'q' || (key.ctrl && input === 'c')) { setExitState(true); runner.exit(); } }); return previousExitState; } function useFlushStdout(runner: TerminalUIReporter) { const stdout = useStdout(); React.useEffect(() => { function flush() { stdout.write(runner.pending_stdout.join('')); runner.pending_stdout.length = 0; } runner.addEventListener('flush', flush); return () => runner.removeEventListener('flush', flush); }); } export type SkipReason = 'feature-disabled' | 'skip-list' | 'slow-list'; export abstract class TestReporter extends EventTarget { tests: Map = new Map(); workers: (Test | undefined)[] = []; protected skipped = 0; protected passed = 0; protected failed = 0; protected ready = false; setSlowTestReporting(threshold: number, stream: WriteStream) { this.slowThreshold = threshold; this.slowStream = stream; } slowThreshold = 2; protected slowStream: WriteStream | null = null; protected stats: { total: number; pending: number; passed: number; failed: number; skipped: number; ready: boolean } = this.getStats(); protected statsStale = false; getStats() { if (!this.statsStale) { return this.stats; } this.statsStale = false; this.stats = { total: this.tests.size, pending: this.tests.size - this.passed - this.failed - this.skipped, passed: this.passed, failed: this.failed, skipped: this.skipped, ready: this.ready, }; return this.stats; } exited = false; abstract stdout(...message: string[]): void abstract start(): void protected slowTimer: NodeJS.Timeout | null = null; protected startSlowTimer() { const seenSlow = new Set(); this.slowTimer = setInterval(() => { let hasSlow = false; for (const test of this.tests.values()) { if (test.getRuntimeSeconds() >= this.slowThreshold) { if (this.slowStream && !seenSlow.has(test.file)) { this.slowStream?.write(`${test.file}\n`); seenSlow.add(test.file); } hasSlow = true; } } if (hasSlow) { this.dispatchEvent(new Event('update')); } }, 400); } allTestsDiscovered() { this.ready = true; this.statsStale = true; this.dispatchEvent(new Event('stats')); } onExit = Promise.withResolvers(); exit() { this.exited = true; if (this.slowTimer) { clearInterval(this.slowTimer); this.slowTimer = null; } this.dispatchEvent(new Event('exit')); } addTest(test: Test) { this.tests.set(test.id, test); this.statsStale = true; } updateWorker(workerId: number, taskId: number | null) { if (this.workers.length <= workerId) { const next = [...this.workers]; next.length = workerId + 1; this.workers = next; } if (taskId === null) { this.workers = this.workers.with(workerId, undefined); } else { const test = this.tests.get(taskId)!; test.status = 'running'; test.startTime = Date.now(); this.workers = this.workers.with(workerId, test!); } this.statsStale = true; this.dispatchEvent(new Event('update')); } skipTest(testId: number, reason: SkipReason, feature?: string) { const test = this.tests.get(testId)!; test.status = 'skipped'; test.skipReason = reason; test.skipFeature = feature ?? null; test.content = ''; this.skipped += 1; this.statsStale = true; test.endTime = Date.now(); this.dispatchEvent(new Event('stats')); } testFailed(testId: number) { const test = this.tests.get(testId)!; test.status = 'failed'; test.endTime = Date.now(); test.content = ''; this.failed += 1; this.statsStale = true; this.dispatchEvent(new Event('stats')); } assertFailedTestFails(testId: number) { this.testPassed(testId); } testPassed(testId: number) { const test = this.tests.get(testId)!; test.status = 'passed'; test.endTime = Date.now(); test.content = ''; this.passed += 1; this.statsStale = true; this.dispatchEvent(new Event('stats')); } } class BasicReporter extends TestReporter { stdout(...message: string[]): void { process.stdout.write(message.join('')); } start(): void { this.timer = setInterval(() => { const { failed, passed, pending, ready, skipped, total, } = this.getStats(); if (ready) { console.log(`Total ${total}, ${passed} passed, ${skipped} skipped, ${failed} failed, ${pending} pending`); } else { console.log(`Total ${total}, ${passed} passed, ${skipped} skipped, ${failed} failed`); } const seen = new Set(); this.workers.forEach((task) => { if (!task || seen.has(task.file)) { return; } seen.add(task.file); const time = task?.getRuntimeSeconds() || 0; if (time >= this.slowThreshold) { console.log(`${task?.file} is slow. Taking ${time}s.`); } }); }, 1000); } private timer: NodeJS.Timeout | undefined; override exit(): void { super.exit(); clearInterval(this.timer); const { failed, passed, pending, skipped, total, } = this.getStats(); console.log(`Total ${total}, ${passed} passed, ${skipped} skipped, ${failed} failed, ${pending} pending`); this.onExit.resolve(); } } class TerminalUIReporter extends TestReporter { pending_stdout: string[] = []; stdout(...message: string[]) { this.pending_stdout.push(...message); this.statsStale = true; this.dispatchEvent(new Event('flush')); } start() { render(h(TerminalUI, { runner: this }), { incrementalRendering: true, exitOnCtrlC: false }).waitUntilExit().then(this.onExit.resolve); this.startSlowTimer(); } override exit() { super.exit(); process.stdin.setRawMode(false); } } export function createTestReporter(): TestReporter { if (isCI) { return new BasicReporter(); } else { return new TerminalUIReporter(); } } export function annotateFileWithURL(filePath: string) { if (supportColor) { const fileLink = link(filePath, new URL(`./test262/test262/test/${filePath}`, import.meta.url)); return `${fileLink} ${link('[GitHub]', `https://github.com/tc39/test262/blob/main/test/${filePath}`)}`; } return filePath; }