@cydoentis/pawprint 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,332 @@
1
+ # Pawprint
2
+
3
+ Automated screenshot generation for web applications across themes, color palettes, and viewport sizes.
4
+
5
+ Pawprint uses Playwright to capture screenshots of your app in every combination of route, mode (light/dark), palette, and viewport you configure. It handles SPA authentication, organizes output into timestamped folders, and generates metadata for each run.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install
11
+ npx playwright install chromium
12
+ npm run build
13
+ ```
14
+
15
+ To make the `pawprint` command available globally:
16
+
17
+ ```bash
18
+ npm link
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```bash
24
+ # 1. Generate a config file in your app directory
25
+ pawprint init /path/to/your-app
26
+
27
+ # 2. Edit the generated pawprint.config.json with your app's details
28
+
29
+ # 3. Start your app, then generate screenshots
30
+ pawprint generate /path/to/your-app
31
+ ```
32
+
33
+ ## CLI Commands
34
+
35
+ ### `pawprint generate <app-path>`
36
+
37
+ Takes screenshots across all configured theme/palette/viewport combinations.
38
+
39
+ ```bash
40
+ pawprint generate /path/to/app
41
+ pawprint generate /path/to/app --config ./custom-config.json
42
+ pawprint generate /path/to/app --output ./my-screenshots
43
+ pawprint generate /path/to/app --debug
44
+ ```
45
+
46
+ | Option | Description |
47
+ |---|---|
48
+ | `-c, --config <path>` | Custom config file path (default: `<app-path>/pawprint.config.json`) |
49
+ | `-o, --output <dir>` | Override output directory |
50
+ | `--debug` | Enable debug mode with extra error output |
51
+
52
+ ### `pawprint init [app-path]`
53
+
54
+ Creates a sample `pawprint.config.json` at the given path (defaults to `.`).
55
+
56
+ ```bash
57
+ pawprint init
58
+ pawprint init /path/to/app
59
+ pawprint init . --force # overwrite existing config
60
+ ```
61
+
62
+ ### `pawprint list <app-path>`
63
+
64
+ Lists previous screenshot runs organized by date.
65
+
66
+ ```bash
67
+ pawprint list /path/to/app
68
+ pawprint list /path/to/app --output ./my-screenshots
69
+ ```
70
+
71
+ ## Configuration
72
+
73
+ Create a `pawprint.config.json` in your app directory or pass a custom path with `--config`.
74
+
75
+ ### Full Example
76
+
77
+ ```json
78
+ {
79
+ "baseUrl": "http://localhost:3000",
80
+ "routes": ["/", "/dashboard", "/settings", "/profile"],
81
+ "modes": ["light", "dark"],
82
+ "palettes": ["default", "high-contrast"],
83
+ "viewports": [
84
+ { "name": "desktop", "width": 1440, "height": 900 },
85
+ { "name": "mobile", "width": 390, "height": 844 },
86
+ { "name": "tablet", "width": 768, "height": 1024 }
87
+ ],
88
+ "appearance": {
89
+ "strategy": "localStorage",
90
+ "modeKey": "theme",
91
+ "paletteKey": "palette"
92
+ },
93
+ "outputDir": "pawprint-output",
94
+ "auth": {
95
+ "strategy": "form",
96
+ "loginUrl": "/login",
97
+ "credentials": {
98
+ "username": "admin@example.com",
99
+ "password": "password123"
100
+ },
101
+ "selectors": {
102
+ "username": "input[type=email]",
103
+ "password": "input[type=password]",
104
+ "submit": "button[type=submit]"
105
+ }
106
+ },
107
+ "screenshotOptions": {
108
+ "fullPage": false,
109
+ "waitForTimeout": 500,
110
+ "waitUntil": "networkidle"
111
+ }
112
+ }
113
+ ```
114
+
115
+ ### Required Fields
116
+
117
+ | Field | Type | Description |
118
+ |---|---|---|
119
+ | `baseUrl` | `string` | Base URL of the running app (e.g. `http://localhost:3000`) |
120
+ | `routes` | `string[]` | Routes to screenshot (e.g. `["/", "/dashboard"]`) |
121
+ | `modes` | `string[]` | Theme modes (e.g. `["light", "dark"]`) |
122
+ | `appearance` | `object` | How themes are applied to the app |
123
+
124
+ ### Optional Fields
125
+
126
+ | Field | Type | Default | Description |
127
+ |---|---|---|---|
128
+ | `palettes` | `string[]` | `["default"]` | Color palette names |
129
+ | `viewports` | `ViewportPreset[]` | `[{name:"default", width:1440, height:900}]` | Viewport sizes |
130
+ | `outputDir` | `string` | `"pawprint-output"` | Root output directory |
131
+ | `auth` | `AuthStrategy` | `undefined` | Authentication config |
132
+ | `screenshotOptions` | `object` | see below | Screenshot capture settings |
133
+
134
+ ### Appearance Strategies
135
+
136
+ **localStorage** -- For apps that store theme preferences in localStorage:
137
+
138
+ ```json
139
+ {
140
+ "strategy": "localStorage",
141
+ "modeKey": "theme",
142
+ "paletteKey": "palette"
143
+ }
144
+ ```
145
+
146
+ Pawprint sets the localStorage keys, then reloads the page so the app picks up the new values.
147
+
148
+ **dom** -- For apps that use CSS classes or DOM attributes (planned):
149
+
150
+ ```json
151
+ {
152
+ "strategy": "dom",
153
+ "modeClass": "dark",
154
+ "paletteAttribute": "data-palette"
155
+ }
156
+ ```
157
+
158
+ ### Authentication
159
+
160
+ #### Form-based login
161
+
162
+ Pawprint fills in a login form, submits it, and saves the browser state for reuse:
163
+
164
+ ```json
165
+ {
166
+ "strategy": "form",
167
+ "loginUrl": "/login",
168
+ "credentials": {
169
+ "username": "user@example.com",
170
+ "password": "password"
171
+ },
172
+ "selectors": {
173
+ "username": "input[type=email]",
174
+ "password": "input[type=password]",
175
+ "submit": "button[type=submit]",
176
+ "authCheck": ".dashboard-header"
177
+ }
178
+ }
179
+ ```
180
+
181
+ The saved `storageState.json` is reused for up to 1 hour before re-authenticating.
182
+
183
+ #### Reusing storage state
184
+
185
+ If you already have a Playwright storage state file:
186
+
187
+ ```json
188
+ {
189
+ "strategy": "storageState",
190
+ "storageStatePath": "./storageState.json"
191
+ }
192
+ ```
193
+
194
+ ## Output Structure
195
+
196
+ Each run creates a timestamped folder to avoid overwriting previous results:
197
+
198
+ ```
199
+ pawprint-output/
200
+ my-app/
201
+ 2026-02-16/
202
+ 2026-02-16_14-30-45/
203
+ default/
204
+ light/
205
+ home-desktop.png
206
+ home-mobile.png
207
+ dashboard-desktop.png
208
+ dark/
209
+ home-desktop.png
210
+ home-mobile.png
211
+ dashboard-desktop.png
212
+ high-contrast/
213
+ light/
214
+ ...
215
+ dark/
216
+ ...
217
+ metadata.json
218
+ ```
219
+
220
+ ### Screenshot Naming
221
+
222
+ | Route | File Name |
223
+ |---|---|
224
+ | `/` | `home-{viewport}.png` |
225
+ | `/dashboard` | `dashboard-{viewport}.png` |
226
+ | `/user/profile` | `user_profile-{viewport}.png` |
227
+ | (auth error) | `ERROR-{route}-{viewport}.png` |
228
+ | (redirect to login) | `REDIRECTED-{route}-{viewport}.png` |
229
+
230
+ ### Metadata
231
+
232
+ Each run writes a `metadata.json` with:
233
+
234
+ ```json
235
+ {
236
+ "timestamp": "2026-02-16T14:30:45.000Z",
237
+ "appPath": "/path/to/app",
238
+ "config": { "baseUrl": "...", "routes": [...], "modes": [...] },
239
+ "outputPath": "pawprint-output/my-app/2026-02-16/2026-02-16_14-30-45",
240
+ "stats": {
241
+ "totalScreenshots": 24,
242
+ "successful": 24,
243
+ "failed": 0
244
+ }
245
+ }
246
+ ```
247
+
248
+ ## Programmatic Usage
249
+
250
+ Pawprint can be imported and used from another Node.js application instead of the CLI.
251
+
252
+ ### Basic Usage
253
+
254
+ ```typescript
255
+ import { loadConfig } from "pawprint/dist/core/config.js";
256
+ import { prepareOutputFolders, takeScreenshots } from "pawprint/dist/core/engine.js";
257
+
258
+ const appPath = "/path/to/your-app";
259
+ const config = loadConfig(appPath);
260
+
261
+ const folderMap = prepareOutputFolders(config, appPath);
262
+ await takeScreenshots(config, appPath, folderMap);
263
+ ```
264
+
265
+ ### With Inline Config
266
+
267
+ ```typescript
268
+ import type { PawprintConfig } from "pawprint/dist/types/config.js";
269
+ import { prepareOutputFolders, takeScreenshots } from "pawprint/dist/core/engine.js";
270
+
271
+ const config: PawprintConfig = {
272
+ baseUrl: "http://localhost:3000",
273
+ routes: ["/", "/dashboard"],
274
+ modes: ["light", "dark"],
275
+ appearance: {
276
+ strategy: "localStorage",
277
+ modeKey: "theme",
278
+ },
279
+ };
280
+
281
+ const appPath = "/path/to/your-app";
282
+ const folderMap = prepareOutputFolders(config, appPath);
283
+ await takeScreenshots(config, appPath, folderMap);
284
+ ```
285
+
286
+ ### Available Exports
287
+
288
+ | Module | Exports | Description |
289
+ |---|---|---|
290
+ | `core/config.js` | `loadConfig(appPath)` | Load and validate `pawprint.config.json` from a directory |
291
+ | `core/engine.js` | `prepareOutputFolders(config, appPath)` | Create the timestamped output folder structure, returns a folder map |
292
+ | `core/engine.js` | `takeScreenshots(config, appPath, folderMap)` | Launch Chromium and capture all screenshots |
293
+ | `core/auth.js` | `setupAuth(context, page, config)` | Run authentication against a Playwright browser context |
294
+ | `types/config.js` | `PawprintConfig`, `AuthStrategy`, `AppearanceStrategy`, `ViewportPreset`, `ScreenshotMetadata`, `FolderMap` | TypeScript interfaces |
295
+
296
+ ### Linking as a Local Dependency
297
+
298
+ From another project:
299
+
300
+ ```bash
301
+ # In the pawprint directory
302
+ npm link
303
+
304
+ # In your other project
305
+ npm link pawprint
306
+ ```
307
+
308
+ Then import as shown above.
309
+
310
+ ## Development
311
+
312
+ ```bash
313
+ # Run CLI directly (no build needed)
314
+ npm start -- generate /path/to/app
315
+
316
+ # Watch mode
317
+ npm run dev -- generate /path/to/app
318
+
319
+ # Build TypeScript
320
+ npm run build
321
+ ```
322
+
323
+ ## Tech Stack
324
+
325
+ - **TypeScript** -- ES2022 target, NodeNext modules
326
+ - **Playwright** -- Chromium browser automation
327
+ - **Commander** -- CLI argument parsing
328
+ - **Chalk** -- Terminal colors
329
+
330
+ ## License
331
+
332
+ ISC
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import chalk from "chalk";
6
+ import { prepareOutputFolders, takeScreenshots } from "../core/engine.js";
7
+ import { writeOutputFiles } from "../core/output.js";
8
+ import { createProgressDisplay } from "./progress.js";
9
+ const CONFIG_FILE = "pawprint.config.json";
10
+ const DEFAULT_OUTPUT_DIR = "pawprint-output";
11
+ const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
12
+ const TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/;
13
+ const program = new Command();
14
+ function generateRunId() {
15
+ const now = new Date();
16
+ const pad = (n) => String(n).padStart(2, "0");
17
+ return `r_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
18
+ }
19
+ function createJsonlEmitter(runId) {
20
+ return (event) => {
21
+ const patched = { ...event, runId };
22
+ process.stdout.write(JSON.stringify(patched) + "\n");
23
+ };
24
+ }
25
+ function computeTotal(config) {
26
+ const routes = config.routes.length;
27
+ const modes = config.modes.length;
28
+ const palettes = config.palettes?.length ?? 1;
29
+ const viewports = config.viewports?.length ?? 1;
30
+ return routes * modes * palettes * viewports;
31
+ }
32
+ program
33
+ .name("pawprint")
34
+ .description("Screenshot tool for web apps across themes, palettes, and viewports")
35
+ .version("1.0.0");
36
+ program
37
+ .command("capture")
38
+ .description("Capture screenshots for a web app")
39
+ .argument("<app-path>", "Path to the app or config file")
40
+ .option("-c, --config <path>", "Path to config file (default: <app-path>/pawprint.config.json)")
41
+ .option("-o, --output <dir>", "Output directory (overrides config)")
42
+ .option("--debug", "Enable debug mode with visible browser")
43
+ .option("--jsonl", "Emit JSONL events to stdout instead of human-readable output")
44
+ .option("--run-id <id>", "Custom run ID (default: auto-generated)")
45
+ .action(async (appPath, options) => {
46
+ const isJsonl = options.jsonl ?? false;
47
+ const runId = options.runId ?? generateRunId();
48
+ let config;
49
+ try {
50
+ const configPath = options.config
51
+ ? path.resolve(options.config)
52
+ : path.join(path.resolve(appPath), CONFIG_FILE);
53
+ if (!fs.existsSync(configPath)) {
54
+ if (isJsonl) {
55
+ process.stdout.write(JSON.stringify({ type: "run_finished", runId, status: "failed", count: 0, failed: 0, endedAt: new Date().toISOString() }) + "\n");
56
+ }
57
+ else {
58
+ console.error(chalk.red(`\nConfig not found: ${configPath}\n`));
59
+ console.log(chalk.dim(` Create one with: pawprint init ${appPath}`));
60
+ }
61
+ process.exit(1);
62
+ }
63
+ const configContent = fs.readFileSync(configPath, "utf-8");
64
+ config = JSON.parse(configContent);
65
+ if (options.output) {
66
+ config.outputDir = options.output;
67
+ }
68
+ if (!config.baseUrl)
69
+ throw new Error("baseUrl is required in config");
70
+ if (!config.routes || config.routes.length === 0)
71
+ throw new Error("routes array is required in config");
72
+ if (!config.modes || config.modes.length === 0)
73
+ throw new Error("modes array is required in config");
74
+ const cfg = config;
75
+ const total = computeTotal(cfg);
76
+ const folderMap = prepareOutputFolders(cfg, appPath);
77
+ const firstFolder = Object.values(folderMap)[0];
78
+ const baseOutputPath = path.dirname(path.dirname(firstFolder));
79
+ const startedAt = new Date().toISOString();
80
+ const startTime = Date.now();
81
+ if (isJsonl) {
82
+ const emit = createJsonlEmitter(runId);
83
+ emit({ type: "run_started", runId, projectId: path.basename(appPath), startedAt });
84
+ const runResult = await takeScreenshots(cfg, appPath, folderMap, emit);
85
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
86
+ const stats = writeOutputFiles(baseOutputPath, appPath, cfg, runResult, runId, startedAt, duration);
87
+ emit({
88
+ type: "run_finished", runId,
89
+ status: stats.failed > 0 && stats.successful === 0 ? "failed" : "success",
90
+ count: stats.successful, failed: stats.failed,
91
+ endedAt: new Date().toISOString(),
92
+ });
93
+ return;
94
+ }
95
+ console.log("");
96
+ const display = createProgressDisplay(total, cfg.viewports);
97
+ const emit = (event) => display.onEvent(event);
98
+ const runResult = await takeScreenshots(cfg, appPath, folderMap, emit);
99
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
100
+ const stats = writeOutputFiles(baseOutputPath, appPath, cfg, runResult, runId, startedAt, duration);
101
+ display.finish(runResult.screenshots, stats, duration, baseOutputPath);
102
+ }
103
+ catch (error) {
104
+ if (isJsonl) {
105
+ process.stdout.write(JSON.stringify({ type: "run_finished", runId, status: "failed", count: 0, failed: 0, endedAt: new Date().toISOString() }) + "\n");
106
+ }
107
+ else {
108
+ const msg = error instanceof Error ? error.message : String(error);
109
+ console.error(chalk.red(`\nError: ${msg}\n`));
110
+ if (msg.includes("ERR_CONNECTION_REFUSED") || msg.includes("ECONNREFUSED")) {
111
+ const baseUrl = config?.baseUrl ?? "your app";
112
+ console.log(chalk.dim(` Could not connect to ${baseUrl}`));
113
+ console.log(chalk.dim(` Make sure your dev server is running before capturing.\n`));
114
+ }
115
+ else if (msg.includes("baseUrl is required")) {
116
+ console.log(chalk.dim(` Add "baseUrl" to your pawprint.config.json (e.g. "http://localhost:3000")\n`));
117
+ }
118
+ else if (msg.includes("routes array is required")) {
119
+ console.log(chalk.dim(` Add a "routes" array to your config (e.g. ["/", "/dashboard"])\n`));
120
+ }
121
+ else if (msg.includes("modes array is required")) {
122
+ console.log(chalk.dim(` Add a "modes" array to your config (e.g. ["light", "dark"])\n`));
123
+ }
124
+ else if (msg.includes("not found") && msg.includes("storageState")) {
125
+ console.log(chalk.dim(` Authentication is configured but no session was saved.`));
126
+ console.log(chalk.dim(` Check your auth selectors and credentials in pawprint.config.json\n`));
127
+ }
128
+ else if (msg.includes("Login failed")) {
129
+ console.log(chalk.dim(` Check your auth credentials and selectors in pawprint.config.json`));
130
+ console.log(chalk.dim(` Run with --debug to see debug screenshots in the current directory.\n`));
131
+ }
132
+ if (options.debug) {
133
+ console.error(error);
134
+ }
135
+ }
136
+ process.exit(1);
137
+ }
138
+ });
139
+ program
140
+ .command("init")
141
+ .description("Create a sample pawprint.config.json file")
142
+ .argument("[app-path]", "Path where to create the config file", ".")
143
+ .option("--force", "Overwrite existing config file")
144
+ .action((appPath, options) => {
145
+ const configPath = path.join(path.resolve(appPath), CONFIG_FILE);
146
+ if (fs.existsSync(configPath) && !options.force) {
147
+ console.error(chalk.red(`Config already exists: ${configPath}`));
148
+ console.log(chalk.dim("Use --force to overwrite"));
149
+ process.exit(1);
150
+ }
151
+ const sampleConfig = {
152
+ baseUrl: "http://localhost:3000",
153
+ routes: ["/", "/dashboard", "/settings", "/profile"],
154
+ modes: ["light", "dark"],
155
+ palettes: ["default", "alternative"],
156
+ viewports: [
157
+ { name: "desktop", width: 1440, height: 900 },
158
+ { name: "mobile", width: 390, height: 844 },
159
+ { name: "tablet", width: 768, height: 1024 },
160
+ ],
161
+ appearance: {
162
+ strategy: "localStorage",
163
+ modeKey: "theme",
164
+ paletteKey: "palette",
165
+ },
166
+ outputDir: DEFAULT_OUTPUT_DIR,
167
+ auth: {
168
+ strategy: "form",
169
+ loginUrl: "/login",
170
+ credentials: {
171
+ username: "your-email@example.com",
172
+ password: "your-password",
173
+ },
174
+ selectors: {
175
+ username: "input[type=email]",
176
+ password: "input[type=password]",
177
+ submit: "button[type=submit]",
178
+ },
179
+ },
180
+ };
181
+ fs.writeFileSync(configPath, JSON.stringify(sampleConfig, null, 2));
182
+ console.log(chalk.green(`Config created: ${configPath}`));
183
+ console.log(chalk.dim(`\nEdit it with your app's details, then run:`));
184
+ console.log(` pawprint capture ${appPath}`);
185
+ });
186
+ program
187
+ .command("list")
188
+ .description("List all screenshot runs for an app")
189
+ .argument("<app-path>", "Path to the app")
190
+ .option("-o, --output <dir>", "Output directory (default: pawprint-output)")
191
+ .action((appPath, options) => {
192
+ const appName = path.basename(path.resolve(appPath));
193
+ const outputDir = options.output || DEFAULT_OUTPUT_DIR;
194
+ const appOutputPath = path.join(outputDir, appName);
195
+ if (!fs.existsSync(appOutputPath)) {
196
+ console.log(chalk.dim(`No runs found for ${appName}`));
197
+ return;
198
+ }
199
+ const dates = fs
200
+ .readdirSync(appOutputPath)
201
+ .filter((name) => DATE_REGEX.test(name))
202
+ .sort()
203
+ .reverse();
204
+ if (dates.length === 0) {
205
+ console.log(chalk.dim(`No runs found for ${appName}`));
206
+ return;
207
+ }
208
+ console.log(chalk.cyan(`\nRuns for ${appName}:\n`));
209
+ dates.forEach((date) => {
210
+ const datePath = path.join(appOutputPath, date);
211
+ const runs = fs
212
+ .readdirSync(datePath)
213
+ .filter((name) => TIMESTAMP_REGEX.test(name))
214
+ .sort()
215
+ .reverse();
216
+ if (runs.length > 0) {
217
+ console.log(` ${chalk.bold(date)}`);
218
+ runs.forEach((run) => {
219
+ const runPath = path.join(datePath, run);
220
+ const metadataPath = path.join(runPath, "metadata.json");
221
+ const timeStr = run.replace(`${date}_`, "").replace(/-/g, ":");
222
+ if (fs.existsSync(metadataPath)) {
223
+ try {
224
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
225
+ const count = metadata.stats?.totalScreenshots || "?";
226
+ const failed = metadata.stats?.failed || 0;
227
+ let line = ` ${timeStr} ${count} screenshots`;
228
+ if (failed > 0)
229
+ line += chalk.yellow(` (${failed} failed)`);
230
+ console.log(line);
231
+ }
232
+ catch {
233
+ console.log(` ${timeStr}`);
234
+ }
235
+ }
236
+ else {
237
+ console.log(` ${timeStr}`);
238
+ }
239
+ });
240
+ }
241
+ });
242
+ console.log("");
243
+ });
244
+ program.parse(process.argv);
@@ -0,0 +1,215 @@
1
+ import chalk from "chalk";
2
+ const isTTY = process.stdout.isTTY ?? false;
3
+ const HIDE_CURSOR = "\x1b[?25l";
4
+ const SHOW_CURSOR = "\x1b[?25h";
5
+ const ERASE_LINE = "\x1b[2K";
6
+ const CURSOR_UP = "\x1b[A";
7
+ function truncate(str, maxLen) {
8
+ if (str.length <= maxLen)
9
+ return str;
10
+ return str.slice(0, maxLen - 1) + "…";
11
+ }
12
+ function formatEta(seconds) {
13
+ if (seconds < 60)
14
+ return `${Math.ceil(seconds)}s`;
15
+ const mins = Math.floor(seconds / 60);
16
+ const secs = Math.ceil(seconds % 60);
17
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
18
+ }
19
+ export function createProgressDisplay(total, viewports) {
20
+ let completed = 0;
21
+ let linesDrawn = 0;
22
+ const startTime = Date.now();
23
+ const viewportCount = viewports?.length ?? 1;
24
+ const vpMap = new Map();
25
+ if (viewports) {
26
+ for (const vp of viewports) {
27
+ vpMap.set(vp.name, `${vp.width}x${vp.height}`);
28
+ }
29
+ }
30
+ const batches = new Map();
31
+ let displayedBatchKey = "";
32
+ const rW = 22;
33
+ const vW = 20;
34
+ const sW = 8;
35
+ const tableWidth = rW + vW + sW;
36
+ if (isTTY) {
37
+ process.stdout.write(HIDE_CURSOR);
38
+ const restore = () => process.stdout.write(SHOW_CURSOR);
39
+ process.on("exit", restore);
40
+ process.on("SIGINT", () => { restore(); process.exit(130); });
41
+ }
42
+ function batchKey(palette, mode) {
43
+ return `${palette}\t${mode}`;
44
+ }
45
+ function renderHeaderBar() {
46
+ const label = chalk.bold("PawPrint");
47
+ let etaStr = "";
48
+ let rawEtaLen = 0;
49
+ if (completed > 0 && completed < total) {
50
+ const elapsed = (Date.now() - startTime) / 1000;
51
+ const avgPerItem = elapsed / completed;
52
+ const remaining = (total - completed) * avgPerItem;
53
+ if (remaining > 1) {
54
+ const etaFormatted = formatEta(remaining);
55
+ etaStr = `${chalk.dim("eta")} ${chalk.dim(etaFormatted)} `;
56
+ rawEtaLen = `eta ${etaFormatted} `.length;
57
+ }
58
+ }
59
+ const counter = `${etaStr}${chalk.bold(`${completed}`)}${chalk.dim(`/${total}`)}`;
60
+ const rawCounterLen = rawEtaLen + `${completed}/${total}`.length;
61
+ const fixedLen = 2 + 8 + 1 + rawCounterLen + 1;
62
+ const barSpace = Math.max(10, tableWidth + 2 - fixedLen);
63
+ const pct = total > 0 ? completed / total : 0;
64
+ const filled = Math.round(pct * barSpace);
65
+ const empty = barSpace - filled;
66
+ const bar = chalk.yellow("━".repeat(filled)) + chalk.dim("━".repeat(empty));
67
+ return ` ${label} ${bar} ${counter}`;
68
+ }
69
+ function routeStatus(batch, route) {
70
+ const saved = batch.routeSaved.get(route) ?? 0;
71
+ const errors = batch.routeErrors.get(route) ?? 0;
72
+ if (saved === 0)
73
+ return chalk.dim("·");
74
+ if (saved >= viewportCount) {
75
+ return errors > 0 ? chalk.red("fail") : chalk.green("ok");
76
+ }
77
+ return chalk.yellow(`${saved}/${viewportCount}`);
78
+ }
79
+ function drawBatch() {
80
+ if (!isTTY)
81
+ return;
82
+ const batch = batches.get(displayedBatchKey);
83
+ if (!batch)
84
+ return;
85
+ if (linesDrawn > 0) {
86
+ for (let i = 0; i < linesDrawn; i++) {
87
+ process.stdout.write(`${CURSOR_UP}${ERASE_LINE}`);
88
+ }
89
+ process.stdout.write("\r");
90
+ }
91
+ let activeBatches = 0;
92
+ let completedBatches = 0;
93
+ for (const b of batches.values()) {
94
+ const batchTotal = b.ok + b.failed;
95
+ if (batchTotal >= b.routes.length * viewportCount)
96
+ completedBatches++;
97
+ else
98
+ activeBatches++;
99
+ }
100
+ const lines = [];
101
+ lines.push(renderHeaderBar());
102
+ lines.push("");
103
+ lines.push(` Capturing ${chalk.bold(batch.palette)} ${chalk.dim("palette")} ${chalk.bold(batch.mode)} ${chalk.dim("mode")}`);
104
+ lines.push("");
105
+ lines.push(` ${chalk.dim("Route".padEnd(rW))}${chalk.dim("Status")}`);
106
+ lines.push(chalk.dim(` ${"─".repeat(rW + sW)}`));
107
+ for (const route of batch.routes) {
108
+ const name = truncate(route, rW).padEnd(rW);
109
+ lines.push(` ${name}${routeStatus(batch, route)}`);
110
+ }
111
+ lines.push("");
112
+ if (batches.size > 1) {
113
+ lines.push(chalk.dim(` ${completedBatches}/${batches.size} batches done — Ctrl+C to cancel`));
114
+ }
115
+ else {
116
+ lines.push(chalk.dim(" Ctrl+C to cancel"));
117
+ }
118
+ process.stdout.write(lines.join("\n") + "\n");
119
+ linesDrawn = lines.length;
120
+ }
121
+ function clearProgress() {
122
+ if (isTTY && linesDrawn > 0) {
123
+ for (let i = 0; i < linesDrawn; i++) {
124
+ process.stdout.write(`${CURSOR_UP}${ERASE_LINE}`);
125
+ }
126
+ process.stdout.write("\r");
127
+ linesDrawn = 0;
128
+ }
129
+ }
130
+ return {
131
+ onEvent(event) {
132
+ if (event.type === "batch_started") {
133
+ const key = batchKey(event.palette, event.mode);
134
+ const routeSaved = new Map();
135
+ const routeErrors = new Map();
136
+ for (const r of event.routes) {
137
+ routeSaved.set(r, 0);
138
+ routeErrors.set(r, 0);
139
+ }
140
+ batches.set(key, {
141
+ palette: event.palette,
142
+ mode: event.mode,
143
+ routes: event.routes,
144
+ routeSaved,
145
+ routeErrors,
146
+ ok: 0,
147
+ failed: 0,
148
+ });
149
+ displayedBatchKey = key;
150
+ drawBatch();
151
+ }
152
+ if (event.type === "screenshot_saved") {
153
+ completed++;
154
+ const key = batchKey(event.palette, event.theme);
155
+ const batch = batches.get(key);
156
+ if (batch) {
157
+ batch.routeSaved.set(event.route, (batch.routeSaved.get(event.route) ?? 0) + 1);
158
+ if (event.status === "ok") {
159
+ batch.ok++;
160
+ }
161
+ else {
162
+ batch.failed++;
163
+ batch.routeErrors.set(event.route, (batch.routeErrors.get(event.route) ?? 0) + 1);
164
+ }
165
+ displayedBatchKey = key;
166
+ }
167
+ drawBatch();
168
+ }
169
+ if (event.type === "route_failed") {
170
+ if (!isTTY) {
171
+ console.log(` ${chalk.red("fail")} ${event.route} ${chalk.red(event.error)}`);
172
+ }
173
+ }
174
+ },
175
+ finish(results, stats, duration, outputPath) {
176
+ clearProgress();
177
+ if (isTTY) {
178
+ process.stdout.write(SHOW_CURSOR);
179
+ }
180
+ completed = total;
181
+ console.log(renderHeaderBar());
182
+ console.log("");
183
+ const pW = 16;
184
+ const mW = 12;
185
+ const groups = new Map();
186
+ for (const r of results) {
187
+ const key = `${r.palette}\t${r.mode}`;
188
+ const g = groups.get(key) ?? { ok: 0, failed: 0, total: 0 };
189
+ g.total++;
190
+ if (r.status === "ok")
191
+ g.ok++;
192
+ else
193
+ g.failed++;
194
+ groups.set(key, g);
195
+ }
196
+ console.log(` ${chalk.dim("Palette".padEnd(pW))}${chalk.dim("Mode".padEnd(mW))}${chalk.dim("Screenshots")}`);
197
+ console.log(chalk.dim(` ${"─".repeat(pW + mW + 14)}`));
198
+ for (const [key, g] of groups) {
199
+ const [palette, mode] = key.split("\t");
200
+ const countStr = g.failed > 0
201
+ ? chalk.red(`${g.ok}/${g.total}`)
202
+ : `${g.ok}/${g.total}`;
203
+ console.log(` ${palette.padEnd(pW)}${mode.padEnd(mW)}${countStr}`);
204
+ }
205
+ console.log("");
206
+ if (stats.failed > 0) {
207
+ console.log(chalk.yellow(` Done — ${stats.successful} ok, ${stats.failed} failed in ${duration}s`));
208
+ }
209
+ else {
210
+ console.log(chalk.green(` Done — ${stats.successful} screenshots in ${duration}s`));
211
+ }
212
+ console.log(chalk.dim(` Output: ${outputPath}`));
213
+ },
214
+ };
215
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Apply appearance settings (theme mode + color palette) to a page.
3
+ * Handles both localStorage and dom strategies.
4
+ * After applying, reloads the page so the app re-initializes with the new values.
5
+ */
6
+ export async function applyAppearance(page, appearance, palette, mode, waitUntil = "networkidle", waitForTimeout = 500) {
7
+ if (appearance.strategy === "localStorage") {
8
+ await page.evaluate((args) => {
9
+ if (args.modeKey)
10
+ localStorage.setItem(args.modeKey, args.mode);
11
+ if (args.paletteKey)
12
+ localStorage.setItem(args.paletteKey, args.palette);
13
+ }, {
14
+ modeKey: appearance.modeKey,
15
+ paletteKey: appearance.paletteKey,
16
+ palette,
17
+ mode,
18
+ });
19
+ await page.reload({ waitUntil });
20
+ await page.waitForTimeout(waitForTimeout);
21
+ }
22
+ else if (appearance.strategy === "dom") {
23
+ await page.evaluate((args) => {
24
+ const root = document.documentElement;
25
+ if (args.modeClass) {
26
+ root.classList.remove("light", "dark");
27
+ root.classList.add(args.mode);
28
+ }
29
+ if (args.paletteAttribute) {
30
+ root.setAttribute(args.paletteAttribute, args.palette);
31
+ }
32
+ }, {
33
+ modeClass: appearance.modeClass,
34
+ paletteAttribute: appearance.paletteAttribute,
35
+ palette,
36
+ mode,
37
+ });
38
+ await page.reload({ waitUntil });
39
+ await page.waitForTimeout(waitForTimeout);
40
+ }
41
+ }
42
+ /**
43
+ * Set initial appearance values on a freshly created page context.
44
+ * Used before navigating to routes so the first load already has the right theme.
45
+ * Navigates to baseUrl first to establish origin for localStorage access.
46
+ */
47
+ export async function initAppearance(page, appearance, baseUrl, palette, mode) {
48
+ if (appearance.strategy === "localStorage") {
49
+ await page.goto(baseUrl, { waitUntil: "domcontentloaded" });
50
+ await page.evaluate((args) => {
51
+ if (args.modeKey)
52
+ localStorage.setItem(args.modeKey, args.mode);
53
+ if (args.paletteKey)
54
+ localStorage.setItem(args.paletteKey, args.palette);
55
+ }, {
56
+ modeKey: appearance.modeKey,
57
+ paletteKey: appearance.paletteKey,
58
+ palette,
59
+ mode,
60
+ });
61
+ }
62
+ else if (appearance.strategy === "dom") {
63
+ await page.goto(baseUrl, { waitUntil: "domcontentloaded" });
64
+ await page.evaluate((args) => {
65
+ const root = document.documentElement;
66
+ if (args.modeClass) {
67
+ root.classList.remove("light", "dark");
68
+ root.classList.add(args.mode);
69
+ }
70
+ if (args.paletteAttribute) {
71
+ root.setAttribute(args.paletteAttribute, args.palette);
72
+ }
73
+ }, {
74
+ modeClass: appearance.modeClass,
75
+ paletteAttribute: appearance.paletteAttribute,
76
+ palette,
77
+ mode,
78
+ });
79
+ }
80
+ }
@@ -0,0 +1,87 @@
1
+ const STORAGE_STATE_FILE = "storageState.json";
2
+ const AUTH_NAVIGATION_TIMEOUT = 10_000;
3
+ const AUTH_SETTLE_DELAY = 2_000;
4
+ const ERROR_SELECTORS = [
5
+ ".error",
6
+ ".alert",
7
+ '[role="alert"]',
8
+ ".text-red-500",
9
+ ".text-danger",
10
+ ".error-message",
11
+ ".invalid-feedback",
12
+ ".Mui-error",
13
+ ".ant-alert-error",
14
+ '[data-testid="error"]',
15
+ ];
16
+ const ERROR_TEXT_COLORS = ["rgb(255, 0, 0)", "rgb(220, 38, 38)"];
17
+ const AUTH_URL_PATTERNS = ["auth", "login", "signin"];
18
+ /** Perform form-based authentication and save the browser session. */
19
+ export async function setupAuth(context, page, config) {
20
+ if (!config.auth)
21
+ return;
22
+ if (config.auth.strategy === "storageState") {
23
+ return;
24
+ }
25
+ if (config.auth.strategy === "form") {
26
+ const loginUrl = new URL(config.auth.loginUrl, config.baseUrl).toString();
27
+ await page.goto(loginUrl, { waitUntil: "networkidle" });
28
+ await page.screenshot({ path: "debug-01-login-page.png" });
29
+ const usernameExists = await page.$(config.auth.selectors.username);
30
+ if (!usernameExists) {
31
+ throw new Error(`Username selector "${config.auth.selectors.username}" not found`);
32
+ }
33
+ await page.fill(config.auth.selectors.username, config.auth.credentials.username);
34
+ const passwordExists = await page.$(config.auth.selectors.password);
35
+ if (!passwordExists) {
36
+ throw new Error(`Password selector "${config.auth.selectors.password}" not found`);
37
+ }
38
+ await page.fill(config.auth.selectors.password, config.auth.credentials.password);
39
+ const submitExists = await page.$(config.auth.selectors.submit);
40
+ if (!submitExists) {
41
+ throw new Error(`Submit selector "${config.auth.selectors.submit}" not found`);
42
+ }
43
+ const responses = [];
44
+ const responseListener = (response) => {
45
+ const url = response.url();
46
+ if (AUTH_URL_PATTERNS.some(pattern => url.includes(pattern))) {
47
+ responses.push({ status: response.status(), url });
48
+ }
49
+ };
50
+ page.on("response", responseListener);
51
+ await Promise.all([
52
+ page.waitForNavigation({ waitUntil: "networkidle", timeout: AUTH_NAVIGATION_TIMEOUT }).catch(() => { }),
53
+ page.click(config.auth.selectors.submit),
54
+ ]);
55
+ page.removeListener("response", responseListener);
56
+ // Allow in-flight requests to settle
57
+ await page.waitForTimeout(AUTH_SETTLE_DELAY);
58
+ await page.screenshot({ path: "debug-02-after-login.png" });
59
+ const errorText = await page.evaluate((selectors) => {
60
+ for (const selector of selectors) {
61
+ const element = document.querySelector(selector);
62
+ if (element && element.textContent) {
63
+ return element.textContent;
64
+ }
65
+ }
66
+ return null;
67
+ }, ERROR_SELECTORS);
68
+ if (errorText) {
69
+ throw new Error(`Login failed: ${errorText}`);
70
+ }
71
+ const failedResponses = responses.filter((r) => r.status === 401 || r.status === 403);
72
+ if (failedResponses.length > 0) {
73
+ throw new Error(`Login failed with status ${failedResponses[0].status}`);
74
+ }
75
+ const stillOnLogin = await page.evaluate(() => {
76
+ return (document.querySelector('input[type="password"]') !== null ||
77
+ document.querySelector('button[type="submit"]') !== null ||
78
+ window.location.pathname.includes("login") ||
79
+ window.location.pathname.includes("signin"));
80
+ });
81
+ if (stillOnLogin) {
82
+ throw new Error("Login failed - still on login page");
83
+ }
84
+ await context.storageState({ path: STORAGE_STATE_FILE });
85
+ await page.screenshot({ path: "debug-03-final-state.png" });
86
+ }
87
+ }
@@ -0,0 +1,24 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ const CONFIG_FILE = "pawprint.config.json";
4
+ export function loadConfig(appPath) {
5
+ const configPath = path.join(appPath, CONFIG_FILE);
6
+ if (!fs.existsSync(configPath)) {
7
+ throw new Error(`Config file not found at path: ${configPath}`);
8
+ }
9
+ const rawData = fs.readFileSync(configPath, "utf-8");
10
+ let config;
11
+ try {
12
+ config = JSON.parse(rawData);
13
+ }
14
+ catch (err) {
15
+ throw new Error(`Invalid JSON in config file: ${err.message}`);
16
+ }
17
+ const requiredFields = ["baseUrl", "routes", "modes", "appearance"];
18
+ for (const field of requiredFields) {
19
+ if (!(field in config)) {
20
+ throw new Error(`Missing required field in config: ${field}`);
21
+ }
22
+ }
23
+ return config;
24
+ }
@@ -0,0 +1,220 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { chromium } from "playwright";
4
+ import { setupAuth } from "./auth.js";
5
+ import { applyAppearance, initAppearance } from "./appearance.js";
6
+ function normalizeRoute(route) {
7
+ return typeof route === "string" ? { path: route } : route;
8
+ }
9
+ const DEFAULT_MODE = "default";
10
+ const DEFAULT_PALETTE = "default";
11
+ const DEFAULT_VIEWPORT = { name: "default", width: 1440, height: 900 };
12
+ const DEFAULT_OUTPUT_DIR = "pawprint-output";
13
+ const DEFAULT_WAIT_UNTIL = "networkidle";
14
+ const DEFAULT_WAIT_TIMEOUT = 500;
15
+ const FOLDER_KEY_SEPARATOR = "-";
16
+ const CONCURRENCY = 3;
17
+ const AUTH_SESSION_TTL_MS = 3_600_000;
18
+ const STORAGE_STATE_FILE = "storageState.json";
19
+ const ERROR_SCREENSHOT_PREFIX = "ERROR-";
20
+ const HOME_ROUTE_NAME = "home";
21
+ function resolveColorScheme(mode) {
22
+ if (mode === "dark")
23
+ return "dark";
24
+ if (mode === "light")
25
+ return "light";
26
+ return undefined;
27
+ }
28
+ const noopEmit = () => { };
29
+ /** Prepare timestamped output folders for all palette/mode combinations. */
30
+ export function prepareOutputFolders(config, appPath) {
31
+ const appName = path.basename(appPath);
32
+ const dateFolder = new Date().toISOString().split("T")[0];
33
+ const timestamp = new Date()
34
+ .toISOString()
35
+ .replace(/:/g, "-")
36
+ .replace("T", "_")
37
+ .split(".")[0];
38
+ const baseOutput = path.join(config.outputDir ?? DEFAULT_OUTPUT_DIR, appName, dateFolder, timestamp);
39
+ const palettes = config.palettes?.length ? config.palettes : [DEFAULT_PALETTE];
40
+ const modes = config.modes?.length ? config.modes : [DEFAULT_MODE];
41
+ const folderMap = {};
42
+ for (const palette of palettes) {
43
+ for (const mode of modes) {
44
+ const folderPath = path.join(baseOutput, palette, mode);
45
+ fs.mkdirSync(folderPath, { recursive: true });
46
+ folderMap[`${palette}${FOLDER_KEY_SEPARATOR}${mode}`] = folderPath;
47
+ }
48
+ }
49
+ return folderMap;
50
+ }
51
+ function resolveStorageStatePath(config) {
52
+ if (config.auth?.strategy === "storageState") {
53
+ return config.auth.storageStatePath;
54
+ }
55
+ return STORAGE_STATE_FILE;
56
+ }
57
+ /** Capture all routes for a single palette/mode combination in parallel. */
58
+ async function capturePaletteMode(browser, config, folderMap, palette, mode, routes, storageStatePath, isPublic, emit, waitUntil, waitForTimeout, viewportResizeWait, fullPage) {
59
+ const contextOptions = {
60
+ colorScheme: resolveColorScheme(mode),
61
+ };
62
+ if (!isPublic && fs.existsSync(storageStatePath)) {
63
+ contextOptions.storageState = storageStatePath;
64
+ }
65
+ const context = await browser.newContext(contextOptions);
66
+ // localStorage is shared across pages in the same context, so initialize once
67
+ if (config.appearance) {
68
+ const setupPage = await context.newPage();
69
+ await initAppearance(setupPage, config.appearance, config.baseUrl, palette, mode);
70
+ await setupPage.close();
71
+ }
72
+ emit({ type: "batch_started", runId: "", palette, mode, routes: routes.map(r => r.path) });
73
+ const batchResults = await Promise.all(routes.map(async (route) => {
74
+ const page = await context.newPage();
75
+ const results = [];
76
+ try {
77
+ emit({ type: "route_started", runId: "", route: route.path, palette, mode });
78
+ await captureRouteScreenshots(page, config, folderMap, palette, mode, route.path, results, emit, waitUntil, waitForTimeout, viewportResizeWait, fullPage);
79
+ }
80
+ catch (error) {
81
+ const msg = error instanceof Error ? error.message : String(error);
82
+ emit({ type: "route_failed", runId: "", route: route.path, error: msg });
83
+ }
84
+ finally {
85
+ await page.close();
86
+ }
87
+ return results;
88
+ }));
89
+ await context.close();
90
+ return batchResults.flat();
91
+ }
92
+ /** Run async tasks with a concurrency limit. */
93
+ async function runWithConcurrency(tasks, limit) {
94
+ const results = [];
95
+ let index = 0;
96
+ async function next() {
97
+ while (index < tasks.length) {
98
+ const i = index++;
99
+ results[i] = await tasks[i]();
100
+ }
101
+ }
102
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => next());
103
+ await Promise.all(workers);
104
+ return results;
105
+ }
106
+ /** Run all palette/mode combos concurrently with routes parallel within each. */
107
+ export async function takeScreenshots(config, appPath, folderMap, emit = noopEmit) {
108
+ const results = [];
109
+ const waitUntil = config.screenshotOptions?.waitUntil ?? DEFAULT_WAIT_UNTIL;
110
+ const waitForTimeout = config.screenshotOptions?.waitForTimeout ?? DEFAULT_WAIT_TIMEOUT;
111
+ const viewportResizeWait = Math.min(waitForTimeout, 300);
112
+ const fullPage = config.screenshotOptions?.fullPage ?? false;
113
+ const storageStatePath = resolveStorageStatePath(config);
114
+ const browser = await chromium.launch();
115
+ try {
116
+ const palettes = config.palettes?.length ? config.palettes : [DEFAULT_PALETTE];
117
+ const modes = config.modes?.length ? config.modes : [DEFAULT_MODE];
118
+ // Authenticate once if needed
119
+ if (config.auth?.strategy === "form") {
120
+ const shouldAuth = !fs.existsSync(storageStatePath) || Date.now() - fs.statSync(storageStatePath).mtimeMs > AUTH_SESSION_TTL_MS;
121
+ if (shouldAuth) {
122
+ emit({ type: "step", runId: "", message: "Setting up authentication" });
123
+ const authContext = await browser.newContext();
124
+ const authPage = await authContext.newPage();
125
+ try {
126
+ await setupAuth(authContext, authPage, config);
127
+ }
128
+ catch (error) {
129
+ throw error;
130
+ }
131
+ finally {
132
+ await authPage.close();
133
+ await authContext.close();
134
+ }
135
+ }
136
+ }
137
+ if (config.auth && !fs.existsSync(storageStatePath)) {
138
+ throw new Error(`Authentication required but ${storageStatePath} not found`);
139
+ }
140
+ const allRoutes = config.routes.map(normalizeRoute);
141
+ const publicRoutes = allRoutes.filter((r) => r.public);
142
+ const authRoutes = allRoutes.filter((r) => !r.public);
143
+ const tasks = [];
144
+ for (const palette of palettes) {
145
+ for (const mode of modes) {
146
+ if (publicRoutes.length > 0) {
147
+ tasks.push(() => capturePaletteMode(browser, config, folderMap, palette, mode, publicRoutes, storageStatePath, true, emit, waitUntil, waitForTimeout, viewportResizeWait, fullPage));
148
+ }
149
+ if (authRoutes.length > 0) {
150
+ tasks.push(() => capturePaletteMode(browser, config, folderMap, palette, mode, authRoutes, storageStatePath, false, emit, waitUntil, waitForTimeout, viewportResizeWait, fullPage));
151
+ }
152
+ }
153
+ }
154
+ const batchResults = await runWithConcurrency(tasks, CONCURRENCY);
155
+ for (const batch of batchResults) {
156
+ results.push(...batch);
157
+ }
158
+ }
159
+ finally {
160
+ await browser.close();
161
+ }
162
+ return { screenshots: results };
163
+ }
164
+ /** Sanitize a route path into a safe filename segment. */
165
+ function sanitizeRoute(route) {
166
+ if (route === "/")
167
+ return HOME_ROUTE_NAME;
168
+ return route
169
+ .replace(/^\//, "")
170
+ .replace(/\//g, "_")
171
+ .replace(/\?/g, "--")
172
+ .replace(/[&=]/g, "-");
173
+ }
174
+ /** Capture screenshots for a single route across all viewports. */
175
+ async function captureRouteScreenshots(page, config, folderMap, palette, mode, route, results, emit, waitUntil, waitForTimeout, viewportResizeWait, fullPage) {
176
+ const url = new URL(route, config.baseUrl).toString();
177
+ const response = await page.goto(url, { waitUntil }).catch(() => {
178
+ return null;
179
+ });
180
+ const viewports = config.viewports?.length ? config.viewports : [DEFAULT_VIEWPORT];
181
+ const safeRoute = sanitizeRoute(route);
182
+ if (response && (response.status() === 401 || response.status() === 403)) {
183
+ for (const viewport of viewports) {
184
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
185
+ const folderKey = `${palette}${FOLDER_KEY_SEPARATOR}${mode}`;
186
+ const folder = folderMap[folderKey];
187
+ const fileName = `${ERROR_SCREENSHOT_PREFIX}${safeRoute}-${viewport.name}.png`;
188
+ const filePath = path.join(folder, fileName);
189
+ await page.screenshot({ path: filePath, fullPage });
190
+ results.push({ route, viewport: viewport.name, palette, mode, filePath, status: "error", error: `HTTP ${response.status()}` });
191
+ emit({
192
+ type: "screenshot_saved", runId: "", route, theme: mode, palette,
193
+ viewport: viewport.name, path: filePath, status: "error",
194
+ });
195
+ }
196
+ emit({ type: "route_failed", runId: "", route, error: `HTTP ${response.status()}` });
197
+ return;
198
+ }
199
+ // Apply appearance and reload so the app re-initializes with correct theme values
200
+ if (config.appearance) {
201
+ await applyAppearance(page, config.appearance, palette, mode, waitUntil, waitForTimeout);
202
+ }
203
+ else {
204
+ await page.waitForTimeout(waitForTimeout);
205
+ }
206
+ for (const viewport of viewports) {
207
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
208
+ await page.waitForTimeout(viewportResizeWait);
209
+ const folderKey = `${palette}${FOLDER_KEY_SEPARATOR}${mode}`;
210
+ const folder = folderMap[folderKey];
211
+ const fileName = `${safeRoute}-${viewport.name}.png`;
212
+ const filePath = path.join(folder, fileName);
213
+ await page.screenshot({ path: filePath, fullPage });
214
+ results.push({ route, viewport: viewport.name, palette, mode, filePath, status: "ok" });
215
+ emit({
216
+ type: "screenshot_saved", runId: "", route, theme: mode, palette,
217
+ viewport: viewport.name, path: filePath, status: "ok",
218
+ });
219
+ }
220
+ }
@@ -0,0 +1,54 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ const METADATA_FILE = "metadata.json";
4
+ const RUN_FILE = "run.json";
5
+ export function writeOutputFiles(baseOutputPath, appPath, config, runResult, runId, startedAt, duration) {
6
+ const successful = runResult.screenshots.filter((s) => s.status === "ok").length;
7
+ const failed = runResult.screenshots.filter((s) => s.status === "error").length;
8
+ const total = runResult.screenshots.length;
9
+ const errors = runResult.screenshots
10
+ .filter((s) => s.status === "error" && s.error)
11
+ .map((s) => ({ route: s.route, mode: s.mode, palette: s.palette, error: s.error }));
12
+ const metadata = {
13
+ timestamp: new Date().toISOString(),
14
+ appPath,
15
+ config: {
16
+ baseUrl: config.baseUrl,
17
+ routes: config.routes,
18
+ modes: config.modes,
19
+ palettes: config.palettes,
20
+ viewports: config.viewports,
21
+ },
22
+ outputPath: baseOutputPath,
23
+ stats: {
24
+ totalScreenshots: total,
25
+ successful,
26
+ failed,
27
+ errors: errors.length > 0 ? errors : undefined,
28
+ },
29
+ };
30
+ fs.writeFileSync(path.join(baseOutputPath, METADATA_FILE), JSON.stringify(metadata, null, 2));
31
+ const runSummary = {
32
+ runId,
33
+ startedAt,
34
+ endedAt: new Date().toISOString(),
35
+ durationSeconds: parseFloat(duration),
36
+ status: failed > 0 && successful === 0 ? "failed" : "success",
37
+ config: {
38
+ baseUrl: config.baseUrl,
39
+ routes: config.routes,
40
+ modes: config.modes,
41
+ palettes: config.palettes,
42
+ viewports: config.viewports,
43
+ screenshotOptions: config.screenshotOptions,
44
+ },
45
+ stats: {
46
+ total,
47
+ successful,
48
+ failed,
49
+ errors: errors.length > 0 ? errors : undefined,
50
+ },
51
+ };
52
+ fs.writeFileSync(path.join(baseOutputPath, RUN_FILE), JSON.stringify(runSummary, null, 2));
53
+ return { successful, failed, total };
54
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@cydoentis/pawprint",
3
+ "version": "1.0.0",
4
+ "description": "Automated screenshot tool for web apps across themes, palettes, and viewports",
5
+ "type": "module",
6
+ "bin": {
7
+ "pawprint": "./dist/cli/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "prepublishOnly": "npm run build",
15
+ "start": "tsx src/cli/index.ts",
16
+ "dev": "tsx watch src/cli/index.ts",
17
+ "test": "echo \"Error: no test specified\" && exit 1"
18
+ },
19
+ "keywords": [
20
+ "screenshot",
21
+ "playwright",
22
+ "testing",
23
+ "visual-regression",
24
+ "theming",
25
+ "dark-mode",
26
+ "cli",
27
+ "automation"
28
+ ],
29
+ "author": "Cydo Entis",
30
+ "license": "MIT",
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/CydoEntis/pawprint"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^25.2.3",
40
+ "tsx": "^4.21.0",
41
+ "typescript": "^5.9.3"
42
+ },
43
+ "dependencies": {
44
+ "chalk": "^5.6.2",
45
+ "commander": "^14.0.3",
46
+ "playwright": "^1.58.2"
47
+ }
48
+ }