From 0c742b776c71641f486f9dfdbd28903f8c91d640 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Wed, 22 Jul 2026 16:20:52 +0900 Subject: [PATCH 1/2] docs(crawler): generalize the illustrative port in a credential-cache comment --- packages/@nitpicker/crawler/src/crawler/crawler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@nitpicker/crawler/src/crawler/crawler.ts b/packages/@nitpicker/crawler/src/crawler/crawler.ts index 730753a..2855070 100644 --- a/packages/@nitpicker/crawler/src/crawler/crawler.ts +++ b/packages/@nitpicker/crawler/src/crawler/crawler.ts @@ -1569,8 +1569,8 @@ export default class Crawler extends EventEmitter { // sub-resource requests issued from the same page — // including cross-origin requests to a different hostname // sharing the same IP / port (e.g. an embedded - // `` loaded from a - // `localhost:8010` page) — get the cached `Authorization` + // `` loaded from a + // `localhost:PORT` page) — get the cached `Authorization` // header re-attached by the network stack. The // `Fetch.authRequired` event never fires for these // pre-emptive attachments, so neither `page.authenticate` From 589f24508b8a9c7d0bb07aab2f562e6abe224f7e Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Wed, 22 Jul 2026 16:25:00 +0900 Subject: [PATCH 2/2] fix(repo): assign the E2E test server a dynamic port instead of 8010 Concurrent git worktrees/sessions running `yarn test` or the E2E suite collided on the fixed port 8010 with EADDRINUSE, or blocked until the earlier process released it. - test-server: `startServer` now binds to an OS-assigned port (`port = 0`) and threads the resolved port into the 7 routes that embed self-referencing "external" URLs via a lazily-read `PortRef` - global-setup.ts: shares the resolved port with test files through vitest's provide/inject channel (`testServerPort`) - Add `test-server-port.ts` exporting `TEST_SERVER_PORT` and origin constants for building test URLs - Update 27 e2e test files and the report-google-sheets API test to read the dynamic port instead of hardcoding 8010 - Add server.spec.ts proving two concurrent instances get different ports - Update ARCHITECTURE.md/CLAUDE.md/git skill docs to drop the stale fixed-port reference Closes #162 --- .claude/skills/git/SKILL.md | 2 +- ARCHITECTURE.md | 4 +- CLAUDE.md | 2 +- .../src/__tests__/api/create-sheets.api.ts | 15 +++- .../src/__tests__/e2e/analyze-pipeline.e2e.ts | 6 +- .../src/__tests__/e2e/append.e2e.ts | 20 +++--- .../src/__tests__/e2e/archive-pipeline.e2e.ts | 4 +- .../src/__tests__/e2e/cli-process-exit.e2e.ts | 4 +- .../__tests__/e2e/config-persistence.e2e.ts | 3 +- .../e2e/crawl-write-entity-tables.e2e.ts | 7 +- .../src/__tests__/e2e/error-status.e2e.ts | 3 +- .../src/__tests__/e2e/exclude.e2e.ts | 21 ++++-- .../src/__tests__/e2e/global-setup.ts | 17 +++-- .../src/__tests__/e2e/inventory.e2e.ts | 72 ++++++++++--------- .../src/__tests__/e2e/js-redirect.e2e.ts | 3 +- .../src/__tests__/e2e/main-contents.e2e.ts | 5 +- .../test-server/src/__tests__/e2e/meta.e2e.ts | 9 +-- .../src/__tests__/e2e/multi-root.e2e.ts | 9 +-- .../src/__tests__/e2e/options.e2e.ts | 7 +- .../src/__tests__/e2e/output-path.e2e.ts | 6 +- .../src/__tests__/e2e/pagination.e2e.ts | 3 +- .../e2e/parallel-and-interval.e2e.ts | 5 +- .../src/__tests__/e2e/recursive.e2e.ts | 12 ++-- .../src/__tests__/e2e/redirect.e2e.ts | 11 +-- .../src/__tests__/e2e/resource-reuse.e2e.ts | 11 +-- .../src/__tests__/e2e/retry-failed.e2e.ts | 14 ++-- .../src/__tests__/e2e/scope-auth-leak.e2e.ts | 11 +-- .../src/__tests__/e2e/scope.e2e.ts | 5 +- .../src/__tests__/e2e/scroll-jack.e2e.ts | 3 +- .../src/__tests__/e2e/single-page.e2e.ts | 3 +- .../src/__tests__/e2e/snapshot.e2e.ts | 3 +- .../src/__tests__/e2e/test-server-port.ts | 35 +++++++++ .../e2e/viewer-read-model-build.e2e.ts | 4 +- packages/test-server/src/routes/exclude.ts | 9 ++- packages/test-server/src/routes/meta.ts | 17 +++-- packages/test-server/src/routes/options.ts | 9 ++- packages/test-server/src/routes/recursive.ts | 7 +- packages/test-server/src/routes/redirect.ts | 9 ++- .../test-server/src/routes/resource-reuse.ts | 9 ++- .../test-server/src/routes/scope-auth-leak.ts | 7 +- packages/test-server/src/server.spec.ts | 40 +++++++++++ packages/test-server/src/server.ts | 50 +++++++++---- 42 files changed, 342 insertions(+), 154 deletions(-) create mode 100644 packages/test-server/src/__tests__/e2e/test-server-port.ts create mode 100644 packages/test-server/src/server.spec.ts diff --git a/.claude/skills/git/SKILL.md b/.claude/skills/git/SKILL.md index b420b49..ee1253f 100644 --- a/.claude/skills/git/SKILL.md +++ b/.claude/skills/git/SKILL.md @@ -45,7 +45,7 @@ description: Git 操作ルール - ドメイン: `example.com` / `example.org` / `example.net`、`*.example` / `*.test` / `*.invalid` / `*.localhost`(RFC 2606 / RFC 6761) - IP: `127.0.0.1`、TEST-NET(`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`)、`2001:db8::/32` - メール: `user@example.com` 系 -- E2E の `localhost:8010` / `127.0.0.1` は正当(test-server / 外部リンクシミュレーション) +- E2E の `localhost:<動的ポート>` / `127.0.0.1` は正当(test-server / 外部リンクシミュレーション) **検出対象(混入してはいけない値):** diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a4fc682..b021cdb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -30,7 +30,7 @@ | `@nitpicker/analyze-*` | axe / lighthouse / markuplint / textlint / search の各監査 | | `@nitpicker/report-google-sheets` | Google Sheets レポート出力(5 フェーズの `createSheets`) | | `@nitpicker/types` | 監査型定義(Report / ConfigJSON) | -| `packages/test-server` | E2E 用 Hono サーバー(port 8010、プロダクション非依存) | +| `packages/test-server` | E2E 用 Hono サーバー(OS割り当ての動的ポート、プロダクション非依存) | ## 境界と所有権 @@ -169,6 +169,6 @@ ## テストと CLI 契約 - ユニット: `yarn test`(Vitest 4、1関数1ファイルにユニットテスト必須) -- E2E: `yarn vitest run --config vitest.e2e.config.ts`(maxWorkers: 1、test-server は port 8010、**外部リンクは `127.0.0.1` でシミュレート**(`localhost` と別ホスト名扱い)) +- E2E: `yarn vitest run --config vitest.e2e.config.ts`(maxWorkers: 1、test-server はOS割り当ての動的ポート(並行worktree/セッション間の `EADDRINUSE` を回避、#162)、**外部リンクは `127.0.0.1` でシミュレート**(`localhost` と別ホスト名扱い)) - viewer E2E: `yarn workspace @nitpicker/viewer test:e2e`(Playwright、fixture 生成 → 実 CLI 起動 → ブラウザ検証) - CLI 終了コード: `0` = 成功 / `1` = 致命的(スコープ内エラー含む)/ `2` = 警告(外部リンクエラーのみ。`--strict` で 1 に昇格) diff --git a/CLAUDE.md b/CLAUDE.md index 6390dfe..ed1e1c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ npx @nitpicker/cli viewer-build [--force] # viewer read model を ```sh yarn test # ユニットテスト(Vitest) -yarn vitest run --config vitest.e2e.config.ts # E2E(maxWorkers: 1、test-server port 8010) +yarn vitest run --config vitest.e2e.config.ts # E2E(maxWorkers: 1、test-server はOS割り当ての動的ポート) yarn workspace @nitpicker/viewer test:e2e # viewer の Playwright E2E yarn build # 全パッケージビルド yarn lint # lint + prettier + cspell diff --git a/packages/@nitpicker/report-google-sheets/src/__tests__/api/create-sheets.api.ts b/packages/@nitpicker/report-google-sheets/src/__tests__/api/create-sheets.api.ts index 0296a39..d2c92e4 100644 --- a/packages/@nitpicker/report-google-sheets/src/__tests__/api/create-sheets.api.ts +++ b/packages/@nitpicker/report-google-sheets/src/__tests__/api/create-sheets.api.ts @@ -3,7 +3,7 @@ import type { Report } from '@nitpicker/types'; import { Sheets } from '@d-zero/google-sheets'; import { google } from 'googleapis'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, inject, it } from 'vitest'; import { createLinks } from '../../data/create-links.js'; import { createPageList } from '../../data/create-page-list.js'; @@ -23,6 +23,16 @@ import { testSheetName, } from './helpers.js'; +// This `ProvidedContext` shape is duplicated in +// `packages/test-server/src/__tests__/e2e/test-server-port.ts` (a separate TS +// project — this package has no reference to that one) — keep both +// declarations in sync if `testServerPort`'s shape ever changes. +declare module 'vitest' { + interface ProvidedContext { + testServerPort: number; + } +} + describe('createSheets pipeline', () => { let auth: Auth; let crawlResult: CrawlResult; @@ -31,7 +41,8 @@ describe('createSheets pipeline', () => { beforeAll(async () => { auth = await getAuth(); - crawlResult = await crawlTestServer(['http://localhost:8010/'], { + const port = inject('testServerPort'); + crawlResult = await crawlTestServer([`http://localhost:${port}/`], { recursive: true, fetchExternal: false, }); diff --git a/packages/test-server/src/__tests__/e2e/analyze-pipeline.e2e.ts b/packages/test-server/src/__tests__/e2e/analyze-pipeline.e2e.ts index 198474a..21ef64e 100644 --- a/packages/test-server/src/__tests__/e2e/analyze-pipeline.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/analyze-pipeline.e2e.ts @@ -10,6 +10,8 @@ import { Archive, CrawlerOrchestrator } from '@nitpicker/crawler'; import { getViolations } from '@nitpicker/query'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { TEST_SERVER_PORT } from './test-server-port.js'; + /** * crawl → write → analyze のクロスパッケージ統合テスト。 * @@ -29,7 +31,7 @@ describe('Analyze pipeline (crawl → write → analyze)', () => { // 1) クロールして .nitpicker を書き出す(スナップショットは SQLite BLOB として保存される) const orchestrator = await CrawlerOrchestrator.crawling( - ['http://localhost:8010/meta/'], + [`http://localhost:${TEST_SERVER_PORT}/meta/`], { cwd, interval: 0, @@ -69,6 +71,6 @@ describe('Analyze pipeline (crawl → write → analyze)', () => { // /meta/ 配下の internal ページの HTML が WorkerPool に渡り、 // per-URL のデータが生成されている(HTML が読めなければ 0 件になる) expect(analyzedUrls.length).toBeGreaterThan(0); - expect(analyzedUrls).toContain('http://localhost:8010/meta/full'); + expect(analyzedUrls).toContain(`http://localhost:${TEST_SERVER_PORT}/meta/full`); }); }); diff --git a/packages/test-server/src/__tests__/e2e/append.e2e.ts b/packages/test-server/src/__tests__/e2e/append.e2e.ts index 38b4330..75de172 100644 --- a/packages/test-server/src/__tests__/e2e/append.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/append.e2e.ts @@ -6,6 +6,8 @@ import path from 'node:path'; import { Archive, CrawlerOrchestrator } from '@nitpicker/crawler'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TEST_SERVER_ORIGIN } from './test-server-port.js'; + /** * Run a baseline crawl that creates a `.nitpicker` archive on disk, then close * it so the caller can re-open it via `Archive.open`. Returns the archive file @@ -44,7 +46,7 @@ describe('Append crawl', () => { beforeAll(async () => { // 1) Baseline archive scoped to /scope/blog/. /scope/docs/ is reached via // a link and therefore recorded as external metadata-only. - const baseline = await crawlAndPersist(['http://localhost:8010/scope/blog/'], { + const baseline = await crawlAndPersist([`${TEST_SERVER_ORIGIN}/scope/blog/`], { fetchExternal: true, }); filePath = baseline.filePath; @@ -54,7 +56,7 @@ describe('Append crawl', () => { // previously-external /scope/docs/ and re-crawls it as internal. const orchestrator = await CrawlerOrchestrator.append( filePath, - ['http://localhost:8010/scope/docs/'], + [`${TEST_SERVER_ORIGIN}/scope/docs/`], { cwd, fetchExternal: true }, ); await orchestrator.write(); @@ -87,8 +89,8 @@ describe('Append crawl', () => { it('info.roots records both the original and the appended root', async () => { const config = await accessor.getConfig(); expect(config.roots).toEqual([ - 'http://localhost:8010/scope/blog/', - 'http://localhost:8010/scope/docs/', + `${TEST_SERVER_ORIGIN}/scope/blog/`, + `${TEST_SERVER_ORIGIN}/scope/docs/`, ]); }); @@ -119,7 +121,7 @@ describe('Append crawl: restore from .bak on failure', () => { let originalArchiveBytes: Buffer; beforeAll(async () => { - const baseline = await crawlAndPersist(['http://localhost:8010/scope/blog/']); + const baseline = await crawlAndPersist([`${TEST_SERVER_ORIGIN}/scope/blog/`]); filePath = baseline.filePath; cwd = baseline.cwd; originalArchiveBytes = await fs.readFile(filePath); @@ -142,7 +144,7 @@ describe('Append crawl: restore from .bak on failure', () => { .mockRejectedValueOnce(new Error('forced-repromote-failure')); await expect( - CrawlerOrchestrator.append(filePath, ['http://localhost:8010/scope/docs/'], { + CrawlerOrchestrator.append(filePath, [`${TEST_SERVER_ORIGIN}/scope/docs/`], { cwd, }), ).rejects.toThrow(/forced-repromote-failure/); @@ -193,7 +195,7 @@ describe('Append crawl: restore from .bak on failure', () => { const thrown = await CrawlerOrchestrator.append( filePath, - ['http://localhost:8010/scope/docs/'], + [`${TEST_SERVER_ORIGIN}/scope/docs/`], { cwd }, ).then( () => { @@ -223,7 +225,7 @@ describe('Append crawl: list-mode rejection', () => { let cwd: string; beforeAll(async () => { - const baseline = await crawlAndPersist(['http://localhost:8010/scope/blog/']); + const baseline = await crawlAndPersist([`${TEST_SERVER_ORIGIN}/scope/blog/`]); filePath = baseline.filePath; cwd = baseline.cwd; @@ -243,7 +245,7 @@ describe('Append crawl: list-mode rejection', () => { it('throws a helpful error when appending to a list-mode archive', async () => { await expect( - CrawlerOrchestrator.append(filePath, ['http://localhost:8010/scope/docs/'], { + CrawlerOrchestrator.append(filePath, [`${TEST_SERVER_ORIGIN}/scope/docs/`], { cwd, }), ).rejects.toThrow( 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..e972e0a 100644 --- a/packages/test-server/src/__tests__/e2e/archive-pipeline.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/archive-pipeline.e2e.ts @@ -6,6 +6,8 @@ import path from 'node:path'; import { Archive, CrawlerOrchestrator } from '@nitpicker/crawler'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { TEST_SERVER_PORT } from './test-server-port.js'; + describe('Archive pipeline (.nitpicker write → reopen)', () => { let orchestrator: CrawlerOrchestrator; let tmpDir: string; @@ -17,7 +19,7 @@ describe('Archive pipeline (.nitpicker write → reopen)', () => { await fs.mkdir(cwd, { recursive: true }); orchestrator = await CrawlerOrchestrator.crawling( - ['http://localhost:8010/'], + [`http://localhost:${TEST_SERVER_PORT}/`], { cwd, recursive: false, diff --git a/packages/test-server/src/__tests__/e2e/cli-process-exit.e2e.ts b/packages/test-server/src/__tests__/e2e/cli-process-exit.e2e.ts index 7831b29..6753013 100644 --- a/packages/test-server/src/__tests__/e2e/cli-process-exit.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/cli-process-exit.e2e.ts @@ -6,6 +6,8 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; +import { TEST_SERVER_PORT } from './test-server-port.js'; + /** Absolute path to the built CLI entry point. */ const CLI_BIN = path.resolve( import.meta.dirname, @@ -23,7 +25,7 @@ describe('CLI process termination', () => { [ CLI_BIN, 'crawl', - 'http://localhost:8010/', + `http://localhost:${TEST_SERVER_PORT}/`, '--silent', '--no-image', '--no-fetch-external', 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 760a93a..0e7fc86 100644 --- a/packages/test-server/src/__tests__/e2e/config-persistence.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/config-persistence.e2e.ts @@ -3,12 +3,13 @@ import type { Config } from '@nitpicker/crawler'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_PORT } from './test-server-port.js'; describe('Config persistence', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/'], { + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/`], { userAgent: 'NitpickerE2EBot/1.0', ignoreRobots: true, }); diff --git a/packages/test-server/src/__tests__/e2e/crawl-write-entity-tables.e2e.ts b/packages/test-server/src/__tests__/e2e/crawl-write-entity-tables.e2e.ts index 27a19fd..652fe87 100644 --- a/packages/test-server/src/__tests__/e2e/crawl-write-entity-tables.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/crawl-write-entity-tables.e2e.ts @@ -4,6 +4,7 @@ import { buildViewerReadModel } from '@nitpicker/query'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_PORT } from './test-server-port.js'; /** * Legacy flat tables that fresh archives no longer carry — the crawler's @@ -49,7 +50,9 @@ describe('crawler write path targets entity tables directly (issue #196)', () => // suite asserts on gets at least one row from one crawl. `image: true` // overrides the helper's default (`false`) so `` elements are // extracted into `images` / `image_items`. - result = await crawl(['http://localhost:8010/resource-reuse/'], { image: true }); + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/resource-reuse/`], { + image: true, + }); }, 120_000); afterAll(async () => { @@ -86,7 +89,7 @@ describe('crawler write path targets entity tables directly (issue #196)', () => }[]; expect(viewerPages.length).toBeGreaterThan(0); expect(viewerPages.map((p) => p.url)).toContain( - 'http://localhost:8010/resource-reuse/', + `http://localhost:${TEST_SERVER_PORT}/resource-reuse/`, ); }); 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..d850722 100644 --- a/packages/test-server/src/__tests__/e2e/error-status.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/error-status.e2e.ts @@ -1,12 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_PORT } from './test-server-port.js'; describe('Error status codes', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/error-status/']); + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/error-status/`]); }, 60_000); afterAll(async () => { diff --git a/packages/test-server/src/__tests__/e2e/exclude.e2e.ts b/packages/test-server/src/__tests__/e2e/exclude.e2e.ts index 94f88e0..4c8550b 100644 --- a/packages/test-server/src/__tests__/e2e/exclude.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/exclude.e2e.ts @@ -1,13 +1,18 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { + TEST_SERVER_EXTERNAL_ORIGIN, + TEST_SERVER_ORIGIN, + TEST_SERVER_PORT, +} from './test-server-port.js'; describe('Exclude patterns', () => { describe('パス除外 (excludes)', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/exclude/'], { + result = await crawl([`${TEST_SERVER_ORIGIN}/exclude/`], { excludes: ['/exclude/secret/*'], }); }, 60_000); @@ -34,7 +39,7 @@ describe('Exclude patterns', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/exclude/'], { + result = await crawl([`${TEST_SERVER_ORIGIN}/exclude/`], { excludeKeywords: ['FORBIDDEN_KEYWORD'], }); }, 60_000); @@ -76,8 +81,8 @@ describe('Exclude patterns', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/exclude/'], { - excludeUrls: ['http://127.0.0.1:8010/exclude/external-a'], + result = await crawl([`${TEST_SERVER_ORIGIN}/exclude/`], { + excludeUrls: [`${TEST_SERVER_EXTERNAL_ORIGIN}/exclude/external-a`], }); }, 60_000); @@ -89,7 +94,7 @@ describe('Exclude patterns', () => { const pages = await result.accessor.getPages('external-page'); const urls = pages.map((p) => p.url.href); const hasExternalA = urls.some((u) => - u.includes('127.0.0.1:8010/exclude/external-a'), + u.includes(`127.0.0.1:${TEST_SERVER_PORT}/exclude/external-a`), ); expect(hasExternalA).toBe(false); }); @@ -98,14 +103,16 @@ describe('Exclude patterns', () => { const pages = await result.accessor.getPages('external-page'); const urls = pages.map((p) => p.url.href); const hasExternalB = urls.some((u) => - u.includes('127.0.0.1:8010/exclude/external-b'), + u.includes(`127.0.0.1:${TEST_SERVER_PORT}/exclude/external-b`), ); expect(hasExternalB).toBe(true); }); it('excludeUrls がアーカイブの config に保存される', async () => { const config = await result.accessor.getConfig(); - expect(config.excludeUrls).toContain('http://127.0.0.1:8010/exclude/external-a'); + expect(config.excludeUrls).toContain( + `${TEST_SERVER_EXTERNAL_ORIGIN}/exclude/external-a`, + ); }); }); }); diff --git a/packages/test-server/src/__tests__/e2e/global-setup.ts b/packages/test-server/src/__tests__/e2e/global-setup.ts index 22e476d..37308bc 100644 --- a/packages/test-server/src/__tests__/e2e/global-setup.ts +++ b/packages/test-server/src/__tests__/e2e/global-setup.ts @@ -1,14 +1,23 @@ import type { Server } from 'node:http'; +import type { TestProject } from 'vitest/node'; let server: Server; /** - * + * Starts the E2E test server on an OS-assigned port and shares the actual + * port with test files via vitest's provide/inject channel — a fixed port + * made concurrent worktrees/sessions collide with `EADDRINUSE` (#162). + * @param project - The vitest project, used to `provide` the resolved port. */ -export async function setup() { +export async function setup(project: TestProject) { const { startServer } = await import('../../../src/server.js'); - server = await startServer(8010); - console.log('[global-setup] Test server started on port 8010'); // eslint-disable-line no-console + server = await startServer(); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('[global-setup] test server did not report a TCP port'); + } + project.provide('testServerPort', address.port); + console.log(`[global-setup] Test server started on port ${address.port}`); // eslint-disable-line no-console } /** diff --git a/packages/test-server/src/__tests__/e2e/inventory.e2e.ts b/packages/test-server/src/__tests__/e2e/inventory.e2e.ts index 9364145..2525721 100644 --- a/packages/test-server/src/__tests__/e2e/inventory.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/inventory.e2e.ts @@ -7,6 +7,8 @@ import { Archive, CrawlerOrchestrator, computeFileSha256 } from '@nitpicker/craw import { listInventoryRuns, listUnusedResources } from '@nitpicker/query'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { TEST_SERVER_ORIGIN, TEST_SERVER_PORT } from './test-server-port.js'; + /** * Run a baseline crawl rooted at the test-server top page, then close the * archive so the caller can re-open it via `Archive.open` / `inventory()`. @@ -44,7 +46,7 @@ describe('Inventory crawl', () => { beforeAll(async () => { // 1) Baseline crawl reaches only the top page and its anchors — // /inventory/* routes are never linked from the crawl-reachable graph. - const baseline = await crawlAndPersist(['http://localhost:8010/']); + const baseline = await crawlAndPersist([`${TEST_SERVER_ORIGIN}/`]); filePath = baseline.filePath; cwd = baseline.cwd; @@ -55,8 +57,8 @@ describe('Inventory crawl', () => { const orchestrator = await CrawlerOrchestrator.inventory( filePath, [ - 'http://localhost:8010/inventory/hidden-lp', - 'http://localhost:8010/inventory/orphan.pdf', + `${TEST_SERVER_ORIGIN}/inventory/hidden-lp`, + `${TEST_SERVER_ORIGIN}/inventory/orphan.pdf`, ], { cwd }, ); @@ -90,7 +92,7 @@ describe('Inventory crawl', () => { const [row] = (await knex('content_items as ci') .join('url_refs as ur', 'ci.url_id', 'ur.id') .select('ci.source as source') - .where('ur.url', 'http://localhost:8010/inventory/hidden-lp')) as { + .where('ur.url', `${TEST_SERVER_ORIGIN}/inventory/hidden-lp`)) as { source: string; }[]; expect(row, 'hidden-lp must have been inserted by --inventory').toBeDefined(); @@ -108,7 +110,7 @@ describe('Inventory crawl', () => { const [row] = (await knex('content_items as ci') .join('url_refs as ur', 'ci.url_id', 'ur.id') .select('ci.source as source') - .where('ur.url', 'http://localhost:8010/inventory/inner-link')) as { + .where('ur.url', `${TEST_SERVER_ORIGIN}/inventory/inner-link`)) as { source: string; }[]; expect( @@ -121,7 +123,7 @@ describe('Inventory crawl', () => { it('records a non-HTML URL from the inventory list directly in resources as inventory-seed', async () => { const rows = await listUnusedResources(accessor, { limit: 50 }); const orphan = rows.items.find( - (row) => row.url === 'http://localhost:8010/inventory/orphan.pdf', + (row) => row.url === `${TEST_SERVER_ORIGIN}/inventory/orphan.pdf`, ); expect(orphan, 'orphan.pdf must be present in unused resources').toBeDefined(); expect(orphan?.source).toBe('inventory-seed'); @@ -210,7 +212,7 @@ describe('Inventory crawl run-audit fingerprint (with source file sha256)', () = let accessor: Archive; beforeAll(async () => { - const baseline = await crawlAndPersist(['http://localhost:8010/']); + const baseline = await crawlAndPersist([`${TEST_SERVER_ORIGIN}/`]); filePath = baseline.filePath; cwd = baseline.cwd; @@ -221,8 +223,8 @@ describe('Inventory crawl run-audit fingerprint (with source file sha256)', () = // boundary deliberately does NOT receive the path post-lift). listFilePath = path.join(cwd, 'inventory-list.txt'); const listBody = [ - 'http://localhost:8010/inventory/hidden-lp', - 'http://localhost:8010/inventory/orphan.pdf', + `${TEST_SERVER_ORIGIN}/inventory/hidden-lp`, + `${TEST_SERVER_ORIGIN}/inventory/orphan.pdf`, ].join('\n'); await fs.writeFile(listFilePath, listBody); @@ -238,8 +240,8 @@ describe('Inventory crawl run-audit fingerprint (with source file sha256)', () = const orchestrator = await CrawlerOrchestrator.inventory( filePath, [ - 'http://localhost:8010/inventory/hidden-lp', - 'http://localhost:8010/inventory/orphan.pdf', + `${TEST_SERVER_ORIGIN}/inventory/hidden-lp`, + `${TEST_SERVER_ORIGIN}/inventory/orphan.pdf`, ], { cwd }, undefined, @@ -300,7 +302,7 @@ describe('Inventory pre-insert survives interrupted scrape (#121)', () => { let accessor: Archive; beforeAll(async () => { - const baseline = await crawlAndPersist(['http://localhost:8010/']); + const baseline = await crawlAndPersist([`${TEST_SERVER_ORIGIN}/`]); filePath = baseline.filePath; cwd = baseline.cwd; @@ -309,8 +311,8 @@ describe('Inventory pre-insert survives interrupted scrape (#121)', () => { [ // Two HTML seeds to make sure the assertion is not a single-row // coincidence — both must show up in the strict pending set. - 'http://localhost:8010/inventory/hidden-lp', - 'http://localhost:8010/inventory/inner-link', + `${TEST_SERVER_ORIGIN}/inventory/hidden-lp`, + `${TEST_SERVER_ORIGIN}/inventory/inner-link`, ], { cwd }, (orch) => { @@ -344,8 +346,8 @@ describe('Inventory pre-insert survives interrupted scrape (#121)', () => { .join('url_refs as ur', 'ci.url_id', 'ur.id') .select('ur.url as url', 'ci.source as source', 'ci.scraped as scraped') .whereIn('ur.url', [ - 'http://localhost:8010/inventory/hidden-lp', - 'http://localhost:8010/inventory/inner-link', + `${TEST_SERVER_ORIGIN}/inventory/hidden-lp`, + `${TEST_SERVER_ORIGIN}/inventory/inner-link`, ]) .orderBy('ur.url')) as Array<{ url: string; @@ -353,8 +355,8 @@ describe('Inventory pre-insert survives interrupted scrape (#121)', () => { scraped: number; }>; expect(rows.map((r) => r.url)).toEqual([ - 'http://localhost:8010/inventory/hidden-lp', - 'http://localhost:8010/inventory/inner-link', + `${TEST_SERVER_ORIGIN}/inventory/hidden-lp`, + `${TEST_SERVER_ORIGIN}/inventory/inner-link`, ]); for (const row of rows) { expect(row.source).toBe('inventory-seed'); @@ -369,8 +371,8 @@ describe('Inventory pre-insert survives interrupted scrape (#121)', () => { // interrupted inventory pass is irrecoverable. const { pending } = await accessor.getCrawlingState(); expect(pending.toSorted()).toEqual([ - 'http://localhost:8010/inventory/hidden-lp', - 'http://localhost:8010/inventory/inner-link', + `${TEST_SERVER_ORIGIN}/inventory/hidden-lp`, + `${TEST_SERVER_ORIGIN}/inventory/inner-link`, ]); }); @@ -412,7 +414,7 @@ describe('Inventory scrape-phase failure persists ingested state (#121 recovery let cwd: string; beforeAll(async () => { - const baseline = await crawlAndPersist(['http://localhost:8010/']); + const baseline = await crawlAndPersist([`${TEST_SERVER_ORIGIN}/`]); filePath = baseline.filePath; cwd = baseline.cwd; @@ -421,8 +423,8 @@ describe('Inventory scrape-phase failure persists ingested state (#121 recovery CrawlerOrchestrator.inventory( filePath, [ - 'http://localhost:8010/inventory/hidden-lp', - 'http://localhost:8010/inventory/inner-link', + `${TEST_SERVER_ORIGIN}/inventory/hidden-lp`, + `${TEST_SERVER_ORIGIN}/inventory/inner-link`, ], { cwd }, () => { @@ -454,8 +456,8 @@ describe('Inventory scrape-phase failure persists ingested state (#121 recovery .join('url_refs as ur', 'ci.url_id', 'ur.id') .select('ur.url as url', 'ci.source as source', 'ci.scraped as scraped') .whereIn('ur.url', [ - 'http://localhost:8010/inventory/hidden-lp', - 'http://localhost:8010/inventory/inner-link', + `${TEST_SERVER_ORIGIN}/inventory/hidden-lp`, + `${TEST_SERVER_ORIGIN}/inventory/inner-link`, ]) .orderBy('ur.url')) as Array<{ url: string; @@ -463,8 +465,8 @@ describe('Inventory scrape-phase failure persists ingested state (#121 recovery scraped: number; }>; expect(rows.map((r) => r.url)).toEqual([ - 'http://localhost:8010/inventory/hidden-lp', - 'http://localhost:8010/inventory/inner-link', + `${TEST_SERVER_ORIGIN}/inventory/hidden-lp`, + `${TEST_SERVER_ORIGIN}/inventory/inner-link`, ]); for (const row of rows) { expect(row.source).toBe('inventory-seed'); @@ -509,7 +511,7 @@ describe('Inventory http/https dedup keeps a single inventory-seed row per origi let accessor: Archive; beforeAll(async () => { - const baseline = await crawlAndPersist(['http://localhost:8010/']); + const baseline = await crawlAndPersist([`${TEST_SERVER_ORIGIN}/`]); filePath = baseline.filePath; cwd = baseline.cwd; @@ -519,8 +521,8 @@ describe('Inventory http/https dedup keeps a single inventory-seed row per origi // Same URL with two schemes. Without dedup, both would // pass `getExistingPageUrls` (which matches exact `url`) // and produce two `pages` rows. - 'http://localhost:8010/inventory/hidden-lp', - 'https://localhost:8010/inventory/hidden-lp', + `${TEST_SERVER_ORIGIN}/inventory/hidden-lp`, + `https://localhost:${TEST_SERVER_PORT}/inventory/hidden-lp`, ], { cwd }, (orch) => { @@ -549,14 +551,14 @@ describe('Inventory http/https dedup keeps a single inventory-seed row per origi .join('url_refs as ur', 'ci.url_id', 'ur.id') .select('ur.url as url', 'ci.source as source') .whereIn('ur.url', [ - 'http://localhost:8010/inventory/hidden-lp', - 'https://localhost:8010/inventory/hidden-lp', + `${TEST_SERVER_ORIGIN}/inventory/hidden-lp`, + `https://localhost:${TEST_SERVER_PORT}/inventory/hidden-lp`, ])) as Array<{ url: string; source: string }>; // First-seen wins: the http scheme was supplied first so it // survives the dedup. The order is documented contract for the // dedup helper — if it ever needs to flip, this test catches it. expect(rows).toHaveLength(1); - expect(rows[0]?.url).toBe('http://localhost:8010/inventory/hidden-lp'); + expect(rows[0]?.url).toBe(`${TEST_SERVER_ORIGIN}/inventory/hidden-lp`); expect(rows[0]?.source).toBe('inventory-seed'); }); }); @@ -568,7 +570,7 @@ describe('Inventory crawl noop run (all URLs already in archive)', () => { beforeAll(async () => { // Baseline crawl reaches `/` and `/about` (anchored from index). - const baseline = await crawlAndPersist(['http://localhost:8010/']); + const baseline = await crawlAndPersist([`${TEST_SERVER_ORIGIN}/`]); filePath = baseline.filePath; cwd = baseline.cwd; @@ -577,7 +579,7 @@ describe('Inventory crawl noop run (all URLs already in archive)', () => { // drops it before any work happens, so this is the noop branch. const orchestrator = await CrawlerOrchestrator.inventory( filePath, - ['http://localhost:8010/'], + [`${TEST_SERVER_ORIGIN}/`], { cwd }, ); await orchestrator.write(); diff --git a/packages/test-server/src/__tests__/e2e/js-redirect.e2e.ts b/packages/test-server/src/__tests__/e2e/js-redirect.e2e.ts index 44651d2..91c2922 100644 --- a/packages/test-server/src/__tests__/e2e/js-redirect.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/js-redirect.e2e.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_PORT } from './test-server-port.js'; /** * E2E coverage for client-side redirect handling. @@ -42,7 +43,7 @@ describe('Client-side window.location.replace() lands in the archive as a redire let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/js-redirect/']); + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/js-redirect/`]); }, 60_000); afterAll(async () => { diff --git a/packages/test-server/src/__tests__/e2e/main-contents.e2e.ts b/packages/test-server/src/__tests__/e2e/main-contents.e2e.ts index 6103bcd..0324b74 100644 --- a/packages/test-server/src/__tests__/e2e/main-contents.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/main-contents.e2e.ts @@ -1,12 +1,15 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_PORT } from './test-server-port.js'; describe('main-content extraction (real headless-browser DOM heuristic, issue: beholder 4.0.0 promotion)', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/main-content/'], { recursive: false }); + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/main-content/`], { + recursive: false, + }); }, 60_000); afterAll(async () => { diff --git a/packages/test-server/src/__tests__/e2e/meta.e2e.ts b/packages/test-server/src/__tests__/e2e/meta.e2e.ts index 26ee153..8d13a3b 100644 --- a/packages/test-server/src/__tests__/e2e/meta.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/meta.e2e.ts @@ -1,12 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_ORIGIN } from './test-server-port.js'; describe('Meta tag extraction (v2 schema)', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/meta/']); + result = await crawl([`${TEST_SERVER_ORIGIN}/meta/`]); }, 180_000); afterAll(async () => { @@ -28,10 +29,10 @@ describe('Meta tag extraction (v2 schema)', () => { expect(page!.og_title).toBe('OG Title'); expect(page!.og_site_name).toBe('Test Site'); expect(page!.og_description).toBe('OG Description'); - expect(page!.og_url).toBe('http://localhost:8010/meta/full'); + expect(page!.og_url).toBe(`${TEST_SERVER_ORIGIN}/meta/full`); expect(page!.og_image).toContain('og-image.png'); expect(page!.twitter_card).toBe('summary_large_image'); - expect(page!.canonical).toBe('http://localhost:8010/meta/full'); + expect(page!.canonical).toBe(`${TEST_SERVER_ORIGIN}/meta/full`); expect(page!.robots_noindex).toBe(false); expect(page!.robots_nofollow).toBe(false); }); @@ -61,7 +62,7 @@ describe('Meta tag extraction (v2 schema)', () => { const page = pages.find((p) => p.url.pathname === '/meta/relative-canonical'); expect(page).toBeDefined(); - expect(page!.canonical).toBe('http://localhost:8010/meta/relative-canonical'); + expect(page!.canonical).toBe(`${TEST_SERVER_ORIGIN}/meta/relative-canonical`); }); it('stores JSON-LD entries in page_jsonld with classified @type', async () => { diff --git a/packages/test-server/src/__tests__/e2e/multi-root.e2e.ts b/packages/test-server/src/__tests__/e2e/multi-root.e2e.ts index a3a2e69..369a7ef 100644 --- a/packages/test-server/src/__tests__/e2e/multi-root.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/multi-root.e2e.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_ORIGIN } from './test-server-port.js'; describe('Multi-root crawl', () => { describe('同一ホスト × 複数サブパス起点', () => { @@ -8,8 +9,8 @@ describe('Multi-root crawl', () => { beforeAll(async () => { result = await crawl([ - 'http://localhost:8010/scope/blog/', - 'http://localhost:8010/scope/docs/', + `${TEST_SERVER_ORIGIN}/scope/blog/`, + `${TEST_SERVER_ORIGIN}/scope/docs/`, ]); }, 120_000); @@ -35,8 +36,8 @@ describe('Multi-root crawl', () => { it('info.roots に両方の起点 URL が記録される', async () => { const config = await result.accessor.getConfig(); expect(config.roots).toEqual([ - 'http://localhost:8010/scope/blog/', - 'http://localhost:8010/scope/docs/', + `${TEST_SERVER_ORIGIN}/scope/blog/`, + `${TEST_SERVER_ORIGIN}/scope/docs/`, ]); }); }); diff --git a/packages/test-server/src/__tests__/e2e/options.e2e.ts b/packages/test-server/src/__tests__/e2e/options.e2e.ts index 2763aab..6e40ada 100644 --- a/packages/test-server/src/__tests__/e2e/options.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/options.e2e.ts @@ -1,13 +1,14 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_ORIGIN } from './test-server-port.js'; describe('Crawler options', () => { describe('fetchExternal: false', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/options/'], { + result = await crawl([`${TEST_SERVER_ORIGIN}/options/`], { fetchExternal: false, }); }, 60_000); @@ -43,7 +44,7 @@ describe('Crawler options', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/options/'], { + result = await crawl([`${TEST_SERVER_ORIGIN}/options/`], { fetchExternal: true, }); }, 60_000); @@ -71,7 +72,7 @@ describe('Crawler options', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/options/'], { + result = await crawl([`${TEST_SERVER_ORIGIN}/options/`], { disableQueries: true, }); }, 60_000); diff --git a/packages/test-server/src/__tests__/e2e/output-path.e2e.ts b/packages/test-server/src/__tests__/e2e/output-path.e2e.ts index fa5344e..f091300 100644 --- a/packages/test-server/src/__tests__/e2e/output-path.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/output-path.e2e.ts @@ -7,6 +7,8 @@ import path from 'node:path'; import { Archive, CrawlerOrchestrator } from '@nitpicker/crawler'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { TEST_SERVER_PORT } from './test-server-port.js'; + describe('Output path (filePath option)', () => { let orchestrator: CrawlerOrchestrator; let cwd: string; @@ -19,7 +21,7 @@ describe('Output path (filePath option)', () => { outputPath = path.join(cwd, 'custom-output.nitpicker'); orchestrator = await CrawlerOrchestrator.crawling( - ['http://localhost:8010/'], + [`http://localhost:${TEST_SERVER_PORT}/`], { cwd, filePath: outputPath, @@ -85,7 +87,7 @@ describe('Output path without extension', () => { await fs.mkdir(cwd, { recursive: true }); orchestrator = await CrawlerOrchestrator.crawling( - ['http://localhost:8010/'], + [`http://localhost:${TEST_SERVER_PORT}/`], { cwd, filePath: path.join(cwd, 'my-report'), diff --git a/packages/test-server/src/__tests__/e2e/pagination.e2e.ts b/packages/test-server/src/__tests__/e2e/pagination.e2e.ts index 85203e9..2af8dea 100644 --- a/packages/test-server/src/__tests__/e2e/pagination.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/pagination.e2e.ts @@ -1,12 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_PORT } from './test-server-port.js'; describe('Speculative pagination (path-based)', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/pagination/'], { + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/pagination/`], { parallels: 5, }); }, 120_000); diff --git a/packages/test-server/src/__tests__/e2e/parallel-and-interval.e2e.ts b/packages/test-server/src/__tests__/e2e/parallel-and-interval.e2e.ts index f2d5beb..03b19e9 100644 --- a/packages/test-server/src/__tests__/e2e/parallel-and-interval.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/parallel-and-interval.e2e.ts @@ -1,13 +1,14 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_PORT } from './test-server-port.js'; describe('Parallel and interval options', () => { describe('parallels: 3', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/recursive/'], { + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/recursive/`], { parallels: 3, }); }, 60_000); @@ -36,7 +37,7 @@ describe('Parallel and interval options', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/recursive/'], { + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/recursive/`], { interval: 500, }); }, 60_000); diff --git a/packages/test-server/src/__tests__/e2e/recursive.e2e.ts b/packages/test-server/src/__tests__/e2e/recursive.e2e.ts index 511d43d..d7b20a4 100644 --- a/packages/test-server/src/__tests__/e2e/recursive.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/recursive.e2e.ts @@ -1,12 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_PORT } from './test-server-port.js'; describe('Recursive crawling', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/recursive/']); + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/recursive/`]); }, 60_000); afterAll(async () => { @@ -41,9 +42,12 @@ describe('Recursive crawling', () => { }); it('recursive: false で再帰しない', async () => { - const nonRecursiveResult = await crawl(['http://localhost:8010/recursive/'], { - recursive: false, - }); + const nonRecursiveResult = await crawl( + [`http://localhost:${TEST_SERVER_PORT}/recursive/`], + { + recursive: false, + }, + ); try { const pages = await nonRecursiveResult.accessor.getPages('page'); diff --git a/packages/test-server/src/__tests__/e2e/redirect.e2e.ts b/packages/test-server/src/__tests__/e2e/redirect.e2e.ts index aa111d6..1d56a0d 100644 --- a/packages/test-server/src/__tests__/e2e/redirect.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/redirect.e2e.ts @@ -1,12 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_ORIGIN } from './test-server-port.js'; describe('Redirect handling', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/redirect/']); + result = await crawl([`${TEST_SERVER_ORIGIN}/redirect/`]); }, 60_000); afterAll(async () => { @@ -71,7 +72,7 @@ describe('Redirect convergence (#73): 多対一リダイレクト先を1回だ let redirectEvents = 0; beforeAll(async () => { - result = await crawl(['http://localhost:8010/converge/'], undefined, (q) => { + result = await crawl([`${TEST_SERVER_ORIGIN}/converge/`], undefined, (q) => { q.on('redirect', () => { redirectEvents++; }); @@ -126,7 +127,7 @@ describe('Redirect convergence dedup はクエリ違いの別ページを潰さ let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/query-distinct/']); + result = await crawl([`${TEST_SERVER_ORIGIN}/query-distinct/`]); }, 60_000); afterAll(async () => { @@ -149,7 +150,7 @@ describe('HEAD と GET で到達先が違っても描画した宛先で claim let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/diverge/']); + result = await crawl([`${TEST_SERVER_ORIGIN}/diverge/`]); }, 60_000); afterAll(async () => { @@ -177,7 +178,7 @@ describe('metadataOnly のリダイレクト元が描画済み宛先を上書き let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/clobber/']); + result = await crawl([`${TEST_SERVER_ORIGIN}/clobber/`]); }, 60_000); afterAll(async () => { diff --git a/packages/test-server/src/__tests__/e2e/resource-reuse.e2e.ts b/packages/test-server/src/__tests__/e2e/resource-reuse.e2e.ts index 948e232..d4ba4b0 100644 --- a/packages/test-server/src/__tests__/e2e/resource-reuse.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/resource-reuse.e2e.ts @@ -2,6 +2,7 @@ import { getSummary, listPages } from '@nitpicker/query'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_ORIGIN } from './test-server-port.js'; describe('Resource reuse', () => { let result: CrawlResult; @@ -10,14 +11,14 @@ describe('Resource reuse', () => { beforeAll(async () => { // 共有サーバーの観測ログを先行テストの蓄積からリセットする // (リセットに失敗すると完全一致アサーションが壊れるため必ず成功を確認) - const reset = await fetch('http://localhost:8010/resource-reuse/__stats', { + const reset = await fetch(`${TEST_SERVER_ORIGIN}/resource-reuse/__stats`, { method: 'DELETE', }); if (!reset.ok) { throw new Error(`__stats reset failed: ${reset.status}`); } - result = await crawl(['http://localhost:8010/resource-reuse/']); - const res = await fetch('http://localhost:8010/resource-reuse/__stats'); + result = await crawl([`${TEST_SERVER_ORIGIN}/resource-reuse/`]); + const res = await fetch(`${TEST_SERVER_ORIGIN}/resource-reuse/__stats`); requests = await res.json(); }, 120_000); @@ -129,7 +130,9 @@ describe('Resource reuse (list mode)', () => { beforeAll(async () => { // list mode (recursive: false): the root page is fully rendered, and every // discovered anchor — internal ones included — is queued as metadataOnly - result = await crawl(['http://localhost:8010/resource-reuse/'], { list: true }); + result = await crawl([`${TEST_SERVER_ORIGIN}/resource-reuse/`], { + list: true, + }); }, 120_000); afterAll(async () => { diff --git a/packages/test-server/src/__tests__/e2e/retry-failed.e2e.ts b/packages/test-server/src/__tests__/e2e/retry-failed.e2e.ts index 6965902..5831d61 100644 --- a/packages/test-server/src/__tests__/e2e/retry-failed.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/retry-failed.e2e.ts @@ -6,6 +6,8 @@ import path from 'node:path'; import { Archive, CrawlerOrchestrator } from '@nitpicker/crawler'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { TEST_SERVER_ORIGIN } from './test-server-port.js'; + /** * Run a baseline crawl that creates a `.nitpicker` archive on disk, then close * it so the caller can re-open it via `Archive.open`. @@ -40,7 +42,7 @@ async function crawlAndPersist( * @param state - `'heal'` to serve 200, `'reset'` to serve 500. */ async function setFlakyState(state: 'heal' | 'reset'): Promise { - const res = await fetch(`http://localhost:8010/flaky/control/${state}`); + const res = await fetch(`${TEST_SERVER_ORIGIN}/flaky/control/${state}`); await res.text(); } @@ -54,7 +56,7 @@ describe('Retry failed crawl (recursive, default)', () => { // 1) Baseline crawl while /flaky/recoverable returns 500. The page is // recorded with status 500 and its (healed-only) child stays unseen. await setFlakyState('reset'); - const baseline = await crawlAndPersist(['http://localhost:8010/flaky/']); + const baseline = await crawlAndPersist([`${TEST_SERVER_ORIGIN}/flaky/`]); filePath = baseline.filePath; cwd = baseline.cwd; const baselineArchive = await Archive.open({ filePath, cwd }); @@ -118,7 +120,7 @@ describe('Retry failed crawl (--no-recursive)', () => { beforeAll(async () => { await setFlakyState('reset'); - const baseline = await crawlAndPersist(['http://localhost:8010/flaky/']); + const baseline = await crawlAndPersist([`${TEST_SERVER_ORIGIN}/flaky/`]); filePath = baseline.filePath; cwd = baseline.cwd; @@ -164,7 +166,7 @@ describe('Retry failed crawl: permanent-kind exclusion converges across iteratio // enters the archive with status=500, classified as a recoverable // candidate by the SQL fail-shape filter. await setFlakyState('reset'); - const baseline = await crawlAndPersist(['http://localhost:8010/flaky/']); + const baseline = await crawlAndPersist([`${TEST_SERVER_ORIGIN}/flaky/`]); filePath = baseline.filePath; cwd = baseline.cwd; @@ -190,7 +192,7 @@ describe('Retry failed crawl: permanent-kind exclusion converges across iteratio const recoverableRow = (await knex('content_items as ci') .join('url_refs as ur', 'ci.url_id', 'ur.id') .select('ci.id as id') - .where('ur.url', 'http://localhost:8010/flaky/recoverable') + .where('ur.url', `${TEST_SERVER_ORIGIN}/flaky/recoverable`) .first()) as { id: number } | undefined; expect(recoverableRow).toBeDefined(); await knex('page_errors').insert({ @@ -247,7 +249,7 @@ describe('Retry failed crawl: list-mode rejection', () => { beforeAll(async () => { await setFlakyState('reset'); - const baseline = await crawlAndPersist(['http://localhost:8010/flaky/']); + const baseline = await crawlAndPersist([`${TEST_SERVER_ORIGIN}/flaky/`]); filePath = baseline.filePath; cwd = baseline.cwd; diff --git a/packages/test-server/src/__tests__/e2e/scope-auth-leak.e2e.ts b/packages/test-server/src/__tests__/e2e/scope-auth-leak.e2e.ts index 4aa16e2..cb171f2 100644 --- a/packages/test-server/src/__tests__/e2e/scope-auth-leak.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/scope-auth-leak.e2e.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_ORIGIN, TEST_SERVER_PORT } from './test-server-port.js'; const SCOPE_USER = 'scope-user'; const SCOPE_PASS = 'scope-pass'; @@ -13,7 +14,7 @@ const EMPTY_AUTH_HEADER = `Basic ${Buffer.from(':').toString('base64')}`; * lack thereof) don't bleed into the next assertion. */ async function resetExternalAuthHeaders(): Promise { - const response = await fetch('http://localhost:8010/scope-auth-leak/reset', { + const response = await fetch(`${TEST_SERVER_ORIGIN}/scope-auth-leak/reset`, { method: 'POST', }); if (!response.ok) { @@ -28,7 +29,7 @@ async function resetExternalAuthHeaders(): Promise { * @returns Recorded `Authorization` header values in arrival order. */ async function readExternalAuthHeaders(): Promise<(string | null)[]> { - const response = await fetch('http://localhost:8010/scope-auth-leak/external-headers'); + const response = await fetch(`${TEST_SERVER_ORIGIN}/scope-auth-leak/external-headers`); const json = (await response.json()) as { headers: (string | null)[] }; return json.headers; } @@ -49,9 +50,9 @@ describe('Scope credential leak guard — sub-resource Basic auth (E2E)', () => it('does NOT send scope credentials to off-scope sub-resources that demand Basic auth', async () => { // Setup recap (full scenario in scope-auth-leak.ts): - // - Scope: http://scope-user:scope-pass@localhost:8010/scope-auth-leak/main + // - Scope: http://scope-user:scope-pass@localhost:PORT/scope-auth-leak/main // - Main endpoint requires Basic auth matching scope-user:scope-pass. - // - HTML embeds + // - HTML embeds // — different hostname (127.0.0.1 vs localhost) so the crawler's // scope map treats it as off-scope even though the port matches. // - External endpoint records every Authorization header it sees @@ -64,7 +65,7 @@ describe('Scope credential leak guard — sub-resource Basic auth (E2E)', () => // helper exists to prevent. result = await crawl([ - `http://${SCOPE_USER}:${SCOPE_PASS}@localhost:8010/scope-auth-leak/main`, + `http://${SCOPE_USER}:${SCOPE_PASS}@localhost:${TEST_SERVER_PORT}/scope-auth-leak/main`, ]); // Sanity: the main scope-protected page was scraped successfully — diff --git a/packages/test-server/src/__tests__/e2e/scope.e2e.ts b/packages/test-server/src/__tests__/e2e/scope.e2e.ts index cb35aa2..e760947 100644 --- a/packages/test-server/src/__tests__/e2e/scope.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/scope.e2e.ts @@ -1,13 +1,14 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_PORT } from './test-server-port.js'; describe('Scope restriction', () => { describe('開始URLによるスコープ制限', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/scope/blog/']); + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/scope/blog/`]); }, 120_000); afterAll(async () => { @@ -37,7 +38,7 @@ describe('Scope restriction', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/scope/blog/'], { + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/scope/blog/`], { fetchExternal: true, }); }, 120_000); diff --git a/packages/test-server/src/__tests__/e2e/scroll-jack.e2e.ts b/packages/test-server/src/__tests__/e2e/scroll-jack.e2e.ts index ce17afb..db74bb2 100644 --- a/packages/test-server/src/__tests__/e2e/scroll-jack.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/scroll-jack.e2e.ts @@ -1,12 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_PORT } from './test-server-port.js'; describe('Scroll-jack page (viewport-dependent redirect)', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/scroll-jack/'], { + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/scroll-jack/`], { recursive: false, image: true, }); diff --git a/packages/test-server/src/__tests__/e2e/single-page.e2e.ts b/packages/test-server/src/__tests__/e2e/single-page.e2e.ts index 70ca75c..a6f10d0 100644 --- a/packages/test-server/src/__tests__/e2e/single-page.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/single-page.e2e.ts @@ -1,12 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_PORT } from './test-server-port.js'; describe('Single page scraping', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/'], { recursive: false }); + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/`], { recursive: false }); }); afterAll(async () => { diff --git a/packages/test-server/src/__tests__/e2e/snapshot.e2e.ts b/packages/test-server/src/__tests__/e2e/snapshot.e2e.ts index 940acb8..c4d9726 100644 --- a/packages/test-server/src/__tests__/e2e/snapshot.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/snapshot.e2e.ts @@ -1,12 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { type CrawlResult, cleanup, crawl } from './helpers.js'; +import { TEST_SERVER_PORT } from './test-server-port.js'; describe('HTML snapshot and metadata', () => { let result: CrawlResult; beforeAll(async () => { - result = await crawl(['http://localhost:8010/meta/']); + result = await crawl([`http://localhost:${TEST_SERVER_PORT}/meta/`]); }, 180_000); afterAll(async () => { diff --git a/packages/test-server/src/__tests__/e2e/test-server-port.ts b/packages/test-server/src/__tests__/e2e/test-server-port.ts new file mode 100644 index 0000000..ef4a217 --- /dev/null +++ b/packages/test-server/src/__tests__/e2e/test-server-port.ts @@ -0,0 +1,35 @@ +import { inject } from 'vitest'; + +// This `ProvidedContext` shape is duplicated in +// `packages/@nitpicker/report-google-sheets/src/__tests__/api/create-sheets.api.ts` +// (a separate TS project — that package has no reference to this one) — keep +// both declarations in sync if `testServerPort`'s shape ever changes. +declare module 'vitest' { + interface ProvidedContext { + testServerPort: number; + } +} + +/** + * The E2E test server's actual listening port. + * + * The server binds to an OS-assigned port instead of a fixed one so that + * concurrent worktrees/sessions never collide on `EADDRINUSE` (#162); + * `global-setup.ts` shares the resolved port with test files through this + * value via vitest's provide/inject channel. + */ +export const TEST_SERVER_PORT = inject('testServerPort'); + +/** + * Origin of the E2E test server (`http://localhost:`, no trailing + * slash) — the shape every test file needs for its in-scope crawl targets. + */ +export const TEST_SERVER_ORIGIN = `http://localhost:${TEST_SERVER_PORT}`; + +/** + * Origin of the E2E test server addressed via `127.0.0.1` instead of + * `localhost` — used deliberately by scope/exclude tests to simulate an + * "external" host on the same server (the crawler's scope map is keyed on + * hostname, so this hostname alone makes a URL look off-scope). + */ +export const TEST_SERVER_EXTERNAL_ORIGIN = `http://127.0.0.1:${TEST_SERVER_PORT}`; diff --git a/packages/test-server/src/__tests__/e2e/viewer-read-model-build.e2e.ts b/packages/test-server/src/__tests__/e2e/viewer-read-model-build.e2e.ts index b3d7c72..5aaf965 100644 --- a/packages/test-server/src/__tests__/e2e/viewer-read-model-build.e2e.ts +++ b/packages/test-server/src/__tests__/e2e/viewer-read-model-build.e2e.ts @@ -8,6 +8,8 @@ import { Archive } from '@nitpicker/crawler'; import { hasViewerReadModel } from '@nitpicker/query'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { TEST_SERVER_PORT } from './test-server-port.js'; + /** Absolute path to the built CLI entry point. */ const CLI_BIN = path.resolve( import.meta.dirname, @@ -80,7 +82,7 @@ describe('viewer read model — build timing (issue #112)', () => { const exitCode = await runCli( [ 'crawl', - 'http://localhost:8010/', + `http://localhost:${TEST_SERVER_PORT}/`, '--silent', '--no-image', '--no-fetch-external', diff --git a/packages/test-server/src/routes/exclude.ts b/packages/test-server/src/routes/exclude.ts index ca9cb20..dee1b3e 100644 --- a/packages/test-server/src/routes/exclude.ts +++ b/packages/test-server/src/routes/exclude.ts @@ -1,18 +1,21 @@ +import type { PortRef } from '../server.js'; import type { Hono } from 'hono'; /** * Registers routes for testing exclude patterns (path glob, keyword, and URL prefix). * @param app - The Hono application instance to register routes on. + * @param portRef - Holder for the server's actual listening port, used to + * build the self-referencing "external" (127.0.0.1) URLs below. */ -export function excludeRoutes(app: Hono) { +export function excludeRoutes(app: Hono, portRef: PortRef) { app.get('/exclude/', (c) => c.html( 'Exclude Top' + 'Page A' + 'Page B' + 'Secret' + - 'External A' + - 'External B' + + `External A` + + `External B` + '', ), ); diff --git a/packages/test-server/src/routes/meta.ts b/packages/test-server/src/routes/meta.ts index dfca944..f12f92f 100644 --- a/packages/test-server/src/routes/meta.ts +++ b/packages/test-server/src/routes/meta.ts @@ -1,3 +1,4 @@ +import type { PortRef } from '../server.js'; import type { Hono } from 'hono'; /** @@ -6,8 +7,10 @@ import type { Hono } from 'hono'; * coverage. Each route renders a deterministic HTML snippet — the e2e * tests in `meta.e2e.ts` assert against these exact strings. * @param app - The Hono application instance to register routes on. + * @param portRef - Holder for the server's actual listening port, used to + * build the absolute self-referencing URLs embedded in `/meta/full` below. */ -export function metaRoutes(app: Hono) { +export function metaRoutes(app: Hono, portRef: PortRef) { app.get('/meta/', (c) => c.html( 'Meta Top' + @@ -39,18 +42,18 @@ export function metaRoutes(app: Hono) { '' + '' + '' + - '' + - '' + + `` + + `` + '' + '' + '' + - '' + + `` + '' + '' + '' + '' + - '' + - '' + + `` + + `` + '' + '' + '' + @@ -62,7 +65,7 @@ export function metaRoutes(app: Hono) { '' + '' + '' + - '' + + `` + '' + '' + '' + diff --git a/packages/test-server/src/routes/options.ts b/packages/test-server/src/routes/options.ts index cf9b4ec..eecb3ee 100644 --- a/packages/test-server/src/routes/options.ts +++ b/packages/test-server/src/routes/options.ts @@ -1,17 +1,20 @@ +import type { PortRef } from '../server.js'; import type { Hono } from 'hono'; /** * Registers routes for testing crawler options such as query strings, external links, and JSON resources. * @param app - The Hono application instance to register routes on. + * @param portRef - Holder for the server's actual listening port, used to + * build the self-referencing "external" (127.0.0.1) URLs below. */ -export function optionsRoutes(app: Hono) { +export function optionsRoutes(app: Hono, portRef: PortRef) { app.get('/options/', (c) => c.html( 'Options Top' + 'Page A' + - 'External' + + `External` + 'Internal JSON' + - 'External JSON' + + `External JSON` + '', ), ); diff --git a/packages/test-server/src/routes/recursive.ts b/packages/test-server/src/routes/recursive.ts index 02ea939..b837bba 100644 --- a/packages/test-server/src/routes/recursive.ts +++ b/packages/test-server/src/routes/recursive.ts @@ -1,10 +1,13 @@ +import type { PortRef } from '../server.js'; import type { Hono } from 'hono'; /** * Registers routes for testing recursive link traversal and external-like host detection. * @param app - The Hono application instance to register routes on. + * @param portRef - Holder for the server's actual listening port, used to + * build the self-referencing "external" (127.0.0.1) URL below. */ -export function recursiveRoutes(app: Hono) { +export function recursiveRoutes(app: Hono, portRef: PortRef) { app.get('/recursive/', (c) => c.html( 'Recursive Top' + @@ -28,7 +31,7 @@ export function recursiveRoutes(app: Hono) { c.html( 'Page B' + 'Page C' + - 'External Like' + + `External Like` + '', ), ); diff --git a/packages/test-server/src/routes/redirect.ts b/packages/test-server/src/routes/redirect.ts index 72fbee3..9e3e3cd 100644 --- a/packages/test-server/src/routes/redirect.ts +++ b/packages/test-server/src/routes/redirect.ts @@ -1,10 +1,13 @@ +import type { PortRef } from '../server.js'; import type { Context, Hono } from 'hono'; /** * Registers routes for testing HTTP redirect chains (301 and 302). * @param app - The Hono application instance to register routes on. + * @param portRef - Holder for the server's actual listening port, used to + * build the self-referencing "external" (127.0.0.1) URLs below. */ -export function redirectRoutes(app: Hono) { +export function redirectRoutes(app: Hono, portRef: PortRef) { app.get('/redirect/', (c) => c.html( 'Redirect Top' + @@ -126,12 +129,12 @@ export function redirectRoutes(app: Hono) { app.get('/clobber/canonical', (c) => c.html( 'Clobber Canonical' + - 'External tracker link' + + `External tracker link` + '', ), ); app.on(['GET', 'HEAD'], '/clobber/ext', (c) => - c.redirect('http://localhost:8010/clobber/canonical', 301), + c.redirect(`http://localhost:${portRef.port}/clobber/canonical`, 301), ); } diff --git a/packages/test-server/src/routes/resource-reuse.ts b/packages/test-server/src/routes/resource-reuse.ts index e084c0c..3eda760 100644 --- a/packages/test-server/src/routes/resource-reuse.ts +++ b/packages/test-server/src/routes/resource-reuse.ts @@ -1,3 +1,4 @@ +import type { PortRef } from '../server.js'; import type { Context, Hono } from 'hono'; /** @@ -32,8 +33,10 @@ const observedRequests: ObservedRequest[] = []; * Each image endpoint records the method of every incoming request so that * tests can assert exactly which requests were made. * @param app - The Hono application instance to register routes on. + * @param portRef - Holder for the server's actual listening port, used to + * build the self-referencing "external" (127.0.0.1) URL below. */ -export function resourceReuseRoutes(app: Hono) { +export function resourceReuseRoutes(app: Hono, portRef: PortRef) { app.get('/resource-reuse/', (c) => c.html( 'Resource Reuse' + @@ -47,8 +50,8 @@ export function resourceReuseRoutes(app: Hono) { 'redirected image' + // External reuse case: 127.0.0.1 is a different hostname (= external // scope) but the same server — rendered AND directly linked - 'external' + - 'external image' + + `external` + + `external image` + '', ), ); diff --git a/packages/test-server/src/routes/scope-auth-leak.ts b/packages/test-server/src/routes/scope-auth-leak.ts index 49d8657..7d3bcfd 100644 --- a/packages/test-server/src/routes/scope-auth-leak.ts +++ b/packages/test-server/src/routes/scope-auth-leak.ts @@ -1,3 +1,4 @@ +import type { PortRef } from '../server.js'; import type { Hono } from 'hono'; /** @@ -36,8 +37,10 @@ const externalReceivedAuthHeaders: (string | null)[] = []; * the safe empty fallback `Basic Og==` (`":"`) or no header at all should * have reached the external endpoint. * @param app - The Hono application instance to register routes on. + * @param portRef - Holder for the server's actual listening port, used to + * build the self-referencing "external" (127.0.0.1) URL below. */ -export function scopeAuthLeakRoutes(app: Hono) { +export function scopeAuthLeakRoutes(app: Hono, portRef: PortRef) { const expectedScopeAuth = 'Basic ' + btoa('scope-user:scope-pass'); app.get('/scope-auth-leak/main', (c) => { @@ -58,7 +61,7 @@ export function scopeAuthLeakRoutes(app: Hono) { return c.html( 'Scope Auth Leak Main' + '

Scope main

' + - 'external' + + `external` + '', ); }); diff --git a/packages/test-server/src/server.spec.ts b/packages/test-server/src/server.spec.ts new file mode 100644 index 0000000..dc5655c --- /dev/null +++ b/packages/test-server/src/server.spec.ts @@ -0,0 +1,40 @@ +import type { Server } from 'node:http'; + +import { describe, expect, it } from 'vitest'; + +import { startServer } from './server.js'; + +/** + * @param server + */ +function closeServer(server: Server): Promise { + server.closeAllConnections(); + return new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); +} + +describe('startServer', () => { + it('assigns a real TCP port by default instead of a fixed one', async () => { + const server = await startServer(); + try { + const address = server.address(); + expect(address).not.toBeNull(); + expect(typeof address).not.toBe('string'); + expect((address as { port: number }).port).toBeGreaterThan(0); + } finally { + await closeServer(server); + } + }); + + it('lets two concurrent instances bind to different ports without EADDRINUSE (#162)', async () => { + const [serverA, serverB] = await Promise.all([startServer(), startServer()]); + try { + const portA = (serverA.address() as { port: number }).port; + const portB = (serverB.address() as { port: number }).port; + expect(portA).not.toBe(portB); + } finally { + await Promise.all([closeServer(serverA), closeServer(serverB)]); + } + }); +}); diff --git a/packages/test-server/src/server.ts b/packages/test-server/src/server.ts index a0e0684..04f8003 100644 --- a/packages/test-server/src/server.ts +++ b/packages/test-server/src/server.ts @@ -20,25 +20,40 @@ import { scopeAuthLeakRoutes } from './routes/scope-auth-leak.js'; import { scopeRoutes } from './routes/scope.js'; import { scrollJackRoutes } from './routes/scroll-jack.js'; +/** + * Mutable holder for the server's actual listening port. + * + * Routes that embed self-referencing "external" URLs are registered via + * `createApp` before `serve()` resolves the OS-assigned port, so they read + * `.port` lazily at request time instead of closing over a stale value + * captured at registration time. `startServer` only resolves its promise + * after setting the real port, so no request can arrive while it is still 0. + */ +export interface PortRef { + port: number; +} + /** * Creates and configures the Hono application with all E2E test routes. + * @param portRef - Holder for the server's actual listening port; passed to + * routes that embed self-referencing "external" URLs. * @returns The configured Hono application instance. */ -export function createApp() { +export function createApp(portRef: PortRef) { const app = new Hono(); basicRoutes(app); - recursiveRoutes(app); - redirectRoutes(app); - metaRoutes(app); - excludeRoutes(app); - optionsRoutes(app); + recursiveRoutes(app, portRef); + redirectRoutes(app, portRef); + metaRoutes(app, portRef); + excludeRoutes(app, portRef); + optionsRoutes(app, portRef); errorStatusRoutes(app); scopeRoutes(app); - scopeAuthLeakRoutes(app); + scopeAuthLeakRoutes(app, portRef); paginationRoutes(app); scrollJackRoutes(app); - resourceReuseRoutes(app); + resourceReuseRoutes(app, portRef); flakyRoutes(app); inventoryRoutes(app); jsRedirectRoutes(app); @@ -47,13 +62,20 @@ export function createApp() { } /** - * Starts the E2E test server on the specified port. - * @param port - The port number to listen on. - * @returns A promise that resolves with the HTTP server instance once it is listening. + * Starts the E2E test server. + * @param port - The port number to listen on. Defaults to `0`, letting the + * OS assign a free port — a fixed port made concurrent worktrees/sessions + * running this server collide with `EADDRINUSE` (#162). + * @returns A promise that resolves with the HTTP server instance once it is + * listening; the resolved server's `.address()` reports the actual port. */ -export function startServer(port = 8010): Promise { - const app = createApp(); +export function startServer(port = 0): Promise { + const portRef: PortRef = { port }; + const app = createApp(portRef); return new Promise((resolve) => { - const server = serve({ fetch: app.fetch, port }, () => resolve(server)); + const server = serve({ fetch: app.fetch, port }, (info) => { + portRef.port = info.port; + resolve(server); + }); }); }