diff --git a/src/checker.ts b/src/checker.ts index b3cbea0..3313198 100644 --- a/src/checker.ts +++ b/src/checker.ts @@ -32,83 +32,60 @@ export async function checkUrl(url: string, options: CheckOptions): Promise ({ + url, + isHttp, + redirectChain: [], + durationMs: Date.now() - started, + ...fields, + }); + if (!isValidUrl(url)) { - return { - url, + return result({ status: 'malformed', - redirectChain: [], - isHttp, - durationMs: Date.now() - started, error: 'URL is malformed or uses an unsupported protocol', - }; + }); } const requestFn = options.requestFn ?? performRequest; try { const { statusCode, redirectChain, finalUrl } = await requestFn(url, options.timeout); - const durationMs = Date.now() - started; - - if (statusCode >= 200 && statusCode < 300) { - return { - url, - status: redirectChain.length > 0 ? 'redirect' : 'ok', - statusCode, - redirectChain, - finalUrl, - isHttp, - durationMs, - }; - } - - if (statusCode >= 300 && statusCode < 400) { - return { - url, - status: 'redirect', - statusCode, - redirectChain, - finalUrl, - isHttp, - durationMs, - }; - } + const status = classifyStatusCode(statusCode, redirectChain.length > 0); - return { - url, - status: 'broken', + return result({ + status, statusCode, redirectChain, finalUrl, - isHttp, - durationMs, - error: `Received HTTP ${statusCode}`, - }; + error: status === 'broken' ? `Received HTTP ${statusCode}` : undefined, + }); } catch (error) { - const durationMs = Date.now() - started; const axiosError = error as AxiosError; if (axiosError.code === 'ECONNABORTED' || axiosError.message?.includes('timeout')) { - return { - url, + return result({ status: 'timeout', - redirectChain: [], - isHttp, - durationMs, error: `Request timed out after ${options.timeout}ms`, - }; + }); } - return { - url, + return result({ status: 'broken', - redirectChain: [], - isHttp, - durationMs, error: axiosError.message ?? 'Unknown network error', - }; + }); } } +type ResultFields = Omit & + Partial>; + +function classifyStatusCode(statusCode: number, redirected: boolean): LinkStatus { + if (statusCode >= 200 && statusCode < 300) return redirected ? 'redirect' : 'ok'; + if (statusCode >= 300 && statusCode < 400) return 'redirect'; + return 'broken'; +} + interface RawRequestResult { statusCode: number; redirectChain: string[]; diff --git a/src/cli.ts b/src/cli.ts index 62cfc0f..e803954 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 { shouldFailScan } from './link-status.js'; +import { toErrorMessage } from './utils.js'; import packageJson from '../package.json' with { type: 'json' }; const program = new Command(); @@ -94,20 +96,15 @@ async function main(path: string, rawOptions: Record): Promise< if (!config.json) console.log(chalk.dim(`Report written to ${config.output}`)); } - const shouldFail = - (config.failOnBroken && report.summary.broken > 0) || - (config.failOnRedirect && report.summary.redirects > 0) || - (config.failOnHttp && report.summary.insecureHttp > 0); - - process.exitCode = shouldFail ? 1 : 0; + process.exitCode = shouldFailScan(report.summary, config) ? 1 : 0; } catch (error) { spinner?.fail('Scan failed'); - console.error(chalk.red(error instanceof Error ? error.message : String(error))); + console.error(chalk.red(toErrorMessage(error))); process.exitCode = 2; } } program.parseAsync(process.argv).catch((error: unknown) => { - console.error(chalk.red(error instanceof Error ? error.message : String(error))); + console.error(chalk.red(toErrorMessage(error))); process.exitCode = 2; }); diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..095e7dc --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,2 @@ +/** Public URL of the URLDN shortener, referenced in reports and console output. */ +export const URLDN_SHORTENER_URL = 'https://www.urldn.com'; diff --git a/src/formatter.ts b/src/formatter.ts index c5c7c5f..f10c410 100644 --- a/src/formatter.ts +++ b/src/formatter.ts @@ -1,31 +1,65 @@ -import chalk from 'chalk'; +import chalk, { type ChalkInstance } from 'chalk'; import type { ScanReport, ScannedLinkResult } from './scanner.js'; import { formatDuration, truncateUrl } from './utils.js'; +import { URLDN_SHORTENER_URL } from './constants.js'; +import { isFailureStatus, isHealthy, STATUS_DISPLAY, SUMMARY_ROWS, type StatusColor } from './link-status.js'; -const URLDN_SHORTENER_URL = 'https://www.urldn.com'; +const COLORS: Record = { + green: chalk.green, + yellow: chalk.yellow, + red: chalk.red, + cyan: chalk.cyan, + gray: chalk.gray, +}; /** Prints a single line for a checked link while a scan is in progress (verbose mode). */ export function printVerboseLine(result: ScannedLinkResult): void { - const icon = statusIcon(result); + const { symbol, color } = STATUS_DISPLAY[result.status]; const url = truncateUrl(result.url, 90); - console.log(`${icon} ${url} ${chalk.dim(`(${formatDuration(result.durationMs)})`)}`); + console.log(`${COLORS[color](symbol)} ${url} ${chalk.dim(`(${formatDuration(result.durationMs)})`)}`); } -function statusIcon(result: ScannedLinkResult): string { - switch (result.status) { - case 'ok': - return chalk.green('✓'); - case 'redirect': - return chalk.yellow('⚠'); - case 'broken': - return chalk.red('✖'); - case 'timeout': - return chalk.red('⏱'); - case 'malformed': - return chalk.red('✖'); - default: - return chalk.gray('•'); +interface Section { + heading: string; + icon: string; + color: StatusColor; + results: ScannedLinkResult[]; + /** Maximum number of entries to print before collapsing the rest. */ + limit?: number; + /** Extra text appended to the URL line. */ + suffix?: (result: ScannedLinkResult) => string; + /** Additional dimmed lines printed underneath the URL line. */ + details?: (result: ScannedLinkResult) => string[]; + /** Dimmed line printed once, after all entries. */ + footer?: string; +} + +/** Prints one titled group of results, sharing layout across all report sections. */ +function printSection(section: Section): void { + const { results, limit, color, icon } = section; + if (results.length === 0) return; + + const paint = COLORS[color]; + console.log(paint(`\n${section.heading} (${results.length})`)); + + const shown = limit === undefined ? results : results.slice(0, limit); + for (const result of shown) { + const suffix = section.suffix?.(result) ?? ''; + console.log(` ${paint(icon)} ${truncateUrl(result.url)}${suffix}`); + for (const detail of section.details?.(result) ?? []) { + console.log(chalk.dim(` ${detail}`)); + } } + + if (limit !== undefined && results.length > limit) { + console.log(chalk.dim(` ... and ${results.length - limit} more`)); + } + if (section.footer) console.log(chalk.dim(` ${section.footer}`)); +} + +function redirectChainLine(result: ScannedLinkResult): string[] { + if (result.redirectChain.length <= 1) return []; + return [`chain: ${[...result.redirectChain, result.finalUrl].join(' → ')}`]; } /** Prints the full human-readable console report after a scan completes. */ @@ -36,58 +70,50 @@ export function printConsoleReport(report: ScanReport): void { console.log(chalk.bold('urldn-link-check report')); console.log(chalk.dim('─'.repeat(48))); - const ok = results.filter((r) => r.status === 'ok' && !r.isHttp && !r.isTooLong); - const redirects = results.filter((r) => r.status === 'redirect'); - const broken = results.filter( - (r) => r.status === 'broken' || r.status === 'malformed' || r.status === 'timeout', - ); - const insecure = results.filter((r) => r.isHttp); - const tooLong = results.filter((r) => r.isTooLong); + printSection({ + heading: '✓ Valid links', + icon: '✓', + color: 'green', + results: results.filter(isHealthy), + limit: 20, + }); - if (ok.length > 0) { - console.log(chalk.green(`\n✓ Valid links (${ok.length})`)); - for (const result of ok.slice(0, 20)) { - console.log(` ${chalk.green('✓')} ${truncateUrl(result.url)}`); - } - if (ok.length > 20) console.log(chalk.dim(` ... and ${ok.length - 20} more`)); - } + printSection({ + heading: '⚠ Redirects', + icon: '⚠', + color: 'yellow', + results: results.filter((r) => r.status === 'redirect'), + suffix: (result) => ` ${chalk.dim(`[${result.statusCode}]`)}`, + details: redirectChainLine, + }); - if (redirects.length > 0) { - console.log(chalk.yellow(`\n⚠ Redirects (${redirects.length})`)); - for (const result of redirects) { - console.log(` ${chalk.yellow('⚠')} ${truncateUrl(result.url)} ${chalk.dim(`[${result.statusCode}]`)}`); - if (result.redirectChain.length > 1) { - console.log(chalk.dim(` chain: ${[...result.redirectChain, result.finalUrl].join(' → ')}`)); - } - } - } - - if (broken.length > 0) { - console.log(chalk.red(`\n✖ Broken (${broken.length})`)); - for (const result of broken) { + printSection({ + heading: '✖ Broken', + icon: '✖', + color: 'red', + results: results.filter((r) => isFailureStatus(r.status)), + suffix: (result) => { const location = result.occurrences[0]; - const where = location ? ` ${chalk.dim(`(${location.file}:${location.line})`)}` : ''; - console.log(` ${chalk.red('✖')} ${truncateUrl(result.url)}${where}`); - if (result.error) console.log(chalk.dim(` ${result.error}`)); - } - } + return location ? ` ${chalk.dim(`(${location.file}:${location.line})`)}` : ''; + }, + details: (result) => (result.error ? [result.error] : []), + }); - if (insecure.length > 0) { - console.log(chalk.yellow(`\n🔓 Insecure HTTP (${insecure.length})`)); - for (const result of insecure) { - console.log(` ${chalk.yellow('🔓')} ${truncateUrl(result.url)}`); - } - } + printSection({ + heading: '🔓 Insecure HTTP', + icon: '🔓', + color: 'yellow', + results: results.filter((r) => r.isHttp), + }); - if (tooLong.length > 0) { - console.log(chalk.cyan(`\n⚡ Long URLs (${tooLong.length})`)); - for (const result of tooLong) { - console.log( - ` ${chalk.cyan('⚡')} ${truncateUrl(result.url)} ${chalk.dim(`(${result.url.length} chars)`)}`, - ); - } - console.log(chalk.dim(` Suggestion: shorten long URLs with URLDN — ${URLDN_SHORTENER_URL}`)); - } + printSection({ + heading: '⚡ Long URLs', + icon: '⚡', + color: 'cyan', + results: results.filter((r) => r.isTooLong), + suffix: (result) => ` ${chalk.dim(`(${result.url.length} chars)`)}`, + footer: `Suggestion: shorten long URLs with URLDN — ${URLDN_SHORTENER_URL}`, + }); console.log(''); console.log(chalk.dim('─'.repeat(48))); @@ -97,10 +123,7 @@ export function printConsoleReport(report: ScanReport): void { `Time: ${formatDuration(summary.durationMs)}`, ); console.log( - ` ${chalk.green(`${summary.ok} ok`)} ${chalk.yellow(`${summary.redirects} redirect`)} ` + - `${chalk.red(`${summary.broken} broken`)} ${chalk.red(`${summary.timeouts} timeout`)} ` + - `${chalk.red(`${summary.malformed} malformed`)} ${chalk.yellow(`${summary.insecureHttp} http`)} ` + - `${chalk.cyan(`${summary.tooLong} long`)}`, + ` ${SUMMARY_ROWS.map((row) => COLORS[row.color](`${summary[row.key]} ${row.shortLabel}`)).join(' ')}`, ); console.log(''); } diff --git a/src/github.ts b/src/github.ts index 7e0b6a4..1b41742 100644 --- a/src/github.ts +++ b/src/github.ts @@ -3,6 +3,8 @@ import * as github from '@actions/github'; import { resolveConfig } from './config.js'; import { runScan } from './scanner.js'; import { toMarkdownReport, writeJsonReport } from './report.js'; +import { shouldFailScan } from './link-status.js'; +import { toErrorMessage } from './utils.js'; const COMMENT_MARKER = ''; @@ -65,12 +67,7 @@ export async function runAction(): Promise { core.warning('Skipping PR comment: no github-token was provided.'); } - const shouldFail = - (failOnBroken && report.summary.broken > 0) || - (failOnRedirect && report.summary.redirects > 0) || - (failOnHttp && report.summary.insecureHttp > 0); - - if (shouldFail) { + if (shouldFailScan(report.summary, config)) { core.setFailed( `Link check failed: ${report.summary.broken} broken, ${report.summary.redirects} redirects, ` + `${report.summary.insecureHttp} insecure http link(s).`, @@ -79,7 +76,7 @@ export async function runAction(): Promise { core.info('Link check passed.'); } } catch (error) { - core.setFailed(error instanceof Error ? error.message : String(error)); + core.setFailed(toErrorMessage(error)); } } @@ -90,26 +87,20 @@ async function upsertPullRequestComment(token: string, body: string): Promise comment.body?.includes(COMMENT_MARKER)); if (existing) { - await octokit.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body: commentBody, - }); + await octokit.rest.issues.updateComment({ ...repo, comment_id: existing.id, body: commentBody }); } else { await octokit.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, + ...repo, issue_number: pullRequest.number, body: commentBody, }); diff --git a/src/index.ts b/src/index.ts index 627ec95..61e39f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,3 +14,15 @@ export { toJsonReport, toMarkdownReport, writeJsonReport, writeMarkdownReport } export type { JsonReport } from './report.js'; export { printConsoleReport, printVerboseLine } from './formatter.js'; + +export { + hasIssue, + isFailureStatus, + isHealthy, + shouldFailScan, + STATUS_DISPLAY, + SUMMARY_ROWS, +} from './link-status.js'; +export type { FailureThresholds, StatusCounts, SummaryRow } from './link-status.js'; + +export { URLDN_SHORTENER_URL } from './constants.js'; diff --git a/src/link-status.ts b/src/link-status.ts new file mode 100644 index 0000000..b798586 --- /dev/null +++ b/src/link-status.ts @@ -0,0 +1,109 @@ +import type { LinkStatus } from './checker.js'; + +/** Colour names shared by the console formatter; mapped to chalk in `formatter.ts`. */ +export type StatusColor = 'green' | 'yellow' | 'red' | 'cyan' | 'gray'; + +/** Symbols used for a link status in the console report and the Markdown report. */ +export interface StatusDisplay { + /** Emoji used in Markdown output. */ + emoji: string; + /** Single-width symbol used in console output. */ + symbol: string; + color: StatusColor; +} + +export const STATUS_DISPLAY: Record = { + ok: { emoji: '✅', symbol: '✓', color: 'green' }, + redirect: { emoji: '↪️', symbol: '⚠', color: 'yellow' }, + broken: { emoji: '❌', symbol: '✖', color: 'red' }, + timeout: { emoji: '⏱️', symbol: '⏱', color: 'red' }, + malformed: { emoji: '🚫', symbol: '✖', color: 'red' }, + 'insecure-http': { emoji: 'ℹ️', symbol: '•', color: 'gray' }, +}; + +/** The per-status and per-flag counters shared by `ScanSummary` and the reporters. */ +export interface StatusCounts { + ok: number; + redirects: number; + broken: number; + timeouts: number; + malformed: number; + insecureHttp: number; + tooLong: number; +} + +/** + * Maps a link status onto the summary counter it increments. `insecure-http` + * is tracked through the `isHttp` flag instead, so it has no counter here. + */ +export const STATUS_SUMMARY_KEY: Record = { + ok: 'ok', + redirect: 'redirects', + broken: 'broken', + timeout: 'timeouts', + malformed: 'malformed', + 'insecure-http': null, +}; + +/** A single row of the summary table, in the order both reporters render it. */ +export interface SummaryRow { + key: keyof StatusCounts; + emoji: string; + /** Label used in the Markdown table. */ + label: string; + /** Compact label used in the console summary line. */ + shortLabel: string; + color: StatusColor; +} + +export const SUMMARY_ROWS: readonly SummaryRow[] = [ + { key: 'ok', emoji: '✅', label: 'OK', shortLabel: 'ok', color: 'green' }, + { key: 'redirects', emoji: '↪️', label: 'Redirect', shortLabel: 'redirect', color: 'yellow' }, + { key: 'broken', emoji: '❌', label: 'Broken', shortLabel: 'broken', color: 'red' }, + { key: 'timeouts', emoji: '⏱️', label: 'Timeout', shortLabel: 'timeout', color: 'red' }, + { key: 'malformed', emoji: '🚫', label: 'Malformed', shortLabel: 'malformed', color: 'red' }, + { key: 'insecureHttp', emoji: '🔓', label: 'Insecure HTTP', shortLabel: 'http', color: 'yellow' }, + { key: 'tooLong', emoji: '📏', label: 'Too long', shortLabel: 'long', color: 'cyan' }, +]; + +/** The minimal shape of a checked link the shared predicates need. */ +export interface LinkFacets { + status: LinkStatus; + isHttp: boolean; + isTooLong?: boolean; + isDuplicate?: boolean; +} + +/** Statuses that represent a link that could not be reached successfully. */ +export function isFailureStatus(status: LinkStatus): boolean { + return status === 'broken' || status === 'malformed' || status === 'timeout'; +} + +/** True when the link reached its target with no warnings attached. */ +export function isHealthy(link: LinkFacets): boolean { + return link.status === 'ok' && !link.isHttp && !link.isTooLong; +} + +/** True when the link is worth reporting: a bad status or any warning flag. */ +export function hasIssue(link: LinkFacets): boolean { + return link.status !== 'ok' || link.isHttp || Boolean(link.isTooLong) || Boolean(link.isDuplicate); +} + +/** The failure thresholds shared by the CLI and the GitHub Action. */ +export interface FailureThresholds { + failOnBroken: boolean; + failOnRedirect: boolean; + failOnHttp: boolean; +} + +/** Decides whether a scan should exit non-zero, given the configured thresholds. */ +export function shouldFailScan( + counts: Pick, + thresholds: FailureThresholds, +): boolean { + return ( + (thresholds.failOnBroken && counts.broken > 0) || + (thresholds.failOnRedirect && counts.redirects > 0) || + (thresholds.failOnHttp && counts.insecureHttp > 0) + ); +} diff --git a/src/markdown.ts b/src/markdown.ts index e230d65..9e9443c 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -9,11 +9,16 @@ export interface ExtractedLink { label?: string; } -const INLINE_LINK_RE = /\[([^\]]*)\]\(\s*]+)>?(?:\s+["'][^"']*["'])?\s*\)/g; +/** `(url "optional title")` target shared by inline links and images. */ +const LINK_TARGET = String.raw`\(\s*]+)>?(?:\s+["'][^"']*["'])?\s*\)`; +/** `[label]` shared by inline links and images. */ +const LINK_LABEL = String.raw`\[([^\]]*)\]`; + +const INLINE_LINK_RE = new RegExp(`${LINK_LABEL}${LINK_TARGET}`, 'g'); +const IMAGE_MARKDOWN_RE = new RegExp(`!${LINK_LABEL}${LINK_TARGET}`, 'g'); const REFERENCE_DEFINITION_RE = /^\s{0,3}\[[^\]]+\]:\s*?(?:\s+["'][^"']*["'])?\s*$/; const AUTOLINK_RE = /<(https?:\/\/[^\s<>]+)>/g; const BARE_URL_RE = /(?()"'\]]+/g; -const IMAGE_MARKDOWN_RE = /!\[([^\]]*)\]\(\s*]+)>?(?:\s+["'][^"']*["'])?\s*\)/g; /** * Strips fenced and inline code from a line/block so that URLs mentioned diff --git a/src/report.ts b/src/report.ts index cb04f23..aefe884 100644 --- a/src/report.ts +++ b/src/report.ts @@ -1,7 +1,7 @@ import { writeFile } from 'node:fs/promises'; import type { ScanReport, ScannedLinkResult } from './scanner.js'; - -const URLDN_SHORTENER_URL = 'https://www.urldn.com'; +import { URLDN_SHORTENER_URL } from './constants.js'; +import { hasIssue, STATUS_DISPLAY, SUMMARY_ROWS } from './link-status.js'; export interface JsonReport { generatedAt: string; @@ -67,18 +67,12 @@ export function toMarkdownReport(report: ScanReport): string { lines.push(''); lines.push('| Status | Count |'); lines.push('| --- | --- |'); - lines.push(`| ✅ OK | ${summary.ok} |`); - lines.push(`| ↪️ Redirect | ${summary.redirects} |`); - lines.push(`| ❌ Broken | ${summary.broken} |`); - lines.push(`| ⏱️ Timeout | ${summary.timeouts} |`); - lines.push(`| 🚫 Malformed | ${summary.malformed} |`); - lines.push(`| 🔓 Insecure HTTP | ${summary.insecureHttp} |`); - lines.push(`| 📏 Too long | ${summary.tooLong} |`); + for (const row of SUMMARY_ROWS) { + lines.push(`| ${row.emoji} ${row.label} | ${summary[row.key]} |`); + } lines.push(''); - const problems = report.results.filter( - (r) => r.status !== 'ok' || r.isHttp || r.isTooLong || r.isDuplicate, - ); + const problems = report.results.filter(hasIssue); if (problems.length === 0) { lines.push('✨ No issues found — every link looks good!'); @@ -89,7 +83,7 @@ export function toMarkdownReport(report: ScanReport): string { lines.push(''); for (const result of problems) { - lines.push(`#### ${statusEmoji(result)} \`${result.url}\``); + lines.push(`#### ${STATUS_DISPLAY[result.status].emoji} \`${result.url}\``); for (const occurrence of result.occurrences) { lines.push(`- ${occurrence.file}:${occurrence.line}:${occurrence.column}`); } @@ -116,23 +110,6 @@ export function toMarkdownReport(report: ScanReport): string { return lines.join('\n'); } -function statusEmoji(result: ScannedLinkResult): string { - switch (result.status) { - case 'ok': - return '✅'; - case 'redirect': - return '↪️'; - case 'broken': - return '❌'; - case 'timeout': - return '⏱️'; - case 'malformed': - return '🚫'; - default: - return 'ℹ️'; - } -} - /** 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'); diff --git a/src/scanner.ts b/src/scanner.ts index 9e2c7a0..cbdf5e0 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -5,6 +5,7 @@ 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 { STATUS_SUMMARY_KEY, type StatusCounts } from './link-status.js'; export interface LinkOccurrence extends ExtractedLink { file: string; @@ -16,17 +17,10 @@ export interface ScannedLinkResult extends CheckResult { isTooLong: boolean; } -export interface ScanSummary { +export interface ScanSummary extends StatusCounts { filesScanned: number; totalLinks: number; uniqueLinks: number; - ok: number; - redirects: number; - broken: number; - timeouts: number; - malformed: number; - insecureHttp: number; - tooLong: number; durationMs: number; } @@ -128,11 +122,8 @@ function summarize(results: ScannedLinkResult[], filesScanned: number, started: }; for (const result of results) { - if (result.status === 'ok') summary.ok += 1; - if (result.status === 'redirect') summary.redirects += 1; - if (result.status === 'broken') summary.broken += 1; - if (result.status === 'timeout') summary.timeouts += 1; - if (result.status === 'malformed') summary.malformed += 1; + const statusKey = STATUS_SUMMARY_KEY[result.status]; + if (statusKey) summary[statusKey] += 1; if (result.isHttp) summary.insecureHttp += 1; if (result.isTooLong) summary.tooLong += 1; } diff --git a/src/utils.ts b/src/utils.ts index 7f095ed..525cd7a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -26,23 +26,29 @@ export async function runWithConcurrency( return results; } -/** Returns true when the given string parses as an absolute http(s) URL. */ -export function isValidUrl(candidate: string): boolean { +/** Parses a URL, returning `undefined` instead of throwing on invalid input. */ +export function parseUrl(candidate: string): URL | undefined { try { - const parsed = new URL(candidate); - return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + return new URL(candidate); } catch { - return false; + return undefined; } } +/** Returns true when the given string parses as an absolute http(s) URL. */ +export function isValidUrl(candidate: string): boolean { + const protocol = parseUrl(candidate)?.protocol; + return protocol === 'http:' || protocol === 'https:'; +} + /** Returns true when the URL uses the insecure `http://` scheme. */ export function isInsecureHttp(candidate: string): boolean { - try { - return new URL(candidate).protocol === 'http:'; - } catch { - return false; - } + return parseUrl(candidate)?.protocol === 'http:'; +} + +/** Normalizes an unknown thrown value into a message string. */ +export function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } /** diff --git a/test/link-status.test.ts b/test/link-status.test.ts new file mode 100644 index 0000000..cef3f7d --- /dev/null +++ b/test/link-status.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { + hasIssue, + isFailureStatus, + isHealthy, + shouldFailScan, + STATUS_DISPLAY, + STATUS_SUMMARY_KEY, + SUMMARY_ROWS, + type LinkFacets, +} from '../src/link-status.js'; + +const link = (overrides: Partial = {}): LinkFacets => ({ + status: 'ok', + isHttp: false, + isTooLong: false, + isDuplicate: false, + ...overrides, +}); + +describe('isFailureStatus', () => { + it('covers broken, malformed and timeout', () => { + expect(isFailureStatus('broken')).toBe(true); + expect(isFailureStatus('malformed')).toBe(true); + expect(isFailureStatus('timeout')).toBe(true); + expect(isFailureStatus('ok')).toBe(false); + expect(isFailureStatus('redirect')).toBe(false); + }); +}); + +describe('isHealthy', () => { + it('requires an ok status with no warning flags', () => { + expect(isHealthy(link())).toBe(true); + expect(isHealthy(link({ isHttp: true }))).toBe(false); + expect(isHealthy(link({ isTooLong: true }))).toBe(false); + expect(isHealthy(link({ status: 'redirect' }))).toBe(false); + }); + + it('ignores duplication', () => { + expect(isHealthy(link({ isDuplicate: true }))).toBe(true); + }); +}); + +describe('hasIssue', () => { + it('reports any bad status or warning flag', () => { + expect(hasIssue(link())).toBe(false); + expect(hasIssue(link({ status: 'broken' }))).toBe(true); + expect(hasIssue(link({ isHttp: true }))).toBe(true); + expect(hasIssue(link({ isTooLong: true }))).toBe(true); + expect(hasIssue(link({ isDuplicate: true }))).toBe(true); + }); +}); + +describe('shouldFailScan', () => { + const counts = { broken: 0, redirects: 0, insecureHttp: 0 }; + const thresholds = { failOnBroken: true, failOnRedirect: false, failOnHttp: false }; + + it('passes a clean scan', () => { + expect(shouldFailScan(counts, thresholds)).toBe(false); + }); + + it('fails on broken links only when configured', () => { + expect(shouldFailScan({ ...counts, broken: 1 }, thresholds)).toBe(true); + expect(shouldFailScan({ ...counts, broken: 1 }, { ...thresholds, failOnBroken: false })).toBe(false); + }); + + it('honours the redirect and http thresholds', () => { + expect(shouldFailScan({ ...counts, redirects: 2 }, thresholds)).toBe(false); + expect(shouldFailScan({ ...counts, redirects: 2 }, { ...thresholds, failOnRedirect: true })).toBe(true); + expect(shouldFailScan({ ...counts, insecureHttp: 3 }, { ...thresholds, failOnHttp: true })).toBe(true); + }); +}); + +describe('status metadata', () => { + it('describes every status', () => { + for (const display of Object.values(STATUS_DISPLAY)) { + expect(display.emoji.length).toBeGreaterThan(0); + expect(display.symbol.length).toBeGreaterThan(0); + } + }); + + it('maps statuses onto summary counters, except insecure-http', () => { + expect(STATUS_SUMMARY_KEY.timeout).toBe('timeouts'); + expect(STATUS_SUMMARY_KEY['insecure-http']).toBeNull(); + }); + + it('lists the summary rows in report order', () => { + expect(SUMMARY_ROWS.map((row) => row.key)).toEqual([ + 'ok', + 'redirects', + 'broken', + 'timeouts', + 'malformed', + 'insecureHttp', + 'tooLong', + ]); + }); +}); diff --git a/test/utils.test.ts b/test/utils.test.ts index 3716780..d1b89bb 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -5,10 +5,33 @@ import { isInsecureHttp, isValidUrl, matchesIgnorePattern, + parseUrl, runWithConcurrency, + toErrorMessage, truncateUrl, } from '../src/utils.js'; +describe('parseUrl', () => { + it('returns a URL for parsable input', () => { + expect(parseUrl('https://example.com/a')?.host).toBe('example.com'); + }); + + it('returns undefined instead of throwing', () => { + expect(parseUrl('not a url')).toBeUndefined(); + }); +}); + +describe('toErrorMessage', () => { + it('uses the message of an Error', () => { + expect(toErrorMessage(new Error('boom'))).toBe('boom'); + }); + + it('stringifies non-Error values', () => { + expect(toErrorMessage('boom')).toBe('boom'); + expect(toErrorMessage(42)).toBe('42'); + }); +}); + describe('isValidUrl', () => { it('accepts http and https URLs', () => { expect(isValidUrl('https://example.com')).toBe(true);