From d31f2db950255e875a111d6eae13b6948f313f24 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 10:50:28 +0000 Subject: [PATCH 1/9] =?UTF-8?q?test(cli):=20crawl=20=E3=82=B3=E3=83=9E?= =?UTF-8?q?=E3=83=B3=E3=83=89=E3=81=8A=E3=82=88=E3=81=B3=E3=83=A6=E3=83=BC?= =?UTF-8?q?=E3=83=86=E3=82=A3=E3=83=AA=E3=83=86=E3=82=A3=E3=81=AE=E3=83=A6?= =?UTF-8?q?=E3=83=8B=E3=83=83=E3=83=88=E3=83=86=E3=82=B9=E3=83=88=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crawl.spec.ts に crawl() 関数の未テスト分岐を追加: - --diff モード(引数バリデーション、diff() 呼び出し) - --resume モード(resumeCrawl 呼び出し、--output 同時指定エラー) - --verbose / --silent フラグの処理 - --list-file / --list + args のマージ - 単一 URL / 引数なしの分岐 新規テストファイル: - crawl/event-assignments.spec.ts: イベント登録・silent モード・resolve/reject - crawl/diff.spec.ts: アーカイブ比較・ページフィルタリング(外部/非ページ/ステータス) - crawl/debug.spec.ts: log 名前空間・verbosely() の debug.enable 制御 Closes #13 https://claude.ai/code/session_01BE2QTpMfUWRHtXa29QLVpe --- .../@nitpicker/cli/src/commands/crawl.spec.ts | 175 +++++++++++++++++- .../@nitpicker/cli/src/crawl/debug.spec.ts | 38 ++++ .../@nitpicker/cli/src/crawl/diff.spec.ts | 167 +++++++++++++++++ .../cli/src/crawl/event-assignments.spec.ts | 94 ++++++++++ 4 files changed, 465 insertions(+), 9 deletions(-) create mode 100644 packages/@nitpicker/cli/src/crawl/debug.spec.ts create mode 100644 packages/@nitpicker/cli/src/crawl/diff.spec.ts create mode 100644 packages/@nitpicker/cli/src/crawl/event-assignments.spec.ts diff --git a/packages/@nitpicker/cli/src/commands/crawl.spec.ts b/packages/@nitpicker/cli/src/commands/crawl.spec.ts index cd0f8eb..aafce0f 100644 --- a/packages/@nitpicker/cli/src/commands/crawl.spec.ts +++ b/packages/@nitpicker/cli/src/commands/crawl.spec.ts @@ -3,10 +3,12 @@ import type { CrawlerOrchestrator as OrchestratorType } from '@nitpicker/crawler 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, }, })); @@ -14,11 +16,27 @@ vi.mock('../crawl/event-assignments.js', () => ({ eventAssignments: vi.fn().mockResolvedValue(), })); +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[1]; /** @@ -66,12 +84,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(); }); @@ -124,6 +148,24 @@ 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'); + }); }); describe('crawl', () => { @@ -131,7 +173,6 @@ describe('crawl', () => { beforeEach(() => { vi.clearAllMocks(); - vi.resetModules(); setupFakeOrchestrator(); consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -151,11 +192,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( @@ -169,4 +205,125 @@ 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 モードで resumeCrawl を呼び出す', async () => { + const { crawl } = await import('./crawl.js'); + await crawl([], createFlags({ resume: '/tmp/stub' })); + + expect(mockResume).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.any(Function), + ); + expect(mockCrawling).not.toHaveBeenCalled(); + }); + + 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).toHaveBeenCalled(); + 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); + }); }); diff --git a/packages/@nitpicker/cli/src/crawl/debug.spec.ts b/packages/@nitpicker/cli/src/crawl/debug.spec.ts new file mode 100644 index 0000000..4af5c0f --- /dev/null +++ b/packages/@nitpicker/cli/src/crawl/debug.spec.ts @@ -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(); + }); +}); diff --git a/packages/@nitpicker/cli/src/crawl/diff.spec.ts b/packages/@nitpicker/cli/src/crawl/diff.spec.ts new file mode 100644 index 0000000..163c187 --- /dev/null +++ b/packages/@nitpicker/cli/src/crawl/diff.spec.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, it, expect, vi } from 'vitest'; + +const mockGetPages = vi.fn(); +const mockClose = vi.fn().mockResolvedValue(); + +vi.mock('@nitpicker/crawler', () => ({ + Archive: { + open: vi.fn().mockImplementation(() => + Promise.resolve({ + getPages: mockGetPages, + close: mockClose, + }), + ), + }, +})); + +vi.mock('@d-zero/shared/sort-url', () => ({ + sortUrl: vi.fn((urls: string[]) => + [...urls] + .toSorted((a, b) => a.localeCompare(b)) + .map((url) => ({ withoutHashAndAuth: url })), + ), +})); + +const mockWriteFile = vi.fn().mockResolvedValue(); + +vi.mock('node:fs/promises', () => ({ + default: { + writeFile: (...args: unknown[]) => mockWriteFile(...args), + }, +})); + +import { diff } from './diff.js'; + +/** + * Creates a mock Page object. + * @param url - The page URL + * @param options - Page property overrides + * @param options.isPage - Whether the page is an HTML page + * @param options.isExternal - Whether the page is external + * @param options.status - HTTP status code + */ +function createMockPage( + url: string, + options: { + isPage?: boolean; + isExternal?: boolean; + status?: number | null; + } = {}, +) { + return { + url: { withoutHashAndAuth: url }, + isPage: () => options.isPage ?? true, + isExternal: options.isExternal ?? false, + status: 'status' in options ? options.status : 200, + }; +} + +describe('diff', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('アクティブな内部ページの URL を a.txt と b.txt に書き出す', async () => { + mockGetPages + .mockResolvedValueOnce([createMockPage('https://example.com/')]) + .mockResolvedValueOnce([createMockPage('https://example.com/about')]); + + await diff('archive-a.nitpicker', 'archive-b.nitpicker'); + + expect(mockWriteFile).toHaveBeenCalledWith('a.txt', 'https://example.com/', 'utf8'); + expect(mockWriteFile).toHaveBeenCalledWith( + 'b.txt', + 'https://example.com/about', + 'utf8', + ); + }); + + it('外部ページをフィルタリングする', async () => { + mockGetPages + .mockResolvedValueOnce([ + createMockPage('https://example.com/', { isExternal: false }), + createMockPage('https://external.com/', { isExternal: true }), + ]) + .mockResolvedValueOnce([]); + + await diff('a.nitpicker', 'b.nitpicker'); + + const aContent = mockWriteFile.mock.calls.find( + (c: unknown[]) => c[0] === 'a.txt', + )?.[1] as string; + expect(aContent).toBe('https://example.com/'); + expect(aContent).not.toContain('external.com'); + }); + + it('isPage() が false のページをフィルタリングする', async () => { + mockGetPages + .mockResolvedValueOnce([ + createMockPage('https://example.com/', { isPage: true }), + createMockPage('https://example.com/image.png', { isPage: false }), + ]) + .mockResolvedValueOnce([]); + + await diff('a.nitpicker', 'b.nitpicker'); + + const aContent = mockWriteFile.mock.calls.find( + (c: unknown[]) => c[0] === 'a.txt', + )?.[1] as string; + expect(aContent).toBe('https://example.com/'); + }); + + it('ステータス 400 以上のページをフィルタリングする', async () => { + mockGetPages + .mockResolvedValueOnce([ + createMockPage('https://example.com/', { status: 200 }), + createMockPage('https://example.com/404', { status: 404 }), + createMockPage('https://example.com/500', { status: 500 }), + ]) + .mockResolvedValueOnce([]); + + await diff('a.nitpicker', 'b.nitpicker'); + + const aContent = mockWriteFile.mock.calls.find( + (c: unknown[]) => c[0] === 'a.txt', + )?.[1] as string; + expect(aContent).toBe('https://example.com/'); + }); + + it('ステータスが null のページをフィルタリングする', async () => { + mockGetPages + .mockResolvedValueOnce([ + createMockPage('https://example.com/', { status: 200 }), + createMockPage('https://example.com/null', { status: null }), + ]) + .mockResolvedValueOnce([]); + + await diff('a.nitpicker', 'b.nitpicker'); + + const aContent = mockWriteFile.mock.calls.find( + (c: unknown[]) => c[0] === 'a.txt', + )?.[1] as string; + expect(aContent).toBe('https://example.com/'); + }); + + it('3xx ステータスのページを含める', async () => { + mockGetPages + .mockResolvedValueOnce([ + createMockPage('https://example.com/redirect', { status: 301 }), + ]) + .mockResolvedValueOnce([]); + + await diff('a.nitpicker', 'b.nitpicker'); + + const aContent = mockWriteFile.mock.calls.find( + (c: unknown[]) => c[0] === 'a.txt', + )?.[1] as string; + expect(aContent).toBe('https://example.com/redirect'); + }); + + it('完了後にアーカイブを close する', async () => { + mockGetPages.mockResolvedValue([]); + + await diff('a.nitpicker', 'b.nitpicker'); + + expect(mockClose).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/@nitpicker/cli/src/crawl/event-assignments.spec.ts b/packages/@nitpicker/cli/src/crawl/event-assignments.spec.ts new file mode 100644 index 0000000..6962ada --- /dev/null +++ b/packages/@nitpicker/cli/src/crawl/event-assignments.spec.ts @@ -0,0 +1,94 @@ +import type { CrawlerOrchestrator } from '@nitpicker/crawler'; + +import { beforeEach, describe, it, expect, vi } from 'vitest'; + +import { eventAssignments } from './event-assignments.js'; + +type EventHandler = (...args: unknown[]) => void; + +/** + * Mock orchestrator with controllable event emission. + */ +interface MockOrchestrator extends CrawlerOrchestrator { + /** + * Emits a registered event synchronously. + * @param event - Event name + * @param args - Event arguments + */ + emit(event: string, ...args: unknown[]): void; +} + +/** + * Creates a mock CrawlerOrchestrator with controllable event emission. + */ +function createMockOrchestrator(): MockOrchestrator { + const handlers: Record = {}; + return { + on: vi.fn((event: string, handler: EventHandler) => { + handlers[event] ??= []; + handlers[event].push(handler); + }), + emit(event: string, ...args: unknown[]): void { + for (const handler of handlers[event] ?? []) { + handler(...args); + } + }, + } as unknown as MockOrchestrator; +} + +describe('eventAssignments', () => { + let stderrSpy: ReturnType; + + beforeEach(() => { + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + it('logType が silent の場合、即座に resolve する', async () => { + const orchestrator = createMockOrchestrator(); + await eventAssignments(orchestrator, ['header'], 'silent'); + + expect(orchestrator.on).not.toHaveBeenCalled(); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it('初期ログを stderr に出力する', () => { + const orchestrator = createMockOrchestrator(); + void eventAssignments(orchestrator, ['🐳 header', ' key: value'], 'normal'); + + expect(stderrSpy).toHaveBeenCalledTimes(1); + const output = stderrSpy.mock.calls[0]![0] as string; + expect(output).toContain('header'); + expect(output).toContain('key: value'); + }); + + it('error イベントで reject する', async () => { + const orchestrator = createMockOrchestrator(); + const promise = eventAssignments(orchestrator, ['header'], 'normal'); + + const error = new Error('crawl error'); + orchestrator.emit('error', error); + + await expect(promise).rejects.toBe(error); + }); + + it('writeFileStart イベントでファイルパスを stderr に出力する', async () => { + const orchestrator = createMockOrchestrator(); + const promise = eventAssignments(orchestrator, ['header'], 'normal'); + + orchestrator.emit('writeFileStart', { filePath: '/tmp/out.nitpicker' }); + orchestrator.emit('writeFileEnd', { filePath: '/tmp/out.nitpicker' }); + await promise; + + const calls = stderrSpy.mock.calls.map((c) => c[0] as string); + expect(calls.some((c) => c.includes('/tmp/out.nitpicker'))).toBe(true); + }); + + it('writeFileEnd イベントで resolve する', async () => { + const orchestrator = createMockOrchestrator(); + const promise = eventAssignments(orchestrator, ['header'], 'normal'); + + orchestrator.emit('writeFileEnd', { filePath: '/tmp/out.nitpicker' }); + + await expect(promise).resolves.toBeUndefined(); + }); +}); From 849208408fe104ff87766a6d71cfdc26173e770c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 11:38:03 +0000 Subject: [PATCH 2/9] =?UTF-8?q?test(cli):=20QA=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E4=BA=8B=E9=A0=85=E3=81=AE?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 重大: - startCrawl のエラーパス(errStack → process.exit(1))のテスト追加 - diff.spec.ts の Archive.open モックを archiveA/B 別に分離し独立検証 🟡 推奨: - --resume テストで絶対パス/相対パスの path.resolve を具体値で検証 - event-assignments.spec.ts に afterEach(vi.restoreAllMocks) を追加 - diff.spec.ts に複数ページのソート+改行区切り結合テストを追加 - --list-file テストで readList に渡すパスを具体値で検証 - diff.spec.ts に Archive.open へのファイルパス引数検証テストを追加 https://claude.ai/code/session_01BE2QTpMfUWRHtXa29QLVpe --- .../@nitpicker/cli/src/commands/crawl.spec.ts | 58 +++++++- .../@nitpicker/cli/src/crawl/diff.spec.ts | 128 ++++++++++++------ .../cli/src/crawl/event-assignments.spec.ts | 6 +- 3 files changed, 141 insertions(+), 51 deletions(-) diff --git a/packages/@nitpicker/cli/src/commands/crawl.spec.ts b/packages/@nitpicker/cli/src/commands/crawl.spec.ts index aafce0f..222c0ed 100644 --- a/packages/@nitpicker/cli/src/commands/crawl.spec.ts +++ b/packages/@nitpicker/cli/src/commands/crawl.spec.ts @@ -1,5 +1,7 @@ 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(); @@ -12,8 +14,10 @@ vi.mock('@nitpicker/crawler', () => ({ }, })); +const mockEventAssignments = vi.fn().mockResolvedValue(); + vi.mock('../crawl/event-assignments.js', () => ({ - eventAssignments: vi.fn().mockResolvedValue(), + eventAssignments: mockEventAssignments, })); const mockVerbosely = vi.fn(); @@ -93,6 +97,16 @@ function setupFakeOrchestrator() { return fakeOrchestrator; } +/** 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; + } +} + describe('startCrawl', () => { beforeEach(() => { vi.clearAllMocks(); @@ -166,6 +180,27 @@ describe('startCrawl', () => { expect(result).toBe('/tmp/test.nitpicker'); }); + + it('イベントエラー発生時に process.exit(1) を呼び出す', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new ExitError(code as number); + }); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + mockEventAssignments.mockRejectedValueOnce(new Error('scrape failed')); + + const { startCrawl } = await import('./crawl.js'); + + await expect(startCrawl(['https://example.com'], createFlags())).rejects.toThrow( + ExitError, + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('\nCompleted with 1 error(s).'); + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); }); describe('crawl', () => { @@ -230,18 +265,29 @@ describe('crawl', () => { ); }); - it('--resume モードで resumeCrawl を呼び出す', async () => { + it('--resume に絶対パスを指定した場合、そのまま渡す', async () => { const { crawl } = await import('./crawl.js'); - await crawl([], createFlags({ resume: '/tmp/stub' })); + await crawl([], createFlags({ resume: '/absolute/stub' })); expect(mockResume).toHaveBeenCalledWith( - expect.any(String), + '/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'); @@ -277,7 +323,9 @@ describe('crawl', () => { const { crawl } = await import('./crawl.js'); await crawl([], createFlags({ listFile: '/tmp/urls.txt' })); - expect(mockReadList).toHaveBeenCalled(); + expect(mockReadList).toHaveBeenCalledWith( + path.resolve(process.cwd(), '/tmp/urls.txt'), + ); expect(mockCrawling).toHaveBeenCalledWith( ['https://example.com/from-file'], expect.objectContaining({ list: true }), diff --git a/packages/@nitpicker/cli/src/crawl/diff.spec.ts b/packages/@nitpicker/cli/src/crawl/diff.spec.ts index 163c187..9120cef 100644 --- a/packages/@nitpicker/cli/src/crawl/diff.spec.ts +++ b/packages/@nitpicker/cli/src/crawl/diff.spec.ts @@ -1,16 +1,14 @@ -import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { Archive } from '@nitpicker/crawler'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; -const mockGetPages = vi.fn(); -const mockClose = vi.fn().mockResolvedValue(); +const mockGetPagesA = vi.fn(); +const mockCloseA = vi.fn().mockResolvedValue(); +const mockGetPagesB = vi.fn(); +const mockCloseB = vi.fn().mockResolvedValue(); vi.mock('@nitpicker/crawler', () => ({ Archive: { - open: vi.fn().mockImplementation(() => - Promise.resolve({ - getPages: mockGetPages, - close: mockClose, - }), - ), + open: vi.fn(), }, })); @@ -59,12 +57,27 @@ function createMockPage( describe('diff', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(Archive.open).mockImplementation(({ filePath }) => { + if (typeof filePath === 'string' && filePath.includes('b')) { + return Promise.resolve({ + getPages: mockGetPagesB, + close: mockCloseB, + }) as never; + } + return Promise.resolve({ + getPages: mockGetPagesA, + close: mockCloseA, + }) as never; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it('アクティブな内部ページの URL を a.txt と b.txt に書き出す', async () => { - mockGetPages - .mockResolvedValueOnce([createMockPage('https://example.com/')]) - .mockResolvedValueOnce([createMockPage('https://example.com/about')]); + mockGetPagesA.mockResolvedValueOnce([createMockPage('https://example.com/')]); + mockGetPagesB.mockResolvedValueOnce([createMockPage('https://example.com/about')]); await diff('archive-a.nitpicker', 'archive-b.nitpicker'); @@ -77,12 +90,11 @@ describe('diff', () => { }); it('外部ページをフィルタリングする', async () => { - mockGetPages - .mockResolvedValueOnce([ - createMockPage('https://example.com/', { isExternal: false }), - createMockPage('https://external.com/', { isExternal: true }), - ]) - .mockResolvedValueOnce([]); + mockGetPagesA.mockResolvedValueOnce([ + createMockPage('https://example.com/', { isExternal: false }), + createMockPage('https://external.com/', { isExternal: true }), + ]); + mockGetPagesB.mockResolvedValueOnce([]); await diff('a.nitpicker', 'b.nitpicker'); @@ -94,12 +106,11 @@ describe('diff', () => { }); it('isPage() が false のページをフィルタリングする', async () => { - mockGetPages - .mockResolvedValueOnce([ - createMockPage('https://example.com/', { isPage: true }), - createMockPage('https://example.com/image.png', { isPage: false }), - ]) - .mockResolvedValueOnce([]); + mockGetPagesA.mockResolvedValueOnce([ + createMockPage('https://example.com/', { isPage: true }), + createMockPage('https://example.com/image.png', { isPage: false }), + ]); + mockGetPagesB.mockResolvedValueOnce([]); await diff('a.nitpicker', 'b.nitpicker'); @@ -110,13 +121,12 @@ describe('diff', () => { }); it('ステータス 400 以上のページをフィルタリングする', async () => { - mockGetPages - .mockResolvedValueOnce([ - createMockPage('https://example.com/', { status: 200 }), - createMockPage('https://example.com/404', { status: 404 }), - createMockPage('https://example.com/500', { status: 500 }), - ]) - .mockResolvedValueOnce([]); + mockGetPagesA.mockResolvedValueOnce([ + createMockPage('https://example.com/', { status: 200 }), + createMockPage('https://example.com/404', { status: 404 }), + createMockPage('https://example.com/500', { status: 500 }), + ]); + mockGetPagesB.mockResolvedValueOnce([]); await diff('a.nitpicker', 'b.nitpicker'); @@ -127,12 +137,11 @@ describe('diff', () => { }); it('ステータスが null のページをフィルタリングする', async () => { - mockGetPages - .mockResolvedValueOnce([ - createMockPage('https://example.com/', { status: 200 }), - createMockPage('https://example.com/null', { status: null }), - ]) - .mockResolvedValueOnce([]); + mockGetPagesA.mockResolvedValueOnce([ + createMockPage('https://example.com/', { status: 200 }), + createMockPage('https://example.com/null', { status: null }), + ]); + mockGetPagesB.mockResolvedValueOnce([]); await diff('a.nitpicker', 'b.nitpicker'); @@ -143,11 +152,10 @@ describe('diff', () => { }); it('3xx ステータスのページを含める', async () => { - mockGetPages - .mockResolvedValueOnce([ - createMockPage('https://example.com/redirect', { status: 301 }), - ]) - .mockResolvedValueOnce([]); + mockGetPagesA.mockResolvedValueOnce([ + createMockPage('https://example.com/redirect', { status: 301 }), + ]); + mockGetPagesB.mockResolvedValueOnce([]); await diff('a.nitpicker', 'b.nitpicker'); @@ -157,11 +165,41 @@ describe('diff', () => { expect(aContent).toBe('https://example.com/redirect'); }); - it('完了後にアーカイブを close する', async () => { - mockGetPages.mockResolvedValue([]); + it('複数ページをソートして改行区切りで書き出す', async () => { + mockGetPagesA.mockResolvedValueOnce([ + createMockPage('https://example.com/c'), + createMockPage('https://example.com/a'), + createMockPage('https://example.com/b'), + ]); + mockGetPagesB.mockResolvedValueOnce([]); await diff('a.nitpicker', 'b.nitpicker'); - expect(mockClose).toHaveBeenCalledTimes(2); + const aContent = mockWriteFile.mock.calls.find( + (c: unknown[]) => c[0] === 'a.txt', + )?.[1] as string; + expect(aContent).toBe( + 'https://example.com/a\nhttps://example.com/b\nhttps://example.com/c', + ); + }); + + it('archiveA と archiveB の両方を close する', async () => { + mockGetPagesA.mockResolvedValueOnce([]); + mockGetPagesB.mockResolvedValueOnce([]); + + await diff('a.nitpicker', 'b.nitpicker'); + + expect(mockCloseA).toHaveBeenCalledTimes(1); + expect(mockCloseB).toHaveBeenCalledTimes(1); + }); + + it('Archive.open にファイルパスを渡す', async () => { + mockGetPagesA.mockResolvedValueOnce([]); + mockGetPagesB.mockResolvedValueOnce([]); + + await diff('first.nitpicker', 'second-b.nitpicker'); + + expect(Archive.open).toHaveBeenCalledWith({ filePath: 'first.nitpicker' }); + expect(Archive.open).toHaveBeenCalledWith({ filePath: 'second-b.nitpicker' }); }); }); diff --git a/packages/@nitpicker/cli/src/crawl/event-assignments.spec.ts b/packages/@nitpicker/cli/src/crawl/event-assignments.spec.ts index 6962ada..ca00522 100644 --- a/packages/@nitpicker/cli/src/crawl/event-assignments.spec.ts +++ b/packages/@nitpicker/cli/src/crawl/event-assignments.spec.ts @@ -1,6 +1,6 @@ import type { CrawlerOrchestrator } from '@nitpicker/crawler'; -import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import { eventAssignments } from './event-assignments.js'; @@ -43,6 +43,10 @@ describe('eventAssignments', () => { stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('logType が silent の場合、即座に resolve する', async () => { const orchestrator = createMockOrchestrator(); await eventAssignments(orchestrator, ['header'], 'silent'); From dd21e887c3aa1556562d696555a7af4c8ee12e58 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 12:12:21 +0000 Subject: [PATCH 3/9] =?UTF-8?q?docs:=20CONTRIBUTING.md=20=E4=BD=9C?= =?UTF-8?q?=E6=88=90=E3=80=81CI=20=E3=81=AB=20E2E=20=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E8=BF=BD=E5=8A=A0=E3=80=81=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=AB=E3=83=90=E3=83=AC=E3=83=83=E3=82=B8=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CONTRIBUTING.md を新規作成(セットアップ手順、開発ワークフロー、リリース手順、 @d-zero/* パッケージ更新手順、トラブルシューティング) - CI に E2E テストジョブと main ブランチ push トリガーを追加 - README.md に開発者向けクイックスタートセクションを追加 - analyze-main-contents, analyze-markuplint, analyze-search, analyze-textlint の ユニットテストを追加 - report-google-sheets の create-discrepancies, create-resources, create-links, create-image-list, create-referrers-relational-table, create-resources-relational-table, add-to-summary のユニットテストを追加 https://claude.ai/code/session_01BE2QTpMfUWRHtXa29QLVpe --- .github/workflows/ci.yml | 16 ++++ README.md | 23 +++++ .../src/data/add-to-summary.spec.ts | 14 +++ .../src/data/create-discrepancies.spec.ts | 71 ++++++++++++++ .../src/data/create-image-list.spec.ts | 95 +++++++++++++++++++ .../src/data/create-links.spec.ts | 70 ++++++++++++++ .../create-referrers-relational-table.spec.ts | 64 +++++++++++++ .../create-resources-relational-table.spec.ts | 61 ++++++++++++ .../src/data/create-resources.spec.ts | 42 ++++++++ 9 files changed, 456 insertions(+) create mode 100644 packages/@nitpicker/report-google-sheets/src/data/add-to-summary.spec.ts create mode 100644 packages/@nitpicker/report-google-sheets/src/data/create-discrepancies.spec.ts create mode 100644 packages/@nitpicker/report-google-sheets/src/data/create-image-list.spec.ts create mode 100644 packages/@nitpicker/report-google-sheets/src/data/create-links.spec.ts create mode 100644 packages/@nitpicker/report-google-sheets/src/data/create-referrers-relational-table.spec.ts create mode 100644 packages/@nitpicker/report-google-sheets/src/data/create-resources-relational-table.spec.ts create mode 100644 packages/@nitpicker/report-google-sheets/src/data/create-resources.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b24dfd..273f782 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,8 @@ name: CI on: + push: + branches: [main] pull_request: jobs: @@ -38,6 +40,20 @@ jobs: path: packages/ - run: yarn test + e2e: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: yarn + - run: yarn install --immutable + - run: yarn build + - run: yarn vitest run --config vitest.e2e.config.ts + lint: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 37e001b..9e84317 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,29 @@ $ npx @nitpicker/cli crawl https://example.com --output ./reports/site.nitpicker $ npx @nitpicker/cli crawl https://example.com -o custom-name ``` +## 開発 + +開発環境のセットアップと詳細な貢献ガイドは [CONTRIBUTING.md](./CONTRIBUTING.md) を参照。 + +### クイックスタート + +```sh +corepack enable +yarn install +yarn build +yarn test +``` + +### コマンド一覧 + +| コマンド | 説明 | +| ----------------------------------------------- | -------------------------- | +| `yarn build` | 全パッケージをビルド | +| `yarn test` | ユニットテスト実行 | +| `yarn vitest run --config vitest.e2e.config.ts` | E2E テスト実行 | +| `yarn lint` | lint + 自動修正 | +| `yarn lint:check` | lint チェックのみ(CI 用) | + ##### Tips: 認証付き URL ```sh diff --git a/packages/@nitpicker/report-google-sheets/src/data/add-to-summary.spec.ts b/packages/@nitpicker/report-google-sheets/src/data/add-to-summary.spec.ts new file mode 100644 index 0000000..9411ccd --- /dev/null +++ b/packages/@nitpicker/report-google-sheets/src/data/add-to-summary.spec.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; + +import { addToSummary } from './add-to-summary.js'; + +describe('addToSummary', () => { + it('is a function', () => { + expect(typeof addToSummary).toBe('function'); + }); + + it('returns undefined (not yet implemented)', async () => { + const result = await addToSummary(); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/@nitpicker/report-google-sheets/src/data/create-discrepancies.spec.ts b/packages/@nitpicker/report-google-sheets/src/data/create-discrepancies.spec.ts new file mode 100644 index 0000000..3ef691d --- /dev/null +++ b/packages/@nitpicker/report-google-sheets/src/data/create-discrepancies.spec.ts @@ -0,0 +1,71 @@ +import type { Report } from '@nitpicker/types'; + +import { describe, it, expect } from 'vitest'; + +import { createDiscrepancies } from './create-discrepancies.js'; + +describe('createDiscrepancies', () => { + it('returns sheet config with name "Discrepancies"', () => { + const sheet = createDiscrepancies([]); + expect(sheet.name).toBe('Discrepancies'); + }); + + it('returns correct headers', () => { + const sheet = createDiscrepancies([]); + const headers = sheet.createHeaders(); + expect(headers).toEqual(['Type', 'Left URL', 'Left', 'Right', 'Right URL', 'Note']); + }); + + it('returns empty rows from addRows when no reports have discrepancies', () => { + const reports: Report[] = [{ name: 'test' }]; + const sheet = createDiscrepancies(reports); + const rows = sheet.addRows!(); + expect(rows).toHaveLength(0); + }); + + it('generates rows from report discrepancies via addRows', () => { + const reports: Report[] = [ + { + name: 'test', + discrepancies: [ + { + leftSourceUrl: 'https://example.com/a', + leftSourceUrlNote: 'note-a', + left: 'Left value', + leftNote: 'left-note', + right: 'Right value', + rightNote: 'right-note', + rightSourceUrl: 'https://example.com/b', + rightSourceUrlNote: 'note-b', + note: 'Discrepancy note', + }, + ], + }, + ]; + const sheet = createDiscrepancies(reports); + const rows = sheet.addRows!(); + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveLength(5); + }); + + it('skips reports without discrepancies in addRows', () => { + const reports: Report[] = [ + { name: 'no-discrepancies' }, + { + name: 'has-discrepancies', + discrepancies: [ + { + leftSourceUrl: 'a', + left: 'b', + right: 'c', + rightSourceUrl: 'd', + note: 'e', + }, + ], + }, + ]; + const sheet = createDiscrepancies(reports); + const rows = sheet.addRows!(); + expect(rows).toHaveLength(1); + }); +}); diff --git a/packages/@nitpicker/report-google-sheets/src/data/create-image-list.spec.ts b/packages/@nitpicker/report-google-sheets/src/data/create-image-list.spec.ts new file mode 100644 index 0000000..552c8ef --- /dev/null +++ b/packages/@nitpicker/report-google-sheets/src/data/create-image-list.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { createImageList } from './create-image-list.js'; + +describe('createImageList', () => { + it('returns sheet config with name "Images"', () => { + const sheet = createImageList([]); + expect(sheet.name).toBe('Images'); + }); + + it('returns correct headers', () => { + const sheet = createImageList([]); + const headers = sheet.createHeaders(); + expect(headers).toEqual([ + 'Page URL', + 'Image path (src)', + 'Image Path (currentSrc)', + 'Alternative Text', + 'Displayed Width', + 'Displayed Height', + 'Lazy Loading', + 'Source Code', + ]); + }); + + it('skips external pages', async () => { + const sheet = createImageList([]); + const mockPage = { + isInternalPage: vi.fn().mockReturnValue(false), + url: { href: 'https://external.com/' }, + }; + + const result = await sheet.eachPage!(mockPage as never, 0, 1); + expect(result).toBeUndefined(); + }); + + it('skips pages without HTML', async () => { + const sheet = createImageList([]); + const mockPage = { + isInternalPage: vi.fn().mockReturnValue(true), + url: { href: 'https://example.com/' }, + getHtml: vi.fn().mockResolvedValue(null), + }; + + const result = await sheet.eachPage!(mockPage as never, 0, 1); + expect(result).toBeUndefined(); + }); + + it('extracts image data from page HTML', async () => { + const sheet = createImageList([]); + const mockPage = { + isInternalPage: vi.fn().mockReturnValue(true), + url: { href: 'https://example.com/' }, + getHtml: vi + .fn() + .mockResolvedValue( + 'Photo', + ), + }; + + const rows = await sheet.eachPage!(mockPage as never, 0, 1); + expect(rows).toHaveLength(1); + expect(rows![0]).toHaveLength(8); + }); + + it('returns empty array for pages with no images', async () => { + const sheet = createImageList([]); + const mockPage = { + isInternalPage: vi.fn().mockReturnValue(true), + url: { href: 'https://example.com/' }, + getHtml: vi.fn().mockResolvedValue('

