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.
@@ -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
+ });
@@ -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
+ }