From 70bf1dd113ed00bc054afd1aa66456f1f1d34964 Mon Sep 17 00:00:00 2001 From: mv-ai Date: Mon, 1 Jun 2026 14:04:45 -0400 Subject: [PATCH 1/6] feat: add --quiet mode that only reports failing suites Adds a `--quiet` flag (and `EXODUS_TEST_QUIET=1` env equivalent) that suppresses pass/skip lines and per-suite headers for passing suites, surfacing only failing suites plus the final summary. Works across the node:test reporter, CI `::group::` grouping, and the per-file bundle/ browser runner path. Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: Claude --- README.md | 2 ++ bin/index.js | 10 ++++++++-- bin/reporter.js | 30 +++++++++++++++++++++++------- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index adab6d72..80168ffd 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,8 @@ Use `--engine` (or `EXODUS_TEST_ENGINE=`) to specify one of: - `--watch` — operate in watch mode and re-run tests on file changes +- `--quiet` — only report failing tests. The final summary is still printed. Can also be enabled with `EXODUS_TEST_QUIET=1` + - `--only` — only run the tests marked with `test.only` - `--passWithNoTests` — do not error when no test files were found diff --git a/bin/index.js b/bin/index.js index c746e852..a15b79f5 100755 --- a/bin/index.js +++ b/bin/index.js @@ -97,6 +97,7 @@ function parseOptions() { coverage: getEnvFlag('EXODUS_TEST_COVERAGE'), coverageEngine: process.platform === 'win32' ? 'node' : 'c8', // c8 or node. TODO: can we use c8 on win? watch: false, + quiet: getEnvFlag('EXODUS_TEST_QUIET'), only: false, passWithNoTests: false, writeSnapshots: false, @@ -185,6 +186,9 @@ function parseOptions() { case '--watch': options.watch = true break + case '--quiet': + options.quiet = true + break case '--test-only': case '--only': options.only = true @@ -295,6 +299,7 @@ setEnv('EXODUS_TEST_ENGINE', options.engine) // e.g. 'hermes:bundle', 'node:bund setEnv('EXODUS_TEST_PLATFORM', options.binary === 'shermes' ? 'hermes' : options.binary) // e.g. 'hermes', 'node' setEnv('EXODUS_TEST_TIMEOUT', options.testTimeout) setEnv('EXODUS_TEST_DEVTOOLS', options.devtools ? '1' : '') +setEnv('EXODUS_TEST_QUIET', options.quiet ? '1' : '') setEnv('EXODUS_TEST_IS_BROWSER', isBrowserLike ? '1' : '') setEnv('EXODUS_TEST_IS_BAREBONE', options.barebone ? '1' : '') setEnv('EXODUS_TEST_ENVIRONMENT', options.bundle ? 'bundle' : '') // perhaps switch to _IS_BUNDLED? @@ -810,12 +815,13 @@ const mainWorker :Workerd.Worker = ( const tasks = files.map((file) => ({ file, task: runConcurrent(file) })) console.time(timeLabel) for (const { file, task } of tasks) { - head(file) const { ok, output, ms } = await task + if (!ok) failures.push(file) + if (options.quiet && ok) continue // quiet mode: only surface failing suites + head(file) middle(file, ok, ms) for (const chunk of output.filter((x) => x.trim())) console.log(format(chunk).trimEnd()) tail(file) - if (!ok) failures.push(file) } if (failures.length > 0) process.exitCode = 1 diff --git a/bin/reporter.js b/bin/reporter.js index f7eb8b5f..922ecdb3 100644 --- a/bin/reporter.js +++ b/bin/reporter.js @@ -33,6 +33,7 @@ const inbandFileAbsolute = fileURLToPath(import.meta.resolve('./inband.js')) const inbandFile = relative(cwd, inbandFileAbsolute) const groupCI = CI && !process.execArgv.includes('--watch') && !LERNA_PACKAGE_NAME // lerna+nx groups already +const quiet = process.env.EXODUS_TEST_QUIET === '1' export const timeLabel = color('Total time', dim) const filename = (f) => (f === inbandFile || f === inbandFileAbsolute ? 'In-band tests' : f) export const head = groupCI ? () => {} : (file) => console.log(color(`# ${filename(file)}`, 'bold')) @@ -100,9 +101,16 @@ export default async function nodeTestReporterExodus(source) { }) const log = [] - const print = (msg) => (groupCI ? log.push(msg) : console.log(msg)) + const buffered = groupCI || quiet + const print = (msg) => (buffered ? log.push(msg) : console.log(msg)) const dump = () => { - middle(file, !failedFiles.has(file)) + const ok = !failedFiles.has(file) + if (quiet && ok) { + log.length = 0 + return + } + + middle(file, ok) for (const line of log) console.log(line) log.length = 0 tail() @@ -123,7 +131,9 @@ export default async function nodeTestReporterExodus(source) { if (file !== undefined) dump() file = newFile assert(files.has(file), 'Cound not determine file') - head(file) + // quiet (non-CI): buffer the header so it only prints for failing suites (under CI, middle() emits it) + if (quiet && !groupCI) log.push(color(`# ${filename(file)}`, 'bold')) + else head(file) } const pathstr = (p) => (p[0]?.startsWith(INBAND_PREFIX) ? p.slice(1) : p).join(' > ') @@ -149,8 +159,11 @@ export default async function nodeTestReporterExodus(source) { while (delayed.length > 0) print(delayed.shift()) break case 'test:pass': - const label = data.skip ? color('⏭ SKIP ', dim) : color('✔ PASS ', 'green') - if (!pskip(path)) print(`${label}${pathstr(path)}${formatSuffix(data)}`) + if (!quiet) { + const label = data.skip ? color('⏭ SKIP ', dim) : color('✔ PASS ', 'green') + if (!pskip(path)) print(`${label}${pathstr(path)}${formatSuffix(data)}`) + } + assert(path.pop() === data.name) break case 'test:fail': @@ -188,7 +201,10 @@ export default async function nodeTestReporterExodus(source) { } dump() - for (const line of delayed) console.log(line) - for (const line of diagnostic) console.log(line) + if (!quiet) { + for (const line of delayed) console.log(line) + for (const line of diagnostic) console.log(line) + } + summary([...files], [...failedFiles]) } From 1c0613d32133db17000de09002856c9deae22792 Mon Sep 17 00:00:00 2001 From: mv-ai Date: Mon, 1 Jun 2026 14:33:45 -0400 Subject: [PATCH 2/6] fix: handle quiet mode edge cases Co-Authored-By: Claude --- bin/index.js | 20 ++++++++++++++++++-- bin/reporter.js | 7 +++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/bin/index.js b/bin/index.js index a15b79f5..5d6eb797 100755 --- a/bin/index.js +++ b/bin/index.js @@ -285,6 +285,12 @@ const setEnv = (name, value) => { process.env[name] = value === undefined ? '' : value } +// `enabled` is already the resolved flag (parseOptions folds in the env value), so it wins over +// any pre-existing env — an explicit `--quiet` must not env-conflict with `EXODUS_TEST_QUIET=0`. +const setEnvFlag = (name, enabled) => { + process.env[name] = enabled ? '1' : process.env[name] === '0' ? '0' : '' +} + const { options, patterns } = parseOptions() const engineName = `${options.engine} engine` // used for warnings to user @@ -299,7 +305,7 @@ setEnv('EXODUS_TEST_ENGINE', options.engine) // e.g. 'hermes:bundle', 'node:bund setEnv('EXODUS_TEST_PLATFORM', options.binary === 'shermes' ? 'hermes' : options.binary) // e.g. 'hermes', 'node' setEnv('EXODUS_TEST_TIMEOUT', options.testTimeout) setEnv('EXODUS_TEST_DEVTOOLS', options.devtools ? '1' : '') -setEnv('EXODUS_TEST_QUIET', options.quiet ? '1' : '') +setEnvFlag('EXODUS_TEST_QUIET', options.quiet) setEnv('EXODUS_TEST_IS_BROWSER', isBrowserLike ? '1' : '') setEnv('EXODUS_TEST_IS_BAREBONE', options.barebone ? '1' : '') setEnv('EXODUS_TEST_ENVIRONMENT', options.bundle ? 'bundle' : '') // perhaps switch to _IS_BUNDLED? @@ -810,6 +816,13 @@ const mainWorker :Workerd.Worker = ( } const { format, head, middle, tail, timeLabel, summary } = await import('./reporter.js') + const filterQuietOutput = (chunk) => + options.quiet + ? chunk + .split('\n') + .filter((line) => !/^(✔ PASS|⏭ SKIP) /u.test(line)) + .join('\n') + : chunk const failures = [] const tasks = files.map((file) => ({ file, task: runConcurrent(file) })) @@ -820,7 +833,10 @@ const mainWorker :Workerd.Worker = ( if (options.quiet && ok) continue // quiet mode: only surface failing suites head(file) middle(file, ok, ms) - for (const chunk of output.filter((x) => x.trim())) console.log(format(chunk).trimEnd()) + for (const chunk of output.map(filterQuietOutput).filter((x) => x.trim())) { + console.log(format(chunk).trimEnd()) + } + tail(file) } diff --git a/bin/reporter.js b/bin/reporter.js index 922ecdb3..bbd0d432 100644 --- a/bin/reporter.js +++ b/bin/reporter.js @@ -26,6 +26,9 @@ export const format = (chunk) => { const formatTime = (ms) => (ms ? color(` (${ms}ms)`, dim) : '') const formatSuffix = (d) => `${formatTime(d.details.duration_ms)}${d.todo ? ' # TODO' : ''}` +const isSummaryDiagnostic = (message) => + /^(suites|tests|pass|fail|cancelled|skipped|todo) \d+$/.test(message) || + /^duration_ms \d+(?:\.\d+)?$/.test(message) const cwd = process.cwd() const INBAND_PREFIX = 'EXODUS_TEST_INBAND:' @@ -187,6 +190,7 @@ export default async function nodeTestReporterExodus(source) { break case 'test:diagnostic': if (/^suites \d+$/.test(data.message)) break // we count suites = files + if (quiet && isSummaryDiagnostic(data.message)) break // summary() prints the result diagnostic.push(color(`ℹ ${data.message}`, 'blue')) break case 'test:stderr': @@ -203,6 +207,9 @@ export default async function nodeTestReporterExodus(source) { dump() if (!quiet) { for (const line of delayed) console.log(line) + } + + if (!quiet || failedFiles.size > 0) { for (const line of diagnostic) console.log(line) } From b049b104862e86ed5860727562249ec8afa6f781 Mon Sep 17 00:00:00 2001 From: mv-ai Date: Mon, 1 Jun 2026 14:47:48 -0400 Subject: [PATCH 3/6] fix: flush quiet watch output Co-Authored-By: Claude --- bin/reporter.js | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/bin/reporter.js b/bin/reporter.js index bbd0d432..e71bdf42 100644 --- a/bin/reporter.js +++ b/bin/reporter.js @@ -106,6 +106,14 @@ export default async function nodeTestReporterExodus(source) { const log = [] const buffered = groupCI || quiet const print = (msg) => (buffered ? log.push(msg) : console.log(msg)) + const dumpDiagnostics = () => { + if (!quiet || failedFiles.size > 0) { + for (const line of diagnostic) console.log(line) + } + + diagnostic.length = 0 + } + const dump = () => { const ok = !failedFiles.has(file) if (quiet && ok) { @@ -125,6 +133,14 @@ export default async function nodeTestReporterExodus(source) { let file const diagnostic = [] const delayed = [] + const resetWatchCycle = () => { + if (file !== undefined) dump() + dumpDiagnostics() + delayed.length = 0 + failedFiles.clear() + file = undefined + } + const isTopLevelTest = ({ nesting, line, column, name, file }) => nesting === 0 && line === 1 && column === 1 && file.endsWith(name) && resolve(name) === file // some events have data.file resolved, some not) const processNewFile = (data) => { @@ -186,6 +202,7 @@ export default async function nodeTestReporterExodus(source) { break case 'test:watch:drained': assert(!groupCI, 'Can not mix --watch with CI grouping') + if (quiet) resetWatchCycle() console.log(color(`ℹ waiting for changes as we are in --watch mode`, 'blue')) break case 'test:diagnostic': @@ -209,9 +226,7 @@ export default async function nodeTestReporterExodus(source) { for (const line of delayed) console.log(line) } - if (!quiet || failedFiles.size > 0) { - for (const line of diagnostic) console.log(line) - } + dumpDiagnostics() summary([...files], [...failedFiles]) } From 4c8edc2263df0335e5c13389ce4d867d4dd3c0c8 Mon Sep 17 00:00:00 2001 From: mv-ai Date: Mon, 1 Jun 2026 15:18:51 -0400 Subject: [PATCH 4/6] refactor: rename quiet watch cycle helper Co-Authored-By: Claude --- bin/reporter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/reporter.js b/bin/reporter.js index e71bdf42..41ac0669 100644 --- a/bin/reporter.js +++ b/bin/reporter.js @@ -133,7 +133,7 @@ export default async function nodeTestReporterExodus(source) { let file const diagnostic = [] const delayed = [] - const resetWatchCycle = () => { + const finishWatchCycle = () => { if (file !== undefined) dump() dumpDiagnostics() delayed.length = 0 @@ -202,7 +202,7 @@ export default async function nodeTestReporterExodus(source) { break case 'test:watch:drained': assert(!groupCI, 'Can not mix --watch with CI grouping') - if (quiet) resetWatchCycle() + if (quiet) finishWatchCycle() console.log(color(`ℹ waiting for changes as we are in --watch mode`, 'blue')) break case 'test:diagnostic': From a9fa83e4a9cdb71f8f757e910a2f2ea24bfd00a2 Mon Sep 17 00:00:00 2001 From: mv-ai Date: Mon, 1 Jun 2026 15:21:17 -0400 Subject: [PATCH 5/6] refactor: clarify node test diagnostic helper Co-Authored-By: Claude --- bin/reporter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/reporter.js b/bin/reporter.js index 41ac0669..25bd18c0 100644 --- a/bin/reporter.js +++ b/bin/reporter.js @@ -26,7 +26,7 @@ export const format = (chunk) => { const formatTime = (ms) => (ms ? color(` (${ms}ms)`, dim) : '') const formatSuffix = (d) => `${formatTime(d.details.duration_ms)}${d.todo ? ' # TODO' : ''}` -const isSummaryDiagnostic = (message) => +const isNodeTestSummaryDiagnostic = (message) => /^(suites|tests|pass|fail|cancelled|skipped|todo) \d+$/.test(message) || /^duration_ms \d+(?:\.\d+)?$/.test(message) @@ -207,7 +207,7 @@ export default async function nodeTestReporterExodus(source) { break case 'test:diagnostic': if (/^suites \d+$/.test(data.message)) break // we count suites = files - if (quiet && isSummaryDiagnostic(data.message)) break // summary() prints the result + if (quiet && isNodeTestSummaryDiagnostic(data.message)) break // summary() prints the result diagnostic.push(color(`ℹ ${data.message}`, 'blue')) break case 'test:stderr': From f1117a026346a0c585472ffe5413bd663fff23e7 Mon Sep 17 00:00:00 2001 From: mv-ai Date: Mon, 1 Jun 2026 15:30:18 -0400 Subject: [PATCH 6/6] fix: make quiet mode CLI-only Co-Authored-By: Claude --- README.md | 2 +- bin/index.js | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 80168ffd..e289ede7 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ Use `--engine` (or `EXODUS_TEST_ENGINE=`) to specify one of: - `--watch` — operate in watch mode and re-run tests on file changes -- `--quiet` — only report failing tests. The final summary is still printed. Can also be enabled with `EXODUS_TEST_QUIET=1` +- `--quiet` — only report failing tests. The final summary is still printed - `--only` — only run the tests marked with `test.only` diff --git a/bin/index.js b/bin/index.js index 5d6eb797..c5ac8226 100755 --- a/bin/index.js +++ b/bin/index.js @@ -97,7 +97,7 @@ function parseOptions() { coverage: getEnvFlag('EXODUS_TEST_COVERAGE'), coverageEngine: process.platform === 'win32' ? 'node' : 'c8', // c8 or node. TODO: can we use c8 on win? watch: false, - quiet: getEnvFlag('EXODUS_TEST_QUIET'), + quiet: false, only: false, passWithNoTests: false, writeSnapshots: false, @@ -285,12 +285,6 @@ const setEnv = (name, value) => { process.env[name] = value === undefined ? '' : value } -// `enabled` is already the resolved flag (parseOptions folds in the env value), so it wins over -// any pre-existing env — an explicit `--quiet` must not env-conflict with `EXODUS_TEST_QUIET=0`. -const setEnvFlag = (name, enabled) => { - process.env[name] = enabled ? '1' : process.env[name] === '0' ? '0' : '' -} - const { options, patterns } = parseOptions() const engineName = `${options.engine} engine` // used for warnings to user @@ -305,7 +299,7 @@ setEnv('EXODUS_TEST_ENGINE', options.engine) // e.g. 'hermes:bundle', 'node:bund setEnv('EXODUS_TEST_PLATFORM', options.binary === 'shermes' ? 'hermes' : options.binary) // e.g. 'hermes', 'node' setEnv('EXODUS_TEST_TIMEOUT', options.testTimeout) setEnv('EXODUS_TEST_DEVTOOLS', options.devtools ? '1' : '') -setEnvFlag('EXODUS_TEST_QUIET', options.quiet) +process.env.EXODUS_TEST_QUIET = options.quiet ? '1' : '' // internal signal for the reporter setEnv('EXODUS_TEST_IS_BROWSER', isBrowserLike ? '1' : '') setEnv('EXODUS_TEST_IS_BAREBONE', options.barebone ? '1' : '') setEnv('EXODUS_TEST_ENVIRONMENT', options.bundle ? 'bundle' : '') // perhaps switch to _IS_BUNDLED?