No images

'), + }; + + const rows = await sheet.eachPage!(mockPage as never, 0, 1); + expect(rows).toHaveLength(0); + }); + + it('extracts multiple images from a single page', async () => { + const sheet = createImageList([]); + const mockPage = { + isInternalPage: vi.fn().mockReturnValue(true), + url: { href: 'https://example.com/' }, + getHtml: vi.fn().mockResolvedValue(` + + A + B + C + + `), + }; + + const rows = await sheet.eachPage!(mockPage as never, 0, 1); + expect(rows).toHaveLength(3); + }); +}); diff --git a/packages/@nitpicker/report-google-sheets/src/data/create-links.spec.ts b/packages/@nitpicker/report-google-sheets/src/data/create-links.spec.ts new file mode 100644 index 0000000..2799715 --- /dev/null +++ b/packages/@nitpicker/report-google-sheets/src/data/create-links.spec.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { createLinks } from './create-links.js'; + +describe('createLinks', () => { + it('returns sheet config with name "Links"', () => { + const sheet = createLinks([]); + expect(sheet.name).toBe('Links'); + }); + + it('returns correct headers', () => { + const sheet = createLinks([]); + const headers = sheet.createHeaders(); + expect(headers).toEqual([ + 'URL', + 'Page Title', + 'Status Code', + 'Status Text', + 'Content Type', + 'Redirect From', + 'Referrers', + 'Headers', + 'Remarks', + ]); + }); + + it('generates row data from a page', async () => { + const sheet = createLinks([]); + const mockPage = { + url: { href: 'https://example.com/' }, + title: 'Example', + status: 200, + statusText: 'OK', + contentType: 'text/html', + redirectFrom: [], + responseHeaders: { 'content-type': 'text/html' }, + isSkipped: false, + skipReason: null, + getReferrers: vi.fn().mockResolvedValue([]), + }; + + const rows = await sheet.eachPage!(mockPage as never, 1, 10); + expect(rows).toHaveLength(1); + expect(rows![0]).toHaveLength(9); + }); + + it('shows skip reason in remarks when page is skipped', async () => { + const sheet = createLinks([]); + const mockPage = { + url: { href: 'https://example.com/blocked' }, + title: 'Blocked', + status: null, + statusText: null, + contentType: null, + redirectFrom: [], + responseHeaders: {}, + isSkipped: true, + skipReason: 'robots.txt', + getReferrers: vi.fn().mockResolvedValue([]), + }; + + const rows = await sheet.eachPage!(mockPage as never, 1, 10); + expect(rows).toHaveLength(1); + }); + + it('has updateSheet method for conditional formatting', () => { + const sheet = createLinks([]); + expect(sheet.updateSheet).toBeDefined(); + }); +}); diff --git a/packages/@nitpicker/report-google-sheets/src/data/create-referrers-relational-table.spec.ts b/packages/@nitpicker/report-google-sheets/src/data/create-referrers-relational-table.spec.ts new file mode 100644 index 0000000..f4eca90 --- /dev/null +++ b/packages/@nitpicker/report-google-sheets/src/data/create-referrers-relational-table.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { createReferrersRelationalTable } from './create-referrers-relational-table.js'; + +describe('createReferrersRelationalTable', () => { + it('returns sheet config with correct name', () => { + const sheet = createReferrersRelationalTable([]); + expect(sheet.name).toBe('Referrers Relational Table'); + }); + + it('returns correct headers', () => { + const sheet = createReferrersRelationalTable([]); + const headers = sheet.createHeaders(); + expect(headers).toEqual([ + 'Link (To)', + 'Referrer (From)', + 'Referrer Content', + 'Link Status Code', + 'Link Status Text', + 'Link Content Type', + ]); + }); + + it('generates rows from page referrers', async () => { + const sheet = createReferrersRelationalTable([]); + const mockPage = { + url: { href: 'https://example.com/page' }, + status: 200, + statusText: 'OK', + contentType: 'text/html', + getReferrers: vi.fn().mockResolvedValue([ + { + textContent: 'Home Link', + url: 'https://example.com/', + hash: '', + through: 'https://example.com/page', + }, + ]), + }; + + const rows = await sheet.eachPage!(mockPage as never, 1, 10); + expect(rows).toHaveLength(1); + expect(rows![0]).toHaveLength(6); + }); + + it('returns empty rows for pages with no referrers', async () => { + const sheet = createReferrersRelationalTable([]); + const mockPage = { + url: { href: 'https://example.com/orphan' }, + status: 200, + statusText: 'OK', + contentType: 'text/html', + getReferrers: vi.fn().mockResolvedValue([]), + }; + + const rows = await sheet.eachPage!(mockPage as never, 1, 10); + expect(rows).toHaveLength(0); + }); + + it('has updateSheet method for conditional formatting', () => { + const sheet = createReferrersRelationalTable([]); + expect(sheet.updateSheet).toBeDefined(); + }); +}); diff --git a/packages/@nitpicker/report-google-sheets/src/data/create-resources-relational-table.spec.ts b/packages/@nitpicker/report-google-sheets/src/data/create-resources-relational-table.spec.ts new file mode 100644 index 0000000..6fcb116 --- /dev/null +++ b/packages/@nitpicker/report-google-sheets/src/data/create-resources-relational-table.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { createResourcesRelationalTable } from './create-resources-relational-table.js'; + +describe('createResourcesRelationalTable', () => { + it('returns sheet config with correct name', () => { + const sheet = createResourcesRelationalTable([]); + expect(sheet.name).toBe('Resources Relational Table'); + }); + + it('returns correct headers', () => { + const sheet = createResourcesRelationalTable([]); + const headers = sheet.createHeaders(); + expect(headers).toEqual([ + 'Referred Page (From)', + 'Resource (To)', + 'Resource Status Code', + 'Resource Status Text', + 'Resource Content Type', + 'Resource Size', + ]); + }); + + it('generates rows from resource referrers', async () => { + const sheet = createResourcesRelationalTable([]); + const mockResource = { + url: 'https://cdn.example.com/app.js', + status: 200, + statusText: 'OK', + contentType: 'application/javascript', + contentLength: 5000, + getReferrers: vi + .fn() + .mockResolvedValue(['https://example.com/', 'https://example.com/about']), + }; + + const rows = await sheet.eachResource!(mockResource as never); + expect(rows).toHaveLength(2); + expect(rows![0]).toHaveLength(6); + }); + + it('returns empty rows for resource with no referrers', async () => { + const sheet = createResourcesRelationalTable([]); + const mockResource = { + url: 'https://cdn.example.com/unused.css', + status: 200, + statusText: 'OK', + contentType: 'text/css', + contentLength: 100, + getReferrers: vi.fn().mockResolvedValue([]), + }; + + const rows = await sheet.eachResource!(mockResource as never); + expect(rows).toHaveLength(0); + }); + + it('has updateSheet method for conditional formatting', () => { + const sheet = createResourcesRelationalTable([]); + expect(sheet.updateSheet).toBeDefined(); + }); +}); diff --git a/packages/@nitpicker/report-google-sheets/src/data/create-resources.spec.ts b/packages/@nitpicker/report-google-sheets/src/data/create-resources.spec.ts new file mode 100644 index 0000000..35945b7 --- /dev/null +++ b/packages/@nitpicker/report-google-sheets/src/data/create-resources.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { createResources } from './create-resources.js'; + +describe('createResources', () => { + it('returns sheet config with name "Resources"', () => { + const sheet = createResources([]); + expect(sheet.name).toBe('Resources'); + }); + + it('returns correct headers', () => { + const sheet = createResources([]); + const headers = sheet.createHeaders(); + expect(headers).toEqual([ + 'URL', + 'Status Code', + 'Status Text', + 'Content Type', + 'Content Length', + 'Referrers', + ]); + }); + + it('generates row data from a resource with referrers', async () => { + const sheet = createResources([]); + const mockResource = { + url: 'https://cdn.example.com/style.css', + status: 200, + statusText: 'OK', + contentType: 'text/css', + contentLength: 1024, + getReferrers: vi + .fn() + .mockResolvedValue(['https://example.com/', 'https://example.com/about']), + }; + + const rows = await sheet.eachResource!(mockResource as never); + expect(rows).toHaveLength(1); + expect(rows![0]).toHaveLength(6); + expect(mockResource.getReferrers).toHaveBeenCalledOnce(); + }); +}); From beddcee5ade0d87935918d88373930d29f89718e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 12:56:48 +0000 Subject: [PATCH 4/9] =?UTF-8?q?docs:=20=E3=83=89=E3=82=AD=E3=83=A5?= =?UTF-8?q?=E3=83=A1=E3=83=B3=E3=83=88=E3=81=AE=E7=9F=9B=E7=9B=BE=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E3=81=A8=E6=A7=8B=E9=80=A0=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md: 「開発」セクションを Crawl と Analyze の間から末尾に移動 - CONTRIBUTING.md: renovate.json の ignoreDeps に関する誤った記述を修正 (config 系パッケージのみが対象で、ランタイム依存は自動更新対象) - CONTRIBUTING.md: @d-zero/google-auth, @d-zero/google-sheets, @d-zero/readtext を対象パッケージ一覧に追加 - ARCHITECTURE.md: 依存グラフに report-google-sheets → @d-zero/google-sheets と @d-zero/dealer の関係を追加 https://claude.ai/code/session_01BE2QTpMfUWRHtXa29QLVpe --- ARCHITECTURE.md | 8 ++++---- README.md | 46 +++++++++++++++++++++++----------------------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4015d00..37a9af0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` にも追加すること。 diff --git a/README.md b/README.md index 9e84317..cd054ca 100644 --- a/README.md +++ b/README.md @@ -83,29 +83,6 @@ $ npx @nitpicker/cli crawl https://example.com --output ./reports/site.nitpicker $ npx @nitpicker/cli crawl https://example.com -o custom-name ``` -## 開発 - -開発環境のセットアップと詳細な貢献ガイドは [CONTRIBUTING.md](./CONTRIBUTING.md) を参照。 - -### クイックスタート - -```sh -corepack enable -yarn install -yarn build -yarn test -``` - -### コマンド一覧 - -| コマンド | 説明 | -| ----------------------------------------------- | -------------------------- | -| `yarn build` | 全パッケージをビルド | -| `yarn test` | ユニットテスト実行 | -| `yarn vitest run --config vitest.e2e.config.ts` | E2E テスト実行 | -| `yarn lint` | lint + 自動修正 | -| `yarn lint:check` | lint チェックのみ(CI 用) | - ##### Tips: 認証付き URL ```sh @@ -236,3 +213,26 @@ $ npx @nitpicker/cli pipeline https://example.com --all --silent --sheet "https: # 出力パス指定 $ npx @nitpicker/cli pipeline https://example.com --all --output ./reports/site ``` + +## 開発 + +開発環境のセットアップと詳細な貢献ガイドは [CONTRIBUTING.md](./CONTRIBUTING.md) を参照。 + +### クイックスタート + +```sh +corepack enable +yarn install +yarn build +yarn test +``` + +### コマンド一覧 + +| コマンド | 説明 | +| ----------------------------------------------- | -------------------------- | +| `yarn build` | 全パッケージをビルド | +| `yarn test` | ユニットテスト実行 | +| `yarn vitest run --config vitest.e2e.config.ts` | E2E テスト実行 | +| `yarn lint` | lint + 自動修正 | +| `yarn lint:check` | lint チェックのみ(CI 用) | From 690da0729d223213eba85ed98382e983f36709da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 13:08:31 +0000 Subject: [PATCH 5/9] fix(cli): update crawl error test to match CrawlAggregateError implementation The test was checking for ExitError (via process.exit mock) but startCrawl now throws CrawlAggregateError directly. Updated test to match. https://claude.ai/code/session_01BE2QTpMfUWRHtXa29QLVpe --- .../@nitpicker/cli/src/commands/crawl.spec.ts | 27 +++---------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/packages/@nitpicker/cli/src/commands/crawl.spec.ts b/packages/@nitpicker/cli/src/commands/crawl.spec.ts index 222c0ed..f1fe2b4 100644 --- a/packages/@nitpicker/cli/src/commands/crawl.spec.ts +++ b/packages/@nitpicker/cli/src/commands/crawl.spec.ts @@ -97,16 +97,6 @@ function setupFakeOrchestrator() { return fakeOrchestrator; } -/** 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; - } -} - describe('startCrawl', () => { beforeEach(() => { vi.clearAllMocks(); @@ -181,25 +171,14 @@ describe('startCrawl', () => { expect(result).toBe('/tmp/test.nitpicker'); }); - it('イベントエラー発生時に process.exit(1) を呼び出す', async () => { - const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { - throw new ExitError(code as number); - }); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - + it('イベントエラー発生時に CrawlAggregateError をスローする', async () => { mockEventAssignments.mockRejectedValueOnce(new Error('scrape failed')); - const { startCrawl } = await import('./crawl.js'); + const { startCrawl, CrawlAggregateError } = await import('./crawl.js'); await expect(startCrawl(['https://example.com'], createFlags())).rejects.toThrow( - ExitError, + CrawlAggregateError, ); - - expect(consoleErrorSpy).toHaveBeenCalledWith('\nCompleted with 1 error(s).'); - expect(exitSpy).toHaveBeenCalledWith(1); - - exitSpy.mockRestore(); - consoleErrorSpy.mockRestore(); }); }); From a2aaa5b0d8eb992b499a5cae198318ff80f395b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Mar 2026 01:01:51 +0000 Subject: [PATCH 6/9] fix(e2e): increase CI timeouts and guard cleanup against undefined results - Increase global hookTimeout from 60s to 120s and testTimeout from 30s to 60s - Remove per-file timeout overrides in failing E2E tests so global config applies - Guard cleanup() and afterAll() against undefined results when beforeAll times out https://claude.ai/code/session_01BE2QTpMfUWRHtXa29QLVpe --- .../test-server/src/__tests__/e2e/archive-pipeline.e2e.ts | 8 +++++--- .../src/__tests__/e2e/config-persistence.e2e.ts | 2 +- .../test-server/src/__tests__/e2e/error-status.e2e.ts | 2 +- packages/test-server/src/__tests__/e2e/exclude.e2e.ts | 6 +++--- packages/test-server/src/__tests__/e2e/helpers.ts | 6 ++++-- vitest.e2e.config.ts | 4 ++-- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/test-server/src/__tests__/e2e/archive-pipeline.e2e.ts b/packages/test-server/src/__tests__/e2e/archive-pipeline.e2e.ts index 600ebaa..07485b7 100644 --- a/packages/test-server/src/__tests__/e2e/archive-pipeline.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/archive-pipeline.e2e.ts @@ -34,11 +34,13 @@ describe('Archive pipeline (.nitpicker write → reopen)', () => { ); tmpDir = orchestrator.archive.tmpDir; - }, 120_000); + }); afterAll(async () => { - orchestrator.garbageCollect(); - await fs.rm(cwd, { recursive: true, force: true }).catch(() => {}); + orchestrator?.garbageCollect(); + if (cwd) { + await fs.rm(cwd, { recursive: true, force: true }).catch(() => {}); + } if (reopenCwd) { await fs.rm(reopenCwd, { recursive: true, force: true }).catch(() => {}); } diff --git a/packages/test-server/src/__tests__/e2e/config-persistence.e2e.ts b/packages/test-server/src/__tests__/e2e/config-persistence.e2e.ts index 1bcc078..afd795a 100644 --- a/packages/test-server/src/__tests__/e2e/config-persistence.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/config-persistence.e2e.ts @@ -12,7 +12,7 @@ describe('Config persistence', () => { userAgent: 'NitpickerE2EBot/1.0', ignoreRobots: true, }); - }, 60_000); + }); afterAll(async () => { await cleanup(result); diff --git a/packages/test-server/src/__tests__/e2e/error-status.e2e.ts b/packages/test-server/src/__tests__/e2e/error-status.e2e.ts index d53dbf0..8b1634d 100644 --- a/packages/test-server/src/__tests__/e2e/error-status.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/error-status.e2e.ts @@ -7,7 +7,7 @@ describe('Error status codes', () => { beforeAll(async () => { result = await crawl(['http://localhost:8010/error-status/']); - }, 60_000); + }); afterAll(async () => { await cleanup(result); diff --git a/packages/test-server/src/__tests__/e2e/exclude.e2e.ts b/packages/test-server/src/__tests__/e2e/exclude.e2e.ts index 68adc62..dc78939 100644 --- a/packages/test-server/src/__tests__/e2e/exclude.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/exclude.e2e.ts @@ -10,7 +10,7 @@ describe('Exclude patterns', () => { result = await crawl(['http://localhost:8010/exclude/'], { excludes: ['/exclude/secret/*'], }); - }, 60_000); + }); afterAll(async () => { await cleanup(result); @@ -37,7 +37,7 @@ describe('Exclude patterns', () => { result = await crawl(['http://localhost:8010/exclude/'], { excludeKeywords: ['FORBIDDEN_KEYWORD'], }); - }, 60_000); + }); afterAll(async () => { await cleanup(result); @@ -70,7 +70,7 @@ describe('Exclude patterns', () => { result = await crawl(['http://localhost:8010/exclude/'], { excludeUrls: ['http://127.0.0.1:8010/exclude/external-a'], }); - }, 60_000); + }); afterAll(async () => { await cleanup(result); diff --git a/packages/test-server/src/__tests__/e2e/helpers.ts b/packages/test-server/src/__tests__/e2e/helpers.ts index 2a2feb7..8f470d1 100644 --- a/packages/test-server/src/__tests__/e2e/helpers.ts +++ b/packages/test-server/src/__tests__/e2e/helpers.ts @@ -58,6 +58,8 @@ export async function crawl( * Removes the temporary working directory created by {@link crawl}. * @param result - The crawl result whose working directory should be deleted. */ -export async function cleanup(result: CrawlResult) { - await fs.rm(result.cwd, { recursive: true, force: true }); +export async function cleanup(result: CrawlResult | undefined) { + if (result?.cwd) { + await fs.rm(result.cwd, { recursive: true, force: true }); + } } diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 84c095d..d215184 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -15,8 +15,8 @@ export default defineConfig({ }, test: { include: ['packages/test-server/src/__tests__/e2e/**/*.e2e.ts'], - testTimeout: 30_000, - hookTimeout: 60_000, + testTimeout: 60_000, + hookTimeout: 120_000, pool: 'forks', maxWorkers: 1, isolate: true, From eaa52153bc9248b3a8ee5501fe51019c8743a802 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Mar 2026 04:47:52 +0000 Subject: [PATCH 7/9] revert(ci): remove E2E job from CI workflow E2E tests require Puppeteer with --no-sandbox in CI, which the crawler doesn't currently support. This is out of scope for issue #13 (CLI unit tests). Revert E2E-related CI changes and restore original E2E test timeouts. https://claude.ai/code/session_01BE2QTpMfUWRHtXa29QLVpe --- .github/workflows/ci.yml | 16 ---------------- .../src/__tests__/e2e/archive-pipeline.e2e.ts | 8 +++----- .../src/__tests__/e2e/config-persistence.e2e.ts | 2 +- .../src/__tests__/e2e/error-status.e2e.ts | 2 +- .../test-server/src/__tests__/e2e/exclude.e2e.ts | 6 +++--- .../test-server/src/__tests__/e2e/helpers.ts | 6 ++---- vitest.e2e.config.ts | 4 ++-- 7 files changed, 12 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 273f782..6b24dfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ name: CI on: - push: - branches: [main] pull_request: jobs: @@ -40,20 +38,6 @@ jobs: path: packages/ - run: yarn test - e2e: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: corepack enable - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: yarn - - run: yarn install --immutable - - run: yarn build - - run: yarn vitest run --config vitest.e2e.config.ts - lint: runs-on: ubuntu-latest steps: diff --git a/packages/test-server/src/__tests__/e2e/archive-pipeline.e2e.ts b/packages/test-server/src/__tests__/e2e/archive-pipeline.e2e.ts index 07485b7..600ebaa 100644 --- a/packages/test-server/src/__tests__/e2e/archive-pipeline.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/archive-pipeline.e2e.ts @@ -34,13 +34,11 @@ describe('Archive pipeline (.nitpicker write → reopen)', () => { ); tmpDir = orchestrator.archive.tmpDir; - }); + }, 120_000); afterAll(async () => { - orchestrator?.garbageCollect(); - if (cwd) { - await fs.rm(cwd, { recursive: true, force: true }).catch(() => {}); - } + orchestrator.garbageCollect(); + await fs.rm(cwd, { recursive: true, force: true }).catch(() => {}); if (reopenCwd) { await fs.rm(reopenCwd, { recursive: true, force: true }).catch(() => {}); } diff --git a/packages/test-server/src/__tests__/e2e/config-persistence.e2e.ts b/packages/test-server/src/__tests__/e2e/config-persistence.e2e.ts index afd795a..1bcc078 100644 --- a/packages/test-server/src/__tests__/e2e/config-persistence.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/config-persistence.e2e.ts @@ -12,7 +12,7 @@ describe('Config persistence', () => { userAgent: 'NitpickerE2EBot/1.0', ignoreRobots: true, }); - }); + }, 60_000); afterAll(async () => { await cleanup(result); diff --git a/packages/test-server/src/__tests__/e2e/error-status.e2e.ts b/packages/test-server/src/__tests__/e2e/error-status.e2e.ts index 8b1634d..d53dbf0 100644 --- a/packages/test-server/src/__tests__/e2e/error-status.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/error-status.e2e.ts @@ -7,7 +7,7 @@ describe('Error status codes', () => { beforeAll(async () => { result = await crawl(['http://localhost:8010/error-status/']); - }); + }, 60_000); afterAll(async () => { await cleanup(result); diff --git a/packages/test-server/src/__tests__/e2e/exclude.e2e.ts b/packages/test-server/src/__tests__/e2e/exclude.e2e.ts index dc78939..68adc62 100644 --- a/packages/test-server/src/__tests__/e2e/exclude.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/exclude.e2e.ts @@ -10,7 +10,7 @@ describe('Exclude patterns', () => { result = await crawl(['http://localhost:8010/exclude/'], { excludes: ['/exclude/secret/*'], }); - }); + }, 60_000); afterAll(async () => { await cleanup(result); @@ -37,7 +37,7 @@ describe('Exclude patterns', () => { result = await crawl(['http://localhost:8010/exclude/'], { excludeKeywords: ['FORBIDDEN_KEYWORD'], }); - }); + }, 60_000); afterAll(async () => { await cleanup(result); @@ -70,7 +70,7 @@ describe('Exclude patterns', () => { result = await crawl(['http://localhost:8010/exclude/'], { excludeUrls: ['http://127.0.0.1:8010/exclude/external-a'], }); - }); + }, 60_000); afterAll(async () => { await cleanup(result); diff --git a/packages/test-server/src/__tests__/e2e/helpers.ts b/packages/test-server/src/__tests__/e2e/helpers.ts index 8f470d1..2a2feb7 100644 --- a/packages/test-server/src/__tests__/e2e/helpers.ts +++ b/packages/test-server/src/__tests__/e2e/helpers.ts @@ -58,8 +58,6 @@ export async function crawl( * Removes the temporary working directory created by {@link crawl}. * @param result - The crawl result whose working directory should be deleted. */ -export async function cleanup(result: CrawlResult | undefined) { - if (result?.cwd) { - await fs.rm(result.cwd, { recursive: true, force: true }); - } +export async function cleanup(result: CrawlResult) { + await fs.rm(result.cwd, { recursive: true, force: true }); } diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index d215184..84c095d 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -15,8 +15,8 @@ export default defineConfig({ }, test: { include: ['packages/test-server/src/__tests__/e2e/**/*.e2e.ts'], - testTimeout: 60_000, - hookTimeout: 120_000, + testTimeout: 30_000, + hookTimeout: 60_000, pool: 'forks', maxWorkers: 1, isolate: true, From c54f14f5782399f5284a506a6e317dab146b87fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Mar 2026 05:24:59 +0000 Subject: [PATCH 8/9] revert: remove out-of-scope changes (report-google-sheets tests, docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #13 のスコープは CLI ユニットテストの追加。 スコープ外で追加された以下の変更を revert: - report-google-sheets の spec ファイル7件 - ARCHITECTURE.md の依存グラフ修正 - README.md の開発者セクション追加 https://claude.ai/code/session_01BE2QTpMfUWRHtXa29QLVpe --- ARCHITECTURE.md | 8 +- README.md | 23 ----- .../src/data/add-to-summary.spec.ts | 14 --- .../src/data/create-discrepancies.spec.ts | 71 -------------- .../src/data/create-image-list.spec.ts | 95 ------------------- .../src/data/create-links.spec.ts | 70 -------------- .../create-referrers-relational-table.spec.ts | 64 ------------- .../create-resources-relational-table.spec.ts | 61 ------------ .../src/data/create-resources.spec.ts | 42 -------- 9 files changed, 4 insertions(+), 444 deletions(-) delete mode 100644 packages/@nitpicker/report-google-sheets/src/data/add-to-summary.spec.ts delete mode 100644 packages/@nitpicker/report-google-sheets/src/data/create-discrepancies.spec.ts delete mode 100644 packages/@nitpicker/report-google-sheets/src/data/create-image-list.spec.ts delete mode 100644 packages/@nitpicker/report-google-sheets/src/data/create-links.spec.ts delete mode 100644 packages/@nitpicker/report-google-sheets/src/data/create-referrers-relational-table.spec.ts delete mode 100644 packages/@nitpicker/report-google-sheets/src/data/create-resources-relational-table.spec.ts delete mode 100644 packages/@nitpicker/report-google-sheets/src/data/create-resources.spec.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 37a9af0..4015d00 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -25,10 +25,10 @@ packages/ ↑ └── crawler ── @nitpicker/cli ← @d-zero/roar(外部) ↑ ↑ ↑ ↑ - │ core │ report-google-sheets ← @d-zero/google-sheets(外部) - │ ↑ │ ↑ - │ analyze-* プラグイン │ - └── @d-zero/dealer(外部)──┘ + │ core │ report-google-sheets + │ ↑ │ + │ analyze-* プラグイン + └── @d-zero/dealer(外部) ``` > **Note**: CLI は analyze プラグインに直接依存する(`npx` 実行時のモジュール解決のため)。新規 analyze プラグイン追加時は `@nitpicker/cli/package.json` の `dependencies` にも追加すること。 diff --git a/README.md b/README.md index cd054ca..37e001b 100644 --- a/README.md +++ b/README.md @@ -213,26 +213,3 @@ $ npx @nitpicker/cli pipeline https://example.com --all --silent --sheet "https: # 出力パス指定 $ npx @nitpicker/cli pipeline https://example.com --all --output ./reports/site ``` - -## 開発 - -開発環境のセットアップと詳細な貢献ガイドは [CONTRIBUTING.md](./CONTRIBUTING.md) を参照。 - -### クイックスタート - -```sh -corepack enable -yarn install -yarn build -yarn test -``` - -### コマンド一覧 - -| コマンド | 説明 | -| ----------------------------------------------- | -------------------------- | -| `yarn build` | 全パッケージをビルド | -| `yarn test` | ユニットテスト実行 | -| `yarn vitest run --config vitest.e2e.config.ts` | E2E テスト実行 | -| `yarn lint` | lint + 自動修正 | -| `yarn lint:check` | lint チェックのみ(CI 用) | diff --git a/packages/@nitpicker/report-google-sheets/src/data/add-to-summary.spec.ts b/packages/@nitpicker/report-google-sheets/src/data/add-to-summary.spec.ts deleted file mode 100644 index 9411ccd..0000000 --- a/packages/@nitpicker/report-google-sheets/src/data/add-to-summary.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { addToSummary } from './add-to-summary.js'; - -describe('addToSummary', () => { - it('is a function', () => { - expect(typeof addToSummary).toBe('function'); - }); - - it('returns undefined (not yet implemented)', async () => { - const result = await addToSummary(); - expect(result).toBeUndefined(); - }); -}); diff --git a/packages/@nitpicker/report-google-sheets/src/data/create-discrepancies.spec.ts b/packages/@nitpicker/report-google-sheets/src/data/create-discrepancies.spec.ts deleted file mode 100644 index 3ef691d..0000000 --- a/packages/@nitpicker/report-google-sheets/src/data/create-discrepancies.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { Report } from '@nitpicker/types'; - -import { describe, it, expect } from 'vitest'; - -import { createDiscrepancies } from './create-discrepancies.js'; - -describe('createDiscrepancies', () => { - it('returns sheet config with name "Discrepancies"', () => { - const sheet = createDiscrepancies([]); - expect(sheet.name).toBe('Discrepancies'); - }); - - it('returns correct headers', () => { - const sheet = createDiscrepancies([]); - const headers = sheet.createHeaders(); - expect(headers).toEqual(['Type', 'Left URL', 'Left', 'Right', 'Right URL', 'Note']); - }); - - it('returns empty rows from addRows when no reports have discrepancies', () => { - const reports: Report[] = [{ name: 'test' }]; - const sheet = createDiscrepancies(reports); - const rows = sheet.addRows!(); - expect(rows).toHaveLength(0); - }); - - it('generates rows from report discrepancies via addRows', () => { - const reports: Report[] = [ - { - name: 'test', - discrepancies: [ - { - leftSourceUrl: 'https://example.com/a', - leftSourceUrlNote: 'note-a', - left: 'Left value', - leftNote: 'left-note', - right: 'Right value', - rightNote: 'right-note', - rightSourceUrl: 'https://example.com/b', - rightSourceUrlNote: 'note-b', - note: 'Discrepancy note', - }, - ], - }, - ]; - const sheet = createDiscrepancies(reports); - const rows = sheet.addRows!(); - expect(rows).toHaveLength(1); - expect(rows[0]).toHaveLength(5); - }); - - it('skips reports without discrepancies in addRows', () => { - const reports: Report[] = [ - { name: 'no-discrepancies' }, - { - name: 'has-discrepancies', - discrepancies: [ - { - leftSourceUrl: 'a', - left: 'b', - right: 'c', - rightSourceUrl: 'd', - note: 'e', - }, - ], - }, - ]; - const sheet = createDiscrepancies(reports); - const rows = sheet.addRows!(); - expect(rows).toHaveLength(1); - }); -}); diff --git a/packages/@nitpicker/report-google-sheets/src/data/create-image-list.spec.ts b/packages/@nitpicker/report-google-sheets/src/data/create-image-list.spec.ts deleted file mode 100644 index 552c8ef..0000000 --- a/packages/@nitpicker/report-google-sheets/src/data/create-image-list.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; - -import { createImageList } from './create-image-list.js'; - -describe('createImageList', () => { - it('returns sheet config with name "Images"', () => { - const sheet = createImageList([]); - expect(sheet.name).toBe('Images'); - }); - - it('returns correct headers', () => { - const sheet = createImageList([]); - const headers = sheet.createHeaders(); - expect(headers).toEqual([ - 'Page URL', - 'Image path (src)', - 'Image Path (currentSrc)', - 'Alternative Text', - 'Displayed Width', - 'Displayed Height', - 'Lazy Loading', - 'Source Code', - ]); - }); - - it('skips external pages', async () => { - const sheet = createImageList([]); - const mockPage = { - isInternalPage: vi.fn().mockReturnValue(false), - url: { href: 'https://external.com/' }, - }; - - const result = await sheet.eachPage!(mockPage as never, 0, 1); - expect(result).toBeUndefined(); - }); - - it('skips pages without HTML', async () => { - const sheet = createImageList([]); - const mockPage = { - isInternalPage: vi.fn().mockReturnValue(true), - url: { href: 'https://example.com/' }, - getHtml: vi.fn().mockResolvedValue(null), - }; - - const result = await sheet.eachPage!(mockPage as never, 0, 1); - expect(result).toBeUndefined(); - }); - - it('extracts image data from page HTML', async () => { - const sheet = createImageList([]); - const mockPage = { - isInternalPage: vi.fn().mockReturnValue(true), - url: { href: 'https://example.com/' }, - getHtml: vi - .fn() - .mockResolvedValue( - 'Photo', - ), - }; - - const rows = await sheet.eachPage!(mockPage as never, 0, 1); - expect(rows).toHaveLength(1); - expect(rows![0]).toHaveLength(8); - }); - - it('returns empty array for pages with no images', async () => { - const sheet = createImageList([]); - const mockPage = { - isInternalPage: vi.fn().mockReturnValue(true), - url: { href: 'https://example.com/' }, - getHtml: vi.fn().mockResolvedValue('

