astro-better-declarative-screenshots 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/.github/workflows/check-screenshots.yml +68 -0
- package/CONFIGURATION.md +251 -0
- package/Highlight.astro +31 -0
- package/README.md +141 -0
- package/Screenshot.astro +131 -0
- package/example/groups.mdx +33 -0
- package/example/screenshot.config.mjs +72 -0
- package/index.mjs +56 -0
- package/package.json +36 -0
- package/scripts/check-screenshots.mjs +188 -0
- package/scripts/take-screenshots.mjs +162 -0
- package/src/capture.mjs +108 -0
- package/src/chrome.mjs +153 -0
- package/src/config.mjs +122 -0
- package/src/diff.mjs +80 -0
- package/src/discover.mjs +128 -0
- package/src/docker.mjs +123 -0
- package/src/highlight.mjs +133 -0
- package/src/naming.mjs +78 -0
- package/src/placeholder.mjs +53 -0
- package/tests/chrome.test.mjs +61 -0
- package/tests/config.test.mjs +96 -0
- package/tests/diff.test.mjs +85 -0
- package/tests/discover.test.mjs +118 -0
- package/tests/naming.test.mjs +72 -0
- package/tests/placeholder.test.mjs +35 -0
- package/vitest.config.mjs +12 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// injects visual highlights into a Playwright page before taking a screenshot.
|
|
2
|
+
// supports 'border' (colored outline), 'arrow' (pointer from outside), and 'both'.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {Object} HighlightSpec
|
|
6
|
+
* @property {string} selector
|
|
7
|
+
* @property {'border'|'arrow'|'both'} style
|
|
8
|
+
* @property {string} color
|
|
9
|
+
* @property {string} label
|
|
10
|
+
* @property {number} borderWidth
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Inject highlight overlays into the page.
|
|
15
|
+
* Returns a cleanup function that removes injected elements.
|
|
16
|
+
*
|
|
17
|
+
* @param {import('playwright').Page} page
|
|
18
|
+
* @param {HighlightSpec[]} highlights
|
|
19
|
+
* @returns {Promise<() => Promise<void>>} cleanup fn
|
|
20
|
+
*/
|
|
21
|
+
export async function injectHighlights(page, highlights) {
|
|
22
|
+
if (!highlights.length) return async () => {};
|
|
23
|
+
|
|
24
|
+
const ids = await page.evaluate((specs) => {
|
|
25
|
+
const injectedIds = [];
|
|
26
|
+
|
|
27
|
+
for (const spec of specs) {
|
|
28
|
+
const target = document.querySelector(spec.selector);
|
|
29
|
+
if (!target) {
|
|
30
|
+
console.warn(`[screenshot] selector not found: ${spec.selector}`);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rect = target.getBoundingClientRect();
|
|
35
|
+
const scrollX = window.scrollX;
|
|
36
|
+
const scrollY = window.scrollY;
|
|
37
|
+
|
|
38
|
+
const top = rect.top + scrollY;
|
|
39
|
+
const left = rect.left + scrollX;
|
|
40
|
+
const width = rect.width;
|
|
41
|
+
const height = rect.height;
|
|
42
|
+
|
|
43
|
+
const id = `__screenshot_hl_${Math.random().toString(36).slice(2)}`;
|
|
44
|
+
injectedIds.push(id);
|
|
45
|
+
|
|
46
|
+
if (spec.style === 'border' || spec.style === 'both') {
|
|
47
|
+
const border = document.createElement('div');
|
|
48
|
+
border.id = id + '_border';
|
|
49
|
+
injectedIds.push(id + '_border');
|
|
50
|
+
Object.assign(border.style, {
|
|
51
|
+
position: 'absolute',
|
|
52
|
+
top: `${top - spec.borderWidth}px`,
|
|
53
|
+
left: `${left - spec.borderWidth}px`,
|
|
54
|
+
width: `${width + spec.borderWidth * 2}px`,
|
|
55
|
+
height: `${height + spec.borderWidth * 2}px`,
|
|
56
|
+
border: `${spec.borderWidth}px solid ${spec.color}`,
|
|
57
|
+
borderRadius: '3px',
|
|
58
|
+
pointerEvents: 'none',
|
|
59
|
+
zIndex: '99999',
|
|
60
|
+
boxSizing: 'border-box',
|
|
61
|
+
});
|
|
62
|
+
document.documentElement.appendChild(border);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (spec.style === 'arrow' || spec.style === 'both') {
|
|
66
|
+
const arrowSize = 28;
|
|
67
|
+
const arrowX = left - arrowSize - 6;
|
|
68
|
+
const arrowY = top + height / 2 - arrowSize / 2;
|
|
69
|
+
|
|
70
|
+
const arrow = document.createElement('div');
|
|
71
|
+
arrow.id = id + '_arrow';
|
|
72
|
+
injectedIds.push(id + '_arrow');
|
|
73
|
+
// right-pointing triangle via SVG
|
|
74
|
+
arrow.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="${arrowSize}" height="${arrowSize}" viewBox="0 0 24 24">
|
|
75
|
+
<polygon points="4,4 20,12 4,20" fill="${spec.color}"/>
|
|
76
|
+
</svg>`;
|
|
77
|
+
Object.assign(arrow.style, {
|
|
78
|
+
position: 'absolute',
|
|
79
|
+
top: `${arrowY}px`,
|
|
80
|
+
left: `${arrowX}px`,
|
|
81
|
+
pointerEvents: 'none',
|
|
82
|
+
zIndex: '99999',
|
|
83
|
+
});
|
|
84
|
+
document.documentElement.appendChild(arrow);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (spec.label) {
|
|
88
|
+
const label = document.createElement('div');
|
|
89
|
+
label.id = id + '_label';
|
|
90
|
+
injectedIds.push(id + '_label');
|
|
91
|
+
label.textContent = spec.label;
|
|
92
|
+
Object.assign(label.style, {
|
|
93
|
+
position: 'absolute',
|
|
94
|
+
top: `${top - 24}px`,
|
|
95
|
+
left: `${left}px`,
|
|
96
|
+
background: spec.color,
|
|
97
|
+
color: '#fff',
|
|
98
|
+
fontSize: '12px',
|
|
99
|
+
fontFamily: 'sans-serif',
|
|
100
|
+
padding: '2px 6px',
|
|
101
|
+
borderRadius: '3px',
|
|
102
|
+
pointerEvents: 'none',
|
|
103
|
+
zIndex: '99999',
|
|
104
|
+
whiteSpace: 'nowrap',
|
|
105
|
+
});
|
|
106
|
+
document.documentElement.appendChild(label);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return injectedIds;
|
|
111
|
+
}, highlights);
|
|
112
|
+
|
|
113
|
+
return async () => {
|
|
114
|
+
await page.evaluate((injectedIds) => {
|
|
115
|
+
for (const id of injectedIds) {
|
|
116
|
+
const el = document.getElementById(id);
|
|
117
|
+
if (el) el.remove();
|
|
118
|
+
}
|
|
119
|
+
}, ids);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Scroll an element into view before screenshotting.
|
|
125
|
+
* @param {import('playwright').Page} page
|
|
126
|
+
* @param {string} selector
|
|
127
|
+
*/
|
|
128
|
+
export async function scrollIntoView(page, selector) {
|
|
129
|
+
await page.evaluate((sel) => {
|
|
130
|
+
const el = document.querySelector(sel);
|
|
131
|
+
if (el) el.scrollIntoView({ block: 'center' });
|
|
132
|
+
}, selector);
|
|
133
|
+
}
|
package/src/naming.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// derives a filename from a URL path + an optional list of highlight selectors.
|
|
2
|
+
// the goal: human-readable, filesystem-safe, collision-resistant.
|
|
3
|
+
//
|
|
4
|
+
// /admin/group/add + selector #id => admin-group-add-id.png
|
|
5
|
+
// /admin/group + no selector => admin-group.png
|
|
6
|
+
// /admin/group + two calls => admin-group.png, admin-group-1.png
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} url - the URL path, e.g. '/admin/group/add' or 'http://...'
|
|
10
|
+
* @param {Array<{selector?: string}>} highlights - highlight configs
|
|
11
|
+
* @param {Set<string>} used - filenames already claimed this run (mutated by this function)
|
|
12
|
+
* @returns {string} filename without extension
|
|
13
|
+
*/
|
|
14
|
+
export function deriveName(url, highlights = [], used = new Set()) {
|
|
15
|
+
const pathname = extractPath(url);
|
|
16
|
+
const parts = slugifyPath(pathname);
|
|
17
|
+
|
|
18
|
+
const selectorParts = highlights
|
|
19
|
+
.map(h => h.selector)
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.flatMap(sel => slugifySelector(sel))
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
|
|
24
|
+
const base = [...parts, ...selectorParts].filter(Boolean).join('-') || 'screenshot';
|
|
25
|
+
|
|
26
|
+
// resolve collisions by appending -1, -2, ...
|
|
27
|
+
let candidate = base;
|
|
28
|
+
let counter = 0;
|
|
29
|
+
while (used.has(candidate)) {
|
|
30
|
+
counter++;
|
|
31
|
+
candidate = `${base}-${counter}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
used.add(candidate);
|
|
35
|
+
return candidate;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function extractPath(url) {
|
|
39
|
+
try {
|
|
40
|
+
return new URL(url).pathname;
|
|
41
|
+
} catch {
|
|
42
|
+
// not a full URL, treat as path
|
|
43
|
+
return url.split('?')[0].split('#')[0];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function slugifyPath(pathname) {
|
|
48
|
+
return pathname
|
|
49
|
+
.split('/')
|
|
50
|
+
.map(segment => segment.trim())
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.map(segment =>
|
|
53
|
+
segment
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
56
|
+
.replace(/^-+|-+$/g, '')
|
|
57
|
+
)
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function slugifySelector(selector) {
|
|
62
|
+
// turn '.group-name' => ['group-name']
|
|
63
|
+
// turn '#id' => ['id']
|
|
64
|
+
// turn 'input[type="text"]' => ['input-type-text']
|
|
65
|
+
// turn '.foo .bar' => ['foo', 'bar']
|
|
66
|
+
return selector
|
|
67
|
+
.split(/\s+/)
|
|
68
|
+
.map(part =>
|
|
69
|
+
part
|
|
70
|
+
.replace(/^[.#]/, '')
|
|
71
|
+
.replace(/[\[\]"'=]/g, '-')
|
|
72
|
+
.replace(/[^a-z0-9-]+/gi, '-')
|
|
73
|
+
.replace(/-{2,}/g, '-')
|
|
74
|
+
.replace(/^-+|-+$/g, '')
|
|
75
|
+
.toLowerCase()
|
|
76
|
+
)
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// generates a placeholder PNG for a screenshot that has not yet been taken.
|
|
2
|
+
// the placeholder is gray with white centered text showing the filename.
|
|
3
|
+
// sharp is used so the placeholder is a real PNG, not a data URI.
|
|
4
|
+
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {string} opts.name - screenshot name (without extension), shown as label
|
|
10
|
+
* @param {number} [opts.width=1280]
|
|
11
|
+
* @param {number} [opts.height=800]
|
|
12
|
+
* @returns {Promise<Buffer>} PNG buffer
|
|
13
|
+
*/
|
|
14
|
+
export async function generatePlaceholder({ name, width = 1280, height = 800 }) {
|
|
15
|
+
const label = name + '.png';
|
|
16
|
+
const fontSize = Math.min(20, Math.floor(width / 40));
|
|
17
|
+
|
|
18
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|
19
|
+
<rect width="${width}" height="${height}" fill="#e8e8e8"/>
|
|
20
|
+
<line x1="0" y1="0" x2="${width}" y2="${height}" stroke="#ccc" stroke-width="1"/>
|
|
21
|
+
<line x1="${width}" y1="0" x2="0" y2="${height}" stroke="#ccc" stroke-width="1"/>
|
|
22
|
+
<rect x="${width / 2 - 200}" y="${height / 2 - 40}" width="400" height="80" rx="8" fill="white" opacity="0.8"/>
|
|
23
|
+
<text
|
|
24
|
+
x="${width / 2}"
|
|
25
|
+
y="${height / 2 - 6}"
|
|
26
|
+
text-anchor="middle"
|
|
27
|
+
dominant-baseline="middle"
|
|
28
|
+
font-family="sans-serif"
|
|
29
|
+
font-size="${fontSize}"
|
|
30
|
+
fill="#666"
|
|
31
|
+
>Screenshot not generated</text>
|
|
32
|
+
<text
|
|
33
|
+
x="${width / 2}"
|
|
34
|
+
y="${height / 2 + fontSize + 4}"
|
|
35
|
+
text-anchor="middle"
|
|
36
|
+
dominant-baseline="middle"
|
|
37
|
+
font-family="monospace"
|
|
38
|
+
font-size="${Math.max(10, fontSize - 4)}"
|
|
39
|
+
fill="#999"
|
|
40
|
+
>${escapeXml(label)}</text>
|
|
41
|
+
</svg>`;
|
|
42
|
+
|
|
43
|
+
return sharp(Buffer.from(svg)).png().toBuffer();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function escapeXml(str) {
|
|
47
|
+
return str
|
|
48
|
+
.replace(/&/g, '&')
|
|
49
|
+
.replace(/</g, '<')
|
|
50
|
+
.replace(/>/g, '>')
|
|
51
|
+
.replace(/"/g, '"')
|
|
52
|
+
.replace(/'/g, ''');
|
|
53
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { addChrome } from '../src/chrome.mjs';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
|
|
5
|
+
// create a minimal 100x80 solid-color PNG to use as the page content
|
|
6
|
+
async function makeTestImage(width = 100, height = 80) {
|
|
7
|
+
return sharp({
|
|
8
|
+
create: { width, height, channels: 3, background: { r: 200, g: 220, b: 240 } },
|
|
9
|
+
}).png().toBuffer();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('addChrome', () => {
|
|
13
|
+
it('returns a Buffer', async () => {
|
|
14
|
+
const page = await makeTestImage();
|
|
15
|
+
const result = await addChrome(page);
|
|
16
|
+
expect(Buffer.isBuffer(result)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('produces a valid PNG', async () => {
|
|
20
|
+
const page = await makeTestImage();
|
|
21
|
+
const result = await addChrome(page);
|
|
22
|
+
const meta = await sharp(result).metadata();
|
|
23
|
+
expect(meta.format).toBe('png');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('output is taller than the input (chrome was added)', async () => {
|
|
27
|
+
const page = await makeTestImage(100, 80);
|
|
28
|
+
const result = await addChrome(page, { shadowBlur: 0 });
|
|
29
|
+
const meta = await sharp(result).metadata();
|
|
30
|
+
expect(meta.height).toBeGreaterThan(80);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('output is wider with shadow padding', async () => {
|
|
34
|
+
const page = await makeTestImage(100, 80);
|
|
35
|
+
const resultWithShadow = await addChrome(page, { shadowBlur: 20, shadowPadding: 30 });
|
|
36
|
+
const resultNoShadow = await addChrome(page, { shadowBlur: 0 });
|
|
37
|
+
const metaWithShadow = await sharp(resultWithShadow).metadata();
|
|
38
|
+
const metaNoShadow = await sharp(resultNoShadow).metadata();
|
|
39
|
+
expect(metaWithShadow.width).toBeGreaterThan(metaNoShadow.width);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('showUrl: false produces a shorter output than showUrl: true', async () => {
|
|
43
|
+
const page = await makeTestImage(100, 80);
|
|
44
|
+
const withUrl = await addChrome(page, { shadowBlur: 0, showUrl: true });
|
|
45
|
+
const withoutUrl = await addChrome(page, { shadowBlur: 0, showUrl: false });
|
|
46
|
+
const metaWith = await sharp(withUrl).metadata();
|
|
47
|
+
const metaWithout = await sharp(withoutUrl).metadata();
|
|
48
|
+
expect(metaWith.height).toBeGreaterThan(metaWithout.height);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('does not error with a long URL', async () => {
|
|
52
|
+
const page = await makeTestImage();
|
|
53
|
+
const longUrl = 'http://localhost:9011/' + 'a'.repeat(200);
|
|
54
|
+
await expect(addChrome(page, { url: longUrl })).resolves.toBeTruthy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('dark option does not throw', async () => {
|
|
58
|
+
const page = await makeTestImage();
|
|
59
|
+
await expect(addChrome(page, { dark: true })).resolves.toBeTruthy();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { loadConfig } from '../src/config.mjs';
|
|
3
|
+
import { mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
|
|
7
|
+
let tmpDir;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tmpDir = path.join(os.tmpdir(), `config-test-${Math.random().toString(36).slice(2)}`);
|
|
11
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function writeConfig(content) {
|
|
19
|
+
writeFileSync(path.join(tmpDir, 'screenshot.config.mjs'), content);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('loadConfig', () => {
|
|
23
|
+
it('throws when no config file exists', async () => {
|
|
24
|
+
await expect(loadConfig(tmpDir)).rejects.toThrow(/No screenshot.config.mjs/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('loads a minimal valid config (no docker)', async () => {
|
|
28
|
+
writeConfig(`
|
|
29
|
+
export default {
|
|
30
|
+
baseUrl: 'http://localhost:9011',
|
|
31
|
+
};
|
|
32
|
+
`);
|
|
33
|
+
const config = await loadConfig(tmpDir);
|
|
34
|
+
expect(config.baseUrl).toBe('http://localhost:9011');
|
|
35
|
+
expect(config.docker).toBeUndefined();
|
|
36
|
+
// defaults applied
|
|
37
|
+
expect(config.browser).toBe('webkit');
|
|
38
|
+
expect(config.outputDir).toBe('./src/assets/screenshots');
|
|
39
|
+
expect(config.colorScheme).toBe('light');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('loads a config with docker settings', async () => {
|
|
43
|
+
writeConfig(`
|
|
44
|
+
export default {
|
|
45
|
+
baseUrl: 'http://localhost:9011',
|
|
46
|
+
docker: {
|
|
47
|
+
healthcheck: { url: 'http://localhost:9011/api/status' },
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
`);
|
|
51
|
+
const config = await loadConfig(tmpDir);
|
|
52
|
+
expect(config.docker.healthcheck.url).toBe('http://localhost:9011/api/status');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('applies window defaults', async () => {
|
|
56
|
+
writeConfig(`export default { baseUrl: 'http://localhost:9011' };`);
|
|
57
|
+
const config = await loadConfig(tmpDir);
|
|
58
|
+
expect(config.window.width).toBe(1280);
|
|
59
|
+
expect(config.window.height).toBe(800);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('applies chrome defaults', async () => {
|
|
63
|
+
writeConfig(`export default { baseUrl: 'http://localhost:9011' };`);
|
|
64
|
+
const config = await loadConfig(tmpDir);
|
|
65
|
+
expect(config.chrome.style).toBe('safari-macos');
|
|
66
|
+
expect(config.chrome.showUrl).toBe(true);
|
|
67
|
+
expect(config.chrome.shadowBlur).toBe(40);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('accepts a custom outputDir', async () => {
|
|
71
|
+
writeConfig(`
|
|
72
|
+
export default {
|
|
73
|
+
baseUrl: 'http://localhost:9011',
|
|
74
|
+
outputDir: 'public/screenshots',
|
|
75
|
+
};
|
|
76
|
+
`);
|
|
77
|
+
const config = await loadConfig(tmpDir);
|
|
78
|
+
expect(config.outputDir).toBe('public/screenshots');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('rejects an invalid baseUrl', async () => {
|
|
82
|
+
writeConfig(`export default { baseUrl: 'not-a-url' };`);
|
|
83
|
+
await expect(loadConfig(tmpDir)).rejects.toThrow(/invalid/i);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('accepts beforeScreenshot as a function', async () => {
|
|
87
|
+
writeConfig(`
|
|
88
|
+
export default {
|
|
89
|
+
baseUrl: 'http://localhost:9011',
|
|
90
|
+
beforeScreenshot: async (page, spec) => {},
|
|
91
|
+
};
|
|
92
|
+
`);
|
|
93
|
+
const config = await loadConfig(tmpDir);
|
|
94
|
+
expect(typeof config.beforeScreenshot).toBe('function');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { diffScreenshot } from '../src/diff.mjs';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { writeFileSync, mkdirSync, rmSync } from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
|
|
8
|
+
let tmpDir;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = path.join(os.tmpdir(), `diff-test-${Math.random().toString(36).slice(2)}`);
|
|
12
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function makeImage(r, g, b, width = 50, height = 50) {
|
|
20
|
+
return sharp({
|
|
21
|
+
create: { width, height, channels: 3, background: { r, g, b } },
|
|
22
|
+
}).png().toBuffer();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('diffScreenshot', () => {
|
|
26
|
+
it('returns ratio=1 when reference file is missing', async () => {
|
|
27
|
+
const newBuf = await makeImage(200, 200, 200);
|
|
28
|
+
const result = await diffScreenshot('/nonexistent/path.png', newBuf);
|
|
29
|
+
expect(result.pixels).toBe(-1);
|
|
30
|
+
expect(result.ratio).toBe(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns ratio=0 for identical images', async () => {
|
|
34
|
+
const buf = await makeImage(128, 128, 128);
|
|
35
|
+
const refPath = path.join(tmpDir, 'ref.png');
|
|
36
|
+
writeFileSync(refPath, buf);
|
|
37
|
+
|
|
38
|
+
const result = await diffScreenshot(refPath, buf);
|
|
39
|
+
expect(result.ratio).toBe(0);
|
|
40
|
+
expect(result.pixels).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('detects pixel differences between different-colored images', async () => {
|
|
44
|
+
const ref = await makeImage(0, 0, 0);
|
|
45
|
+
const newer = await makeImage(255, 255, 255);
|
|
46
|
+
const refPath = path.join(tmpDir, 'ref.png');
|
|
47
|
+
writeFileSync(refPath, ref);
|
|
48
|
+
|
|
49
|
+
const result = await diffScreenshot(refPath, newer, { threshold: 0.1 });
|
|
50
|
+
expect(result.pixels).toBeGreaterThan(0);
|
|
51
|
+
expect(result.ratio).toBeGreaterThan(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns null diffImage when writeDiff is false', async () => {
|
|
55
|
+
const ref = await makeImage(0, 0, 0);
|
|
56
|
+
const refPath = path.join(tmpDir, 'ref.png');
|
|
57
|
+
writeFileSync(refPath, ref);
|
|
58
|
+
|
|
59
|
+
const result = await diffScreenshot(refPath, ref, { writeDiff: false });
|
|
60
|
+
expect(result.diffImage).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns a diffImage buffer when writeDiff is true and images differ', async () => {
|
|
64
|
+
const ref = await makeImage(0, 0, 0);
|
|
65
|
+
const newer = await makeImage(255, 0, 0);
|
|
66
|
+
const refPath = path.join(tmpDir, 'ref.png');
|
|
67
|
+
writeFileSync(refPath, ref);
|
|
68
|
+
|
|
69
|
+
const result = await diffScreenshot(refPath, newer, { writeDiff: true, threshold: 0.1 });
|
|
70
|
+
expect(Buffer.isBuffer(result.diffImage)).toBe(true);
|
|
71
|
+
// the diff image should be a valid PNG
|
|
72
|
+
const meta = await sharp(result.diffImage).metadata();
|
|
73
|
+
expect(meta.format).toBe('png');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns ratio=1 for images of different dimensions', async () => {
|
|
77
|
+
const ref = await makeImage(0, 0, 0, 100, 100);
|
|
78
|
+
const newer = await makeImage(0, 0, 0, 200, 200);
|
|
79
|
+
const refPath = path.join(tmpDir, 'ref.png');
|
|
80
|
+
writeFileSync(refPath, ref);
|
|
81
|
+
|
|
82
|
+
const result = await diffScreenshot(refPath, newer);
|
|
83
|
+
expect(result.ratio).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { discoverScreenshots } from '../src/discover.mjs';
|
|
3
|
+
import { mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
|
|
7
|
+
let tmpDir;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tmpDir = path.join(os.tmpdir(), `discover-test-${Math.random().toString(36).slice(2)}`);
|
|
11
|
+
mkdirSync(path.join(tmpDir, 'src/content/docs'), { recursive: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function write(filename, content) {
|
|
19
|
+
const fullPath = path.join(tmpDir, filename);
|
|
20
|
+
mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
21
|
+
writeFileSync(fullPath, content);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('discoverScreenshots', () => {
|
|
25
|
+
it('finds a simple Screenshot component', async () => {
|
|
26
|
+
write('src/content/docs/groups.mdx', `
|
|
27
|
+
import Screenshot from 'astro-better-declarative-screenshots/Screenshot.astro';
|
|
28
|
+
|
|
29
|
+
<Screenshot url="/admin/group" />
|
|
30
|
+
`);
|
|
31
|
+
|
|
32
|
+
const specs = await discoverScreenshots(tmpDir);
|
|
33
|
+
expect(specs).toHaveLength(1);
|
|
34
|
+
expect(specs[0].url).toBe('/admin/group');
|
|
35
|
+
expect(specs[0].name).toBe('admin-group');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('finds Screenshot with Highlight children', async () => {
|
|
39
|
+
write('src/content/docs/page.mdx', `
|
|
40
|
+
<Screenshot url="/admin/group">
|
|
41
|
+
<Highlight selector="#name" style="border" color="#f60" label="Name field" />
|
|
42
|
+
</Screenshot>
|
|
43
|
+
`);
|
|
44
|
+
const specs = await discoverScreenshots(tmpDir);
|
|
45
|
+
expect(specs).toHaveLength(1);
|
|
46
|
+
expect(specs[0].highlights).toHaveLength(1);
|
|
47
|
+
expect(specs[0].highlights[0].selector).toBe('#name');
|
|
48
|
+
expect(specs[0].highlights[0].color).toBe('#f60');
|
|
49
|
+
expect(specs[0].name).toBe('admin-group-name');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('finds multiple Screenshot components in one file', async () => {
|
|
53
|
+
write('src/content/docs/page.mdx', `
|
|
54
|
+
<Screenshot url="/admin/group" />
|
|
55
|
+
<Screenshot url="/admin/user" />
|
|
56
|
+
`);
|
|
57
|
+
const specs = await discoverScreenshots(tmpDir);
|
|
58
|
+
expect(specs).toHaveLength(2);
|
|
59
|
+
expect(specs[0].url).toBe('/admin/group');
|
|
60
|
+
expect(specs[1].url).toBe('/admin/user');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('respects an explicit id prop', async () => {
|
|
64
|
+
write('src/content/docs/page.mdx', `
|
|
65
|
+
<Screenshot url="/admin/group" id="my-custom-name" />
|
|
66
|
+
`);
|
|
67
|
+
const specs = await discoverScreenshots(tmpDir);
|
|
68
|
+
expect(specs[0].name).toBe('my-custom-name');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('extracts width/height/fullPage props', async () => {
|
|
72
|
+
write('src/content/docs/page.mdx', `
|
|
73
|
+
<Screenshot url="/admin" width={1440} height={900} fullPage={true} />
|
|
74
|
+
`);
|
|
75
|
+
const specs = await discoverScreenshots(tmpDir);
|
|
76
|
+
expect(specs[0].width).toBe(1440);
|
|
77
|
+
expect(specs[0].height).toBe(900);
|
|
78
|
+
expect(specs[0].fullPage).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('handles same URL with different highlights -- no collision', async () => {
|
|
82
|
+
write('src/content/docs/page.mdx', `
|
|
83
|
+
<Screenshot url="/admin/group">
|
|
84
|
+
<Highlight selector="#name" />
|
|
85
|
+
</Screenshot>
|
|
86
|
+
<Screenshot url="/admin/group">
|
|
87
|
+
<Highlight selector="#id" />
|
|
88
|
+
</Screenshot>
|
|
89
|
+
`);
|
|
90
|
+
const specs = await discoverScreenshots(tmpDir);
|
|
91
|
+
expect(specs).toHaveLength(2);
|
|
92
|
+
expect(specs[0].name).toBe('admin-group-name');
|
|
93
|
+
expect(specs[1].name).toBe('admin-group-id');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('resolves same URL + same highlights collision with numeric suffix', async () => {
|
|
97
|
+
write('src/content/docs/page.mdx', `
|
|
98
|
+
<Screenshot url="/admin/group" />
|
|
99
|
+
<Screenshot url="/admin/group" />
|
|
100
|
+
`);
|
|
101
|
+
const specs = await discoverScreenshots(tmpDir);
|
|
102
|
+
expect(specs[0].name).toBe('admin-group');
|
|
103
|
+
expect(specs[1].name).toBe('admin-group-1');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('returns empty array when no Screenshot components found', async () => {
|
|
107
|
+
write('src/content/docs/page.mdx', 'Just some markdown content.\n');
|
|
108
|
+
const specs = await discoverScreenshots(tmpDir);
|
|
109
|
+
expect(specs).toHaveLength(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('records the source file path', async () => {
|
|
113
|
+
const filePath = 'src/content/docs/groups.mdx';
|
|
114
|
+
write(filePath, '<Screenshot url="/admin" />');
|
|
115
|
+
const specs = await discoverScreenshots(tmpDir);
|
|
116
|
+
expect(specs[0].sourceFile).toBe(path.join(tmpDir, filePath));
|
|
117
|
+
});
|
|
118
|
+
});
|