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
24 changes: 24 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,30 @@ pathMatch('/about', '/blog/*') → false
| page.goto() | タイムアウト, ERR_NAME_NOT_RESOLVED | `@retryable` でリトライ後 `type='error'` で返却 |
| DOM 解析 | evaluate 失敗 | catch でフォールバック値 |

### CLI 終了コード

`crawl` コマンドと `pipeline` コマンドはエラーの種類に応じて異なる終了コードを返す:

| コード | 定数 (`exit-code.ts`) | 意味 |
| ------ | --------------------- | ---------------------------------------------------------------- |
| `0` | `ExitCode.Success` | 成功 |
| `1` | `ExitCode.Fatal` | 致命的エラー(引数不足、内部エラー、スコープ内ページのエラー等) |
| `2` | `ExitCode.Warning` | 警告 — 外部リンクエラーのみ発生(クロール自体は成功) |

### エラー分類フロー

```
CrawlerError.isExternal
├── true → 外部エラー(DNS 失敗、証明書エラー等)
└── false → 内部エラー(スコープ内ページの失敗)

CrawlAggregateError
├── hasOnlyExternalErrors = true → exit 2(--strict 時は exit 1)
└── hasOnlyExternalErrors = false → exit 1
```

`--strict` フラグを指定すると、外部リンクエラーのみの場合でも exit 1(致命的)として扱う。CI/CD パイプラインで外部リンクの一時的な障害を許容したい場合は `--strict` を省略する。

---

## 12. E2E テスト構成
Expand Down
41 changes: 35 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ $ npx @nitpicker/cli crawl https://example.com
| `--user-agent` | 文字列 | `Nitpicker/<version>` | 不可 | HTTP リクエストのカスタム User-Agent 文字列 |
| `--ignore-robots` | なし | なし | 不可 | robots.txt の制限を無視する |
| `--output` `-o` | ファイルパス | 自動生成 | 不可 | アーカイブファイルの出力先パス |
| `--strict` | なし | なし | 不可 | 外部リンクエラーも致命的エラーとして扱う |
| `--verbose` | なし | なし | 不可 | 実行中に詳細ログを標準出力に表示 |
| `--silent` | なし | なし | 不可 | 実行中のログ出力を抑制 |
| `--diff` | なし | なし | 不可 | 差分モード |
Expand Down Expand Up @@ -83,6 +84,24 @@ $ npx @nitpicker/cli crawl https://example.com --output ./reports/site.nitpicker
$ npx @nitpicker/cli crawl https://example.com -o custom-name
```

#### 終了コード

| コード | 意味 | 説明 |
| ------ | ---------------------- | -------------------------------------------------------------- |
| `0` | 成功 | エラーなしで完了 |
| `1` | 致命的エラー | 引数不足、内部エラー、スコープ内ページのスクレイプ失敗など |
| `2` | 警告(外部エラーのみ) | 外部リンク(DNS 失敗、証明書エラー等)のみでクロール自体は成功 |

CI/CD パイプラインでは、外部リンクの一時的な障害でビルドが失敗しないよう exit code `2` を利用できる。`--strict` を指定すると外部リンクエラーも exit code `1`(致命的)として扱う。

```sh
# CI: 外部リンクエラーを無視(exit 2 を許容)
npx @nitpicker/cli crawl https://example.com || [ $? -eq 2 ]

# CI: 外部リンクエラーも失敗にする
npx @nitpicker/cli crawl https://example.com --strict
```

##### Tips: 認証付き URL

```sh
Expand Down Expand Up @@ -186,12 +205,12 @@ $ npx @nitpicker/cli pipeline <URL>

crawl / analyze / report のオプションをすべて指定可能。各ステップに対応するフラグが自動的にルーティングされる。

