diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2553089411..e25c92d632 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -34,6 +34,11 @@ jobs: coverage: name: Coverage runs-on: ubuntu-latest + # One Vitest worker per package. nx.json still runs 4 packages at once. + # More workers time out on the 4-core runner. Same cap as `pnpm test:coverage`. + # Not an Nx input, so the remote cache hash is unchanged. + env: + VITEST_MAX_WORKERS: "1" permissions: contents: read pull-requests: write # the last step posts the table as a PR comment; only it reads the token @@ -54,7 +59,9 @@ jobs: # very PR under test. Both runs and the comparison use this copy, so all # three agree on the version. - name: Stage the coverage script - run: cp scripts/coverage-check.mjs "$RUNNER_TEMP/coverage-check.mjs" + run: | + cp scripts/coverage-check.mjs "$RUNNER_TEMP/coverage-check.mjs" + cp scripts/print-failed-nx-logs.mjs "$RUNNER_TEMP/print-failed-nx-logs.mjs" # The set of packages to measure is decided once, here, so that the base # run measures exactly what the head run does. `--with-target` keeps out # projects that have no test:coverage script, so a docs-only PR resolves @@ -73,7 +80,14 @@ jobs: PROJECTS: ${{ steps.affected.outputs.list }} run: | rm -rf packages/*/coverage + set +e pnpm exec nx run-many --targets=test:coverage --projects="$PROJECTS" --outputStyle=static -- --coverage.reporter=json-summary --coverage.include='src/**' + status=$? + set -e + if [ "$status" -ne 0 ]; then + node --disable-warning=ExperimentalWarning "$RUNNER_TEMP/print-failed-nx-logs.mjs" --cache .nx/cache --data .nx/workspace-data || echo "Could not print failed task logs" + exit "$status" + fi node "$RUNNER_TEMP/coverage-check.mjs" --collect "$RUNNER_TEMP/cov-head" --expect "$PROJECTS" # Re-measuring the merge-base is normally a set of Nx Cloud cache # restores seeded by the Coverage workflow on main: the task hashes are @@ -102,7 +116,14 @@ jobs: fi # Extra reporters/include so a merge-base whose scripts are still # `vitest run --coverage` still writes coverage-summary.json. + set +e pnpm exec nx run-many --targets=test:coverage --projects="$base_list" --outputStyle=static -- --coverage.reporter=json-summary --coverage.include='src/**' + status=$? + set -e + if [ "$status" -ne 0 ]; then + node --disable-warning=ExperimentalWarning "$RUNNER_TEMP/print-failed-nx-logs.mjs" --cache .nx/cache --data .nx/workspace-data || echo "Could not print failed task logs" + exit "$status" + fi node "$RUNNER_TEMP/coverage-check.mjs" --collect "$RUNNER_TEMP/cov-base" --expect "$base_list" - name: Compare if: steps.affected.outputs.list != '' diff --git a/package.json b/package.json index cb31d86c42..07d7197e54 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:knip": "knip", "test:docs": "tsx scripts/verify-links.ts", "test:dts": "node scripts/scan-dangling-dts.mjs", - "test:maintainer": "vitest run --root scripts/maintainer && vitest run scripts/model-sync scripts/publish-staged-docs.test.ts && vitest run scripts/coverage-check.test.ts", + "test:maintainer": "vitest run --root scripts/maintainer && vitest run scripts/model-sync scripts/publish-staged-docs.test.ts && vitest run scripts/coverage-check.test.ts scripts/print-failed-nx-logs.test.ts", "test:ai-review": "vitest run --root agent-scripts/ai-review", "ai-review": "node agent-scripts/ai-review/run.ts", "maintainer:sweep": "node scripts/maintainer/sweep.ts", diff --git a/scripts/print-failed-nx-logs.mjs b/scripts/print-failed-nx-logs.mjs new file mode 100644 index 0000000000..3c4c4b5754 --- /dev/null +++ b/scripts/print-failed-nx-logs.mjs @@ -0,0 +1,98 @@ +// Print the terminal output of Nx tasks that ran and failed. +// +// Nx 23 keeps a successful task in the workspace-data cache_outputs table +// (code 0) and writes every task's log to /terminalOutputs/. +// A failure is a log file whose hash is missing there, or stored with a +// non-zero code. Nx Cloud's CI summary does not print that log. +// +// Usage: +// node scripts/print-failed-nx-logs.mjs --cache --data +import { DatabaseSync } from 'node:sqlite' +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' + +function arg(name) { + const i = process.argv.indexOf(name) + return i === -1 ? undefined : process.argv[i + 1] +} + +const cacheDir = arg('--cache') +const dataDir = arg('--data') +if (!cacheDir || !dataDir) { + console.error( + 'Usage: print-failed-nx-logs.mjs --cache --data ', + ) + process.exit(2) +} + +const outputsDir = join(cacheDir, 'terminalOutputs') +if (!existsSync(outputsDir)) { + console.error(`No Nx terminal output directory at ${outputsDir}`) + process.exit(0) +} + +const db = openCacheDb(dataDir) +const successes = new Set() +if (db) { + for (const row of db.prepare('SELECT hash, code FROM cache_outputs').all()) { + if (row.code === 0) successes.add(String(row.hash)) + } +} + +const hashes = readdirSync(outputsDir).filter((name) => { + const path = join(outputsDir, name) + return statSync(path).isFile() && !successes.has(name) +}) + +if (hashes.length === 0) { + console.error(`No failed Nx task logs in ${outputsDir}`) + process.exit(0) +} + +for (const hash of hashes) { + const text = readFileSync(join(outputsDir, hash), 'utf8') + console.log(`\nfailed Nx task ${label(db, hash)}`) + process.stdout.write(text) + if (!text.endsWith('\n')) console.log() +} + +function openCacheDb(dir) { + if (!existsSync(dir)) { + console.error(`No Nx workspace-data directory at ${dir}`) + return null + } + for (const name of readdirSync(dir)) { + if (!name.endsWith('.db')) continue + try { + const database = new DatabaseSync(join(dir, name), { readOnly: true }) + const table = database + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'cache_outputs'", + ) + .get() + if (table) return database + database.close() + } catch (error) { + console.error(`Could not read ${name}: ${error.message}`) + } + } + console.error(`No cache_outputs table under ${dir}`) + return null +} + +function label(database, hash) { + if (!database) return hash + try { + const row = database + .prepare( + 'SELECT project, target, configuration FROM task_details WHERE hash = ?', + ) + .get(hash) + if (!row) return hash + return row.configuration + ? `${row.project}:${row.target}:${row.configuration}` + : `${row.project}:${row.target}` + } catch { + return hash + } +} diff --git a/scripts/print-failed-nx-logs.test.ts b/scripts/print-failed-nx-logs.test.ts new file mode 100644 index 0000000000..ec282ac2d4 --- /dev/null +++ b/scripts/print-failed-nx-logs.test.ts @@ -0,0 +1,109 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' + +const SCRIPT = fileURLToPath( + new URL('./print-failed-nx-logs.mjs', import.meta.url), +) + +const temps: Array = [] + +afterEach(() => { + for (const dir of temps.splice(0)) + rmSync(dir, { recursive: true, force: true }) +}) + +function tmp() { + const dir = mkdtempSync(join(tmpdir(), 'nx-logs-')) + temps.push(dir) + return dir +} + +function run(cache: string, data: string) { + return spawnSync( + process.execPath, + [ + '--disable-warning=ExperimentalWarning', + SCRIPT, + '--cache', + cache, + '--data', + data, + ], + { encoding: 'utf8' }, + ) +} + +function fixture() { + const root = tmp() + const cache = join(root, 'cache') + const data = join(root, 'data') + mkdirSync(join(cache, 'terminalOutputs'), { recursive: true }) + mkdirSync(data, { recursive: true }) + writeFileSync( + join(cache, 'terminalOutputs', 'failed-hash'), + 'FAIL marker from the failing test\n', + ) + writeFileSync( + join(cache, 'terminalOutputs', 'ok-hash'), + 'PASS this task succeeded\n', + ) + const db = new DatabaseSync(join(data, 'cache.db')) + db.exec(` + CREATE TABLE cache_outputs ( + hash TEXT PRIMARY KEY NOT NULL, + code INTEGER NOT NULL, + size INTEGER NOT NULL + ); + CREATE TABLE task_details ( + hash TEXT PRIMARY KEY NOT NULL, + project TEXT NOT NULL, + target TEXT NOT NULL, + configuration TEXT + ); + `) + db.prepare( + 'INSERT INTO cache_outputs (hash, code, size) VALUES (?, ?, ?)', + ).run('ok-hash', 0, 1) + db.prepare( + 'INSERT INTO task_details (hash, project, target, configuration) VALUES (?, ?, ?, ?)', + ).run('failed-hash', '@tanstack/ai-harness', 'test:coverage', null) + db.close() + return { cache, data } +} + +describe('print-failed-nx-logs', () => { + it('prints the failed task log and skips a cached success', () => { + const { cache, data } = fixture() + const result = run(cache, data) + expect(result.status).toBe(0) + expect(result.stdout).toContain( + 'failed Nx task @tanstack/ai-harness:test:coverage', + ) + expect(result.stdout).toContain('FAIL marker from the failing test') + expect(result.stdout).not.toContain('PASS this task succeeded') + }) + + it('prints a task stored with a non-zero code', () => { + const { cache, data } = fixture() + const db = new DatabaseSync(join(data, 'cache.db')) + db.prepare( + 'INSERT INTO cache_outputs (hash, code, size) VALUES (?, ?, ?)', + ).run('failed-hash', 1, 1) + db.close() + const result = run(cache, data) + expect(result.status).toBe(0) + expect(result.stdout).toContain('FAIL marker from the failing test') + }) + + it('exits 0 when the terminal output directory is missing', () => { + const root = tmp() + const result = run(join(root, 'cache'), join(root, 'data')) + expect(result.status).toBe(0) + expect(result.stderr).toContain('No Nx terminal output directory') + }) +})