Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 != ''
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
98 changes: 98 additions & 0 deletions scripts/print-failed-nx-logs.mjs
Original file line number Diff line number Diff line change
@@ -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 <cache>/terminalOutputs/<hash>.
// 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 <nx-cache-dir> --data <workspace-data-dir>
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 <nx-cache-dir> --data <workspace-data-dir>',
)
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
}
}
109 changes: 109 additions & 0 deletions scripts/print-failed-nx-logs.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> = []

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')
})
})
Loading