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,72 @@
|
|
|
1
|
+
// example screenshot.config.mjs for the FusionAuth docs site
|
|
2
|
+
// place at your Astro project root
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
// base URL of the running app (local or in Docker)
|
|
6
|
+
baseUrl: 'http://localhost:9011',
|
|
7
|
+
|
|
8
|
+
// where to write screenshots; relative to project root
|
|
9
|
+
outputDir: 'public/screenshots',
|
|
10
|
+
|
|
11
|
+
// default viewport
|
|
12
|
+
window: { width: 1280, height: 800 },
|
|
13
|
+
|
|
14
|
+
// Playwright browser engine
|
|
15
|
+
browser: 'webkit',
|
|
16
|
+
|
|
17
|
+
// macOS Safari-style chrome with drop shadow
|
|
18
|
+
chrome: {
|
|
19
|
+
style: 'safari-macos',
|
|
20
|
+
showUrl: true,
|
|
21
|
+
dark: false,
|
|
22
|
+
shadowBlur: 40,
|
|
23
|
+
shadowPadding: 48,
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
// docker setup -- start the app and wait for it to be healthy
|
|
27
|
+
docker: {
|
|
28
|
+
compose: 'screenshots/docker-compose.yml',
|
|
29
|
+
service: 'fusionauth',
|
|
30
|
+
|
|
31
|
+
// bootstrap seeds initial data. the file path is passed to docker-compose.yml
|
|
32
|
+
// as SCREENSHOT_BOOTSTRAP_PATH and its content as SCREENSHOT_BOOTSTRAP_CONTENT.
|
|
33
|
+
// wire those env vars into your container however it needs.
|
|
34
|
+
// for FusionAuth: FUSIONAUTH_APP_KICKSTART_FILE: ${SCREENSHOT_BOOTSTRAP_PATH}
|
|
35
|
+
// for other apps: mount the path as a volume or use the content as a seed script
|
|
36
|
+
bootstrap: 'screenshots/kickstart/kickstart.json',
|
|
37
|
+
|
|
38
|
+
healthcheck: {
|
|
39
|
+
url: 'http://localhost:9011/api/status',
|
|
40
|
+
timeout: 120000,
|
|
41
|
+
interval: 3000,
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// optional shell command to run after health check passes.
|
|
45
|
+
// runs in the project root. use for migrations, extra seeding, etc.
|
|
46
|
+
// postStart: 'node scripts/extra-seed.mjs',
|
|
47
|
+
|
|
48
|
+
env: {
|
|
49
|
+
DATABASE_PASSWORD: 'change-in-production',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
// called once before any screenshot is taken -- log in, set up app state, etc.
|
|
54
|
+
beforeAll: async (context) => {
|
|
55
|
+
const page = await context.newPage();
|
|
56
|
+
await page.goto('http://localhost:9011/admin/login');
|
|
57
|
+
await page.fill('#loginId', 'admin@example.com');
|
|
58
|
+
await page.fill('#password', 'password');
|
|
59
|
+
await page.click('[type=submit]');
|
|
60
|
+
await page.waitForURL('**/admin/**');
|
|
61
|
+
await page.close();
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
// called before each individual screenshot -- navigate or set up state
|
|
65
|
+
beforeScreenshot: async (page, { url, name }) => {
|
|
66
|
+
// page is already navigated to url by this point
|
|
67
|
+
// close any open modals, dismiss notifications, etc.
|
|
68
|
+
await page.evaluate(() => {
|
|
69
|
+
document.querySelectorAll('.notification, .toast').forEach(el => el.remove());
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
};
|
package/index.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Astro integration entry point.
|
|
2
|
+
// usage in astro.config.mjs:
|
|
3
|
+
// import screenshots from 'astro-better-declarative-screenshots';
|
|
4
|
+
// export default defineConfig({ integrations: [screenshots()] });
|
|
5
|
+
|
|
6
|
+
import { loadConfig } from './src/config.mjs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {Partial<import('./src/config.mjs').ConfigSchema['_type']>} [integrationConfig]
|
|
11
|
+
* @returns {import('astro').AstroIntegration}
|
|
12
|
+
*/
|
|
13
|
+
export default function screenshotsIntegration(integrationConfig = {}) {
|
|
14
|
+
let resolvedConfig;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
name: 'astro-better-declarative-screenshots',
|
|
18
|
+
|
|
19
|
+
hooks: {
|
|
20
|
+
'astro:config:setup': async ({ config: astroConfig, addWatchFile, logger }) => {
|
|
21
|
+
const projectRoot = astroConfig.root
|
|
22
|
+
? new URL(astroConfig.root).pathname
|
|
23
|
+
: process.cwd();
|
|
24
|
+
|
|
25
|
+
// load and validate the screenshot.config.mjs
|
|
26
|
+
try {
|
|
27
|
+
resolvedConfig = await loadConfig(projectRoot);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
if (integrationConfig.strict === false) {
|
|
30
|
+
logger.warn(`[astro-better-declarative-screenshots] ${e.message}`);
|
|
31
|
+
resolvedConfig = null;
|
|
32
|
+
} else {
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// watch the config file so the dev server restarts when it changes
|
|
38
|
+
const configFile = path.join(projectRoot, 'screenshot.config.mjs');
|
|
39
|
+
addWatchFile(configFile);
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
'astro:build:done': async ({ logger }) => {
|
|
43
|
+
if (!resolvedConfig) return;
|
|
44
|
+
logger.info(
|
|
45
|
+
'[astro-better-declarative-screenshots] build done. ' +
|
|
46
|
+
'Run `take-screenshots` to update screenshots.'
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// re-export utilities consumers may want
|
|
54
|
+
export { loadConfig } from './src/config.mjs';
|
|
55
|
+
export { discoverScreenshots } from './src/discover.mjs';
|
|
56
|
+
export { deriveName } from './src/naming.mjs';
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "astro-better-declarative-screenshots",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Declarative, Docker-backed screenshots for Astro docs. Define the page and the elements to highlight, and this produces a screenshot.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.mjs",
|
|
8
|
+
"./Screenshot.astro": "./Screenshot.astro",
|
|
9
|
+
"./Highlight.astro": "./Highlight.astro"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"take-screenshots": "./scripts/take-screenshots.mjs",
|
|
13
|
+
"check-screenshots": "./scripts/check-screenshots.mjs"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"astro": ">=4.0.0"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"playwright": "^1.45.0",
|
|
24
|
+
"sharp": "^0.33.0",
|
|
25
|
+
"pixelmatch": "^6.0.0",
|
|
26
|
+
"pngjs": "^7.0.0",
|
|
27
|
+
"glob": "^11.0.0",
|
|
28
|
+
"zod": "^3.23.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"vitest": "^2.0.0",
|
|
32
|
+
"astro": "^4.0.0"
|
|
33
|
+
},
|
|
34
|
+
"keywords": ["astro", "screenshots", "declarative", "docs", "playwright", "integration"],
|
|
35
|
+
"license": "MIT"
|
|
36
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI: check-screenshots
|
|
3
|
+
// captures all screenshots and diffs them against the committed references.
|
|
4
|
+
// exits non-zero if any screenshot exceeds the diff threshold.
|
|
5
|
+
// designed for CI -- does NOT overwrite the committed files.
|
|
6
|
+
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
9
|
+
import { loadConfig } from '../src/config.mjs';
|
|
10
|
+
import { discoverScreenshots } from '../src/discover.mjs';
|
|
11
|
+
import { startDocker, stopDocker } from '../src/docker.mjs';
|
|
12
|
+
import { capturePage, createBrowserContext } from '../src/capture.mjs';
|
|
13
|
+
import { addChrome } from '../src/chrome.mjs';
|
|
14
|
+
import { diffScreenshot } from '../src/diff.mjs';
|
|
15
|
+
|
|
16
|
+
const projectRoot = process.cwd();
|
|
17
|
+
|
|
18
|
+
// default: 0.1% of pixels may differ before it's a failure
|
|
19
|
+
const DEFAULT_THRESHOLD_RATIO = 0.001;
|
|
20
|
+
|
|
21
|
+
async function main() {
|
|
22
|
+
const args = parseArgs(process.argv.slice(2));
|
|
23
|
+
const config = await loadConfig(projectRoot).catch(e => {
|
|
24
|
+
console.error(e.message);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const outputDir = path.resolve(projectRoot, config.outputDir);
|
|
29
|
+
const diffDir = path.resolve(projectRoot, args.diffDir ?? '.screenshot-diffs');
|
|
30
|
+
mkdirSync(diffDir, { recursive: true });
|
|
31
|
+
|
|
32
|
+
const specs = await discoverScreenshots(projectRoot);
|
|
33
|
+
|
|
34
|
+
if (specs.length === 0) {
|
|
35
|
+
console.log('[check-screenshots] no Screenshot components found.');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const filter = args.filter;
|
|
40
|
+
const toCheck = filter
|
|
41
|
+
? specs.filter(s => s.name.includes(filter) || s.url.includes(filter))
|
|
42
|
+
: specs;
|
|
43
|
+
|
|
44
|
+
console.log(`[check-screenshots] checking ${toCheck.length} screenshots...`);
|
|
45
|
+
|
|
46
|
+
if (config.docker) {
|
|
47
|
+
await startDocker(config.docker, projectRoot).catch(e => {
|
|
48
|
+
console.error('[check-screenshots] docker start failed:', e.message);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// create one browser context shared across beforeAll and all captures
|
|
54
|
+
const { browser, context } = await createBrowserContext({
|
|
55
|
+
browser: config.browser,
|
|
56
|
+
width: config.window.width,
|
|
57
|
+
height: config.window.height,
|
|
58
|
+
colorScheme: config.colorScheme,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (config.beforeAll) {
|
|
62
|
+
await config.beforeAll(context).catch(e => {
|
|
63
|
+
console.error('[check-screenshots] beforeAll hook failed:', e.message);
|
|
64
|
+
browser.close();
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const failures = [];
|
|
70
|
+
const missing = [];
|
|
71
|
+
let passed = 0;
|
|
72
|
+
|
|
73
|
+
for (const spec of toCheck) {
|
|
74
|
+
const fullUrl = spec.url.startsWith('http')
|
|
75
|
+
? spec.url
|
|
76
|
+
: `${config.baseUrl.replace(/\/$/, '')}${spec.url}`;
|
|
77
|
+
|
|
78
|
+
const referencePath = path.join(outputDir, `${spec.name}.png`);
|
|
79
|
+
process.stdout.write(` ${spec.name} ... `);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const rawBuffer = await capturePage({
|
|
83
|
+
url: fullUrl,
|
|
84
|
+
name: spec.name,
|
|
85
|
+
highlights: spec.highlights,
|
|
86
|
+
width: spec.width ?? config.window.width,
|
|
87
|
+
height: spec.height ?? config.window.height,
|
|
88
|
+
fullPage: spec.fullPage,
|
|
89
|
+
browser: config.browser,
|
|
90
|
+
colorScheme: config.colorScheme,
|
|
91
|
+
beforeScreenshot: config.beforeScreenshot,
|
|
92
|
+
context,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
let newBuffer = rawBuffer;
|
|
96
|
+
if (config.chrome.style !== 'none') {
|
|
97
|
+
newBuffer = await addChrome(rawBuffer, {
|
|
98
|
+
url: fullUrl,
|
|
99
|
+
dark: config.chrome.dark,
|
|
100
|
+
showUrl: config.chrome.showUrl,
|
|
101
|
+
shadowBlur: config.chrome.shadowBlur,
|
|
102
|
+
shadowPadding: config.chrome.shadowPadding,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const result = await diffScreenshot(referencePath, newBuffer, {
|
|
107
|
+
threshold: args.pixelThreshold ?? 0.1,
|
|
108
|
+
writeDiff: true,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (result.pixels === -1) {
|
|
112
|
+
// reference file was missing
|
|
113
|
+
process.stdout.write('MISSING\n');
|
|
114
|
+
missing.push(spec.name);
|
|
115
|
+
// save the new capture as the diff artifact
|
|
116
|
+
writeFileSync(path.join(diffDir, `${spec.name}.new.png`), newBuffer);
|
|
117
|
+
} else if (result.ratio > (args.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO)) {
|
|
118
|
+
process.stdout.write(`CHANGED (${(result.ratio * 100).toFixed(2)}% pixels differ)\n`);
|
|
119
|
+
failures.push({ name: spec.name, ratio: result.ratio, pixels: result.pixels });
|
|
120
|
+
if (result.diffImage) {
|
|
121
|
+
writeFileSync(path.join(diffDir, `${spec.name}.diff.png`), result.diffImage);
|
|
122
|
+
}
|
|
123
|
+
writeFileSync(path.join(diffDir, `${spec.name}.new.png`), newBuffer);
|
|
124
|
+
} else {
|
|
125
|
+
process.stdout.write('ok\n');
|
|
126
|
+
passed++;
|
|
127
|
+
}
|
|
128
|
+
} catch (e) {
|
|
129
|
+
process.stdout.write(`ERROR: ${e.message}\n`);
|
|
130
|
+
failures.push({ name: spec.name, ratio: 1, pixels: -1, error: e.message });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (config.afterAll) {
|
|
135
|
+
await config.afterAll().catch(e => console.warn('[check-screenshots] afterAll hook failed:', e.message));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await browser.close();
|
|
139
|
+
|
|
140
|
+
if (config.docker) {
|
|
141
|
+
stopDocker(config.docker, projectRoot);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
console.log(`\n[check-screenshots] ${passed} passed, ${failures.length} changed, ${missing.length} missing`);
|
|
145
|
+
|
|
146
|
+
if (missing.length > 0) {
|
|
147
|
+
console.log('\nMissing screenshots (run take-screenshots to generate):');
|
|
148
|
+
for (const name of missing) console.log(` - ${name}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (failures.length > 0) {
|
|
152
|
+
console.log('\nChanged screenshots (review diffs in ' + args.diffDir + '):');
|
|
153
|
+
for (const f of failures) {
|
|
154
|
+
if (f.error) console.log(` - ${f.name}: error (${f.error})`);
|
|
155
|
+
else console.log(` - ${f.name}: ${(f.ratio * 100).toFixed(2)}% pixels changed`);
|
|
156
|
+
}
|
|
157
|
+
console.log('\nIf changes are intentional, run take-screenshots to update the reference files.');
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (missing.length > 0 && args.failOnMissing) {
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseArgs(argv) {
|
|
167
|
+
const args = {
|
|
168
|
+
filter: null,
|
|
169
|
+
diffDir: '.screenshot-diffs',
|
|
170
|
+
thresholdRatio: DEFAULT_THRESHOLD_RATIO,
|
|
171
|
+
pixelThreshold: 0.1,
|
|
172
|
+
failOnMissing: false,
|
|
173
|
+
};
|
|
174
|
+
for (let i = 0; i < argv.length; i++) {
|
|
175
|
+
if (argv[i] === '--fail-on-missing') args.failOnMissing = true;
|
|
176
|
+
if (argv[i] === '--filter' && argv[i + 1]) args.filter = argv[++i];
|
|
177
|
+
if (argv[i].startsWith('--filter=')) args.filter = argv[i].slice(9);
|
|
178
|
+
if (argv[i].startsWith('--diff-dir=')) args.diffDir = argv[i].slice(11);
|
|
179
|
+
if (argv[i] === '--diff-dir' && argv[i + 1]) args.diffDir = argv[++i];
|
|
180
|
+
if (argv[i].startsWith('--threshold=')) args.thresholdRatio = parseFloat(argv[i].slice(12));
|
|
181
|
+
}
|
|
182
|
+
return args;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
main().catch(e => {
|
|
186
|
+
console.error('[check-screenshots] fatal:', e);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI: take-screenshots
|
|
3
|
+
// discovers Screenshot components in the project, starts Docker, captures
|
|
4
|
+
// each page via Playwright, composites chrome, and writes PNGs to outputDir.
|
|
5
|
+
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
8
|
+
import { loadConfig } from '../src/config.mjs';
|
|
9
|
+
import { discoverScreenshots } from '../src/discover.mjs';
|
|
10
|
+
import { startDocker, stopDocker } from '../src/docker.mjs';
|
|
11
|
+
import { capturePage, createBrowserContext } from '../src/capture.mjs';
|
|
12
|
+
import { addChrome } from '../src/chrome.mjs';
|
|
13
|
+
import { generatePlaceholder } from '../src/placeholder.mjs';
|
|
14
|
+
|
|
15
|
+
const projectRoot = process.cwd();
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
const args = parseArgs(process.argv.slice(2));
|
|
19
|
+
const config = await loadConfig(projectRoot).catch(e => {
|
|
20
|
+
console.error(e.message);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const outputDir = path.resolve(projectRoot, config.outputDir);
|
|
25
|
+
mkdirSync(outputDir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
// discover all Screenshot components in the source
|
|
28
|
+
console.log(`[screenshots] scanning ${projectRoot} for Screenshot components...`);
|
|
29
|
+
const specs = await discoverScreenshots(projectRoot);
|
|
30
|
+
|
|
31
|
+
if (specs.length === 0) {
|
|
32
|
+
console.log('[screenshots] no Screenshot components found.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const filter = args.filter;
|
|
37
|
+
const toCapture = filter
|
|
38
|
+
? specs.filter(s => s.name.includes(filter) || s.url.includes(filter))
|
|
39
|
+
: specs;
|
|
40
|
+
|
|
41
|
+
console.log(`[screenshots] found ${specs.length} screenshots, capturing ${toCapture.length}`);
|
|
42
|
+
|
|
43
|
+
// start Docker if configured
|
|
44
|
+
if (config.docker) {
|
|
45
|
+
await startDocker(config.docker, projectRoot).catch(e => {
|
|
46
|
+
console.error('[screenshots] docker start failed:', e.message);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// create one browser context shared across beforeAll and all captures so
|
|
52
|
+
// session cookies from login are preserved throughout
|
|
53
|
+
const { browser, context } = await createBrowserContext({
|
|
54
|
+
browser: config.browser,
|
|
55
|
+
width: config.window.width,
|
|
56
|
+
height: config.window.height,
|
|
57
|
+
colorScheme: config.colorScheme,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (config.beforeAll) {
|
|
61
|
+
console.log('[screenshots] running beforeAll hook');
|
|
62
|
+
await config.beforeAll(context).catch(e => {
|
|
63
|
+
console.error('[screenshots] beforeAll hook failed:', e.message);
|
|
64
|
+
browser.close();
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const errors = [];
|
|
70
|
+
let captured = 0;
|
|
71
|
+
|
|
72
|
+
for (const spec of toCapture) {
|
|
73
|
+
const fullUrl = spec.url.startsWith('http')
|
|
74
|
+
? spec.url
|
|
75
|
+
: `${config.baseUrl.replace(/\/$/, '')}${spec.url}`;
|
|
76
|
+
|
|
77
|
+
const outputPath = path.join(outputDir, `${spec.name}.png`);
|
|
78
|
+
process.stdout.write(` [${++captured}/${toCapture.length}] ${spec.name} ... `);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const rawBuffer = await capturePage({
|
|
82
|
+
url: fullUrl,
|
|
83
|
+
name: spec.name,
|
|
84
|
+
highlights: spec.highlights,
|
|
85
|
+
width: spec.width ?? config.window.width,
|
|
86
|
+
height: spec.height ?? config.window.height,
|
|
87
|
+
fullPage: spec.fullPage,
|
|
88
|
+
browser: config.browser,
|
|
89
|
+
colorScheme: config.colorScheme,
|
|
90
|
+
beforeScreenshot: config.beforeScreenshot,
|
|
91
|
+
context,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
let finalBuffer = rawBuffer;
|
|
95
|
+
|
|
96
|
+
if (config.chrome.style !== 'none') {
|
|
97
|
+
finalBuffer = await addChrome(rawBuffer, {
|
|
98
|
+
url: fullUrl,
|
|
99
|
+
dark: config.chrome.dark,
|
|
100
|
+
showUrl: config.chrome.showUrl,
|
|
101
|
+
shadowBlur: config.chrome.shadowBlur,
|
|
102
|
+
shadowPadding: config.chrome.shadowPadding,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
writeFileSync(outputPath, finalBuffer);
|
|
107
|
+
process.stdout.write('done\n');
|
|
108
|
+
} catch (e) {
|
|
109
|
+
process.stdout.write(`FAILED: ${e.message}\n`);
|
|
110
|
+
errors.push({ spec, error: e });
|
|
111
|
+
|
|
112
|
+
if (args.strict || config.strict) {
|
|
113
|
+
// write a placeholder so the build doesn't break
|
|
114
|
+
try {
|
|
115
|
+
const placeholder = await generatePlaceholder({
|
|
116
|
+
name: spec.name,
|
|
117
|
+
width: spec.width ?? config.window.width,
|
|
118
|
+
height: spec.height ?? config.window.height,
|
|
119
|
+
});
|
|
120
|
+
writeFileSync(outputPath, placeholder);
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (config.afterAll) {
|
|
127
|
+
await config.afterAll().catch(e => {
|
|
128
|
+
console.warn('[screenshots] afterAll hook failed:', e.message);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await browser.close();
|
|
133
|
+
|
|
134
|
+
if (config.docker) {
|
|
135
|
+
stopDocker(config.docker, projectRoot);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (errors.length > 0) {
|
|
139
|
+
console.error(`\n[screenshots] ${errors.length} screenshot(s) failed:`);
|
|
140
|
+
for (const { spec, error } of errors) {
|
|
141
|
+
console.error(` - ${spec.name}: ${error.message}`);
|
|
142
|
+
}
|
|
143
|
+
if (args.strict) process.exit(1);
|
|
144
|
+
} else {
|
|
145
|
+
console.log(`\n[screenshots] all ${captured} screenshots saved to ${config.outputDir}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseArgs(argv) {
|
|
150
|
+
const args = { strict: false, filter: null };
|
|
151
|
+
for (let i = 0; i < argv.length; i++) {
|
|
152
|
+
if (argv[i] === '--strict') args.strict = true;
|
|
153
|
+
if (argv[i] === '--filter' && argv[i + 1]) args.filter = argv[++i];
|
|
154
|
+
if (argv[i].startsWith('--filter=')) args.filter = argv[i].slice(9);
|
|
155
|
+
}
|
|
156
|
+
return args;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
main().catch(e => {
|
|
160
|
+
console.error('[screenshots] fatal:', e);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
});
|
package/src/capture.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Playwright-based page capture. opens the page, runs beforeScreenshot hooks,
|
|
2
|
+
// injects highlights, takes the screenshot, then cleans up.
|
|
3
|
+
|
|
4
|
+
import { chromium, firefox, webkit } from 'playwright';
|
|
5
|
+
import { injectHighlights, scrollIntoView } from './highlight.mjs';
|
|
6
|
+
|
|
7
|
+
const BROWSERS = { webkit, chromium, firefox };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} CaptureOptions
|
|
11
|
+
* @property {string} url - full URL including host
|
|
12
|
+
* @property {string} name - screenshot name (for logging)
|
|
13
|
+
* @property {Array<import('./highlight.mjs').HighlightSpec>} highlights
|
|
14
|
+
* @property {number} [width=1280]
|
|
15
|
+
* @property {number} [height=800]
|
|
16
|
+
* @property {boolean} [fullPage=false]
|
|
17
|
+
* @property {'webkit'|'chromium'|'firefox'} [browser='webkit']
|
|
18
|
+
* @property {'light'|'dark'} [colorScheme='light']
|
|
19
|
+
* @property {Function} [beforeScreenshot]
|
|
20
|
+
* @property {import('playwright').BrowserContext} [context] - reuse an existing context
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Capture a single screenshot and return the PNG buffer.
|
|
25
|
+
*
|
|
26
|
+
* @param {CaptureOptions} opts
|
|
27
|
+
* @returns {Promise<Buffer>}
|
|
28
|
+
*/
|
|
29
|
+
export async function capturePage(opts) {
|
|
30
|
+
const {
|
|
31
|
+
url,
|
|
32
|
+
name,
|
|
33
|
+
highlights = [],
|
|
34
|
+
width = 1280,
|
|
35
|
+
height = 800,
|
|
36
|
+
fullPage = false,
|
|
37
|
+
browser: browserName = 'webkit',
|
|
38
|
+
colorScheme = 'light',
|
|
39
|
+
beforeScreenshot,
|
|
40
|
+
context: existingContext,
|
|
41
|
+
} = opts;
|
|
42
|
+
|
|
43
|
+
let browser;
|
|
44
|
+
let context = existingContext;
|
|
45
|
+
let page;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
if (!context) {
|
|
49
|
+
browser = await BROWSERS[browserName].launch({ headless: true });
|
|
50
|
+
context = await browser.newContext({
|
|
51
|
+
viewport: { width, height },
|
|
52
|
+
colorScheme,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
page = await context.newPage();
|
|
57
|
+
await page.setViewportSize({ width, height });
|
|
58
|
+
|
|
59
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
60
|
+
|
|
61
|
+
if (beforeScreenshot) {
|
|
62
|
+
await beforeScreenshot(page, { url, name, highlights });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// if there are highlights, scroll the first target into view
|
|
66
|
+
if (highlights.length > 0 && highlights[0].selector) {
|
|
67
|
+
await scrollIntoView(page, highlights[0].selector).catch(() => {});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const cleanup = await injectHighlights(page, highlights);
|
|
71
|
+
|
|
72
|
+
// small pause to let any CSS transitions finish after highlight injection
|
|
73
|
+
await page.waitForTimeout(150);
|
|
74
|
+
|
|
75
|
+
const buffer = await page.screenshot({
|
|
76
|
+
type: 'png',
|
|
77
|
+
fullPage,
|
|
78
|
+
animations: 'disabled',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await cleanup();
|
|
82
|
+
await page.close();
|
|
83
|
+
|
|
84
|
+
return buffer;
|
|
85
|
+
} finally {
|
|
86
|
+
if (browser) await browser.close().catch(() => {});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a persistent browser context for taking multiple screenshots.
|
|
92
|
+
* Caller is responsible for calling context.close() when done.
|
|
93
|
+
*
|
|
94
|
+
* @param {object} opts
|
|
95
|
+
* @param {'webkit'|'chromium'|'firefox'} [opts.browser='webkit']
|
|
96
|
+
* @param {number} [opts.width=1280]
|
|
97
|
+
* @param {number} [opts.height=800]
|
|
98
|
+
* @param {'light'|'dark'} [opts.colorScheme='light']
|
|
99
|
+
* @returns {Promise<{browser: import('playwright').Browser, context: import('playwright').BrowserContext}>}
|
|
100
|
+
*/
|
|
101
|
+
export async function createBrowserContext({ browser: browserName = 'webkit', width = 1280, height = 800, colorScheme = 'light' } = {}) {
|
|
102
|
+
const browser = await BROWSERS[browserName].launch({ headless: true });
|
|
103
|
+
const context = await browser.newContext({
|
|
104
|
+
viewport: { width, height },
|
|
105
|
+
colorScheme,
|
|
106
|
+
});
|
|
107
|
+
return { browser, context };
|
|
108
|
+
}
|