Skip to content
Merged
8 changes: 4 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ packages/
└── crawler ── @nitpicker/cli ← @d-zero/roar(外部)
↑ ↑ ↑ ↑
│ core │ report-google-sheets
│ ↑ │
│ analyze-* プラグイン
└── @d-zero/dealer(外部)
│ core │ report-google-sheets ← @d-zero/google-sheets(外部)
│ ↑ │
│ analyze-* プラグイン
└── @d-zero/dealer(外部)──┘
```

> **Note**: CLI は analyze プラグインに直接依存する(`npx` 実行時のモジュール解決のため)。新規 analyze プラグイン追加時は `@nitpicker/cli/package.json` の `dependencies` にも追加すること。
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ fix(core): prevent analyze results from being silently empty
docs: update README with new CLI options
```

commitlint がプリコミットフックで検証します
`.commitlintrc` で設定されていますが、現在 Git フックには組み込まれていません。CI やレビュー時にコミットメッセージを確認してください

## コーディング規約

Expand Down
204 changes: 194 additions & 10 deletions packages/@nitpicker/cli/src/commands/crawl.spec.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,46 @@
import type { CrawlerOrchestrator as OrchestratorType } from '@nitpicker/crawler';

import path from 'node:path';

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

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

vi.mock('@nitpicker/crawler', () => ({
CrawlerOrchestrator: {
crawling: mockCrawling,
resume: mockResume,
},
}));

const mockEventAssignments = vi.fn().mockResolvedValue();

vi.mock('../crawl/event-assignments.js', () => ({
eventAssignments: vi.fn().mockResolvedValue(),
eventAssignments: mockEventAssignments,
}));

const mockVerbosely = vi.fn();
const mockLog = vi.fn();

vi.mock('../crawl/debug.js', () => ({
log: vi.fn(),
verbosely: vi.fn(),
log: mockLog,
verbosely: mockVerbosely,
}));

const mockDiff = vi.fn().mockResolvedValue();

vi.mock('../crawl/diff.js', () => ({
diff: mockDiff,
}));

const mockReadList = vi.fn().mockResolvedValue(['https://example.com/from-file']);

vi.mock('@d-zero/readtext/list', () => ({
readList: mockReadList,
}));

// eslint-disable-next-line @typescript-eslint/consistent-type-imports
type CrawlFlags = Parameters<typeof import('./crawl.js').startCrawl>[1];

