Skip to content

Commit b8e9045

Browse files
authored
Merge pull request #39 from happyprime/task/tests-and-validation
Add config validation and a test suite
2 parents a49aa5c + 6d38731 commit b8e9045

22 files changed

Lines changed: 1521 additions & 189 deletions

.github/workflows/test.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Lint and test
2+
3+
on:
4+
push:
5+
branches:
6+
- trunk
7+
pull_request:
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
node-version:
15+
- 22
16+
- 24
17+
env:
18+
# The unit tests run against in-memory PNGs and config files, so the
19+
# Chromium download from the postinstall script is not needed here.
20+
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
21+
steps:
22+
- uses: actions/checkout@v4
23+
- uses: actions/setup-node@v4
24+
with:
25+
node-version: ${{ matrix.node-version }}
26+
cache: npm
27+
- run: npm ci
28+
- run: npm run lint
29+
- run: npm test

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ node_modules
33
# Reglance output directory.
44
.reglance
55

6+
# Multi-persona code review artifacts.
7+
.reviews
8+
69
# Legacy output directories (pre-2.0), kept ignored for leftover local data.
710
captures
811
compares

README.md

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,17 @@ self-ignored `.reglance/` directory — nothing to add to `.gitignore`.
5252
| `viewports` | no | `[{ name, width, height }]`. Defaults to `desktop` (1920×1080), `mobile` (390×844). |
5353
| `output` | no | Output directory. Defaults to `.reglance`. |
5454
| `pixelmatchOptions` | no | [pixelmatch](https://github.com/mapbox/pixelmatch) options, e.g. `{ "threshold": 0.1 }`. |
55+
| `timeouts` | no | `{ goto, settle }` in ms. Navigation and post-scroll network-idle waits. Defaults `{ goto: 15000, settle: 8000 }`. Raise `settle` for slow, lazy-loading pages. |
5556

5657
`domain` is only needed by `capture`; `control` and `compare` work on the files
5758
already captured. See [`reglance.example.json`](reglance.example.json) for a full
5859
example.
5960

61+
A `paths` value may be a full URL pointing at a different host than `domain`.
62+
This is supported, but `capture` prints a warning listing such paths so
63+
off-domain navigation is a conscious choice — keep configs from trusted
64+
sources, since the report renders captured content.
65+
6066
## Commands
6167

6268
| Command | Description |
@@ -70,14 +76,17 @@ Append path keys to limit a command to specific pages:
7076

7177
## Options
7278

73-
| Flag | Command | Description |
74-
| ------------------- | ------- | ------------------------------------------------------ |
75-
| `--domain=<host>` | capture | Override the configured domain for this run. |
76-
| `--concurrency=<n>` | capture | Parallel browser contexts (default: 4). |
77-
| `--stagger=<ms>` | capture | Delay between starting contexts (default: 500). |
78-
| `--skip-reload` | capture | Reuse the page between viewports instead of reloading. |
79-
| `--no-open` | compare | Don't open the report when finished. |
80-
| `--config=<path>` | any | Path to the config file (default: `reglance.json`). |
79+
| Flag | Command | Description |
80+
| -------------------------- | ------- | --------------------------------------------------------------------------------- |
81+
| `--domain=<host>` | capture | Override the configured domain for this run. |
82+
| `--concurrency=<n>` | capture | Parallel browser contexts (default: 4). Must be a positive integer. |
83+
| `--stagger=<ms>` | capture | Delay between starting contexts (default: 500). `0` disables staggering. |
84+
| `--skip-reload` | capture | Reuse the page between viewports instead of reloading. |
85+
| `--fail-on-degraded` | capture | Exit non-zero if any page failed to load cleanly (for CI). Default: warn, exit 0. |
86+
| `--insecure` | capture | Ignore TLS certificate errors for non-local hosts (already ignored for `.test`/localhost). |
87+
| `--compare-concurrency=<n>`| compare | Parallel diff workers (default: CPU count − 1). Lower it for very tall pages. |
88+
| `--no-open` | compare | Don't open the report when finished. |
89+
| `--config=<path>` | any | Path to the config file (default: `reglance.json`). |
8190

8291
### Per-developer domains
8392

@@ -88,12 +97,25 @@ their own local site without editing the config:
8897
npx reglance capture --domain=site2.test
8998
```
9099

100+
### Trustworthy baselines
101+
102+
A baseline is only useful if it reflects pages that actually loaded. reglance
103+
guards against silently baselining bad data:
104+
105+
- If a page never loads cleanly (after retries), `capture` reports it as
106+
degraded instead of treating it as a success. Add `--fail-on-degraded` to
107+
make the run exit non-zero in CI.
108+
- `control` records each promotion in `.reglance/controls/manifest.json` and
109+
warns when it promoted fewer captures than expected (so the untouched
110+
controls are now stale). `compare` warns when the baseline mixes controls
111+
from more than one `control` run.
112+
91113
## Output
92114

93115
```
94116
.reglance/
95117
captures/ Latest screenshots + HTML snapshots
96-
controls/ Baseline screenshots + HTML
118+
controls/ Baseline screenshots + HTML (+ manifest.json)
97119
compares/ Diff images and HTML diffs
98120
reports/ The report — open reports/index.html
99121
```

bin/reglance.mjs

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#!/usr/bin/env node
22
import { parseArgs } from 'node:util';
3+
import { toInt } from '../src/args.mjs';
34
import { loadConfig, ensureOutputDir } from '../src/config.mjs';
45
import { capture } from '../src/capture.mjs';
56
import { control } from '../src/control.mjs';
@@ -26,6 +27,11 @@ Options:
2627
--concurrency=<n> Parallel browser contexts for capture (default: 4).
2728
--stagger=<ms> Delay between starting capture contexts (default: 500).
2829
--skip-reload Reuse the page between viewports during capture.
30+
--insecure Ignore TLS certificate errors for non-local hosts
31+
(already ignored for .test/localhost).
32+
--fail-on-degraded Exit non-zero if any capture failed to load cleanly.
33+
--compare-concurrency=<n> Parallel diff workers for compare
34+
(default: CPU count - 1; lower it for very tall pages).
2935
--no-open Don't open the report automatically after compare.
3036
-h, --help Show this help.
3137
@@ -43,6 +49,9 @@ const { values, positionals } = parseArgs({
4349
concurrency: { type: 'string' },
4450
stagger: { type: 'string' },
4551
'skip-reload': { type: 'boolean' },
52+
insecure: { type: 'boolean' },
53+
'fail-on-degraded': { type: 'boolean' },
54+
'compare-concurrency': { type: 'string' },
4655
'no-open': { type: 'boolean' },
4756
help: { type: 'boolean', short: 'h' },
4857
},
@@ -56,23 +65,7 @@ if (values.help || !command) {
5665
}
5766

5867
/**
59-
* Parse an integer flag, exiting with an error when it is not a number.
60-
*
61-
* @param {string} value - The raw flag value.
62-
* @param {string} name - The flag name, for error messages.
63-
* @returns {number} The parsed integer.
64-
*/
65-
function toInt(value, name) {
66-
const parsed = parseInt(value, 10);
67-
if (Number.isNaN(parsed)) {
68-
console.error(`Invalid value for --${name}: ${value}`);
69-
process.exit(1);
70-
}
71-
return parsed;
72-
}
73-
74-
/**
75-
*
68+
* Dispatch the parsed command.
7669
*/
7770
async function main() {
7871
const config = loadConfig({
@@ -96,9 +89,11 @@ async function main() {
9689
? toInt(values.concurrency, 'concurrency')
9790
: undefined,
9891
staggerDelay: values.stagger
99-
? toInt(values.stagger, 'stagger')
92+
? toInt(values.stagger, 'stagger', { min: 0 })
10093
: undefined,
10194
skipReload: values['skip-reload'],
95+
failOnDegraded: values['fail-on-degraded'],
96+
insecure: values.insecure,
10297
});
10398
break;
10499

@@ -107,7 +102,16 @@ async function main() {
107102
break;
108103

109104
case 'compare':
110-
await compare(config, { only, open: !values['no-open'] });
105+
await compare(config, {
106+
only,
107+
open: !values['no-open'],
108+
concurrency: values['compare-concurrency']
109+
? toInt(
110+
values['compare-concurrency'],
111+
'compare-concurrency'
112+
)
113+
: undefined,
114+
});
111115
break;
112116

113117
default:

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"files": [
2323
"bin",
2424
"src",
25+
"scripts",
2526
"templates",
2627
"reglance.example.json"
2728
],
@@ -31,13 +32,14 @@
3132
"scripts": {
3233
"lint": "eslint .",
3334
"fix": "eslint . --fix",
34-
"postinstall": "playwright install chromium"
35+
"test": "node --test",
36+
"postinstall": "node scripts/postinstall.mjs"
3537
},
3638
"dependencies": {
3739
"diff": "^8.0.3",
3840
"open": "^11.0.0",
3941
"pixelmatch": "^7.2.0",
40-
"playwright": "^1.60.0",
42+
"playwright": "1.60.0",
4143
"pngjs": "^7.0.0"
4244
},
4345
"devDependencies": {

reglance.example.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,9 @@
1515
"includeAA": false,
1616
"alpha": 0.1,
1717
"diffColor": [255, 0, 0]
18+
},
19+
"timeouts": {
20+
"goto": 15000,
21+
"settle": 8000
1822
}
1923
}

scripts/postinstall.mjs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/usr/bin/env node
2+
import { spawnSync } from 'node:child_process';
3+
4+
// reglance is installed as a dev dependency, so this hook runs inside every
5+
// consuming project's install. It must never fail that install: a blocked CDN,
6+
// an air-gapped runner, or a proxy should degrade gracefully, not abort
7+
// `npm ci` for the whole project.
8+
9+
// Honor the standard Playwright skip flag (CI that doesn't capture, or that
10+
// provisions Chromium separately). The explicit `playwright install` command
11+
// ignores this variable, so we check it ourselves.
12+
if (process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD) {
13+
process.exit(0);
14+
}
15+
16+
const result = spawnSync('playwright', ['install', 'chromium'], {
17+
stdio: 'inherit',
18+
// shell:true so the `playwright` bin resolves on Windows (.cmd) too.
19+
shell: true,
20+
});
21+
22+
if (result.status !== 0) {
23+
console.error(
24+
'\n⚠️ reglance: Chromium download failed. reglance is installed, but ' +
25+
'`reglance capture` needs a browser — run `npx playwright install ' +
26+
'chromium` before capturing.'
27+
);
28+
}
29+
30+
// Always succeed: a missing browser is recoverable and is reported again at
31+
// capture time; it must not break the consumer's install.
32+
process.exit(0);

src/args.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Parse an integer CLI flag, throwing an actionable error when it is invalid.
3+
*
4+
* Uses `Number` rather than `parseInt` so trailing garbage ("8x") is rejected
5+
* instead of silently truncated, and enforces a minimum so a value like
6+
* `--concurrency=0` cannot reach the capture loop and hang it.
7+
*
8+
* @param {string} value - The raw flag value.
9+
* @param {string} name - The flag name, for error messages.
10+
* @param {object} [options] - Parse options.
11+
* @param {number} [options.min] - The smallest allowed value (default 1).
12+
* @returns {number} The parsed integer.
13+
*/
14+
export function toInt(value, name, { min = 1 } = {}) {
15+
const parsed = Number(value);
16+
17+
if (!Number.isInteger(parsed) || parsed < min) {
18+
throw new Error(
19+
`Invalid value for --${name}: ${value} (expected an integer >= ${min}).`
20+
);
21+
}
22+
23+
return parsed;
24+
}

0 commit comments

Comments
 (0)