@kaze-no-ryuu/argus 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +189 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +114 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +462 -0
- package/dist/init.d.ts +25 -0
- package/dist/init.js +158 -0
- package/dist/report.d.ts +3 -0
- package/dist/report.js +92 -0
- package/dist/types.d.ts +120 -0
- package/dist/types.js +1 -0
- package/package.json +68 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaze
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,190 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Argus
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Does what you built match the real thing?** Argus compares your local site, page by page and at desktop, tablet and mobile sizes, against a **live reference site** and/or **design images**. It tells you how much changed and *which element* changed.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @kaze-no-ryuu/argus
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
That's the whole setup. The first run downloads Chromium if it isn't installed yet, detects your framework, writes a config and adds an `argus` npm script. Every run after that starts your dev server, screenshots everything, diffs it and writes one HTML report.
|
|
10
|
+
|
|
11
|
+
## Why Argus
|
|
12
|
+
|
|
13
|
+
- **Zero-config start.** Detects Next.js, Astro, Nuxt, SvelteKit, Vite and others, the dev command and port, and every route. No test files to write.
|
|
14
|
+
- **Compares against a live site *and* designs in one run.** Rebuilding or migrating a site? Point it at production. Building from mockups? Drop PNGs in `designs/`. Mix both per page and per viewport.
|
|
15
|
+
- **Says where, not just how much.** Each changed area is labeled with the elements it falls in (`section#pricing`, `h2 "Plans"`), so you can jump straight to the code.
|
|
16
|
+
- **Catches layout shifts.** A page that got taller or shorter (missing or extra content) is flagged on its own, with the height difference. A pixel diff alone hides this.
|
|
17
|
+
- **Built for AI agents and CI.** `--json` output with absolute file paths, `failed` flags and element labels, plus clear exit codes (`0` / `1` / `2`). An agent can run → read → fix → re-run with no human in the loop.
|
|
18
|
+
- **Runs your dev server for you.** Starts it, waits until it responds, and stops it afterwards. If it's already running, it's reused.
|
|
19
|
+
- **One self-contained report.** A pass/fail grid, % per page × viewport, the list of changed areas, and a diff / reference / local / side-by-side viewer. Works in light and dark mode.
|
|
20
|
+
- **Fast and safe to run in parallel.** Parallel tabs sized to your CPU and memory, CI sharding, remote browsers, and a lock so parallel runs can't overwrite each other.
|
|
21
|
+
|
|
22
|
+
### Compared to alternatives
|
|
23
|
+
|
|
24
|
+
| | Argus | BackstopJS | Playwright `toHaveScreenshot` | Percy / Chromatic / Applitools |
|
|
25
|
+
|---|:-:|:-:|:-:|:-:|
|
|
26
|
+
| Live site vs local (no stored screenshots) | ✓ | ✓ | — | — |
|
|
27
|
+
| Design images as reference | ✓ | manual | — | — |
|
|
28
|
+
| Detects framework, routes, dev server | ✓ | — | — | — |
|
|
29
|
+
| Names the changed elements | ✓ | — | — | — |
|
|
30
|
+
| JSON made for agents | ✓ | — | — | API |
|
|
31
|
+
| Review dashboard / approval flow | — | — | — | ✓ |
|
|
32
|
+
| Self-hosted, free | ✓ | ✓ | ✓ | — |
|
|
33
|
+
|
|
34
|
+
Use the hosted services when you need a team review flow for screenshots that change over time. Use Argus when the question is "does my build match *that*?"
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm i -D @kaze-no-ryuu/argus # pin the version in your project (optional, recommended for CI)
|
|
40
|
+
npx argus init # detects your framework, writes argus.config.json, adds an npm script
|
|
41
|
+
npm run argus # first run downloads Chromium (~100 MB, once)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
On a fresh Linux machine or CI image, Chromium also needs system libraries. Install them once with `npx playwright install-deps chromium` (needs root).
|
|
45
|
+
|
|
46
|
+
Running `npx argus` without a config does the `init` step for you.
|
|
47
|
+
|
|
48
|
+
> The npm package is `@kaze-no-ryuu/argus` and the command is `argus`. Without a local install, run `npx @kaze-no-ryuu/argus` instead. `npx argus` would fetch an unrelated package.
|
|
49
|
+
|
|
50
|
+
`init` detects:
|
|
51
|
+
|
|
52
|
+
| | |
|
|
53
|
+
|---|---|
|
|
54
|
+
| Framework | Next.js, Astro, Nuxt, SvelteKit, Gatsby, Remix, React Router, Angular, CRA, Vue CLI, Eleventy, Vite, plain HTML |
|
|
55
|
+
| Start command | the `dev` / `start` / `serve` / `preview` script, using your package manager (npm, pnpm, yarn, bun) |
|
|
56
|
+
| Local URL | the framework's default port, or a `--port` / `-p` in the script |
|
|
57
|
+
| Pages | routes from `pages/`, `app/`, `src/pages/`, `src/routes/`, or `*.html` files (dynamic `[slug]` routes are skipped) |
|
|
58
|
+
| Remote URL | `homepage` in package.json, `site:` in the framework config, or `CNAME`. Otherwise it asks, or takes `--remote URL` |
|
|
59
|
+
|
|
60
|
+
## Config: `argus.config.json`
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"remote": "https://www.example.com",
|
|
65
|
+
"local": { "url": "http://localhost:4321", "command": "npm run dev" },
|
|
66
|
+
"pages": [
|
|
67
|
+
"/",
|
|
68
|
+
"/about",
|
|
69
|
+
{ "path": "/pricing", "local": "/pricing.html" },
|
|
70
|
+
{ "path": "/contact", "ref": { "desktop": "mockups/contact.png" } }
|
|
71
|
+
],
|
|
72
|
+
"refs": "designs/{page}-{viewport}.png",
|
|
73
|
+
"viewports": {
|
|
74
|
+
"desktop": { "width": 1440, "height": 900 },
|
|
75
|
+
"tablet": { "width": 768, "height": 1024, "isMobile": true },
|
|
76
|
+
"mobile": { "width": 390, "height": 844, "isMobile": true }
|
|
77
|
+
},
|
|
78
|
+
"threshold": 0.1,
|
|
79
|
+
"imageThreshold": 1,
|
|
80
|
+
"out": "argus",
|
|
81
|
+
"diffDir": "argus/diffs",
|
|
82
|
+
"report": "argus/report.html",
|
|
83
|
+
"css": ".reveal{opacity:1!important;transform:none!important}"
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| key | meaning |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `remote` | Reference site. Each page path is appended to it. Optional if every page has a reference image. |
|
|
90
|
+
| `refs` | Path pattern for reference images (PNG). Wherever a matching file exists, it is used instead of the remote screenshot. `{page}` is the page name (`index`, `about`, `blog-post`), `{viewport}` the viewport key. |
|
|
91
|
+
| `pages[].ref` | Reference image for one page: `"x.png"` for all viewports, or `{ "mobile": "x.png" }`. Takes priority over `refs`. |
|
|
92
|
+
| `local.url` / `local.command` | If `url` already responds, that server is used. Otherwise `command` is started, awaited, and stopped afterwards (its output goes to `<out>/server.log`). |
|
|
93
|
+
| `pages` | Paths, or `{ path, remote, local, name }` when the two sites use different URLs (relative paths or full URLs). |
|
|
94
|
+
| `viewports` | Any number of them. Optional: `deviceScaleFactor` (default 1), `isMobile`, `userAgent`. |
|
|
95
|
+
| `threshold` | Max % of changed pixels per page and viewport before it fails. |
|
|
96
|
+
| `imageThreshold` | Same, for checks against reference images (defaults to `threshold`). Design tools draw text slightly differently from browsers, so a looser value is common. |
|
|
97
|
+
| `out` / `diffDir` / `report` | Where screenshots, diff images and the HTML report go. |
|
|
98
|
+
| `css` | Injected into both sites before the screenshot, e.g. to finish scroll animations or hide cookie banners. |
|
|
99
|
+
| `settle`, `serverTimeout`, `concurrency` | Wait (ms) before each screenshot (default 300), time to wait for the dev server to start (default 120000), number of parallel tabs. |
|
|
100
|
+
|
|
101
|
+
Before each screenshot, the page is scrolled from top to bottom so scroll-triggered animations play.
|
|
102
|
+
|
|
103
|
+
## Comparing against design images
|
|
104
|
+
|
|
105
|
+
Export each frame as PNG at 1×, name it `<page>-<viewport>.png` and put it in `designs/` (e.g. `designs/index-desktop.png`, `designs/about-mobile.png`). Pages or viewports without an image still compare against `remote`.
|
|
106
|
+
|
|
107
|
+
- The image width must equal the viewport width × `deviceScaleFactor`. A 375px mobile frame needs `"mobile": { "width": 375, … }`. If the width is wrong, that check shows an error explaining it.
|
|
108
|
+
- A height difference is only a **warning** for images, because design frames rarely match the real page height. The pixel diff and changed areas are still reported.
|
|
109
|
+
|
|
110
|
+
## Output
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
argus/
|
|
114
|
+
report.html one-page report: pass/fail matrix, % per page × viewport, changed areas, image viewer
|
|
115
|
+
desktop/about-remote.png screenshots
|
|
116
|
+
desktop/about-local.png
|
|
117
|
+
diffs/desktop/about.png changed pixels in red
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A check **fails** when it changed more than the threshold, when the page size differs from the remote screenshot (missing or extra content), or when it errors (HTTP ≥ 400, timeout, missing or wrong-width image). Each result has `"failed": true/false` and `"refType": "remote" | "image"`.
|
|
121
|
+
|
|
122
|
+
## CLI
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
argus init [--remote URL] [--force] [-y]
|
|
126
|
+
argus [-c config] [-p /about,/faq] [--viewport mobile] [--json] [--open]
|
|
127
|
+
[--no-server] [--no-shoot] [-j N] [--shard k/n] [--browser-ws URL]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Exit codes: `0` everything within the threshold, `1` diffs found, `2` error.
|
|
131
|
+
|
|
132
|
+
## For AI agents
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npx argus --json
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"ok": false, "threshold": 0.1, "report": "/abs/argus/report.html",
|
|
141
|
+
"counts": { "total": 6, "passed": 4, "failed": 2, "errors": 0 },
|
|
142
|
+
"failed": ["about@mobile", "about@desktop"],
|
|
143
|
+
"results": [{
|
|
144
|
+
"page": "about", "viewport": "mobile", "status": "diff",
|
|
145
|
+
"diffPercentage": 3.42, "diffCount": 11873, "sizeMismatch": true,
|
|
146
|
+
"size": { "remote": { "width": 390, "height": 2400 }, "local": { "width": 390, "height": 2520 } },
|
|
147
|
+
"bands": [{ "y1": 812, "y2": 1104, "height": 293, "where": ["section#pricing", "h2 \"Plans\""] }],
|
|
148
|
+
"refType": "remote", "failed": true,
|
|
149
|
+
"remote": "…/mobile/about-remote.png", "local": "…/mobile/about-local.png", "diff": "…/diffs/mobile/about.png"
|
|
150
|
+
}]
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
A fix loop that works well:
|
|
155
|
+
|
|
156
|
+
1. Go through `failed` and start with `sizeMismatch` entries. A different height means content is missing or extra, which shifts everything below it. Fix those first.
|
|
157
|
+
2. Use `bands[].where` to find the element in your code, and open the `diff` PNG next to the `remote` and `local` PNGs to see what changed.
|
|
158
|
+
3. Re-run only what you touched: `npx argus --json -p /about --viewport mobile`.
|
|
159
|
+
|
|
160
|
+
## Scaling
|
|
161
|
+
|
|
162
|
+
- `-j N` sets how many tabs run at once. By default it's picked from your CPU and memory (container limits included).
|
|
163
|
+
- `--shard k/n` splits the checks across CI jobs. Give each shard its own `out`.
|
|
164
|
+
- `--browser-ws ws://…` uses a remote browser (`npx playwright run-server`, Browserless).
|
|
165
|
+
- Runs that share an `out` folder wait for each other through a lock file instead of overwriting each other's files.
|
|
166
|
+
|
|
167
|
+
## API
|
|
168
|
+
|
|
169
|
+
```js
|
|
170
|
+
import { loadConfig, run } from '@kaze-no-ryuu/argus';
|
|
171
|
+
const result = await run(loadConfig('argus.config.json'), { viewports: ['mobile'] });
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`detect(cwd)` and `init(opts)` are exported from `@kaze-no-ryuu/argus/init`. TypeScript types are included (`Config`, `RawConfig`, `Result`, `Summary`, …).
|
|
175
|
+
|
|
176
|
+
## Development
|
|
177
|
+
|
|
178
|
+
Requires Node 22.18 or newer. The tests run the TypeScript source directly.
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
npm install && npx playwright install chromium
|
|
182
|
+
npm run typecheck # tsc, source and tests
|
|
183
|
+
npm test # unit, init/detection, CLI and end-to-end (real browser) tests
|
|
184
|
+
npm run test:coverage
|
|
185
|
+
npm run build # src/ → dist/
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { run, loadConfig, CONFIG_FILE } from './index.js';
|
|
6
|
+
import { init } from './init.js';
|
|
7
|
+
import { fmtPct } from './report.js';
|
|
8
|
+
const { values: a, positionals } = parseArgs({
|
|
9
|
+
allowPositionals: true,
|
|
10
|
+
options: {
|
|
11
|
+
config: { type: 'string', short: 'c', default: CONFIG_FILE },
|
|
12
|
+
remote: { type: 'string' },
|
|
13
|
+
force: { type: 'boolean' },
|
|
14
|
+
yes: { type: 'boolean', short: 'y' },
|
|
15
|
+
only: { type: 'string', short: 'p' },
|
|
16
|
+
viewport: { type: 'string' },
|
|
17
|
+
concurrency: { type: 'string', short: 'j' },
|
|
18
|
+
shard: { type: 'string' },
|
|
19
|
+
'browser-ws': { type: 'string' },
|
|
20
|
+
'no-shoot': { type: 'boolean' },
|
|
21
|
+
'no-server': { type: 'boolean' },
|
|
22
|
+
open: { type: 'boolean' },
|
|
23
|
+
json: { type: 'boolean' },
|
|
24
|
+
help: { type: 'boolean', short: 'h' },
|
|
25
|
+
version: { type: 'boolean', short: 'v' },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
if (a.version) {
|
|
29
|
+
console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
if (a.help) {
|
|
33
|
+
console.log(`argus: compare your local site with a live site or design images, page by page, per viewport
|
|
34
|
+
|
|
35
|
+
argus init [--remote URL] [--force] [-y] detect framework, write ${CONFIG_FILE},
|
|
36
|
+
add "argus" npm script
|
|
37
|
+
argus [options] start dev server, screenshot local and the
|
|
38
|
+
reference (remote site or design PNGs),
|
|
39
|
+
diff, write HTML report
|
|
40
|
+
|
|
41
|
+
-c, --config FILE config file (default ${CONFIG_FILE}; created by init if missing)
|
|
42
|
+
-p, --only a,b only these pages (name or path)
|
|
43
|
+
--viewport a,b only these viewports (e.g. mobile)
|
|
44
|
+
-j, --concurrency N parallel tabs (default: auto from CPU/memory)
|
|
45
|
+
--shard k/n run every n-th check starting at k (CI matrix)
|
|
46
|
+
--browser-ws URL use a remote browser (Playwright server / Browserless)
|
|
47
|
+
--no-server don't start local.command; expect local.url to be up
|
|
48
|
+
--no-shoot re-diff existing screenshots only
|
|
49
|
+
--open open the HTML report when done
|
|
50
|
+
--json machine-readable result on stdout
|
|
51
|
+
-y, --yes init: never prompt
|
|
52
|
+
-v, --version print version
|
|
53
|
+
|
|
54
|
+
Exit codes: 0 all checks within threshold, 1 diffs found, 2 error.`);
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
const log = a.json ? () => { } : (m) => console.error(m);
|
|
58
|
+
const list = (s) => s?.split(',').map((x) => x.trim()).filter(Boolean);
|
|
59
|
+
const errMsg = (e) => (e instanceof Error ? e.message : String(e));
|
|
60
|
+
try {
|
|
61
|
+
if (positionals[0] === 'init') {
|
|
62
|
+
const res = await init({ remote: a.remote, force: a.force, yes: a.yes || a.json, log });
|
|
63
|
+
if (a.json)
|
|
64
|
+
console.log(JSON.stringify(res, null, 2));
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
if (positionals.length)
|
|
68
|
+
throw new Error(`unknown command "${positionals[0]}" (see --help)`);
|
|
69
|
+
if (!existsSync(a.config)) {
|
|
70
|
+
log(`no ${a.config} found, running init`);
|
|
71
|
+
const res = await init({ remote: a.remote, yes: a.yes || a.json, log });
|
|
72
|
+
if (res.needsRemote)
|
|
73
|
+
throw new Error(`set "remote" in ${CONFIG_FILE} (or put reference PNGs in designs/ and remove "remote"), then run argus again`);
|
|
74
|
+
}
|
|
75
|
+
const cfg = loadConfig(a.config);
|
|
76
|
+
if (a.remote)
|
|
77
|
+
cfg.remote = a.remote;
|
|
78
|
+
const res = await run(cfg, {
|
|
79
|
+
log,
|
|
80
|
+
shoot: !a['no-shoot'],
|
|
81
|
+
server: !a['no-server'],
|
|
82
|
+
only: list(a.only),
|
|
83
|
+
viewports: list(a.viewport),
|
|
84
|
+
concurrency: a.concurrency ? Number(a.concurrency) : undefined,
|
|
85
|
+
shard: a.shard,
|
|
86
|
+
browserWs: a['browser-ws'],
|
|
87
|
+
});
|
|
88
|
+
if (a.json) {
|
|
89
|
+
console.log(JSON.stringify(res, null, 2));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
console.log(`\n${'Page'.padEnd(20)}${'Viewport'.padEnd(10)}${'Diff'.padEnd(10)}Where`);
|
|
93
|
+
console.log('-'.repeat(70));
|
|
94
|
+
for (const r of res.results) {
|
|
95
|
+
const d = r.status === 'error' ? 'ERROR' : fmtPct(r.diffPercentage ?? 0);
|
|
96
|
+
const where = r.status === 'error' ? r.error
|
|
97
|
+
: [r.sizeMismatch && `height ${r.size?.remote.height}→${r.size?.local.height}`, r.bands?.[0]?.where[0]].filter(Boolean).join(', ');
|
|
98
|
+
console.log(`${r.page.padEnd(20)}${r.viewport.padEnd(10)}${d.padEnd(10)}${where}`);
|
|
99
|
+
}
|
|
100
|
+
console.log(`\n${res.ok ? 'OK' : `FAILED: ${res.failed.join(', ')}`}\nReport: ${res.report}`);
|
|
101
|
+
}
|
|
102
|
+
if (a.open) {
|
|
103
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
104
|
+
spawn(cmd, [res.report], { stdio: 'ignore', detached: true }).unref();
|
|
105
|
+
}
|
|
106
|
+
process.exit(res.ok ? 0 : 1);
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
if (a.json)
|
|
110
|
+
console.log(JSON.stringify({ ok: false, error: errMsg(e) }));
|
|
111
|
+
else
|
|
112
|
+
console.error(`error: ${errMsg(e)}`);
|
|
113
|
+
process.exit(2);
|
|
114
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Browser } from 'playwright';
|
|
2
|
+
import type { Band, Box, Config, Log, Page, RawConfig, Result, RunOptions, Summary, Viewport } from './types.ts';
|
|
3
|
+
export type * from './types.ts';
|
|
4
|
+
export declare const CONFIG_FILE = "argus.config.json";
|
|
5
|
+
export declare const DEFAULTS: {
|
|
6
|
+
out: string;
|
|
7
|
+
threshold: number;
|
|
8
|
+
viewports: Record<string, Viewport>;
|
|
9
|
+
css: string;
|
|
10
|
+
settle: number;
|
|
11
|
+
serverTimeout: number;
|
|
12
|
+
};
|
|
13
|
+
export declare const isMissingBrowser: (e: unknown) => boolean;
|
|
14
|
+
export declare function launchBrowser(log: Log): Promise<Browser>;
|
|
15
|
+
export declare function loadConfig(file?: string): Config;
|
|
16
|
+
export declare function normalizeConfig(raw: RawConfig, root?: string): Config;
|
|
17
|
+
export declare function pageName(p: string): string;
|
|
18
|
+
export declare function resolvePages(cfg: Config): Page[];
|
|
19
|
+
export declare function autoConcurrency(): number;
|
|
20
|
+
export interface ServerOptions {
|
|
21
|
+
url: string;
|
|
22
|
+
command?: string | null;
|
|
23
|
+
cwd: string;
|
|
24
|
+
timeout: number;
|
|
25
|
+
logFile: string;
|
|
26
|
+
log: Log;
|
|
27
|
+
}
|
|
28
|
+
export declare function startServer({ url, command, cwd, timeout, logFile, log }: ServerOptions): Promise<() => void>;
|
|
29
|
+
export declare function toBands(lines: number[], boxes: Box[], gap?: number, max?: number): Band[];
|
|
30
|
+
export declare function refImage(page: Pick<Page, 'name' | 'ref'>, vp: string, cfg: Pick<Config, 'root' | 'refs'>): string | null;
|
|
31
|
+
export declare function isFailed(r: Result, cfg: Pick<Config, 'threshold' | 'imageThreshold'>): boolean;
|
|
32
|
+
export declare function run(cfg: Config, opts?: RunOptions): Promise<Summary>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import { compare } from 'odiff-bin';
|
|
3
|
+
import { spawn, execSync, execFileSync } from 'node:child_process';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { writeReport } from './report.js';
|
|
10
|
+
export const CONFIG_FILE = 'argus.config.json';
|
|
11
|
+
export const DEFAULTS = {
|
|
12
|
+
out: 'argus',
|
|
13
|
+
threshold: 0.1,
|
|
14
|
+
viewports: {
|
|
15
|
+
desktop: { width: 1440, height: 900 },
|
|
16
|
+
tablet: { width: 768, height: 1024, isMobile: true },
|
|
17
|
+
mobile: { width: 390, height: 844, isMobile: true },
|
|
18
|
+
},
|
|
19
|
+
css: '.reveal{opacity:1!important;transform:none!important;transition:none!important}',
|
|
20
|
+
settle: 300,
|
|
21
|
+
serverTimeout: 120000,
|
|
22
|
+
};
|
|
23
|
+
const errMsg = (e) => (e instanceof Error ? e.message : String(e));
|
|
24
|
+
export const isMissingBrowser = (e) => /Executable doesn't exist/.test(errMsg(e));
|
|
25
|
+
// Launches Chromium, downloading it first if it isn't installed yet, so
|
|
26
|
+
// `npx argus` works without a separate `playwright install` step.
|
|
27
|
+
export async function launchBrowser(log) {
|
|
28
|
+
try {
|
|
29
|
+
return await chromium.launch();
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
if (!isMissingBrowser(e))
|
|
33
|
+
throw e;
|
|
34
|
+
log('Chromium is not installed yet, downloading it (one time, ~100 MB)...');
|
|
35
|
+
const cli = path.join(path.dirname(createRequire(import.meta.url).resolve('playwright')), 'cli.js');
|
|
36
|
+
// Output goes to stderr so --json stdout stays clean.
|
|
37
|
+
execFileSync(process.execPath, [cli, 'install', 'chromium'], { stdio: ['ignore', 2, 2] });
|
|
38
|
+
return chromium.launch();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Fills defaults and resolves file paths relative to the config file.
|
|
42
|
+
export function loadConfig(file = CONFIG_FILE) {
|
|
43
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
44
|
+
return normalizeConfig(raw, path.dirname(path.resolve(file)));
|
|
45
|
+
}
|
|
46
|
+
export function normalizeConfig(raw, root = process.cwd()) {
|
|
47
|
+
const merged = { ...DEFAULTS, ...raw, root };
|
|
48
|
+
if (!merged.remote && !merged.refs && !merged.pages?.some((p) => typeof p !== 'string' && p.ref)) {
|
|
49
|
+
throw new Error('config: set "remote" (reference site URL), "refs" (reference image pattern) or per-page "ref"');
|
|
50
|
+
}
|
|
51
|
+
if (!merged.local?.url)
|
|
52
|
+
throw new Error('config: "local.url" is required');
|
|
53
|
+
if (!merged.pages?.length)
|
|
54
|
+
throw new Error('config: "pages" is empty');
|
|
55
|
+
const out = path.resolve(root, merged.out);
|
|
56
|
+
return {
|
|
57
|
+
...merged,
|
|
58
|
+
out,
|
|
59
|
+
diffDir: path.resolve(root, raw.diffDir ?? path.join(out, 'diffs')),
|
|
60
|
+
report: path.resolve(root, raw.report ?? path.join(out, 'report.html')),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function pageName(p) {
|
|
64
|
+
const slug = p.replace(/\.html?$/, '').replace(/^\/+|\/+$/g, '').replace(/[^\w.-]+/g, '-');
|
|
65
|
+
return slug || 'index';
|
|
66
|
+
}
|
|
67
|
+
const join = (base, p) => base.replace(/\/+$/, '') + (p.startsWith('/') ? p : `/${p}`);
|
|
68
|
+
// Pages are paths ("/about") or { path, remote, local, name, ref } when the
|
|
69
|
+
// two sites use different URLs, or the reference is an image instead of a site.
|
|
70
|
+
export function resolvePages(cfg) {
|
|
71
|
+
return cfg.pages.map((spec) => {
|
|
72
|
+
const p = typeof spec === 'string' ? { path: spec } : spec;
|
|
73
|
+
const any = p.path ?? p.local ?? p.remote;
|
|
74
|
+
if (!any)
|
|
75
|
+
throw new Error(`config: page needs "path", "local" or "remote": ${JSON.stringify(spec)}`);
|
|
76
|
+
const remotePath = p.remote ?? p.path;
|
|
77
|
+
return {
|
|
78
|
+
name: p.name ?? pageName(any),
|
|
79
|
+
path: p.path ?? null,
|
|
80
|
+
ref: p.ref ?? null,
|
|
81
|
+
remoteUrl: p.remote?.includes('://') ? p.remote : cfg.remote && remotePath ? join(cfg.remote, remotePath) : null,
|
|
82
|
+
localUrl: p.local?.includes('://') ? p.local : join(cfg.local.url, p.local ?? p.path ?? '/'),
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const readNum = (file, fn) => {
|
|
87
|
+
try {
|
|
88
|
+
return fn(fs.readFileSync(file, 'utf8').trim());
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return NaN;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
// Container-aware limits: cgroup v2 quotas when present, host values otherwise.
|
|
95
|
+
export function autoConcurrency() {
|
|
96
|
+
const cpuQuota = readNum('/sys/fs/cgroup/cpu.max', (s) => {
|
|
97
|
+
const [q, p] = s.split(' ');
|
|
98
|
+
return q === 'max' ? NaN : Number(q) / Number(p);
|
|
99
|
+
});
|
|
100
|
+
const cpus = Number.isFinite(cpuQuota) ? Math.max(1, Math.floor(cpuQuota)) : os.availableParallelism();
|
|
101
|
+
const memMax = readNum('/sys/fs/cgroup/memory.max', Number);
|
|
102
|
+
const mem = Number.isFinite(memMax) ? memMax : os.totalmem();
|
|
103
|
+
// ponytail: ~300MB browser base + ~400MB per full-page tab; measure if tabs OOM.
|
|
104
|
+
const byMem = Math.floor((mem / 2 ** 20 - 300) / 400);
|
|
105
|
+
return Math.max(1, Math.min(cpus, byMem));
|
|
106
|
+
}
|
|
107
|
+
// Cross-instance lock on the output dir (mkdir is atomic, also on NFS/shared
|
|
108
|
+
// volumes). Other instances queue until it's released or goes stale.
|
|
109
|
+
async function acquireLock(dir, { staleMs = 120_000, log }) {
|
|
110
|
+
const lock = path.join(dir, '.argus.lock');
|
|
111
|
+
for (let waited = false;;) {
|
|
112
|
+
try {
|
|
113
|
+
fs.mkdirSync(lock);
|
|
114
|
+
fs.writeFileSync(path.join(lock, 'owner'), `${os.hostname()} pid ${process.pid} ${new Date().toISOString()}`);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
if (e.code !== 'EEXIST')
|
|
119
|
+
throw e;
|
|
120
|
+
let age = 0;
|
|
121
|
+
try {
|
|
122
|
+
age = Date.now() - fs.statSync(lock).mtimeMs;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
continue;
|
|
126
|
+
} // released meanwhile
|
|
127
|
+
if (age > staleMs) {
|
|
128
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!waited) {
|
|
132
|
+
log(`queued: ${dir} is locked by another instance`);
|
|
133
|
+
waited = true;
|
|
134
|
+
}
|
|
135
|
+
await sleep(1000);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const beat = setInterval(() => { try {
|
|
139
|
+
const t = new Date();
|
|
140
|
+
fs.utimesSync(lock, t, t);
|
|
141
|
+
}
|
|
142
|
+
catch { } }, staleMs / 4);
|
|
143
|
+
beat.unref();
|
|
144
|
+
const release = () => { clearInterval(beat); fs.rmSync(lock, { recursive: true, force: true }); };
|
|
145
|
+
const onSignal = () => { release(); process.exit(130); };
|
|
146
|
+
process.once('SIGINT', onSignal).once('SIGTERM', onSignal);
|
|
147
|
+
return () => { process.off('SIGINT', onSignal).off('SIGTERM', onSignal); release(); };
|
|
148
|
+
}
|
|
149
|
+
async function isUp(url) {
|
|
150
|
+
try {
|
|
151
|
+
await fetch(url, { signal: AbortSignal.timeout(2000) });
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Reuses a server already listening on local.url, otherwise runs
|
|
159
|
+
// local.command and waits for the URL. Returns a stop function.
|
|
160
|
+
export async function startServer({ url, command, cwd, timeout, logFile, log }) {
|
|
161
|
+
if (await isUp(url)) {
|
|
162
|
+
log(`using running server at ${url}`);
|
|
163
|
+
return () => { };
|
|
164
|
+
}
|
|
165
|
+
if (!command)
|
|
166
|
+
throw new Error(`${url} is not reachable and local.command is not set`);
|
|
167
|
+
log(`starting "${command}" (output: ${logFile})`);
|
|
168
|
+
const fd = fs.openSync(logFile, 'w');
|
|
169
|
+
const win = process.platform === 'win32';
|
|
170
|
+
const child = spawn(command, {
|
|
171
|
+
shell: true, cwd, detached: !win, stdio: ['ignore', fd, fd],
|
|
172
|
+
env: { ...process.env, BROWSER: 'none', FORCE_COLOR: '0' },
|
|
173
|
+
});
|
|
174
|
+
let exited = null;
|
|
175
|
+
child.on('exit', (code) => { exited = code ?? 'signal'; });
|
|
176
|
+
const stop = () => {
|
|
177
|
+
if (exited !== null || child.pid === undefined)
|
|
178
|
+
return;
|
|
179
|
+
try {
|
|
180
|
+
if (win)
|
|
181
|
+
execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'ignore' });
|
|
182
|
+
else
|
|
183
|
+
process.kill(-child.pid, 'SIGTERM'); // whole group: npm → node → vite
|
|
184
|
+
}
|
|
185
|
+
catch { }
|
|
186
|
+
};
|
|
187
|
+
process.once('exit', stop);
|
|
188
|
+
const deadline = Date.now() + timeout;
|
|
189
|
+
while (!(await isUp(url))) {
|
|
190
|
+
if (exited !== null)
|
|
191
|
+
throw new Error(`"${command}" exited (${exited}) before ${url} came up, see ${logFile}`);
|
|
192
|
+
if (Date.now() > deadline) {
|
|
193
|
+
stop();
|
|
194
|
+
throw new Error(`timed out waiting for ${url}, see ${logFile}`);
|
|
195
|
+
}
|
|
196
|
+
await sleep(500);
|
|
197
|
+
}
|
|
198
|
+
log(`server up at ${url}`);
|
|
199
|
+
return stop;
|
|
200
|
+
}
|
|
201
|
+
async function pool(items, n, fn) {
|
|
202
|
+
let i = 0;
|
|
203
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
|
|
204
|
+
while (i < items.length)
|
|
205
|
+
await fn(items[i++]);
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
// Runs in the browser. Boxes of landmark elements, used to say *where* on
|
|
209
|
+
// the page a diff is.
|
|
210
|
+
function collectBoxes() {
|
|
211
|
+
const out = [];
|
|
212
|
+
for (const el of document.querySelectorAll('header,nav,main,section,article,aside,footer,form,[id],h1,h2,h3')) {
|
|
213
|
+
const r = el.getBoundingClientRect();
|
|
214
|
+
if (r.height < 8 || r.width < 8)
|
|
215
|
+
continue;
|
|
216
|
+
let label = el.tagName.toLowerCase();
|
|
217
|
+
if (el.id)
|
|
218
|
+
label += `#${el.id}`;
|
|
219
|
+
else if (typeof el.className === 'string' && el.className.trim())
|
|
220
|
+
label += `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}`;
|
|
221
|
+
if (/^H\d$/.test(el.tagName))
|
|
222
|
+
label += ` "${(el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 40)}"`;
|
|
223
|
+
out.push({ label, top: Math.round(r.top + scrollY), bottom: Math.round(r.bottom + scrollY) });
|
|
224
|
+
if (out.length >= 800)
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
async function shoot(tab, url, outPath, cfg) {
|
|
230
|
+
const res = await tab.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
231
|
+
if (res && res.status() >= 400)
|
|
232
|
+
throw new Error(`${url} returned HTTP ${res.status()}`);
|
|
233
|
+
// Scroll through so IntersectionObserver-driven animations fire.
|
|
234
|
+
const vh = tab.viewportSize()?.height ?? 900;
|
|
235
|
+
const h = await tab.evaluate(() => document.documentElement.scrollHeight);
|
|
236
|
+
for (let y = 0; y < h; y += vh) {
|
|
237
|
+
await tab.evaluate((pos) => window.scrollTo(0, pos), y);
|
|
238
|
+
await tab.waitForTimeout(150);
|
|
239
|
+
}
|
|
240
|
+
await tab.evaluate(() => window.scrollTo(0, 0));
|
|
241
|
+
if (cfg.css)
|
|
242
|
+
await tab.addStyleTag({ content: cfg.css });
|
|
243
|
+
await tab.waitForTimeout(cfg.settle);
|
|
244
|
+
await tab.screenshot({ path: outPath, fullPage: true, animations: 'disabled' });
|
|
245
|
+
return tab.evaluate(collectBoxes);
|
|
246
|
+
}
|
|
247
|
+
function readHead(file, n) {
|
|
248
|
+
const b = Buffer.alloc(n);
|
|
249
|
+
const fd = fs.openSync(file, 'r');
|
|
250
|
+
try {
|
|
251
|
+
fs.readSync(fd, b, 0, n, 0);
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
fs.closeSync(fd);
|
|
255
|
+
}
|
|
256
|
+
return b;
|
|
257
|
+
}
|
|
258
|
+
function pngSize(file) {
|
|
259
|
+
const b = readHead(file, 24);
|
|
260
|
+
return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
|
|
261
|
+
}
|
|
262
|
+
const isPng = (f) => readHead(f, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
|
263
|
+
// Merges changed rows into vertical bands and labels each with the
|
|
264
|
+
// smallest page elements that cover it.
|
|
265
|
+
export function toBands(lines, boxes, gap = 16, max = 30) {
|
|
266
|
+
const bands = [];
|
|
267
|
+
for (const y of lines) {
|
|
268
|
+
const last = bands.at(-1);
|
|
269
|
+
if (last && y - last.y2 <= gap) {
|
|
270
|
+
last.y2 = y;
|
|
271
|
+
last.rows++;
|
|
272
|
+
}
|
|
273
|
+
else
|
|
274
|
+
bands.push({ y1: y, y2: y, rows: 1 });
|
|
275
|
+
}
|
|
276
|
+
const top = bands.length > max ? [...bands].sort((a, b) => b.rows - a.rows).slice(0, max).sort((a, b) => a.y1 - b.y1) : bands;
|
|
277
|
+
return top.map((b) => {
|
|
278
|
+
const where = boxes
|
|
279
|
+
.filter((x) => x.top <= b.y2 && x.bottom >= b.y1)
|
|
280
|
+
.map((x) => ({ label: x.label, covers: x.top <= b.y1 && x.bottom >= b.y2, h: x.bottom - x.top }))
|
|
281
|
+
.sort((p, q) => (Number(q.covers) - Number(p.covers)) || (p.h - q.h))
|
|
282
|
+
.slice(0, 3)
|
|
283
|
+
.map((x) => x.label);
|
|
284
|
+
return { ...b, height: b.y2 - b.y1 + 1, where };
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
// Element boxes are CSS px; screenshots are device px.
|
|
288
|
+
const scaleBoxes = (boxes, k) => (k === 1 ? boxes : boxes.map((b) => ({ ...b, top: b.top * k, bottom: b.bottom * k })));
|
|
289
|
+
// Reference image for a page × viewport: page.ref ("x.png" for all viewports
|
|
290
|
+
// or { mobile: "x.png" }), else the cfg.refs pattern if that file exists.
|
|
291
|
+
export function refImage(page, vp, cfg) {
|
|
292
|
+
const explicit = typeof page.ref === 'string' ? page.ref : page.ref?.[vp];
|
|
293
|
+
if (explicit)
|
|
294
|
+
return path.resolve(cfg.root, explicit);
|
|
295
|
+
if (!cfg.refs)
|
|
296
|
+
return null;
|
|
297
|
+
const f = path.resolve(cfg.root, cfg.refs.replaceAll('{page}', page.name).replaceAll('{viewport}', vp));
|
|
298
|
+
return fs.existsSync(f) ? f : null;
|
|
299
|
+
}
|
|
300
|
+
async function runTask({ page, vp, size: vpSize, ctx, cfg }) {
|
|
301
|
+
const shotDir = path.join(cfg.out, vp);
|
|
302
|
+
const diffDir = path.join(cfg.diffDir, vp);
|
|
303
|
+
fs.mkdirSync(shotDir, { recursive: true });
|
|
304
|
+
fs.mkdirSync(diffDir, { recursive: true });
|
|
305
|
+
const image = refImage(page, vp, cfg);
|
|
306
|
+
const remote = image ?? path.join(shotDir, `${page.name}-remote.png`);
|
|
307
|
+
const local = path.join(shotDir, `${page.name}-local.png`);
|
|
308
|
+
const diff = path.join(diffDir, `${page.name}.png`);
|
|
309
|
+
const boxesFile = `${local}.boxes.json`;
|
|
310
|
+
const base = {
|
|
311
|
+
page: page.name, path: page.path, viewport: vp, refType: image ? 'image' : 'remote',
|
|
312
|
+
remoteUrl: image ? null : page.remoteUrl, localUrl: page.localUrl, remote, local, diff,
|
|
313
|
+
};
|
|
314
|
+
try {
|
|
315
|
+
if (image) {
|
|
316
|
+
if (!fs.existsSync(image))
|
|
317
|
+
throw new Error(`reference image not found: ${image}`);
|
|
318
|
+
if (!isPng(image))
|
|
319
|
+
throw new Error(`reference image must be PNG: ${image}`);
|
|
320
|
+
}
|
|
321
|
+
else if (!page.remoteUrl) {
|
|
322
|
+
throw new Error(`no reference: set "remote", or add a ref image for ${page.name} @ ${vp}`);
|
|
323
|
+
}
|
|
324
|
+
let boxes;
|
|
325
|
+
if (ctx) {
|
|
326
|
+
const tab = await ctx.newPage();
|
|
327
|
+
try {
|
|
328
|
+
if (!image)
|
|
329
|
+
await shoot(tab, page.remoteUrl, remote, cfg);
|
|
330
|
+
boxes = await shoot(tab, page.localUrl, local, cfg);
|
|
331
|
+
fs.writeFileSync(boxesFile, JSON.stringify(boxes));
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
await tab.close();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
try {
|
|
339
|
+
boxes = JSON.parse(fs.readFileSync(boxesFile, 'utf8'));
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
boxes = [];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const r = await compare(remote, local, diff, { antialiasing: true, captureDiffLines: true, failOnLayoutDiff: false, noFailOnFsErrors: true });
|
|
346
|
+
if (!r.match && r.reason === 'file-not-exists')
|
|
347
|
+
throw new Error(`missing screenshot ${r.file} (run without --no-shoot)`);
|
|
348
|
+
const size = { remote: pngSize(remote), local: pngSize(local) };
|
|
349
|
+
if (image && size.remote.width !== size.local.width) {
|
|
350
|
+
throw new Error(`reference image is ${size.remote.width}px wide but the ${vp} screenshot is ${size.local.width}px; `
|
|
351
|
+
+ 'match the viewport width (× deviceScaleFactor) to the design');
|
|
352
|
+
}
|
|
353
|
+
// odiff only compares the overlapping area, so a height change has to be flagged on its own.
|
|
354
|
+
const sizeMismatch = size.remote.width !== size.local.width || size.remote.height !== size.local.height;
|
|
355
|
+
if (r.match) {
|
|
356
|
+
fs.copyFileSync(local, diff); // odiff writes nothing on exact match
|
|
357
|
+
return { ...base, status: sizeMismatch ? 'diff' : 'match', diffPercentage: 0, diffCount: 0, size, sizeMismatch, bands: [] };
|
|
358
|
+
}
|
|
359
|
+
if (r.reason === 'layout-diff')
|
|
360
|
+
return { ...base, status: 'diff', diffPercentage: 100, diffCount: null, size, sizeMismatch: true, bands: [] };
|
|
361
|
+
if (r.reason !== 'pixel-diff')
|
|
362
|
+
throw new Error(`unexpected odiff result: ${JSON.stringify(r)}`);
|
|
363
|
+
const bands = toBands(r.diffLines ?? [], scaleBoxes(boxes, vpSize.deviceScaleFactor ?? 1));
|
|
364
|
+
return { ...base, status: 'diff', diffPercentage: r.diffPercentage, diffCount: r.diffCount, size, sizeMismatch, bands };
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
return { ...base, status: 'error', error: errMsg(e).split('\n')[0] };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Design images are rarely drawn at the real page height, so for them a height
|
|
371
|
+
// difference is only a warning; they can also get a looser threshold.
|
|
372
|
+
export function isFailed(r, cfg) {
|
|
373
|
+
if (r.status === 'error')
|
|
374
|
+
return true;
|
|
375
|
+
const image = r.refType === 'image';
|
|
376
|
+
if (r.sizeMismatch && !image)
|
|
377
|
+
return true;
|
|
378
|
+
return (r.diffPercentage ?? 0) > (image ? cfg.imageThreshold ?? cfg.threshold : cfg.threshold);
|
|
379
|
+
}
|
|
380
|
+
export async function run(cfg, opts = {}) {
|
|
381
|
+
const { log = () => { }, shoot: doShoot = true, server = true, only, viewports, concurrency, shard, browserWs } = opts;
|
|
382
|
+
fs.mkdirSync(cfg.out, { recursive: true });
|
|
383
|
+
let pages = resolvePages(cfg);
|
|
384
|
+
if (only?.length)
|
|
385
|
+
pages = pages.filter((p) => only.includes(p.name) || (p.path !== null && only.includes(p.path)));
|
|
386
|
+
let vps = Object.entries(cfg.viewports);
|
|
387
|
+
if (viewports?.length)
|
|
388
|
+
vps = vps.filter(([name]) => viewports.includes(name));
|
|
389
|
+
let tasks = pages.flatMap((page) => vps.map(([vp, size]) => ({ page, vp, size })));
|
|
390
|
+
if (shard) {
|
|
391
|
+
const [k, n] = shard.split('/').map(Number);
|
|
392
|
+
if (!(k >= 1 && k <= n))
|
|
393
|
+
throw new Error(`bad shard "${shard}", expected k/n`);
|
|
394
|
+
tasks = tasks.filter((_, i) => i % n === k - 1);
|
|
395
|
+
}
|
|
396
|
+
if (!tasks.length)
|
|
397
|
+
throw new Error('nothing to do: no pages/viewports match the filters');
|
|
398
|
+
const n = concurrency ?? cfg.concurrency ?? autoConcurrency();
|
|
399
|
+
log(`${pages.length} pages × ${vps.length} viewports, concurrency ${n}${shard ? `, shard ${shard}` : ''}`);
|
|
400
|
+
const release = await acquireLock(cfg.out, { log });
|
|
401
|
+
const results = [];
|
|
402
|
+
let stopServer = () => { };
|
|
403
|
+
try {
|
|
404
|
+
if (doShoot && server) {
|
|
405
|
+
stopServer = await startServer({
|
|
406
|
+
url: cfg.local.url, command: cfg.local.command, cwd: cfg.root,
|
|
407
|
+
timeout: cfg.serverTimeout, logFile: path.join(cfg.out, 'server.log'), log,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
let browser;
|
|
411
|
+
if (doShoot)
|
|
412
|
+
browser = browserWs ? await chromium.connect(browserWs) : await launchBrowser(log);
|
|
413
|
+
try {
|
|
414
|
+
const ctxs = new Map();
|
|
415
|
+
if (browser) {
|
|
416
|
+
for (const [vp, s] of vps) {
|
|
417
|
+
ctxs.set(vp, await browser.newContext({
|
|
418
|
+
viewport: { width: s.width, height: s.height },
|
|
419
|
+
deviceScaleFactor: s.deviceScaleFactor ?? 1,
|
|
420
|
+
isMobile: !!s.isMobile,
|
|
421
|
+
hasTouch: !!s.isMobile,
|
|
422
|
+
...(s.userAgent && { userAgent: s.userAgent }),
|
|
423
|
+
}));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
await pool(tasks, n, async (t) => {
|
|
427
|
+
log(`${t.page.name} @ ${t.vp}`);
|
|
428
|
+
results.push(await runTask({ ...t, ctx: ctxs.get(t.vp), cfg }));
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
if (browser)
|
|
433
|
+
await browser.close();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
stopServer();
|
|
438
|
+
release();
|
|
439
|
+
}
|
|
440
|
+
const order = new Map(tasks.map((t, i) => [`${t.page.name}@${t.vp}`, i]));
|
|
441
|
+
const key = (r) => order.get(`${r.page}@${r.viewport}`) ?? 0;
|
|
442
|
+
results.sort((a, b) => key(a) - key(b));
|
|
443
|
+
for (const r of results)
|
|
444
|
+
r.failed = isFailed(r, cfg);
|
|
445
|
+
const failedResults = results.filter((r) => r.failed);
|
|
446
|
+
const summary = {
|
|
447
|
+
ok: failedResults.length === 0,
|
|
448
|
+
threshold: cfg.threshold,
|
|
449
|
+
shard: shard ?? null,
|
|
450
|
+
report: cfg.report,
|
|
451
|
+
counts: {
|
|
452
|
+
total: results.length,
|
|
453
|
+
passed: results.length - failedResults.length,
|
|
454
|
+
failed: failedResults.filter((r) => r.status !== 'error').length,
|
|
455
|
+
errors: results.filter((r) => r.status === 'error').length,
|
|
456
|
+
},
|
|
457
|
+
failed: failedResults.map((r) => `${r.page}@${r.viewport}`),
|
|
458
|
+
results,
|
|
459
|
+
};
|
|
460
|
+
writeReport(summary, cfg);
|
|
461
|
+
return summary;
|
|
462
|
+
}
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Log, RawConfig } from './types.ts';
|
|
2
|
+
export interface Detected {
|
|
3
|
+
framework: string;
|
|
4
|
+
local: {
|
|
5
|
+
url: string;
|
|
6
|
+
command: string | null;
|
|
7
|
+
};
|
|
8
|
+
pages: string[];
|
|
9
|
+
remote: string | null;
|
|
10
|
+
}
|
|
11
|
+
export declare function detect(cwd?: string): Detected;
|
|
12
|
+
export interface InitOptions {
|
|
13
|
+
cwd?: string;
|
|
14
|
+
remote?: string | null;
|
|
15
|
+
force?: boolean;
|
|
16
|
+
yes?: boolean;
|
|
17
|
+
log?: Log;
|
|
18
|
+
}
|
|
19
|
+
export interface InitResult {
|
|
20
|
+
configPath: string;
|
|
21
|
+
created: boolean;
|
|
22
|
+
config: RawConfig | null;
|
|
23
|
+
needsRemote?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare function init({ cwd, remote, force, yes, log }?: InitOptions): Promise<InitResult>;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline/promises';
|
|
4
|
+
import { CONFIG_FILE, DEFAULTS } from './index.js';
|
|
5
|
+
const PLACEHOLDER = 'https://example.com';
|
|
6
|
+
const SELF = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).name;
|
|
7
|
+
const SKIP_DIRS = /(^|\/)(node_modules|dist|build|out|\.git|\.next|\.astro|\.svelte-kit|\.nuxt|coverage|argus)(\/|$)/;
|
|
8
|
+
function list(cwd, dir) {
|
|
9
|
+
try {
|
|
10
|
+
return fs.readdirSync(path.join(cwd, dir), { recursive: true })
|
|
11
|
+
.map((f) => String(f).split(path.sep).join('/'))
|
|
12
|
+
.filter((f) => !SKIP_DIRS.test(f));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const dynamic = (seg) => seg.startsWith('_') || seg.startsWith('@') || seg.includes('[');
|
|
19
|
+
// File-based routing: pages/about.astro → /about, pages/blog/index.astro → /blog
|
|
20
|
+
const fileRoutes = (dirs, ext) => (cwd) => dirs.flatMap((d) => list(cwd, d)
|
|
21
|
+
.filter((f) => ext.test(f))
|
|
22
|
+
.map((f) => f.replace(ext, '').split('/'))
|
|
23
|
+
.filter((segs) => segs[0] !== 'api' && !segs.some(dynamic))
|
|
24
|
+
.map((segs) => `/${(segs.at(-1) === 'index' ? segs.slice(0, -1) : segs).join('/')}`));
|
|
25
|
+
// Folder-based routing: app/about/page.tsx → /about, (group) folders dropped
|
|
26
|
+
const dirRoutes = (dirs, file) => (cwd) => dirs.flatMap((d) => list(cwd, d)
|
|
27
|
+
.filter((f) => file.test(f.split('/').at(-1) ?? ''))
|
|
28
|
+
.map((f) => f.split('/').slice(0, -1).filter((s) => !/^\(.*\)$/.test(s)))
|
|
29
|
+
.filter((segs) => segs[0] !== 'api' && !segs.some(dynamic))
|
|
30
|
+
.map((segs) => `/${segs.join('/')}`));
|
|
31
|
+
const htmlRoutes = (cwd) => list(cwd, '.')
|
|
32
|
+
.filter((f) => /\.html$/.test(f) && !f.startsWith('public/'))
|
|
33
|
+
.map((f) => (f === 'index.html' ? '/' : `/${f.replace(/(^|\/)index\.html$/, '$1')}`));
|
|
34
|
+
const FRAMEWORKS = [
|
|
35
|
+
{ dep: 'next', name: 'Next.js', port: 3000, routes: (cwd) => [
|
|
36
|
+
...dirRoutes(['app', 'src/app'], /^page\.(jsx?|tsx?|mdx?)$/)(cwd),
|
|
37
|
+
...fileRoutes(['pages', 'src/pages'], /\.(jsx?|tsx?|mdx?)$/)(cwd),
|
|
38
|
+
] },
|
|
39
|
+
{ dep: 'astro', name: 'Astro', port: 4321, routes: fileRoutes(['src/pages'], /\.(astro|mdx?|html)$/) },
|
|
40
|
+
{ dep: 'nuxt', name: 'Nuxt', port: 3000, routes: fileRoutes(['pages', 'app/pages'], /\.vue$/) },
|
|
41
|
+
{ dep: '@sveltejs/kit', name: 'SvelteKit', port: 5173, routes: dirRoutes(['src/routes'], /^\+page\.(svelte|md|svx)$/) },
|
|
42
|
+
{ dep: 'gatsby', name: 'Gatsby', port: 8000, routes: fileRoutes(['src/pages'], /\.(jsx?|tsx?)$/) },
|
|
43
|
+
{ dep: '@remix-run/dev', name: 'Remix', port: 5173 },
|
|
44
|
+
{ dep: '@react-router/dev', name: 'React Router', port: 5173 },
|
|
45
|
+
{ dep: '@angular/core', name: 'Angular', port: 4200 },
|
|
46
|
+
{ dep: 'react-scripts', name: 'Create React App', port: 3000 },
|
|
47
|
+
{ dep: '@vue/cli-service', name: 'Vue CLI', port: 8080 },
|
|
48
|
+
{ dep: '@11ty/eleventy', name: 'Eleventy', port: 8080 },
|
|
49
|
+
{ dep: 'vite', name: 'Vite', port: 5173, routes: htmlRoutes },
|
|
50
|
+
];
|
|
51
|
+
function readJson(file) { try {
|
|
52
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
} }
|
|
57
|
+
function packageManager(cwd) {
|
|
58
|
+
const has = (f) => fs.existsSync(path.join(cwd, f));
|
|
59
|
+
if (has('pnpm-lock.yaml'))
|
|
60
|
+
return 'pnpm';
|
|
61
|
+
if (has('yarn.lock'))
|
|
62
|
+
return 'yarn';
|
|
63
|
+
if (has('bun.lockb') || has('bun.lock'))
|
|
64
|
+
return 'bun';
|
|
65
|
+
return 'npm';
|
|
66
|
+
}
|
|
67
|
+
function findRemote(cwd, pkg) {
|
|
68
|
+
if (pkg?.homepage && /^https?:\/\//.test(pkg.homepage))
|
|
69
|
+
return pkg.homepage;
|
|
70
|
+
for (const f of fs.readdirSync(cwd)) {
|
|
71
|
+
if (!/^(astro|next|nuxt|svelte|vite|gatsby-config|docusaurus)\.config\.|^gatsby-config\./.test(f))
|
|
72
|
+
continue;
|
|
73
|
+
const m = fs.readFileSync(path.join(cwd, f), 'utf8').match(/\b(?:site|siteUrl|url|baseUrl)\s*:\s*['"`](https?:\/\/[^'"`]+)/);
|
|
74
|
+
if (m)
|
|
75
|
+
return m[1];
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const cname = fs.readFileSync(path.join(cwd, 'CNAME'), 'utf8').trim() || fs.readFileSync(path.join(cwd, 'public/CNAME'), 'utf8').trim();
|
|
79
|
+
if (cname)
|
|
80
|
+
return `https://${cname}`;
|
|
81
|
+
}
|
|
82
|
+
catch { }
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
export function detect(cwd = process.cwd()) {
|
|
86
|
+
const pkg = readJson(path.join(cwd, 'package.json'));
|
|
87
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
88
|
+
const fw = FRAMEWORKS.find((f) => f.dep in deps);
|
|
89
|
+
const scripts = pkg?.scripts ?? {};
|
|
90
|
+
const script = ['dev', 'start', 'serve', 'preview'].find((s) => scripts[s]);
|
|
91
|
+
let command = script ? `${packageManager(cwd)} run ${script}` : null;
|
|
92
|
+
let port = fw?.port ?? 3000;
|
|
93
|
+
const portFlag = script && scripts[script].match(/(?:--port|-p)[ =](\d{2,5})/);
|
|
94
|
+
if (portFlag)
|
|
95
|
+
port = Number(portFlag[1]);
|
|
96
|
+
let pages = fw?.routes?.(cwd) ?? [];
|
|
97
|
+
if (!fw && fs.existsSync(path.join(cwd, 'index.html'))) {
|
|
98
|
+
pages = htmlRoutes(cwd);
|
|
99
|
+
command ??= `npx --yes serve -l ${port} .`;
|
|
100
|
+
}
|
|
101
|
+
pages = [...new Set(pages.length ? pages : ['/'])].sort((a, b) => (a === '/' ? -1 : b === '/' ? 1 : a.localeCompare(b))).slice(0, 50);
|
|
102
|
+
return {
|
|
103
|
+
framework: fw?.name ?? (command ? 'unknown' : 'static'),
|
|
104
|
+
local: { url: `http://localhost:${port}`, command },
|
|
105
|
+
pages,
|
|
106
|
+
remote: findRemote(cwd, pkg),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export async function init({ cwd = process.cwd(), remote, force = false, yes = false, log = console.error } = {}) {
|
|
110
|
+
const configPath = path.join(cwd, CONFIG_FILE);
|
|
111
|
+
if (fs.existsSync(configPath) && !force) {
|
|
112
|
+
log(`${CONFIG_FILE} already exists (use --force to regenerate)`);
|
|
113
|
+
return { configPath, created: false, config: readJson(configPath) };
|
|
114
|
+
}
|
|
115
|
+
const d = detect(cwd);
|
|
116
|
+
log(`detected: ${d.framework}${d.local.command ? `, start with "${d.local.command}"` : ''}, ${d.pages.length} page(s)`);
|
|
117
|
+
remote ??= d.remote;
|
|
118
|
+
if (!remote && !yes && process.stdin.isTTY) {
|
|
119
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
120
|
+
remote = (await rl.question('Reference (remote) site URL to compare against: ')).trim() || null;
|
|
121
|
+
rl.close();
|
|
122
|
+
}
|
|
123
|
+
const config = {
|
|
124
|
+
remote: remote || PLACEHOLDER,
|
|
125
|
+
local: d.local,
|
|
126
|
+
pages: d.pages,
|
|
127
|
+
refs: 'designs/{page}-{viewport}.png',
|
|
128
|
+
viewports: DEFAULTS.viewports,
|
|
129
|
+
threshold: DEFAULTS.threshold,
|
|
130
|
+
out: DEFAULTS.out,
|
|
131
|
+
diffDir: `${DEFAULTS.out}/diffs`,
|
|
132
|
+
report: `${DEFAULTS.out}/report.html`,
|
|
133
|
+
css: DEFAULTS.css,
|
|
134
|
+
};
|
|
135
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
136
|
+
log(`wrote ${CONFIG_FILE}`);
|
|
137
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
138
|
+
if (fs.existsSync(pkgPath)) {
|
|
139
|
+
const text = fs.readFileSync(pkgPath, 'utf8');
|
|
140
|
+
const pkg = JSON.parse(text);
|
|
141
|
+
if (!pkg.scripts?.['argus']) {
|
|
142
|
+
// The `argus` bin only exists when the package is installed in the project.
|
|
143
|
+
const installed = SELF in { ...pkg.dependencies, ...pkg.devDependencies };
|
|
144
|
+
pkg.scripts = { ...pkg.scripts, argus: installed ? 'argus' : `npx ${SELF}` };
|
|
145
|
+
const indent = text.match(/^[ \t]+(?=")/m)?.[0] ?? ' ';
|
|
146
|
+
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`);
|
|
147
|
+
log(`added "argus" script to package.json: ${pkg.scripts.argus}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const gi = path.join(cwd, '.gitignore');
|
|
151
|
+
if (fs.existsSync(gi) && !/^\/?argus\/?$/m.test(fs.readFileSync(gi, 'utf8'))) {
|
|
152
|
+
fs.appendFileSync(gi, `\n/${DEFAULTS.out}/\n`);
|
|
153
|
+
log(`added /${DEFAULTS.out}/ to .gitignore`);
|
|
154
|
+
}
|
|
155
|
+
if (config.remote === PLACEHOLDER)
|
|
156
|
+
log(`set "remote" in ${CONFIG_FILE} to the site you compare against`);
|
|
157
|
+
return { configPath, created: true, config, needsRemote: config.remote === PLACEHOLDER };
|
|
158
|
+
}
|
package/dist/report.d.ts
ADDED
package/dist/report.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const ENTITIES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
4
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ENTITIES[c]);
|
|
5
|
+
export const fmtPct = (n) => `${n.toFixed(n < 1 ? 3 : 2)}%`;
|
|
6
|
+
const pct = (r) => (r.status === 'error' ? 'error' : fmtPct(r.diffPercentage ?? 0));
|
|
7
|
+
const tone = (r) => (r.status === 'error' ? 'err' : r.failed ? 'bad' : r.status === 'match' ? 'ok' : 'warn');
|
|
8
|
+
const id = (r) => `r-${r.page}-${r.viewport}`.replace(/[^\w-]/g, '_');
|
|
9
|
+
function detail(r, rel) {
|
|
10
|
+
const head = `<summary><span class="dot ${tone(r)}"></span><b>${esc(r.page)}</b> <span class="muted">${esc(r.viewport)}</span>
|
|
11
|
+
<span class="pct ${tone(r)}">${pct(r)}</span></summary>`;
|
|
12
|
+
const refLabel = r.refType === 'image' ? 'reference' : 'remote';
|
|
13
|
+
const refLink = r.refType === 'image' ? `image <code>${esc(rel(r.remote))}</code>` : `remote <a href="${esc(r.remoteUrl)}">${esc(r.remoteUrl)}</a>`;
|
|
14
|
+
const links = `<p class="muted">${refLink} · local <a href="${esc(r.localUrl)}">${esc(r.localUrl)}</a></p>`;
|
|
15
|
+
if (r.status === 'error')
|
|
16
|
+
return `<details id="${id(r)}" class="card" open>${head}${links}<p class="errmsg">${esc(r.error)}</p></details>`;
|
|
17
|
+
const s = r.size;
|
|
18
|
+
const bandList = r.bands ?? [];
|
|
19
|
+
const facts = [
|
|
20
|
+
`${(r.diffCount ?? 0).toLocaleString('en')} px changed`,
|
|
21
|
+
`${refLabel} ${s.remote.width}×${s.remote.height}`,
|
|
22
|
+
`local ${s.local.width}×${s.local.height}`,
|
|
23
|
+
];
|
|
24
|
+
const sizeNote = r.sizeMismatch
|
|
25
|
+
? `<p class="warnmsg">Page size differs (height ${s.local.height - s.remote.height > 0 ? '+' : ''}${s.local.height - s.remote.height}px locally). Only the overlapping area is pixel-compared${r.refType === 'image' ? '. Design frames often have a different height than the real page, so this is a warning only' : ', so look for missing or extra content first'}.</p>`
|
|
26
|
+
: '';
|
|
27
|
+
// Bands are in remote-image coordinates (the diff image has the remote's size).
|
|
28
|
+
const H = s.remote.height || 1;
|
|
29
|
+
const overlay = bandList.map((b, i) => `<a class="band" href="#${id(r)}-b${i}" style="top:${(b.y1 / H) * 100}%;height:${Math.max((b.height / H) * 100, 0.3)}%" title="y ${b.y1}–${b.y2}"></a>`).join('');
|
|
30
|
+
const bands = bandList.length
|
|
31
|
+
? `<table class="bands"><thead><tr><th>#</th><th>y (px)</th><th>height</th><th>where</th></tr></thead><tbody>${bandList.map((b, i) => `<tr id="${id(r)}-b${i}"><td>${i + 1}</td><td>${b.y1}–${b.y2}</td><td>${b.height}px</td><td>${b.where.map((w) => `<code>${esc(w)}</code>`).join(' ') || '<span class="muted">—</span>'}</td></tr>`).join('')}</tbody></table>`
|
|
32
|
+
: '';
|
|
33
|
+
const img = (f, cls) => `<img class="${cls}" loading="lazy" src="${esc(rel(f))}" alt="${cls}">`;
|
|
34
|
+
const viewer = `<div class="viewer" data-mode="diff">
|
|
35
|
+
<div class="modes">${['diff', 'remote', 'local', 'side'].map((m) => `<button data-m="${m}">${m === 'side' ? 'side by side' : m === 'remote' ? refLabel : m}</button>`).join('')}</div>
|
|
36
|
+
<div class="stage" style="max-width:${s.remote.width}px"><div class="frame">${img(r.diff, 'diff')}${overlay}</div>${img(r.remote, 'remote')}${img(r.local, 'local')}</div>
|
|
37
|
+
<div class="side">${img(r.remote, 'x')}${img(r.local, 'x')}${img(r.diff, 'x')}</div>
|
|
38
|
+
</div>`;
|
|
39
|
+
return `<details id="${id(r)}" class="card"${r.failed ? ' open' : ''}>${head}${links}<p class="facts">${facts.join(' · ')}</p>${sizeNote}${bands}${viewer}</details>`;
|
|
40
|
+
}
|
|
41
|
+
export function writeReport(summary, cfg) {
|
|
42
|
+
const dir = path.dirname(cfg.report);
|
|
43
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
44
|
+
const rel = (f) => path.relative(dir, f).split(path.sep).join('/');
|
|
45
|
+
const { results, counts, threshold } = summary;
|
|
46
|
+
const pages = [...new Set(results.map((r) => r.page))];
|
|
47
|
+
const vps = [...new Set(results.map((r) => r.viewport))];
|
|
48
|
+
const by = new Map(results.map((r) => [`${r.page}@${r.viewport}`, r]));
|
|
49
|
+
const matrix = `<table class="matrix"><thead><tr><th>page</th>${vps.map((v) => `<th>${esc(v)}</th>`).join('')}</tr></thead><tbody>${pages.map((p) => `<tr><td>${esc(p)}</td>${vps.map((v) => {
|
|
50
|
+
const r = by.get(`${p}@${v}`);
|
|
51
|
+
return r ? `<td><a class="pct ${tone(r)}" href="#${id(r)}">${pct(r)}${r.sizeMismatch ? ' ↕' : ''}</a></td>` : '<td></td>';
|
|
52
|
+
}).join('')}</tr>`).join('')}</tbody></table>`;
|
|
53
|
+
const html = `<!doctype html>
|
|
54
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
55
|
+
<title>Argus report</title>
|
|
56
|
+
<style>
|
|
57
|
+
:root{--bg:#fafafa;--fg:#1a1a1a;--muted:#6b6b6b;--card:#fff;--line:#e4e4e4;--ok:#1a7f37;--warn:#9a6700;--bad:#cf222e;--accent:#0969da}
|
|
58
|
+
@media (prefers-color-scheme:dark){:root{--bg:#111;--fg:#e8e8e8;--muted:#9a9a9a;--card:#1b1b1b;--line:#2e2e2e;--ok:#3fb950;--warn:#d29922;--bad:#f85149;--accent:#58a6ff}}
|
|
59
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,sans-serif}
|
|
60
|
+
main{max-width:1200px;margin:0 auto;padding:24px 16px}a{color:var(--accent)}h1{font-size:22px;margin:0 0 4px}
|
|
61
|
+
.muted{color:var(--muted)}.chips{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0}.chip{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px 14px}
|
|
62
|
+
.chip b{font-size:20px;display:block}table{border-collapse:collapse;width:100%;background:var(--card)}th,td{border-bottom:1px solid var(--line);padding:6px 10px;text-align:left}
|
|
63
|
+
.matrix{border:1px solid var(--line);border-radius:8px;overflow:hidden;margin-bottom:24px}.pct{font-variant-numeric:tabular-nums;font-weight:600;text-decoration:none}
|
|
64
|
+
.ok{color:var(--ok)}.warn{color:var(--warn)}.bad,.err{color:var(--bad)}
|
|
65
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px 14px;margin:10px 0}
|
|
66
|
+
summary{cursor:pointer;display:flex;gap:10px;align-items:center}summary .pct{margin-left:auto}
|
|
67
|
+
.dot{width:10px;height:10px;border-radius:50%;background:currentColor;display:inline-block}
|
|
68
|
+
.dot.ok{color:var(--ok)}.dot.warn{color:var(--warn)}.dot.bad,.dot.err{color:var(--bad)}
|
|
69
|
+
.errmsg,.warnmsg{padding:8px 10px;border-radius:6px;border:1px solid}.errmsg{color:var(--bad)}.warnmsg{color:var(--warn)}
|
|
70
|
+
.bands{margin:8px 0;font-size:13px}code{font-size:12px;background:var(--bg);padding:1px 5px;border-radius:4px;border:1px solid var(--line)}
|
|
71
|
+
.modes{display:flex;gap:4px;margin:10px 0}.modes button{font:inherit;padding:4px 10px;border:1px solid var(--line);background:var(--bg);color:var(--fg);border-radius:6px;cursor:pointer}
|
|
72
|
+
.viewer[data-mode=diff] [data-m=diff],.viewer[data-mode=remote] [data-m=remote],.viewer[data-mode=local] [data-m=local],.viewer[data-mode=side] [data-m=side]{background:var(--accent);color:#fff;border-color:var(--accent)}
|
|
73
|
+
img{display:block;max-width:100%;height:auto;border:1px solid var(--line)}
|
|
74
|
+
.stage img.remote,.stage img.local,.side{display:none}
|
|
75
|
+
.viewer[data-mode=remote] .frame,.viewer[data-mode=local] .frame{display:none}
|
|
76
|
+
.viewer[data-mode=remote] img.remote,.viewer[data-mode=local] img.local{display:block}
|
|
77
|
+
.viewer[data-mode=side] .stage{display:none}.viewer[data-mode=side] .side{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;align-items:start}
|
|
78
|
+
.frame{position:relative}.band{position:absolute;left:0;right:0;background:color-mix(in srgb,var(--bad) 18%,transparent);border-left:4px solid var(--bad)}
|
|
79
|
+
</style></head><body><main>
|
|
80
|
+
<h1>Argus report</h1>
|
|
81
|
+
<p class="muted">${esc(new Date().toISOString())} · ${cfg.remote ? `remote <a href="${esc(cfg.remote)}">${esc(cfg.remote)}</a>` : 'reference images'}${cfg.remote && cfg.refs ? ` + images <code>${esc(cfg.refs)}</code>` : ''} vs local <a href="${esc(cfg.local.url)}">${esc(cfg.local.url)}</a> · threshold ${threshold}%</p>
|
|
82
|
+
<div class="chips"><div class="chip"><b class="${summary.ok ? 'ok' : 'bad'}">${summary.ok ? 'PASS' : 'FAIL'}</b>result</div>
|
|
83
|
+
<div class="chip"><b>${counts.total}</b>checks</div><div class="chip"><b class="ok">${counts.passed}</b>passed</div>
|
|
84
|
+
<div class="chip"><b class="bad">${counts.failed}</b>over threshold</div><div class="chip"><b class="bad">${counts.errors}</b>errors</div></div>
|
|
85
|
+
${matrix}
|
|
86
|
+
${results.map((r) => detail(r, rel)).join('\n')}
|
|
87
|
+
</main><script>
|
|
88
|
+
document.addEventListener('click',e=>{const b=e.target.closest('.modes button');if(b)b.closest('.viewer').dataset.mode=b.dataset.m});
|
|
89
|
+
</script></body></html>`;
|
|
90
|
+
fs.writeFileSync(cfg.report, html);
|
|
91
|
+
return cfg.report;
|
|
92
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export interface Viewport {
|
|
2
|
+
width: number;
|
|
3
|
+
height: number;
|
|
4
|
+
deviceScaleFactor?: number;
|
|
5
|
+
isMobile?: boolean;
|
|
6
|
+
userAgent?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Reference image: one path for every viewport, or one per viewport. */
|
|
9
|
+
export type RefSpec = string | Record<string, string>;
|
|
10
|
+
export type PageSpec = string | {
|
|
11
|
+
path?: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
remote?: string;
|
|
14
|
+
local?: string;
|
|
15
|
+
ref?: RefSpec;
|
|
16
|
+
};
|
|
17
|
+
/** argus.config.json as written by the user. */
|
|
18
|
+
export interface RawConfig {
|
|
19
|
+
remote?: string;
|
|
20
|
+
local: {
|
|
21
|
+
url: string;
|
|
22
|
+
command?: string | null;
|
|
23
|
+
};
|
|
24
|
+
pages: PageSpec[];
|
|
25
|
+
refs?: string;
|
|
26
|
+
viewports?: Record<string, Viewport>;
|
|
27
|
+
threshold?: number;
|
|
28
|
+
imageThreshold?: number;
|
|
29
|
+
out?: string;
|
|
30
|
+
diffDir?: string;
|
|
31
|
+
report?: string;
|
|
32
|
+
css?: string;
|
|
33
|
+
settle?: number;
|
|
34
|
+
serverTimeout?: number;
|
|
35
|
+
concurrency?: number;
|
|
36
|
+
}
|
|
37
|
+
/** Config with defaults filled and paths made absolute. */
|
|
38
|
+
export interface Config extends RawConfig {
|
|
39
|
+
root: string;
|
|
40
|
+
viewports: Record<string, Viewport>;
|
|
41
|
+
threshold: number;
|
|
42
|
+
out: string;
|
|
43
|
+
diffDir: string;
|
|
44
|
+
report: string;
|
|
45
|
+
css: string;
|
|
46
|
+
settle: number;
|
|
47
|
+
serverTimeout: number;
|
|
48
|
+
}
|
|
49
|
+
export interface Page {
|
|
50
|
+
name: string;
|
|
51
|
+
path: string | null;
|
|
52
|
+
ref: RefSpec | null;
|
|
53
|
+
remoteUrl: string | null;
|
|
54
|
+
localUrl: string;
|
|
55
|
+
}
|
|
56
|
+
export interface Box {
|
|
57
|
+
label: string;
|
|
58
|
+
top: number;
|
|
59
|
+
bottom: number;
|
|
60
|
+
}
|
|
61
|
+
export interface Band {
|
|
62
|
+
y1: number;
|
|
63
|
+
y2: number;
|
|
64
|
+
rows: number;
|
|
65
|
+
height: number;
|
|
66
|
+
/** Smallest page elements covering the band, e.g. `section#pricing`. */
|
|
67
|
+
where: string[];
|
|
68
|
+
}
|
|
69
|
+
export interface Size {
|
|
70
|
+
width: number;
|
|
71
|
+
height: number;
|
|
72
|
+
}
|
|
73
|
+
export interface Result {
|
|
74
|
+
page: string;
|
|
75
|
+
path: string | null;
|
|
76
|
+
viewport: string;
|
|
77
|
+
refType: 'remote' | 'image';
|
|
78
|
+
remoteUrl: string | null;
|
|
79
|
+
localUrl: string;
|
|
80
|
+
/** Reference image path (remote screenshot or design image). */
|
|
81
|
+
remote: string;
|
|
82
|
+
local: string;
|
|
83
|
+
diff: string;
|
|
84
|
+
status: 'match' | 'diff' | 'error';
|
|
85
|
+
error?: string;
|
|
86
|
+
diffPercentage?: number;
|
|
87
|
+
diffCount?: number | null;
|
|
88
|
+
size?: {
|
|
89
|
+
remote: Size;
|
|
90
|
+
local: Size;
|
|
91
|
+
};
|
|
92
|
+
sizeMismatch?: boolean;
|
|
93
|
+
bands?: Band[];
|
|
94
|
+
failed?: boolean;
|
|
95
|
+
}
|
|
96
|
+
export interface Summary {
|
|
97
|
+
ok: boolean;
|
|
98
|
+
threshold: number;
|
|
99
|
+
shard: string | null;
|
|
100
|
+
report: string;
|
|
101
|
+
counts: {
|
|
102
|
+
total: number;
|
|
103
|
+
passed: number;
|
|
104
|
+
failed: number;
|
|
105
|
+
errors: number;
|
|
106
|
+
};
|
|
107
|
+
failed: string[];
|
|
108
|
+
results: Result[];
|
|
109
|
+
}
|
|
110
|
+
export type Log = (msg: string) => void;
|
|
111
|
+
export interface RunOptions {
|
|
112
|
+
log?: Log;
|
|
113
|
+
shoot?: boolean;
|
|
114
|
+
server?: boolean;
|
|
115
|
+
only?: string[];
|
|
116
|
+
viewports?: string[];
|
|
117
|
+
concurrency?: number;
|
|
118
|
+
shard?: string;
|
|
119
|
+
browserWs?: string;
|
|
120
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaze-no-ryuu/argus",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Argus: visual diff of your local site against a live site or design images. Auto-detects framework and dev server, desktop/tablet/mobile, pinpoints changed elements, HTML report + JSON for CI and AI agents.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"argus": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./init": {
|
|
15
|
+
"types": "./dist/init.d.ts",
|
|
16
|
+
"default": "./dist/init.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
|
|
25
|
+
"test": "node --test \"test/*.test.ts\"",
|
|
26
|
+
"prepublishOnly": "npm run build && npm test",
|
|
27
|
+
"test:coverage": "node --test --experimental-test-coverage --test-coverage-include=\"src/**\" \"test/*.test.ts\""
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"ai-agent",
|
|
34
|
+
"astro",
|
|
35
|
+
"cli",
|
|
36
|
+
"diff",
|
|
37
|
+
"nextjs",
|
|
38
|
+
"odiff",
|
|
39
|
+
"pixel-diff",
|
|
40
|
+
"playwright",
|
|
41
|
+
"responsive",
|
|
42
|
+
"screenshot",
|
|
43
|
+
"screenshot-comparison",
|
|
44
|
+
"visual-regression",
|
|
45
|
+
"visual-testing",
|
|
46
|
+
"vite"
|
|
47
|
+
],
|
|
48
|
+
"author": "Kaze",
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/Junkiez/argus.git"
|
|
53
|
+
},
|
|
54
|
+
"homepage": "https://github.com/Junkiez/argus#readme",
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/Junkiez/argus/issues"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"odiff-bin": "^3.2.1",
|
|
60
|
+
"playwright": "^1.45.0"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
},
|
|
65
|
+
"types": "./dist/index.d.ts",
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@types/node": "^22.18.0",
|
|
68
|
+
"typescript": "^7.0.2"
|
|
69
|
+
}
|
|
70
|
+
}
|