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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions packages/@nitpicker/analyze-lighthouse/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,17 +120,28 @@ describe('analyze-lighthouse plugin', () => {
});
});

it('propagates non-Error exceptions without swallowing them', async () => {
it('wraps non-Error exceptions into Error and returns error result', async () => {
lighthouseMock.mockRejectedValue('unexpected string throw');

const plugin = pluginFactory({}, '');
const url = new URL('https://example.com');

await expect(
plugin.eachPage!({ url, html: '', window: {} as never, num: 0, total: 1 }),
).rejects.toBe('unexpected string throw');
const result = await plugin.eachPage!({
url,
html: '',
window: {} as never,
num: 0,
total: 1,
});

expect(killMock).toHaveBeenCalledTimes(1);
expect(result).toEqual({
page: {
performance: { value: 0, note: 'Error' },
accessibility: { value: 0, note: 'Error' },
'best-practices': { value: 0, note: 'Error' },
seo: { value: 0, note: 'Error' },
},
});
});

it('propagates unexpected errors from report processing without swallowing them', async () => {
Expand Down
9 changes: 3 additions & 6 deletions packages/@nitpicker/analyze-lighthouse/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import * as chromeLauncher from 'chrome-launcher';
import lighthouse from 'lighthouse';
import { ReportUtils } from 'lighthouse/report/renderer/report-utils.js';

import { toError } from './to-error.js';

/**
* Plugin options for the Lighthouse analysis.
*/
Expand Down Expand Up @@ -62,12 +64,7 @@ export default definePlugin((options: Options) => {

try {
const result = await lighthouse(url.href, { port: chrome.port }, config).catch(
(error: unknown) => {
if (error instanceof Error) {
return error;
}
throw error;
},
(error: unknown) => toError(error),
);

if (!result || result instanceof Error) {
Expand Down
36 changes: 36 additions & 0 deletions packages/@nitpicker/analyze-lighthouse/src/to-error.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';

import { toError } from './to-error.js';

describe('toError', () => {
it('returns the same Error instance when given an Error', () => {
const original = new Error('original message');
const result = toError(original);
expect(result).toBe(original);
});

it('wraps a string into a new Error', () => {
const result = toError('string message');
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe('string message');
});

it('wraps null into a new Error', () => {
const result = toError(null);
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe('null');
});

it('wraps undefined into a new Error', () => {
const result = toError();
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe('undefined');
});

it('preserves subclass instances of Error', () => {
const original = new TypeError('type error');
const result = toError(original);
expect(result).toBe(original);
expect(result).toBeInstanceOf(TypeError);
});
});
15 changes: 15 additions & 0 deletions packages/@nitpicker/analyze-lighthouse/src/to-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Coerces an unknown thrown value into an `Error` instance.
*
* If the value is already an `Error`, it is returned as-is so that its
* original `stack` trace and any custom properties are preserved.
* Otherwise, the value is converted to a string and wrapped in a new `Error`.
* @param value - The caught value to normalise.
* @returns An `Error` instance, either the original or a newly created one.
*/
export function toError(value?: unknown): Error {
if (value instanceof Error) {
return value;
}
return new Error(String(value));
}
80 changes: 52 additions & 28 deletions packages/@nitpicker/cli/src/commands/crawl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,11 @@ function run(
* Starts a fresh crawl session for the given URLs.
*
* Creates a CrawlerOrchestrator, runs the crawl, writes the archive,
* and cleans up browser processes. Exits with code 1 if errors occurred.
* and cleans up browser processes.
* @param siteUrl - One or more root URLs to crawl
* @param flags - Parsed CLI flags from the `crawl` command
* @returns A promise that resolves with the archive file path when crawling, writing, and cleanup are complete.
* @throws {CrawlAggregateError} When one or more errors occurred during crawling.
*/
export async function startCrawl(siteUrl: string[], flags: CrawlFlags): Promise<string> {
const errStack: (CrawlerError | Error)[] = [];
Expand Down Expand Up @@ -205,7 +206,7 @@ export async function startCrawl(siteUrl: string[], flags: CrawlFlags): Promise<

if (errStack.length > 0) {
formatCrawlErrors(errStack);
process.exit(1);
throw new CrawlAggregateError(errStack);
}

return archivePath;
Expand Down Expand Up @@ -248,7 +249,7 @@ async function resumeCrawl(stubFilePath: string, flags: CrawlFlags) {

if (errStack.length > 0) {
formatCrawlErrors(errStack);
process.exit(1);
throw new CrawlAggregateError(errStack);
}
}

Expand Down Expand Up @@ -281,39 +282,62 @@ export async function crawl(args: string[], flags: CrawlFlags) {
return;
}

if (flags.resume) {
if (flags.output) {
throw new Error(
'--output flag is not supported with --resume. The archive path is determined by the stub file.',
);
}
await resumeCrawl(flags.resume, flags);
return;
}

if (flags.single && (flags.list?.length || flags.listFile)) {
// eslint-disable-next-line no-console
console.warn('Warning: --single is ignored when --list or --list-file is specified.');
}

if (flags.listFile) {
const list = await readList(path.resolve(process.cwd(), flags.listFile));
flags.list = list;
await startCrawl(list, flags);
return;
}
try {
if (flags.resume) {
if (flags.output) {
throw new Error(
'--output flag is not supported with --resume. The archive path is determined by the stub file.',
);
}
await resumeCrawl(flags.resume, flags);
return;
}

if (flags.list && flags.list.length > 0) {
const pageList = [...flags.list, ...args];
await startCrawl(pageList, flags);
return;
}
if (flags.listFile) {
const list = await readList(path.resolve(process.cwd(), flags.listFile));
flags.list = list;
await startCrawl(list, flags);
return;
}

const siteUrl = args[0];
if (flags.list && flags.list.length > 0) {
const pageList = [...flags.list, ...args];
await startCrawl(pageList, flags);
return;
}

if (siteUrl) {
await startCrawl([siteUrl], flags);
return;
const siteUrl = args[0];

if (siteUrl) {
await startCrawl([siteUrl], flags);
return;
}
} catch (error) {
if (error instanceof CrawlAggregateError) {
process.exit(1);
}
throw error;
}
}

/**
* Error thrown when one or more errors occurred during crawling.
* Wraps the collected errors so callers can inspect them.
*/
export class CrawlAggregateError extends Error {
/** The individual errors that occurred during crawling. */
readonly errors: readonly (CrawlerError | Error)[];
/**
* @param errors - The individual errors collected during the crawl session.
*/
constructor(errors: (CrawlerError | Error)[]) {
super(`Crawl completed with ${errors.length} error(s).`);
this.errors = errors;
}
}

Expand Down
27 changes: 27 additions & 0 deletions packages/@nitpicker/cli/src/commands/pipeline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,33 @@ describe('pipeline command', () => {
).rejects.toThrow('Report failed');
});

it('suppresses pipeline log output when --silent is set', async () => {
vi.mocked(startCrawlFn).mockResolvedValue('/tmp/site.nitpicker');
vi.mocked(analyzeFn).mockResolvedValue();

await pipeline(['https://example.com'], {
...defaultFlags,
silent: true,
all: true,
});

expect(consoleLogSpy).not.toHaveBeenCalled();
});

it('suppresses pipeline log output when --silent is set with --sheet', async () => {
vi.mocked(startCrawlFn).mockResolvedValue('/tmp/site.nitpicker');
vi.mocked(analyzeFn).mockResolvedValue();
vi.mocked(reportFn).mockResolvedValue();

await pipeline(['https://example.com'], {
...defaultFlags,
silent: true,
sheet: 'https://docs.google.com/spreadsheets/d/xxx',
});

expect(consoleLogSpy).not.toHaveBeenCalled();
});

it('shows completion message after all steps', async () => {
vi.mocked(startCrawlFn).mockResolvedValue('/tmp/site.nitpicker');
vi.mocked(analyzeFn).mockResolvedValue();
Expand Down
33 changes: 23 additions & 10 deletions packages/@nitpicker/cli/src/commands/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { report } from './report.js';
* that executes the full crawl → analyze → report workflow sequentially.
* @see {@link pipeline} for the main entry point
*/
// TODO: フラグ定義が crawl.ts / analyze.ts / report.ts と重複している。
// @d-zero/roar の CommandDef 型制約により合成が困難なため手動同期が必要。
// crawl / analyze / report にフラグを追加・変更した際はここも更新すること。
export const commandDef = {
desc: 'Run crawl → analyze → report sequentially',
flags: {
Expand Down Expand Up @@ -175,7 +178,7 @@ type PipelineFlags = InferFlags<typeof commandDef.flags>;
* to the analyze step. If `--sheet` is provided, the report step runs
* last to publish results to Google Sheets.
*
* Each step's errors cause `process.exit(1)`, halting the pipeline.
* Errors from any step propagate to the caller as exceptions.
* @param args - Positional arguments; first argument is the root URL to crawl.
* @param flags - Parsed CLI flags from the `pipeline` command.
* @returns Resolves when all pipeline steps complete.
Expand All @@ -191,9 +194,13 @@ export async function pipeline(args: string[], flags: PipelineFlags) {
process.exit(1);
}

const silent = !!flags.silent;

// Step 1: Crawl
// eslint-disable-next-line no-console
console.log('\n📡 [pipeline] Step 1/3: Crawling...');
if (!silent) {
// eslint-disable-next-line no-console
console.log('\n📡 [pipeline] Step 1/3: Crawling...');
}
const archivePath = await startCrawl([siteUrl], {
interval: flags.interval,
image: flags.image,
Expand Down Expand Up @@ -221,8 +228,10 @@ export async function pipeline(args: string[], flags: PipelineFlags) {
});

// Step 2: Analyze
// eslint-disable-next-line no-console
console.log('\n🔍 [pipeline] Step 2/3: Analyzing...');
if (!silent) {
// eslint-disable-next-line no-console
console.log('\n🔍 [pipeline] Step 2/3: Analyzing...');
}
await analyze([archivePath], {
all: flags.all,
plugin: flags.plugin,
Expand All @@ -236,8 +245,10 @@ export async function pipeline(args: string[], flags: PipelineFlags) {

// Step 3: Report (only if --sheet is provided)
if (flags.sheet) {
// eslint-disable-next-line no-console
console.log('\n📊 [pipeline] Step 3/3: Reporting...');
if (!silent) {
// eslint-disable-next-line no-console
console.log('\n📊 [pipeline] Step 3/3: Reporting...');
}
await report([archivePath], {
sheet: flags.sheet,
credentials: flags.credentials,
Expand All @@ -247,11 +258,13 @@ export async function pipeline(args: string[], flags: PipelineFlags) {
verbose: flags.verbose,
silent: flags.silent,
});
} else {
} else if (!silent) {
// eslint-disable-next-line no-console
console.log('\n📊 [pipeline] Step 3/3: Skipped (no --sheet specified)');
}

// eslint-disable-next-line no-console
console.log('\n✅ [pipeline] All steps completed.');
if (!silent) {
// eslint-disable-next-line no-console
console.log('\n✅ [pipeline] All steps completed.');
}
}
Loading
Loading