/**
Expand Down Expand Up @@ -66,12 +88,18 @@ function setupFakeOrchestrator() {
cb?.(fakeOrchestrator, { baseUrl: 'https://example.com' });
return Promise.resolve(fakeOrchestrator);
});

mockResume.mockImplementation((_path, _opts, cb) => {
cb?.(fakeOrchestrator, { baseUrl: 'https://example.com' });
return Promise.resolve(fakeOrchestrator);
});

return fakeOrchestrator;
}

describe('startCrawl', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
setupFakeOrchestrator();
});

Expand Down Expand Up @@ -124,14 +152,41 @@ describe('startCrawl', () => {
expect.any(Function),
);
});

it('--output フラグを filePath として渡す', async () => {
const { startCrawl } = await import('./crawl.js');
await startCrawl(['https://example.com'], createFlags({ output: '/custom/output' }));

expect(mockCrawling).toHaveBeenCalledWith(
['https://example.com'],
expect.objectContaining({ filePath: '/custom/output' }),
expect.any(Function),
);
});

it('アーカイブファイルパスを返す', async () => {
const { startCrawl } = await import('./crawl.js');
const result = await startCrawl(['https://example.com'], createFlags());

expect(result).toBe('/tmp/test.nitpicker');
});

it('イベントエラー発生時に CrawlAggregateError をスローする', async () => {
mockEventAssignments.mockRejectedValueOnce(new Error('scrape failed'));

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

await expect(startCrawl(['https://example.com'], createFlags())).rejects.toThrow(
CrawlAggregateError,
);
});
});

describe('crawl', () => {
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
setupFakeOrchestrator();
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
Expand All @@ -151,11 +206,6 @@ describe('crawl', () => {

it('--single と --list-file を同時指定した場合、警告を出力する', async () => {
const { crawl } = await import('./crawl.js');

vi.mock('@d-zero/readtext/list', () => ({
readList: vi.fn().mockResolvedValue(['https://example.com/a']),
}));

await crawl([], createFlags({ single: true, listFile: '/tmp/list.txt' }));

expect(consoleWarnSpy).toHaveBeenCalledWith(
Expand All @@ -169,4 +219,138 @@ describe('crawl', () => {

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

it('--diff モードで引数が2つの場合、diff() を呼び出す', async () => {
const { crawl } = await import('./crawl.js');
await crawl(['a.nitpicker', 'b.nitpicker'], createFlags({ diff: true }));

expect(mockDiff).toHaveBeenCalledWith('a.nitpicker', 'b.nitpicker');
expect(mockCrawling).not.toHaveBeenCalled();
});

it('--diff モードで引数が不足している場合、エラーを投げる', async () => {
const { crawl } = await import('./crawl.js');

await expect(crawl([], createFlags({ diff: true }))).rejects.toThrow(
'Please provide two file paths to compare',
);
});

it('--diff モードで引数が1つの場合、エラーを投げる', async () => {
const { crawl } = await import('./crawl.js');

await expect(crawl(['a.nitpicker'], createFlags({ diff: true }))).rejects.toThrow(
'Please provide two file paths to compare',
);
});

it('--resume に絶対パスを指定した場合、そのまま渡す', async () => {
const { crawl } = await import('./crawl.js');
await crawl([], createFlags({ resume: '/absolute/stub' }));

expect(mockResume).toHaveBeenCalledWith(
'/absolute/stub',
expect.any(Object),
expect.any(Function),
);
expect(mockCrawling).not.toHaveBeenCalled();
});

it('--resume に相対パスを指定した場合、resolve して渡す', async () => {
const { crawl } = await import('./crawl.js');
await crawl([], createFlags({ resume: 'relative/stub' }));

expect(mockResume).toHaveBeenCalledWith(
path.resolve(process.cwd(), 'relative/stub'),
expect.any(Object),
expect.any(Function),
);
});

it('--resume と --output を同時指定した場合、エラーを投げる', async () => {
const { crawl } = await import('./crawl.js');

await expect(
crawl([], createFlags({ resume: '/tmp/stub', output: '/tmp/out' })),
).rejects.toThrow(
'--output flag is not supported with --resume. The archive path is determined by the stub file.',
);
});

it('--verbose フラグで verbosely() を呼び出す', async () => {
const { crawl } = await import('./crawl.js');
await crawl(['https://example.com'], createFlags({ verbose: true }));

expect(mockVerbosely).toHaveBeenCalled();
});

it('--verbose が未指定の場合、verbosely() を呼び出さない', async () => {
const { crawl } = await import('./crawl.js');
await crawl(['https://example.com'], createFlags());

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

it('--verbose と --silent を同時指定した場合、verbosely() を呼び出さない', async () => {
const { crawl } = await import('./crawl.js');
await crawl(['https://example.com'], createFlags({ verbose: true, silent: true }));

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

it('--list-file フラグでファイルからURLリストを読み込んで startCrawl を呼び出す', async () => {
const { crawl } = await import('./crawl.js');
await crawl([], createFlags({ listFile: '/tmp/urls.txt' }));

expect(mockReadList).toHaveBeenCalledWith(
path.resolve(process.cwd(), '/tmp/urls.txt'),
);
expect(mockCrawling).toHaveBeenCalledWith(
['https://example.com/from-file'],
expect.objectContaining({ list: true }),
expect.any(Function),
);
});

it('--list と args を両方指定した場合、マージして startCrawl を呼び出す', async () => {
const { crawl } = await import('./crawl.js');
await crawl(
['https://example.com/arg'],
createFlags({ list: ['https://example.com/list'] }),
);

expect(mockCrawling).toHaveBeenCalledWith(
['https://example.com/list', 'https://example.com/arg'],
expect.any(Object),
expect.any(Function),
);
});

it('単一 URL 引数で startCrawl を呼び出す', async () => {
const { crawl } = await import('./crawl.js');
await crawl(['https://example.com'], createFlags());

expect(mockCrawling).toHaveBeenCalledWith(
['https://example.com'],
expect.any(Object),
expect.any(Function),
);
});

it('引数なし・フラグなしの場合、何も呼び出さずに正常終了する', async () => {
const { crawl } = await import('./crawl.js');
await crawl([], createFlags());

expect(mockCrawling).not.toHaveBeenCalled();
expect(mockResume).not.toHaveBeenCalled();
expect(mockDiff).not.toHaveBeenCalled();
});

it('常に log() でフラグをログ出力する', async () => {
const { crawl } = await import('./crawl.js');
const flags = createFlags();
await crawl([], flags);

expect(mockLog).toHaveBeenCalledWith('Options: %O', flags);
});
});
38 changes: 38 additions & 0 deletions packages/@nitpicker/cli/src/crawl/debug.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import debug from 'debug';
import { describe, it, expect, vi, afterEach } from 'vitest';

import { log, verbosely } from './debug.js';

describe('log', () => {
it('Nitpicker:CLI 名前空間の debug インスタンスである', () => {
expect(log.namespace).toBe('Nitpicker:CLI');
});
});

describe('verbosely', () => {
afterEach(() => {
debug.disable();
});

it('Nitpicker 名前空間が無効な場合、debug.enable を呼び出す', () => {
debug.disable();
const enableSpy = vi.spyOn(debug, 'enable');

verbosely();

expect(enableSpy).toHaveBeenCalledWith(
'Nitpicker*,-Nitpicker:Crawler:Deal,-Nitpicker:Scraper:DOM:Details:*,-Nitpicker:Scraper:Resource:*',
);
enableSpy.mockRestore();
});

it('Nitpicker 名前空間がすでに有効な場合、再度 enable を呼び出さない', () => {
debug.enable('Nitpicker*');
const enableSpy = vi.spyOn(debug, 'enable');

verbosely();

expect(enableSpy).not.toHaveBeenCalled();
enableSpy.mockRestore();
});
});
Loading
Loading