freshshot 0.1.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 +137 -0
- package/action.yml +54 -0
- package/bin/freshshot.mjs +67 -0
- package/package.json +41 -0
- package/src/capture.mjs +94 -0
- package/src/compare.mjs +18 -0
- package/src/config.mjs +44 -0
- package/src/server.mjs +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Hanko
|
|
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
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# 📸 freshshot
|
|
2
|
+
|
|
3
|
+
**Keep your README screenshots fresh.** Capture them, compare them *perceptually*, and commit only the ones that truly changed.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## The problem
|
|
12
|
+
|
|
13
|
+
README screenshots rot. You add one the day you launch; the UI moves a week later; now your README quietly lies to everyone who lands on it. A stale screenshot makes a project look abandoned.
|
|
14
|
+
|
|
15
|
+
The obvious fix is to regenerate them in CI. But naive screenshot automation commits a **new image on every run** — fonts anti-alias differently, animations land on different frames, a random value nudges a pixel. Your git history fills with binary noise and the diffs become worthless. So most people just... don't.
|
|
16
|
+
|
|
17
|
+
## How freshshot fixes it
|
|
18
|
+
|
|
19
|
+
Two ideas, working together:
|
|
20
|
+
|
|
21
|
+
**1. Determinism.** freshshot serves your site locally, optionally seeds `Math.random`, freezes animations, and waits for web fonts. The same UI renders the same way every run.
|
|
22
|
+
|
|
23
|
+
**2. Perceptual diff.** A freshly captured shot replaces the committed one *only* if more than a threshold of pixels **genuinely** moved. Sub-pixel anti-aliasing and blur jitter is ignored. Real changes are not.
|
|
24
|
+
|
|
25
|
+
Run it in CI on every change and it commits a screenshot **only when the UI actually changed.** No noise. The README stays honest on its own.
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install --save-dev freshshot
|
|
31
|
+
npx playwright install chromium
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Add a `freshshot.config.json` at your project root:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"serve": "site",
|
|
39
|
+
"outDir": "screenshots",
|
|
40
|
+
"shots": [
|
|
41
|
+
{ "name": "home", "steps": [ { "goto": "/index.html" } ] }
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Run it:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx freshshot
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
freshshot: 1 shot(s) from freshshot.config.json
|
|
54
|
+
= home unchanged (0.000%)
|
|
55
|
+
|
|
56
|
+
No changes - every screenshot is already current.
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
freshshot captures each shot, compares it to the committed PNG, and rewrites only what meaningfully changed. Embed the results in your README with plain `<img>` tags and you're done.
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
A `freshshot.config.json` has a few top-level keys and a list of `shots`.
|
|
64
|
+
|
|
65
|
+
**Top level**
|
|
66
|
+
|
|
67
|
+
- `serve` — a local folder to serve and screenshot, **or**
|
|
68
|
+
- `baseUrl` — an already-running URL to screenshot instead
|
|
69
|
+
- `outDir` — where screenshots are written (default `screenshots`)
|
|
70
|
+
- `viewport` — `{ "width", "height" }` (default `1280x800`)
|
|
71
|
+
- `deviceScaleFactor` — e.g. `2` for retina-crisp images (default `1`)
|
|
72
|
+
- `diffRatio` — how much must change before a rewrite (default `0.0015`, i.e. 0.15%)
|
|
73
|
+
- `seedRandom` — replace `Math.random` with a fixed seed for determinism (default `false`)
|
|
74
|
+
- `settleMs` — pause after fonts load, before capture (default `200`)
|
|
75
|
+
- `shots` — the screenshots to take
|
|
76
|
+
|
|
77
|
+
**Each shot**
|
|
78
|
+
|
|
79
|
+
- `name` — output filename (`<name>.png`)
|
|
80
|
+
- `steps` — actions that drive the page into the state you want
|
|
81
|
+
- `screenshot` — Playwright screenshot options, e.g. `{ "animations": "disabled" }`
|
|
82
|
+
|
|
83
|
+
**Steps**
|
|
84
|
+
|
|
85
|
+
- `{ "goto": "/path" }`
|
|
86
|
+
- `{ "click": "<selector>", "times": 1 }`
|
|
87
|
+
- `{ "fill": "<selector>", "value": "text" }`
|
|
88
|
+
- `{ "waitFor": "<selector>" }`
|
|
89
|
+
- `{ "wait": 500 }` — milliseconds
|
|
90
|
+
- `{ "eval": "<javascript>" }` — escape hatch for app-specific tweaks (force an element visible, pause an animation, and so on)
|
|
91
|
+
|
|
92
|
+
Shots run **in order on one browser session**, so a shot whose steps don't start with `goto` simply continues from the previous shot's state. That makes multi-step flows — open a menu, play a turn, reach a result screen — easy to capture.
|
|
93
|
+
|
|
94
|
+
See [`examples/freshshot.config.json`](examples/freshshot.config.json) for a complete, real-world config.
|
|
95
|
+
|
|
96
|
+
## In CI
|
|
97
|
+
|
|
98
|
+
```yaml
|
|
99
|
+
name: Screenshots
|
|
100
|
+
on:
|
|
101
|
+
push:
|
|
102
|
+
branches: [main]
|
|
103
|
+
paths: ['site/**']
|
|
104
|
+
permissions:
|
|
105
|
+
contents: write
|
|
106
|
+
jobs:
|
|
107
|
+
freshshot:
|
|
108
|
+
runs-on: ubuntu-latest
|
|
109
|
+
steps:
|
|
110
|
+
- uses: actions/checkout@v4
|
|
111
|
+
- uses: actions/setup-node@v4
|
|
112
|
+
with:
|
|
113
|
+
node-version: 20
|
|
114
|
+
- uses: mphinance/freshshot@v1
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The action captures, perceptual-diffs, and commits back **only** the screenshots that changed. An unchanged UI produces zero commits.
|
|
118
|
+
|
|
119
|
+
## `--check` mode
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npx freshshot --check
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Captures and compares but writes nothing; exits non-zero if any screenshot is stale. Useful as a pull-request gate.
|
|
126
|
+
|
|
127
|
+
## Why "perceptual"?
|
|
128
|
+
|
|
129
|
+
Byte-for-byte screenshot comparison is a trap: two visually **identical** captures routinely differ at the byte level, because anti-aliasing and filter effects render with tiny variations. Compare bytes, and you commit noise forever. freshshot compares *pixels* with [pixelmatch](https://github.com/mapbox/pixelmatch) — sub-pixel jitter is filtered out, genuine changes are caught. That single decision is what makes screenshot automation actually usable.
|
|
130
|
+
|
|
131
|
+
## Origin
|
|
132
|
+
|
|
133
|
+
freshshot was extracted from [Third Settler](https://github.com/mphinance/third-settler), a board-game app whose README screenshots had to stay honest while the UI changed by the hour. The pattern worked well enough that it wanted to be its own tool.
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT — see [LICENSE](LICENSE).
|
package/action.yml
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
name: 'freshshot'
|
|
2
|
+
description: 'Keep README screenshots fresh - capture, perceptual-diff, and commit only real changes.'
|
|
3
|
+
branding:
|
|
4
|
+
icon: 'camera'
|
|
5
|
+
color: 'purple'
|
|
6
|
+
|
|
7
|
+
inputs:
|
|
8
|
+
config:
|
|
9
|
+
description: 'Path to the freshshot config file.'
|
|
10
|
+
required: false
|
|
11
|
+
default: 'freshshot.config.json'
|
|
12
|
+
commit:
|
|
13
|
+
description: 'Commit refreshed screenshots back to the branch.'
|
|
14
|
+
required: false
|
|
15
|
+
default: 'true'
|
|
16
|
+
commit-message:
|
|
17
|
+
description: 'Commit message used when screenshots change.'
|
|
18
|
+
required: false
|
|
19
|
+
default: 'Refresh screenshots [skip ci]'
|
|
20
|
+
|
|
21
|
+
runs:
|
|
22
|
+
using: 'composite'
|
|
23
|
+
steps:
|
|
24
|
+
- name: Install freshshot
|
|
25
|
+
shell: bash
|
|
26
|
+
working-directory: ${{ github.action_path }}
|
|
27
|
+
run: npm install --omit=dev --no-audit --no-fund
|
|
28
|
+
|
|
29
|
+
- name: Install Chromium
|
|
30
|
+
shell: bash
|
|
31
|
+
working-directory: ${{ github.action_path }}
|
|
32
|
+
run: npx playwright install --with-deps chromium
|
|
33
|
+
|
|
34
|
+
- name: Run freshshot
|
|
35
|
+
shell: bash
|
|
36
|
+
run: node "${{ github.action_path }}/bin/freshshot.mjs" --config "${{ inputs.config }}"
|
|
37
|
+
|
|
38
|
+
- name: Commit refreshed screenshots
|
|
39
|
+
if: ${{ inputs.commit == 'true' }}
|
|
40
|
+
shell: bash
|
|
41
|
+
run: |
|
|
42
|
+
out=$(node -e "console.log(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).outDir||'screenshots')" "${{ inputs.config }}")
|
|
43
|
+
git add "$out"
|
|
44
|
+
if git diff --cached --quiet; then
|
|
45
|
+
echo "Screenshots are already up to date."
|
|
46
|
+
exit 0
|
|
47
|
+
fi
|
|
48
|
+
git config user.name "github-actions[bot]"
|
|
49
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
50
|
+
git commit -m "${{ inputs.commit-message }}"
|
|
51
|
+
# Rebase onto anything that landed while the job ran, so the push
|
|
52
|
+
# always fast-forwards.
|
|
53
|
+
git pull --rebase origin "${GITHUB_REF_NAME}"
|
|
54
|
+
git push
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// freshshot - keep your README screenshots fresh.
|
|
3
|
+
|
|
4
|
+
import { loadConfig } from '../src/config.mjs';
|
|
5
|
+
import { capture } from '../src/capture.mjs';
|
|
6
|
+
|
|
7
|
+
const HELP = `freshshot - keep your README screenshots fresh
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
freshshot [--config <path>] [--check]
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--config <path> Config file to use (default: freshshot.config.json)
|
|
14
|
+
--check Report stale screenshots without writing; exit 1 if any.
|
|
15
|
+
--help, -h Show this help.
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
function flagValue(name) {
|
|
19
|
+
const i = process.argv.indexOf(name);
|
|
20
|
+
return i > -1 ? process.argv[i + 1] : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
25
|
+
process.stdout.write(HELP);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const check = process.argv.includes('--check');
|
|
30
|
+
const configPath = flagValue('--config') || 'freshshot.config.json';
|
|
31
|
+
|
|
32
|
+
const cfg = loadConfig(configPath);
|
|
33
|
+
console.log(`freshshot: ${cfg.shots.length} shot(s) from ${configPath}`);
|
|
34
|
+
|
|
35
|
+
const results = await capture(cfg, { check });
|
|
36
|
+
|
|
37
|
+
let stale = 0;
|
|
38
|
+
for (const r of results) {
|
|
39
|
+
const pct = `${(r.ratio * 100).toFixed(3)}%`;
|
|
40
|
+
if (r.status === 'new') {
|
|
41
|
+
stale++;
|
|
42
|
+
console.log(` + ${r.name} new`);
|
|
43
|
+
} else if (r.status === 'changed') {
|
|
44
|
+
stale++;
|
|
45
|
+
console.log(` ~ ${r.name} changed (${pct})`);
|
|
46
|
+
} else {
|
|
47
|
+
console.log(` = ${r.name} unchanged (${pct})`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (check) {
|
|
52
|
+
if (stale > 0) {
|
|
53
|
+
console.error(`\n${stale} screenshot(s) are out of date. Run freshshot to refresh them.`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
console.log('\nAll screenshots are current.');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
console.log(stale > 0
|
|
60
|
+
? `\n${stale} screenshot(s) refreshed.`
|
|
61
|
+
: '\nNo changes - every screenshot is already current.');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
main().catch((err) => {
|
|
65
|
+
console.error('freshshot: ' + err.message);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "freshshot",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Keep your README screenshots fresh: capture, perceptual-diff, and commit only the ones that truly changed.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"freshshot": "bin/freshshot.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"action.yml"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"freshshot": "node bin/freshshot.mjs",
|
|
19
|
+
"test": "node --test"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"screenshots",
|
|
23
|
+
"readme",
|
|
24
|
+
"playwright",
|
|
25
|
+
"documentation",
|
|
26
|
+
"ci",
|
|
27
|
+
"visual-regression"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/mphinance/freshshot.git"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/mphinance/freshshot#readme",
|
|
35
|
+
"bugs": "https://github.com/mphinance/freshshot/issues",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"pixelmatch": "^7.0.0",
|
|
38
|
+
"playwright": "^1.49.0",
|
|
39
|
+
"pngjs": "^7.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/capture.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The capture engine: drive each configured shot through Playwright, then
|
|
2
|
+
// keep the result only if it differs meaningfully from the committed one.
|
|
3
|
+
|
|
4
|
+
import { chromium } from 'playwright';
|
|
5
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { serve } from './server.mjs';
|
|
8
|
+
import { diffRatio } from './compare.mjs';
|
|
9
|
+
|
|
10
|
+
// mulberry32 - a seeded PRNG injected to replace Math.random, so anything
|
|
11
|
+
// random in the page (generated content, dice, confetti) renders the same
|
|
12
|
+
// way every run.
|
|
13
|
+
const SEED_SCRIPT = `(function () {
|
|
14
|
+
var s = 0x9e3779b9;
|
|
15
|
+
Math.random = function () {
|
|
16
|
+
s = (s + 0x6D2B79F5) | 0;
|
|
17
|
+
var t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
18
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
19
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
20
|
+
};
|
|
21
|
+
})();`;
|
|
22
|
+
|
|
23
|
+
async function runStep(page, step, base) {
|
|
24
|
+
if (step.goto !== undefined) {
|
|
25
|
+
await page.goto(base + step.goto, { waitUntil: 'networkidle' });
|
|
26
|
+
} else if (step.click !== undefined) {
|
|
27
|
+
const times = step.times || 1;
|
|
28
|
+
for (let i = 0; i < times; i++) await page.click(step.click);
|
|
29
|
+
} else if (step.fill !== undefined) {
|
|
30
|
+
await page.fill(step.fill, String(step.value ?? ''));
|
|
31
|
+
} else if (step.waitFor !== undefined) {
|
|
32
|
+
await page.waitForSelector(step.waitFor, { state: step.state || 'attached' });
|
|
33
|
+
} else if (step.wait !== undefined) {
|
|
34
|
+
await page.waitForTimeout(step.wait);
|
|
35
|
+
} else if (step.eval !== undefined) {
|
|
36
|
+
await page.evaluate(step.eval);
|
|
37
|
+
} else {
|
|
38
|
+
throw new Error(`Unknown step: ${JSON.stringify(step)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function decide(name, dest, buf, threshold, write) {
|
|
43
|
+
if (!existsSync(dest)) {
|
|
44
|
+
if (write) writeFileSync(dest, buf);
|
|
45
|
+
return { name, status: 'new', ratio: 1 };
|
|
46
|
+
}
|
|
47
|
+
const ratio = diffRatio(readFileSync(dest), buf);
|
|
48
|
+
if (ratio > threshold) {
|
|
49
|
+
if (write) writeFileSync(dest, buf);
|
|
50
|
+
return { name, status: 'changed', ratio };
|
|
51
|
+
}
|
|
52
|
+
return { name, status: 'unchanged', ratio };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Runs every shot in the config. With { check: true } nothing is written;
|
|
56
|
+
// the returned statuses just report what would change.
|
|
57
|
+
export async function capture(cfg, { check = false } = {}) {
|
|
58
|
+
mkdirSync(cfg.outDir, { recursive: true });
|
|
59
|
+
|
|
60
|
+
let base = cfg.baseUrl;
|
|
61
|
+
let server = null;
|
|
62
|
+
if (cfg.serve) {
|
|
63
|
+
server = await serve(cfg.serve);
|
|
64
|
+
base = server.url;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const browser = await chromium.launch();
|
|
68
|
+
const page = await browser.newPage({
|
|
69
|
+
viewport: cfg.viewport,
|
|
70
|
+
deviceScaleFactor: cfg.deviceScaleFactor
|
|
71
|
+
});
|
|
72
|
+
if (cfg.seedRandom) await page.addInitScript(SEED_SCRIPT);
|
|
73
|
+
|
|
74
|
+
const results = [];
|
|
75
|
+
try {
|
|
76
|
+
for (const shot of cfg.shots) {
|
|
77
|
+
try {
|
|
78
|
+
for (const step of shot.steps) await runStep(page, step, base);
|
|
79
|
+
// Let any web fonts finish, then settle, for a stable capture.
|
|
80
|
+
await page.evaluate(() => (document.fonts ? document.fonts.ready : null));
|
|
81
|
+
await page.waitForTimeout(cfg.settleMs);
|
|
82
|
+
const buf = await page.screenshot(shot.screenshot || {});
|
|
83
|
+
const dest = join(cfg.outDir, `${shot.name}.png`);
|
|
84
|
+
results.push(decide(shot.name, dest, buf, cfg.diffRatio, !check));
|
|
85
|
+
} catch (e) {
|
|
86
|
+
throw new Error(`shot "${shot.name}" failed: ${e.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
await browser.close();
|
|
91
|
+
if (server) server.close();
|
|
92
|
+
}
|
|
93
|
+
return results;
|
|
94
|
+
}
|
package/src/compare.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Perceptual image comparison. Returns the fraction of pixels that
|
|
2
|
+
// genuinely changed, ignoring the sub-pixel anti-aliasing and filter
|
|
3
|
+
// jitter that makes byte-for-byte screenshot comparison useless.
|
|
4
|
+
|
|
5
|
+
import pixelmatch from 'pixelmatch';
|
|
6
|
+
import pngjs from 'pngjs';
|
|
7
|
+
|
|
8
|
+
const { PNG } = pngjs;
|
|
9
|
+
|
|
10
|
+
// Fraction (0..1) of pixels that meaningfully differ between two PNG buffers.
|
|
11
|
+
// Differently sized images count as fully different.
|
|
12
|
+
export function diffRatio(pngBufferA, pngBufferB) {
|
|
13
|
+
const a = PNG.sync.read(pngBufferA);
|
|
14
|
+
const b = PNG.sync.read(pngBufferB);
|
|
15
|
+
if (a.width !== b.width || a.height !== b.height) return 1;
|
|
16
|
+
const moved = pixelmatch(a.data, b.data, null, a.width, a.height, { threshold: 0.1 });
|
|
17
|
+
return moved / (a.width * a.height);
|
|
18
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Loads, defaults, and validates a freshshot config file.
|
|
2
|
+
|
|
3
|
+
import { readFileSync, existsSync } from 'fs';
|
|
4
|
+
|
|
5
|
+
const DEFAULTS = {
|
|
6
|
+
serve: null, // a local folder to serve, OR
|
|
7
|
+
baseUrl: null, // an existing URL to capture
|
|
8
|
+
outDir: 'screenshots',
|
|
9
|
+
viewport: { width: 1280, height: 800 },
|
|
10
|
+
deviceScaleFactor: 1,
|
|
11
|
+
diffRatio: 0.0015, // rewrite a shot only if >0.15% of pixels moved
|
|
12
|
+
seedRandom: false, // replace Math.random with a fixed seed
|
|
13
|
+
settleMs: 200, // pause after fonts load, before the screenshot
|
|
14
|
+
shots: []
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function loadConfig(path) {
|
|
18
|
+
if (!existsSync(path)) {
|
|
19
|
+
throw new Error(`Config file not found: ${path}`);
|
|
20
|
+
}
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
24
|
+
} catch (e) {
|
|
25
|
+
throw new Error(`Config is not valid JSON (${path}): ${e.message}`);
|
|
26
|
+
}
|
|
27
|
+
const cfg = { ...DEFAULTS, ...raw };
|
|
28
|
+
// Deep-merge nested objects so a partial "viewport" keeps its defaults.
|
|
29
|
+
cfg.viewport = { ...DEFAULTS.viewport, ...(raw.viewport || {}) };
|
|
30
|
+
|
|
31
|
+
if (!cfg.serve && !cfg.baseUrl) {
|
|
32
|
+
throw new Error('Config must set "serve" (a local folder) or "baseUrl" (a URL).');
|
|
33
|
+
}
|
|
34
|
+
if (!Array.isArray(cfg.shots) || cfg.shots.length === 0) {
|
|
35
|
+
throw new Error('Config must define a non-empty "shots" array.');
|
|
36
|
+
}
|
|
37
|
+
cfg.shots.forEach((shot, i) => {
|
|
38
|
+
if (!shot.name) throw new Error(`shots[${i}] is missing "name".`);
|
|
39
|
+
if (!Array.isArray(shot.steps)) {
|
|
40
|
+
throw new Error(`shot "${shot.name}" is missing a "steps" array.`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return cfg;
|
|
44
|
+
}
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// A tiny static file server, so captures can run against a local folder
|
|
2
|
+
// with no separate dev server and no dependency on a live deploy.
|
|
3
|
+
|
|
4
|
+
import { createServer } from 'http';
|
|
5
|
+
import { readFileSync, existsSync, statSync } from 'fs';
|
|
6
|
+
import { join, extname, resolve, sep } from 'path';
|
|
7
|
+
|
|
8
|
+
const MIME = {
|
|
9
|
+
'.html': 'text/html; charset=utf-8',
|
|
10
|
+
'.js': 'text/javascript',
|
|
11
|
+
'.mjs': 'text/javascript',
|
|
12
|
+
'.css': 'text/css',
|
|
13
|
+
'.svg': 'image/svg+xml',
|
|
14
|
+
'.png': 'image/png',
|
|
15
|
+
'.jpg': 'image/jpeg',
|
|
16
|
+
'.jpeg': 'image/jpeg',
|
|
17
|
+
'.gif': 'image/gif',
|
|
18
|
+
'.webp': 'image/webp',
|
|
19
|
+
'.ico': 'image/x-icon',
|
|
20
|
+
'.json': 'application/json',
|
|
21
|
+
'.webmanifest': 'application/manifest+json',
|
|
22
|
+
'.woff': 'font/woff',
|
|
23
|
+
'.woff2': 'font/woff2'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Serves `dir` on a random free port. Resolves { url, close }.
|
|
27
|
+
export function serve(dir) {
|
|
28
|
+
const root = resolve(dir);
|
|
29
|
+
return new Promise((ok) => {
|
|
30
|
+
const server = createServer((req, res) => {
|
|
31
|
+
let path = decodeURIComponent(req.url.split('?')[0]);
|
|
32
|
+
if (path.endsWith('/')) path += 'index.html';
|
|
33
|
+
const file = resolve(join(root, path));
|
|
34
|
+
// Stay inside the served folder. The trailing separator stops a
|
|
35
|
+
// sibling like "site-other" from matching a root of "site".
|
|
36
|
+
if (!file.startsWith(root + sep) || !existsSync(file) || !statSync(file).isFile()) {
|
|
37
|
+
res.writeHead(404);
|
|
38
|
+
res.end('not found');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
res.writeHead(200, {
|
|
42
|
+
'Content-Type': MIME[extname(file).toLowerCase()] || 'application/octet-stream'
|
|
43
|
+
});
|
|
44
|
+
res.end(readFileSync(file));
|
|
45
|
+
});
|
|
46
|
+
server.listen(0, () => {
|
|
47
|
+
ok({
|
|
48
|
+
url: `http://localhost:${server.address().port}`,
|
|
49
|
+
close: () => server.close()
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|