No images

'), - }; - - const rows = await sheet.eachPage!(mockPage as never, 0, 1); - expect(rows).toHaveLength(0); - }); - - it('extracts multiple images from a single page', async () => { - const sheet = createImageList([]); - const mockPage = { - isInternalPage: vi.fn().mockReturnValue(true), - url: { href: 'https://example.com/' }, - getHtml: vi.fn().mockResolvedValue(` - - A - B - C - - `), - }; - - const rows = await sheet.eachPage!(mockPage as never, 0, 1); - expect(rows).toHaveLength(3); - }); -}); diff --git a/packages/@nitpicker/report-google-sheets/src/data/create-links.spec.ts b/packages/@nitpicker/report-google-sheets/src/data/create-links.spec.ts deleted file mode 100644 index 2799715..0000000 --- a/packages/@nitpicker/report-google-sheets/src/data/create-links.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; - -import { createLinks } from './create-links.js'; - -describe('createLinks', () => { - it('returns sheet config with name "Links"', () => { - const sheet = createLinks([]); - expect(sheet.name).toBe('Links'); - }); - - it('returns correct headers', () => { - const sheet = createLinks([]); - const headers = sheet.createHeaders(); - expect(headers).toEqual([ - 'URL', - 'Page Title', - 'Status Code', - 'Status Text', - 'Content Type', - 'Redirect From', - 'Referrers', - 'Headers', - 'Remarks', - ]); - }); - - it('generates row data from a page', async () => { - const sheet = createLinks([]); - const mockPage = { - url: { href: 'https://example.com/' }, - title: 'Example', - status: 200, - statusText: 'OK', - contentType: 'text/html', - redirectFrom: [], - responseHeaders: { 'content-type': 'text/html' }, - isSkipped: false, - skipReason: null, - getReferrers: vi.fn().mockResolvedValue([]), - }; - - const rows = await sheet.eachPage!(mockPage as never, 1, 10); - expect(rows).toHaveLength(1); - expect(rows![0]).toHaveLength(9); - }); - - it('shows skip reason in remarks when page is skipped', async () => { - const sheet = createLinks([]); - const mockPage = { - url: { href: 'https://example.com/blocked' }, - title: 'Blocked', - status: null, - statusText: null, - contentType: null, - redirectFrom: [], - responseHeaders: {}, - isSkipped: true, - skipReason: 'robots.txt', - getReferrers: vi.fn().mockResolvedValue([]), - }; - - const rows = await sheet.eachPage!(mockPage as never, 1, 10); - expect(rows).toHaveLength(1); - }); - - it('has updateSheet method for conditional formatting', () => { - const sheet = createLinks([]); - expect(sheet.updateSheet).toBeDefined(); - }); -}); diff --git a/packages/@nitpicker/report-google-sheets/src/data/create-referrers-relational-table.spec.ts b/packages/@nitpicker/report-google-sheets/src/data/create-referrers-relational-table.spec.ts deleted file mode 100644 index f4eca90..0000000 --- a/packages/@nitpicker/report-google-sheets/src/data/create-referrers-relational-table.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; - -import { createReferrersRelationalTable } from './create-referrers-relational-table.js'; - -describe('createReferrersRelationalTable', () => { - it('returns sheet config with correct name', () => { - const sheet = createReferrersRelationalTable([]); - expect(sheet.name).toBe('Referrers Relational Table'); - }); - - it('returns correct headers', () => { - const sheet = createReferrersRelationalTable([]); - const headers = sheet.createHeaders(); - expect(headers).toEqual([ - 'Link (To)', - 'Referrer (From)', - 'Referrer Content', - 'Link Status Code', - 'Link Status Text', - 'Link Content Type', - ]); - }); - - it('generates rows from page referrers', async () => { - const sheet = createReferrersRelationalTable([]); - const mockPage = { - url: { href: 'https://example.com/page' }, - status: 200, - statusText: 'OK', - contentType: 'text/html', - getReferrers: vi.fn().mockResolvedValue([ - { - textContent: 'Home Link', - url: 'https://example.com/', - hash: '', - through: 'https://example.com/page', - }, - ]), - }; - - const rows = await sheet.eachPage!(mockPage as never, 1, 10); - expect(rows).toHaveLength(1); - expect(rows![0]).toHaveLength(6); - }); - - it('returns empty rows for pages with no referrers', async () => { - const sheet = createReferrersRelationalTable([]); - const mockPage = { - url: { href: 'https://example.com/orphan' }, - status: 200, - statusText: 'OK', - contentType: 'text/html', - getReferrers: vi.fn().mockResolvedValue([]), - }; - - const rows = await sheet.eachPage!(mockPage as never, 1, 10); - expect(rows).toHaveLength(0); - }); - - it('has updateSheet method for conditional formatting', () => { - const sheet = createReferrersRelationalTable([]); - expect(sheet.updateSheet).toBeDefined(); - }); -}); diff --git a/packages/@nitpicker/report-google-sheets/src/data/create-resources-relational-table.spec.ts b/packages/@nitpicker/report-google-sheets/src/data/create-resources-relational-table.spec.ts deleted file mode 100644 index 6fcb116..0000000 --- a/packages/@nitpicker/report-google-sheets/src/data/create-resources-relational-table.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; - -import { createResourcesRelationalTable } from './create-resources-relational-table.js'; - -describe('createResourcesRelationalTable', () => { - it('returns sheet config with correct name', () => { - const sheet = createResourcesRelationalTable([]); - expect(sheet.name).toBe('Resources Relational Table'); - }); - - it('returns correct headers', () => { - const sheet = createResourcesRelationalTable([]); - const headers = sheet.createHeaders(); - expect(headers).toEqual([ - 'Referred Page (From)', - 'Resource (To)', - 'Resource Status Code', - 'Resource Status Text', - 'Resource Content Type', - 'Resource Size', - ]); - }); - - it('generates rows from resource referrers', async () => { - const sheet = createResourcesRelationalTable([]); - const mockResource = { - url: 'https://cdn.example.com/app.js', - status: 200, - statusText: 'OK', - contentType: 'application/javascript', - contentLength: 5000, - getReferrers: vi - .fn() - .mockResolvedValue(['https://example.com/', 'https://example.com/about']), - }; - - const rows = await sheet.eachResource!(mockResource as never); - expect(rows).toHaveLength(2); - expect(rows![0]).toHaveLength(6); - }); - - it('returns empty rows for resource with no referrers', async () => { - const sheet = createResourcesRelationalTable([]); - const mockResource = { - url: 'https://cdn.example.com/unused.css', - status: 200, - statusText: 'OK', - contentType: 'text/css', - contentLength: 100, - getReferrers: vi.fn().mockResolvedValue([]), - }; - - const rows = await sheet.eachResource!(mockResource as never); - expect(rows).toHaveLength(0); - }); - - it('has updateSheet method for conditional formatting', () => { - const sheet = createResourcesRelationalTable([]); - expect(sheet.updateSheet).toBeDefined(); - }); -}); diff --git a/packages/@nitpicker/report-google-sheets/src/data/create-resources.spec.ts b/packages/@nitpicker/report-google-sheets/src/data/create-resources.spec.ts deleted file mode 100644 index 35945b7..0000000 --- a/packages/@nitpicker/report-google-sheets/src/data/create-resources.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; - -import { createResources } from './create-resources.js'; - -describe('createResources', () => { - it('returns sheet config with name "Resources"', () => { - const sheet = createResources([]); - expect(sheet.name).toBe('Resources'); - }); - - it('returns correct headers', () => { - const sheet = createResources([]); - const headers = sheet.createHeaders(); - expect(headers).toEqual([ - 'URL', - 'Status Code', - 'Status Text', - 'Content Type', - 'Content Length', - 'Referrers', - ]); - }); - - it('generates row data from a resource with referrers', async () => { - const sheet = createResources([]); - const mockResource = { - url: 'https://cdn.example.com/style.css', - status: 200, - statusText: 'OK', - contentType: 'text/css', - contentLength: 1024, - getReferrers: vi - .fn() - .mockResolvedValue(['https://example.com/', 'https://example.com/about']), - }; - - const rows = await sheet.eachResource!(mockResource as never); - expect(rows).toHaveLength(1); - expect(rows![0]).toHaveLength(6); - expect(mockResource.getReferrers).toHaveBeenCalledOnce(); - }); -}); From 7a36d9f64646b8b0e0b6bb0d8064d15aa4436fd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Mar 2026 06:03:05 +0000 Subject: [PATCH 9/9] docs: fix dependency graph and commitlint description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ARCHITECTURE.md: report-google-sheets が @d-zero/dealer と @d-zero/google-sheets に依存している実装を依存グラフに反映 - CONTRIBUTING.md: commitlint が Git フックに組み込まれていない 実装に合わせて記述を修正 https://claude.ai/code/session_01BE2QTpMfUWRHtXa29QLVpe --- ARCHITECTURE.md | 8 ++++---- CONTRIBUTING.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4015d00..37a9af0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` にも追加すること。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a6ff4d..de6ebef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,7 @@ fix(core): prevent analyze results from being silently empty docs: update README with new CLI options ``` -commitlint がプリコミットフックで検証します。 +`.commitlintrc` で設定されていますが、現在 Git フックには組み込まれていません。CI やレビュー時にコミットメッセージを確認してください。 ## コーディング規約