@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 +332 -0
- package/dist/cli/index.js +244 -0
- package/dist/cli/progress.js +215 -0
- package/dist/core/appearance.js +80 -0
- package/dist/core/auth.js +87 -0
- package/dist/core/config.js +24 -0
- package/dist/core/engine.js +220 -0
- package/dist/core/output.js +54 -0
- package/dist/types/config.js +1 -0
- package/package.json +48 -0
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
|
+
}
|