Skip to content

Commit d955e34

Browse files
authored
Merge pull request #47 from happyprime/add-device-scale-factor
Add per-viewport deviceScaleFactor (DPR) support
2 parents 219897e + 1aad921 commit d955e34

10 files changed

Lines changed: 303 additions & 93 deletions

File tree

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ self-ignored `.reglance/` directory — nothing to add to `.gitignore`.
4949
| `paths` | yes | Map of `key` → path. A value may be a full URL to point at a different domain. |
5050
| `domain` | capture | Default domain. A bare host (`site.test`) becomes `https://site.test`. |
5151
| `name` | no | Label shown in the report. Defaults to the domain host. |
52-
| `viewports` | no | `[{ name, width, height }]`. Defaults to `desktop` (1920×1080), `mobile` (390×844). |
52+
| `viewports` | no | `[{ name, width, height, deviceScaleFactor? }]`. Defaults to `desktop` (1920×1080), `mobile` (390×844). |
5353
| `output` | no | Output directory. Defaults to `.reglance`. |
5454
| `pixelmatchOptions` | no | [pixelmatch](https://github.com/mapbox/pixelmatch) options, e.g. `{ "threshold": 0.1 }`. |
5555
| `timeouts` | no | `{ goto, settle }` in ms. Navigation and post-scroll network-idle waits. Defaults `{ goto: 15000, settle: 8000 }`. Raise `settle` for slow, lazy-loading pages. |
@@ -58,6 +58,15 @@ self-ignored `.reglance/` directory — nothing to add to `.gitignore`.
5858
already captured. See [`reglance.example.json`](reglance.example.json) for a full
5959
example.
6060

61+
A viewport's optional `deviceScaleFactor` (device pixel ratio) renders the page
62+
as it would appear on a higher-density display — use `2` for a retina capture,
63+
`3` for some phones. It defaults to `1`. Captures sharing a DPR run in one
64+
browser context; a new DPR opens a fresh context, so prefer grouping retina and
65+
non-retina variants rather than scattering them. Note that a `` screenshot is
66+
twice the pixel dimensions of its `` counterpart, so changing the
67+
`deviceScaleFactor` of an existing viewport will diff against its controls as
68+
fully changed until you re-run `reglance control`.
69+
6170
A `paths` value may be a full URL pointing at a different host than `domain`.
6271
This is supported, but `capture` prints a warning listing such paths so
6372
off-domain navigation is a conscious choice — keep configs from trusted

reglance.example.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
},
99
"viewports": [
1010
{ "name": "desktop", "width": 1920, "height": 1080 },
11-
{ "name": "mobile", "width": 390, "height": 844 }
11+
{ "name": "mobile", "width": 390, "height": 844 },
12+
{ "name": "mobile-retina", "width": 390, "height": 844, "deviceScaleFactor": 2 }
1213
],
1314
"pixelmatchOptions": {
1415
"threshold": 0.1,

src/capture.mjs

Lines changed: 132 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,47 @@ async function autoScroll(page) {
9191
});
9292
}
9393

