diff --git a/src/batch/commands/attach-batch-options.ts b/src/batch/commands/attach-batch-options.ts new file mode 100644 index 0000000..1a79b08 --- /dev/null +++ b/src/batch/commands/attach-batch-options.ts @@ -0,0 +1,23 @@ +import type { Command } from "commander"; +import { parseConcurrency } from "../services/resolve-concurrency.js"; + +/** + * Attach the batch flags to a scrape-style command. In batch mode the existing + * `-o, --output` flag is interpreted as an output directory. + */ +export function attachBatchOptions(command: Command): Command { + return command + .option( + "--input-file ", + "Run each line/row of a .txt or .csv file as a batch item" + ) + .option( + "--input-column ", + "Column to read inputs from when --input-file is a CSV" + ) + .option( + "--concurrency ", + "Max requests to run in parallel in batch mode (default: 4)", + parseConcurrency + ); +} diff --git a/src/batch/services/binary-directory-sink.ts b/src/batch/services/binary-directory-sink.ts new file mode 100644 index 0000000..f0e8539 --- /dev/null +++ b/src/batch/services/binary-directory-sink.ts @@ -0,0 +1,44 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { BatchResult } from "../types/batch-result.js"; +import type { BatchSink } from "../types/batch-sink.js"; +import { batchItemFilename } from "./batch-item-filename.js"; +import { toBatchRecord } from "./batch-record.js"; +import { uniqueName } from "./unique-name.js"; + +function isBytes(value: unknown): value is Uint8Array { + return value instanceof Uint8Array; +} + +/** + * Per-item sink for binary (e.g. screenshot) batches: writes `.` for + * each successful item and `.error.json` for failures. Used when the + * target produces binary output, which cannot be streamed as ndjson. + */ +export function createBinaryDirectorySink( + dir: string, + extension = "png" +): BatchSink { + mkdirSync(dir, { recursive: true }); + const used = new Set(); + + return { + write(result: BatchResult): void { + const name = uniqueName( + batchItemFilename(result.input, result.index), + used + ); + + if (result.ok && isBytes(result.data)) { + writeFileSync(join(dir, `${name}.${extension}`), result.data); + return; + } + + writeFileSync( + join(dir, `${name}.error.json`), + JSON.stringify(toBatchRecord(result), null, 2), + "utf8" + ); + }, + }; +} diff --git a/src/batch/services/directory-sink.ts b/src/batch/services/directory-sink.ts index b0ee8c8..a493566 100644 --- a/src/batch/services/directory-sink.ts +++ b/src/batch/services/directory-sink.ts @@ -4,27 +4,12 @@ import type { BatchResult } from "../types/batch-result.js"; import type { BatchSink } from "../types/batch-sink.js"; import { batchItemFilename } from "./batch-item-filename.js"; import { toBatchRecord } from "./batch-record.js"; +import { uniqueName } from "./unique-name.js"; export interface DirectorySinkOptions { pretty?: boolean; } -function uniqueName(base: string, used: Set): string { - if (!used.has(base)) { - used.add(base); - return base; - } - - let suffix = 2; - let candidate = `${base}-${suffix}`; - while (used.has(candidate)) { - suffix++; - candidate = `${base}-${suffix}`; - } - used.add(candidate); - return candidate; -} - /** * Per-item sink: writes one `.json` file per result into `dir` (created * if needed). File names come from the input URL slug or row index, deduped on diff --git a/src/batch/services/resolve-concurrency.ts b/src/batch/services/resolve-concurrency.ts new file mode 100644 index 0000000..f6975ef --- /dev/null +++ b/src/batch/services/resolve-concurrency.ts @@ -0,0 +1,15 @@ +import { ValidationError } from "@decodo/sdk-ts"; +import { DEFAULT_CONCURRENCY } from "../constants.js"; + +/** Commander option parser for `--concurrency`. */ +export function parseConcurrency(value: string): number { + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed) || parsed < 1) { + throw new ValidationError("--concurrency must be a positive integer."); + } + return parsed; +} + +export function resolveConcurrency(value: number | undefined): number { + return value ?? DEFAULT_CONCURRENCY; +} diff --git a/src/batch/services/run-batch-command.ts b/src/batch/services/run-batch-command.ts new file mode 100644 index 0000000..0a3861b --- /dev/null +++ b/src/batch/services/run-batch-command.ts @@ -0,0 +1,73 @@ +import { ValidationError } from "@decodo/sdk-ts"; +import { CliUsageError } from "../../platform/services/handle-cli-error.js"; +import type { BatchSummary } from "../types/batch-result.js"; +import type { BatchSink } from "../types/batch-sink.js"; +import { createBinaryDirectorySink } from "./binary-directory-sink.js"; +import { createDirectorySink } from "./directory-sink.js"; +import { createNdjsonStdoutSink } from "./ndjson-stdout-sink.js"; +import { readInputFile } from "./read-input-file.js"; +import { runBatch } from "./run-batch.js"; + +export interface RunBatchCommandOptions { + /** Target produces binary output (e.g. screenshots). Requires `output`. */ + binary?: boolean; + concurrency: number; + inputColumn?: string; + inputFile: string; + /** Output directory; when set, one file per item is written here. */ + output?: string; + pretty?: boolean; + /** Runs a single input; resolves to the payload (or bytes) to emit. */ + scrapeItem: (input: string) => Promise; +} + +function selectSink(options: RunBatchCommandOptions): BatchSink { + if (options.binary) { + if (!options.output) { + throw new CliUsageError( + "Batch mode for binary output requires -o to write files." + ); + } + return createBinaryDirectorySink(options.output); + } + + if (options.output) { + return createDirectorySink(options.output, { pretty: options.pretty }); + } + + return createNdjsonStdoutSink(); +} + +/** + * Drive a batch run end to end: stream inputs from the file, execute each via + * `scrapeItem` with bounded concurrency, and emit results to the appropriate + * sink (ndjson stdout by default, or one file per item when `output` is set). + * Per-item failures are recorded, not fatal; a summary is printed to stderr. + */ +export async function runBatchCommand( + options: RunBatchCommandOptions +): Promise { + const sink = selectSink(options); + const items = readInputFile(options.inputFile, { + inputColumn: options.inputColumn, + }); + + const summary = await runBatch({ + items, + concurrency: options.concurrency, + worker: (item) => options.scrapeItem(item.input), + onResult: (result) => sink.write(result), + }); + + await sink.close?.(); + + if (summary.total === 0) { + throw new ValidationError("Input file produced no inputs."); + } + + console.error( + `Batch complete: ${summary.succeeded} succeeded, ${summary.failed} failed (${summary.total} total).` + ); + + return summary; +} diff --git a/src/batch/services/unique-name.ts b/src/batch/services/unique-name.ts new file mode 100644 index 0000000..722708e --- /dev/null +++ b/src/batch/services/unique-name.ts @@ -0,0 +1,19 @@ +/** + * Return `base` if unused, otherwise append the smallest `-N` suffix that is + * free. Records the chosen name in `used`. + */ +export function uniqueName(base: string, used: Set): string { + if (!used.has(base)) { + used.add(base); + return base; + } + + let suffix = 2; + while (used.has(`${base}-${suffix}`)) { + suffix++; + } + + const candidate = `${base}-${suffix}`; + used.add(candidate); + return candidate; +} diff --git a/src/batch/types/batch-flags.ts b/src/batch/types/batch-flags.ts new file mode 100644 index 0000000..148b3d6 --- /dev/null +++ b/src/batch/types/batch-flags.ts @@ -0,0 +1,6 @@ +/** Batch flags attached to scrape-style commands. */ +export interface BatchFlags { + concurrency?: number; + inputColumn?: string; + inputFile?: string; +} diff --git a/src/scrape/commands/scrape.ts b/src/scrape/commands/scrape.ts index 3dd2f16..af647ec 100644 --- a/src/scrape/commands/scrape.ts +++ b/src/scrape/commands/scrape.ts @@ -1,8 +1,10 @@ import { type DecodoSchema, Target, ValidationError } from "@decodo/sdk-ts"; import { Command } from "commander"; +import { attachBatchOptions } from "../../batch/commands/attach-batch-options.js"; import { attachScrapeOutputOptions } from "../../output/commands/attach-output-options.js"; import { applyRequestDefaults } from "../../output/services/apply-request-defaults.js"; import type { OutputOptions } from "../../output/types/output-options.js"; +import { CliUsageError } from "../../platform/services/handle-cli-error.js"; import { resolveTarget } from "../services/resolve-target.js"; import { createTargetAction } from "../services/run-target-scrape.js"; import type { ScrapeOptions } from "../types/scrape-command.js"; @@ -32,17 +34,18 @@ export function createScrapeCommand(schema: DecodoSchema): Command { .description( "Scrape a URL with the universal target (markdown by default). Use decodo universal for --markdown, --parse, and other API flags." ) - .argument("", "URL to scrape") + .argument("[url]", "URL to scrape (omit when using --input-file)") .option("--country ", "Geo / country code (maps to geo)") .option("--headers ", "Request headers as a JSON object string") .option("--target ", "Scrape target override (default: universal)"); attachScrapeOutputOptions(command); + attachBatchOptions(command); return command.action( createTargetAction(Target.Universal, schema, (url, options) => { if (url === undefined) { - throw new Error("Missing required URL."); + throw new CliUsageError("Missing required URL."); } const opts = options as ScrapeOptions & OutputOptions; diff --git a/src/scrape/commands/screenshot.ts b/src/scrape/commands/screenshot.ts index b2d0d66..195ae53 100644 --- a/src/scrape/commands/screenshot.ts +++ b/src/scrape/commands/screenshot.ts @@ -1,6 +1,8 @@ import { type DecodoSchema, Target } from "@decodo/sdk-ts"; import { Command } from "commander"; +import { attachBatchOptions } from "../../batch/commands/attach-batch-options.js"; import { attachScrapeOutputOptions } from "../../output/commands/attach-output-options.js"; +import { CliUsageError } from "../../platform/services/handle-cli-error.js"; import { resolveTarget } from "../services/resolve-target.js"; import { createTargetAction } from "../services/run-target-scrape.js"; import type { ScreenshotOptions } from "../types/screenshot-command.js"; @@ -10,13 +12,14 @@ export function createScreenshotCommand(schema: DecodoSchema): Command { .description( "Capture a PNG screenshot (universal, headless). Use decodo universal --headless png for full options." ) - .argument("", "URL to screenshot") + .argument("[url]", "URL to screenshot (omit when using --input-file)") .option("--country ", "Geo / country code (maps to geo)") .option("--target ", "Scrape target override (default: universal)"); attachScrapeOutputOptions(command, { outputHelp: "Write PNG to file or directory (default name: .png)", }); + attachBatchOptions(command); return command.action( createTargetAction( @@ -24,7 +27,7 @@ export function createScreenshotCommand(schema: DecodoSchema): Command { schema, (url, options) => { if (url === undefined) { - throw new Error("Missing required URL."); + throw new CliUsageError("Missing required URL."); } const opts = options as ScreenshotOptions; diff --git a/src/scrape/commands/search.ts b/src/scrape/commands/search.ts index 8ac9da5..568dce7 100644 --- a/src/scrape/commands/search.ts +++ b/src/scrape/commands/search.ts @@ -1,7 +1,9 @@ import { type DecodoSchema, Target, ValidationError } from "@decodo/sdk-ts"; import { Command, Option } from "commander"; +import { attachBatchOptions } from "../../batch/commands/attach-batch-options.js"; import { attachScrapeOutputOptions } from "../../output/commands/attach-output-options.js"; import { applyRequestDefaults } from "../../output/services/apply-request-defaults.js"; +import { CliUsageError } from "../../platform/services/handle-cli-error.js"; import { resolveTarget } from "../services/resolve-target.js"; import { createTargetAction } from "../services/run-target-scrape.js"; import type { SearchOptions } from "../types/search-command.js"; @@ -52,7 +54,7 @@ export function createSearchCommand(schema: DecodoSchema): Command { .description( "Search the web (default: Google). Use decodo google-search or decodo bing-search for full options." ) - .argument("", "Search query") + .argument("[query]", "Search query (omit when using --input-file)") .addOption( new Option("--engine ", "Search engine") .choices(["google", "bing"]) @@ -63,11 +65,12 @@ export function createSearchCommand(schema: DecodoSchema): Command { .option("--target ", "Scrape target override"); attachScrapeOutputOptions(command); + attachBatchOptions(command); return command.action( createTargetAction(Target.GoogleSearch, schema, (query, options) => { if (query === undefined) { - throw new Error("Missing required query."); + throw new CliUsageError("Missing required query."); } const opts = options as SearchOptions; diff --git a/src/scrape/services/command-builder.ts b/src/scrape/services/command-builder.ts index f80bcf9..91a48c8 100644 --- a/src/scrape/services/command-builder.ts +++ b/src/scrape/services/command-builder.ts @@ -1,8 +1,10 @@ import type { DecodoSchema } from "@decodo/sdk-ts"; import { type Command, Option } from "commander"; import type { JSONSchema4 } from "json-schema"; +import { attachBatchOptions } from "../../batch/commands/attach-batch-options.js"; import { attachScrapeOutputOptions } from "../../output/commands/attach-output-options.js"; import { applyRequestDefaults } from "../../output/services/apply-request-defaults.js"; +import { CliUsageError } from "../../platform/services/handle-cli-error.js"; import type { TargetCommandConfig } from "../types/target-command.js"; import { snakeToCamel, snakeToKebab } from "./naming.js"; import { getPrimaryInputField } from "./primary-input.js"; @@ -81,7 +83,7 @@ export function configureTargetCommand( | undefined; const inputHelp = primarySchema?.description ?? `Primary ${primaryField} input`; - command.argument("", inputHelp); + command.argument("[input]", inputHelp); } const optionFields = Object.keys(parameterSchema?.properties ?? {}).filter( @@ -94,6 +96,7 @@ export function configureTargetCommand( } attachScrapeOutputOptions(command); + attachBatchOptions(command); return { target, primaryField, optionFields }; } @@ -109,7 +112,9 @@ export function buildScrapeBody( if (config.primaryField) { if (input === undefined) { - throw new Error(`Missing required input for ${config.primaryField}.`); + throw new CliUsageError( + `Missing required input for ${config.primaryField}.` + ); } body[config.primaryField] = input; } diff --git a/src/scrape/services/run-target-scrape.ts b/src/scrape/services/run-target-scrape.ts index 11934d6..923d6ea 100644 --- a/src/scrape/services/run-target-scrape.ts +++ b/src/scrape/services/run-target-scrape.ts @@ -2,11 +2,18 @@ import type { DecodoSchema, ScrapeRequest } from "@decodo/sdk-ts"; import type { Command } from "commander"; import { AuthRequiredError } from "../../auth/errors/auth-required-error.js"; import { resolveAuthToken } from "../../auth/services/resolve-token.js"; +import { resolveConcurrency } from "../../batch/services/resolve-concurrency.js"; +import { runBatchCommand } from "../../batch/services/run-batch-command.js"; +import type { BatchFlags } from "../../batch/types/batch-flags.js"; import { getRootOpts } from "../../cli/services/global-opts.js"; import { verboseLog } from "../../cli/services/verbose-log.js"; +import { extractPayload } from "../../output/services/extract-payload.js"; import { writeScrapeResponse } from "../../output/services/write-scrape-response.js"; import type { OutputOptions } from "../../output/types/output-options.js"; -import { handleCliError } from "../../platform/services/handle-cli-error.js"; +import { + CliUsageError, + handleCliError, +} from "../../platform/services/handle-cli-error.js"; import type { ExecuteScrapeOptions, OutputContextBuilder, @@ -14,6 +21,7 @@ import type { } from "../types/run-target-scrape.js"; import { createDecodoClient } from "./client.js"; import { buildScrapeBody, getTargetCommandConfig } from "./command-builder.js"; +import { extractPngFromResponse } from "./extract-png.js"; import { formatScrapeRequestLog } from "./format-scrape-request-log.js"; async function executeScrape({ @@ -39,6 +47,47 @@ async function executeScrape({ }); } +interface ExecuteBatchOptions { + binary: boolean; + options: Record; + resolveBody: ScrapeBodyBuilder; + schema: DecodoSchema; + token: string; + verbose: boolean; +} + +async function executeBatch({ + token, + schema, + options, + resolveBody, + binary, + verbose, +}: ExecuteBatchOptions): Promise { + const client = createDecodoClient(token, schema); + const batch = options as BatchFlags & OutputOptions; + const full = batch.full === true; + + await runBatchCommand({ + inputFile: batch.inputFile as string, + inputColumn: batch.inputColumn, + concurrency: resolveConcurrency(batch.concurrency), + output: batch.output, + pretty: batch.pretty, + binary, + scrapeItem: async (itemInput) => { + const body = resolveBody(itemInput, options); + verboseLog(verbose, formatScrapeRequestLog(body)); + const response = await client.webScrapingApi.scrape( + body as unknown as ScrapeRequest + ); + return binary + ? extractPngFromResponse(response) + : extractPayload(response, full); + }, + }); +} + export function createTargetAction( target: string, schema: DecodoSchema, @@ -60,12 +109,32 @@ export function createTargetAction( const verbose = rootOpts.verbose === true; try { + const batchMode = (options as BatchFlags).inputFile !== undefined; + if (batchMode && input !== undefined) { + throw new CliUsageError( + "Cannot combine --input-file with a positional input." + ); + } + const auth = await resolveAuthToken({ token: rootOpts.token }); verboseLog(verbose, `auth source=${auth.source}`); if (!auth.token) { throw new AuthRequiredError(); } + if (batchMode) { + const outputContext = getOutputContext?.(undefined, options); + await executeBatch({ + token: auth.token, + schema, + options, + resolveBody, + binary: outputContext?.binary?.kind === "png", + verbose, + }); + return; + } + const body = resolveBody(input, options); verboseLog(verbose, formatScrapeRequestLog(body)); const outputContext = getOutputContext?.(input, options); diff --git a/tests/batch-cli.test.ts b/tests/batch-cli.test.ts new file mode 100644 index 0000000..24b17d1 --- /dev/null +++ b/tests/batch-cli.test.ts @@ -0,0 +1,74 @@ +import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const rootDir = dirname(fileURLToPath(import.meta.url)); +const cliPath = join(rootDir, "..", "build", "esm", "index.js"); + +function runCli(args: string[]): { exitCode: number; stderr: string } { + try { + execFileSync(process.execPath, [cliPath, ...args], { encoding: "utf8" }); + return { exitCode: 0, stderr: "" }; + } catch (err) { + const execErr = err as { status?: number; stderr?: string }; + return { exitCode: execErr.status ?? 1, stderr: execErr.stderr ?? "" }; + } +} + +describe("batch flags", () => { + it.each([ + "scrape", + "search", + "screenshot", + ])("exposes batch flags in `%s --help`", (command) => { + const output = execFileSync( + process.execPath, + [cliPath, command, "--help"], + { encoding: "utf8" } + ); + + expect(output).toContain("--input-file"); + expect(output).toContain("--input-column"); + expect(output).toContain("--concurrency"); + }); + + it("rejects --input-file combined with a positional input", () => { + const { exitCode, stderr } = runCli([ + "scrape", + "https://example.com", + "--input-file", + "urls.txt", + ]); + + expect(exitCode).toBe(2); + expect(stderr).toContain("Cannot combine --input-file with a positional"); + }); + + it("requires --input-column for a CSV input file", () => { + const { exitCode, stderr } = runCli([ + "scrape", + "--token", + "dummy", + "--input-file", + "data.csv", + ]); + + expect(exitCode).toBe(2); + expect(stderr).toContain("--input-column is required"); + }); + + it("rejects a non-positive --concurrency", () => { + const { exitCode } = runCli([ + "scrape", + "--token", + "dummy", + "--input-file", + "urls.txt", + "--concurrency", + "0", + ]); + + expect(exitCode).not.toBe(0); + }); +}); diff --git a/tests/batch/run-batch-command.test.ts b/tests/batch/run-batch-command.test.ts new file mode 100644 index 0000000..2c72a9d --- /dev/null +++ b/tests/batch/run-batch-command.test.ts @@ -0,0 +1,125 @@ +import { + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ValidationError } from "@decodo/sdk-ts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { runBatchCommand } from "../../src/batch/services/run-batch-command.js"; +import { CliUsageError } from "../../src/platform/services/handle-cli-error.js"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "batch-cmd-")); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +function writeInput(name: string, content: string): string { + const path = join(dir, name); + writeFileSync(path, content, "utf8"); + return path; +} + +describe("runBatchCommand", () => { + it("writes one file per item to the output dir, recording failures", async () => { + const inputFile = writeInput( + "urls.txt", + "https://a.com\nhttps://boom.com\nhttps://c.com\n" + ); + const outDir = join(dir, "out"); + + const summary = await runBatchCommand({ + inputFile, + concurrency: 2, + output: outDir, + scrapeItem: (input) => { + if (input.includes("boom")) { + return Promise.reject(new ValidationError("kaboom")); + } + return Promise.resolve({ url: input }); + }, + }); + + expect(summary).toEqual({ total: 3, succeeded: 2, failed: 1 }); + + const files = readdirSync(outDir).sort(); + expect(files).toEqual(["a.com.json", "boom.com.json", "c.com.json"]); + + const failure = JSON.parse( + readFileSync(join(outDir, "boom.com.json"), "utf8") + ); + expect(failure.error).toEqual({ + class: "ValidationError", + message: "kaboom", + }); + }); + + it("streams ndjson to stdout when no output dir is given", async () => { + const inputFile = writeInput("urls.txt", "https://a.com\nhttps://b.com\n"); + const lines: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + lines.push(String(chunk)); + return true; + }); + + await runBatchCommand({ + inputFile, + concurrency: 4, + scrapeItem: (input) => Promise.resolve(input), + }); + + expect(lines).toHaveLength(2); + expect(lines.every((line) => line.endsWith("\n"))).toBe(true); + }); + + it("writes one binary file per item for binary batches", async () => { + const inputFile = writeInput("urls.txt", "https://a.com\nhttps://b.com\n"); + const outDir = join(dir, "shots"); + + const summary = await runBatchCommand({ + inputFile, + concurrency: 2, + output: outDir, + binary: true, + scrapeItem: (input) => Promise.resolve(Buffer.from(input)), + }); + + expect(summary.succeeded).toBe(2); + expect(readdirSync(outDir).sort()).toEqual(["a.com.png", "b.com.png"]); + }); + + it("rejects binary batches without an output dir", async () => { + const inputFile = writeInput("urls.txt", "https://a.com\n"); + + await expect( + runBatchCommand({ + inputFile, + concurrency: 1, + binary: true, + scrapeItem: () => Promise.resolve(Buffer.from([1])), + }) + ).rejects.toThrow(CliUsageError); + }); + + it("errors when the input file yields no items", async () => { + const inputFile = writeInput("empty.txt", "\n\n \n"); + + await expect( + runBatchCommand({ + inputFile, + concurrency: 1, + scrapeItem: () => Promise.resolve("x"), + }) + ).rejects.toThrow(ValidationError); + }); +}); diff --git a/tests/scrape/services/command-builder.test.ts b/tests/scrape/services/command-builder.test.ts index be9dca6..2603522 100644 --- a/tests/scrape/services/command-builder.test.ts +++ b/tests/scrape/services/command-builder.test.ts @@ -10,13 +10,14 @@ import { snakeToCamel } from "../../../src/scrape/services/naming.js"; const schema = BundledSchema.shared; describe("configureTargetCommand", () => { - it("adds a required input argument for google_search", () => { + it("adds an optional input argument for google_search", () => { const command = new Command("google-search"); const config = configureTargetCommand(command, "google_search", schema); expect(config.primaryField).toBe("query"); expect(command.registeredArguments).toHaveLength(1); - expect(command.registeredArguments[0]?.required).toBe(true); + // Optional so the input can come from --input-file in batch mode instead. + expect(command.registeredArguments[0]?.required).toBe(false); expect(command.options.some((opt) => opt.long?.includes("headless"))).toBe( true );