Skip to content
Open
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
129 changes: 129 additions & 0 deletions test/checker.request.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
24 changes: 24 additions & 0 deletions test/checker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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');
});
});
221 changes: 221 additions & 0 deletions test/formatter.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;

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> = {}): 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['summary']> = {}): 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');
});
});
Loading
Loading