@thepixelhouse/cli 0.1.0 → 0.2.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 +249 -0
- package/dist/cli.js +194 -47
- package/package.json +5 -3
package/README.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# @thepixelhouse/cli
|
|
2
|
+
|
|
3
|
+
Visual regression testing from the command line. Capture screenshots, diff them against baselines, run regression suites, and manage monitors — all from your terminal or CI pipeline.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @thepixelhouse/cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or run without installing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @thepixelhouse/cli screenshot https://example.com
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# 1. Create a config file
|
|
21
|
+
pixelhouse init
|
|
22
|
+
|
|
23
|
+
# 2. Set your API key
|
|
24
|
+
export PIXELHOUSE_API_KEY=ph_live_your_key_here
|
|
25
|
+
|
|
26
|
+
# 3. Capture your first screenshot
|
|
27
|
+
pixelhouse screenshot https://your-site.com --project prj_xxx
|
|
28
|
+
|
|
29
|
+
# 4. Promote it to a baseline
|
|
30
|
+
pixelhouse baseline create ss_abc123 --project prj_xxx --url https://your-site.com
|
|
31
|
+
|
|
32
|
+
# 5. Run a regression test
|
|
33
|
+
pixelhouse regression run --url https://your-site.com --baseline bl_xyz789
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Authentication
|
|
37
|
+
|
|
38
|
+
The CLI needs an API key. Provide it in any of these ways (highest priority first):
|
|
39
|
+
|
|
40
|
+
1. **CLI flag:** `--api-key ph_live_xxx`
|
|
41
|
+
2. **Environment variable:** `PIXELHOUSE_API_KEY=ph_live_xxx`
|
|
42
|
+
3. **Config file:** `apiKey` field in `.pixelhouserc.json`
|
|
43
|
+
|
|
44
|
+
Get your API key from the [dashboard](https://thepixelhouse.co.uk/settings/api-keys).
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
### `pixelhouse screenshot <url>`
|
|
49
|
+
|
|
50
|
+
Capture a screenshot of a URL.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pixelhouse screenshot https://example.com
|
|
54
|
+
pixelhouse screenshot https://example.com --viewport mobile
|
|
55
|
+
pixelhouse screenshot https://example.com --viewport 1440x900 --full-page
|
|
56
|
+
pixelhouse screenshot https://example.com --output ./screenshot.png
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
| Option | Description | Default |
|
|
60
|
+
|--------|-------------|---------|
|
|
61
|
+
| `--viewport <preset\|WxH>` | Viewport size (desktop, tablet, mobile, or custom) | `desktop` |
|
|
62
|
+
| `--full-page` | Capture the full scrollable page | `false` |
|
|
63
|
+
| `--project <id>` | Project ID | from config |
|
|
64
|
+
| `--output <path>` | Save PNG to a local file | - |
|
|
65
|
+
| `--wait-for <strategy>` | Load strategy (networkIdle, domContentLoaded, load) | `networkIdle` |
|
|
66
|
+
|
|
67
|
+
### `pixelhouse compare <screenshot-a> <screenshot-b>`
|
|
68
|
+
|
|
69
|
+
Compare two screenshots for visual differences.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pixelhouse compare ss_abc123 ss_def456
|
|
73
|
+
pixelhouse compare ss_abc123 ss_def456 --threshold 0.5 --output ./diff.png
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Option | Description | Default |
|
|
77
|
+
|--------|-------------|---------|
|
|
78
|
+
| `--threshold <number>` | Diff threshold percentage (0-100) | `1.0` |
|
|
79
|
+
| `--output <path>` | Save diff image to a local file | - |
|
|
80
|
+
|
|
81
|
+
### `pixelhouse regression run`
|
|
82
|
+
|
|
83
|
+
Run visual regression tests against baselines.
|
|
84
|
+
|
|
85
|
+
**Single URL mode:**
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pixelhouse regression run --url https://example.com --baseline bl_xyz789
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Config-driven mode** (reads pages from `.pixelhouserc.json`):
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pixelhouse regression run
|
|
95
|
+
pixelhouse regression run --ci
|
|
96
|
+
pixelhouse regression run --fail-on-diff
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
| Option | Description | Default |
|
|
100
|
+
|--------|-------------|---------|
|
|
101
|
+
| `--url <url>` | Single URL to test | - |
|
|
102
|
+
| `--baseline <id>` | Baseline ID to compare against | - |
|
|
103
|
+
| `--threshold <number>` | Diff threshold percentage | `1.0` |
|
|
104
|
+
| `--viewport <preset\|WxH>` | Viewport size | `desktop` |
|
|
105
|
+
| `--ci` | CI mode with exit codes | `false` |
|
|
106
|
+
| `--fail-on-diff` | Non-zero exit on any diff exceeding threshold | `false` |
|
|
107
|
+
|
|
108
|
+
**Exit codes (CI mode):**
|
|
109
|
+
- `0` — all tests passed
|
|
110
|
+
- `1` — visual regression detected
|
|
111
|
+
- `2` — error (capture failed, network error, etc.)
|
|
112
|
+
|
|
113
|
+
### `pixelhouse baseline <subcommand>`
|
|
114
|
+
|
|
115
|
+
Manage screenshot baselines.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# List baselines
|
|
119
|
+
pixelhouse baseline list --project prj_xxx
|
|
120
|
+
pixelhouse baseline list --project prj_xxx --branch staging
|
|
121
|
+
|
|
122
|
+
# Promote a screenshot to baseline
|
|
123
|
+
pixelhouse baseline create ss_abc123 --project prj_xxx --url https://example.com
|
|
124
|
+
pixelhouse baseline create ss_abc123 --project prj_xxx --url https://example.com --branch staging
|
|
125
|
+
|
|
126
|
+
# Delete a baseline
|
|
127
|
+
pixelhouse baseline delete bl_xyz789
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### `pixelhouse monitor <subcommand>`
|
|
131
|
+
|
|
132
|
+
Manage scheduled visual monitors.
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# List monitors
|
|
136
|
+
pixelhouse monitor list --project prj_xxx
|
|
137
|
+
|
|
138
|
+
# Add a monitor (checks every hour)
|
|
139
|
+
pixelhouse monitor add https://example.com --project prj_xxx --every 1h
|
|
140
|
+
|
|
141
|
+
# Add with custom interval and threshold
|
|
142
|
+
pixelhouse monitor add https://example.com --project prj_xxx --every 30m --threshold 0.5
|
|
143
|
+
|
|
144
|
+
# Pause / resume / delete
|
|
145
|
+
pixelhouse monitor pause mon_abc123
|
|
146
|
+
pixelhouse monitor resume mon_abc123
|
|
147
|
+
pixelhouse monitor delete mon_abc123
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
| Interval | Cron equivalent |
|
|
151
|
+
|----------|-----------------|
|
|
152
|
+
| `30m` | `*/30 * * * *` |
|
|
153
|
+
| `1h` | `0 */1 * * *` |
|
|
154
|
+
| `6h` | `0 */6 * * *` |
|
|
155
|
+
| `1d` | `0 0 */1 * *` |
|
|
156
|
+
|
|
157
|
+
You can also pass a raw cron expression: `--every "0 9 * * 1-5"`.
|
|
158
|
+
|
|
159
|
+
### `pixelhouse init`
|
|
160
|
+
|
|
161
|
+
Create a `.pixelhouserc.json` configuration file.
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pixelhouse init
|
|
165
|
+
pixelhouse init --force # overwrite existing
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Configuration
|
|
169
|
+
|
|
170
|
+
Create a `.pixelhouserc.json` in your project root (or run `pixelhouse init`):
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
{
|
|
174
|
+
"apiKey": "ph_live_your_key_here",
|
|
175
|
+
"apiUrl": "https://api.thepixelhouse.co.uk",
|
|
176
|
+
"projectId": "prj_xxx",
|
|
177
|
+
"viewport": "desktop",
|
|
178
|
+
"threshold": 1.0,
|
|
179
|
+
"branch": "main",
|
|
180
|
+
"pages": [
|
|
181
|
+
{
|
|
182
|
+
"url": "https://your-site.com",
|
|
183
|
+
"baselineId": "bl_abc123",
|
|
184
|
+
"viewport": "desktop",
|
|
185
|
+
"threshold": 1.0
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"url": "https://your-site.com/pricing",
|
|
189
|
+
"baselineId": "bl_def456",
|
|
190
|
+
"viewport": "mobile"
|
|
191
|
+
}
|
|
192
|
+
]
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The CLI searches for this file starting from your current directory, walking up to the filesystem root.
|
|
197
|
+
|
|
198
|
+
## CI/CD integration
|
|
199
|
+
|
|
200
|
+
### GitHub Actions
|
|
201
|
+
|
|
202
|
+
```yaml
|
|
203
|
+
- name: Run visual regression tests
|
|
204
|
+
env:
|
|
205
|
+
PIXELHOUSE_API_KEY: ${{ secrets.PIXELHOUSE_API_KEY }}
|
|
206
|
+
run: npx @thepixelhouse/cli regression run --ci
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### GitLab CI
|
|
210
|
+
|
|
211
|
+
```yaml
|
|
212
|
+
visual-regression:
|
|
213
|
+
script:
|
|
214
|
+
- npx @thepixelhouse/cli regression run --ci
|
|
215
|
+
variables:
|
|
216
|
+
PIXELHOUSE_API_KEY: $PIXELHOUSE_API_KEY
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Global options
|
|
220
|
+
|
|
221
|
+
These options apply to all commands:
|
|
222
|
+
|
|
223
|
+
| Option | Description |
|
|
224
|
+
|--------|-------------|
|
|
225
|
+
| `--api-key <key>` | API key (overrides env var and config) |
|
|
226
|
+
| `--api-url <url>` | API base URL |
|
|
227
|
+
| `--json` | Output results as JSON |
|
|
228
|
+
| `--no-color` | Disable coloured output |
|
|
229
|
+
| `-v, --version` | Print version |
|
|
230
|
+
| `-h, --help` | Print help |
|
|
231
|
+
|
|
232
|
+
## JSON output
|
|
233
|
+
|
|
234
|
+
Pass `--json` to any command for machine-readable output:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
pixelhouse screenshot https://example.com --json | jq '.id'
|
|
238
|
+
pixelhouse regression run --json | jq '.summary'
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Links
|
|
242
|
+
|
|
243
|
+
- [Documentation](https://thepixelhouse.co.uk/docs)
|
|
244
|
+
- [Dashboard](https://thepixelhouse.co.uk)
|
|
245
|
+
- [API reference](https://api.thepixelhouse.co.uk/docs)
|
|
246
|
+
|
|
247
|
+
## Licence
|
|
248
|
+
|
|
249
|
+
MIT - ToggleKit Ltd
|
package/dist/cli.js
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command as Command7 } from "commander";
|
|
5
|
-
import
|
|
5
|
+
import chalk8 from "chalk";
|
|
6
6
|
|
|
7
7
|
// src/commands/screenshot.ts
|
|
8
8
|
import { Command } from "commander";
|
|
9
|
-
import * as
|
|
10
|
-
import * as
|
|
9
|
+
import * as fs3 from "fs";
|
|
10
|
+
import * as path3 from "path";
|
|
11
11
|
import ora from "ora";
|
|
12
|
+
import chalk2 from "chalk";
|
|
12
13
|
|
|
13
14
|
// src/api/client.ts
|
|
14
15
|
var ApiClient = class {
|
|
@@ -73,17 +74,17 @@ var ApiClient = class {
|
|
|
73
74
|
return this.delete(`/v1/monitors/${id}`);
|
|
74
75
|
}
|
|
75
76
|
// ─── Internal helpers ─────────────────────────────
|
|
76
|
-
async request(
|
|
77
|
-
const url = `${this.baseUrl}${
|
|
77
|
+
async request(path6, init) {
|
|
78
|
+
const url = `${this.baseUrl}${path6}`;
|
|
78
79
|
const headers = new Headers(init.headers);
|
|
79
80
|
headers.set("Authorization", `Bearer ${this.apiKey}`);
|
|
80
81
|
headers.set("Accept", "application/json");
|
|
81
82
|
headers.set("User-Agent", "@pixelhouse/cli");
|
|
82
83
|
return fetch(url, { ...init, headers });
|
|
83
84
|
}
|
|
84
|
-
async get(
|
|
85
|
+
async get(path6) {
|
|
85
86
|
try {
|
|
86
|
-
const res = await this.request(
|
|
87
|
+
const res = await this.request(path6, { method: "GET" });
|
|
87
88
|
const body = await res.json();
|
|
88
89
|
if (!res.ok || "error" in body) {
|
|
89
90
|
const errBody = body;
|
|
@@ -94,9 +95,9 @@ var ApiClient = class {
|
|
|
94
95
|
return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
|
-
async getPaginated(
|
|
98
|
+
async getPaginated(path6) {
|
|
98
99
|
try {
|
|
99
|
-
const res = await this.request(
|
|
100
|
+
const res = await this.request(path6, { method: "GET" });
|
|
100
101
|
const body = await res.json();
|
|
101
102
|
if (!res.ok || "error" in body) {
|
|
102
103
|
const errBody = body;
|
|
@@ -107,9 +108,9 @@ var ApiClient = class {
|
|
|
107
108
|
return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
|
-
async post(
|
|
111
|
+
async post(path6, body) {
|
|
111
112
|
try {
|
|
112
|
-
const res = await this.request(
|
|
113
|
+
const res = await this.request(path6, {
|
|
113
114
|
method: "POST",
|
|
114
115
|
headers: { "Content-Type": "application/json" },
|
|
115
116
|
body: JSON.stringify(body)
|
|
@@ -124,9 +125,9 @@ var ApiClient = class {
|
|
|
124
125
|
return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
|
-
async patch(
|
|
128
|
+
async patch(path6, body) {
|
|
128
129
|
try {
|
|
129
|
-
const res = await this.request(
|
|
130
|
+
const res = await this.request(path6, {
|
|
130
131
|
method: "PATCH",
|
|
131
132
|
headers: { "Content-Type": "application/json" },
|
|
132
133
|
body: JSON.stringify(body)
|
|
@@ -141,9 +142,9 @@ var ApiClient = class {
|
|
|
141
142
|
return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
|
|
142
143
|
}
|
|
143
144
|
}
|
|
144
|
-
async delete(
|
|
145
|
+
async delete(path6) {
|
|
145
146
|
try {
|
|
146
|
-
const res = await this.request(
|
|
147
|
+
const res = await this.request(path6, { method: "DELETE" });
|
|
147
148
|
const json = await res.json();
|
|
148
149
|
if (!res.ok || "error" in json) {
|
|
149
150
|
const errBody = json;
|
|
@@ -154,9 +155,9 @@ var ApiClient = class {
|
|
|
154
155
|
return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
|
-
async getBlob(
|
|
158
|
+
async getBlob(path6) {
|
|
158
159
|
try {
|
|
159
|
-
const res = await this.request(
|
|
160
|
+
const res = await this.request(path6, { method: "GET" });
|
|
160
161
|
if (!res.ok) {
|
|
161
162
|
let message = `API returned ${res.status}`;
|
|
162
163
|
try {
|
|
@@ -174,6 +175,34 @@ var ApiClient = class {
|
|
|
174
175
|
}
|
|
175
176
|
};
|
|
176
177
|
|
|
178
|
+
// src/api/free-client.ts
|
|
179
|
+
var FreeClient = class {
|
|
180
|
+
baseUrl;
|
|
181
|
+
constructor(baseUrl) {
|
|
182
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
183
|
+
}
|
|
184
|
+
async captureScreenshot(params) {
|
|
185
|
+
try {
|
|
186
|
+
const res = await fetch(`${this.baseUrl}/v1/free/screenshot`, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: {
|
|
189
|
+
"Content-Type": "application/json",
|
|
190
|
+
"User-Agent": "@thepixelhouse/cli"
|
|
191
|
+
},
|
|
192
|
+
body: JSON.stringify(params)
|
|
193
|
+
});
|
|
194
|
+
const body = await res.json();
|
|
195
|
+
if (!res.ok || "error" in body) {
|
|
196
|
+
const errBody = body;
|
|
197
|
+
return { success: false, error: errBody.error.message };
|
|
198
|
+
}
|
|
199
|
+
return { success: true, data: body.data };
|
|
200
|
+
} catch (err) {
|
|
201
|
+
return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
177
206
|
// src/config/loader.ts
|
|
178
207
|
import * as fs from "fs";
|
|
179
208
|
import * as path from "path";
|
|
@@ -300,6 +329,58 @@ function output(text, jsonData, jsonMode) {
|
|
|
300
329
|
}
|
|
301
330
|
}
|
|
302
331
|
|
|
332
|
+
// src/lib/trial.ts
|
|
333
|
+
import * as fs2 from "fs";
|
|
334
|
+
import * as path2 from "path";
|
|
335
|
+
import * as os from "os";
|
|
336
|
+
var TRIAL_LIMIT = 10;
|
|
337
|
+
var TRIAL_FILE = "trial.json";
|
|
338
|
+
function defaultConfigDir() {
|
|
339
|
+
return path2.join(os.homedir(), ".config", "thepixelhouse");
|
|
340
|
+
}
|
|
341
|
+
function trialPath(configDir) {
|
|
342
|
+
return path2.join(configDir, TRIAL_FILE);
|
|
343
|
+
}
|
|
344
|
+
function readTrial(configDir) {
|
|
345
|
+
const filePath = trialPath(configDir);
|
|
346
|
+
try {
|
|
347
|
+
const raw = fs2.readFileSync(filePath, "utf-8");
|
|
348
|
+
return JSON.parse(raw);
|
|
349
|
+
} catch {
|
|
350
|
+
return { count: 0, firstUsed: (/* @__PURE__ */ new Date()).toISOString() };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function writeTrial(configDir, data) {
|
|
354
|
+
fs2.mkdirSync(configDir, { recursive: true });
|
|
355
|
+
fs2.writeFileSync(trialPath(configDir), JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
356
|
+
}
|
|
357
|
+
function getTrialUsage(configDir) {
|
|
358
|
+
const dir = configDir ?? defaultConfigDir();
|
|
359
|
+
const data = readTrial(dir);
|
|
360
|
+
return {
|
|
361
|
+
count: data.count,
|
|
362
|
+
remaining: Math.max(0, TRIAL_LIMIT - data.count)
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
function incrementTrialUsage(configDir) {
|
|
366
|
+
const dir = configDir ?? defaultConfigDir();
|
|
367
|
+
const data = readTrial(dir);
|
|
368
|
+
data.count += 1;
|
|
369
|
+
if (data.count === 1) {
|
|
370
|
+
data.firstUsed = (/* @__PURE__ */ new Date()).toISOString();
|
|
371
|
+
}
|
|
372
|
+
writeTrial(dir, data);
|
|
373
|
+
return {
|
|
374
|
+
count: data.count,
|
|
375
|
+
remaining: Math.max(0, TRIAL_LIMIT - data.count)
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
function isTrialExpired(configDir) {
|
|
379
|
+
const dir = configDir ?? defaultConfigDir();
|
|
380
|
+
const data = readTrial(dir);
|
|
381
|
+
return data.count >= TRIAL_LIMIT;
|
|
382
|
+
}
|
|
383
|
+
|
|
303
384
|
// src/commands/screenshot.ts
|
|
304
385
|
function createScreenshotCommand() {
|
|
305
386
|
const cmd = new Command("screenshot").description("Capture a screenshot of a URL").argument("<url>", "URL to capture").option("--viewport <viewport>", "viewport preset or WxH (e.g. desktop, 1440x900)", "desktop").option("--full-page", "capture the full scrollable page", false).option("--project <id>", "project ID to associate the screenshot with").option("--output <path>", "save the screenshot PNG to a local file").option("--wait-for <strategy>", "page load strategy: networkIdle, domContentLoaded, load", "networkIdle").action(async (url, opts) => {
|
|
@@ -309,8 +390,12 @@ function createScreenshotCommand() {
|
|
|
309
390
|
apiUrl: parentOpts?.apiUrl
|
|
310
391
|
});
|
|
311
392
|
const jsonMode = parentOpts?.json ?? false;
|
|
312
|
-
const
|
|
313
|
-
|
|
393
|
+
const apiUrl = config.apiUrl ?? "https://api.thepixelhouse.co.uk";
|
|
394
|
+
if (!config.apiKey) {
|
|
395
|
+
await handleTrialCapture(url, opts, apiUrl, jsonMode);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const client = new ApiClient(apiUrl, config.apiKey);
|
|
314
399
|
const spinner = jsonMode ? null : ora("Capturing screenshot...").start();
|
|
315
400
|
const result = await client.captureScreenshot({
|
|
316
401
|
url,
|
|
@@ -330,8 +415,8 @@ function createScreenshotCommand() {
|
|
|
330
415
|
spinner?.warn("Screenshot captured but download failed");
|
|
331
416
|
throw new CliError(`Failed to download image: ${downloadResult.error}`, EXIT_CODES.ERROR);
|
|
332
417
|
}
|
|
333
|
-
const outputPath =
|
|
334
|
-
|
|
418
|
+
const outputPath = path3.resolve(opts.output);
|
|
419
|
+
fs3.writeFileSync(outputPath, Buffer.from(downloadResult.data));
|
|
335
420
|
spinner?.succeed("Screenshot captured and saved");
|
|
336
421
|
const text2 = [
|
|
337
422
|
formatSuccess("Screenshot captured and saved"),
|
|
@@ -358,13 +443,75 @@ function createScreenshotCommand() {
|
|
|
358
443
|
});
|
|
359
444
|
return cmd;
|
|
360
445
|
}
|
|
446
|
+
async function handleTrialCapture(url, opts, apiUrl, jsonMode) {
|
|
447
|
+
if (isTrialExpired()) {
|
|
448
|
+
const msg = [
|
|
449
|
+
chalk2.yellow(`You have used all ${String(TRIAL_LIMIT)} free trial screenshots.`),
|
|
450
|
+
"",
|
|
451
|
+
"Sign up for a free account to continue:",
|
|
452
|
+
chalk2.cyan(" https://thepixelhouse.co.uk/register"),
|
|
453
|
+
"",
|
|
454
|
+
"Then set your API key:",
|
|
455
|
+
chalk2.dim(" export PIXELHOUSE_API_KEY=ph_live_your_key")
|
|
456
|
+
].join("\n");
|
|
457
|
+
throw new CliError(msg, EXIT_CODES.ERROR);
|
|
458
|
+
}
|
|
459
|
+
const viewport = opts.viewport;
|
|
460
|
+
if (!["desktop", "tablet", "mobile"].includes(viewport)) {
|
|
461
|
+
throw new CliError(
|
|
462
|
+
"Trial mode only supports viewport presets: desktop, tablet, mobile. Sign up for custom viewports.",
|
|
463
|
+
EXIT_CODES.ERROR
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
const { remaining } = getTrialUsage();
|
|
467
|
+
const spinner = jsonMode ? null : ora(`Capturing screenshot (trial: ${String(remaining)} of ${String(TRIAL_LIMIT)} remaining)...`).start();
|
|
468
|
+
const client = new FreeClient(apiUrl);
|
|
469
|
+
const result = await client.captureScreenshot({ url, viewport });
|
|
470
|
+
if (!result.success) {
|
|
471
|
+
spinner?.fail("Screenshot capture failed");
|
|
472
|
+
throw new CliError(result.error, EXIT_CODES.ERROR);
|
|
473
|
+
}
|
|
474
|
+
const usage = incrementTrialUsage();
|
|
475
|
+
if (opts.output) {
|
|
476
|
+
const outputPath = path3.resolve(opts.output);
|
|
477
|
+
const imageBuffer = Buffer.from(result.data.image, "base64");
|
|
478
|
+
fs3.writeFileSync(outputPath, imageBuffer);
|
|
479
|
+
spinner?.succeed("Screenshot captured and saved");
|
|
480
|
+
const text2 = [
|
|
481
|
+
formatSuccess("Screenshot captured and saved (trial)"),
|
|
482
|
+
formatInfo("URL", url),
|
|
483
|
+
formatInfo("Viewport", `${result.data.viewport} (${String(result.data.viewportWidth)}x${String(result.data.viewportHeight)})`),
|
|
484
|
+
formatInfo("Saved to", outputPath),
|
|
485
|
+
"",
|
|
486
|
+
chalk2.dim(`Trial: ${String(usage.remaining)} of ${String(TRIAL_LIMIT)} free screenshots remaining`)
|
|
487
|
+
].join("\n");
|
|
488
|
+
output(text2, { ...result.data, trial: true, remaining: usage.remaining }, jsonMode);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
spinner?.succeed("Screenshot captured (trial)");
|
|
492
|
+
const text = [
|
|
493
|
+
formatSuccess("Screenshot captured (trial)"),
|
|
494
|
+
formatInfo("URL", url),
|
|
495
|
+
formatInfo("Viewport", `${result.data.viewport} (${String(result.data.viewportWidth)}x${String(result.data.viewportHeight)})`),
|
|
496
|
+
"",
|
|
497
|
+
chalk2.dim(`Trial: ${String(usage.remaining)} of ${String(TRIAL_LIMIT)} free screenshots remaining`),
|
|
498
|
+
chalk2.dim("Use --output <file.png> to save the image locally")
|
|
499
|
+
].join("\n");
|
|
500
|
+
output(text, { ...result.data, trial: true, remaining: usage.remaining }, jsonMode);
|
|
501
|
+
if (usage.remaining <= 3 && usage.remaining > 0) {
|
|
502
|
+
console.error(
|
|
503
|
+
chalk2.yellow(`
|
|
504
|
+
Sign up for a free account to keep capturing: https://thepixelhouse.co.uk/register`)
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
361
508
|
|
|
362
509
|
// src/commands/compare.ts
|
|
363
510
|
import { Command as Command2 } from "commander";
|
|
364
|
-
import * as
|
|
365
|
-
import * as
|
|
511
|
+
import * as fs4 from "fs";
|
|
512
|
+
import * as path4 from "path";
|
|
366
513
|
import ora2 from "ora";
|
|
367
|
-
import
|
|
514
|
+
import chalk3 from "chalk";
|
|
368
515
|
function createCompareCommand() {
|
|
369
516
|
const cmd = new Command2("compare").description("Compare two screenshots for visual differences").argument("<screenshot-a>", "ID of the first screenshot (before)").argument("<screenshot-b>", "ID of the second screenshot (after)").option("--threshold <number>", "diff threshold percentage (0-100)", "1.0").option("--output <path>", "save the diff image to a local file").action(async (screenshotA, screenshotB, opts) => {
|
|
370
517
|
const parentOpts = cmd.parent?.opts();
|
|
@@ -393,14 +540,14 @@ function createCompareCommand() {
|
|
|
393
540
|
if (opts.output && comparison.diffImageUrl) {
|
|
394
541
|
const downloadResult = await client.downloadDiffImage(comparison.id);
|
|
395
542
|
if (downloadResult.success) {
|
|
396
|
-
const outputPath =
|
|
397
|
-
|
|
543
|
+
const outputPath = path4.resolve(opts.output);
|
|
544
|
+
fs4.writeFileSync(outputPath, Buffer.from(downloadResult.data));
|
|
398
545
|
}
|
|
399
546
|
}
|
|
400
547
|
spinner?.succeed("Comparison complete");
|
|
401
|
-
const statusLabel = comparison.status === "pass" ?
|
|
548
|
+
const statusLabel = comparison.status === "pass" ? chalk3.green("PASS") : comparison.status === "fail" ? chalk3.red("FAIL") : chalk3.yellow(comparison.status.toUpperCase());
|
|
402
549
|
const lines = [
|
|
403
|
-
`${
|
|
550
|
+
`${chalk3.bold("Result:")} ${statusLabel}`,
|
|
404
551
|
formatInfo("Comparison ID", comparison.id),
|
|
405
552
|
formatInfo("Diff percentage", comparison.diffPercentage !== null ? `${String(comparison.diffPercentage)}%` : "N/A"),
|
|
406
553
|
formatInfo("SSIM score", comparison.ssimScore !== null ? String(comparison.ssimScore) : "N/A"),
|
|
@@ -410,7 +557,7 @@ function createCompareCommand() {
|
|
|
410
557
|
lines.push(formatInfo("Diff image", comparison.diffImageUrl));
|
|
411
558
|
}
|
|
412
559
|
if (opts.output && comparison.diffImageUrl) {
|
|
413
|
-
lines.push(formatInfo("Saved to",
|
|
560
|
+
lines.push(formatInfo("Saved to", path4.resolve(opts.output)));
|
|
414
561
|
}
|
|
415
562
|
output(lines.join("\n"), comparison, jsonMode);
|
|
416
563
|
});
|
|
@@ -420,7 +567,7 @@ function createCompareCommand() {
|
|
|
420
567
|
// src/commands/regression.ts
|
|
421
568
|
import { Command as Command3 } from "commander";
|
|
422
569
|
import ora3 from "ora";
|
|
423
|
-
import
|
|
570
|
+
import chalk4 from "chalk";
|
|
424
571
|
function toTableRow(url, result) {
|
|
425
572
|
return {
|
|
426
573
|
url,
|
|
@@ -497,10 +644,10 @@ function createRegressionCommand() {
|
|
|
497
644
|
}
|
|
498
645
|
const colouredRows = rows.map((row) => ({
|
|
499
646
|
...row,
|
|
500
|
-
status: row.status === "pass" ?
|
|
647
|
+
status: row.status === "pass" ? chalk4.green("pass") : row.status === "fail" ? chalk4.red("fail") : chalk4.yellow(row.status)
|
|
501
648
|
}));
|
|
502
649
|
spinner?.stop();
|
|
503
|
-
const summary = hasFailure ? formatError(`${String(rows.filter((r) => r.status === "fail" || r.status ===
|
|
650
|
+
const summary = hasFailure ? formatError(`${String(rows.filter((r) => r.status === "fail" || r.status === chalk4.red("fail")).length)} test(s) failed`) : hasError ? formatError(`${String(rows.filter((r) => r.status === "error" || r.status === chalk4.yellow("error")).length)} test(s) errored`) : formatSuccess(`All ${String(rows.length)} test(s) passed`);
|
|
504
651
|
const tableOutput = formatTable(colouredRows, ["url", "status", "diff", "ssim", "threshold"]);
|
|
505
652
|
const text = `${tableOutput}
|
|
506
653
|
|
|
@@ -543,7 +690,7 @@ function buildPageList(opts, config) {
|
|
|
543
690
|
// src/commands/baseline.ts
|
|
544
691
|
import { Command as Command4 } from "commander";
|
|
545
692
|
import ora4 from "ora";
|
|
546
|
-
import
|
|
693
|
+
import chalk5 from "chalk";
|
|
547
694
|
function createBaselineCommand() {
|
|
548
695
|
const baseline = new Command4("baseline").description("Manage screenshot baselines");
|
|
549
696
|
baseline.command("list").description("List baselines for a project").requiredOption("--project <id>", "project ID").option("--branch <name>", "filter by branch name").option("--limit <number>", "max results to return", "25").action(async (opts) => {
|
|
@@ -571,7 +718,7 @@ function createBaselineCommand() {
|
|
|
571
718
|
url: b.url,
|
|
572
719
|
viewport: b.viewport,
|
|
573
720
|
branch: b.branch,
|
|
574
|
-
active: b.isActive ?
|
|
721
|
+
active: b.isActive ? chalk5.green("yes") : chalk5.dim("no"),
|
|
575
722
|
created: b.createdAt
|
|
576
723
|
}));
|
|
577
724
|
const tableText = formatTable(rows, ["id", "url", "viewport", "branch", "active", "created"]);
|
|
@@ -633,7 +780,7 @@ function createBaselineCommand() {
|
|
|
633
780
|
// src/commands/monitor.ts
|
|
634
781
|
import { Command as Command5 } from "commander";
|
|
635
782
|
import ora5 from "ora";
|
|
636
|
-
import
|
|
783
|
+
import chalk6 from "chalk";
|
|
637
784
|
function intervalToCron(interval) {
|
|
638
785
|
if (interval.includes(" ")) {
|
|
639
786
|
return interval;
|
|
@@ -682,7 +829,7 @@ function createMonitorCommand() {
|
|
|
682
829
|
viewport: m.viewport,
|
|
683
830
|
schedule: m.cronExpression,
|
|
684
831
|
threshold: `${String(m.threshold)}%`,
|
|
685
|
-
active: m.isActive ?
|
|
832
|
+
active: m.isActive ? chalk6.green("yes") : chalk6.dim("no"),
|
|
686
833
|
lastRun: m.lastRunAt ?? "never",
|
|
687
834
|
lastStatus: m.lastStatus ?? "-"
|
|
688
835
|
}));
|
|
@@ -788,9 +935,9 @@ function createMonitorCommand() {
|
|
|
788
935
|
|
|
789
936
|
// src/commands/init.ts
|
|
790
937
|
import { Command as Command6 } from "commander";
|
|
791
|
-
import * as
|
|
792
|
-
import * as
|
|
793
|
-
import
|
|
938
|
+
import * as fs5 from "fs";
|
|
939
|
+
import * as path5 from "path";
|
|
940
|
+
import chalk7 from "chalk";
|
|
794
941
|
var DEFAULT_CONFIG = {
|
|
795
942
|
apiUrl: "https://api.thepixelhouse.co.uk",
|
|
796
943
|
viewport: "desktop",
|
|
@@ -802,8 +949,8 @@ function createInitCommand() {
|
|
|
802
949
|
const cmd = new Command6("init").description("Create a .pixelhouserc.json configuration file").option("--force", "overwrite existing configuration file", false).action(async (opts) => {
|
|
803
950
|
const parentOpts = cmd.parent?.opts();
|
|
804
951
|
const jsonMode = parentOpts?.json ?? false;
|
|
805
|
-
const configPath =
|
|
806
|
-
if (
|
|
952
|
+
const configPath = path5.join(process.cwd(), CONFIG_FILE_NAME);
|
|
953
|
+
if (fs5.existsSync(configPath) && !opts.force) {
|
|
807
954
|
throw new CliError(
|
|
808
955
|
`${CONFIG_FILE_NAME} already exists. Use --force to overwrite.`,
|
|
809
956
|
EXIT_CODES.ERROR
|
|
@@ -814,15 +961,15 @@ function createInitCommand() {
|
|
|
814
961
|
if (apiKey) {
|
|
815
962
|
config.apiKey = apiKey;
|
|
816
963
|
}
|
|
817
|
-
|
|
964
|
+
fs5.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
818
965
|
const text = [
|
|
819
966
|
formatSuccess("Configuration file created"),
|
|
820
967
|
formatInfo("Path", configPath),
|
|
821
968
|
"",
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
969
|
+
chalk7.dim("Next steps:"),
|
|
970
|
+
chalk7.dim(` 1. Add your API key to ${CONFIG_FILE_NAME} or set PIXELHOUSE_API_KEY`),
|
|
971
|
+
chalk7.dim(' 2. Add pages to the "pages" array for regression testing'),
|
|
972
|
+
chalk7.dim(' 3. Run "pixelhouse screenshot <url>" to capture your first screenshot')
|
|
826
973
|
].join("\n");
|
|
827
974
|
output(text, { path: configPath, config }, jsonMode);
|
|
828
975
|
});
|
|
@@ -836,7 +983,7 @@ function createProgram() {
|
|
|
836
983
|
program2.name("pixelhouse").description("Visual regression testing CLI for The Pixel House").version(VERSION, "-v, --version").option("--api-key <key>", "API key (overrides PIXELHOUSE_API_KEY env var)").option("--api-url <url>", "API base URL (default: https://api.thepixelhouse.co.uk)").option("--json", "output results as JSON", false).option("--no-color", "disable coloured output").hook("preAction", (_thisCommand, actionCommand) => {
|
|
837
984
|
const rootOpts = actionCommand.optsWithGlobals();
|
|
838
985
|
if (rootOpts.color === false) {
|
|
839
|
-
|
|
986
|
+
chalk8.level = 0;
|
|
840
987
|
}
|
|
841
988
|
});
|
|
842
989
|
program2.addCommand(createScreenshotCommand());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thepixelhouse/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Visual regression testing CLI for The Pixel House. Capture screenshots, diff them, manage baselines, and run monitors from your terminal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "ToggleKit Ltd <hello@thepixelhouse.co.uk>",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
},
|
|
30
30
|
"type": "module",
|
|
31
31
|
"files": [
|
|
32
|
-
"dist"
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md"
|
|
33
34
|
],
|
|
34
35
|
"engines": {
|
|
35
36
|
"node": ">=18.0.0"
|
|
@@ -53,6 +54,7 @@
|
|
|
53
54
|
"@pixelhouse/shared": "workspace:*",
|
|
54
55
|
"@types/node": "^25.5.0",
|
|
55
56
|
"tsup": "^8.0.0",
|
|
56
|
-
"typescript": "^5.7.0"
|
|
57
|
+
"typescript": "^5.7.0",
|
|
58
|
+
"vitest": "^2.1.0"
|
|
57
59
|
}
|
|
58
60
|
}
|