94+
/**
95+
* Group viewports by their device scale factor, preserving order.
96+
*
97+
* Playwright's deviceScaleFactor can only be set when a context is created and
98+
* cannot be changed on a live context, so each distinct DPR needs its own
99+
* context. Viewports usually share a DPR, so grouping is cheaper than a context
100+
* per viewport while still letting setViewportSize switch sizes within a group.
101+
* A viewport without an explicit deviceScaleFactor defaults to 1.
102+
*
103+
* @param {Array} viewports - Viewport definitions.
104+
* @returns {Array<{ deviceScaleFactor: number, viewports: Array }>} Groups in
105+
* first-seen DPR order.
106+
*/
107+
export function groupViewportsByScaleFactor(viewports) {
108+
const groups = new Map();
109+
110+
for (const viewport of viewports) {
111+
const deviceScaleFactor = viewport.deviceScaleFactor ?? 1;
112+
if (!groups.has(deviceScaleFactor)) {
113+
groups.set(deviceScaleFactor, []);
114+
}
115+
groups.get(deviceScaleFactor).push(viewport);
116+
}
117+
118+
return [...groups.entries()].map(([deviceScaleFactor, grouped]) => ({
119+
deviceScaleFactor,
120+
viewports: grouped,
121+
}));
122+
}
123+
94124
/**
95125
* Capture screenshots for a single target across all viewports.
96126
*
97127
* Returns the list of slugs that did not capture cleanly so the caller can
98128
* report them. A degraded capture is still written (best effort) but is
99129
* recorded as a failure rather than silently treated as success.
100130
*
131+
* Viewports are captured one context per device scale factor (see
132+
* groupViewportsByScaleFactor); within a context the first viewport navigates
133+
* fresh and the rest reuse the page (reloading unless --skip-reload).
134+
*
101135
* @param {import('playwright').Browser} browser - The shared browser.
102136
* @param {object} target - The target ({ key, url }).
103137
* @param {Array} viewports - Viewport definitions.
@@ -112,107 +146,118 @@ async function captureTarget(browser, target, viewports, dirs, options) {
112146
timeouts = DEFAULT_TIMEOUTS,
113147
ignoreHTTPSErrors = false,
114148
} = options;
115-
const context = await browser.newContext({ ignoreHTTPSErrors });
116-
const page = await context.newPage();
117149
const failures = [];
118150
let currentSlug = target.key;
119151

120-
const failedResources = new Set();
121-
page.on('requestfailed', (request) => {
122-
const type = request.resourceType();
123-
if (type === 'stylesheet' || type === 'script') {
124-
failedResources.add(request.url());
125-
console.warn(`⚠️ Failed to load ${type}: ${request.url()}`);
126-
}
127-
});
128-
129-
try {
130-
for (let i = 0; i < viewports.length; i++) {
131-
const viewport = viewports[i];
132-
const slug = `${target.key}-${viewport.name}`;
133-
currentSlug = slug;
134-
135-
console.log(`Capturing ${slug}...`);
136-
137-
await page.setViewportSize({
138-
width: viewport.width,
139-
height: viewport.height,
140-
});
141-
142-
failedResources.clear();
152+
for (const group of groupViewportsByScaleFactor(viewports)) {
153+
const context = await browser.newContext({
154+
ignoreHTTPSErrors,
155+
deviceScaleFactor: group.deviceScaleFactor,
156+
});
157+
const page = await context.newPage();
158+
159+
const failedResources = new Set();
160+
page.on('requestfailed', (request) => {
161+
const type = request.resourceType();
162+
if (type === 'stylesheet' || type === 'script') {
163+
failedResources.add(request.url());
164+
console.warn(`⚠️ Failed to load ${type}: ${request.url()}`);
165+
}
166+
});
143167

144-
let attempts = 0;
145-
let success = false;
168+
try {
169+
for (let i = 0; i < group.viewports.length; i++) {
170+
const viewport = group.viewports[i];
171+
const slug = `${target.key}-${viewport.name}`;
172+
currentSlug = slug;
146173

147-
while (attempts <= retryCount && !success) {
148-
try {
149-
if (i === 0 || !skipReload) {
150-
if (attempts > 0) {
151-
console.log(` Retry attempt ${attempts}...`);
152-
await page.waitForTimeout(1000);
153-
}
174+
console.log(`Capturing ${slug}...`);
154175

155-
const gotoOptions = {
156-
waitUntil: 'networkidle',
157-
timeout: timeouts.goto,
158-
};
176+
await page.setViewportSize({
177+
width: viewport.width,
178+
height: viewport.height,
179+
});
159180

160-
if (i === 0 || attempts > 0) {
161-
await page.goto(target.url, gotoOptions);
162-
} else {
163-
await page.reload(gotoOptions);
181+
failedResources.clear();
182+
183+
let attempts = 0;
184+
let success = false;
185+
186+
while (attempts <= retryCount && !success) {
187+
try {
188+
if (i === 0 || !skipReload) {
189+
if (attempts > 0) {
190+
console.log(` Retry attempt ${attempts}...`);
191+
await page.waitForTimeout(1000);
192+
}
193+
194+
const gotoOptions = {
195+
waitUntil: 'networkidle',
196+
timeout: timeouts.goto,
197+
};
198+
199+
if (i === 0 || attempts > 0) {
200+
await page.goto(target.url, gotoOptions);
201+
} else {
202+
await page.reload(gotoOptions);
203+
}
204+
205+
if (
206+
failedResources.size > 0 &&
207+
attempts < retryCount
208+
) {
209+
throw new Error(
210+
'Critical resources failed to load'
211+
);
212+
}
164213
}
165-
166-
if (failedResources.size > 0 && attempts < retryCount) {
167-
throw new Error(
168-
'Critical resources failed to load'
214+
success = true;
215+
} catch (error) {
216+
attempts++;
217+
if (attempts > retryCount) {
218+
// Retries exhausted. Screenshot best-effort below, but
219+
// record the slug as degraded rather than faking success.
220+
const reason = error.message || String(error);
221+
console.error(
222+
` ⚠️ ${slug} did not load cleanly after ${retryCount + 1} attempts: ${reason}`
169223
);
224+
failures.push({ slug, url: target.url, reason });
225+
success = true;
170226
}
171227
}
172-
success = true;
173-
} catch (error) {
174-
attempts++;
175-
if (attempts > retryCount) {
176-
// Retries exhausted. Screenshot best-effort below, but
177-
// record the slug as degraded rather than faking success.
178-
const reason = error.message || String(error);
179-
console.error(
180-
` ⚠️ ${slug} did not load cleanly after ${retryCount + 1} attempts: ${reason}`
181-
);
182-
failures.push({ slug, url: target.url, reason });
183-
success = true;
184-
}
185228
}
186-
}
187229

188-
await autoScroll(page);
189-
// A single event-driven settle after scrolling, bounded by the
190-
// configurable timeout: near-instant on a page that is already
191-
// quiet, and long enough for lazy assets on a slow one. (The goto
192-
// above already waited for the initial network idle.)
193-
await page
194-
.waitForLoadState('networkidle', { timeout: timeouts.settle })
195-
.catch(() => {
196-
// Slow/never-idle page; screenshot what we have.
197-
});
198-
199-
const imagePath = path.join(dirs.captures, `${slug}.png`);
200-
await page.screenshot({ path: imagePath, fullPage: true });
201-
202-
const htmlPath = path.join(dirs.capturesHtml, `${slug}.html`);
203-
fs.writeFileSync(htmlPath, await page.content());
204-
205-
console.log(`✓ Captured ${slug}`);
230+
await autoScroll(page);
231+
// A single event-driven settle after scrolling, bounded by the
232+
// configurable timeout: near-instant on a page that is already
233+
// quiet, and long enough for lazy assets on a slow one. (The goto
234+
// above already waited for the initial network idle.)
235+
await page
236+
.waitForLoadState('networkidle', {
237+
timeout: timeouts.settle,
238+
})
239+
.catch(() => {
240+
// Slow/never-idle page; screenshot what we have.
241+
});
242+
243+
const imagePath = path.join(dirs.captures, `${slug}.png`);
244+
await page.screenshot({ path: imagePath, fullPage: true });
245+
246+
const htmlPath = path.join(dirs.capturesHtml, `${slug}.html`);
247+
fs.writeFileSync(htmlPath, await page.content());
248+
249+
console.log(`✓ Captured ${slug}`);
250+
}
251+
} catch (error) {
252+
// A failure outside the retry loop (e.g. screenshot or HTML write).
253+
const reason = error.message || String(error);
254+
console.error(
255+
`Error capturing ${currentSlug} (${target.url}): ${reason}`
256+
);
257+
failures.push({ slug: currentSlug, url: target.url, reason });
258+
} finally {
259+
await context.close();
206260
}
207-
} catch (error) {
208-
// A failure outside the retry loop (e.g. screenshot or HTML write).
209-
const reason = error.message || String(error);
210-
console.error(
211-
`Error capturing ${currentSlug} (${target.url}): ${reason}`
212-
);
213-
failures.push({ slug: currentSlug, url: target.url, reason });
214-
} finally {
215-
await context.close();
216261
}
217262

218263
return failures;

src/config.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,22 @@ export function validateViewports(viewports) {
165165
);
166166
}
167167
}
168+
169+
// Optional. Maps to Playwright's deviceScaleFactor (the page's
170+
// devicePixelRatio); fractional ratios like 1.5 are valid, so this is a
171+
// positive number rather than an integer. Defaults to 1 when omitted.
172+
const { deviceScaleFactor } = viewport;
173+
if (
174+
deviceScaleFactor !== undefined &&
175+
(typeof deviceScaleFactor !== 'number' ||
176+
!Number.isFinite(deviceScaleFactor) ||
177+
deviceScaleFactor <= 0)
178+
) {
179+
throw new Error(
180+
`❌ Invalid viewport "${viewport.name}": deviceScaleFactor must be a positive number, got ${JSON.stringify(deviceScaleFactor)}.\n` +
181+
'💡 Use a device pixel ratio like 1 (default), 2 (retina), or 3.'
182+
);
183+
}
168184
});
169185
}
170186

src/report.mjs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,21 @@ export function escapeHtml(str) {
5252
.replace(/'/g, '&#039;');
5353
}
5454

55+
/**
56+
* A device-pixel-ratio suffix for a viewport, e.g. ` @2x`.
57+
*
58+
* Returns an empty string at the default ratio of 1 so standard captures read
59+
* cleanly and only retina / high-density viewports are annotated. The value is
60+
* a validated positive number (see validateViewports), so it needs no escaping.
61+
*
62+
* @param {object} viewport - The viewport definition.
63+
* @returns {string} The suffix (with a leading space) or an empty string.
64+
*/
65+
export function dprLabel(viewport) {
66+
const dpr = viewport?.deviceScaleFactor ?? 1;
67+
return dpr === 1 ? '' : ` @${dpr}x`;
68+
}
69+
5570
/**
5671
* Serialize a value for embedding in an inline <script>.
5772
*
@@ -108,6 +123,7 @@ export function generateHtmlDiff(html1, html2, context, diffLines) {
108123
.replaceAll('{viewportName}', escapeHtml(viewport.name))
109124
.replaceAll('{viewportWidth}', String(viewport.width))
110125
.replaceAll('{viewportHeight}', String(viewport.height))
126+
.replaceAll('{viewportDpr}', dprLabel(viewport))
111127
.replaceAll('{status}', hasChanges ? 'Changes detected' : 'No changes')
112128
.replaceAll('{diffContent}', diffContent);
113129

@@ -144,6 +160,7 @@ export function generateReport(config, report) {
144160
.replaceAll('{viewportName}', escapeHtml(viewport.name))
145161
.replaceAll('{viewportWidth}', String(viewport.width))
146162
.replaceAll('{viewportHeight}', String(viewport.height))
163+
.replaceAll('{viewportDpr}', dprLabel(viewport))
147164
.replaceAll('{originalImage}', rel(controlImage))
148165
.replaceAll('{secondImage}', rel(captureImage))
149166
.replaceAll('{diffImage}', rel(diffImage))
@@ -208,7 +225,7 @@ export function generateIndex(config, reports) {
208225
return `
209226
<tr data-url="${url}" data-viewport="${viewportName}" data-diff="${report.diffPercentage}" data-index="${index}">
210227
<td class="url-cell" title="${url}">${url}</td>
211-
<td>${viewportName} (${report.viewport.width}x${report.viewport.height})</td>
228+
<td>${viewportName} (${report.viewport.width}x${report.viewport.height})${dprLabel(report.viewport)}</td>
212229
<td class="diff-percentage ${diffClass}"><span class="visually-hidden">${diffClass} difference: </span>${report.diffPercentage.toFixed(2)}%</td>
213230
<td class="diff-percentage ${htmlDiffClass}">${report.htmlHasChanges ? 'Yes' : 'No'}</td>
214231
<td><a href="${rel(report.reportPath)}">View Report</a></td>
@@ -221,7 +238,7 @@ export function generateIndex(config, reports) {
221238
const viewportOptions = viewports
222239
.map((v) => {
223240
const vn = escapeHtml(v.name);
224-
return `<option value="${vn}">${vn} (${v.width}x${v.height})</option>`;
241+
return `<option value="${vn}">${vn} (${v.width}x${v.height})${dprLabel(v)}</option>`;
225242
})
226243
.join('\n\t\t\t\t');
227244

templates/html-diff.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
<h1>HTML Diff Report</h1>
2929
<p>Site: {name}</p>
3030
<p>URL: {urlKey}</p>
31-
<p>Viewport: {viewportName} ({viewportWidth}x{viewportHeight})</p>
31+
<p>Viewport: {viewportName} ({viewportWidth}x{viewportHeight}){viewportDpr}</p>
3232
<p>Status: {status}</p>
3333
<div class="diff-content">
3434
<pre>{diffContent}</pre>

templates/report.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
<a href="index.html" class="back-link">&larr; Back to overview</a>
1515
<h1>{name} &mdash; {urlKey}</h1>
1616
<div class="report-meta">
17-
<span class="meta-item">{viewportName} ({viewportWidth}&times;{viewportHeight})</span>
17+
<span class="meta-item">{viewportName} ({viewportWidth}&times;{viewportHeight}){viewportDpr}</span>
1818
<span class="meta-item diff-badge">{diffPercentage}% different</span>
1919
</div>
2020
</div>

0 commit comments

Comments
 (0)