| カテゴリ | 主要オプション | 説明 |
| -------- | -------------------------------------------------------------------------------- | ---------------------------------------------- |
| crawl | `--interval`, `--parallels`, `--no-image`, `--scope`, `--exclude`, `--output` 等 | クロール動作の制御(crawl セクション参照) |
| analyze | `--all`, `--plugin`, `--search-keywords`, `--axe-lang` 等 | 分析プラグインの制御(analyze セクション参照) |
| report | `--sheet`, `--credentials`, `--config`, `--limit` | レポート出力の制御(report セクション参照) |
| 共通 | `--verbose`, `--silent` | ログ出力の制御 |
| カテゴリ | 主要オプション | 説明 |
| -------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| crawl | `--interval`, `--parallels`, `--no-image`, `--scope`, `--exclude`, `--output`, `--strict` 等 | クロール動作の制御(crawl セクション参照) |
| analyze | `--all`, `--plugin`, `--search-keywords`, `--axe-lang` 等 | 分析プラグインの制御(analyze セクション参照) |
| report | `--sheet`, `--credentials`, `--config`, `--limit` | レポート出力の制御(report セクション参照) |
| 共通 | `--verbose`, `--silent` | ログ出力の制御 |

> **注意**: `--resume`, `--diff` は crawl 専用モードのため pipeline では使用不可。

Expand All @@ -212,4 +231,14 @@ $ npx @nitpicker/cli pipeline https://example.com --all --silent --sheet "https:

# 出力パス指定
$ npx @nitpicker/cli pipeline https://example.com --all --output ./reports/site

# CI: 外部リンクエラーを無視(exit 2 を許容)
$ npx @nitpicker/cli pipeline https://example.com --all --silent || [ $? -eq 2 ]

