From 1b402b8603bf6cd1a8de7fd046c8134f597201b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 04:57:50 +0000 Subject: [PATCH] Add wait-for, wait-timeout, and cookies flags for dashboards (v2.2.0) Wait for a CSS selector after network idle, then apply the existing extra -t delay. Load session cookies from a JSON, Playwright storageState, or Netscape file before navigation. All options are CLI/batch flags only. Co-authored-by: Helvio Pedreschi --- CHANGELOG.md | 11 +++ README.md | 51 ++++++---- dist/screenshot.js | 189 +++++++++++++++++++++++++++++++++--- package-lock.json | 4 +- package.json | 2 +- src/Sanitizer.ts | 20 ++++ src/capture.ts | 7 ++ src/cli.ts | 20 +++- src/cookies.ts | 160 ++++++++++++++++++++++++++++++ src/runScreenshots.ts | 16 +++ src/types/WebScreenshot.ts | 3 + test/Sanitizer.test.ts | 43 ++++++++ test/capture.test.ts | 19 ++++ test/cli-bin.test.ts | 2 + test/cli.test.ts | 67 +++++++++++++ test/cookies.test.ts | 150 ++++++++++++++++++++++++++++ test/runScreenshots.test.ts | 72 ++++++++++++++ 17 files changed, 800 insertions(+), 36 deletions(-) create mode 100644 src/cookies.ts create mode 100644 test/cookies.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a19f47..5948633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.2.0] - 2026-08-13 + +### Added +- `-s` / `--wait-for ` waits for a CSS selector (`page.waitForSelector`) after the page is idle and before the extra `-t` delay. Use this for slow or login-gated dashboards whose main content appears after JavaScript renders. +- `--wait-timeout [s]` is the selector wait in seconds (default 30, clamped to 1–600). Ignored unless `--wait-for` is set. +- `--cookies [file]` loads cookies before navigation. Accepts a JSON array of Puppeteer cookies, Playwright `storageState` JSON, or a Netscape cookie file. Cookies without `url` or `domain` use the screenshot URL. +- Batch lines accept the new flags the same way as the CLI (including quoted selectors). + +### Changed +- `-a` / `--auth` help text now says HTTP basic/NTLM (still `username:password` via `page.authenticate`). + ## [2.1.0] - 2026-08-13 ### Added diff --git a/README.md b/README.md index 35e7777..b80763e 100644 --- a/README.md +++ b/README.md @@ -31,38 +31,49 @@ Usage: web-screenshot [options] Take screenshots of web pages Options: - -V, --version output the version number - -p, --path [path] Chrome executable path. - -d, --debug Enable debug mode. (default: false) - -b, --batch [file] Batch file with URLs to screenshot. Supersedes all - other options. - -u, --url URL (website) to screenshot. - -t, --time [s] Extra seconds to wait after the page is idle. (default: - 3) - -x, --x [x] Leftmost pixel. (default: 0) - -y, --y [y] Top pixel. (default: 0) - -w, --width [width] Image width in pixels. 0 takes a full-page screenshot. - (default: 1920) - -h, --height [height] Image height in pixels. 0 takes a full-page screenshot. - (default: 1080) - -o, --out [out] Absolute or relative path to save the screenshot. - -c, --crop Auto crop same-color borders. - -a, --auth [auth] NTLM credentials in username:password format. - --help display help for command + -V, --version output the version number + -p, --path [path] Chrome executable path. + -d, --debug Enable debug mode. (default: false) + -b, --batch [file] Batch file with URLs to screenshot. Supersedes all + other options. + -u, --url URL (website) to screenshot. + -t, --time [s] Extra seconds to wait after the page is idle. + (default: 3) + -x, --x [x] Leftmost pixel. (default: 0) + -y, --y [y] Top pixel. (default: 0) + -w, --width [width] Image width in pixels. 0 takes a full-page + screenshot. (default: 1920) + -h, --height [height] Image height in pixels. 0 takes a full-page + screenshot. (default: 1080) + -o, --out [out] Absolute or relative path to save the screenshot. + -c, --crop Auto crop same-color borders. + -a, --auth [auth] HTTP basic/NTLM credentials in username:password + format. + -s, --wait-for CSS selector to wait for before taking the + screenshot. + --wait-timeout [s] Seconds to wait for --wait-for. Ignored without + --wait-for. (default: 30) + --cookies Cookies file: JSON array, Playwright storageState, + or Netscape format. Applied before navigation. + --help display help for command Examples: $ web-screenshot -u https://example.com $ web-screenshot -u github.com -w 0 -h 0 -o full.png $ web-screenshot -u https://google.com -x 700 -y 190 -w 700 -h 180 -o google_logo.png --crop + $ web-screenshot -u https://example.com --wait-for "#dashboard" --wait-timeout 30 -t 2 -o dashboard.png + $ web-screenshot -u https://example.com --cookies cookies.json -o dashboard.png $ web-screenshot -b jobs.txt ``` Tips ---- -* Navigation waits until the network is idle (`networkidle2`), then `-t` extra seconds (default 3; invalid values fall back to 5 seconds, clamped to 1–600). +* Navigation waits until the network is idle (`networkidle2`). If `-s` / `--wait-for` is set, the CLI then waits for that CSS selector (`page.waitForSelector`, up to `--wait-timeout` seconds, default 30; invalid values fall back to 30, clamped to 1–600). After that, `-t` extra seconds (default 3; invalid values fall back to 5 seconds, clamped to 1–600). +* Login-gated dashboards: use `-a` / `--auth user:pass` for HTTP basic or NTLM, and/or `--cookies cookies.json` for session cookies. The cookies file can be a JSON array of Puppeteer cookies, Playwright `storageState` JSON, or a Netscape cookie file. Cookies missing `url` and `domain` use the screenshot URL. * For batch mode, each line should contain one set of arguments, such as: * `-u https://google.com -x 700 -y 900 -w 700 -h 180 -o google_logo.png --crop` - * Quoted paths are supported: `-u https://example.com -o "My Screenshots/home.png"` + * `-u https://example.com --wait-for "#dashboard" --wait-timeout 45 -t 2 --cookies session.json -o dashboard.png` + * Quoted paths and selectors are supported: `-u https://example.com --wait-for "#main .dashboard" -o "My Screenshots/home.png"` * Lines that begin with `#` will be ignored (comments) * You can call web-screenshot with the URL only, such as `web-screenshot -u github.com`. * The program will append `http://` to your URL and save the output file as `github.com.png`. diff --git a/dist/screenshot.js b/dist/screenshot.js index 21c3d86..150f1da 100644 --- a/dist/screenshot.js +++ b/dist/screenshot.js @@ -1187,7 +1187,7 @@ var require_command = __commonJS({ var EventEmitter = require("node:events").EventEmitter; var childProcess = require("node:child_process"); var path = require("node:path"); - var fs4 = require("node:fs"); + var fs5 = require("node:fs"); var process2 = require("node:process"); var { Argument: Argument2, humanReadableArgName } = require_argument(); var { CommanderError: CommanderError2 } = require_error(); @@ -2182,7 +2182,7 @@ Expecting one of '${allowedValues.join("', '")}'`); * @param {string} subcommandName */ _checkForMissingExecutable(executableFile, executableDir, subcommandName) { - if (fs4.existsSync(executableFile)) return; + if (fs5.existsSync(executableFile)) return; const executableDirMessage = executableDir ? `searched for local subcommand relative to directory '${executableDir}'` : "no directory for search for local subcommand, use .executableDir() to supply a custom directory"; const executableMissing = `'${executableFile}' does not exist - if '${subcommandName}' is not meant to be an executable command, remove description parameter from '.command()' and use '.description()' instead @@ -2201,10 +2201,10 @@ Expecting one of '${allowedValues.join("', '")}'`); const sourceExt = [".js", ".ts", ".tsx", ".mjs", ".cjs"]; function findFile(baseDir, baseName) { const localBin = path.resolve(baseDir, baseName); - if (fs4.existsSync(localBin)) return localBin; + if (fs5.existsSync(localBin)) return localBin; if (sourceExt.includes(path.extname(baseName))) return void 0; const foundExt = sourceExt.find( - (ext) => fs4.existsSync(`${localBin}${ext}`) + (ext) => fs5.existsSync(`${localBin}${ext}`) ); if (foundExt) return `${localBin}${foundExt}`; return void 0; @@ -2216,7 +2216,7 @@ Expecting one of '${allowedValues.join("', '")}'`); if (this._scriptPath) { let resolvedScriptPath; try { - resolvedScriptPath = fs4.realpathSync(this._scriptPath); + resolvedScriptPath = fs5.realpathSync(this._scriptPath); } catch { resolvedScriptPath = this._scriptPath; } @@ -3452,7 +3452,7 @@ var require_commander = __commonJS({ }); // src/screenshot.ts -var fs3 = __toESM(require("node:fs")); +var fs4 = __toESM(require("node:fs")); // src/cli.ts var fs = __toESM(require("node:fs")); @@ -3477,7 +3477,7 @@ var { // package.json var package_default = { name: "@helvio/web-screenshot", - version: "2.1.0", + version: "2.2.0", description: "CLI to take webpage screenshots with Puppeteer and optional Sharp crop", keywords: [ "screenshot", @@ -3599,6 +3599,23 @@ var Sanitizer = { sanitizeAuth(auth) { if (!auth) return void 0; return /^[^:]+:[^:]+$/.test(auth) ? auth : void 0; + }, + // CSS selector to wait for. Empty or non-string values are ignored. + sanitizeWaitFor(selector) { + if (typeof selector !== "string") return void 0; + const trimmed = selector.trim(); + return trimmed.length > 0 ? trimmed : void 0; + }, + // Seconds to wait for --wait-for. Integer 1–600, otherwise 30s (30000 ms). + sanitizeWaitTimeout(timeout) { + const n = parseInteger(timeout); + return n !== void 0 && n >= 1 && n <= 600 ? n * 1e3 : 3e4; + }, + // Path to a cookies file. Empty or non-string values are ignored. + sanitizeCookiesFile(file) { + if (typeof file !== "string") return void 0; + const trimmed = file.trim(); + return trimmed.length > 0 ? trimmed : void 0; } }; var Sanitizer_default = Sanitizer; @@ -3652,13 +3669,22 @@ function createProgram() { new Option("-w, --width [width]", "Image width in pixels. 0 takes a full-page screenshot.").default(1920) ).addOption( new Option("-h, --height [height]", "Image height in pixels. 0 takes a full-page screenshot.").default(1080) - ).addOption(new Option("-o, --out [out]", "Absolute or relative path to save the screenshot.")).addOption(new Option("-c, --crop", "Auto crop same-color borders.")).addOption(new Option("-a, --auth [auth]", "NTLM credentials in username:password format.")).addHelpText( + ).addOption(new Option("-o, --out [out]", "Absolute or relative path to save the screenshot.")).addOption(new Option("-c, --crop", "Auto crop same-color borders.")).addOption(new Option("-a, --auth [auth]", "HTTP basic/NTLM credentials in username:password format.")).addOption(new Option("-s, --wait-for ", "CSS selector to wait for before taking the screenshot.")).addOption( + new Option("--wait-timeout [s]", "Seconds to wait for --wait-for. Ignored without --wait-for.").default(30) + ).addOption( + new Option( + "--cookies ", + "Cookies file: JSON array, Playwright storageState, or Netscape format. Applied before navigation." + ) + ).addHelpText( "after", ` Examples: $ web-screenshot -u https://example.com $ web-screenshot -u github.com -w 0 -h 0 -o full.png $ web-screenshot -u https://google.com -x 700 -y 190 -w 700 -h 180 -o google_logo.png --crop + $ web-screenshot -u https://example.com --wait-for "#dashboard" --wait-timeout 30 -t 2 -o dashboard.png + $ web-screenshot -u https://example.com --cookies cookies.json -o dashboard.png $ web-screenshot -b jobs.txt ` ); @@ -3682,7 +3708,10 @@ function optionsToScreenshot(options2) { tmp: tmpSanitized.path, ext: outSanitized.ext, auth: Sanitizer_default.sanitizeAuth(authValue), - crop: Boolean(options2.crop) + crop: Boolean(options2.crop), + waitFor: Sanitizer_default.sanitizeWaitFor(options2.waitFor), + waitTimeout: Sanitizer_default.sanitizeWaitTimeout(options2.waitTimeout), + cookiesFile: Sanitizer_default.sanitizeCookiesFile(options2.cookies) }; } function parseBatchContent(content, debug2 = false) { @@ -3727,7 +3756,7 @@ function jobsFromOptions(options2, io = fs, debug2 = false) { } // src/runScreenshots.ts -var fs2 = __toESM(require("node:fs")); +var fs3 = __toESM(require("node:fs")); // src/capture.ts function isFullPage(width, height) { @@ -3738,11 +3767,136 @@ function planCapture(ss) { const screenshot = isFullPage(ss.width, ss.height) ? { path, fullPage: true } : { path, clip: { x: ss.x, y: ss.y, width: ss.width, height: ss.height } }; return { goto: { url: ss.url, waitUntil: "networkidle2" }, + waitFor: ss.waitFor ? { selector: ss.waitFor, timeout: ss.waitTimeout } : void 0, extraWaitMs: ss.time, screenshot }; } +// src/cookies.ts +var fs2 = __toESM(require("node:fs")); +var SAME_SITE = { + strict: "Strict", + lax: "Lax", + none: "None" +}; +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function isCookieLike(value) { + return isRecord(value) && typeof value.name === "string" && typeof value.value === "string"; +} +function sameSite(value) { + if (typeof value !== "string") return void 0; + return SAME_SITE[value.toLowerCase()]; +} +function expires(value) { + if (typeof value === "number" && Number.isFinite(value) && value > 0) return value; + return void 0; +} +function ensureUrlOrDomain(cookie, pageUrl) { + if (cookie.url || cookie.domain) return cookie; + return { ...cookie, url: pageUrl }; +} +function normalizeCookie(raw, pageUrl) { + const cookie = { + name: String(raw.name), + value: String(raw.value) + }; + if (typeof raw.url === "string" && raw.url.length > 0) cookie.url = raw.url; + if (typeof raw.domain === "string" && raw.domain.length > 0) cookie.domain = raw.domain; + if (typeof raw.path === "string" && raw.path.length > 0) cookie.path = raw.path; + const exp = expires(raw.expires) ?? expires(raw.expirationDate); + if (exp !== void 0) cookie.expires = exp; + if (typeof raw.httpOnly === "boolean") cookie.httpOnly = raw.httpOnly; + if (typeof raw.secure === "boolean") cookie.secure = raw.secure; + const site = sameSite(raw.sameSite); + if (site) cookie.sameSite = site; + return ensureUrlOrDomain(cookie, pageUrl); +} +function parseJsonCookies(content, pageUrl) { + let data; + try { + data = JSON.parse(content); + } catch { + throw new Error("Cookies file contains invalid JSON."); + } + let raw; + if (Array.isArray(data)) { + raw = data; + } else if (isRecord(data) && Array.isArray(data.cookies)) { + raw = data.cookies; + } else if (isCookieLike(data)) { + raw = [data]; + } else { + throw new Error( + "Cookies file must be a JSON array of cookies or a Playwright storageState object with a cookies array." + ); + } + return raw.map((item, index) => { + if (!isCookieLike(item)) { + throw new Error(`Cookies file entry ${index} is missing name or value.`); + } + return normalizeCookie(item, pageUrl); + }); +} +function parseNetscapeCookies(content, pageUrl) { + const cookies = []; + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line === "") continue; + let httpOnly = false; + let fieldsLine = line; + if (line.startsWith("#HttpOnly_")) { + httpOnly = true; + fieldsLine = line.slice("#HttpOnly_".length); + } else if (line.startsWith("#")) { + continue; + } + const fields = fieldsLine.split(" "); + if (fields.length < 7) continue; + const [domain, , path, secure, expiry, name, ...valueParts] = fields; + if (!name) continue; + cookies.push( + ensureUrlOrDomain( + { + name, + value: valueParts.join(" "), + domain: domain || void 0, + path: path || "/", + secure: String(secure).toUpperCase() === "TRUE", + httpOnly, + expires: expires(Number(expiry)) + }, + pageUrl + ) + ); + } + return cookies; +} +function parseCookies(content, pageUrl) { + const trimmed = content.trim(); + if (trimmed === "") { + throw new Error("Cookies file is empty."); + } + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + return parseJsonCookies(trimmed, pageUrl); + } + const cookies = parseNetscapeCookies(trimmed, pageUrl); + if (cookies.length === 0) { + throw new Error( + "Cookies file must be a JSON array of cookies, a Playwright storageState object, or a Netscape cookie file." + ); + } + return cookies; +} +function loadCookiesFromFile(filePath, pageUrl, io = fs2) { + if (!io.existsSync(filePath)) { + throw new Error(`Cookies file "${filePath}" does not exist.`); + } + return parseCookies(io.readFileSync(filePath, "utf-8"), pageUrl); +} + // src/runScreenshots.ts var defaultSleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay)); async function defaultTrimToFile(input, output) { @@ -3751,7 +3905,7 @@ async function defaultTrimToFile(input, output) { } async function runScreenshots(jobs2, runtime) { const sleep = runtime.sleep ?? defaultSleep; - const fileOps = runtime.fs ?? fs2; + const fileOps = runtime.fs ?? fs3; const trimToFile = runtime.trimToFile ?? defaultTrimToFile; const launchOptions = { headless: runtime.debug ? false : "shell", @@ -3772,9 +3926,20 @@ async function runScreenshots(jobs2, runtime) { await page.authenticate({ username, password: password ?? "" }); console.log("Credentials Entered"); } + if (ss.cookiesFile) { + const cookies = loadCookiesFromFile(ss.cookiesFile, ss.url); + if (cookies.length > 0) { + await page.setCookie(...cookies); + console.log(`Cookies loaded from ${ss.cookiesFile}`); + } + } const plan = planCapture(ss); await page.goto(plan.goto.url, { waitUntil: plan.goto.waitUntil }); console.log(`Navigated to ${ss.url}`); + if (plan.waitFor) { + console.log(`Waiting for selector ${plan.waitFor.selector}`); + await page.waitForSelector(plan.waitFor.selector, { timeout: plan.waitFor.timeout }); + } console.log(`Waiting for ${ss.time / 1e3} seconds`); await sleep(plan.extraWaitMs); console.log("Page Loaded"); @@ -3807,7 +3972,7 @@ var debug = Boolean(options.debug); var chromePath = typeof options.path === "string" ? options.path : void 0; var jobs = []; try { - jobs = jobsFromOptions(options, fs3, debug); + jobs = jobsFromOptions(options, fs4, debug); } catch (error) { console.error(error.message); process.exit(1); diff --git a/package-lock.json b/package-lock.json index fd81416..2ecb875 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@helvio/web-screenshot", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@helvio/web-screenshot", - "version": "2.1.0", + "version": "2.2.0", "license": "ISC", "dependencies": { "commander": "^14.0.0", diff --git a/package.json b/package.json index 71bee8b..b4f46f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@helvio/web-screenshot", - "version": "2.1.0", + "version": "2.2.0", "description": "CLI to take webpage screenshots with Puppeteer and optional Sharp crop", "keywords": [ "screenshot", diff --git a/src/Sanitizer.ts b/src/Sanitizer.ts index d1325b1..84b8dd5 100644 --- a/src/Sanitizer.ts +++ b/src/Sanitizer.ts @@ -80,6 +80,26 @@ const Sanitizer = { if (!auth) return undefined return /^[^:]+:[^:]+$/.test(auth) ? auth : undefined }, + + // CSS selector to wait for. Empty or non-string values are ignored. + sanitizeWaitFor(selector: unknown): string | undefined { + if (typeof selector !== 'string') return undefined + const trimmed = selector.trim() + return trimmed.length > 0 ? trimmed : undefined + }, + + // Seconds to wait for --wait-for. Integer 1–600, otherwise 30s (30000 ms). + sanitizeWaitTimeout(timeout: unknown): number { + const n = parseInteger(timeout) + return n !== undefined && n >= 1 && n <= 600 ? n * 1000 : 30000 + }, + + // Path to a cookies file. Empty or non-string values are ignored. + sanitizeCookiesFile(file: unknown): string | undefined { + if (typeof file !== 'string') return undefined + const trimmed = file.trim() + return trimmed.length > 0 ? trimmed : undefined + }, } export default Sanitizer diff --git a/src/capture.ts b/src/capture.ts index f008328..04d101b 100644 --- a/src/capture.ts +++ b/src/capture.ts @@ -9,8 +9,14 @@ export type ClipRect = { export type ScreenshotCall = { path: string; fullPage: true } | { path: string; clip: ClipRect } +export type WaitForPlan = { + selector: string + timeout: number +} + export type CapturePlan = { goto: { url: string; waitUntil: 'networkidle2' } + waitFor?: WaitForPlan extraWaitMs: number screenshot: ScreenshotCall } @@ -27,6 +33,7 @@ export function planCapture(ss: WebScreenshot): CapturePlan { return { goto: { url: ss.url, waitUntil: 'networkidle2' }, + waitFor: ss.waitFor ? { selector: ss.waitFor, timeout: ss.waitTimeout } : undefined, extraWaitMs: ss.time, screenshot, } diff --git a/src/cli.ts b/src/cli.ts index f7726d6..8373368 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,7 +29,17 @@ export function createProgram(): Command { ) .addOption(new Option('-o, --out [out]', 'Absolute or relative path to save the screenshot.')) .addOption(new Option('-c, --crop', 'Auto crop same-color borders.')) - .addOption(new Option('-a, --auth [auth]', 'NTLM credentials in username:password format.')) + .addOption(new Option('-a, --auth [auth]', 'HTTP basic/NTLM credentials in username:password format.')) + .addOption(new Option('-s, --wait-for ', 'CSS selector to wait for before taking the screenshot.')) + .addOption( + new Option('--wait-timeout [s]', 'Seconds to wait for --wait-for. Ignored without --wait-for.').default(30), + ) + .addOption( + new Option( + '--cookies ', + 'Cookies file: JSON array, Playwright storageState, or Netscape format. Applied before navigation.', + ), + ) .addHelpText( 'after', ` @@ -37,6 +47,8 @@ Examples: $ web-screenshot -u https://example.com $ web-screenshot -u github.com -w 0 -h 0 -o full.png $ web-screenshot -u https://google.com -x 700 -y 190 -w 700 -h 180 -o google_logo.png --crop + $ web-screenshot -u https://example.com --wait-for "#dashboard" --wait-timeout 30 -t 2 -o dashboard.png + $ web-screenshot -u https://example.com --cookies cookies.json -o dashboard.png $ web-screenshot -b jobs.txt `, ) @@ -54,6 +66,9 @@ export function optionsToScreenshot(options: { out?: unknown auth?: unknown crop?: unknown + waitFor?: unknown + waitTimeout?: unknown + cookies?: unknown }): WebScreenshot | undefined { if (!options.url || typeof options.url !== 'string') return undefined @@ -75,6 +90,9 @@ export function optionsToScreenshot(options: { ext: outSanitized.ext, auth: Sanitizer.sanitizeAuth(authValue), crop: Boolean(options.crop), + waitFor: Sanitizer.sanitizeWaitFor(options.waitFor), + waitTimeout: Sanitizer.sanitizeWaitTimeout(options.waitTimeout), + cookiesFile: Sanitizer.sanitizeCookiesFile(options.cookies), } } diff --git a/src/cookies.ts b/src/cookies.ts new file mode 100644 index 0000000..4797736 --- /dev/null +++ b/src/cookies.ts @@ -0,0 +1,160 @@ +import * as fs from 'node:fs' + +export type Cookie = { + name: string + value: string + url?: string + domain?: string + path?: string + expires?: number + httpOnly?: boolean + secure?: boolean + sameSite?: 'Strict' | 'Lax' | 'None' +} + +const SAME_SITE: Record = { + strict: 'Strict', + lax: 'Lax', + none: 'None', +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function isCookieLike(value: unknown): value is Record { + return isRecord(value) && typeof value.name === 'string' && typeof value.value === 'string' +} + +function sameSite(value: unknown): Cookie['sameSite'] | undefined { + if (typeof value !== 'string') return undefined + return SAME_SITE[value.toLowerCase()] +} + +function expires(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value + return undefined +} + +function ensureUrlOrDomain(cookie: Cookie, pageUrl: string): Cookie { + if (cookie.url || cookie.domain) return cookie + return { ...cookie, url: pageUrl } +} + +function normalizeCookie(raw: Record, pageUrl: string): Cookie { + const cookie: Cookie = { + name: String(raw.name), + value: String(raw.value), + } + if (typeof raw.url === 'string' && raw.url.length > 0) cookie.url = raw.url + if (typeof raw.domain === 'string' && raw.domain.length > 0) cookie.domain = raw.domain + if (typeof raw.path === 'string' && raw.path.length > 0) cookie.path = raw.path + const exp = expires(raw.expires) ?? expires(raw.expirationDate) + if (exp !== undefined) cookie.expires = exp + if (typeof raw.httpOnly === 'boolean') cookie.httpOnly = raw.httpOnly + if (typeof raw.secure === 'boolean') cookie.secure = raw.secure + const site = sameSite(raw.sameSite) + if (site) cookie.sameSite = site + return ensureUrlOrDomain(cookie, pageUrl) +} + +function parseJsonCookies(content: string, pageUrl: string): Cookie[] { + let data: unknown + try { + data = JSON.parse(content) + } catch { + throw new Error('Cookies file contains invalid JSON.') + } + + let raw: unknown[] + if (Array.isArray(data)) { + raw = data + } else if (isRecord(data) && Array.isArray(data.cookies)) { + raw = data.cookies + } else if (isCookieLike(data)) { + raw = [data] + } else { + throw new Error( + 'Cookies file must be a JSON array of cookies or a Playwright storageState object with a cookies array.', + ) + } + + return raw.map((item, index) => { + if (!isCookieLike(item)) { + throw new Error(`Cookies file entry ${index} is missing name or value.`) + } + return normalizeCookie(item, pageUrl) + }) +} + +function parseNetscapeCookies(content: string, pageUrl: string): Cookie[] { + const cookies: Cookie[] = [] + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim() + if (line === '') continue + + let httpOnly = false + let fieldsLine = line + if (line.startsWith('#HttpOnly_')) { + httpOnly = true + fieldsLine = line.slice('#HttpOnly_'.length) + } else if (line.startsWith('#')) { + continue + } + + const fields = fieldsLine.split('\t') + if (fields.length < 7) continue + + const [domain, , path, secure, expiry, name, ...valueParts] = fields + if (!name) continue + + cookies.push( + ensureUrlOrDomain( + { + name, + value: valueParts.join('\t'), + domain: domain || undefined, + path: path || '/', + secure: String(secure).toUpperCase() === 'TRUE', + httpOnly, + expires: expires(Number(expiry)), + }, + pageUrl, + ), + ) + } + + return cookies +} + +export function parseCookies(content: string, pageUrl: string): Cookie[] { + const trimmed = content.trim() + if (trimmed === '') { + throw new Error('Cookies file is empty.') + } + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + return parseJsonCookies(trimmed, pageUrl) + } + const cookies = parseNetscapeCookies(trimmed, pageUrl) + if (cookies.length === 0) { + throw new Error( + 'Cookies file must be a JSON array of cookies, a Playwright storageState object, or a Netscape cookie file.', + ) + } + return cookies +} + +export function loadCookiesFromFile( + filePath: string, + pageUrl: string, + io: { + existsSync: (path: string) => boolean + readFileSync: (path: string, encoding: 'utf-8') => string + } = fs, +): Cookie[] { + if (!io.existsSync(filePath)) { + throw new Error(`Cookies file "${filePath}" does not exist.`) + } + return parseCookies(io.readFileSync(filePath, 'utf-8'), pageUrl) +} diff --git a/src/runScreenshots.ts b/src/runScreenshots.ts index 009971a..7b2e5b3 100644 --- a/src/runScreenshots.ts +++ b/src/runScreenshots.ts @@ -1,5 +1,6 @@ import * as fs from 'node:fs' import { planCapture } from './capture' +import { type Cookie, loadCookiesFromFile } from './cookies' import type WebScreenshot from './types/WebScreenshot' export type LaunchOptions = { @@ -10,8 +11,10 @@ export type LaunchOptions = { export type PageLike = { authenticate: (credentials: { username: string; password: string }) => Promise + setCookie: (...cookies: Cookie[]) => Promise setViewport: (viewport: { width: number; height: number }) => Promise goto: (url: string, options?: { waitUntil: 'networkidle2' }) => Promise + waitForSelector: (selector: string, options?: { timeout?: number }) => Promise screenshot: (options: Record) => Promise close: () => Promise } @@ -69,10 +72,23 @@ export async function runScreenshots(jobs: WebScreenshot[], runtime: ScreenshotR console.log('Credentials Entered') } + if (ss.cookiesFile) { + const cookies = loadCookiesFromFile(ss.cookiesFile, ss.url) + if (cookies.length > 0) { + await page.setCookie(...cookies) + console.log(`Cookies loaded from ${ss.cookiesFile}`) + } + } + const plan = planCapture(ss) await page.goto(plan.goto.url, { waitUntil: plan.goto.waitUntil }) console.log(`Navigated to ${ss.url}`) + if (plan.waitFor) { + console.log(`Waiting for selector ${plan.waitFor.selector}`) + await page.waitForSelector(plan.waitFor.selector, { timeout: plan.waitFor.timeout }) + } + console.log(`Waiting for ${ss.time / 1000} seconds`) await sleep(plan.extraWaitMs) console.log('Page Loaded') diff --git a/src/types/WebScreenshot.ts b/src/types/WebScreenshot.ts index 8aa7e7b..b26252e 100644 --- a/src/types/WebScreenshot.ts +++ b/src/types/WebScreenshot.ts @@ -10,4 +10,7 @@ export default interface WebScreenshot { ext: 'jpeg' | 'png' | 'webp' crop: boolean auth?: string + waitFor?: string + waitTimeout: number + cookiesFile?: string } diff --git a/test/Sanitizer.test.ts b/test/Sanitizer.test.ts index e522cc0..b7607c8 100644 --- a/test/Sanitizer.test.ts +++ b/test/Sanitizer.test.ts @@ -120,3 +120,46 @@ describe('Sanitizer.sanitizeAuth', () => { assert.equal(Sanitizer.sanitizeAuth('user:pass'), 'user:pass') }) }) + +describe('Sanitizer.sanitizeWaitFor', () => { + it('trims a CSS selector', () => { + assert.equal(Sanitizer.sanitizeWaitFor(' #dashboard '), '#dashboard') + }) + + it('returns undefined for empty or non-string values', () => { + assert.equal(Sanitizer.sanitizeWaitFor(undefined), undefined) + assert.equal(Sanitizer.sanitizeWaitFor(''), undefined) + assert.equal(Sanitizer.sanitizeWaitFor(' '), undefined) + assert.equal(Sanitizer.sanitizeWaitFor(true), undefined) + }) +}) + +describe('Sanitizer.sanitizeWaitTimeout', () => { + it('converts integer seconds in 1–600 to milliseconds, defaulting to 30s', () => { + assert.equal(Sanitizer.sanitizeWaitTimeout(30), 30000) + assert.equal(Sanitizer.sanitizeWaitTimeout(1), 1000) + assert.equal(Sanitizer.sanitizeWaitTimeout(600), 600000) + assert.equal(Sanitizer.sanitizeWaitTimeout('45'), 45000) + }) + + it('falls back to 30000ms for invalid values', () => { + assert.equal(Sanitizer.sanitizeWaitTimeout(undefined), 30000) + assert.equal(Sanitizer.sanitizeWaitTimeout(0), 30000) + assert.equal(Sanitizer.sanitizeWaitTimeout(601), 30000) + assert.equal(Sanitizer.sanitizeWaitTimeout('nope'), 30000) + assert.equal(Sanitizer.sanitizeWaitTimeout(true), 30000) + }) +}) + +describe('Sanitizer.sanitizeCookiesFile', () => { + it('keeps a non-empty path', () => { + assert.equal(Sanitizer.sanitizeCookiesFile('session.json'), 'session.json') + assert.equal(Sanitizer.sanitizeCookiesFile(' cookies/session.json '), 'cookies/session.json') + }) + + it('returns undefined for empty or non-string values', () => { + assert.equal(Sanitizer.sanitizeCookiesFile(undefined), undefined) + assert.equal(Sanitizer.sanitizeCookiesFile(''), undefined) + assert.equal(Sanitizer.sanitizeCookiesFile(true), undefined) + }) +}) diff --git a/test/capture.test.ts b/test/capture.test.ts index 5a44dde..402ae7d 100644 --- a/test/capture.test.ts +++ b/test/capture.test.ts @@ -15,6 +15,7 @@ function job(overrides: Partial = {}): WebScreenshot { tmp: 'example.com_tmp', ext: 'png', crop: false, + waitTimeout: 30000, ...overrides, } } @@ -24,6 +25,7 @@ describe('planCapture', () => { const plan = planCapture(job()) assert.deepEqual(plan.goto, { url: 'https://example.com', waitUntil: 'networkidle2' }) assert.equal(plan.extraWaitMs, 3000) + assert.equal(plan.waitFor, undefined) assert.deepEqual(plan.screenshot, { path: 'example.com_tmp.png', clip: { x: 0, y: 0, width: 1920, height: 1080 }, @@ -53,4 +55,21 @@ describe('planCapture', () => { clip: { x: 700, y: 190, width: 700, height: 180 }, }) }) + + it('omits waitFor when no selector is set', () => { + assert.equal(planCapture(job()).waitFor, undefined) + }) + + it('plans waitForSelector after networkidle, before the extra -t wait', () => { + const plan = planCapture( + job({ + waitFor: '#dashboard', + waitTimeout: 45000, + time: 2000, + }), + ) + assert.deepEqual(plan.goto, { url: 'https://example.com', waitUntil: 'networkidle2' }) + assert.deepEqual(plan.waitFor, { selector: '#dashboard', timeout: 45000 }) + assert.equal(plan.extraWaitMs, 2000) + }) }) diff --git a/test/cli-bin.test.ts b/test/cli-bin.test.ts index b72bde1..ea8db9e 100644 --- a/test/cli-bin.test.ts +++ b/test/cli-bin.test.ts @@ -12,6 +12,8 @@ describe('built CLI', () => { assert.match(result.stdout, /Usage: web-screenshot \[options\]/) assert.match(result.stdout, /-u, --url /) assert.match(result.stdout, /-p, --path \[path\]/) + assert.match(result.stdout, /-s, --wait-for /) + assert.match(result.stdout, /--cookies /) }) it('prints the package version', { skip: !existsSync('dist/screenshot.js') }, () => { diff --git a/test/cli.test.ts b/test/cli.test.ts index 363c354..abef065 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -19,9 +19,15 @@ describe('CLI help', () => { assert.match(help, /-d, --debug/) assert.match(help, /-a, --auth \[auth\]/) assert.match(help, /-p, --path \[path\]/) + assert.match(help, /-s, --wait-for /) + assert.match(help, /--wait-timeout \[s\]/) + assert.match(help, /--cookies /) assert.match(help, /--help/) assert.match(help, /-V, --version/) assert.doesNotMatch(help, /Usage: screenshot /) + assert.doesNotMatch(help, /WEB_SCREENSHOT/) + assert.doesNotMatch(help, /SCREENSHOT_AUTH/) + assert.doesNotMatch(help, /environment variable/i) }) it('shows the package version', () => { @@ -44,6 +50,9 @@ describe('CLI arg parsing', () => { assert.equal(job.ext, 'png') assert.equal(job.crop, false) assert.equal(job.auth, undefined) + assert.equal(job.waitFor, undefined) + assert.equal(job.waitTimeout, 30000) + assert.equal(job.cookiesFile, undefined) }) it('treats string -t 3 as 3000ms rather than the invalid-input fallback', () => { @@ -106,6 +115,48 @@ describe('CLI arg parsing', () => { ) }) + it('parses wait-for, wait-timeout, and cookies file flags', () => { + const { job } = parseCli([ + '-u', + 'https://example.com', + '-s', + '#dashboard', + '--wait-timeout', + '45', + '--cookies', + 'session.json', + '-t', + '2', + '-o', + 'dashboard.png', + ]) + assert.equal(job?.waitFor, '#dashboard') + assert.equal(job?.waitTimeout, 45000) + assert.equal(job?.cookiesFile, 'session.json') + assert.equal(job?.time, 2000) + assert.equal(job?.path, 'dashboard') + }) + + it('does not read auth or cookies from the environment', () => { + const previousAuth = process.env.WEB_SCREENSHOT_AUTH + const previousCookies = process.env.WEB_SCREENSHOT_COOKIES + process.env.WEB_SCREENSHOT_AUTH = 'envuser:envpass' + process.env.WEB_SCREENSHOT_COOKIES = 'from-env.json' + try { + const { job } = parseCli(['-u', 'https://example.com']) + assert.equal(job?.auth, undefined) + assert.equal(job?.cookiesFile, undefined) + const { job: withFlags } = parseCli(['-u', 'https://example.com', '-a', 'user:pass', '--cookies', 'session.json']) + assert.equal(withFlags?.auth, 'user:pass') + assert.equal(withFlags?.cookiesFile, 'session.json') + } finally { + if (previousAuth === undefined) delete process.env.WEB_SCREENSHOT_AUTH + else process.env.WEB_SCREENSHOT_AUTH = previousAuth + if (previousCookies === undefined) delete process.env.WEB_SCREENSHOT_COOKIES + else process.env.WEB_SCREENSHOT_COOKIES = previousCookies + } + }) + it('exposes debug and chrome path on the program options', () => { const { options } = parseCli(['-u', 'https://example.com', '-d', '-p', '/usr/bin/google-chrome']) assert.equal(options.debug, true) @@ -145,6 +196,22 @@ describe('batch parser', () => { assert.equal(jobs[1].height, 0) }) + it('parses wait-for, wait-timeout, cookies, and quoted selectors', () => { + const jobs = parseBatchContent(` +-u https://example.com --wait-for "#main .dashboard" --wait-timeout 45 -t 2 --cookies session.json -o dashboard.png +-u https://example.com -s "#ready" -a user:pass -o other.png +`) + assert.equal(jobs.length, 2) + assert.equal(jobs[0].waitFor, '#main .dashboard') + assert.equal(jobs[0].waitTimeout, 45000) + assert.equal(jobs[0].time, 2000) + assert.equal(jobs[0].cookiesFile, 'session.json') + assert.equal(jobs[0].path, 'dashboard') + assert.equal(jobs[1].waitFor, '#ready') + assert.equal(jobs[1].auth, 'user:pass') + assert.equal(jobs[1].waitTimeout, 30000) + }) + it('skips lines without a URL', () => { const jobs = parseBatchContent('-w 800 -h 600\n-u https://ok.example') assert.equal(jobs.length, 1) diff --git a/test/cookies.test.ts b/test/cookies.test.ts new file mode 100644 index 0000000..2cd3869 --- /dev/null +++ b/test/cookies.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { loadCookiesFromFile, parseCookies } from '../src/cookies' + +const pageUrl = 'https://example.com/dashboard' + +describe('parseCookies JSON', () => { + it('loads a Puppeteer cookie array and fills url when domain is missing', () => { + const cookies = parseCookies( + JSON.stringify([ + { name: 'session', value: 'abc' }, + { + name: 'theme', + value: 'dark', + domain: '.example.com', + path: '/', + secure: true, + httpOnly: false, + sameSite: 'Lax', + }, + ]), + pageUrl, + ) + assert.deepEqual(cookies, [ + { name: 'session', value: 'abc', url: pageUrl }, + { + name: 'theme', + value: 'dark', + domain: '.example.com', + path: '/', + secure: true, + httpOnly: false, + sameSite: 'Lax', + }, + ]) + }) + + it('loads Playwright storageState JSON', () => { + const cookies = parseCookies( + JSON.stringify({ + cookies: [ + { + name: 'sid', + value: 'token', + domain: 'example.com', + path: '/', + expires: 1893456000, + httpOnly: true, + secure: true, + sameSite: 'Strict', + }, + ], + origins: [], + }), + pageUrl, + ) + assert.equal(cookies.length, 1) + assert.equal(cookies[0].name, 'sid') + assert.equal(cookies[0].value, 'token') + assert.equal(cookies[0].domain, 'example.com') + assert.equal(cookies[0].expires, 1893456000) + assert.equal(cookies[0].sameSite, 'Strict') + }) + + it('loads a single cookie object', () => { + const cookies = parseCookies(JSON.stringify({ name: 'a', value: 'b' }), pageUrl) + assert.deepEqual(cookies, [{ name: 'a', value: 'b', url: pageUrl }]) + }) + + it('normalizes lowercase sameSite values', () => { + const cookies = parseCookies(JSON.stringify([{ name: 'a', value: 'b', sameSite: 'none' }]), pageUrl) + assert.equal(cookies[0].sameSite, 'None') + }) + + it('rejects invalid JSON', () => { + assert.throws(() => parseCookies('{not json', pageUrl), /invalid JSON/) + }) + + it('rejects JSON objects that are not cookies', () => { + assert.throws( + () => parseCookies(JSON.stringify({ foo: 1 }), pageUrl), + /JSON array of cookies or a Playwright storageState/, + ) + }) + + it('rejects array entries missing name or value', () => { + assert.throws(() => parseCookies(JSON.stringify([{ name: 'x' }]), pageUrl), /entry 0 is missing name or value/) + }) + + it('rejects an empty file', () => { + assert.throws(() => parseCookies(' ', pageUrl), /empty/) + }) +}) + +describe('parseCookies Netscape', () => { + it('parses tab-separated cookies including HttpOnly', () => { + const cookies = parseCookies( + `# Netscape HTTP Cookie File +.example.com TRUE / TRUE 1893456000 session abc +#HttpOnly_.example.com TRUE /dash FALSE 0 id 42 +`, + pageUrl, + ) + assert.equal(cookies.length, 2) + assert.deepEqual(cookies[0], { + name: 'session', + value: 'abc', + domain: '.example.com', + path: '/', + secure: true, + httpOnly: false, + expires: 1893456000, + }) + assert.equal(cookies[1].name, 'id') + assert.equal(cookies[1].value, '42') + assert.equal(cookies[1].httpOnly, true) + assert.equal(cookies[1].secure, false) + assert.equal(cookies[1].path, '/dash') + assert.equal(cookies[1].expires, undefined) + }) + + it('rejects unrecognized non-JSON content', () => { + assert.throws(() => parseCookies('not a cookie file', pageUrl), /Netscape cookie file/) + }) +}) + +describe('loadCookiesFromFile', () => { + it('throws when the file is missing', () => { + assert.throws( + () => + loadCookiesFromFile('missing.json', pageUrl, { + existsSync: () => false, + readFileSync: () => { + throw new Error('should not read') + }, + }), + /Cookies file "missing.json" does not exist/, + ) + }) + + it('reads the file and parses JSON cookies', () => { + const cookies = loadCookiesFromFile('session.json', pageUrl, { + existsSync: () => true, + readFileSync: () => JSON.stringify([{ name: 'session', value: 'abc', domain: 'example.com' }]), + }) + assert.equal(cookies.length, 1) + assert.equal(cookies[0].name, 'session') + assert.equal(cookies[0].domain, 'example.com') + }) +}) diff --git a/test/runScreenshots.test.ts b/test/runScreenshots.test.ts index 2eedb1e..9f6fdf6 100644 --- a/test/runScreenshots.test.ts +++ b/test/runScreenshots.test.ts @@ -1,4 +1,7 @@ import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, it } from 'node:test' import { type BrowserLike, type LaunchOptions, type PageLike, runScreenshots } from '../src/runScreenshots' import type WebScreenshot from '../src/types/WebScreenshot' @@ -15,6 +18,7 @@ function job(overrides: Partial = {}): WebScreenshot { tmp: 'out_tmp', ext: 'png', crop: false, + waitTimeout: 30000, ...overrides, } } @@ -24,6 +28,8 @@ function createFakeBrowser(calls: { goto?: unknown[] screenshot?: unknown[] authenticate?: unknown[] + setCookie?: unknown[] + waitForSelector?: unknown[] viewport?: unknown waits?: number[] }): BrowserLike { @@ -32,6 +38,10 @@ function createFakeBrowser(calls: { calls.authenticate = calls.authenticate ?? [] calls.authenticate.push(credentials) }, + async setCookie(...cookies) { + calls.setCookie = calls.setCookie ?? [] + calls.setCookie.push(...cookies) + }, async setViewport(viewport) { calls.viewport = viewport }, @@ -39,6 +49,10 @@ function createFakeBrowser(calls: { calls.goto = calls.goto ?? [] calls.goto.push({ url, options }) }, + async waitForSelector(selector, options) { + calls.waitForSelector = calls.waitForSelector ?? [] + calls.waitForSelector.push({ selector, options }) + }, async screenshot(options) { calls.screenshot = calls.screenshot ?? [] calls.screenshot.push(options) @@ -142,4 +156,62 @@ describe('runScreenshots', () => { assert.deepEqual(trimmed, [['out_tmp.png', 'out.png']]) assert.deepEqual(unlinked, ['out_tmp.png']) }) + + it('waits for a CSS selector after navigation, then the extra -t delay', async () => { + const calls: { goto?: unknown[]; waitForSelector?: unknown[] } = {} + const waits: number[] = [] + + await runScreenshots([job({ waitFor: '#dashboard', waitTimeout: 30000, time: 2000 })], { + launch: async () => createFakeBrowser(calls), + sleep: async (ms) => { + waits.push(ms) + }, + fs: { + renameSync: () => {}, + unlinkSync: () => {}, + }, + }) + + assert.deepEqual(calls.goto, [{ url: 'https://example.com', options: { waitUntil: 'networkidle2' } }]) + assert.deepEqual(calls.waitForSelector, [{ selector: '#dashboard', options: { timeout: 30000 } }]) + assert.deepEqual(waits, [2000]) + }) + + it('does not call waitForSelector when no selector is set', async () => { + const calls: { waitForSelector?: unknown[] } = {} + + await runScreenshots([job()], { + launch: async () => createFakeBrowser(calls), + sleep: async () => {}, + fs: { + renameSync: () => {}, + unlinkSync: () => {}, + }, + }) + + assert.equal(calls.waitForSelector, undefined) + }) + + it('sets cookies from a JSON file before navigation', async () => { + const calls: { setCookie?: unknown[]; goto?: unknown[] } = {} + const dir = mkdtempSync(join(tmpdir(), 'web-screenshot-')) + const cookiesPath = join(dir, 'session.json') + writeFileSync(cookiesPath, JSON.stringify([{ name: 'session', value: 'abc', domain: 'example.com' }]), 'utf-8') + + try { + await runScreenshots([job({ cookiesFile: cookiesPath })], { + launch: async () => createFakeBrowser(calls), + sleep: async () => {}, + fs: { + renameSync: () => {}, + unlinkSync: () => {}, + }, + }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + + assert.deepEqual(calls.setCookie, [{ name: 'session', value: 'abc', domain: 'example.com' }]) + assert.deepEqual(calls.goto, [{ url: 'https://example.com', options: { waitUntil: 'networkidle2' } }]) + }) })