From 1766d3669059ca389ab6e5066b91b2d8ee5fff22 Mon Sep 17 00:00:00 2001 From: Assmira Lahcen Date: Wed, 19 Aug 2026 19:34:00 +0000 Subject: [PATCH] test: cover formatter, request path, report writers, and scanner edges Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- test/checker.request.test.ts | 129 ++++++++++++++++++++ test/checker.test.ts | 24 ++++ test/formatter.test.ts | 221 +++++++++++++++++++++++++++++++++++ test/index.test.ts | 32 +++++ test/markdown.test.ts | 28 +++++ test/report.write.test.ts | 141 ++++++++++++++++++++++ test/scanner.events.test.ts | 171 +++++++++++++++++++++++++++ test/utils.test.ts | 18 +++ vitest.config.ts | 2 +- 9 files changed, 765 insertions(+), 1 deletion(-) create mode 100644 test/checker.request.test.ts create mode 100644 test/formatter.test.ts create mode 100644 test/index.test.ts create mode 100644 test/report.write.test.ts create mode 100644 test/scanner.events.test.ts diff --git a/test/checker.request.test.ts b/test/checker.request.test.ts new file mode 100644 index 0000000..a7f4a28 --- /dev/null +++ b/test/checker.request.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { headMock, getMock, createMock } = vi.hoisted(() => { + const headMock = vi.fn(); + const getMock = vi.fn(); + return { headMock, getMock, createMock: vi.fn(() => ({ head: headMock, get: getMock })) }; +}); + +vi.mock('axios', () => ({ default: { create: createMock } })); + +const { checkUrl, performRequest } = await import('../src/checker.js'); + +function response(status: number, location?: string) { + return { status, headers: location ? { location } : {} }; +} + +beforeEach(() => { + headMock.mockReset(); + getMock.mockReset(); + createMock.mockClear(); +}); + +describe('performRequest', () => { + it('returns the status of a direct response without following redirects', async () => { + headMock.mockResolvedValue(response(200)); + + const result = await performRequest('https://example.com/ok', 5000); + + expect(result).toEqual({ + statusCode: 200, + redirectChain: [], + finalUrl: 'https://example.com/ok', + }); + expect(getMock).not.toHaveBeenCalled(); + }); + + it('creates a client with the requested timeout and manual redirect handling', async () => { + headMock.mockResolvedValue(response(200)); + + await performRequest('https://example.com/ok', 1234); + + expect(createMock).toHaveBeenCalledWith( + expect.objectContaining({ timeout: 1234, maxRedirects: 0 }), + ); + }); + + it.each([405, 501])('retries with GET when HEAD returns %i', async (status) => { + headMock.mockResolvedValue(response(status)); + getMock.mockResolvedValue(response(200)); + + const result = await performRequest('https://example.com/head-rejected', 5000); + + expect(getMock).toHaveBeenCalledWith('https://example.com/head-rejected'); + expect(result.statusCode).toBe(200); + }); + + it('retries with GET when HEAD throws', async () => { + headMock.mockRejectedValue(new Error('socket hang up')); + getMock.mockResolvedValue(response(204)); + + const result = await performRequest('https://example.com/no-head', 5000); + + expect(result.statusCode).toBe(204); + }); + + it('follows a redirect chain and reports the final url', async () => { + headMock + .mockResolvedValueOnce(response(301, 'https://example.com/step-2')) + .mockResolvedValueOnce(response(302, 'https://example.com/final')) + .mockResolvedValueOnce(response(200)); + + const result = await performRequest('https://example.com/step-1', 5000); + + expect(result).toEqual({ + statusCode: 200, + redirectChain: ['https://example.com/step-1', 'https://example.com/step-2'], + finalUrl: 'https://example.com/final', + }); + }); + + it('resolves relative Location headers against the current url', async () => { + headMock.mockResolvedValueOnce(response(302, '/moved')).mockResolvedValueOnce(response(200)); + + const result = await performRequest('https://example.com/docs/page', 5000); + + expect(result.finalUrl).toBe('https://example.com/moved'); + }); + + it('treats a 3xx response without a Location header as the final response', async () => { + headMock.mockResolvedValue(response(304)); + + const result = await performRequest('https://example.com/cached', 5000); + + expect(result.statusCode).toBe(304); + expect(result.redirectChain).toEqual([]); + }); + + it('throws once the redirect limit is exceeded', async () => { + let hop = 0; + headMock.mockImplementation(async () => { + hop += 1; + return response(301, `https://example.com/hop-${hop}`); + }); + + await expect(performRequest('https://example.com/loop', 5000)).rejects.toThrow( + 'Too many redirects (> 10)', + ); + }); +}); + +describe('checkUrl with the real request path', () => { + it('uses performRequest when no requestFn override is given', async () => { + headMock.mockResolvedValue(response(200)); + + const result = await checkUrl('https://example.com/ok', { timeout: 5000 }); + + expect(headMock).toHaveBeenCalledWith('https://example.com/ok'); + expect(result.status).toBe('ok'); + }); + + it('reports a redirect limit failure as broken', async () => { + headMock.mockImplementation(async () => response(301, 'https://example.com/next')); + + const result = await checkUrl('https://example.com/loop', { timeout: 5000 }); + + expect(result.status).toBe('broken'); + expect(result.error).toContain('Too many redirects'); + }); +}); diff --git a/test/checker.test.ts b/test/checker.test.ts index d64c437..f4dd330 100644 --- a/test/checker.test.ts +++ b/test/checker.test.ts @@ -81,6 +81,19 @@ describe('checkUrl', () => { expect(result.status).toBe('timeout'); }); + it('classifies a 2xx response reached through redirects as redirect', async () => { + const result = await checkUrl('https://example.com/old', { + timeout: 5000, + requestFn: async () => ({ + statusCode: 200, + redirectChain: ['https://example.com/old'], + finalUrl: 'https://example.com/new', + }), + }); + expect(result.status).toBe('redirect'); + expect(result.statusCode).toBe(200); + }); + it('classifies a generic network error as broken', async () => { const result = await checkUrl('https://example.com/dns-fail', { timeout: 5000, @@ -91,4 +104,15 @@ describe('checkUrl', () => { expect(result.status).toBe('broken'); expect(result.error).toContain('ENOTFOUND'); }); + + it('falls back to a generic error message when the failure has none', async () => { + const result = await checkUrl('https://example.com/odd-failure', { + timeout: 5000, + requestFn: async () => { + throw {}; + }, + }); + expect(result.status).toBe('broken'); + expect(result.error).toBe('Unknown network error'); + }); }); diff --git a/test/formatter.test.ts b/test/formatter.test.ts new file mode 100644 index 0000000..ef9c6fe --- /dev/null +++ b/test/formatter.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { printConsoleReport, printVerboseLine } from '../src/formatter.js'; +import { resolveConfig } from '../src/config.js'; +import type { ScanReport, ScannedLinkResult } from '../src/scanner.js'; + +let logSpy: ReturnType; + +beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); +}); + +afterEach(() => { + logSpy.mockRestore(); +}); + +/** Joins every logged line with ANSI colour codes removed. */ +function output(): string { + return logSpy.mock.calls + .map((call) => String(call[0] ?? '')) + .join('\n') + .replace(/\u001b\[[0-9;]*m/g, ''); +} + +function makeResult(overrides: Partial = {}): ScannedLinkResult { + return { + url: 'https://example.com/ok', + status: 'ok', + statusCode: 200, + redirectChain: [], + isHttp: false, + durationMs: 12, + occurrences: [{ url: 'https://example.com/ok', file: 'docs/a.md', line: 1, column: 1 }], + isDuplicate: false, + isTooLong: false, + ...overrides, + }; +} + +function buildReport(results: ScannedLinkResult[], overrides: Partial = {}): ScanReport { + return { + config: resolveConfig({ path: 'docs' }), + files: ['docs/a.md'], + results, + summary: { + filesScanned: 1, + totalLinks: results.reduce((total, r) => total + r.occurrences.length, 0), + uniqueLinks: results.length, + ok: results.filter((r) => r.status === 'ok').length, + redirects: results.filter((r) => r.status === 'redirect').length, + broken: results.filter((r) => r.status === 'broken').length, + timeouts: results.filter((r) => r.status === 'timeout').length, + malformed: results.filter((r) => r.status === 'malformed').length, + insecureHttp: results.filter((r) => r.isHttp).length, + tooLong: results.filter((r) => r.isTooLong).length, + durationMs: 1500, + ...overrides, + }, + }; +} + +describe('printVerboseLine', () => { + it('prints the url and its duration', () => { + printVerboseLine(makeResult({ durationMs: 312 })); + expect(output()).toContain('https://example.com/ok'); + expect(output()).toContain('312ms'); + }); + + it.each([ + ['ok', '✓'], + ['redirect', '⚠'], + ['broken', '✖'], + ['timeout', '⏱'], + ['malformed', '✖'], + ['insecure-http', '•'], + ] as const)('uses the %s icon', (status, icon) => { + printVerboseLine(makeResult({ status })); + expect(output()).toContain(icon); + }); + + it('truncates very long urls', () => { + const url = `https://example.com/${'a'.repeat(200)}`; + printVerboseLine(makeResult({ url })); + const printed = output(); + expect(printed).toContain('...'); + expect(printed).not.toContain(url); + }); +}); + +describe('printConsoleReport', () => { + it('lists each category with its count', () => { + const report = buildReport([ + makeResult(), + makeResult({ url: 'https://example.com/old', status: 'redirect', statusCode: 301 }), + makeResult({ + url: 'https://example.com/missing', + status: 'broken', + statusCode: 404, + error: 'Received HTTP 404', + occurrences: [{ url: 'https://example.com/missing', file: 'docs/b.md', line: 42, column: 3 }], + }), + makeResult({ url: 'http://example.com/legacy', isHttp: true }), + makeResult({ url: 'https://example.com/long', isTooLong: true }), + ]); + + printConsoleReport(report); + const printed = output(); + + expect(printed).toContain('✓ Valid links (1)'); + expect(printed).toContain('⚠ Redirects (1)'); + expect(printed).toContain('✖ Broken (1)'); + expect(printed).toContain('🔓 Insecure HTTP (1)'); + expect(printed).toContain('⚡ Long URLs (1)'); + }); + + it('counts timeouts and malformed urls as broken', () => { + const report = buildReport([ + makeResult({ url: 'https://example.com/slow', status: 'timeout' }), + makeResult({ url: 'nope', status: 'malformed' }), + ]); + + printConsoleReport(report); + expect(output()).toContain('✖ Broken (2)'); + }); + + it('shows the file location and error for broken links', () => { + const report = buildReport([ + makeResult({ + url: 'https://example.com/missing', + status: 'broken', + error: 'Received HTTP 404', + occurrences: [{ url: 'https://example.com/missing', file: 'docs/b.md', line: 42, column: 3 }], + }), + ]); + + printConsoleReport(report); + const printed = output(); + expect(printed).toContain('docs/b.md:42'); + expect(printed).toContain('Received HTTP 404'); + }); + + it('omits the location when a broken link has no occurrences', () => { + const report = buildReport([ + makeResult({ url: 'https://example.com/missing', status: 'broken', occurrences: [] }), + ]); + + printConsoleReport(report); + expect(output()).not.toContain('docs/a.md'); + }); + + it('prints the redirect chain only when there is more than one hop', () => { + const single = buildReport([ + makeResult({ + url: 'https://example.com/one', + status: 'redirect', + redirectChain: ['https://example.com/one'], + finalUrl: 'https://example.com/final', + }), + ]); + printConsoleReport(single); + expect(output()).not.toContain('chain:'); + + logSpy.mockClear(); + + const multi = buildReport([ + makeResult({ + url: 'https://example.com/one', + status: 'redirect', + redirectChain: ['https://example.com/one', 'https://example.com/two'], + finalUrl: 'https://example.com/final', + }), + ]); + printConsoleReport(multi); + expect(output()).toContain('chain: https://example.com/one → https://example.com/two → https://example.com/final'); + }); + + it('truncates the valid links list at 20 entries', () => { + const results = Array.from({ length: 25 }, (_, index) => + makeResult({ url: `https://example.com/page-${index}` }), + ); + + printConsoleReport(buildReport(results)); + const printed = output(); + + expect(printed).toContain('✓ Valid links (25)'); + expect(printed).toContain('... and 5 more'); + expect(printed).toContain('https://example.com/page-19'); + expect(printed).not.toContain('https://example.com/page-20'); + }); + + it('suggests the URLDN shortener for long urls', () => { + printConsoleReport(buildReport([makeResult({ isTooLong: true })])); + expect(output()).toContain('https://www.urldn.com'); + }); + + it('prints only the summary when there are no results', () => { + printConsoleReport(buildReport([])); + const printed = output(); + + expect(printed).toContain('Summary'); + expect(printed).toContain('Files: 1 Links: 0 (0 unique) Time: 1.5s'); + expect(printed).not.toContain('Valid links'); + expect(printed).not.toContain('Broken'); + expect(printed).not.toContain('Insecure HTTP'); + expect(printed).not.toContain('Long URLs'); + }); + + it('renders every counter in the summary footer', () => { + const report = buildReport([], { + ok: 18, + redirects: 2, + broken: 1, + timeouts: 3, + malformed: 4, + insecureHttp: 5, + tooLong: 6, + }); + + printConsoleReport(report); + expect(output()).toContain('18 ok 2 redirect 1 broken 3 timeout 4 malformed 5 http 6 long'); + }); +}); diff --git a/test/index.test.ts b/test/index.test.ts new file mode 100644 index 0000000..f2aa003 --- /dev/null +++ b/test/index.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import * as api from '../src/index.js'; + +describe('public API surface', () => { + it('exports every documented entry point', () => { + expect(Object.keys(api).sort()).toEqual( + [ + 'ConfigSchema', + 'DEFAULT_FILE_PATTERNS', + 'DEFAULT_IGNORE_PATTERNS', + 'checkUrl', + 'extractLinksFromMarkdown', + 'findFiles', + 'printConsoleReport', + 'printVerboseLine', + 'resolveConfig', + 'runScan', + 'toJsonReport', + 'toMarkdownReport', + 'writeJsonReport', + 'writeMarkdownReport', + ].sort(), + ); + }); + + it('re-exports working implementations, not placeholders', () => { + expect(api.resolveConfig({ path: 'docs' }).maxUrlLength).toBe(80); + expect(api.extractLinksFromMarkdown('[a](https://example.com)')).toHaveLength(1); + expect(api.DEFAULT_FILE_PATTERNS).toContain('**/*.md'); + expect(api.DEFAULT_IGNORE_PATTERNS).toContain('**/node_modules/**'); + }); +}); diff --git a/test/markdown.test.ts b/test/markdown.test.ts index bf00806..7b4e09e 100644 --- a/test/markdown.test.ts +++ b/test/markdown.test.ts @@ -74,4 +74,32 @@ describe('extractLinksFromMarkdown', () => { const links = extractLinksFromMarkdown('Just plain text, nothing to see here.'); expect(links).toHaveLength(0); }); + + it('skips links whose target is only punctuation', () => { + const links = extractLinksFromMarkdown('[placeholder](.)'); + expect(links).toHaveLength(0); + }); + + it('handles an empty document', () => { + expect(extractLinksFromMarkdown('')).toEqual([]); + }); + + it('resumes extraction after a tilde-fenced code block closes', () => { + const content = [ + '~~~', + 'https://example.com/in-fence', + '~~~', + '[after](https://example.com/after)', + ].join('\n'); + + const links = extractLinksFromMarkdown(content); + + expect(links.map((l) => l.url)).toEqual(['https://example.com/after']); + }); + + it('does not close a backtick fence with a tilde fence', () => { + const content = ['```', '~~~', 'https://example.com/still-in-fence', '```', ''].join('\n'); + + expect(extractLinksFromMarkdown(content)).toEqual([]); + }); }); diff --git a/test/report.write.test.ts b/test/report.write.test.ts new file mode 100644 index 0000000..119708c --- /dev/null +++ b/test/report.write.test.ts @@ -0,0 +1,141 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { toMarkdownReport, writeJsonReport, writeMarkdownReport } from '../src/report.js'; +import { resolveConfig } from '../src/config.js'; +import type { ScanReport, ScannedLinkResult } from '../src/scanner.js'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'urldn-link-check-report-')); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +function makeResult(overrides: Partial = {}): ScannedLinkResult { + return { + url: 'https://example.com/ok', + status: 'ok', + statusCode: 200, + redirectChain: [], + isHttp: false, + durationMs: 5, + occurrences: [{ url: 'https://example.com/ok', file: 'docs/a.md', line: 1, column: 1 }], + isDuplicate: false, + isTooLong: false, + ...overrides, + }; +} + +function buildReport(results: ScannedLinkResult[]): ScanReport { + return { + config: resolveConfig({ path: 'docs' }), + files: ['docs/a.md'], + results, + summary: { + filesScanned: 1, + totalLinks: results.reduce((total, r) => total + r.occurrences.length, 0), + uniqueLinks: results.length, + ok: results.filter((r) => r.status === 'ok').length, + redirects: results.filter((r) => r.status === 'redirect').length, + broken: results.filter((r) => r.status === 'broken').length, + timeouts: results.filter((r) => r.status === 'timeout').length, + malformed: results.filter((r) => r.status === 'malformed').length, + insecureHttp: results.filter((r) => r.isHttp).length, + tooLong: results.filter((r) => r.isTooLong).length, + durationMs: 42, + }, + }; +} + +describe('writeJsonReport', () => { + it('writes a parsable JSON report to disk', async () => { + const outputPath = join(dir, 'report.json'); + await writeJsonReport(buildReport([makeResult()]), outputPath); + + const raw = await readFile(outputPath, 'utf-8'); + expect(raw.endsWith('\n')).toBe(true); + + const parsed = JSON.parse(raw) as { summary: { ok: number }; links: Array<{ url: string }> }; + expect(parsed.summary.ok).toBe(1); + expect(parsed.links[0]?.url).toBe('https://example.com/ok'); + }); +}); + +describe('writeMarkdownReport', () => { + it('writes the rendered markdown report to disk', async () => { + const outputPath = join(dir, 'report.md'); + const report = buildReport([makeResult()]); + await writeMarkdownReport(report, outputPath); + + const raw = await readFile(outputPath, 'utf-8'); + expect(raw).toBe(`${toMarkdownReport(report)}\n`); + expect(raw).toContain('urldn-link-check report'); + }); +}); + +describe('toMarkdownReport issue details', () => { + it.each([ + ['redirect', '↪️'], + ['broken', '❌'], + ['timeout', '⏱️'], + ['malformed', '🚫'], + ['insecure-http', 'ℹ️'], + ] as const)('uses the %s emoji', (status, emoji) => { + const markdown = toMarkdownReport(buildReport([makeResult({ status })])); + expect(markdown).toContain(`${emoji} \`https://example.com/ok\``); + }); + + it('renders the redirect chain, status code, and error', () => { + const markdown = toMarkdownReport( + buildReport([ + makeResult({ + status: 'redirect', + statusCode: 301, + error: 'Moved', + redirectChain: ['https://example.com/old'], + finalUrl: 'https://example.com/new', + }), + ]), + ); + + expect(markdown).toContain('- Error: Moved'); + expect(markdown).toContain('- HTTP status: 301'); + expect(markdown).toContain('- Redirect chain: https://example.com/old → https://example.com/new'); + }); + + it('flags insecure http links as an issue', () => { + const markdown = toMarkdownReport( + buildReport([makeResult({ url: 'http://example.com/legacy', isHttp: true })]), + ); + + expect(markdown).toContain('Uses insecure `http://`'); + }); + + it('reports how many times a duplicated link is referenced', () => { + const markdown = toMarkdownReport( + buildReport([ + makeResult({ + isDuplicate: true, + occurrences: [ + { url: 'https://example.com/ok', file: 'docs/a.md', line: 1, column: 1 }, + { url: 'https://example.com/ok', file: 'docs/b.md', line: 7, column: 2 }, + ], + }), + ]), + ); + + expect(markdown).toContain('Referenced 2 times'); + expect(markdown).toContain('docs/b.md:7:2'); + }); + + it('renders the status counts table', () => { + const markdown = toMarkdownReport(buildReport([makeResult({ status: 'broken' })])); + expect(markdown).toContain('| ❌ Broken | 1 |'); + expect(markdown).toContain('| ✅ OK | 0 |'); + }); +}); diff --git a/test/scanner.events.test.ts b/test/scanner.events.test.ts new file mode 100644 index 0000000..5486714 --- /dev/null +++ b/test/scanner.events.test.ts @@ -0,0 +1,171 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { resolveConfig } from '../src/config.js'; +import { findFiles, runScan } from '../src/scanner.js'; +import type { CheckResult } from '../src/checker.js'; +import type { ScannedLinkResult } from '../src/scanner.js'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'urldn-link-check-events-')); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +function fakeCheck(statuses: Record> = {}) { + return async (url: string): Promise => ({ + url, + status: 'ok', + redirectChain: [], + isHttp: url.startsWith('http://'), + durationMs: 1, + ...(statuses[url] ?? {}), + }); +} + +describe('findFiles', () => { + it('accepts a single markdown file as the path', async () => { + const file = join(dir, 'a.md'); + await writeFile(file, '# A'); + + const files = await findFiles(resolveConfig({ path: file })); + + expect(files).toEqual([file]); + }); + + it('ignores a non-markdown file passed directly as the path', async () => { + const file = join(dir, 'notes.txt'); + await writeFile(file, 'hello'); + + const files = await findFiles(resolveConfig({ path: file })); + + expect(files).toEqual([]); + }); + + it('handles a trailing slash on a directory path', async () => { + await writeFile(join(dir, 'a.md'), '# A'); + + const files = await findFiles(resolveConfig({ path: `${dir}/` })); + + expect(files.some((f) => f.endsWith('a.md'))).toBe(true); + }); + + it('finds nested files and skips default-ignored directories', async () => { + await mkdir(join(dir, 'guides'), { recursive: true }); + await mkdir(join(dir, 'node_modules'), { recursive: true }); + await writeFile(join(dir, 'guides', 'nested.markdown'), '# Nested'); + await writeFile(join(dir, 'node_modules', 'dep.md'), '# Dep'); + + const files = await findFiles(resolveConfig({ path: dir })); + + expect(files.some((f) => f.endsWith('nested.markdown'))).toBe(true); + expect(files.some((f) => f.includes('node_modules'))).toBe(false); + }); + + it('honours user-supplied ignore patterns and de-duplicates results', async () => { + await writeFile(join(dir, 'a.md'), '# A'); + await writeFile(join(dir, 'skip.md'), '# Skip'); + + const files = await findFiles(resolveConfig({ path: dir, ignore: ['**/skip.md'] })); + + expect(files).toHaveLength(1); + expect(files[0]?.endsWith('a.md')).toBe(true); + }); +}); + +describe('runScan events', () => { + it('reports discovered files and every checked link', async () => { + await writeFile( + join(dir, 'a.md'), + '[one](https://example.com/one)\n[two](https://example.com/two)', + ); + + const foundFiles: string[][] = []; + const checked: Array<{ url: string; index: number; total: number }> = []; + + await runScan(resolveConfig({ path: dir, concurrency: 1 }), { + checkUrlFn: fakeCheck(), + onFileFound: (files) => foundFiles.push(files), + onLinkChecked: (result: ScannedLinkResult, index: number, total: number) => { + checked.push({ url: result.url, index, total }); + }, + }); + + expect(foundFiles).toHaveLength(1); + expect(foundFiles[0]?.[0]?.endsWith('a.md')).toBe(true); + expect(checked).toEqual([ + { url: 'https://example.com/one', index: 1, total: 2 }, + { url: 'https://example.com/two', index: 2, total: 2 }, + ]); + }); + + it('passes the configured timeout to the check function', async () => { + await writeFile(join(dir, 'a.md'), '[one](https://example.com/one)'); + const timeouts: number[] = []; + + await runScan(resolveConfig({ path: dir, timeout: 1234 }), { + checkUrlFn: async (url, timeout) => { + timeouts.push(timeout); + return { url, status: 'ok', redirectChain: [], isHttp: false, durationMs: 1 }; + }, + }); + + expect(timeouts).toEqual([1234]); + }); + + it('counts timeouts, malformed urls, redirects, and insecure http links', async () => { + await writeFile( + join(dir, 'a.md'), + [ + '[slow](https://example.com/slow)', + '[bad](https://example.com/bad)', + '[old](https://example.com/old)', + '[legacy](http://example.com/legacy)', + '[nope](https://example.com/nope)', + ].join('\n'), + ); + + const report = await runScan(resolveConfig({ path: dir }), { + checkUrlFn: fakeCheck({ + 'https://example.com/slow': { status: 'timeout' }, + 'https://example.com/bad': { status: 'broken' }, + 'https://example.com/old': { status: 'redirect' }, + 'https://example.com/nope': { status: 'malformed' }, + }), + }); + + expect(report.summary).toMatchObject({ + ok: 1, + redirects: 1, + broken: 1, + timeouts: 1, + malformed: 1, + insecureHttp: 1, + }); + }); + + it('returns an empty report when no files match', async () => { + const report = await runScan(resolveConfig({ path: dir }), { checkUrlFn: fakeCheck() }); + + expect(report.files).toEqual([]); + expect(report.results).toEqual([]); + expect(report.summary.filesScanned).toBe(0); + expect(report.summary.totalLinks).toBe(0); + expect(report.summary.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('includes the resolved config in the report', async () => { + await writeFile(join(dir, 'a.md'), '[one](https://example.com/one)'); + + const config = resolveConfig({ path: dir, maxUrlLength: 10 }); + const report = await runScan(config, { checkUrlFn: fakeCheck() }); + + expect(report.config).toEqual(config); + expect(report.results[0]?.isTooLong).toBe(true); + }); +}); diff --git a/test/utils.test.ts b/test/utils.test.ts index 3716780..8bfbc4c 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -48,6 +48,19 @@ describe('matchesIgnorePattern', () => { it('returns false when nothing matches', () => { expect(matchesIgnorePattern('https://example.com/keep', ['internal.example.com'])).toBe(false); }); + + it('falls back to a substring match with the wildcards removed', () => { + expect(matchesIgnorePattern('https://example.com/tracking?id=1', ['tracking*'])).toBe(true); + }); + + it('escapes regex metacharacters in patterns', () => { + expect(matchesIgnorePattern('https://example.com/a+b', ['https://example.com/a+*'])).toBe(true); + expect(matchesIgnorePattern('https://example.com/axb', ['https://example.com/a+*'])).toBe(false); + }); + + it('returns false when there are no patterns', () => { + expect(matchesIgnorePattern('https://example.com', [])).toBe(false); + }); }); describe('dedupe', () => { @@ -105,4 +118,9 @@ describe('runWithConcurrency', () => { const results = await runWithConcurrency([], 4, async (item) => item); expect(results).toEqual([]); }); + + it('clamps a non-positive limit to a single worker', async () => { + const results = await runWithConcurrency([1, 2, 3], 0, async (item) => item + 1); + expect(results).toEqual([2, 3, 4]); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 67d87aa..a26be0e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'lcov', 'html'], include: ['src/**/*.ts'], - exclude: ['src/cli.ts', 'src/github.ts'], + exclude: ['src/cli.ts', 'src/github.ts', 'src/action-entry.ts'], }, }, });