diff --git a/packages/@d-zero/a11y-check-axe-scenario/package.json b/packages/@d-zero/a11y-check-axe-scenario/package.json index beda0caa..bf1a8e82 100644 --- a/packages/@d-zero/a11y-check-axe-scenario/package.json +++ b/packages/@d-zero/a11y-check-axe-scenario/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { @@ -30,11 +33,11 @@ }, "devDependencies": { "axe-core": "4.12.1", - "puppeteer": "25.3.0" + "puppeteer": "25.5.0" }, "peerDependencies": { "axe-core": "4.12.1", - "puppeteer": "25.2.1" + "puppeteer": "25.5.0" }, "repository": { "type": "git", diff --git a/packages/@d-zero/a11y-check-core/package.json b/packages/@d-zero/a11y-check-core/package.json index 3c914901..16f8aafc 100644 --- a/packages/@d-zero/a11y-check-core/package.json +++ b/packages/@d-zero/a11y-check-core/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { @@ -27,7 +30,7 @@ "@d-zero/shared": "0.22.5", "ansi-colors": "4.1.3", "color-contrast-checker": "2.1.0", - "puppeteer": "25.3.0" + "puppeteer": "25.5.0" }, "devDependencies": { "@d-zero/dealer": "1.10.4", diff --git a/packages/@d-zero/a11y-check-scenarios/package.json b/packages/@d-zero/a11y-check-scenarios/package.json index 777ef45f..2ad58cb9 100644 --- a/packages/@d-zero/a11y-check-scenarios/package.json +++ b/packages/@d-zero/a11y-check-scenarios/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { @@ -28,10 +31,10 @@ "ansi-colors": "4.1.3" }, "devDependencies": { - "puppeteer": "25.3.0" + "puppeteer": "25.5.0" }, "peerDependencies": { - "puppeteer": "25.2.1" + "puppeteer": "25.5.0" }, "repository": { "type": "git", diff --git a/packages/@d-zero/a11y-check-scenarios/src/scenario2.ts b/packages/@d-zero/a11y-check-scenarios/src/scenario2.ts index 8c3ee399..476bd8b8 100644 --- a/packages/@d-zero/a11y-check-scenarios/src/scenario2.ts +++ b/packages/@d-zero/a11y-check-scenarios/src/scenario2.ts @@ -1,8 +1,10 @@ import type { ScenarioOptions } from './types.js'; import type { NeedAnalysis } from '@d-zero/a11y-check-core'; +import type { ConsoleMessage } from 'puppeteer'; import { createScenario } from '@d-zero/a11y-check-core'; import { Cache } from '@d-zero/shared/cache'; +import { disposableListener } from '@d-zero/shared/disposable-listener'; import c from 'ansi-colors'; const scenarioId = 'a11y-check/scenario02'; @@ -41,19 +43,27 @@ export default createScenario((options?: ScenarioOptions) => { for (const selector of navigations) { const logBase = `Finding "${selector}"`; logger(`Finding "${selector}"`); - page.on('console', (msg) => { - const msgType = msg.type(); - switch (msgType) { - case 'error': { - logger(`${logBase}: ${c.red(msg.text())}`); - break; + // `using` により、各ループ反復の終わりで確実にリスナーが解除される。 + // ループ内で page.on('console', ...) するため、解除しないと + // selector の数だけリスナーが累積する。 + using _consoleListener = disposableListener( + page, + 'console', + (msg: ConsoleMessage) => { + const msgType = msg.type(); + switch (msgType) { + case 'error': { + logger(`${logBase}: ${c.red(msg.text())}`); + break; + } + default: { + logger(`${logBase}: ${c.gray(msg.text())}`); + break; + } } - default: { - logger(`${logBase}: ${c.gray(msg.text())}`); - break; - } - } - }); + }, + ); + void _consoleListener; const outerHTML = await page.evaluate((selector) => { return [...document.querySelectorAll(selector)].map((el) => el.outerHTML); }, selector); diff --git a/packages/@d-zero/a11y-check/package.json b/packages/@d-zero/a11y-check/package.json index 6b0f0af9..ef3e591b 100644 --- a/packages/@d-zero/a11y-check/package.json +++ b/packages/@d-zero/a11y-check/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { diff --git a/packages/@d-zero/a11y-check/src/spreadsheet.ts b/packages/@d-zero/a11y-check/src/spreadsheet.ts index 09b9a72e..3d3e5c7c 100644 --- a/packages/@d-zero/a11y-check/src/spreadsheet.ts +++ b/packages/@d-zero/a11y-check/src/spreadsheet.ts @@ -16,6 +16,23 @@ export class SpreadsheetReporter { // eslint-disable-next-line no-restricted-syntax private constructor() {} + /** + * `await using` 宣言のスコープ脱出時に呼ばれ、内部の {@link SheetTable} を + * フラッシュする。バッファに未送信行が残ったままスコープを抜けてデータが + * 欠損するのを防ぐ。 + * @example + * ```ts + * { + * await using reporter = await SpreadsheetReporter.setup(sheetUrl, sheetName); + * await reporter.report(violations); + * } // スコープ脱出時に自動で内部 SheetTable の未送信バッファが flush される + * ``` + */ + async [Symbol.asyncDispose]() { + if (this.#table) { + await this.#table[Symbol.asyncDispose](); + } + } async report(results: readonly Violation[]) { if (!this.#table) { throw new Error('Table is not created'); diff --git a/packages/@d-zero/anatomist/package.json b/packages/@d-zero/anatomist/package.json index de986839..450fa4be 100644 --- a/packages/@d-zero/anatomist/package.json +++ b/packages/@d-zero/anatomist/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { @@ -36,10 +39,11 @@ }, "dependencies": { "@d-zero/beholder": "4.2.2", + "@d-zero/cli-core": "1.3.16", "@d-zero/dealer": "1.10.4", "@d-zero/puppeteer-page-scan": "4.6.8", "@d-zero/shared": "0.22.5", - "puppeteer": "25.3.0" + "puppeteer": "25.5.0" }, "repository": { "type": "git", diff --git a/packages/@d-zero/anatomist/src/cli.spec.ts b/packages/@d-zero/anatomist/src/cli.spec.ts index 5d50b60e..91ae314c 100644 --- a/packages/@d-zero/anatomist/src/cli.spec.ts +++ b/packages/@d-zero/anatomist/src/cli.spec.ts @@ -215,6 +215,8 @@ describe('runCli', () => { return true; }), end: mockEnd, + // cli.ts の `await using outFile` が要求する Symbol.asyncDispose のスタブ + [Symbol.asyncDispose]: vi.fn(async () => {}), } as never); vi.mocked(runBatch).mockImplementation((_urls, options?: RunBatchOptions) => { options?.onResult?.({ @@ -249,6 +251,7 @@ describe('runCli', () => { end: vi.fn((callback: (error: Error) => void) => { callback(new Error('disk full')); }), + [Symbol.asyncDispose]: vi.fn(async () => {}), } as never); const stderr = new PassThrough(); diff --git a/packages/@d-zero/anatomist/src/cli.ts b/packages/@d-zero/anatomist/src/cli.ts index 15874a1b..0df0ee70 100644 --- a/packages/@d-zero/anatomist/src/cli.ts +++ b/packages/@d-zero/anatomist/src/cli.ts @@ -8,6 +8,8 @@ import { createWriteStream } from 'node:fs'; import { readFile } from 'node:fs/promises'; import process from 'node:process'; +import { unwrapSuppressedError } from '@d-zero/cli-core'; + import { formatResultLine } from './format-output.js'; import { parseArgs } from './parse-args.js'; import { parseUrlList } from './parse-url-list.js'; @@ -134,9 +136,12 @@ export async function runCli(options: { return 1; } - const outStream: NodeJS.WritableStream = args.out - ? createWriteStream(args.out) - : options.stdout; + // `await using` により、runBatch() が想定外の例外を投げてスコープを抜けても + // --out で開いたファイル記述子が確実に閉じられる(stdout の場合は outFile が + // undefined のままなので dispose は no-op — process.stdout を誤って + // close してしまうことはない)。 + await using outFile = args.out ? createWriteStream(args.out) : undefined; + const outStream: NodeJS.WritableStream = outFile ?? options.stdout; let hadError = false; await runBatch(urls, { @@ -158,24 +163,26 @@ export async function runCli(options: { }, onError: (url, error) => { hadError = true; - options.stderr.write( - `anatomist: failed to analyze ${url}: ${(error as Error).message}\n`, - ); + // SuppressedError(using スコープ内で本体と dispose の両方が例外を投げた + // 場合)を分解し、定型メッセージの裏に隠れる根本原因を両方とも出力する + for (const cause of unwrapSuppressedError(error)) { + options.stderr.write( + `anatomist: failed to analyze ${url}: ${cause instanceof Error ? cause.message : String(cause)}\n`, + ); + } }, }); - if (args.out) { + if (outFile) { try { await new Promise((resolve, reject) => { - (outStream as ReturnType).end( - (error?: Error | null) => { - if (error) { - reject(error); - } else { - resolve(); - } - }, - ); + outFile.end((error?: Error | null) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); }); } catch (error) { // Consistent with every other failure path here: report to stderr diff --git a/packages/@d-zero/anatomist/src/run-batch.spec.ts b/packages/@d-zero/anatomist/src/run-batch.spec.ts index 89294f0a..2cb1903c 100644 --- a/packages/@d-zero/anatomist/src/run-batch.spec.ts +++ b/packages/@d-zero/anatomist/src/run-batch.spec.ts @@ -45,15 +45,37 @@ function makeSequentialDealMock() { } describe('runBatch', () => { - let mockPage: { close: ReturnType }; - let mockBrowser: { newPage: ReturnType; close: ReturnType }; + let mockPage: { + close: ReturnType; + [Symbol.asyncDispose]: ReturnType; + }; + let mockBrowser: { + newPage: ReturnType; + close: ReturnType; + [Symbol.asyncDispose]: ReturnType; + }; beforeEach(() => { - mockPage = { close: vi.fn().mockResolvedValue() }; + // `run-batch.ts` now uses `await using`, which requires a real + // `Symbol.asyncDispose` implementation — delegate to the existing + // `close` mock so assertions on `close` call counts stay meaningful. + mockPage = { + close: vi.fn().mockResolvedValue(), + [Symbol.asyncDispose]: vi.fn(), + }; + mockPage[Symbol.asyncDispose].mockImplementation(async () => { + await mockPage.close(); + }); + mockBrowser = { newPage: vi.fn().mockResolvedValue(mockPage), close: vi.fn().mockResolvedValue(), + [Symbol.asyncDispose]: vi.fn(), }; + mockBrowser[Symbol.asyncDispose].mockImplementation(async () => { + await mockBrowser.close(); + }); + vi.mocked(launch).mockResolvedValue(mockBrowser as never); vi.mocked(deal).mockImplementation(makeSequentialDealMock() as never); vi.mocked(analyzePageLayout).mockReset().mockResolvedValue([]); diff --git a/packages/@d-zero/anatomist/src/run-batch.ts b/packages/@d-zero/anatomist/src/run-batch.ts index d96f5307..5469908b 100644 --- a/packages/@d-zero/anatomist/src/run-batch.ts +++ b/packages/@d-zero/anatomist/src/run-batch.ts @@ -43,41 +43,38 @@ export async function runBatch( urls: readonly string[], options: RunBatchOptions = {}, ): Promise { - const browser = await launch({ headless: true }); - try { - const items: UrlItem[] = urls.map((url, index) => ({ index, url })); + // `await using` により、deal() が例外を投げてもブラウザが確実に閉じられる。 + // puppeteer の Browser/Page は Symbol.asyncDispose をネイティブ実装している + // (型定義上の対応は puppeteer 25.5.0 以降)。 + await using browser = await launch({ headless: true }); + const items: UrlItem[] = urls.map((url, index) => ({ index, url })); - await deal( - items, - ({ url }, update) => { - return async () => { - update(`analyzing ${url}`); - const page = await browser.newPage(); - try { - const results = await analyzePageLayout(page, url, options); - for (const result of results) { - options.onResult?.(result); - } - } catch (error) { - options.onError?.(url, error); - } finally { - await page.close(); + await deal( + items, + ({ url }, update) => { + return async () => { + update(`analyzing ${url}`); + await using page = await browser.newPage(); + try { + const results = await analyzePageLayout(page, url, options); + for (const result of results) { + options.onResult?.(result); } - }; - }, - { - header: HEADER, - // Clamp to 1: `Dealer`'s worker loop (`while (this.#workers.size < - // this.#limit)`) never launches a worker when `limit` is `0`, and - // `?? 1` alone doesn't catch that — nullish coalescing only - // replaces `null`/`undefined`, not an explicit `0` such as - // `--concurrency 0` would parse to. Without this clamp, a `0` - // hangs the whole run with no output and no error. - limit: Math.max(1, options.concurrency ?? 1), - stream: options.stderr, - }, - ); - } finally { - await browser.close(); - } + } catch (error) { + options.onError?.(url, error); + } + }; + }, + { + header: HEADER, + // Clamp to 1: `Dealer`'s worker loop (`while (this.#workers.size < + // this.#limit)`) never launches a worker when `limit` is `0`, and + // `?? 1` alone doesn't catch that — nullish coalescing only + // replaces `null`/`undefined`, not an explicit `0` such as + // `--concurrency 0` would parse to. Without this clamp, a `0` + // hangs the whole run with no output and no error. + limit: Math.max(1, options.concurrency ?? 1), + stream: options.stderr, + }, + ); } diff --git a/packages/@d-zero/archaeologist/package.json b/packages/@d-zero/archaeologist/package.json index f4d20d2e..7dfbc0eb 100644 --- a/packages/@d-zero/archaeologist/package.json +++ b/packages/@d-zero/archaeologist/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { @@ -41,7 +44,7 @@ "parse-diff": "0.12.0", "pixelmatch": "7.2.0", "pngjs": "7.0.0", - "puppeteer": "25.3.0", + "puppeteer": "25.5.0", "strip-ansi": "7.2.0" }, "devDependencies": { diff --git a/packages/@d-zero/backlog-projects/package.json b/packages/@d-zero/backlog-projects/package.json index 586f69b2..42a72f76 100644 --- a/packages/@d-zero/backlog-projects/package.json +++ b/packages/@d-zero/backlog-projects/package.json @@ -8,7 +8,7 @@ "access": "public" }, "engines": { - "node": ">=22.1.0" + "node": ">=24.11.0" }, "type": "module", "bin": "dist/cli.js", diff --git a/packages/@d-zero/beholder/package.json b/packages/@d-zero/beholder/package.json index 7ce8ec73..44874aab 100644 --- a/packages/@d-zero/beholder/package.json +++ b/packages/@d-zero/beholder/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { @@ -23,7 +26,7 @@ "@d-zero/puppeteer-page-scan": "4.6.8", "@d-zero/shared": "0.22.5", "debug": "4.4.3", - "puppeteer": "25.3.0", + "puppeteer": "25.5.0", "simple-wappalyzer": "1.1.100" }, "devDependencies": { diff --git a/packages/@d-zero/beholder/src/dom-evaluation.spec.ts b/packages/@d-zero/beholder/src/dom-evaluation.spec.ts index b794e1bf..8dc933f4 100644 --- a/packages/@d-zero/beholder/src/dom-evaluation.spec.ts +++ b/packages/@d-zero/beholder/src/dom-evaluation.spec.ts @@ -213,6 +213,9 @@ function mockAnchorHandle( Promise.resolve({ jsonValue: () => Promise.resolve(props[propName] ?? ''), }), + // getAnchorList() が AsyncDisposableStack へ登録するため、実際の + // ElementHandle と同様 Symbol.asyncDispose を実装しておく必要がある + [Symbol.asyncDispose]: () => Promise.resolve(), } as unknown as ElementHandle; } @@ -291,6 +294,7 @@ describe('getAnchorList', () => { textContent(); return Promise.resolve({ jsonValue: () => Promise.resolve('text fallback') }); }, + [Symbol.asyncDispose]: () => Promise.resolve(), } as unknown as ElementHandle; const page = mockPageForAnchors({ anchors: [$anchor], @@ -329,6 +333,7 @@ describe('getAnchorList', () => { remoteObject: () => { throw new Error('Handle is detached'); }, + [Symbol.asyncDispose]: () => Promise.resolve(), } as unknown as ElementHandle; const $good = mockAnchorHandle('obj-1', { href: 'https://example.com/page' }); const page = mockPageForAnchors({ @@ -436,6 +441,7 @@ describe('getAnchorList', () => { const $slow = { remoteObject: () => ({ objectId: 'obj-slow' }), getProperty: () => new Promise(() => {}), // never resolves + [Symbol.asyncDispose]: () => Promise.resolve(), } as unknown as ElementHandle; const page = mockPageForAnchors({ anchors: [$fast, $slow], @@ -491,6 +497,41 @@ describe('getAnchorList', () => { expect(anchors).toStrictEqual([]); }); + + it('disposes every anchor handle after the list is collected (no CDP handle leak)', async () => { + const disposeA = vi.fn(async () => {}); + const disposeB = vi.fn(async () => {}); + const $a = { + remoteObject: () => ({ objectId: 'obj-a' }), + getProperty: () => + Promise.resolve({ jsonValue: () => Promise.resolve('https://example.com/a') }), + [Symbol.asyncDispose]: disposeA, + } as unknown as ElementHandle; + const $b = { + remoteObject: () => ({ objectId: 'obj-b' }), + getProperty: () => + Promise.resolve({ jsonValue: () => Promise.resolve('https://example.com/b') }), + [Symbol.asyncDispose]: disposeB, + } as unknown as ElementHandle; + const page = mockPageForAnchors({ + anchors: [$a, $b], + axNodes: [ + { backendDOMNodeId: 1, name: { value: 'A' } }, + { backendDOMNodeId: 2, name: { value: 'B' } }, + ], + describeNodes: { 'obj-a': 1, 'obj-b': 2 }, + }); + + const anchors = await getAnchorList(page); + expect(anchors).toHaveLength(2); + + // 解放は work() 完了後に detached な .finally() で行われるため、 + // getAnchorList の resolve と同期しない。1 tick 待ってから観測する + await new Promise((resolve) => setImmediate(resolve)); + + expect(disposeA).toHaveBeenCalledTimes(1); + expect(disposeB).toHaveBeenCalledTimes(1); + }); }); describe('DEFAULT_DOM_EVALUATION_TIMEOUT', () => { diff --git a/packages/@d-zero/beholder/src/dom-evaluation.ts b/packages/@d-zero/beholder/src/dom-evaluation.ts index ddbf8c78..39de4069 100644 --- a/packages/@d-zero/beholder/src/dom-evaluation.ts +++ b/packages/@d-zero/beholder/src/dom-evaluation.ts @@ -371,6 +371,17 @@ export async function getAnchorList( return []; } + // `$anchors` の各 ElementHandle は CDP 側のリモートオブジェクト参照を保持する + // ため、解放しないとページごとにハンドルがリークする。AsyncDisposableStack に + // まとめて登録して解放するが、`await using` でこの関数のスコープ脱出時に + // 即座に解放してはいけない — タイムアウトで打ち切られた後もバックグラウンドで + // resolveAnchor が動き続けている間にハンドルを解放すると、実行中の CDP 呼び出しが + // 失敗する。解放は work() の完了(成功・失敗いずれも)を待って初めて行う。 + const anchorHandles = new AsyncDisposableStack(); + for (const $anchor of $anchors) { + anchorHandles.use($anchor); + } + const collected: AnchorData[] = []; let axHits = 0; let textFallbacks = 0; @@ -410,7 +421,14 @@ export async function getAnchorList( ); }; - const { timeout: timedOut } = await raceWithTimeout(work, timeout); + const workPromise = work(); + // work() が真に完了した時点(タイムアウトで打ち切られた場合は、その後 + // バックグラウンドで走り続ける resolveAnchor がすべて解決した時点)で + // ハンドルを解放する。disposeAsync() 自体の失敗も無視してよい + // (ベストエフォートの後始末であり、呼び出し元への影響はない)。 + void workPromise.finally(() => anchorHandles.disposeAsync()).catch(() => {}); + + const { timeout: timedOut } = await raceWithTimeout(() => workPromise, timeout); cancelled = true; if (timedOut) { log( diff --git a/packages/@d-zero/beholder/src/to-console-log-entry.ts b/packages/@d-zero/beholder/src/to-console-log-entry.ts index 1f6ec759..4ab572e0 100644 --- a/packages/@d-zero/beholder/src/to-console-log-entry.ts +++ b/packages/@d-zero/beholder/src/to-console-log-entry.ts @@ -23,6 +23,10 @@ export async function toConsoleLogEntry( } catch { return; } finally { + // Why not `await using`: dispose() が reject すると SuppressedError に + // 包まれ、本来無視したいだけの dispose 失敗が呼び出し元へ伝播してしまう。 + // ここでは jsonValue() の結果を最優先し、dispose の失敗は握りつぶす + // 現状の挙動を維持する。 await arg.dispose().catch(() => {}); } }), diff --git a/packages/@d-zero/beholder/tsconfig.json b/packages/@d-zero/beholder/tsconfig.json index 53b17cd4..4a614f1e 100644 --- a/packages/@d-zero/beholder/tsconfig.json +++ b/packages/@d-zero/beholder/tsconfig.json @@ -3,7 +3,18 @@ "compilerOptions": { "composite": true, "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + // Why not the repo-wide `target: "ESNext"`: `scraper.ts`'s `@retryable` + // decorator wraps a private method (`#fetchData`), and esbuild's test + // transform (vitest) emits invalid output for that combination at + // target ESNext ("Private field '#fetchData' must be declared in an + // enclosing class"). `tsc` itself has no issue with it — only vitest's + // esbuild-based transform does — so this package alone stays on the + // `@d-zero/tsconfig` base target until that esbuild limitation is fixed. + // TODO: esbuild がデコレータ + private メソッドを target=esnext で正しく + // 変換できるようになったら、この override を撤去して root の ESNext + // (ネイティブ using / await using emit)に揃える。 + "target": "es2024" }, "references": [ { diff --git a/packages/@d-zero/cli-core/README.md b/packages/@d-zero/cli-core/README.md index 5c1e9389..c780c18a 100644 --- a/packages/@d-zero/cli-core/README.md +++ b/packages/@d-zero/cli-core/README.md @@ -31,3 +31,20 @@ const cli = createCLI({ ``` `-v`/`--version` の挙動・エイリアス衝突時のフォールバックは `src/cli.ts` の JSDoc を参照。 + +### エラー表示(`SuppressedError` の分解) + +`using`/`await using` のスコープ内で本体の例外と dispose 処理の例外が同時に発生すると、`SuppressedError` が投げられ定型メッセージの裏に根本原因が隠れる。`unwrapSuppressedError` で分解してから表示する: + +```ts +import { unwrapSuppressedError } from '@d-zero/cli-core'; + +try { + await run(); +} catch (error) { + for (const cause of unwrapSuppressedError(error)) { + console.error('Error:', cause instanceof Error ? cause.message : cause); + } + process.exit(1); +} +``` diff --git a/packages/@d-zero/cli-core/package.json b/packages/@d-zero/cli-core/package.json index a9329116..455fcae3 100644 --- a/packages/@d-zero/cli-core/package.json +++ b/packages/@d-zero/cli-core/package.json @@ -4,6 +4,9 @@ "description": "Common CLI utilities for D-Zero tools", "author": "D-ZERO", "license": "MIT", + "engines": { + "node": ">=24.11.0" + }, "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/@d-zero/cli-core/src/index.ts b/packages/@d-zero/cli-core/src/index.ts index 8ef44ea3..13d7485e 100644 --- a/packages/@d-zero/cli-core/src/index.ts +++ b/packages/@d-zero/cli-core/src/index.ts @@ -1,3 +1,4 @@ export { createCLI, parseCommonOptions } from './cli.js'; export { parseList } from './parse-list.js'; export type { BaseCLIOptions, CLIAlias, CLIConfig, ParsedCLI } from './types.js'; +export { unwrapSuppressedError } from './unwrap-suppressed-error.js'; diff --git a/packages/@d-zero/cli-core/src/unwrap-suppressed-error.spec.ts b/packages/@d-zero/cli-core/src/unwrap-suppressed-error.spec.ts new file mode 100644 index 00000000..32581fc7 --- /dev/null +++ b/packages/@d-zero/cli-core/src/unwrap-suppressed-error.spec.ts @@ -0,0 +1,48 @@ +import { describe, test, expect } from 'vitest'; + +import { unwrapSuppressedError } from './unwrap-suppressed-error.js'; + +describe('unwrapSuppressedError', () => { + test('returns a non-SuppressedError as a single-element array', () => { + const error = new Error('plain'); + + expect(unwrapSuppressedError(error)).toEqual([error]); + }); + + test('returns non-Error values (string, null) untouched', () => { + expect(unwrapSuppressedError('string error')).toEqual(['string error']); + expect(unwrapSuppressedError(null)).toEqual([null]); + }); + + test('flattens a SuppressedError into [error, suppressed]', () => { + const body = new Error('body failure'); + const dispose = new Error('dispose failure'); + const suppressed = new SuppressedError( + dispose, + body, + 'An error was suppressed during disposal.', + ); + + // SuppressedError(error, suppressed): `error` が後発(dispose 側)、 + // `suppressed` が先発(本体側) + expect(unwrapSuppressedError(suppressed)).toEqual([dispose, body]); + }); + + test('recursively flattens nested SuppressedError (multiple disposals failing)', () => { + const body = new Error('body failure'); + const dispose1 = new Error('dispose failure 1'); + const dispose2 = new Error('dispose failure 2'); + const inner = new SuppressedError( + dispose1, + body, + 'An error was suppressed during disposal.', + ); + const outer = new SuppressedError( + dispose2, + inner, + 'An error was suppressed during disposal.', + ); + + expect(unwrapSuppressedError(outer)).toEqual([dispose2, dispose1, body]); + }); +}); diff --git a/packages/@d-zero/cli-core/src/unwrap-suppressed-error.ts b/packages/@d-zero/cli-core/src/unwrap-suppressed-error.ts new file mode 100644 index 00000000..31467f54 --- /dev/null +++ b/packages/@d-zero/cli-core/src/unwrap-suppressed-error.ts @@ -0,0 +1,31 @@ +/** + * `SuppressedError`(`using` / `await using` のスコープ内で本体の例外と + * dispose 処理の例外が同時に発生した際に投げられる)を再帰的に分解し、 + * すべての根本原因を配列で返す。 + * + * `SuppressedError.message` は定型文(例: "An error was suppressed during + * disposal.")のみで本体側の実際のエラー内容を隠してしまうため、 + * CLI のエラー表示ではこの関数で分解してから出力する。 + * @param error - 捕捉した例外(`SuppressedError` かどうかは問わない) + * @returns 根本原因のエラーを列挙した配列(`SuppressedError` でなければ `[error]` を返す) + * @example + * ```ts + * try { + * await run(); + * } catch (error) { + * for (const cause of unwrapSuppressedError(error)) { + * console.error('Error:', cause instanceof Error ? cause.message : cause); + * } + * } + * ``` + */ +export function unwrapSuppressedError(error: unknown): unknown[] { + if (error instanceof SuppressedError) { + return [ + ...unwrapSuppressedError(error.error), + ...unwrapSuppressedError(error.suppressed), + ]; + } + + return [error]; +} diff --git a/packages/@d-zero/dealer/README.md b/packages/@d-zero/dealer/README.md index ca3a7868..84ad6f8d 100644 --- a/packages/@d-zero/dealer/README.md +++ b/packages/@d-zero/dealer/README.md @@ -42,6 +42,6 @@ abort 時の挙動: **新規ワーカー起動を停止、実行中ワーカー - **`interval` 遅延はアイテム開始の「直後・最初の出力前」**に実行される(順序に注意) - **`unshift` は既存キューの先頭に割り込む**(優先度の高い動的追加用、push との順序を理解する必要あり) -- **`verbose` モードでは `close()` でリスナーを明示解放**する必要あり(leak 防止) +- **`Lanes` / `Display` を直接使う場合は `using` 宣言(`Symbol.dispose`)で自動解放**する(leak 防止)。スコープと解放タイミングが一致しない場合のみ `close()` を直接呼ぶ(`close()` は deprecated) これらの背景と実装は `src/deal.ts` / `src/dealer.ts` / `src/lanes.ts` の JSDoc を参照。 diff --git a/packages/@d-zero/dealer/package.json b/packages/@d-zero/dealer/package.json index e6a1e9f5..755120c6 100644 --- a/packages/@d-zero/dealer/package.json +++ b/packages/@d-zero/dealer/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { diff --git a/packages/@d-zero/dealer/src/deal.spec.ts b/packages/@d-zero/dealer/src/deal.spec.ts index 91fd9135..c997dcb9 100644 --- a/packages/@d-zero/dealer/src/deal.spec.ts +++ b/packages/@d-zero/dealer/src/deal.spec.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from 'vitest'; +import { describe, test, expect, vi } from 'vitest'; import { deal } from './deal.js'; @@ -124,4 +124,32 @@ describe('deal', () => { // interval 待機を挟んでも先頭割り込みの順序は維持される expect(order).toEqual([0, 2, 1]); }); + + test('setup callback throwing still releases lanes (no leaked SIGINT/resize listeners)', async () => { + // verbose: false(デフォルト)でないと Display は SIGINT ハンドラを登録しないため、 + // このテストは非 verbose モードで実行し、`using lanes` が例外時にも + // 解放されることを直接観測する + const stdoutWriteSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const resizeBefore = process.stdout.listenerCount('resize'); + const sigintBefore = process.listenerCount('SIGINT'); + + const items = createItems(1); + + await expect( + deal( + items, + () => { + throw new Error('boom'); + }, + { limit: 1 }, + ), + ).rejects.toThrow('boom'); + + expect(process.stdout.listenerCount('resize')).toBe(resizeBefore); + expect(process.listenerCount('SIGINT')).toBe(sigintBefore); + + stdoutWriteSpy.mockRestore(); + }); }); diff --git a/packages/@d-zero/dealer/src/deal.ts b/packages/@d-zero/dealer/src/deal.ts index e61437a9..6ee54287 100644 --- a/packages/@d-zero/dealer/src/deal.ts +++ b/packages/@d-zero/dealer/src/deal.ts @@ -96,7 +96,9 @@ export async function deal( options?: DealOptions, ) { const dealer = new Dealer(items, options); - const lanes = new Lanes(options); + // `using` により、setup() が例外を投げてもスコープ脱出時に必ず + // lanes(内部の Display)のタイマー・resize リスナー・SIGINT ハンドラが解放される。 + using lanes = new Lanes(options); if (options?.header) { dealer.progress((progress, done, total, limit) => { @@ -130,12 +132,11 @@ export async function deal( }); } - return new Promise((resolve) => { - dealer.finish(() => { - lanes.close(); - resolve(); - }); - - dealer.play(); - }); + // `return new Promise(...)` にすると `using` の dispose が deal() の呼び出し元へ + // 返す Promise の解決前に走らない(dispose はこの関数のスコープを抜けるときに + // 実行される必要がある)ため、ここは `await` で完了を待ってからスコープを抜ける。 + const { promise, resolve } = Promise.withResolvers(); + dealer.finish(resolve); + dealer.play(); + await promise; } diff --git a/packages/@d-zero/dealer/src/display.spec.ts b/packages/@d-zero/dealer/src/display.spec.ts index 1951268d..e650615b 100644 --- a/packages/@d-zero/dealer/src/display.spec.ts +++ b/packages/@d-zero/dealer/src/display.spec.ts @@ -1,5 +1,6 @@ import { Writable } from 'node:stream'; +import { disposableListener } from '@d-zero/shared/disposable-listener'; import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import { Display } from './display.js'; @@ -71,24 +72,21 @@ describe('Display listener lifecycle', () => { const resizeBefore = process.stdout.listenerCount('resize'); const warnings: Error[] = []; const captureWarning = (warning: Error) => warnings.push(warning); - process.on('warning', captureWarning); + using _warningListener = disposableListener(process, 'warning', captureWarning); + void _warningListener; - try { - // More cycles than the default MaxListeners limit (10) — this leaked - // before the fix and triggered MaxListenersExceededWarning - for (let i = 0; i < 20; i++) { - const display = new Display({ verbose: true }); - display.close(); - } - // process.emitWarning is dispatched via process.nextTick. - // Without yielding the event loop here, captureWarning would - // never have run before the assertion below — making this test - // silently pass even when a regression emits the warning - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - } finally { - process.off('warning', captureWarning); + // More cycles than the default MaxListeners limit (10) — this leaked + // before the fix and triggered MaxListenersExceededWarning + for (let i = 0; i < 20; i++) { + const display = new Display({ verbose: true }); + display.close(); } + // process.emitWarning is dispatched via process.nextTick. + // Without yielding the event loop here, captureWarning would + // never have run before the assertion below — making this test + // silently pass even when a regression emits the warning + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); expect(process.stdout.listenerCount('resize')).toBe(resizeBefore); // Catches the second-tier leak: a regression that keeps listenerCount @@ -108,6 +106,31 @@ describe('Display listener lifecycle', () => { expect(process.stdout.listenerCount('resize')).toBe(resizeBefore); }); + + test('using releases the resize and SIGINT listeners on scope exit', () => { + const resizeBefore = process.stdout.listenerCount('resize'); + const sigintBefore = process.listenerCount('SIGINT'); + + { + using display = new Display(); + expect(display).toBeInstanceOf(Display); + expect(process.stdout.listenerCount('resize')).toBe(resizeBefore + 1); + expect(process.listeners('SIGINT').length).toBe(sigintBefore + 1); + } + + expect(process.stdout.listenerCount('resize')).toBe(resizeBefore); + expect(process.listeners('SIGINT').length).toBe(sigintBefore); + }); + + test('[Symbol.dispose] and close() both delegate to the same release logic (idempotent together)', () => { + const resizeBefore = process.stdout.listenerCount('resize'); + + const display = new Display(); + display[Symbol.dispose](); + display.close(); + + expect(process.stdout.listenerCount('resize')).toBe(resizeBefore); + }); }); describe('Display.close() finalization', () => { diff --git a/packages/@d-zero/dealer/src/display.ts b/packages/@d-zero/dealer/src/display.ts index bd4017be..cc0efc57 100644 --- a/packages/@d-zero/dealer/src/display.ts +++ b/packages/@d-zero/dealer/src/display.ts @@ -64,14 +64,62 @@ export class Display { if (!this.#verbose) { this.#sigintHandler = () => { - this.close(); + this.#close(); process.exit(130); }; process.on('SIGINT', this.#sigintHandler); } } + /** + * `using` 宣言のスコープ脱出時に呼ばれ、{@link Display.close} と同じ解放処理を行う。 + * @example + * ```ts + * { + * using display = new Display(); + * display.write('processing...'); + * } // スコープ脱出時に自動でタイマー・リスナーが解放される + * ``` + */ + [Symbol.dispose]() { + this.#close(); + } + /** + * ディスプレイを閉じ、タイマー・resize リスナー・SIGINT ハンドラを解放する。 + * 複数回呼び出しても安全(冪等)。 + * @deprecated `using` 宣言(`Symbol.dispose`)による自動解放を使用すること。 + * スコープと解放タイミングが一致しない場合のみ直接呼び出す。 + */ close() { + this.#close(); + } + + verboseMode() { + this.#verbose = true; + } + write(...logs: string[]) { + // After close() the lifecycle is finished: timers and signal listeners + // have been released, so a late write() must not re-arm setTimeout (the + // very leak close() exists to stop) or print past a "finalized" frame + if (this.#closed) { + return; + } + + if (this.#verbose) { + for (const log of logs) { + this.#stream.write(this.#text(log, false) + '\n'); + } + return; + } + + this.#stack = [...this.#debugMessages, ...logs]; + if (this.#timer) { + return; + } + + this.#enterFrame(); + } + #close() { if (this.#closed) { return; } @@ -103,33 +151,6 @@ export class Display { this.#stack = null; } - verboseMode() { - this.#verbose = true; - } - - write(...logs: string[]) { - // After close() the lifecycle is finished: timers and signal listeners - // have been released, so a late write() must not re-arm setTimeout (the - // very leak close() exists to stop) or print past a "finalized" frame - if (this.#closed) { - return; - } - - if (this.#verbose) { - for (const log of logs) { - this.#stream.write(this.#text(log, false) + '\n'); - } - return; - } - - this.#stack = [...this.#debugMessages, ...logs]; - if (this.#timer) { - return; - } - - this.#enterFrame(); - } - #countDown(text: string) { const parsed = countDownFunctionParser(text); diff --git a/packages/@d-zero/dealer/src/lanes.spec.ts b/packages/@d-zero/dealer/src/lanes.spec.ts new file mode 100644 index 00000000..c43629fd --- /dev/null +++ b/packages/@d-zero/dealer/src/lanes.spec.ts @@ -0,0 +1,47 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { Lanes } from './lanes.js'; + +let stdoutWriteSpy: ReturnType; + +beforeEach(() => { + stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); +}); + +afterEach(() => { + stdoutWriteSpy.mockRestore(); +}); + +describe('Lanes dispose', () => { + test('close() releases the underlying Display resize listener', () => { + const resizeBefore = process.stdout.listenerCount('resize'); + + const lanes = new Lanes(); + expect(process.stdout.listenerCount('resize')).toBe(resizeBefore + 1); + + lanes.close(); + expect(process.stdout.listenerCount('resize')).toBe(resizeBefore); + }); + + test('using releases the underlying Display resize listener on scope exit', () => { + const resizeBefore = process.stdout.listenerCount('resize'); + + { + using lanes = new Lanes(); + expect(lanes).toBeInstanceOf(Lanes); + expect(process.stdout.listenerCount('resize')).toBe(resizeBefore + 1); + } + + expect(process.stdout.listenerCount('resize')).toBe(resizeBefore); + }); + + test('[Symbol.dispose] and close() both delegate to the same release logic (idempotent together)', () => { + const resizeBefore = process.stdout.listenerCount('resize'); + + const lanes = new Lanes(); + lanes[Symbol.dispose](); + lanes.close(); + + expect(process.stdout.listenerCount('resize')).toBe(resizeBefore); + }); +}); diff --git a/packages/@d-zero/dealer/src/lanes.ts b/packages/@d-zero/dealer/src/lanes.ts index 07fe9b50..e1525219 100644 --- a/packages/@d-zero/dealer/src/lanes.ts +++ b/packages/@d-zero/dealer/src/lanes.ts @@ -48,6 +48,19 @@ export class Lanes { this.#verbose = options?.verbose ?? false; } + /** + * `using` 宣言のスコープ脱出時に呼ばれ、{@link Lanes.close} と同じ解放処理を行う。 + * @example + * ```ts + * { + * using lanes = new Lanes(); + * lanes.update(0, 'processing...'); + * } // スコープ脱出時に自動でディスプレイが閉じられる + * ``` + */ + [Symbol.dispose]() { + this.#close(); + } /** * すべてのログをクリアする。verbose モードでは何もしない。 * @param options - クリアオプション @@ -67,10 +80,12 @@ export class Lanes { this.write(); } /** - * ディスプレイを閉じ、リソースを解放する。 + * ディスプレイを閉じ、リソースを解放する。複数回呼び出しても安全(冪等)。 + * @deprecated `using` 宣言(`Symbol.dispose`)による自動解放を使用すること。 + * スコープと解放タイミングが一致しない場合のみ直接呼び出す。 */ close() { - this.#display.close(); + this.#close(); } /** @@ -85,7 +100,6 @@ export class Lanes { this.#logs.delete(id); this.write(); } - /** * ヘッダーテキストを設定する。 * @param text - ヘッダーとして表示する文字列 @@ -99,7 +113,6 @@ export class Lanes { this.write(); } - /** * 指定した ID のログを更新する。 * verbose モードではヘッダーとログを連結して即時出力する。 @@ -115,7 +128,6 @@ export class Lanes { this.#logs.set(id, log); this.write(); } - /** * 現在のログをソートしてターミナルに表示する。 * verbose モードでは何もしない。 @@ -135,4 +147,7 @@ export class Lanes { } this.#display.write(...messages); } + #close() { + this.#display[Symbol.dispose](); + } } diff --git a/packages/@d-zero/filematch/package.json b/packages/@d-zero/filematch/package.json index c7c5ec7b..aa77caa1 100644 --- a/packages/@d-zero/filematch/package.json +++ b/packages/@d-zero/filematch/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/@d-zero/filematch/src/compare-files.ts b/packages/@d-zero/filematch/src/compare-files.ts index 5603ec79..b84c0f4a 100644 --- a/packages/@d-zero/filematch/src/compare-files.ts +++ b/packages/@d-zero/filematch/src/compare-files.ts @@ -25,12 +25,22 @@ export async function compareFiles( return false; } + // Why not `await using`: Node.js の stream の `Symbol.asyncDispose` は + // stream が既に error で終了している場合、dispose 時にその error を再度 + // reject するため、compareStreams() の失敗が `SuppressedError`(message は + // 空文字列)に包まれて原因が呼び出し元から見えなくなる。try/finally + + // `destroy()`(同期・冪等・throw しない)で同等の解放保証を得る。 const stream1 = createReadStream(filePath1); const stream2 = createReadStream(filePath2); - return compareStreams( - stream1, - stream2, - onProgress && ((byte) => onProgress(byte / size1)), - ); + try { + return await compareStreams( + stream1, + stream2, + onProgress && ((byte) => onProgress(byte / size1)), + ); + } finally { + stream1.destroy(); + stream2.destroy(); + } } diff --git a/packages/@d-zero/filematch/src/compare-streams.spec.ts b/packages/@d-zero/filematch/src/compare-streams.spec.ts new file mode 100644 index 00000000..488d8b28 --- /dev/null +++ b/packages/@d-zero/filematch/src/compare-streams.spec.ts @@ -0,0 +1,47 @@ +import { createReadStream } from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { describe, test, expect, beforeAll, afterAll } from 'vitest'; + +import { compareStreams } from './compare-streams.js'; + +let dir: string; + +beforeAll(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'filematch-spec-')); + await writeFile(path.join(dir, 'a.txt'), 'same content'); + await writeFile(path.join(dir, 'b.txt'), 'same content'); + await writeFile(path.join(dir, 'c.txt'), 'diff content'); +}); + +afterAll(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('compareStreams', () => { + test('resolves true for streams with identical contents', async () => { + const stream1 = createReadStream(path.join(dir, 'a.txt')); + const stream2 = createReadStream(path.join(dir, 'b.txt')); + + await expect(compareStreams(stream1, stream2)).resolves.toBe(true); + }); + + test('resolves false for streams with different contents', async () => { + const stream1 = createReadStream(path.join(dir, 'a.txt')); + const stream2 = createReadStream(path.join(dir, 'c.txt')); + + await expect(compareStreams(stream1, stream2)).resolves.toBe(false); + }); + + test('rejects when a stream errors, destroying BOTH streams (the healthy stream must not stay open)', async () => { + const broken = createReadStream(path.join(dir, 'no-such-file.txt')); + const healthy = createReadStream(path.join(dir, 'a.txt')); + + await expect(compareStreams(broken, healthy)).rejects.toThrow(/ENOENT/); + + expect(broken.destroyed).toBe(true); + expect(healthy.destroyed).toBe(true); + }); +}); diff --git a/packages/@d-zero/filematch/src/compare-streams.ts b/packages/@d-zero/filematch/src/compare-streams.ts index 9777ea60..34ca7c47 100644 --- a/packages/@d-zero/filematch/src/compare-streams.ts +++ b/packages/@d-zero/filematch/src/compare-streams.ts @@ -82,9 +82,20 @@ export async function compareStreams( resolve(false); } + /** + * @param error + */ + function handleError(error: unknown) { + // destroy() せずに reject だけすると、エラーを出さなかった側の + // ストリームが開いたまま残る + stream1.destroy(); + stream2.destroy(); + reject(error); + } + stream1.on('end', handleEnd); stream2.on('end', handleEnd); - stream1.on('error', reject); - stream2.on('error', reject); + stream1.on('error', handleError); + stream2.on('error', handleError); }); } diff --git a/packages/@d-zero/filematch/src/url-to-file-while-download.ts b/packages/@d-zero/filematch/src/url-to-file-while-download.ts index 7c39d0a5..8b08b5ef 100644 --- a/packages/@d-zero/filematch/src/url-to-file-while-download.ts +++ b/packages/@d-zero/filematch/src/url-to-file-while-download.ts @@ -15,6 +15,9 @@ export async function urlToFileWhileDownload(urlOrFilePath: string) { return urlOrFilePath; } + // Why not `using`/`mkdtempDisposable`: 戻り値の tempFile はこの関数の + // スコープを抜けた後も呼び出し元がファイルとして参照し続けるため、 + // スコープ脱出と同時にディレクトリを削除するわけにはいかない const tempDir = await mkdtemp(path.join(tmpdir(), 'filematch-')); const tempFile = path.join(tempDir, path.basename(urlOrFilePath)); diff --git a/packages/@d-zero/fs/package.json b/packages/@d-zero/fs/package.json index 9d391790..c643a93e 100644 --- a/packages/@d-zero/fs/package.json +++ b/packages/@d-zero/fs/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { "./zip": { diff --git a/packages/@d-zero/fs/src/zip.spec.ts b/packages/@d-zero/fs/src/zip.spec.ts new file mode 100644 index 00000000..d99943b5 --- /dev/null +++ b/packages/@d-zero/fs/src/zip.spec.ts @@ -0,0 +1,47 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { describe, test, expect, beforeAll, afterAll } from 'vitest'; + +import { unzip, zip } from './zip.js'; + +let dir: string; + +beforeAll(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'fs-zip-spec-')); + await mkdir(path.join(dir, 'src', 'nested'), { recursive: true }); + await writeFile(path.join(dir, 'src', 'hello.txt'), 'hello zip'); + await writeFile(path.join(dir, 'src', 'nested', 'deep.txt'), 'nested content'); +}); + +afterAll(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('zip / unzip', () => { + test('round-trips a directory: zipped archive extracts to identical file contents', async () => { + const zipPath = path.join(dir, 'out.zip'); + const extractDir = path.join(dir, 'extracted'); + + await zip(zipPath, path.join(dir, 'src')); + await unzip(zipPath, extractDir); + + expect(await readFile(path.join(extractDir, 'hello.txt'), 'utf8')).toBe('hello zip'); + expect(await readFile(path.join(extractDir, 'nested', 'deep.txt'), 'utf8')).toBe( + 'nested content', + ); + }); + + test('zip() rejects (instead of crashing on an unhandled error event) when the output path is not writable', async () => { + await expect( + zip(path.join(dir, 'no-such-dir', 'out.zip'), path.join(dir, 'src')), + ).rejects.toThrow('Failed to save file'); + }); + + test('unzip() rejects (instead of crashing on an unhandled error event) when the zip file does not exist', async () => { + await expect( + unzip(path.join(dir, 'no-such.zip'), path.join(dir, 'extracted2')), + ).rejects.toThrow(/ENOENT/); + }); +}); diff --git a/packages/@d-zero/fs/src/zip.ts b/packages/@d-zero/fs/src/zip.ts index 38c08245..03571358 100644 --- a/packages/@d-zero/fs/src/zip.ts +++ b/packages/@d-zero/fs/src/zip.ts @@ -4,47 +4,91 @@ import { ZipArchive } from 'archiver'; import unzipper from 'unzipper'; /** + * 指定ディレクトリの内容を zip アーカイブとして書き出す。 * - * @param outputfilePath - * @param targetDir + * Why not `await using output`: Node.js の stream の `Symbol.asyncDispose` は + * stream が既に error で終了している場合、dispose 時にその error を再度 reject + * するため、失敗パスの reject 理由が `SuppressedError`(message は空文字列)に + * 包まれて本来の原因が呼び出し元から見えなくなる。try/finally + `destroy()` + * (同期・冪等・throw しない)で同等の解放保証を得る。 + * @param outputfilePath - 出力する zip ファイルのパス + * @param targetDir - アーカイブ対象のディレクトリ + * @example + * ```ts + * await zip('/path/to/output.zip', '/path/to/dir'); + * ``` */ export async function zip(outputfilePath: string, targetDir: string) { const output = fs.createWriteStream(outputfilePath); - const archive = new ZipArchive(); + try { + const archive = new ZipArchive(); + archive.pipe(output); + archive.directory(targetDir, false); - archive.pipe(output); - archive.directory(targetDir, false); - await archive.finalize(); + // 'error' リスナーは finalize() の await より前に登録する。出力先が + // 開けない場合の error は finalize() 待機中に発火するため、登録が + // 後だと unhandled 'error' イベントとしてプロセスごとクラッシュする + const written = new Promise((resolve, reject) => { + output.on('finish', () => resolve()); + output.on('error', () => + reject(new Error(`Failed to save file "${outputfilePath}" from "${targetDir}"`)), + ); + }); - return new Promise((resolve, reject) => { - output.on('finish', () => resolve()); - output.on('error', () => - reject(`Failed to save file "${outputfilePath}" from "${targetDir}"`), - ); - }); + // Promise.all で両方を観測する(片方だけ await すると、もう片方の + // 失敗が unhandled rejection になる)。出力先エラー時は 'error' + // イベント(written 側)が finalize の失敗より先に確定する + await Promise.all([archive.finalize(), written]); + } finally { + output.destroy(); + } } /** + * zip アーカイブを指定ディレクトリへ展開する。 * - * @param zipFilePath - * @param targetDir + * Why not `await using input`: {@link zip} と同じく、error で終了した stream の + * `Symbol.asyncDispose` が原因の error を `SuppressedError` で二重包装するため。 + * @param zipFilePath - 展開する zip ファイルのパス + * @param targetDir - 展開先ディレクトリ + * @example + * ```ts + * await unzip('/path/to/archive.zip', '/path/to/dest'); + * ``` */ export async function unzip(zipFilePath: string, targetDir: string) { - const extract = fs.createReadStream(zipFilePath).pipe( - unzipper.Extract({ - path: targetDir, - }), - ); + const input = fs.createReadStream(zipFilePath); + try { + const extract = input.pipe( + unzipper.Extract({ + path: targetDir, + }), + ); - return new Promise((resolve, reject) => { - extract.on('finish', () => resolve()); - extract.on('error', (err) => reject(err)); - }); + await new Promise((resolve, reject) => { + // `.pipe()` は source のエラーを destination へ転送しないため、 + // input(zip ファイルが存在しない等)のエラーもここで捕捉しないと + // unhandled 'error' イベントとしてプロセスごとクラッシュする + input.on('error', reject); + // unzipper の Extract は「全ファイルの書き込み完了」を 'close' で通知する。 + // 'finish' は入力(zip の読み取り)を消費し終えた時点で発火するため、 + // 'finish' で resolve すると展開途中のファイルが残ったまま完了扱いになる + extract.on('close', () => resolve()); + extract.on('error', (err) => reject(err)); + }); + } finally { + input.destroy(); + } } /** - * - * @param zipFilePath + * zip アーカイブを展開せずに開き、エントリ一覧へアクセスできるオブジェクトを返す。 + * @param zipFilePath - 開く zip ファイルのパス + * @example + * ```ts + * const directory = await extractZip('/path/to/archive.zip'); + * console.log(directory.files.map((file) => file.path)); + * ``` */ export async function extractZip(zipFilePath: string) { const directory = await unzipper.Open.file(zipFilePath); diff --git a/packages/@d-zero/google-auth/package.json b/packages/@d-zero/google-auth/package.json index 203ec581..415ef6ac 100644 --- a/packages/@d-zero/google-auth/package.json +++ b/packages/@d-zero/google-auth/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { diff --git a/packages/@d-zero/google-auth/src/authentication.ts b/packages/@d-zero/google-auth/src/authentication.ts index 2da01da8..584e533a 100644 --- a/packages/@d-zero/google-auth/src/authentication.ts +++ b/packages/@d-zero/google-auth/src/authentication.ts @@ -302,6 +302,12 @@ function waitForAuthCode( return new Promise((resolve, reject) => { let redirectUri = ''; + // server.close() / clearTimeout の呼び出しが 4 箇所・3 箇所に分散していたのを + // AsyncDisposableStack に一元化する。404 パス(コールバック待機の継続)だけは + // 意図的に解放しない — それ以外の 3 経路(成功・エラー・タイムアウト・listen 失敗) + // はすべてここで登録した解放処理を通る。 + const stack = new AsyncDisposableStack(); + const server = http.createServer((req, res) => { const reqUrl = new URL(req.url ?? '/', redirectUri); const code = reqUrl.searchParams.get('code'); @@ -313,8 +319,7 @@ function waitForAuthCode( Connection: 'close', }); res.end(authResultHtml(false, error)); - clearTimeout(timeoutId); - server.close(); + void stack.disposeAsync(); reject(new Error(`Authentication error: ${error}`)); return; } @@ -325,27 +330,35 @@ function waitForAuthCode( Connection: 'close', }); res.end(authResultHtml(true)); - clearTimeout(timeoutId); - server.close(); + void stack.disposeAsync(); resolve({ code, redirectUri }); return; } + // Why not: 認証コールバックの待機を継続する必要があるパスのため、 + // ここでは stack を解放しない res.writeHead(404, { Connection: 'close' }); res.end(); }); - const timeoutId = setTimeout(() => { + // closeAllConnections() を併用しないと、keep-alive 接続が残っている間 + // server.close() のコールバックが発火せず dispose が完了しない + stack.defer(() => { + server.closeAllConnections(); server.close(); + }); + + const timeoutId = setTimeout(() => { + void stack.disposeAsync(); reject(new Error('Authentication timed out (5 minutes)')); }, AUTH_TIMEOUT_MS); timeoutId.unref(); + stack.use(timeoutId); server.listen(0, () => { const address = server.address(); if (!address || typeof address === 'string') { - clearTimeout(timeoutId); - server.close(); + void stack.disposeAsync(); reject(new Error('Failed to start local server')); return; } diff --git a/packages/@d-zero/google-sheets/package.json b/packages/@d-zero/google-sheets/package.json index 52c8c05e..c21a5e96 100644 --- a/packages/@d-zero/google-sheets/package.json +++ b/packages/@d-zero/google-sheets/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { diff --git a/packages/@d-zero/google-sheets/src/sheet-table.ts b/packages/@d-zero/google-sheets/src/sheet-table.ts index 41a808cf..406fcf0b 100644 --- a/packages/@d-zero/google-sheets/src/sheet-table.ts +++ b/packages/@d-zero/google-sheets/src/sheet-table.ts @@ -87,6 +87,22 @@ export class SheetTable { this.#bodyStartRow = options?.bodyStartRow ?? 2; } + /** + * `await using` 宣言のスコープ脱出時に呼ばれ、内部の {@link Sheet} をフラッシュする。 + * バッファに未送信行が残ったままスコープを抜けてデータが欠損するのを防ぐ。 + * @example + * ```ts + * { + * await using table = await SheetTable.create(sheetUrl, sheetName, auth, header); + * await table.addRecords(records); + * } // スコープ脱出時に自動で内部 Sheet の未送信バッファが flush される + * ``` + */ + async [Symbol.asyncDispose]() { + if (this.#sheet) { + await this.#sheet[Symbol.asyncDispose](); + } + } /** * Appends rows to the sheet. * @param records - Array of row data keyed by header identifiers diff --git a/packages/@d-zero/google-sheets/src/sheets/sheet.spec.ts b/packages/@d-zero/google-sheets/src/sheets/sheet.spec.ts index f3663dac..62125ed3 100644 --- a/packages/@d-zero/google-sheets/src/sheets/sheet.spec.ts +++ b/packages/@d-zero/google-sheets/src/sheets/sheet.spec.ts @@ -255,6 +255,19 @@ describe('appendRow / flush', () => { expect(sheet.sentCount).toBe(0); }); + test('[Symbol.asyncDispose] flushes the remaining buffer (await using does not lose pending rows)', async () => { + const { parent, updateCellsRows } = createRecordingParent(); + const sheet = new Sheet(mockSheet as never, parent as never); + + await sheet.appendRow(...Array.from({ length: 3 }, () => eagerRow())); + expect(updateCellsRows).toEqual([]); + + await sheet[Symbol.asyncDispose](); + + expect(updateCellsRows).toEqual([3]); + expect(sheet.sentCount).toBe(3); + }); + test('suspends auto-flush as soon as a lazy row enters the buffer', async () => { const { parent, updateCellsRows } = createRecordingParent(); const sheet = new Sheet(mockSheet as never, parent as never); diff --git a/packages/@d-zero/google-sheets/src/sheets/sheet.ts b/packages/@d-zero/google-sheets/src/sheets/sheet.ts index 2d255a12..3bf40800 100644 --- a/packages/@d-zero/google-sheets/src/sheets/sheet.ts +++ b/packages/@d-zero/google-sheets/src/sheets/sheet.ts @@ -113,6 +113,20 @@ export class Sheet { this.#parent = parent; } + /** + * `await using` 宣言のスコープ脱出時に呼ばれ、{@link Sheet.flush} と同じ処理を行う。 + * バッファに未送信行が残ったままスコープを抜けてデータが欠損するのを防ぐ。 + * @example + * ```ts + * { + * await using sheet = sheets.create('Report'); + * await sheet.appendRow(row); + * } // スコープ脱出時に自動で残りのバッファが flush される + * ``` + */ + async [Symbol.asyncDispose]() { + await this.flush(); + } async addRowData(data: Row[], next = true) { const total = data.length; sheetLog('Will add %d items', total); diff --git a/packages/@d-zero/html-distiller/package.json b/packages/@d-zero/html-distiller/package.json index 73ff4cf2..afcc5df0 100644 --- a/packages/@d-zero/html-distiller/package.json +++ b/packages/@d-zero/html-distiller/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { diff --git a/packages/@d-zero/notion/package.json b/packages/@d-zero/notion/package.json index 11361490..ab76a51a 100644 --- a/packages/@d-zero/notion/package.json +++ b/packages/@d-zero/notion/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { diff --git a/packages/@d-zero/page-cluster/package.json b/packages/@d-zero/page-cluster/package.json index e68ef185..03d97592 100644 --- a/packages/@d-zero/page-cluster/package.json +++ b/packages/@d-zero/page-cluster/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { @@ -76,6 +79,7 @@ "clean": "tsc --build --clean" }, "dependencies": { + "@d-zero/cli-core": "1.3.16", "@d-zero/dealer": "1.10.4", "@d-zero/shared": "0.22.5", "htmlparser2": "12.0.0" diff --git a/packages/@d-zero/page-cluster/src/cli.spec.ts b/packages/@d-zero/page-cluster/src/cli.spec.ts index b3d22f6a..4e083c80 100644 --- a/packages/@d-zero/page-cluster/src/cli.spec.ts +++ b/packages/@d-zero/page-cluster/src/cli.spec.ts @@ -1,8 +1,9 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { Readable, Writable } from 'node:stream'; +import { mkdtempDisposable } from '@d-zero/shared/mkdtemp-disposable'; import { describe, expect, test } from 'vitest'; import { parseArgs, runCli } from './cli.js'; @@ -361,36 +362,32 @@ describe('runCli', () => { ].join('\n'); const stdout = makeCollector(); const stderr = makeCollector(); - const dir = await mkdtemp(path.join(tmpdir(), 'page-cluster-cli-')); - const reasonsFile = path.join(dir, 'reasons.json'); - try { - const code = await runCli({ - stdin: makeStdin(input), - stdout: stdout.stream, - stderr: stderr.stream, - argv: ['--cluster-reasons-file', reasonsFile], - version: '0.0.0', - }); - expect(code).toBe(0); - - const lines = stdout.read().split('\n').filter(Boolean); - expect(lines).toHaveLength(2); - const parsed = lines.map( - (line) => JSON.parse(line) as { id: string; clusterKey: string }, - ); - expect(parsed[0]).not.toHaveProperty('landmarks'); - expect(parsed[0]!.clusterKey).toBe(parsed[1]!.clusterKey); - - const reasonsByKey = JSON.parse(await readFile(reasonsFile, 'utf8')) as Record< - string, - { memberCount: number; landmarks: { header?: { chromeRate: number } } } - >; - const reason = reasonsByKey[parsed[0]!.clusterKey]; - expect(reason?.memberCount).toBe(2); - expect(reason?.landmarks.header?.chromeRate).toBe(1); - } finally { - await rm(dir, { recursive: true, force: true }); - } + await using dir = await mkdtempDisposable(path.join(tmpdir(), 'page-cluster-cli-')); + const reasonsFile = path.join(dir.path, 'reasons.json'); + const code = await runCli({ + stdin: makeStdin(input), + stdout: stdout.stream, + stderr: stderr.stream, + argv: ['--cluster-reasons-file', reasonsFile], + version: '0.0.0', + }); + expect(code).toBe(0); + + const lines = stdout.read().split('\n').filter(Boolean); + expect(lines).toHaveLength(2); + const parsed = lines.map( + (line) => JSON.parse(line) as { id: string; clusterKey: string }, + ); + expect(parsed[0]).not.toHaveProperty('landmarks'); + expect(parsed[0]!.clusterKey).toBe(parsed[1]!.clusterKey); + + const reasonsByKey = JSON.parse(await readFile(reasonsFile, 'utf8')) as Record< + string, + { memberCount: number; landmarks: { header?: { chromeRate: number } } } + >; + const reason = reasonsByKey[parsed[0]!.clusterKey]; + expect(reason?.memberCount).toBe(2); + expect(reason?.landmarks.header?.chromeRate).toBe(1); }); test('--cluster-reasons-file write failure is reported as a clean exit-1 error, not an unhandled rejection', async () => { @@ -453,50 +450,42 @@ describe('runCli', () => { .join('\n'); const stdout = makeCollector(); const stderr = makeCollector(); - const dir = await mkdtemp(path.join(tmpdir(), 'page-cluster-cli-')); - const validationFile = path.join(dir, 'validation.json'); - try { - const code = await runCli({ - stdin: makeStdin(input), - stdout: stdout.stream, - stderr: stderr.stream, - argv: ['--validation-file', validationFile], - version: '0.0.0', - }); - expect(code).toBe(0); - - const report = JSON.parse(await readFile(validationFile, 'utf8')) as { - mirrorAxis: { position: number; values: string[] } | null; - cohesion: unknown[]; - crossClusterDuplicates: unknown[]; - }; - expect(report.mirrorAxis).toEqual({ position: 0, values: ['en', 'zh'] }); - expect(Array.isArray(report.cohesion)).toBe(true); - expect(Array.isArray(report.crossClusterDuplicates)).toBe(true); - } finally { - await rm(dir, { recursive: true, force: true }); - } + await using dir = await mkdtempDisposable(path.join(tmpdir(), 'page-cluster-cli-')); + const validationFile = path.join(dir.path, 'validation.json'); + const code = await runCli({ + stdin: makeStdin(input), + stdout: stdout.stream, + stderr: stderr.stream, + argv: ['--validation-file', validationFile], + version: '0.0.0', + }); + expect(code).toBe(0); + + const report = JSON.parse(await readFile(validationFile, 'utf8')) as { + mirrorAxis: { position: number; values: string[] } | null; + cohesion: unknown[]; + crossClusterDuplicates: unknown[]; + }; + expect(report.mirrorAxis).toEqual({ position: 0, values: ['en', 'zh'] }); + expect(Array.isArray(report.cohesion)).toBe(true); + expect(Array.isArray(report.crossClusterDuplicates)).toBe(true); }); test('without --validation-file, no validation file is written', async () => { const stdout = makeCollector(); const stderr = makeCollector(); - const dir = await mkdtemp(path.join(tmpdir(), 'page-cluster-cli-')); - const validationFile = path.join(dir, 'validation.json'); - try { - const code = await runCli({ - stdin: makeStdin( - JSON.stringify({ id: 'a', html: '
H
' }), - ), - stdout: stdout.stream, - stderr: stderr.stream, - argv: [], - version: '0.0.0', - }); - expect(code).toBe(0); - await expect(readFile(validationFile, 'utf8')).rejects.toThrow(); - } finally { - await rm(dir, { recursive: true, force: true }); - } + await using dir = await mkdtempDisposable(path.join(tmpdir(), 'page-cluster-cli-')); + const validationFile = path.join(dir.path, 'validation.json'); + const code = await runCli({ + stdin: makeStdin( + JSON.stringify({ id: 'a', html: '
H
' }), + ), + stdout: stdout.stream, + stderr: stderr.stream, + argv: [], + version: '0.0.0', + }); + expect(code).toBe(0); + await expect(readFile(validationFile, 'utf8')).rejects.toThrow(); }); }); diff --git a/packages/@d-zero/page-cluster/src/cli.ts b/packages/@d-zero/page-cluster/src/cli.ts index 7e4a9ed9..c7f5a648 100644 --- a/packages/@d-zero/page-cluster/src/cli.ts +++ b/packages/@d-zero/page-cluster/src/cli.ts @@ -16,6 +16,7 @@ import type { ClusterPartitionReport } from './validate-cluster-partition.js'; import { writeFile } from 'node:fs/promises'; import process from 'node:process'; +import { unwrapSuppressedError } from '@d-zero/cli-core'; import { Lanes } from '@d-zero/dealer'; import { resolvePageClusterKeys } from './resolve-page-cluster-keys.js'; @@ -366,6 +367,21 @@ function errorLine(message: string): ProgressLine { }; } +/** + * Formats a caught error for `errorLine()`. `SuppressedError` (thrown when a + * `using`-scoped body error and a disposal error occur together) hides the + * real cause behind a generic message, so its underlying causes are + * unwrapped and joined into one line — `errorLine()`/`renderProgress()` must + * still be called exactly once per catch site, since Lanes' TTY repaint only + * keeps the latest frame (see {@link errorLine}'s JSDoc). + * @param error + */ +function formatErrorMessage(error: unknown): string { + return unwrapSuppressedError(error) + .map((cause) => (cause instanceof Error ? cause.message : String(cause))) + .join(' / '); +} + /** * Maps a library `ProgressEvent` to a human-facing `ProgressLine`. The * verbose arm keeps the historical `pass0:` / `pass1:` / `pass1b:` / @@ -472,7 +488,10 @@ export async function runCli(options: { // pass in) does — reading it defensively lets both real usage and // unit-test doubles work without a separate `--no-progress` flag. const useTty = (options.stderr as { isTTY?: boolean }).isTTY === true; - const lanes = new Lanes({ stream: options.stderr, verbose: !useTty }); + // `using` により、この関数を抜けるすべての経路(下記の各 `return` は + // もちろん、想定外の例外を含む)で確実に lanes.close() が呼ばれ、 + // Display の setTimeout タイマーが解放される。 + using lanes = new Lanes({ stream: options.stderr, verbose: !useTty }); // Verbose Lanes prepends `#header` to every `update()` line. Without // this seed call the header would be undefined and each progress line // would begin with the literal string `undefined ` — bug caught by @@ -484,98 +503,90 @@ export async function runCli(options: { const startTime = Date.now(); const elapsed = () => Math.max(0, Math.round((Date.now() - startTime) / 1000)); - // Every early return past this point must run through the finally block - // so `lanes.close()` releases the display's setTimeout timer — without - // it a `return 1` on a stdin parse error would leave the process - // hanging on the timer's next tick. - try { - renderProgress(lanes, useTty, READING_INPUT); + renderProgress(lanes, useTty, READING_INPUT); - // Load every JSONL line into memory once so the ids array stays - // parallel to the pages array — the streaming driver reads its - // factory twice, and stdin is a one-shot pipe. - const ids: (string | number | undefined)[] = []; - const pages: PageClusterSignals[] = []; - try { - for await (const { id, page } of readJsonlPages(options.stdin)) { - ids.push(id); - pages.push(page); - } - } catch (error) { - renderProgress(lanes, useTty, errorLine((error as Error).message)); - return 1; + // Load every JSONL line into memory once so the ids array stays + // parallel to the pages array — the streaming driver reads its + // factory twice, and stdin is a one-shot pipe. + const ids: (string | number | undefined)[] = []; + const pages: PageClusterSignals[] = []; + try { + for await (const { id, page } of readJsonlPages(options.stdin)) { + ids.push(id); + pages.push(page); } + } catch (error) { + renderProgress(lanes, useTty, errorLine(formatErrorMessage(error))); + return 1; + } - renderProgress(lanes, useTty, readingDoneLine(pages.length)); + renderProgress(lanes, useTty, readingDoneLine(pages.length)); + + // Only worth collecting when the caller asked for the file — a + // ClusterReason Map costs bookkeeping proportional to cluster count, + // not page count, but there's no reason to pay even that when unused. + const reasonsByClusterKey = args.clusterReasonsFile + ? new Map() + : undefined; + + // Same opt-in gate as `reasonsByClusterKey` — `onPartitionReport` + // itself gates the underlying validation pass (see its own JSDoc). + let partitionReport: ClusterPartitionReport | undefined; + + const resolveOptions: ResolvePageClusterKeysOptions = { + contentBlockAttribute: args.contentBlockAttribute, + onProgress: (event) => { + renderProgress(lanes, useTty, formatProgressLine(event, elapsed())); + }, + onClusterReason: reasonsByClusterKey + ? (key, reason) => reasonsByClusterKey.set(key, reason) + : undefined, + onPartitionReport: args.validationFile + ? (report) => (partitionReport = report) + : undefined, + }; - // Only worth collecting when the caller asked for the file — a - // ClusterReason Map costs bookkeeping proportional to cluster count, - // not page count, but there's no reason to pay even that when unused. - const reasonsByClusterKey = args.clusterReasonsFile - ? new Map() - : undefined; + let clusterKeys: string[]; + try { + clusterKeys = await resolvePageClusterKeys(() => pages, resolveOptions); + } catch (error) { + renderProgress(lanes, useTty, errorLine(formatErrorMessage(error))); + return 1; + } - // Same opt-in gate as `reasonsByClusterKey` — `onPartitionReport` - // itself gates the underlying validation pass (see its own JSDoc). - let partitionReport: ClusterPartitionReport | undefined; + const clusterCount = new Set(clusterKeys).size; + renderProgress(lanes, useTty, doneLine(pages.length, clusterCount, elapsed())); - const resolveOptions: ResolvePageClusterKeysOptions = { - contentBlockAttribute: args.contentBlockAttribute, - onProgress: (event) => { - renderProgress(lanes, useTty, formatProgressLine(event, elapsed())); - }, - onClusterReason: reasonsByClusterKey - ? (key, reason) => reasonsByClusterKey.set(key, reason) - : undefined, - onPartitionReport: args.validationFile - ? (report) => (partitionReport = report) - : undefined, - }; + for (const [index, key] of clusterKeys.entries()) { + const row = { id: ids[index] ?? index, clusterKey: key }; + options.stdout.write(`${JSON.stringify(row)}\n`); + } - let clusterKeys: string[]; + if (args.clusterReasonsFile && reasonsByClusterKey) { try { - clusterKeys = await resolvePageClusterKeys(() => pages, resolveOptions); + await writeFile( + args.clusterReasonsFile, + JSON.stringify(Object.fromEntries(reasonsByClusterKey), null, 2), + ); } catch (error) { - renderProgress(lanes, useTty, errorLine((error as Error).message)); + renderProgress(lanes, useTty, errorLine(formatErrorMessage(error))); return 1; } + } - const clusterCount = new Set(clusterKeys).size; - renderProgress(lanes, useTty, doneLine(pages.length, clusterCount, elapsed())); - - for (const [index, key] of clusterKeys.entries()) { - const row = { id: ids[index] ?? index, clusterKey: key }; - options.stdout.write(`${JSON.stringify(row)}\n`); - } - - if (args.clusterReasonsFile && reasonsByClusterKey) { - try { - await writeFile( - args.clusterReasonsFile, - JSON.stringify(Object.fromEntries(reasonsByClusterKey), null, 2), - ); - } catch (error) { - renderProgress(lanes, useTty, errorLine((error as Error).message)); - return 1; - } - } - - if (args.validationFile && partitionReport) { - try { - await writeFile( - args.validationFile, - JSON.stringify(toJsonSafePartitionReport(partitionReport), null, 2), - ); - } catch (error) { - renderProgress(lanes, useTty, errorLine((error as Error).message)); - return 1; - } + if (args.validationFile && partitionReport) { + try { + await writeFile( + args.validationFile, + JSON.stringify(toJsonSafePartitionReport(partitionReport), null, 2), + ); + } catch (error) { + renderProgress(lanes, useTty, errorLine(formatErrorMessage(error))); + return 1; } - - return 0; - } finally { - lanes.close(); } + + return 0; } /** diff --git a/packages/@d-zero/print/package.json b/packages/@d-zero/print/package.json index 83b85c2e..1b0f2c29 100644 --- a/packages/@d-zero/print/package.json +++ b/packages/@d-zero/print/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { @@ -35,7 +38,7 @@ "dayjs": "1.11.21", "front-matter": "4.0.2", "minimist": "1.2.8", - "puppeteer": "25.3.0" + "puppeteer": "25.5.0" }, "repository": { "type": "git", diff --git a/packages/@d-zero/proc-talk/README.md b/packages/@d-zero/proc-talk/README.md index 37964930..ae686e9e 100644 --- a/packages/@d-zero/proc-talk/README.md +++ b/packages/@d-zero/proc-talk/README.md @@ -19,13 +19,13 @@ type WorkerAPI = { add: (a: number, b: number) => Promise; }; -const worker = new ProcTalk({ +await using worker = new ProcTalk({ type: 'main', subModulePath: './worker.js', }); const result = await worker.call('add', 10, 20); -await worker.dispose(); +// スコープ脱出時に子プロセスへ自動で :kill が送られ、exit まで待機する ``` 子プロセス (`./worker.js`): @@ -41,4 +41,4 @@ new ProcTalk({ }); ``` -シリアライズ仕様(関数は IPC 越境で `null` 化される制約)、エラー時のスタックトレース保持、`dispose` 時のクリーンアップ順序は `src/proc-talk.ts` と `src/serialize.ts` の JSDoc を参照。 +シリアライズ仕様(関数は IPC 越境で `null` 化される制約)、エラー時のスタックトレース保持、`Symbol.asyncDispose` 時のクリーンアップ順序は `src/proc-talk.ts` と `src/serialize.ts` の JSDoc を参照。 diff --git a/packages/@d-zero/proc-talk/package.json b/packages/@d-zero/proc-talk/package.json index 4dfebf92..7a5c44c3 100644 --- a/packages/@d-zero/proc-talk/package.json +++ b/packages/@d-zero/proc-talk/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { diff --git a/packages/@d-zero/proc-talk/src/proc-talk.spec.ts b/packages/@d-zero/proc-talk/src/proc-talk.spec.ts new file mode 100644 index 00000000..36e4c4f1 --- /dev/null +++ b/packages/@d-zero/proc-talk/src/proc-talk.spec.ts @@ -0,0 +1,140 @@ +import type * as childProcessModule from 'node:child_process'; + +import { ChildProcess, fork } from 'node:child_process'; + +import { describe, test, expect, vi } from 'vitest'; + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fork: vi.fn(), + }; +}); + +import { ProcTalk } from './proc-talk.js'; + +/** + * `fork()` が実プロセスを起動せずに返す `ChildProcess` の代役を作る。 + * `send` はプロトタイプに実装がない(実際の IPC チャネルがないため)ので + * テスト用にスタブを生やす。 + * @param options - 代役の初期状態 + * @param options.connected - IPC チャネルが開いているか(デフォルト: true) + */ +function createFakeChildProcess(options: { connected?: boolean } = {}): ChildProcess { + const cp = new ChildProcess(); + // @ts-expect-error -- test stub: 実際の spawn を経ていないため connected は読み取り専用でない + cp.connected = options.connected ?? true; + // @ts-expect-error -- test stub: 実際の spawn を経ていないため send が無い + cp.send = vi.fn(() => true); + // @ts-expect-error -- test stub + cp.kill = vi.fn(); + return cp; +} + +describe('ProcTalk.close() idempotency', () => { + test('two concurrent calls return the same promise and send :kill only once', async () => { + const cp = createFakeChildProcess(); + vi.mocked(fork).mockReturnValue(cp); + + const talk = new ProcTalk({ type: 'main', subModulePath: '/dummy.js' }); + + const close1 = talk.close(); + const close2 = talk.close(); + + expect(close1).toBe(close2); + + cp.emit('exit', 0, null); + + await expect(close1).resolves.toBeUndefined(); + await expect(close2).resolves.toBeUndefined(); + expect(cp.send).toHaveBeenCalledTimes(1); + }); + + test('close() on an already-exited process resolves immediately without hanging', async () => { + const cp = createFakeChildProcess(); + vi.mocked(fork).mockReturnValue(cp); + cp.exitCode = 0; + + const talk = new ProcTalk({ type: 'main', subModulePath: '/dummy.js' }); + + // 事前に exitCode が設定済み(=既に exit 済み)の場合、 + // once('exit', ...) を張ると二度と発火せず永久 pending になっていた回帰を防ぐ + await expect(talk.close()).resolves.toBeUndefined(); + }); + + test('[Symbol.asyncDispose] delegates to the same idempotent close logic', async () => { + const cp = createFakeChildProcess(); + vi.mocked(fork).mockReturnValue(cp); + + const talk = new ProcTalk({ type: 'main', subModulePath: '/dummy.js' }); + + const disposePromise = talk[Symbol.asyncDispose](); + cp.emit('exit', 0, null); + await disposePromise; + + await expect(talk.close()).resolves.toBeUndefined(); + expect(cp.send).toHaveBeenCalledTimes(1); + }); +}); + +describe('ProcTalk.close() channel/fallback handling', () => { + test('closed IPC channel (connected=false) falls back to kill() without sending', async () => { + const cp = createFakeChildProcess({ connected: false }); + vi.mocked(fork).mockReturnValue(cp); + + const talk = new ProcTalk({ type: 'main', subModulePath: '/dummy.js' }); + + const closePromise = talk.close(); + + // :kill は届かないので send せず直接シグナルで止める + expect(cp.send).not.toHaveBeenCalled(); + expect(cp.kill).toHaveBeenCalledTimes(1); + + cp.emit('exit', null, 'SIGTERM'); + await expect(closePromise).resolves.toBeUndefined(); + }); + + test('send() delivery failure (callback error) falls back to kill() instead of crashing', async () => { + const cp = createFakeChildProcess(); + // send のコールバックにエラーを渡す = ERR_IPC_CHANNEL_CLOSED 相当。 + // コールバック形式なら 'error' イベントは emit されず、ここで捕捉できる + // @ts-expect-error -- test stub + cp.send = vi.fn((_msg: unknown, callback: (error: Error | null) => void) => { + callback(new Error('ERR_IPC_CHANNEL_CLOSED')); + return false; + }); + vi.mocked(fork).mockReturnValue(cp); + + const talk = new ProcTalk({ type: 'main', subModulePath: '/dummy.js' }); + + const closePromise = talk.close(); + + expect(cp.kill).toHaveBeenCalledTimes(1); + + cp.emit('exit', null, 'SIGTERM'); + await expect(closePromise).resolves.toBeUndefined(); + }); + + test('send() returning false with successful delivery (backpressure) does NOT kill', async () => { + const cp = createFakeChildProcess(); + // バックプレッシャ: 戻り値は false だがメッセージ自体はキューされ、 + // コールバックはエラーなしで呼ばれる — この場合 SIGTERM を送ってはいけない + // (graceful cleanup(Chromium teardown 等)がスキップされてしまう) + // @ts-expect-error -- test stub + cp.send = vi.fn((_msg: unknown, callback: (error: Error | null) => void) => { + callback(null); + return false; + }); + vi.mocked(fork).mockReturnValue(cp); + + const talk = new ProcTalk({ type: 'main', subModulePath: '/dummy.js' }); + + const closePromise = talk.close(); + + expect(cp.kill).not.toHaveBeenCalled(); + + cp.emit('exit', 0, null); + await expect(closePromise).resolves.toBeUndefined(); + }); +}); diff --git a/packages/@d-zero/proc-talk/src/proc-talk.ts b/packages/@d-zero/proc-talk/src/proc-talk.ts index 1fd5ccad..cb1f8301 100644 --- a/packages/@d-zero/proc-talk/src/proc-talk.ts +++ b/packages/@d-zero/proc-talk/src/proc-talk.ts @@ -27,6 +27,7 @@ export type ChildProcCleanup = () => void | Promise; export class ProcTalk { readonly #callLog: typeof log; #cleanup: ChildProcCleanup | null = null; + #closePromise: Promise | null = null; readonly #id: number; readonly #initialized = new Deferred(); readonly #initLog: typeof log; @@ -60,6 +61,19 @@ export class ProcTalk { void this.#init(config); } + /** + * `await using` 宣言のスコープ脱出時に呼ばれ、{@link ProcTalk.close} と同じ解放処理を行う。 + * @example + * ```ts + * { + * await using talk = new ProcTalk({ type: 'main', subModulePath }); + * await talk.call('doSomething'); + * } // スコープ脱出時に自動で子プロセスへ :kill が送られ、exit を待つ + * ``` + */ + async [Symbol.asyncDispose]() { + await this.#close(); + } bind

(type: P, listener: T[P]) { this.#log('bind:%s', type); this.#listeners.set(type.toString(), listener); @@ -101,18 +115,15 @@ export class ProcTalk { return callPromise; } + /** + * 子プロセスに `:kill` を送信し、`exit` するまで待機する。複数回呼び出しても + * 同じ Promise を返すため安全(冪等)。 + * @deprecated `await using` 宣言(`Symbol.asyncDispose`)による自動解放を使用すること。 + * スコープと解放タイミングが一致しない場合のみ直接呼び出す。 + * @returns 子プロセスが終了したら解決する Promise + */ close() { - if (this.#type === 'main' && this.#process instanceof ChildProcess) { - return new Promise((resolve) => { - this.#process.once('exit', () => { - resolve(); - }); - this.#process.send?.({ - type: ':kill', - }); - }); - } - return Promise.resolve(); + return this.#close(); } async initialized(): Promise { @@ -120,10 +131,53 @@ export class ProcTalk { await this.#initialized.promise(); this.#initLog('done'); } - log(...args: Parameters) { this.#log(...args); } + #close() { + if (this.#type !== 'main' || !(this.#process instanceof ChildProcess)) { + return Promise.resolve(); + } + + const proc = this.#process; + + // 2 回目以降の呼び出しは同じ Promise を返すことで冪等にする。 + // ガードがないと、既に exit 済みのプロセスへ再度 once('exit', ...) を + // 張ってしまい、exit イベントが二度と発火せず Promise が永久に pending になる。 + this.#closePromise ??= new Promise((resolve) => { + if (proc.exitCode !== null || proc.signalCode !== null) { + resolve(); + return; + } + + proc.once('exit', () => { + resolve(); + }); + + if (!proc.connected) { + // IPC チャネルが既に閉じている: :kill は届かないので直接シグナルで止める + proc.kill(); + return; + } + + // コールバック形式で送る理由: + // 1. コールバックなしだと、送信失敗時(ERR_IPC_CHANNEL_CLOSED 等)に + // ChildProcess へ 'error' イベントが emit され、リスナーがいないため + // 親プロセスが uncaughtException でクラッシュする。コールバックを + // 渡すとエラーはコールバック引数に渡され、'error' emit が抑止される + // 2. send() の戻り値 false はバックプレッシャ(送信キュー滞留)でも + // 発生するため、戻り値でのフォールバック判定は健在な子への早すぎる + // SIGTERM(graceful cleanup のスキップ)につながる。実際に届かなかった + // 場合だけコールバックの error で判定する + proc.send({ type: ':kill' }, (error) => { + if (error) { + proc.kill(); + } + }); + }); + + return this.#closePromise; + } #error(error: unknown) { let message = 'unknown error'; diff --git a/packages/@d-zero/puppeteer-dealer/package.json b/packages/@d-zero/puppeteer-dealer/package.json index c9872904..c19e4481 100644 --- a/packages/@d-zero/puppeteer-dealer/package.json +++ b/packages/@d-zero/puppeteer-dealer/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { @@ -32,10 +35,10 @@ "puppeteer-extra-plugin-stealth": "2.11.2" }, "devDependencies": { - "puppeteer": "25.3.0" + "puppeteer": "25.5.0" }, "peerDependencies": { - "puppeteer": "25.2.1" + "puppeteer": "25.5.0" }, "repository": { "type": "git", diff --git a/packages/@d-zero/puppeteer-dealer/src/create-main-process.ts b/packages/@d-zero/puppeteer-dealer/src/create-main-process.ts index 6997b56c..9a960ece 100644 --- a/packages/@d-zero/puppeteer-dealer/src/create-main-process.ts +++ b/packages/@d-zero/puppeteer-dealer/src/create-main-process.ts @@ -46,19 +46,41 @@ export class ChildProcessManager { this.#procTalk.bind('init', () => Promise.resolve(params)); } + /** + * `await using` 宣言のスコープ脱出時に呼ばれ、{@link ChildProcessManager.close} と + * 同じ解放処理を行う。 + * @example + * ```ts + * { + * await using processManager = createProcess()(needAuth); + * await processManager.ready(); + * await processManager.each(id, url, index); + * } // スコープ脱出時(例外発生時を含む)に必ず子プロセスと Chromium が閉じられる + * ``` + */ + async [Symbol.asyncDispose]() { + await this.#close(); + } + /** + * 子プロセス(と、その中で起動している Chromium)を終了させる。 + * 複数回呼び出しても安全({@link ProcTalk.close} 経由で冪等)。 + * @deprecated `await using` 宣言(`Symbol.asyncDispose`)による自動解放を使用すること。 + * スコープと解放タイミングが一致しない場合のみ直接呼び出す。 + */ async close() { - await this.#procTalk.close(); + await this.#close(); } async each(id: string, url: string, index: number) { return await this.#procTalk.call('each', id, url, index); } - log(logger: Logger) { this.#procTalk.bind('log', logger); } - async ready() { await this.#procTalk.initialized(); } + async #close() { + await this.#procTalk[Symbol.asyncDispose](); + } } diff --git a/packages/@d-zero/puppeteer-dealer/src/deal.spec.ts b/packages/@d-zero/puppeteer-dealer/src/deal.spec.ts new file mode 100644 index 00000000..d6cd74ee --- /dev/null +++ b/packages/@d-zero/puppeteer-dealer/src/deal.spec.ts @@ -0,0 +1,93 @@ +import type { ChildProcessManager } from './create-main-process.js'; +import type { URLInfo } from './types.js'; + +import { deal as coreDeal } from '@d-zero/dealer'; +import { describe, test, expect, vi } from 'vitest'; + +import { deal } from './deal.js'; + +vi.mock('@d-zero/dealer', () => ({ + deal: vi.fn(), +})); + +/** + * `createProcess()(needAuth)` が返す {@link ChildProcessManager} の代役。 + * `each` の成功/失敗を差し替えられるようにし、`Symbol.asyncDispose` の + * 呼び出し回数を観測する。 + * @param overrides - 差し替えたいメソッド + * @param overrides.each + */ +function createFakeManager(overrides: { each?: () => Promise } = {}) { + const disposeSpy = vi.fn(async () => {}); + const manager = { + ready: vi.fn(async () => {}), + log: vi.fn(), + each: overrides.each ?? vi.fn(async () => {}), + [Symbol.asyncDispose]: disposeSpy, + }; + return { + manager: manager as unknown as ChildProcessManager, + disposeSpy, + }; +} + +const list: URLInfo[] = [{ id: 'a', url: 'https://example.com/' }]; + +/** + * `@d-zero/dealer`'s real `Dealer` hangs forever if a worker's `start()` + * rejects (its worker-completion `.then()` has no `.catch()`, so a failed + * worker never advances `#doneCount` — a pre-existing gap unrelated to this + * change). Mocking `coreDeal` to drive `setup`/`start` directly isolates + * these tests to `puppeteer-dealer`'s own `deal.ts` logic. + * @param items + * @param setup + */ +async function driveSetupDirectly( + items: readonly URLInfo[], + setup: ( + item: URLInfo, + update: (log: string) => void, + index: number, + setLineHeader: (lineHeader: string) => void, + push: (...items: URLInfo[]) => Promise, + ) => Promise<() => void | Promise> | (() => void | Promise), +) { + for (const [index, item] of items.entries()) { + const start = await setup(item, vi.fn(), index, vi.fn(), vi.fn()); + await start().catch(() => {}); + } +} + +describe('deal (puppeteer-dealer)', () => { + test('disposes the process manager even when each() rejects', async () => { + const { manager, disposeSpy } = createFakeManager({ + each: vi.fn(() => { + throw new Error('boom'); + }), + }); + vi.mocked(coreDeal).mockImplementation(driveSetupDirectly as never); + + await deal( + list, + () => '', + () => () => manager, + { verbose: true }, + ); + + expect(disposeSpy).toHaveBeenCalledTimes(1); + }); + + test('disposes the process manager on the success path too', async () => { + const { manager, disposeSpy } = createFakeManager(); + vi.mocked(coreDeal).mockImplementation(driveSetupDirectly as never); + + await deal( + list, + () => '', + () => () => manager, + { verbose: true }, + ); + + expect(disposeSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/@d-zero/puppeteer-dealer/src/deal.ts b/packages/@d-zero/puppeteer-dealer/src/deal.ts index e706b8bf..2da0c390 100644 --- a/packages/@d-zero/puppeteer-dealer/src/deal.ts +++ b/packages/@d-zero/puppeteer-dealer/src/deal.ts @@ -37,7 +37,11 @@ export function deal( return async () => { update(`Using ${needAuth ? 'auth' : 'no auth'}`); - const processManager = createProcess()(needAuth); + // `await using` により、ready()/each()/options.each() のいずれで + // throw しても子プロセスと Chromium が確実に回収される。 + // スコープ末尾での明示的な close() では例外パスを通らず、 + // 子プロセスと Chromium がゾンビ化する。 + await using processManager = createProcess()(needAuth); update(`Booting ChildProcess%dots%`); await processManager.ready(); processManager.log((log) => update(log)); @@ -45,7 +49,6 @@ export function deal( if (options?.each) { await options.each(result, push); } - await processManager.close(); }; }, { diff --git a/packages/@d-zero/puppeteer-general-actions/package.json b/packages/@d-zero/puppeteer-general-actions/package.json index 0040713b..0e0aa438 100644 --- a/packages/@d-zero/puppeteer-general-actions/package.json +++ b/packages/@d-zero/puppeteer-general-actions/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { diff --git a/packages/@d-zero/puppeteer-page-scan/package.json b/packages/@d-zero/puppeteer-page-scan/package.json index b4faed46..21885c05 100644 --- a/packages/@d-zero/puppeteer-page-scan/package.json +++ b/packages/@d-zero/puppeteer-page-scan/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { @@ -28,10 +31,10 @@ "@d-zero/shared": "0.22.5" }, "devDependencies": { - "puppeteer": "25.3.0" + "puppeteer": "25.5.0" }, "peerDependencies": { - "puppeteer": "25.2.1" + "puppeteer": "25.5.0" }, "repository": { "type": "git", diff --git a/packages/@d-zero/puppeteer-screenshot/package.json b/packages/@d-zero/puppeteer-screenshot/package.json index 1b651f0b..b277980e 100644 --- a/packages/@d-zero/puppeteer-screenshot/package.json +++ b/packages/@d-zero/puppeteer-screenshot/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { @@ -28,10 +31,10 @@ "@d-zero/shared": "0.22.5" }, "devDependencies": { - "puppeteer": "25.3.0" + "puppeteer": "25.5.0" }, "peerDependencies": { - "puppeteer": "25.2.1" + "puppeteer": "25.5.0" }, "repository": { "type": "git", diff --git a/packages/@d-zero/puppeteer-scroll/package.json b/packages/@d-zero/puppeteer-scroll/package.json index 157d653a..8c59546e 100644 --- a/packages/@d-zero/puppeteer-scroll/package.json +++ b/packages/@d-zero/puppeteer-scroll/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { @@ -26,10 +29,10 @@ "@d-zero/shared": "0.22.5" }, "devDependencies": { - "puppeteer": "25.3.0" + "puppeteer": "25.5.0" }, "peerDependencies": { - "puppeteer": "25.2.1" + "puppeteer": "25.5.0" }, "repository": { "type": "git", diff --git a/packages/@d-zero/readtext/package.json b/packages/@d-zero/readtext/package.json index ea20269c..8e838c67 100644 --- a/packages/@d-zero/readtext/package.json +++ b/packages/@d-zero/readtext/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { "./grid": { diff --git a/packages/@d-zero/remote-inspector/package.json b/packages/@d-zero/remote-inspector/package.json index b3947ab3..bbd85f7c 100644 --- a/packages/@d-zero/remote-inspector/package.json +++ b/packages/@d-zero/remote-inspector/package.json @@ -4,6 +4,9 @@ "description": "Compare local and remote files via SSH/SFTP before deployment", "author": "D-ZERO", "license": "MIT", + "engines": { + "node": ">=24.11.0" + }, "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/@d-zero/remote-inspector/src/cli.ts b/packages/@d-zero/remote-inspector/src/cli.ts index dcc34d14..3c6a6167 100644 --- a/packages/@d-zero/remote-inspector/src/cli.ts +++ b/packages/@d-zero/remote-inspector/src/cli.ts @@ -4,7 +4,7 @@ import type { ParsedArgs } from 'minimist'; import { createRequire } from 'node:module'; -import { createCLI, parseCommonOptions } from '@d-zero/cli-core'; +import { createCLI, parseCommonOptions, unwrapSuppressedError } from '@d-zero/cli-core'; import { config as dotenvConfig } from 'dotenv'; import { remoteInspector } from './remote-inspector.js'; @@ -108,7 +108,11 @@ const { options } = createCLI(config); try { await remoteInspector(options); } catch (error) { - // eslint-disable-next-line no-console - console.error('Error:', error instanceof Error ? error.message : error); + // SuppressedError(using スコープ内で本体と dispose の両方が例外を投げた場合) + // を分解し、定型メッセージの裏に隠れる根本原因を両方とも出力する + for (const cause of unwrapSuppressedError(error)) { + // eslint-disable-next-line no-console + console.error('Error:', cause instanceof Error ? cause.message : cause); + } process.exit(1); } diff --git a/packages/@d-zero/replicator/package.json b/packages/@d-zero/replicator/package.json index 0971adc2..8d9a44bc 100644 --- a/packages/@d-zero/replicator/package.json +++ b/packages/@d-zero/replicator/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { @@ -32,7 +35,7 @@ "@d-zero/shared": "0.22.5", "ansi-colors": "4.1.3", "minimist": "1.2.8", - "puppeteer": "25.3.0" + "puppeteer": "25.5.0" }, "devDependencies": { "@types/minimist": "1.2.5" diff --git a/packages/@d-zero/replicator/src/child-process.ts b/packages/@d-zero/replicator/src/child-process.ts index 1169bd79..bc7bce7c 100644 --- a/packages/@d-zero/replicator/src/child-process.ts +++ b/packages/@d-zero/replicator/src/child-process.ts @@ -4,6 +4,7 @@ import type { HTTPResponse } from 'puppeteer'; import { createChildProcess } from '@d-zero/puppeteer-dealer'; import { beforePageScan, devicePresets } from '@d-zero/puppeteer-page-scan'; import { scrollAllOver } from '@d-zero/puppeteer-scroll'; +import { disposableListener } from '@d-zero/shared/disposable-listener'; import { encodeResourcePath } from '@d-zero/shared/encode-resource-path'; createChildProcess((param) => { @@ -52,7 +53,12 @@ createChildProcess((param) => { resourcePaths.add(encodeResourcePath(resourceUrlObj, mimeType)); }; - page.on('response', responseHandler); + // `using` により、beforePageScan()/scrollAllOver() が throw しても + // スコープ脱出時に確実にリスナーが解除される。この page は同一 + // 子プロセス内で次の URL にも再利用されるため、例外パスでの + // 解除漏れはリスナーの累積に直結する。 + using _responseListener = disposableListener(page, 'response', responseHandler); + void _responseListener; if (username && password) { await page.authenticate({ username, password }); @@ -85,8 +91,6 @@ createChildProcess((param) => { }); } - page.off('response', responseHandler); - logger(`📦 Collected ${resourcePaths.size} resources`); return { diff --git a/packages/@d-zero/replicator/src/cli.ts b/packages/@d-zero/replicator/src/cli.ts index c3a34f3f..0154a44c 100644 --- a/packages/@d-zero/replicator/src/cli.ts +++ b/packages/@d-zero/replicator/src/cli.ts @@ -4,7 +4,12 @@ import type { BaseCLIOptions } from '@d-zero/cli-core'; import { createRequire } from 'node:module'; -import { createCLI, parseCommonOptions, parseList } from '@d-zero/cli-core'; +import { + createCLI, + parseCommonOptions, + parseList, + unwrapSuppressedError, +} from '@d-zero/cli-core'; import { parseDevicesOption } from '@d-zero/puppeteer-page-scan'; import { replicate } from './index.js'; @@ -108,16 +113,20 @@ try { password, }); } catch (error) { - if (error instanceof Error) { - // eslint-disable-next-line no-console - console.error('❌ Error:', error.message); - if (options.verbose) { + // SuppressedError(using スコープ内で本体と dispose の両方が例外を投げた場合) + // を分解し、定型メッセージの裏に隠れる根本原因を両方とも出力する + for (const cause of unwrapSuppressedError(error)) { + if (cause instanceof Error) { // eslint-disable-next-line no-console - console.error('Stack trace:', error.stack); + console.error('❌ Error:', cause.message); + if (options.verbose) { + // eslint-disable-next-line no-console + console.error('Stack trace:', cause.stack); + } + } else { + // eslint-disable-next-line no-console + console.error('❌ Unknown error:', cause); } - } else { - // eslint-disable-next-line no-console - console.error('❌ Unknown error:', error); } process.exit(1); } diff --git a/packages/@d-zero/roar/package.json b/packages/@d-zero/roar/package.json index c47237a4..ed0b9800 100644 --- a/packages/@d-zero/roar/package.json +++ b/packages/@d-zero/roar/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "exports": { ".": { diff --git a/packages/@d-zero/shared/README.md b/packages/@d-zero/shared/README.md index f8c37131..46694cae 100644 --- a/packages/@d-zero/shared/README.md +++ b/packages/@d-zero/shared/README.md @@ -6,13 +6,15 @@ ### コアユーティリティ -| Import Path | Description | -| ------------------------------ | ---------------------------------------------------------------------- | -| `@d-zero/shared/cache` | ファイルシステムにデータを保存するシンプルなキャッシュシステムのクラス | -| `@d-zero/shared/config-reader` | フロントマターをサポートする設定ファイルリーダー | -| `@d-zero/shared/deferred` | 遅延解決可能なPromiseクラス | -| `@d-zero/shared/hash` | 文字列のSHA-256ハッシュ値を生成 | -| `@d-zero/shared/types` | TypeScript型定義 | +| Import Path | Description | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------- | +| `@d-zero/shared/cache` | ファイルシステムにデータを保存するシンプルなキャッシュシステムのクラス | +| `@d-zero/shared/config-reader` | フロントマターをサポートする設定ファイルリーダー | +| `@d-zero/shared/deferred` | 遅延解決可能なPromiseクラス | +| `@d-zero/shared/disposable-listener` | イベントリスナーを登録し、`using` 宣言のスコープ脱出時に自動解除する `Disposable` を返す関数 | +| `@d-zero/shared/mkdtemp-disposable` | 一時ディレクトリを作成し、`using` 宣言のスコープ脱出時に自動削除する `AsyncDisposable` ハンドルを返す関数 | +| `@d-zero/shared/hash` | 文字列のSHA-256ハッシュ値を生成 | +| `@d-zero/shared/types` | TypeScript型定義 | ### ランダム数値生成と遅延機能 diff --git a/packages/@d-zero/shared/package.json b/packages/@d-zero/shared/package.json index 83c22197..759286d5 100644 --- a/packages/@d-zero/shared/package.json +++ b/packages/@d-zero/shared/package.json @@ -7,6 +7,9 @@ "publishConfig": { "access": "public" }, + "engines": { + "node": ">=24.11.0" + }, "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -177,6 +180,14 @@ "./safe-filepath": { "import": "./dist/safe-filepath.js", "types": "./dist/safe-filepath.d.ts" + }, + "./disposable-listener": { + "import": "./dist/disposable-listener.js", + "types": "./dist/disposable-listener.d.ts" + }, + "./mkdtemp-disposable": { + "import": "./dist/mkdtemp-disposable.js", + "types": "./dist/mkdtemp-disposable.d.ts" } }, "files": [ diff --git a/packages/@d-zero/shared/src/disposable-listener.spec.ts b/packages/@d-zero/shared/src/disposable-listener.spec.ts new file mode 100644 index 00000000..aba19d73 --- /dev/null +++ b/packages/@d-zero/shared/src/disposable-listener.spec.ts @@ -0,0 +1,41 @@ +import { EventEmitter } from 'node:events'; + +import { describe, test, expect, vi } from 'vitest'; + +import { disposableListener } from './disposable-listener.js'; + +describe('disposableListener', () => { + test('registers the listener on creation and removes it on scope exit', () => { + // eslint-disable-next-line unicorn/prefer-event-target -- disposableListener は on/off 型エミッタ(EventEmitter / puppeteer Page)専用で、addEventListener 型の EventTarget は対象外 + const emitter = new EventEmitter(); + const listener = vi.fn(); + + { + using _sub = disposableListener(emitter, 'ping', listener); + void _sub; + emitter.emit('ping', 'a'); + expect(listener).toHaveBeenCalledWith('a'); + } + + emitter.emit('ping', 'b'); + expect(listener).toHaveBeenCalledTimes(1); + expect(emitter.listenerCount('ping')).toBe(0); + }); + + test('removes only its own listener, leaving others registered', () => { + // eslint-disable-next-line unicorn/prefer-event-target -- disposableListener は on/off 型エミッタ(EventEmitter / puppeteer Page)専用で、addEventListener 型の EventTarget は対象外 + const emitter = new EventEmitter(); + const managed = vi.fn(); + const unmanaged = vi.fn(); + emitter.on('ping', unmanaged); + + { + using _sub = disposableListener(emitter, 'ping', managed); + void _sub; + } + + emitter.emit('ping'); + expect(managed).not.toHaveBeenCalled(); + expect(unmanaged).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/@d-zero/shared/src/disposable-listener.ts b/packages/@d-zero/shared/src/disposable-listener.ts new file mode 100644 index 00000000..e17d02c4 --- /dev/null +++ b/packages/@d-zero/shared/src/disposable-listener.ts @@ -0,0 +1,50 @@ +/** + * `on` / `off` の 2 メソッドを持つイベント発行元の構造型。 + * Node.js の `EventEmitter` と puppeteer の `Page` / `Browser` の + * どちらも構造的にこの型を満たす。 + * @template E - イベント名の型 + * @template A - リスナーに渡される引数のタプル型 + */ +export type ListenerTarget = { + off(event: E, listener: (...args: A) => void): unknown; + on(event: E, listener: (...args: A) => void): unknown; +}; + +/** + * イベントリスナーを登録し、`using` 宣言のスコープ脱出時に自動で解除する + * `Disposable` を返す。 + * + * `page.on(...)` / `emitter.on(...)` の登録と解除を一箇所にまとめ、 + * 例外発生時の解除漏れを防ぐために使用する。Node 標準の `EventEmitter` と + * puppeteer の `Page` / `Browser` のどちらにも使える。 + * @template E - イベント名の型 + * @template A - リスナーに渡される引数のタプル型 + * @param target - `on`/`off` を持つイベント発行元(`EventEmitter` や puppeteer の `Page` など) + * @param event - 購読するイベント名 + * @param listener - イベント発生時に呼ばれるコールバック + * @returns スコープ脱出時に `off` を呼び出す `Disposable` + * @example + * ```ts + * { + * using _sub = disposableListener(page, 'console', (msg) => console.log(msg.text())); + * await page.goto(url); + * } // スコープ脱出時に自動で page.off('console', ...) が呼ばれる + * ``` + */ +export function disposableListener( + // NoInfer がないと E/A が target 側(例: puppeteer の Page#on の generic + // シグネチャ)からも推論されて E が `string` に広がり、Page などの型付き + // エミッタで型エラーになる。event と listener だけから推論させることで、 + // Node 標準 EventEmitter と puppeteer の Page/Browser の両方に適合する。 + target: NoInfer>, + event: E, + listener: (...args: A) => void, +): Disposable { + target.on(event, listener); + + return { + [Symbol.dispose]() { + target.off(event, listener); + }, + }; +} diff --git a/packages/@d-zero/shared/src/mkdtemp-disposable.spec.ts b/packages/@d-zero/shared/src/mkdtemp-disposable.spec.ts new file mode 100644 index 00000000..c873d6de --- /dev/null +++ b/packages/@d-zero/shared/src/mkdtemp-disposable.spec.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, test, expect } from 'vitest'; + +import { mkdtempDisposable } from './mkdtemp-disposable.js'; + +describe('mkdtempDisposable', () => { + test('creates a directory and removes it on scope exit', async () => { + let createdPath: string; + { + await using tmpDir = await mkdtempDisposable( + path.join(os.tmpdir(), 'mkdtemp-disposable-spec-'), + ); + createdPath = tmpDir.path; + const stat = await fs.stat(createdPath); + expect(stat.isDirectory()).toBe(true); + } + await expect(fs.stat(createdPath)).rejects.toThrow(); + }); + + test('resolves a bare (relative) prefix under os.tmpdir(), not the CWD', async () => { + await using tmpDir = await mkdtempDisposable('mkdtemp-disposable-spec-'); + expect(path.isAbsolute(tmpDir.path)).toBe(true); + // macOS では os.tmpdir() がシンボリックリンク(/var → /private/var)の + // ことがあるため、実体パス同士で比較する + const realTmp = await fs.realpath(os.tmpdir()); + const realDir = await fs.realpath(tmpDir.path); + expect(realDir.startsWith(realTmp)).toBe(true); + expect(realDir.startsWith(await fs.realpath(process.cwd()))).toBe(false); + }); + + test('removes non-empty directories recursively', async () => { + let createdPath: string; + { + await using tmpDir = await mkdtempDisposable( + path.join(os.tmpdir(), 'mkdtemp-disposable-spec-'), + ); + createdPath = tmpDir.path; + await fs.mkdir(path.join(createdPath, 'nested')); + await fs.writeFile(path.join(createdPath, 'nested', 'file.txt'), 'data'); + } + await expect(fs.stat(createdPath)).rejects.toThrow(); + }); +}); diff --git a/packages/@d-zero/shared/src/mkdtemp-disposable.ts b/packages/@d-zero/shared/src/mkdtemp-disposable.ts new file mode 100644 index 00000000..2b1220c2 --- /dev/null +++ b/packages/@d-zero/shared/src/mkdtemp-disposable.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * 一時ディレクトリを作成し、`using` 宣言のスコープ脱出時に自動で削除する + * `AsyncDisposable` ハンドルを返す。 + * + * `prefix` が相対パスの場合は OS の一時ディレクトリ配下に解決される + * (素の `fs.mkdtemp()` と異なり、プロセスのカレントディレクトリ直下に + * 作成してしまう事故を防ぐ)。絶対パスの場合はそのまま使用する。 + * @param prefix - 一時ディレクトリ名のプレフィックス。省略時は OS の一時ディレクトリ配下に `d-zero-` プレフィックスで作成する + * @returns 作成したディレクトリの絶対パス(`path`)と、スコープ脱出時に再帰削除する `AsyncDisposable` + * @example + * ```ts + * { + * await using tmpDir = await mkdtempDisposable('my-tool-'); + * await writeFile(`${tmpDir.path}/data.json`, '{}'); + * } // スコープ脱出時に自動で tmpDir.path が再帰削除される + * ``` + */ +export async function mkdtempDisposable( + prefix?: string, +): Promise<{ path: string } & AsyncDisposable> { + const resolvedPrefix = + prefix == null + ? path.join(os.tmpdir(), 'd-zero-') + : path.isAbsolute(prefix) + ? prefix + : path.join(os.tmpdir(), prefix); + const dirPath = await fs.mkdtemp(resolvedPrefix); + + return { + path: dirPath, + async [Symbol.asyncDispose]() { + await fs.rm(dirPath, { recursive: true, force: true }); + }, + }; +} diff --git a/packages/@d-zero/shared/src/race-with-timeout.spec.ts b/packages/@d-zero/shared/src/race-with-timeout.spec.ts new file mode 100644 index 00000000..30e3cbb9 --- /dev/null +++ b/packages/@d-zero/shared/src/race-with-timeout.spec.ts @@ -0,0 +1,52 @@ +import { describe, test, expect, vi, afterEach } from 'vitest'; + +import { raceWithTimeout } from './race-with-timeout.js'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('raceWithTimeout', () => { + test('resolves with the result when the promise settles before the timeout', async () => { + const { result, timeout } = await raceWithTimeout(() => 42, 5000); + + expect(result).toBe(42); + expect(timeout).toBe(false); + }); + + test('resolves with timeout:true when the timer wins', async () => { + vi.useFakeTimers(); + + const promise = raceWithTimeout(() => new Promise(() => {}), 5000); + await vi.advanceTimersByTimeAsync(5000); + const { result, timeout } = await promise; + + expect(result).toBeUndefined(); + expect(timeout).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + test('clears the timer when the promise resolves (loser-side cleanup)', async () => { + vi.useFakeTimers(); + + await raceWithTimeout(() => 'done', 5000); + + expect(vi.getTimerCount()).toBe(0); + }); + + test('clears the timer even when the promise rejects (the timer must not outlive a rejected race)', async () => { + vi.useFakeTimers(); + + await expect( + raceWithTimeout(() => Promise.reject(new Error('boom')), 5000), + ).rejects.toThrow('boom'); + + expect(vi.getTimerCount()).toBe(0); + }); + + test('rejects with the original error when the promise rejects', async () => { + await expect( + raceWithTimeout(() => Promise.reject(new Error('original cause')), 5000), + ).rejects.toThrow('original cause'); + }); +}); diff --git a/packages/@d-zero/shared/src/race-with-timeout.ts b/packages/@d-zero/shared/src/race-with-timeout.ts index b1d941b0..5b1bd4b0 100644 --- a/packages/@d-zero/shared/src/race-with-timeout.ts +++ b/packages/@d-zero/shared/src/race-with-timeout.ts @@ -27,17 +27,20 @@ export type RaceWithTimeoutResult = * console.log('Operation succeeded with result:', result); * } * ``` + * @todo `vi.useFakeTimers()`(@sinonjs/fake-timers)のフェイク Timeout が + * `Symbol.dispose` を実装したら、try/finally を撤去して + * `using timeoutId = setTimeout(...)` に移行する(Node 24 のネイティブ Timeout は + * 実装済みだが、フェイクタイマー環境で壊れるため採用していない)。 */ export async function raceWithTimeout( promise: () => Promise | T, timeout: number, ): Promise> { - let timeoutId: NodeJS.Timeout | undefined; + const { promise: timeoutSignal, resolve: onTimeout } = Promise.withResolvers(); + const timeoutId = setTimeout(onTimeout, timeout); const timer = async () => { - await new Promise((r) => { - timeoutId = setTimeout(r, timeout); - }); + await timeoutSignal; return { result: undefined, timeout: true } as const; }; @@ -46,10 +49,12 @@ export async function raceWithTimeout( return { result, timeout: false } as const; }; - const result = await Promise.race([timer(), challenger()]); - if (timeoutId) { + // finally により、challenger 側が reject して Promise.race が早期に throw + // してもタイマーが必ず解放される。finally の外に clearTimeout を置くと + // reject 経路で到達せず、タイマーが最大 timeout ms 生存する。 + try { + return await Promise.race([timer(), challenger()]); + } finally { clearTimeout(timeoutId); } - - return result; } diff --git a/tsconfig.json b/tsconfig.json index 4c34a9c4..20b5b5ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,6 @@ { - "extends": ["@d-zero/tsconfig"] + "extends": ["@d-zero/tsconfig"], + "compilerOptions": { + "target": "ESNext" + } } diff --git a/yarn.lock b/yarn.lock index 77572f43..03bea72e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1070,10 +1070,10 @@ __metadata: "@d-zero/db-wcag": "npm:1.0.0-alpha.1" "@d-zero/shared": "npm:0.22.5" axe-core: "npm:4.12.1" - puppeteer: "npm:25.3.0" + puppeteer: "npm:25.5.0" peerDependencies: axe-core: 4.12.1 - puppeteer: 25.2.1 + puppeteer: 25.5.0 languageName: unknown linkType: soft @@ -1087,7 +1087,7 @@ __metadata: "@d-zero/shared": "npm:0.22.5" ansi-colors: "npm:4.1.3" color-contrast-checker: "npm:2.1.0" - puppeteer: "npm:25.3.0" + puppeteer: "npm:25.5.0" languageName: unknown linkType: soft @@ -1098,9 +1098,9 @@ __metadata: "@d-zero/a11y-check-core": "npm:0.7.14" "@d-zero/shared": "npm:0.22.5" ansi-colors: "npm:4.1.3" - puppeteer: "npm:25.3.0" + puppeteer: "npm:25.5.0" peerDependencies: - puppeteer: 25.2.1 + puppeteer: 25.5.0 languageName: unknown linkType: soft @@ -1132,10 +1132,11 @@ __metadata: resolution: "@d-zero/anatomist@workspace:packages/@d-zero/anatomist" dependencies: "@d-zero/beholder": "npm:4.2.2" + "@d-zero/cli-core": "npm:1.3.16" "@d-zero/dealer": "npm:1.10.4" "@d-zero/puppeteer-page-scan": "npm:4.6.8" "@d-zero/shared": "npm:0.22.5" - puppeteer: "npm:25.3.0" + puppeteer: "npm:25.5.0" bin: anatomist: dist/cli.js languageName: unknown @@ -1165,7 +1166,7 @@ __metadata: parse-diff: "npm:0.12.0" pixelmatch: "npm:7.2.0" pngjs: "npm:7.0.0" - puppeteer: "npm:25.3.0" + puppeteer: "npm:25.5.0" strip-ansi: "npm:7.2.0" bin: archaeologist: dist/cli.js @@ -1200,7 +1201,7 @@ __metadata: "@types/jsdom": "npm:28.0.3" debug: "npm:4.4.3" jsdom: "npm:29.1.1" - puppeteer: "npm:25.3.0" + puppeteer: "npm:25.5.0" simple-wappalyzer: "npm:1.1.100" languageName: unknown linkType: soft @@ -1364,6 +1365,7 @@ __metadata: version: 0.0.0-use.local resolution: "@d-zero/page-cluster@workspace:packages/@d-zero/page-cluster" dependencies: + "@d-zero/cli-core": "npm:1.3.16" "@d-zero/dealer": "npm:1.10.4" "@d-zero/shared": "npm:0.22.5" htmlparser2: "npm:12.0.0" @@ -1400,7 +1402,7 @@ __metadata: dayjs: "npm:1.11.21" front-matter: "npm:4.0.2" minimist: "npm:1.2.8" - puppeteer: "npm:25.3.0" + puppeteer: "npm:25.5.0" bin: print: dist/cli.js languageName: unknown @@ -1424,11 +1426,11 @@ __metadata: "@d-zero/shared": "npm:0.22.5" ansi-colors: "npm:4.1.3" debug: "npm:4.4.3" - puppeteer: "npm:25.3.0" + puppeteer: "npm:25.5.0" puppeteer-extra: "npm:3.3.6" puppeteer-extra-plugin-stealth: "npm:2.11.2" peerDependencies: - puppeteer: 25.2.1 + puppeteer: 25.5.0 languageName: unknown linkType: soft @@ -1447,9 +1449,9 @@ __metadata: "@d-zero/puppeteer-general-actions": "npm:1.2.6" "@d-zero/puppeteer-scroll": "npm:4.0.11" "@d-zero/shared": "npm:0.22.5" - puppeteer: "npm:25.3.0" + puppeteer: "npm:25.5.0" peerDependencies: - puppeteer: 25.2.1 + puppeteer: 25.5.0 languageName: unknown linkType: soft @@ -1460,9 +1462,9 @@ __metadata: "@d-zero/puppeteer-general-actions": "npm:1.2.6" "@d-zero/puppeteer-page-scan": "npm:4.6.8" "@d-zero/shared": "npm:0.22.5" - puppeteer: "npm:25.3.0" + puppeteer: "npm:25.5.0" peerDependencies: - puppeteer: 25.2.1 + puppeteer: 25.5.0 languageName: unknown linkType: soft @@ -1471,9 +1473,9 @@ __metadata: resolution: "@d-zero/puppeteer-scroll@workspace:packages/@d-zero/puppeteer-scroll" dependencies: "@d-zero/shared": "npm:0.22.5" - puppeteer: "npm:25.3.0" + puppeteer: "npm:25.5.0" peerDependencies: - puppeteer: 25.2.1 + puppeteer: 25.5.0 languageName: unknown linkType: soft @@ -1515,7 +1517,7 @@ __metadata: "@types/minimist": "npm:1.2.5" ansi-colors: "npm:4.1.3" minimist: "npm:1.2.8" - puppeteer: "npm:25.3.0" + puppeteer: "npm:25.5.0" bin: replicator: dist/cli.js languageName: unknown @@ -3159,9 +3161,9 @@ __metadata: languageName: node linkType: hard -"@puppeteer/browsers@npm:3.0.6": - version: 3.0.6 - resolution: "@puppeteer/browsers@npm:3.0.6" +"@puppeteer/browsers@npm:3.1.0": + version: 3.1.0 + resolution: "@puppeteer/browsers@npm:3.1.0" dependencies: modern-tar: "npm:^0.7.6" yargs: "npm:^18.0.0" @@ -3175,7 +3177,7 @@ __metadata: optional: true bin: browsers: lib/main-cli.js - checksum: 10c0/145c9136b6a4a2de0a1a6413ab92eb791384a8d103dea253a487eacaaabc66927205dbe843f899dfb961fb0fddc164e17ad6fbad83bc40ff39513a0a0b51172a + checksum: 10c0/cab18c02f6e77a8d261a08a062c3cebd1227666d425f3731bc91cd001331beeaf165046bc20d3d25dc1eaf5869a6d9f4f8fc5d3d76f1dace28d79b616846291f languageName: node linkType: hard @@ -5432,15 +5434,15 @@ __metadata: languageName: node linkType: hard -"chromium-bidi@npm:16.0.1": - version: 16.0.1 - resolution: "chromium-bidi@npm:16.0.1" +"chromium-bidi@npm:17.0.2": + version: 17.0.2 + resolution: "chromium-bidi@npm:17.0.2" dependencies: mitt: "npm:^3.0.1" zod: "npm:^3.24.1" peerDependencies: devtools-protocol: "*" - checksum: 10c0/cfd67f14c4e1f2d2f5e2a3661300c7f487f964445314956eb4005d46c056814fa9d1dcb854a2c6c3537de37d7c134a3889703901f948524d994ae8b76cf7e9bc + checksum: 10c0/cc1fcbdf2309f2f0083096c78a299342918c6dc8e80158487aada8e2ee73160ed7301bb54c5520274e4514f2a14f05e45d730a536e0a95f2e204dd8bf42eebee languageName: node linkType: hard @@ -6567,10 +6569,10 @@ __metadata: languageName: node linkType: hard -"devtools-protocol@npm:0.0.1638949": - version: 0.0.1638949 - resolution: "devtools-protocol@npm:0.0.1638949" - checksum: 10c0/68aab193628a6a9c06487c1d68f39a106f50e3f83784a8767ee8d703a1f1fde4f055a59f239bda3a0bb76941fecf1da3122d3b66b81b5ea36dc0532223479c79 +"devtools-protocol@npm:0.0.1653615": + version: 0.0.1653615 + resolution: "devtools-protocol@npm:0.0.1653615" + checksum: 10c0/2a8e63aac7fb7ee4a7eef0e75bf17b7f03f44f085c79ac4e2c53b82e86f908094c1d1b2a0f85f2a7742b3d420e5a3ff8e522bca3dc609c98850239dbd2ce5428 languageName: node linkType: hard @@ -12459,17 +12461,17 @@ __metadata: languageName: node linkType: hard -"puppeteer-core@npm:25.3.0": - version: 25.3.0 - resolution: "puppeteer-core@npm:25.3.0" +"puppeteer-core@npm:25.5.0": + version: 25.5.0 + resolution: "puppeteer-core@npm:25.5.0" dependencies: - "@puppeteer/browsers": "npm:3.0.6" - chromium-bidi: "npm:16.0.1" - devtools-protocol: "npm:0.0.1638949" + "@puppeteer/browsers": "npm:3.1.0" + chromium-bidi: "npm:17.0.2" + devtools-protocol: "npm:0.0.1653615" typed-query-selector: "npm:^2.12.2" webdriver-bidi-protocol: "npm:0.4.2" - ws: "npm:^8.21.0" - checksum: 10c0/95453c7553a1795c2cf99991783235c1ad6fed5f2601e5910ab379f4283e8a5de93f6da9735c1bf8bfa3d77ee2b333fd11537174fd524bb6877ed1797ae92e30 + ws: "npm:^8.21.1" + checksum: 10c0/137132e4977ac9aa77c6ddbfb12a9412f3c67a312381800b85a602cabbe3a3659fe41c64ee8c5ab42d3793ba8933987145e7ba06d85999cae406446b3fcb077a languageName: node linkType: hard @@ -12573,19 +12575,19 @@ __metadata: languageName: node linkType: hard -"puppeteer@npm:25.3.0": - version: 25.3.0 - resolution: "puppeteer@npm:25.3.0" +"puppeteer@npm:25.5.0": + version: 25.5.0 + resolution: "puppeteer@npm:25.5.0" dependencies: - "@puppeteer/browsers": "npm:3.0.6" - chromium-bidi: "npm:16.0.1" - devtools-protocol: "npm:0.0.1638949" + "@puppeteer/browsers": "npm:3.1.0" + chromium-bidi: "npm:17.0.2" + devtools-protocol: "npm:0.0.1653615" lilconfig: "npm:^3.1.3" - puppeteer-core: "npm:25.3.0" + puppeteer-core: "npm:25.5.0" typed-query-selector: "npm:^2.12.2" bin: puppeteer: lib/puppeteer/node/cli.js - checksum: 10c0/0c4a52dad12f30f8d7f90f2f8cde3cff686b0d496d482cedd5206a9fb40af38b958ab33f4ff4e086af0937a5af011ba4351bd556cc07924af208edbcdaf96fc2 + checksum: 10c0/c331e7ecb1f6c3f675490ba793307b5f6f7c7c0ef748f65717fe01bae0b9804d29c39c2cffc00f2857e4ed6939f5f916bf917bd465b260675c0ddc114f87def7 languageName: node linkType: hard @@ -15776,6 +15778,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.21.1": + version: 8.21.2 + resolution: "ws@npm:8.21.2" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/82d687bbfbccce04e91266ff9911b27c67ca622fd8fbacc6a64d467a43fbd7ecaa3ef6d820b315c060682f901463f57cac01a02b00c4379f49c747ec7da83169 + languageName: node + linkType: hard + "xdg-basedir@npm:^5.1.0": version: 5.1.0 resolution: "xdg-basedir@npm:5.1.0"