diff --git a/CHANGELOG.md b/CHANGELOG.md index 05bb2e0..f7c1bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Preserve request causes, cancellation/timeout classifications, and + aggregated concurrency errors instead of silently discarding them. +- Report unreadable files and unexpected checker failures while allowing the + rest of the scan to continue. +- Improve CLI and GitHub Action validation and error messages, including + report-write failures and auxiliary publication warnings. + +### Added + +- JSON, Markdown, and console reporting for scan-integrity errors, including + file paths and messages. +- For programmatic API consumers, `ScanReport.fileErrors` and + `ScanSummary.filesFailed` now expose files that could not be scanned. +- Error codes are included in per-link JSON results when available. + ## [1.0.0] - 2026-07-22 ### Added diff --git a/README.md b/README.md index c10e9b8..c5f31a3 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ npx urldn-link-check docs/ --verbose | `--verbose` | Log every URL as it is checked | `false` | | `--output ` | Write the JSON report to this file | — | +Unreadable or otherwise failed files are reported in the scan instead of +aborting it, so links from other files can still be checked. A scan with file +errors is considered incomplete and exits with code `2`. + ## GitHub Action usage ```yaml @@ -167,6 +171,9 @@ See [`examples/workflows`](./examples/workflows) for a more complete setup | `total-links` | Total number of unique links checked | | `report-path` | Path to the written JSON report, if `output` was set | +The action also reports unreadable or failed files in its report and fails the +step when the scan is incomplete. + When run on a pull request, the action posts a single, self-updating comment with the full Markdown report (subsequent runs edit the same comment instead of piling up new ones), and it always writes a step summary via @@ -189,6 +196,14 @@ the `Config` object in the [programmatic API](#programmatic-api)): | Fail on HTTP | `--fail-on-http` | `fail-on-http` | `failOnHttp` | | Output file | `--output` | `output` | `output` | +### CLI exit codes + +- **`0`** — the scan completed cleanly and no enabled link policy failed. +- **`1`** — the scan completed, but a configured link policy failed (for + example, broken links, redirects, or insecure HTTP links). +- **`2`** — a configuration or scan-integrity error occurred, including + unreadable or otherwise failed files. + ## What gets checked For every unique URL found across your Markdown/MDX files: @@ -219,7 +234,9 @@ blocks and inline code spans are intentionally skipped. redirects, `✖` broken, plus a summary line. - **JSON** (`--json` / `output` input) — a stable, scriptable shape with a `summary` object and a `links` array, each entry including every file, - line, and column where that URL was referenced. + line, and column where that URL was referenced. The report includes + `fileErrors` for files that could not be read, `summary.filesFailed`, and + `links[].errorCode` when the checker provides an error code. - **Markdown** (`--markdown`) — the same content used for GitHub Step Summaries and PR comments, safe to pipe into any other Markdown-consuming tool. diff --git a/src/action-entry.ts b/src/action-entry.ts index c0d6608..0b38afb 100644 --- a/src/action-entry.ts +++ b/src/action-entry.ts @@ -1,3 +1,8 @@ import { runAction } from './github.js'; +import * as core from '@actions/core'; +import { formatError } from './errors.js'; -void runAction(); +void runAction().catch((error: unknown) => { + core.setFailed(formatError(error)); + core.debug(formatError(error, true)); +}); diff --git a/src/checker.ts b/src/checker.ts index b3cbea0..01c7291 100644 --- a/src/checker.ts +++ b/src/checker.ts @@ -1,4 +1,5 @@ -import axios, { type AxiosError } from 'axios'; +import axios from 'axios'; +import { formatError, getErrorCode } from './errors.js'; import { isInsecureHttp, isValidUrl } from './utils.js'; export type LinkStatus = 'ok' | 'redirect' | 'broken' | 'timeout' | 'malformed' | 'insecure-http'; @@ -16,6 +17,7 @@ export interface CheckResult { redirectChain: string[]; finalUrl?: string; error?: string; + errorCode?: string; isHttp: boolean; durationMs: number; } @@ -85,9 +87,15 @@ export async function checkUrl(url: string, options: CheckOptions): Promise ${MAX_REDIRECTS})`); } + +function isTimeoutOrCancellation(error: unknown): boolean { + const code = getErrorCode(error); + if (code === 'ECONNABORTED' || code === 'ETIMEDOUT' || code === 'ERR_CANCELED') return true; + return error instanceof Error && /timeout|abort|cancel/i.test(error.message); +} diff --git a/src/cli.ts b/src/cli.ts index 62cfc0f..6b0af9c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,8 @@ import { resolveConfig } from './config.js'; import { runScan } from './scanner.js'; import { printConsoleReport, printVerboseLine } from './formatter.js'; import { toJsonReport, toMarkdownReport, writeJsonReport } from './report.js'; +import { formatError } from './errors.js'; +import { ZodError } from 'zod'; import packageJson from '../package.json' with { type: 'json' }; const program = new Command(); @@ -49,25 +51,26 @@ interface CliOptions { async function main(path: string, rawOptions: Record): Promise { const options = rawOptions as unknown as CliOptions; - const config = resolveConfig({ - path, - json: options.json, - markdown: options.markdown, - failOnBroken: options.failOnBroken, - failOnRedirect: options.failOnRedirect, - failOnHttp: options.failOnHttp, - maxUrlLength: Number(options.maxUrlLength), - ignore: options.ignore ?? [], - timeout: Number(options.timeout), - concurrency: Number(options.concurrency), - verbose: options.verbose, - output: options.output, - }); - - const useSpinner = !config.json && !config.markdown && !config.verbose && process.stdout.isTTY; - const spinner = useSpinner ? ora('Scanning for links...').start() : undefined; + let spinner: ReturnType | undefined; try { + const config = resolveConfig({ + path, + json: options.json, + markdown: options.markdown, + failOnBroken: options.failOnBroken, + failOnRedirect: options.failOnRedirect, + failOnHttp: options.failOnHttp, + maxUrlLength: parseNumericOption('--max-url-length', options.maxUrlLength), + ignore: options.ignore ?? [], + timeout: parseNumericOption('--timeout', options.timeout), + concurrency: parseNumericOption('--concurrency', options.concurrency), + verbose: options.verbose, + output: options.output, + }); + + const useSpinner = !config.json && !config.markdown && !config.verbose && process.stdout.isTTY; + spinner = useSpinner ? ora('Scanning for links...').start() : undefined; const report = await runScan(config, { onFileFound: (files) => { if (spinner) spinner.text = `Found ${files.length} file(s). Checking links...`; @@ -99,15 +102,30 @@ async function main(path: string, rawOptions: Record): Promise< (config.failOnRedirect && report.summary.redirects > 0) || (config.failOnHttp && report.summary.insecureHttp > 0); - process.exitCode = shouldFail ? 1 : 0; + process.exitCode = report.fileErrors.length > 0 ? 2 : shouldFail ? 1 : 0; } catch (error) { spinner?.fail('Scan failed'); - console.error(chalk.red(error instanceof Error ? error.message : String(error))); + if (error instanceof ZodError) { + console.error(chalk.red('Invalid configuration:')); + for (const issue of error.issues) { + console.error(chalk.red(`- ${issue.path.join('.') || 'options'}: ${issue.message}`)); + } + } else { + console.error(chalk.red(formatError(error, options.verbose))); + } process.exitCode = 2; } } +function parseNumericOption(flag: string, value: string): number { + const parsed = Number(value); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid value for ${flag}: "${value}" (expected a positive integer).`); + } + return parsed; +} + program.parseAsync(process.argv).catch((error: unknown) => { - console.error(chalk.red(error instanceof Error ? error.message : String(error))); + console.error(chalk.red(formatError(error))); process.exitCode = 2; }); diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..9ab7d99 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,57 @@ +/** Returns a useful message for an arbitrary thrown value. */ +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +/** Returns the first error code found on an error in the cause chain. */ +export function getErrorCode(error: unknown): string | undefined { + let current: unknown = error; + const seen = new Set(); + + while (current !== undefined && current !== null && !seen.has(current)) { + seen.add(current); + if (typeof current === 'object' && 'code' in current) { + const code = (current as { code?: unknown }).code; + if (typeof code === 'string') return code; + } + current = + typeof current === 'object' && current !== null && 'cause' in current + ? (current as { cause?: unknown }).cause + : undefined; + } + + return undefined; +} + +/** + * Formats an error message and its cause chain. Stacks are included only + * when requested, so callers can use the same formatter for user output and + * debug logs. + */ +export function formatError(error: unknown, includeStack = false): string { + const messages: string[] = []; + const stacks: string[] = []; + const seen = new Set(); + + const visit = (current: unknown): void => { + if (current === undefined || current === null || seen.has(current)) return; + seen.add(current); + messages.push(getErrorMessage(current)); + if (includeStack && current instanceof Error && current.stack) stacks.push(current.stack); + + if (current instanceof AggregateError) { + for (const nested of current.errors) visit(nested); + } + + const cause = + typeof current === 'object' && 'cause' in current ? (current as { cause?: unknown }).cause : undefined; + visit(cause); + }; + + visit(error); + + const message = messages.join(' (caused by: '); + const chain = messages.length > 1 ? `${message}${')'.repeat(messages.length - 1)}` : message; + return includeStack && stacks.length > 0 ? `${chain}\n${stacks.join('\n')}` : chain; +} diff --git a/src/formatter.ts b/src/formatter.ts index c5c7c5f..ad9ad64 100644 --- a/src/formatter.ts +++ b/src/formatter.ts @@ -102,5 +102,12 @@ export function printConsoleReport(report: ScanReport): void { `${chalk.red(`${summary.malformed} malformed`)} ${chalk.yellow(`${summary.insecureHttp} http`)} ` + `${chalk.cyan(`${summary.tooLong} long`)}`, ); + if (report.fileErrors.length > 0) { + console.log(chalk.red(`\n✖ File errors (${report.fileErrors.length})`)); + for (const fileError of report.fileErrors) { + console.log(` ${chalk.red('✖')} ${fileError.file}`); + console.log(chalk.dim(` ${fileError.message}`)); + } + } console.log(''); } diff --git a/src/github.ts b/src/github.ts index 7e0b6a4..6e9f1d7 100644 --- a/src/github.ts +++ b/src/github.ts @@ -3,20 +3,38 @@ import * as github from '@actions/github'; import { resolveConfig } from './config.js'; import { runScan } from './scanner.js'; import { toMarkdownReport, writeJsonReport } from './report.js'; +import { formatError } from './errors.js'; +import { ZodError } from 'zod'; const COMMENT_MARKER = ''; -function getBooleanInput(name: string, fallback: boolean): boolean { +export function getBooleanInput(name: string, fallback: boolean): boolean { const raw = core.getInput(name); if (!raw) return fallback; - return raw.toLowerCase() === 'true'; + switch (raw.trim().toLowerCase()) { + case 'true': + case 'yes': + case 'on': + case '1': + return true; + case 'false': + case 'no': + case 'off': + case '0': + return false; + default: + throw new Error(`Invalid value for "${name}": "${raw}" (expected true or false).`); + } } -function getNumberInput(name: string, fallback: number): number { +export function getNumberInput(name: string, fallback: number): number { const raw = core.getInput(name); if (!raw) return fallback; const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : fallback; + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid value for "${name}": "${raw}" (expected a positive number).`); + } + return parsed; } /** Main entrypoint used by `action.yml` (`node dist/github.js`). */ @@ -44,7 +62,11 @@ export async function runAction(): Promise { const report = await runScan(config); const markdown = toMarkdownReport(report); - await core.summary.addRaw(markdown).write(); + try { + await core.summary.addRaw(markdown).write(); + } catch (error) { + core.warning(`Failed to write step summary: ${formatError(error)}`); + } if (output) { await writeJsonReport(report, output); @@ -60,7 +82,11 @@ export async function runAction(): Promise { const pullRequest = context.payload.pull_request; if (commentOnPr && token && pullRequest) { - await upsertPullRequestComment(token, markdown); + try { + await upsertPullRequestComment(token, markdown); + } catch (error) { + core.warning(`Failed to update PR comment: ${formatError(error)}`); + } } else if (commentOnPr && pullRequest && !token) { core.warning('Skipping PR comment: no github-token was provided.'); } @@ -68,18 +94,29 @@ export async function runAction(): Promise { const shouldFail = (failOnBroken && report.summary.broken > 0) || (failOnRedirect && report.summary.redirects > 0) || - (failOnHttp && report.summary.insecureHttp > 0); + (failOnHttp && report.summary.insecureHttp > 0) || + report.fileErrors.length > 0; if (shouldFail) { core.setFailed( `Link check failed: ${report.summary.broken} broken, ${report.summary.redirects} redirects, ` + - `${report.summary.insecureHttp} insecure http link(s).`, + `${report.summary.insecureHttp} insecure http link(s), ${report.fileErrors.length} file error(s).`, ); } else { core.info('Link check passed.'); } } catch (error) { - core.setFailed(error instanceof Error ? error.message : String(error)); + if (error instanceof ZodError) { + core.setFailed( + [ + 'Invalid configuration:', + ...error.issues.map((issue) => `- ${issue.path.join('.') || 'options'}: ${issue.message}`), + ].join('\n'), + ); + } else { + core.setFailed(formatError(error)); + } + core.debug(formatError(error, true)); } } diff --git a/src/index.ts b/src/index.ts index 627ec95..bd45ac7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,3 +14,5 @@ export { toJsonReport, toMarkdownReport, writeJsonReport, writeMarkdownReport } export type { JsonReport } from './report.js'; export { printConsoleReport, printVerboseLine } from './formatter.js'; + +export { formatError, getErrorCode, getErrorMessage } from './errors.js'; diff --git a/src/report.ts b/src/report.ts index cb04f23..31eb8a7 100644 --- a/src/report.ts +++ b/src/report.ts @@ -1,4 +1,5 @@ -import { writeFile } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; import type { ScanReport, ScannedLinkResult } from './scanner.js'; const URLDN_SHORTENER_URL = 'https://www.urldn.com'; @@ -7,6 +8,7 @@ export interface JsonReport { generatedAt: string; summary: ScanReport['summary']; files: string[]; + fileErrors: ScanReport['fileErrors']; links: Array<{ url: string; status: ScannedLinkResult['status']; @@ -14,6 +16,7 @@ export interface JsonReport { redirectChain: string[]; finalUrl?: string; error?: string; + errorCode?: string; isHttp: boolean; isDuplicate: boolean; isTooLong: boolean; @@ -28,6 +31,7 @@ export function toJsonReport(report: ScanReport): JsonReport { generatedAt: new Date().toISOString(), summary: report.summary, files: report.files, + fileErrors: report.fileErrors, links: report.results.map((result) => ({ url: result.url, status: result.status, @@ -35,6 +39,7 @@ export function toJsonReport(report: ScanReport): JsonReport { redirectChain: result.redirectChain, finalUrl: result.finalUrl, error: result.error, + errorCode: result.errorCode, isHttp: result.isHttp, isDuplicate: result.isDuplicate, isTooLong: result.isTooLong, @@ -46,8 +51,13 @@ export function toJsonReport(report: ScanReport): JsonReport { /** Writes the JSON report to disk at `outputPath`. */ export async function writeJsonReport(report: ScanReport, outputPath: string): Promise { - const json = toJsonReport(report); - await writeFile(outputPath, `${JSON.stringify(json, null, 2)}\n`, 'utf-8'); + try { + await mkdir(dirname(outputPath), { recursive: true }); + const json = toJsonReport(report); + await writeFile(outputPath, `${JSON.stringify(json, null, 2)}\n`, 'utf-8'); + } catch (error) { + throw new Error(`Failed to write JSON report to "${outputPath}"`, { cause: error }); + } } /** @@ -74,17 +84,29 @@ export function toMarkdownReport(report: ScanReport): string { lines.push(`| 🚫 Malformed | ${summary.malformed} |`); lines.push(`| 🔓 Insecure HTTP | ${summary.insecureHttp} |`); lines.push(`| 📏 Too long | ${summary.tooLong} |`); + lines.push(`| 📁 Files failed | ${summary.filesFailed} |`); lines.push(''); + if (report.fileErrors.length > 0) { + lines.push('### File errors'); + lines.push(''); + for (const fileError of report.fileErrors) { + lines.push(`- \`${fileError.file}\`: ${fileError.message}`); + } + lines.push(''); + } + const problems = report.results.filter( (r) => r.status !== 'ok' || r.isHttp || r.isTooLong || r.isDuplicate, ); - if (problems.length === 0) { + if (problems.length === 0 && report.fileErrors.length === 0) { lines.push('✨ No issues found — every link looks good!'); return lines.join('\n'); } + if (problems.length === 0) return lines.join('\n'); + lines.push('### Issues'); lines.push(''); @@ -135,5 +157,10 @@ function statusEmoji(result: ScannedLinkResult): string { /** Writes the Markdown report to disk at `outputPath`. */ export async function writeMarkdownReport(report: ScanReport, outputPath: string): Promise { - await writeFile(outputPath, `${toMarkdownReport(report)}\n`, 'utf-8'); + try { + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${toMarkdownReport(report)}\n`, 'utf-8'); + } catch (error) { + throw new Error(`Failed to write Markdown report to "${outputPath}"`, { cause: error }); + } } diff --git a/src/scanner.ts b/src/scanner.ts index 9e2c7a0..f7fe75b 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -4,7 +4,8 @@ import type { Config } from './config.js'; import { DEFAULT_FILE_PATTERNS, DEFAULT_IGNORE_PATTERNS } from './config.js'; import { extractLinksFromMarkdown, type ExtractedLink } from './markdown.js'; import { checkUrl, type CheckResult } from './checker.js'; -import { runWithConcurrency, matchesIgnorePattern } from './utils.js'; +import { isInsecureHttp, runWithConcurrency, matchesIgnorePattern } from './utils.js'; +import { formatError, getErrorCode } from './errors.js'; export interface LinkOccurrence extends ExtractedLink { file: string; @@ -18,6 +19,7 @@ export interface ScannedLinkResult extends CheckResult { export interface ScanSummary { filesScanned: number; + filesFailed: number; totalLinks: number; uniqueLinks: number; ok: number; @@ -35,6 +37,7 @@ export interface ScanReport { summary: ScanSummary; results: ScannedLinkResult[]; files: string[]; + fileErrors: Array<{ file: string; message: string }>; } export interface ScanEvents { @@ -77,9 +80,16 @@ export async function runScan(config: Config, events: ScanEvents = {}): Promise< events.onFileFound?.(files); const occurrencesByUrl = new Map(); + const fileErrors: Array<{ file: string; message: string }> = []; for (const file of files) { - const content = await readFile(file, 'utf-8'); + let content: string; + try { + content = await readFile(file, 'utf-8'); + } catch (error) { + fileErrors.push({ file, message: formatError(error) }); + continue; + } const links = extractLinksFromMarkdown(content); for (const link of links) { if (matchesIgnorePattern(link.url, config.ignore)) continue; @@ -94,27 +104,49 @@ export async function runScan(config: Config, events: ScanEvents = {}): Promise< const check = events.checkUrlFn ?? ((url: string, timeout: number) => checkUrl(url, { timeout })); const results = await runWithConcurrency(uniqueUrls, config.concurrency, async (url) => { - const checkResult = await check(url, config.timeout); const occurrences = occurrencesByUrl.get(url) ?? []; - const scanned: ScannedLinkResult = { - ...checkResult, - occurrences, - isDuplicate: occurrences.length > 1, - isTooLong: url.length > config.maxUrlLength, - }; + let scanned: ScannedLinkResult; + try { + const checkResult = await check(url, config.timeout); + scanned = { + ...checkResult, + occurrences, + isDuplicate: occurrences.length > 1, + isTooLong: url.length > config.maxUrlLength, + }; + } catch (error) { + scanned = { + url, + status: 'broken', + redirectChain: [], + isHttp: isInsecureHttp(url), + durationMs: 0, + error: formatError(error), + errorCode: getErrorCode(error), + occurrences, + isDuplicate: occurrences.length > 1, + isTooLong: url.length > config.maxUrlLength, + }; + } checkedCount += 1; events.onLinkChecked?.(scanned, checkedCount, uniqueUrls.length); return scanned; }); - const summary = summarize(results, files.length, started); + const summary = summarize(results, files.length, fileErrors.length, started); - return { config, summary, results, files }; + return { config, summary, results, files, fileErrors }; } -function summarize(results: ScannedLinkResult[], filesScanned: number, started: number): ScanSummary { +function summarize( + results: ScannedLinkResult[], + filesScanned: number, + filesFailed: number, + started: number, +): ScanSummary { const summary: ScanSummary = { filesScanned, + filesFailed, totalLinks: results.reduce((total, r) => total + r.occurrences.length, 0), uniqueLinks: results.length, ok: 0, diff --git a/src/utils.ts b/src/utils.ts index 7f095ed..c5e1cf2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -22,7 +22,11 @@ export async function runWithConcurrency( const poolSize = Math.max(1, Math.min(limit, items.length || 1)); const pool = Array.from({ length: poolSize }, () => next()); - await Promise.all(pool); + const settled = await Promise.allSettled(pool); + const errors = settled + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map((result) => result.reason as unknown); + if (errors.length > 0) throw new AggregateError(errors, 'One or more concurrent tasks failed'); return results; } diff --git a/test/checker.test.ts b/test/checker.test.ts index d64c437..96342a9 100644 --- a/test/checker.test.ts +++ b/test/checker.test.ts @@ -1,5 +1,14 @@ -import { describe, expect, it } from 'vitest'; -import { checkUrl } from '../src/checker.js'; +import axios from 'axios'; +import { describe, expect, it, vi } from 'vitest'; +import { checkUrl, performRequest } from '../src/checker.js'; + +vi.mock('axios', () => ({ + default: { + create: vi.fn(), + isAxiosError: (error: unknown) => + typeof error === 'object' && error !== null && 'isAxiosError' in error, + }, +})); describe('checkUrl', () => { it('classifies a 200 response as ok', async () => { @@ -91,4 +100,53 @@ describe('checkUrl', () => { expect(result.status).toBe('broken'); expect(result.error).toContain('ENOTFOUND'); }); + + it('includes a wrapped cause chain in network errors', async () => { + const headError = new Error('HEAD failed'); + const getError = new Error('GET failed', { cause: headError }); + const result = await checkUrl('https://example.com/failure', { + timeout: 5000, + requestFn: async () => { + throw getError; + }, + }); + + expect(result.error).toContain('GET failed'); + expect(result.error).toContain('HEAD failed'); + }); + + it('reports the actual value for non-Error throws', async () => { + const result = await checkUrl('https://example.com/failure', { + timeout: 5000, + requestFn: async () => { + throw 'connection refused'; + }, + }); + + expect(result.error).toContain('connection refused'); + }); +}); + +describe('performRequest', () => { + it('does not retry a timed-out HEAD request with GET', async () => { + const head = vi.fn().mockRejectedValue(Object.assign(new Error('request timeout'), { code: 'ETIMEDOUT' })); + const get = vi.fn(); + vi.mocked(axios.create).mockReturnValue({ head, get } as never); + + await expect(performRequest('https://example.com/slow', 100)).rejects.toMatchObject({ + code: 'ETIMEDOUT', + }); + expect(get).not.toHaveBeenCalled(); + }); + + it('preserves the HEAD error as the cause when GET also fails', async () => { + const headError = new Error('HEAD rejected'); + const head = vi.fn().mockRejectedValue(headError); + const get = vi.fn().mockRejectedValue(new Error('GET rejected')); + vi.mocked(axios.create).mockReturnValue({ head, get } as never); + + await expect(performRequest('https://example.com/failure', 100)).rejects.toMatchObject({ + cause: headError, + }); + }); }); diff --git a/test/github.test.ts b/test/github.test.ts new file mode 100644 index 0000000..473f2fd --- /dev/null +++ b/test/github.test.ts @@ -0,0 +1,77 @@ +import * as core from '@actions/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getBooleanInput, getNumberInput, runAction } from '../src/github.js'; + +vi.mock('@actions/github', () => ({ + context: { + payload: { pull_request: { number: 42 } }, + repo: { owner: 'owner', repo: 'repo' }, + }, + getOctokit: vi.fn(() => ({ + rest: { + issues: { + listComments: vi.fn().mockRejectedValue(new Error('comment service unavailable')), + }, + }, + })), +})); + +vi.mock('../src/scanner.js', () => ({ + runScan: vi.fn(async () => ({ + config: {}, + files: [], + results: [], + fileErrors: [], + summary: { + filesScanned: 0, + filesFailed: 0, + totalLinks: 0, + uniqueLinks: 0, + ok: 0, + redirects: 0, + broken: 0, + timeouts: 0, + malformed: 0, + insecureHttp: 0, + tooLong: 0, + durationMs: 0, + }, + })), +})); + +vi.mock('../src/report.js', () => ({ + toMarkdownReport: vi.fn(() => '# report'), + writeJsonReport: vi.fn(), +})); + +describe('GitHub Action input validation', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('rejects unrecognized boolean values', () => { + vi.spyOn(core, 'getInput').mockReturnValue('sometimes'); + expect(() => getBooleanInput('fail-on-broken', true)).toThrow('fail-on-broken'); + expect(() => getBooleanInput('fail-on-broken', true)).toThrow('sometimes'); + }); + + it('rejects invalid numeric values instead of using the fallback', () => { + vi.spyOn(core, 'getInput').mockReturnValue('not-a-number'); + expect(() => getNumberInput('max-url-length', 80)).toThrow('max-url-length'); + expect(() => getNumberInput('max-url-length', 80)).toThrow('not-a-number'); + }); + + it('downgrades PR comment failures to warnings', async () => { + vi.spyOn(core, 'getInput').mockImplementation((name) => (name === 'github-token' ? 'token' : '')); + vi.spyOn(core.summary, 'addRaw').mockReturnValue({ + write: vi.fn().mockResolvedValue(undefined), + } as never); + const warning = vi.spyOn(core, 'warning').mockImplementation(() => undefined); + const setFailed = vi.spyOn(core, 'setFailed').mockImplementation(() => undefined); + + await runAction(); + + expect(warning).toHaveBeenCalledWith(expect.stringContaining('comment service unavailable')); + expect(setFailed).not.toHaveBeenCalled(); + }); +}); diff --git a/test/report.test.ts b/test/report.test.ts index 6bf3809..c8c0421 100644 --- a/test/report.test.ts +++ b/test/report.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { toJsonReport, toMarkdownReport } from '../src/report.js'; +import { toJsonReport, toMarkdownReport, writeJsonReport } from '../src/report.js'; import { resolveConfig } from '../src/config.js'; import type { ScanReport, ScannedLinkResult } from '../src/scanner.js'; @@ -11,6 +11,7 @@ function buildReport(results: ScannedLinkResult[]): ScanReport { results, summary: { filesScanned: 1, + filesFailed: 0, totalLinks: results.reduce((t, r) => t + r.occurrences.length, 0), uniqueLinks: results.length, ok: results.filter((r) => r.status === 'ok').length, @@ -22,6 +23,7 @@ function buildReport(results: ScannedLinkResult[]): ScanReport { tooLong: results.filter((r) => r.isTooLong).length, durationMs: 42, }, + fileErrors: [], }; } @@ -60,6 +62,7 @@ describe('toJsonReport', () => { expect(json.links).toHaveLength(2); expect(json.links[1]?.status).toBe('broken'); expect(json.links[1]?.occurrences[0]?.line).toBe(2); + expect(json.fileErrors).toEqual([]); }); }); @@ -88,4 +91,18 @@ describe('toMarkdownReport', () => { const markdown = toMarkdownReport(report); expect(markdown).toContain('urldn.com'); }); + + it('clearly reports file errors', () => { + const report = buildReport([okResult]); + report.fileErrors = [{ file: 'docs/missing.md', message: 'Permission denied' }]; + report.summary.filesFailed = 1; + const markdown = toMarkdownReport(report); + expect(markdown).toContain('### File errors'); + expect(markdown).toContain('docs/missing.md'); + }); + + it('includes the report path when writing fails', async () => { + const report = buildReport([okResult]); + await expect(writeJsonReport(report, '/dev/null/report.json')).rejects.toThrow('/dev/null/report.json'); + }); }); diff --git a/test/scanner.test.ts b/test/scanner.test.ts index 756bf48..f01b2b0 100644 --- a/test/scanner.test.ts +++ b/test/scanner.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -62,6 +62,7 @@ describe('runScan', () => { expect(report.summary.totalLinks).toBe(3); expect(report.summary.broken).toBe(1); expect(report.summary.ok).toBe(1); + expect(report.fileErrors).toEqual([]); const good = report.results.find((r) => r.url === 'https://example.com/good'); expect(good?.isDuplicate).toBe(true); @@ -91,4 +92,31 @@ describe('runScan', () => { expect(report.summary.uniqueLinks).toBe(1); expect(report.results[0]?.url).toBe('https://example.com/public'); }); + + it('turns an unexpected worker throw into a broken result', async () => { + await writeFile(join(dir, 'a.md'), '[bad](https://example.com/throws)'); + + const config = resolveConfig({ path: dir }); + const report = await runScan(config, { + checkUrlFn: async () => { + throw new Error('injected checker failure'); + }, + }); + + expect(report.summary.broken).toBe(1); + expect(report.results[0]?.status).toBe('broken'); + expect(report.results[0]?.error).toContain('injected checker failure'); + }); + + it('records an unreadable file and continues scanning other files', async () => { + await writeFile(join(dir, 'good.md'), '[good](https://example.com/good)'); + await symlink(join(dir, 'missing.md'), join(dir, 'unreadable.md')); + + const config = resolveConfig({ path: dir }); + const report = await runScan(config, { checkUrlFn: fakeCheck({}) }); + + expect(report.results.map((result) => result.url)).toEqual(['https://example.com/good']); + expect(report.summary.filesFailed).toBe(1); + expect(report.fileErrors[0]?.file).toContain('unreadable.md'); + }); }); diff --git a/test/utils.test.ts b/test/utils.test.ts index 3716780..6f409d3 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -8,6 +8,7 @@ import { runWithConcurrency, truncateUrl, } from '../src/utils.js'; +import { formatError } from '../src/errors.js'; describe('isValidUrl', () => { it('accepts http and https URLs', () => { @@ -105,4 +106,32 @@ describe('runWithConcurrency', () => { const results = await runWithConcurrency([], 4, async (item) => item); expect(results).toEqual([]); }); + + it('waits for all workers and aggregates their errors', async () => { + const errors = [new Error('first'), new Error('second')]; + await expect( + runWithConcurrency([1, 2], 2, async (item) => { + throw errors[item - 1]; + }), + ).rejects.toMatchObject({ + name: 'AggregateError', + errors, + }); + }); +}); + +describe('formatError', () => { + it('renders nested AggregateError causes', () => { + const aggregate = new AggregateError( + [new Error('first failure'), new AggregateError([new Error('nested failure')], 'nested aggregate')], + 'concurrent work failed', + ); + + const formatted = formatError(new Error('scan failed', { cause: aggregate })); + + expect(formatted).toContain('scan failed'); + expect(formatted).toContain('concurrent work failed'); + expect(formatted).toContain('first failure'); + expect(formatted).toContain('nested failure'); + }); });