# CI: 外部リンクエラーも失敗にする
$ npx @nitpicker/cli pipeline https://example.com --all --silent --strict
```

#### 終了コード

crawl コマンドと同じ終了コード体系に従う。詳細は [crawl の終了コード](#終了コード) を参照。
190 changes: 189 additions & 1 deletion packages/@nitpicker/cli/src/commands/crawl.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { CrawlerOrchestrator as OrchestratorType } from '@nitpicker/crawler';
import type {
CrawlerOrchestrator as OrchestratorType,
CrawlerError,
} from '@nitpicker/crawler';

import path from 'node:path';

import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';

import { ExitCode } from '../exit-code.js';

const mockCrawling = vi.fn();
const mockResume = vi.fn();

Expand Down Expand Up @@ -69,6 +74,7 @@ function createFlags(overrides: Partial<CrawlFlags> = {}): CrawlFlags {
userAgent: undefined,
ignoreRobots: undefined,
output: undefined,
strict: undefined,
verbose: undefined,
silent: undefined,
diff: undefined,
Expand Down Expand Up @@ -354,3 +360,185 @@ describe('crawl', () => {
expect(mockLog).toHaveBeenCalledWith('Options: %O', flags);
});
});

/** Sentinel error thrown by the process.exit mock to halt execution. */
class ExitError extends Error {
/** The exit code passed to process.exit(). */
readonly code: number;
constructor(code: number) {
super(`process.exit(${code})`);
this.code = code;
}
}

/**
* Creates a fake CrawlerError for testing.
* @param isExternal - Whether the error is from an external URL.
*/
function createCrawlerError(isExternal: boolean): CrawlerError {
return {
pid: 1,
isMainProcess: true,
url: isExternal ? 'https://external.example.com' : 'https://example.com/page',
isExternal,
error: new Error('test error'),
};
}

describe('CrawlAggregateError', () => {
it('外部エラーのみの場合、hasOnlyExternalErrors が true', async () => {
const { CrawlAggregateError } = await import('./crawl.js');
const error = new CrawlAggregateError([
createCrawlerError(true),
createCrawlerError(true),
]);
expect(error.hasOnlyExternalErrors).toBe(true);
});

it('内部エラーを含む場合、hasOnlyExternalErrors が false', async () => {
const { CrawlAggregateError } = await import('./crawl.js');
const error = new CrawlAggregateError([
createCrawlerError(true),
createCrawlerError(false),
]);
expect(error.hasOnlyExternalErrors).toBe(false);
});

it('内部エラーのみの場合、hasOnlyExternalErrors が false', async () => {
const { CrawlAggregateError } = await import('./crawl.js');
const error = new CrawlAggregateError([createCrawlerError(false)]);
expect(error.hasOnlyExternalErrors).toBe(false);
});

it('plain Error は内部エラーとして扱う', async () => {
const { CrawlAggregateError } = await import('./crawl.js');
const error = new CrawlAggregateError([new Error('plain error')]);
expect(error.hasOnlyExternalErrors).toBe(false);
});

it('空の配列に対して hasOnlyExternalErrors が false', async () => {
const { CrawlAggregateError } = await import('./crawl.js');
const error = new CrawlAggregateError([]);
expect(error.hasOnlyExternalErrors).toBe(false);
expect(error.errors).toHaveLength(0);
});

it('外部エラーのみの場合、message に "external" の内訳を含む', async () => {
const { CrawlAggregateError } = await import('./crawl.js');
const error = new CrawlAggregateError([
createCrawlerError(true),
createCrawlerError(true),
]);
expect(error.message).toBe('Crawl completed with 2 error(s) (2 external).');
});

it('混合エラーの場合、message に内部と外部の内訳を含む', async () => {
const { CrawlAggregateError } = await import('./crawl.js');
const error = new CrawlAggregateError([
createCrawlerError(false),
createCrawlerError(true),
createCrawlerError(false),
]);
expect(error.message).toBe(
'Crawl completed with 3 error(s) (2 internal, 1 external).',
);
});

it('内部エラーのみの場合、message に "internal" の内訳を含む', async () => {
const { CrawlAggregateError } = await import('./crawl.js');
const error = new CrawlAggregateError([createCrawlerError(false)]);
expect(error.message).toBe('Crawl completed with 1 error(s) (1 internal).');
});
});

describe('crawl exit codes', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
setupFakeOrchestrator();
exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new ExitError(code as number);
});
vi.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

it('外部エラーのみの場合、サマリーに "external" を含む', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockEventAssignments.mockRejectedValueOnce(createCrawlerError(true));

const { crawl } = await import('./crawl.js');

try {
await crawl(['https://example.com'], createFlags());
} catch {
// exit mock throws
}
expect(consoleErrorSpy).toHaveBeenCalledWith(
'\nCompleted with 1 error(s) (1 external).',
);
});

it('内部エラーの場合、サマリーに "internal" を含む', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockEventAssignments.mockRejectedValueOnce(createCrawlerError(false));

const { crawl } = await import('./crawl.js');

try {
await crawl(['https://example.com'], createFlags());
} catch {
// exit mock throws
}
expect(consoleErrorSpy).toHaveBeenCalledWith(
'\nCompleted with 1 error(s) (1 internal).',
);
});

it('--resume 経由の外部エラーでも exit code 2 で終了する', async () => {
mockEventAssignments.mockRejectedValueOnce(createCrawlerError(true));

const { crawl } = await import('./crawl.js');

await expect(crawl([], createFlags({ resume: '/tmp/stub' }))).rejects.toThrow(
ExitError,
);
expect(exitSpy).toHaveBeenCalledWith(ExitCode.Warning);
});

it('外部エラーのみの場合、exit code 2 で終了する', async () => {
mockEventAssignments.mockRejectedValueOnce(createCrawlerError(true));

const { crawl } = await import('./crawl.js');

await expect(crawl(['https://example.com'], createFlags())).rejects.toThrow(
ExitError,
);
expect(exitSpy).toHaveBeenCalledWith(ExitCode.Warning);
});

it('内部エラーを含む場合、exit code 1 で終了する', async () => {
mockEventAssignments.mockRejectedValueOnce(createCrawlerError(false));

const { crawl } = await import('./crawl.js');

await expect(crawl(['https://example.com'], createFlags())).rejects.toThrow(
ExitError,
);
expect(exitSpy).toHaveBeenCalledWith(ExitCode.Fatal);
});

it('--strict 指定時、外部エラーのみでも exit code 1 で終了する', async () => {
mockEventAssignments.mockRejectedValueOnce(createCrawlerError(true));

const { crawl } = await import('./crawl.js');

await expect(
crawl(['https://example.com'], createFlags({ strict: true })),
).rejects.toThrow(ExitError);
expect(exitSpy).toHaveBeenCalledWith(ExitCode.Fatal);
});
});
Loading
Loading