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/src/chrome.mjs ADDED
@@ -0,0 +1,153 @@
1
+ // composes a macOS Safari-style window chrome around a screenshot buffer.
2
+ // everything is generated programmatically via sharp + SVG -- no PNG templates.
3
+
4
+ import sharp from 'sharp';
5
+
6
+ const CHROME_HEIGHT = 52;
7
+ const TL_CY = 26;
8
+ const TL_X0 = 18;
9
+ const TL_R = 7;
10
+ const TL_GAP = 9;
11
+ const URL_BAR_H = 28;
12
+ const URL_BAR_Y = (CHROME_HEIGHT - URL_BAR_H) / 2; // vertically centered
13
+ const URL_BAR_X = 100;
14
+ const URL_BAR_X_MARGIN_R = 20;
15
+ const URL_BAR_RADIUS = 5;
16
+
17
+ export async function addChrome(screenshotBuffer, opts = {}) {
18
+ const {
19
+ url = '',
20
+ dark = false,
21
+ showUrl = true,
22
+ shadowBlur = 40,
23
+ shadowPadding = 48,
24
+ } = opts;
25
+
26
+ const pageImg = sharp(screenshotBuffer);
27
+ const { width: pageWidth, height: pageHeight } = await pageImg.metadata();
28
+
29
+ const chromeHeight = showUrl ? CHROME_HEIGHT : Math.floor(CHROME_HEIGHT * 0.6);
30
+ const totalWidth = pageWidth + (shadowBlur > 0 ? shadowPadding * 2 : 0);
31
+ const totalHeight = pageHeight + chromeHeight + (shadowBlur > 0 ? shadowPadding * 2 : 0);
32
+
33
+ const windowX = shadowBlur > 0 ? shadowPadding : 0;
34
+ const windowY = shadowBlur > 0 ? shadowPadding : 0;
35
+ const windowWidth = pageWidth;
36
+ const windowHeight = pageHeight + chromeHeight;
37
+
38
+ const chromeSvg = buildChromeSvg({ windowWidth, chromeHeight, dark, showUrl, url });
39
+
40
+ const chromeBuf = await sharp(Buffer.from(chromeSvg))
41
+ .resize(windowWidth, chromeHeight)
42
+ .png()
43
+ .toBuffer();
44
+
45
+ const layers = [];
46
+
47
+ if (shadowBlur > 0) {
48
+ const shadowSvg = buildShadowSvg({
49
+ totalWidth, totalHeight, windowX, windowY,
50
+ windowWidth, windowHeight, shadowBlur,
51
+ });
52
+ const shadowBuf = await sharp(Buffer.from(shadowSvg))
53
+ .resize(totalWidth, totalHeight)
54
+ .png()
55
+ .toBuffer();
56
+ layers.push({ input: shadowBuf, top: 0, left: 0 });
57
+ }
58
+
59
+ layers.push({ input: screenshotBuffer, top: windowY + chromeHeight, left: windowX });
60
+ layers.push({ input: chromeBuf, top: windowY, left: windowX });
61
+
62
+ const base = {
63
+ create: {
64
+ width: totalWidth,
65
+ height: totalHeight,
66
+ channels: 4,
67
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
68
+ },
69
+ };
70
+
71
+ return sharp(base).composite(layers).png().toBuffer();
72
+ }
73
+
74
+ function buildChromeSvg({ windowWidth, chromeHeight, dark, showUrl, url }) {
75
+ const bg = dark ? '#323232' : '#e8e8e8';
76
+ const bgBottom = dark ? '#2c2c2c' : '#dcdcdc';
77
+ const urlBarBg = dark ? '#454547' : '#ffffff';
78
+ const urlBorder = dark ? '#5a5a5c' : '#cacaca';
79
+ const urlText = dark ? '#f0f0f0' : '#111111';
80
+ const separator = dark ? '#1a1a1a' : '#b8b8b8';
81
+
82
+ const tlColors = ['#ff5f57', '#febc2e', '#28c840'];
83
+ const lights = tlColors.map((color, i) => {
84
+ const cx = TL_X0 + i * (TL_R * 2 + TL_GAP);
85
+ return `<circle cx="${cx}" cy="${TL_CY}" r="${TL_R}" fill="${color}"/>`;
86
+ }).join('\n ');
87
+
88
+ let urlBar = '';
89
+ if (showUrl) {
90
+ const barX = URL_BAR_X;
91
+ const barY = URL_BAR_Y;
92
+ const barW = windowWidth - URL_BAR_X - URL_BAR_X_MARGIN_R;
93
+ const displayUrl = url.length > 90 ? url.slice(0, 87) + '...' : url;
94
+ urlBar = `
95
+ <rect x="${barX}" y="${barY}" width="${barW}" height="${URL_BAR_H}"
96
+ rx="${URL_BAR_RADIUS}" fill="${urlBarBg}" stroke="${urlBorder}" stroke-width="1"/>
97
+ <text
98
+ x="${barX + barW / 2}"
99
+ y="${barY + URL_BAR_H / 2 + 5}"
100
+ text-anchor="middle"
101
+ font-family="Arial, sans-serif"
102
+ font-size="13"
103
+ fill="${urlText}"
104
+ >${escapeXml(displayUrl)}</text>`;
105
+ }
106
+
107
+ // gradient: slightly lighter top, slightly darker bottom
108
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${windowWidth}" height="${chromeHeight}">
109
+ <defs>
110
+ <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
111
+ <stop offset="0%" stop-color="${bg}"/>
112
+ <stop offset="100%" stop-color="${bgBottom}"/>
113
+ </linearGradient>
114
+ </defs>
115
+ <rect width="${windowWidth}" height="${chromeHeight}" fill="url(#bg)" rx="10" ry="10"/>
116
+ <rect x="0" y="${chromeHeight - 10}" width="${windowWidth}" height="10" fill="${bgBottom}"/>
117
+ <line x1="0" y1="${chromeHeight - 0.5}" x2="${windowWidth}" y2="${chromeHeight - 0.5}"
118
+ stroke="${separator}" stroke-width="1"/>
119
+ ${lights}
120
+ ${urlBar}
121
+ </svg>`;
122
+ }
123
+
124
+ function buildShadowSvg({ totalWidth, totalHeight, windowX, windowY, windowWidth, windowHeight, shadowBlur }) {
125
+ const halfBlur = shadowBlur / 2;
126
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
127
+ <defs>
128
+ <filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
129
+ <feDropShadow
130
+ dx="0" dy="${halfBlur / 2}"
131
+ stdDeviation="${halfBlur}"
132
+ flood-color="rgba(0,0,0,0.35)"
133
+ />
134
+ </filter>
135
+ </defs>
136
+ <rect
137
+ x="${windowX}" y="${windowY}"
138
+ width="${windowWidth}" height="${windowHeight}"
139
+ rx="10" ry="10"
140
+ fill="white"
141
+ filter="url(#shadow)"
142
+ />
143
+ </svg>`;
144
+ }
145
+
146
+ function escapeXml(str) {
147
+ return String(str)
148
+ .replace(/&/g, '&amp;')
149
+ .replace(/</g, '&lt;')
150
+ .replace(/>/g, '&gt;')
151
+ .replace(/"/g, '&quot;')
152
+ .replace(/'/g, '&apos;');
153
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,122 @@
1
+ import { z } from 'zod';
2
+ import { pathToFileURL } from 'url';
3
+ import { existsSync } from 'fs';
4
+ import path from 'path';
5
+
6
+ const WindowSizeSchema = z.object({
7
+ width: z.number().int().positive(),
8
+ height: z.number().int().positive(),
9
+ });
10
+
11
+ const DockerSchema = z.object({
12
+ // path to a docker-compose file
13
+ compose: z.string().optional(),
14
+ // specific service to start (omit to start all services in the compose file)
15
+ service: z.string().optional(),
16
+ // health check URL -- polled until 200 or timeout
17
+ healthcheck: z.object({
18
+ url: z.string().url(),
19
+ timeout: z.number().int().positive().default(60000),
20
+ interval: z.number().int().positive().default(2000),
21
+ }),
22
+ // path to a bootstrap/seed file to pass into the container.
23
+ // exposed to docker-compose.yml as env vars SCREENSHOT_BOOTSTRAP_PATH and
24
+ // SCREENSHOT_BOOTSTRAP_CONTENT. wire these into your container however it needs
25
+ // (e.g. FUSIONAUTH_APP_KICKSTART_FILE=SCREENSHOT_BOOTSTRAP_PATH for FusionAuth,
26
+ // or mount the path as a volume for other apps).
27
+ bootstrap: z.string().optional(),
28
+ // legacy alias for bootstrap -- 'kickstart' still works
29
+ kickstart: z.string().optional(),
30
+ // shell command to run after the health check passes and before beforeAll.
31
+ // runs in the project root. useful for database migrations, seed scripts, etc.
32
+ // example: 'node scripts/seed.js' or 'docker exec app npm run db:seed'
33
+ postStart: z.string().optional(),
34
+ // arbitrary env vars merged into the container environment when running compose up
35
+ env: z.record(z.string()).optional(),
36
+ });
37
+
38
+ const ChromeSchema = z.object({
39
+ // 'safari-macos' | 'none'
40
+ style: z.enum(['safari-macos', 'none']).default('safari-macos'),
41
+ // show the URL bar in the chrome
42
+ showUrl: z.boolean().default(true),
43
+ // dark chrome (title bar) regardless of page dark mode
44
+ dark: z.boolean().default(false),
45
+ // drop shadow radius in pixels; 0 to disable
46
+ shadowBlur: z.number().int().min(0).default(40),
47
+ // extra padding around the window to make room for the shadow
48
+ shadowPadding: z.number().int().min(0).default(48),
49
+ });
50
+
51
+ const ConfigSchema = z.object({
52
+ // base URL of the running app
53
+ baseUrl: z.string().url(),
54
+
55
+ // where to write screenshot PNGs -- relative to the project root
56
+ outputDir: z.string().default('./src/assets/screenshots'),
57
+
58
+ // default window dimensions
59
+ window: WindowSizeSchema.default({ width: 1280, height: 800 }),
60
+
61
+ // minimum and maximum window dimensions enforced globally
62
+ minWindow: WindowSizeSchema.default({ width: 640, height: 400 }),
63
+ maxWindow: WindowSizeSchema.default({ width: 2560, height: 1600 }),
64
+
65
+ // maximum dimensions for full-page captures
66
+ maxFullPage: WindowSizeSchema.default({ width: 2560, height: 8000 }),
67
+
68
+ // window chrome style
69
+ chrome: ChromeSchema.default({}),
70
+
71
+ // browser engine -- webkit gives the most Safari-accurate render
72
+ browser: z.enum(['webkit', 'chromium', 'firefox']).default('webkit'),
73
+
74
+ // default color scheme
75
+ colorScheme: z.enum(['light', 'dark']).default('light'),
76
+
77
+ // docker service configuration -- optional when the app is already running
78
+ docker: DockerSchema.optional(),
79
+
80
+ // strict mode -- fail the build when a screenshot file is missing
81
+ // defaults to true when SCREENSHOTS_STRICT=true env var is set
82
+ strict: z.boolean().default(process.env.SCREENSHOTS_STRICT === 'true'),
83
+
84
+ // called once before all screenshots are taken (good for login flows)
85
+ beforeAll: z.function().args(z.any()).returns(z.promise(z.any())).optional(),
86
+
87
+ // called before each individual screenshot
88
+ beforeScreenshot: z.function().args(z.any(), z.any()).returns(z.promise(z.any())).optional(),
89
+
90
+ // called once after all screenshots are taken
91
+ afterAll: z.function().returns(z.promise(z.any())).optional(),
92
+ });
93
+
94
+ export async function loadConfig(projectRoot) {
95
+ const candidates = [
96
+ path.join(projectRoot, 'screenshot.config.mjs'),
97
+ path.join(projectRoot, 'screenshot.config.js'),
98
+ ];
99
+
100
+ const configPath = candidates.find(existsSync);
101
+ if (!configPath) {
102
+ throw new Error(
103
+ `No screenshot.config.mjs found in ${projectRoot}. ` +
104
+ `Create one -- see the README for the full config reference.`
105
+ );
106
+ }
107
+
108
+ const raw = await import(/* @vite-ignore */ pathToFileURL(configPath).href);
109
+ const config = raw.default ?? raw;
110
+
111
+ const result = ConfigSchema.safeParse(config);
112
+ if (!result.success) {
113
+ const issues = result.error.issues
114
+ .map(i => ` ${i.path.join('.')}: ${i.message}`)
115
+ .join('\n');
116
+ throw new Error(`screenshot.config.mjs is invalid:\n${issues}`);
117
+ }
118
+
119
+ return result.data;
120
+ }
121
+
122
+ export { ConfigSchema, DockerSchema };
package/src/diff.mjs ADDED
@@ -0,0 +1,80 @@
1
+ // pixel-level diff between an existing screenshot and a newly captured one.
2
+ // used by check-screenshots to detect visual regressions in CI.
3
+
4
+ import { PNG } from 'pngjs';
5
+ import pixelmatch from 'pixelmatch';
6
+ import { readFileSync } from 'fs';
7
+ import sharp from 'sharp';
8
+
9
+ /**
10
+ * @typedef {Object} DiffResult
11
+ * @property {number} pixels - total number of differing pixels
12
+ * @property {number} ratio - differing pixels / total pixels (0..1)
13
+ * @property {Buffer|null} diffImage - PNG buffer visualizing the diff, or null if identical
14
+ */
15
+
16
+ /**
17
+ * Compare a newly captured PNG buffer against the on-disk reference file.
18
+ *
19
+ * @param {string} referencePath - absolute path to the reference PNG
20
+ * @param {Buffer} newBuffer - PNG buffer from a fresh capture
21
+ * @param {object} [opts]
22
+ * @param {number} [opts.threshold=0.1] - pixelmatch threshold (0 = strict, 1 = lenient)
23
+ * @param {boolean} [opts.writeDiff=false] - include the diff image buffer in the result
24
+ * @returns {Promise<DiffResult>}
25
+ */
26
+ export async function diffScreenshot(referencePath, newBuffer, opts = {}) {
27
+ const { threshold = 0.1, writeDiff = false } = opts;
28
+
29
+ let refBuffer;
30
+ try {
31
+ refBuffer = readFileSync(referencePath);
32
+ } catch {
33
+ // reference doesn't exist -- treat as fully different
34
+ return { pixels: -1, ratio: 1, diffImage: null };
35
+ }
36
+
37
+ // normalize both images to the same dimensions before diffing
38
+ const [refNorm, newNorm] = await Promise.all([
39
+ normalizeSize(refBuffer),
40
+ normalizeSize(newBuffer),
41
+ ]);
42
+
43
+ const refPng = PNG.sync.read(refNorm);
44
+ const newPng = PNG.sync.read(newNorm);
45
+
46
+ // if dimensions differ after normalization, sizes changed -- treat as fully different
47
+ if (refPng.width !== newPng.width || refPng.height !== newPng.height) {
48
+ return {
49
+ pixels: refPng.width * refPng.height,
50
+ ratio: 1,
51
+ diffImage: null,
52
+ };
53
+ }
54
+
55
+ const { width, height } = refPng;
56
+ const totalPixels = width * height;
57
+ const diffPng = writeDiff ? new PNG({ width, height }) : null;
58
+
59
+ const diffPixels = pixelmatch(
60
+ refPng.data,
61
+ newPng.data,
62
+ diffPng?.data ?? null,
63
+ width,
64
+ height,
65
+ { threshold }
66
+ );
67
+
68
+ const diffImage = diffPng ? PNG.sync.write(diffPng) : null;
69
+
70
+ return {
71
+ pixels: diffPixels,
72
+ ratio: diffPixels / totalPixels,
73
+ diffImage,
74
+ };
75
+ }
76
+
77
+ async function normalizeSize(buffer) {
78
+ // re-encode via sharp to ensure consistent PNG format and strip metadata
79
+ return sharp(buffer).png().toBuffer();
80
+ }
@@ -0,0 +1,128 @@
1
+ // scans MDX/Astro source files for Screenshot components and extracts their specs.
2
+ // this is how the CLI knows what screenshots to take without running the full Astro build.
3
+
4
+ import { readFileSync } from 'fs';
5
+ import { glob } from 'glob';
6
+ import path from 'path';
7
+ import { deriveName } from './naming.mjs';
8
+
9
+ /**
10
+ * @typedef {Object} HighlightSpec
11
+ * @property {string} selector
12
+ * @property {'border'|'arrow'|'both'} style
13
+ * @property {string} color
14
+ * @property {string} label
15
+ * @property {number} borderWidth
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} ScreenshotSpec
20
+ * @property {string} url
21
+ * @property {string} name - derived output filename without extension
22
+ * @property {HighlightSpec[]} highlights
23
+ * @property {number|undefined} width
24
+ * @property {number|undefined} height
25
+ * @property {boolean} fullPage
26
+ * @property {string} sourceFile - absolute path of the file containing this Screenshot
27
+ */
28
+
29
+ /**
30
+ * Scan a project's source files and return all Screenshot specs.
31
+ *
32
+ * @param {string} projectRoot - absolute path to the project root
33
+ * @param {string[]} [patterns] - glob patterns relative to projectRoot; default scans src/**
34
+ * @returns {Promise<ScreenshotSpec[]>}
35
+ */
36
+ export async function discoverScreenshots(projectRoot, patterns) {
37
+ const defaultPatterns = ['src/**/*.mdx', 'src/**/*.md', 'src/**/*.astro'];
38
+ const globs = (patterns ?? defaultPatterns).map(p => path.join(projectRoot, p));
39
+
40
+ const files = (
41
+ await Promise.all(globs.map(pattern => glob(pattern, { absolute: true })))
42
+ ).flat();
43
+
44
+ const used = new Set();
45
+ const specs = [];
46
+
47
+ for (const file of files) {
48
+ const fileSpecs = parseFile(file, used);
49
+ specs.push(...fileSpecs);
50
+ }
51
+
52
+ return specs;
53
+ }
54
+
55
+ function parseFile(filePath, used) {
56
+ let source;
57
+ try {
58
+ source = readFileSync(filePath, 'utf8');
59
+ } catch {
60
+ return [];
61
+ }
62
+
63
+ const specs = [];
64
+
65
+ // find all <Screenshot ...> blocks, including multiline
66
+ // we look for <Screenshot, then collect attributes until we see > or />
67
+ // then look for Highlight children until </Screenshot>
68
+ const screenshotRe = /<Screenshot\b([\s\S]*?)(?:\/>|>([\s\S]*?)<\/Screenshot>)/g;
69
+ let match;
70
+
71
+ while ((match = screenshotRe.exec(source)) !== null) {
72
+ const attrBlock = match[1];
73
+ const childBlock = match[2] ?? '';
74
+
75
+ const url = extractProp(attrBlock, 'url');
76
+ if (!url) continue;
77
+
78
+ const id = extractProp(attrBlock, 'id');
79
+ const widthStr = extractProp(attrBlock, 'width');
80
+ const heightStr = extractProp(attrBlock, 'height');
81
+ const fullPageStr = extractProp(attrBlock, 'fullPage');
82
+
83
+ const highlights = parseHighlightChildren(childBlock);
84
+ const name = id ?? deriveName(url, highlights, used);
85
+
86
+ specs.push({
87
+ url,
88
+ name,
89
+ highlights,
90
+ width: widthStr ? parseInt(widthStr, 10) : undefined,
91
+ height: heightStr ? parseInt(heightStr, 10) : undefined,
92
+ fullPage: fullPageStr === 'true' || fullPageStr === '{true}',
93
+ sourceFile: filePath,
94
+ });
95
+ }
96
+
97
+ return specs;
98
+ }
99
+
100
+ function extractProp(attrBlock, propName) {
101
+ // matches: propName="value" or propName={'value'} or propName={value}
102
+ const re = new RegExp(
103
+ `${propName}\\s*=\\s*(?:"([^"]*?)"|'([^']*?)'|\\{['"](.*?)['"]}|\\{([^}]+?)\\})`,
104
+ 's'
105
+ );
106
+ const m = re.exec(attrBlock);
107
+ if (!m) return undefined;
108
+ return (m[1] ?? m[2] ?? m[3] ?? m[4] ?? '').trim();
109
+ }
110
+
111
+ function parseHighlightChildren(childBlock) {
112
+ const highlights = [];
113
+ const highlightRe = /<Highlight\b([^>]*?)(?:\/>|>[\s\S]*?<\/Highlight>)/g;
114
+ let m;
115
+ while ((m = highlightRe.exec(childBlock)) !== null) {
116
+ const attrs = m[1];
117
+ const selector = extractProp(attrs, 'selector');
118
+ if (!selector) continue;
119
+ highlights.push({
120
+ selector,
121
+ style: extractProp(attrs, 'style') ?? 'border',
122
+ color: extractProp(attrs, 'color') ?? '#f60',
123
+ label: extractProp(attrs, 'label') ?? '',
124
+ borderWidth: parseInt(extractProp(attrs, 'borderWidth') ?? '3', 10),
125
+ });
126
+ }
127
+ return highlights;
128
+ }
package/src/docker.mjs ADDED
@@ -0,0 +1,123 @@
1
+ // manages the Docker container lifecycle for screenshot sessions.
2
+ // starts the container, optionally loads a bootstrap/kickstart file, polls
3
+ // a health check URL until the app is ready, then runs an optional postStart command.
4
+
5
+ import { execSync } from 'child_process';
6
+ import { readFileSync } from 'fs';
7
+ import path from 'path';
8
+
9
+ /**
10
+ * Start the Docker service described in config.docker and wait for it to be healthy.
11
+ * After the healthcheck passes, runs docker.postStart if defined.
12
+ *
13
+ * @param {import('./config.mjs').DockerConfig} dockerConfig
14
+ * @param {string} projectRoot
15
+ * @returns {Promise<void>}
16
+ */
17
+ export async function startDocker(dockerConfig, projectRoot) {
18
+ if (!dockerConfig) return;
19
+
20
+ const composePath = dockerConfig.compose
21
+ ? path.resolve(projectRoot, dockerConfig.compose)
22
+ : null;
23
+
24
+ if (composePath) {
25
+ // support both 'bootstrap' (preferred) and 'kickstart' (legacy alias)
26
+ const bootstrapPath = dockerConfig.bootstrap ?? dockerConfig.kickstart;
27
+ const env = buildEnv(dockerConfig.env ?? {}, bootstrapPath, projectRoot);
28
+ const serviceArg = dockerConfig.service ? ` ${dockerConfig.service}` : '';
29
+
30
+ console.log(`[docker] starting services via ${path.relative(projectRoot, composePath)}`);
31
+ execSync(
32
+ `docker compose -f "${composePath}" up -d${serviceArg}`,
33
+ { stdio: 'inherit', env: { ...process.env, ...env } }
34
+ );
35
+ }
36
+
37
+ console.log(`[docker] waiting for ${dockerConfig.healthcheck.url}`);
38
+ await pollHealthcheck(dockerConfig.healthcheck);
39
+ console.log('[docker] app is ready');
40
+
41
+ if (dockerConfig.postStart) {
42
+ console.log(`[docker] running postStart: ${dockerConfig.postStart}`);
43
+ execSync(dockerConfig.postStart, {
44
+ stdio: 'inherit',
45
+ cwd: projectRoot,
46
+ env: process.env,
47
+ });
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Stop containers started for this session.
53
+ * Does NOT remove volumes -- use `docker compose down -v` manually to reset seed data.
54
+ *
55
+ * @param {import('./config.mjs').DockerConfig} dockerConfig
56
+ * @param {string} projectRoot
57
+ */
58
+ export function stopDocker(dockerConfig, projectRoot) {
59
+ if (!dockerConfig?.compose) return;
60
+
61
+ const composePath = path.resolve(projectRoot, dockerConfig.compose);
62
+ console.log('[docker] stopping services');
63
+ try {
64
+ execSync(`docker compose -f "${composePath}" down`, { stdio: 'inherit' });
65
+ } catch (e) {
66
+ console.warn('[docker] warning: docker compose down failed:', e.message);
67
+ }
68
+ }
69
+
70
+ async function pollHealthcheck({ url, timeout = 60000, interval = 2000 }) {
71
+ const deadline = Date.now() + timeout;
72
+ let lastError;
73
+ let attempts = 0;
74
+
75
+ while (Date.now() < deadline) {
76
+ attempts++;
77
+ try {
78
+ const res = await fetch(url, { signal: AbortSignal.timeout(interval) });
79
+ if (res.ok) {
80
+ if (attempts > 1) process.stdout.write('\n');
81
+ return;
82
+ }
83
+ lastError = new Error(`HTTP ${res.status}`);
84
+ } catch (e) {
85
+ lastError = e;
86
+ }
87
+ process.stdout.write('.');
88
+ await sleep(interval);
89
+ }
90
+
91
+ process.stdout.write('\n');
92
+ throw new Error(
93
+ `[docker] health check timed out after ${timeout}ms waiting for ${url}` +
94
+ (lastError ? `: ${lastError.message}` : '')
95
+ );
96
+ }
97
+
98
+ function buildEnv(extraEnv, bootstrapPath, projectRoot) {
99
+ const env = { ...extraEnv };
100
+ if (bootstrapPath) {
101
+ const absPath = path.resolve(projectRoot, bootstrapPath);
102
+ let content;
103
+ try {
104
+ content = readFileSync(absPath, 'utf8');
105
+ } catch {
106
+ throw new Error(`[docker] bootstrap file not found: ${absPath}`);
107
+ }
108
+ // expose bootstrap content and path as env vars.
109
+ // the docker-compose.yml can wire these into the container however it needs.
110
+ // for FusionAuth: set FUSIONAUTH_APP_KICKSTART_FILE to SCREENSHOT_BOOTSTRAP_PATH.
111
+ // for other apps: mount the file or use the content as a seed script input.
112
+ env.SCREENSHOT_BOOTSTRAP_CONTENT = content;
113
+ env.SCREENSHOT_BOOTSTRAP_PATH = absPath;
114
+ // legacy alias for backward compat
115
+ env.SCREENSHOT_KICKSTART_CONTENT = content;
116
+ env.SCREENSHOT_KICKSTART_PATH = absPath;
117
+ }
118
+ return env;
119
+ }
120
+
121
+ function sleep(ms) {
122
+ return new Promise(resolve => setTimeout(resolve, ms));
123
+ }