@d-zero/beholder 3.1.5 → 4.0.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,282 @@
1
+ import type { Page } from 'puppeteer';
2
+
3
+ import { JSDOM } from 'jsdom';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+
6
+ import { extractMainContentsFromDocument, getMainContents } from './get-main-contents.js';
7
+
8
+ /**
9
+ * @param html
10
+ */
11
+ function createDocument(html: string): Document {
12
+ return new JSDOM(html, { url: 'https://example.com/' }).window.document;
13
+ }
14
+
15
+ /**
16
+ * @param html
17
+ * @param mainContentSelector
18
+ */
19
+ function extract(html: string, mainContentSelector: string | null = null) {
20
+ return extractMainContentsFromDocument(mainContentSelector, createDocument(html));
21
+ }
22
+
23
+ describe('extractMainContentsFromDocument', () => {
24
+ it('returns empty main metrics when no main content element is found, but counts body text', () => {
25
+ const result = extract(
26
+ '<html><head><title>T</title></head><body><header>ABC</header><div>No main</div></body></html>',
27
+ );
28
+
29
+ expect(result).toEqual({
30
+ title: 'T',
31
+ main: null,
32
+ wordCount: 0,
33
+ bodyWordCount: 9,
34
+ headings: [],
35
+ images: [],
36
+ tables: [],
37
+ buttons: [],
38
+ iframes: [],
39
+ videos: [],
40
+ audios: [],
41
+ canvases: [],
42
+ });
43
+ });
44
+
45
+ it('detects <main> and counts word length', () => {
46
+ const result = extract('<html><body><main>こんにちは世界</main></body></html>');
47
+
48
+ expect(result.wordCount).toBe(7);
49
+ expect(result.bodyWordCount).toBeGreaterThanOrEqual(7);
50
+ expect(result.main?.nodeName).toBe('MAIN');
51
+ });
52
+
53
+ it('detects [role="main"]', () => {
54
+ const result = extract('<html><body><div role="main">Content</div></body></html>');
55
+
56
+ expect(result.wordCount).toBe(7);
57
+ expect(result.main?.role).toBe('main');
58
+ });
59
+
60
+ it('uses custom mainContentSelector when provided', () => {
61
+ const result = extract(
62
+ '<html><body><section id="page-body">Custom main</section></body></html>',
63
+ '#page-body',
64
+ );
65
+
66
+ expect(result.wordCount).toBe(10);
67
+ expect(result.main?.id).toBe('page-body');
68
+ });
69
+
70
+ it('falls back to built-in selectors when custom selector is invalid', () => {
71
+ const result = extract(
72
+ '<html><body><main>Still works</main></body></html>',
73
+ '[[[invalid',
74
+ );
75
+
76
+ expect(result.main?.nodeName).toBe('MAIN');
77
+ expect(result.wordCount).toBe(10);
78
+ });
79
+
80
+ it('extracts headings from main content', () => {
81
+ const result = extract(`
82
+ <html><body><main>
83
+ <h1>Title</h1>
84
+ <h2>Subtitle</h2>
85
+ <h3>Section</h3>
86
+ </main></body></html>
87
+ `);
88
+
89
+ expect(result.headings).toEqual([
90
+ { text: 'Title', level: 1 },
91
+ { text: 'Subtitle', level: 2 },
92
+ { text: 'Section', level: 3 },
93
+ ]);
94
+ });
95
+
96
+ it('extracts images from main content', () => {
97
+ const result = extract(`
98
+ <html><body><main>
99
+ <img src="a.png" alt="Image A">
100
+ <img src="b.png" alt="Image B">
101
+ <input type="image" src="c.png" alt="Image C">
102
+ </main></body></html>
103
+ `);
104
+
105
+ expect(result.images).toHaveLength(3);
106
+ expect(result.images[0]?.alt).toBe('Image A');
107
+ expect(result.images[0]?.src).toContain('a.png');
108
+ });
109
+
110
+ it('extracts table metadata from main content', () => {
111
+ const result = extract(`
112
+ <html><body><main>
113
+ <table>
114
+ <thead><tr><th>A</th><th>B</th></tr></thead>
115
+ <tfoot><tr><td>Footer</td><td></td></tr></tfoot>
116
+ <tbody>
117
+ <tr><td colspan="2">Merged</td></tr>
118
+ <tr><td>1</td><td>2</td></tr>
119
+ </tbody>
120
+ </table>
121
+ </main></body></html>
122
+ `);
123
+
124
+ expect(result.tables).toEqual([
125
+ {
126
+ rows: 4,
127
+ cols: 2,
128
+ hasHeader: true,
129
+ hasFooter: true,
130
+ hasMergedCell: true,
131
+ },
132
+ ]);
133
+ });
134
+
135
+ it('extracts buttons from main content', () => {
136
+ const result = extract(`
137
+ <html><body><main>
138
+ <button>Save</button>
139
+ <a role="button" href="#">Go</a>
140
+ <span class="btn">Click</span>
141
+ <div class="share-button">Share</div>
142
+ </main></body></html>
143
+ `);
144
+
145
+ expect(result.buttons).toHaveLength(4);
146
+ expect(result.buttons[0]).toMatchObject({
147
+ nodeName: 'BUTTON',
148
+ text: 'Save',
149
+ disabled: false,
150
+ });
151
+ expect(result.buttons[1]?.role).toBe('button');
152
+ });
153
+
154
+ it('extracts iframe, video, audio, and canvas from main content', () => {
155
+ const result = extract(`
156
+ <html><body><main>
157
+ <iframe src="/embed" title="Embed" width="640" height="360"></iframe>
158
+ <video src="/movie.mp4" poster="/poster.jpg" width="320" height="180"></video>
159
+ <audio src="/sound.mp3"></audio>
160
+ <canvas width="100" height="50"></canvas>
161
+ </main></body></html>
162
+ `);
163
+
164
+ expect(result.iframes).toHaveLength(1);
165
+ expect(result.iframes[0]).toMatchObject({
166
+ title: 'Embed',
167
+ width: '640',
168
+ height: '360',
169
+ });
170
+ expect(result.iframes[0]?.src).toContain('/embed');
171
+
172
+ expect(result.videos).toHaveLength(1);
173
+ expect(result.videos[0]?.src).toContain('/movie.mp4');
174
+ expect(result.videos[0]?.poster).toContain('/poster.jpg');
175
+ expect(result.videos[0]?.width).toBe(320);
176
+ expect(result.videos[0]?.height).toBe(180);
177
+
178
+ expect(result.audios).toHaveLength(1);
179
+ expect(result.audios[0]?.src).toContain('/sound.mp3');
180
+
181
+ expect(result.canvases).toEqual([{ width: 100, height: 50 }]);
182
+ });
183
+
184
+ it('treats whitespace-only main text as zero word count', () => {
185
+ const result = extract('<html><body><main> \n\t </main></body></html>');
186
+
187
+ expect(result.wordCount).toBe(0);
188
+ });
189
+
190
+ it('detects #content fallback selector', () => {
191
+ const result = extract(
192
+ '<html><body><div id="content">Fallback content</div></body></html>',
193
+ );
194
+
195
+ expect(result.wordCount).toBe(15);
196
+ });
197
+
198
+ it('detects .main fallback selector', () => {
199
+ const result = extract(
200
+ '<html><body><div class="main">Class-based main</div></body></html>',
201
+ );
202
+
203
+ expect(result.wordCount).toBe(15);
204
+ });
205
+
206
+ it('returns first matching element in DOM order when multiple Phase-1 selectors match', () => {
207
+ const result = extract(`
208
+ <html><body>
209
+ <main>Semantic main</main>
210
+ <div id="content">ID content</div>
211
+ </body></html>
212
+ `);
213
+
214
+ expect(result.main?.nodeName).toBe('MAIN');
215
+ expect(result.wordCount).toBe(12);
216
+ });
217
+
218
+ it('uses Phase-2 class*=main when Phase-1 finds nothing', () => {
219
+ const result = extract(
220
+ '<html><body><div class="page-main-area">Phase two</div></body></html>',
221
+ );
222
+
223
+ expect(result.main?.classList).toContain('page-main-area');
224
+ // "Phase two" → "Phasetwo" (8) after whitespace removal
225
+ expect(result.wordCount).toBe(8);
226
+ });
227
+
228
+ it('uses Phase-2 id*=content case-insensitively when Phase-1 finds nothing', () => {
229
+ const result = extract(
230
+ '<html><body><div id="PrimaryContent">Primary</div></body></html>',
231
+ );
232
+
233
+ expect(result.main?.id).toBe('PrimaryContent');
234
+ expect(result.wordCount).toBe(7);
235
+ });
236
+
237
+ it('prefers Phase-1 <main> over an earlier header-content that would match Phase-2', () => {
238
+ const result = extract(`
239
+ <html><body>
240
+ <div class="header-content">Chrome</div>
241
+ <main>Real main</main>
242
+ </body></html>
243
+ `);
244
+
245
+ expect(result.main?.nodeName).toBe('MAIN');
246
+ expect(result.wordCount).toBe(8);
247
+ });
248
+
249
+ it('may match class*=main on maintenance via Phase-2 (accepted false positive)', () => {
250
+ const result = extract(
251
+ '<html><body><div class="maintenance">Down</div></body></html>',
252
+ );
253
+
254
+ expect(result.main?.classList).toContain('maintenance');
255
+ expect(result.wordCount).toBe(4);
256
+ });
257
+
258
+ it('builds a simple diagnostic selector from tag, id, and classes', () => {
259
+ const result = extract(
260
+ '<html><body><div id="page-body" class="foo bar" role="main">X</div></body></html>',
261
+ );
262
+
263
+ expect(result.main?.selector).toBe('div#page-body.foo.bar');
264
+ });
265
+ });
266
+
267
+ describe('getMainContents', () => {
268
+ it('passes only the selector into page.evaluate so Document defaults in the page realm', async () => {
269
+ const evaluate = vi.fn(
270
+ (fn: typeof extractMainContentsFromDocument, sel: string | null) =>
271
+ Promise.resolve(
272
+ fn(sel, createDocument('<html><body><main>Hi</main></body></html>')),
273
+ ),
274
+ );
275
+ const page = { evaluate } as unknown as Page;
276
+
277
+ const result = await getMainContents(page, { mainContentSelector: '#x' });
278
+
279
+ expect(evaluate).toHaveBeenCalledWith(extractMainContentsFromDocument, '#x');
280
+ expect(result.wordCount).toBe(2);
281
+ });
282
+ });
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Main-content quantitative extraction for scraped HTML pages.
3
+ *
4
+ * Runs entirely against a `Document` (Puppeteer page realm or jsdom) so the
5
+ * same logic is unit-tested without a browser. Puppeteer callers use
6
+ * {@link getMainContents}, which passes this function into `page.evaluate`.
7
+ *
8
+ * WHY no `@medv/finder`: selector strings are diagnostic only; a tag+id+class // cspell:disable-line
9
+ * path is enough and avoids a Node-only dependency inside the page realm.
10
+ * @module
11
+ */
12
+
13
+ import type { MainContentsData } from './types.js';
14
+ import type { Page } from 'puppeteer';
15
+
16
+ /**
17
+ * Extract main-content metrics from a `Document`.
18
+ *
19
+ * This function must remain free of closure over imports so Puppeteer can
20
+ * serialize it into `page.evaluate`.
21
+ *
22
+ * Argument order matches `page.evaluate(fn, mainContentSelector)`: the selector
23
+ * is the first argument; `doc` defaults to the page-realm global `document`
24
+ * when omitted (jsdom tests pass an explicit `Document` as the second argument).
25
+ * @param mainContentSelector - Optional selector prepended to the default list.
26
+ * @param doc - The document to inspect (defaults to the global `document`).
27
+ * @returns Quantitative main-content data (never `null`; empty when no main region).
28
+ * @example
29
+ * ```ts
30
+ * // In page.evaluate / browser:
31
+ * const data = extractMainContentsFromDocument('#page-body');
32
+ * // In jsdom tests:
33
+ * const data = extractMainContentsFromDocument('#page-body', document);
34
+ * ```
35
+ */
36
+ export function extractMainContentsFromDocument(
37
+ mainContentSelector: string | null = null,
38
+ doc: Document = document,
39
+ ): MainContentsData {
40
+ /**
41
+ * @param text
42
+ */
43
+ function removeSpaces(text: string | null): string | null {
44
+ return text?.trim().replaceAll(/\s+/g, '') || null;
45
+ }
46
+
47
+ /**
48
+ * @param value
49
+ */
50
+ function cssEscape(value: string): string {
51
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
52
+ return CSS.escape(value);
53
+ }
54
+ return value.replaceAll(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
55
+ }
56
+
57
+ /**
58
+ * @param el
59
+ */
60
+ function buildSelector(el: Element): string {
61
+ const tag = el.nodeName.toLowerCase();
62
+ const id = el.id ? `#${cssEscape(el.id)}` : '';
63
+ const classes = [...el.classList].map((c) => `.${cssEscape(c)}`).join('');
64
+ return `${tag}${id}${classes}`;
65
+ }
66
+
67
+ /**
68
+ * @param media
69
+ */
70
+ function resolveMediaSrc(media: HTMLMediaElement): string {
71
+ if (media.currentSrc) {
72
+ return media.currentSrc;
73
+ }
74
+ const attrSrc = media.getAttribute('src');
75
+ if (attrSrc) {
76
+ try {
77
+ return new URL(attrSrc, doc.baseURI).href;
78
+ } catch {
79
+ return attrSrc;
80
+ }
81
+ }
82
+ const source = media.querySelector('source[src]');
83
+ const sourceSrc = source?.getAttribute('src');
84
+ if (sourceSrc) {
85
+ try {
86
+ return new URL(sourceSrc, doc.baseURI).href;
87
+ } catch {
88
+ return sourceSrc;
89
+ }
90
+ }
91
+ return '';
92
+ }
93
+
94
+ const title = doc.title?.trim() ?? '';
95
+ const bodyWordCount = removeSpaces(doc.body?.textContent ?? null)?.length ?? 0;
96
+
97
+ const selectors = [
98
+ 'main',
99
+ '[role="main"]',
100
+ '#main',
101
+ '.main',
102
+ '#content',
103
+ '.content',
104
+ '#contents',
105
+ '.contents',
106
+ '#main-content',
107
+ '.main-content',
108
+ '#main_content',
109
+ '.main_content',
110
+ '#mainContent',
111
+ '.mainContent',
112
+ ];
113
+ if (mainContentSelector) {
114
+ selectors.unshift(mainContentSelector);
115
+ }
116
+
117
+ let $main: Element | null = null;
118
+ try {
119
+ $main = doc.querySelector(selectors.join(','));
120
+ } catch {
121
+ // Invalid custom selector: retry without it so built-in selectors still run.
122
+ if (mainContentSelector) {
123
+ selectors.shift();
124
+ $main = doc.querySelector(selectors.join(','));
125
+ }
126
+ }
127
+
128
+ if (!$main) {
129
+ const fallbackSelectors = [
130
+ '[id*="main" i]',
131
+ '[class*="main" i]',
132
+ '[id*="content" i]',
133
+ '[class*="content" i]',
134
+ ];
135
+ for (const sel of fallbackSelectors) {
136
+ const candidate = doc.querySelector(sel);
137
+ if (candidate && candidate !== doc.body && candidate !== doc.documentElement) {
138
+ $main = candidate;
139
+ break;
140
+ }
141
+ }
142
+ }
143
+
144
+ if (!$main) {
145
+ return {
146
+ title,
147
+ main: null,
148
+ wordCount: 0,
149
+ bodyWordCount,
150
+ headings: [],
151
+ images: [],
152
+ tables: [],
153
+ buttons: [],
154
+ iframes: [],
155
+ videos: [],
156
+ audios: [],
157
+ canvases: [],
158
+ };
159
+ }
160
+
161
+ const headings: MainContentsData['headings'] = [];
162
+ for (const $heading of $main.querySelectorAll<HTMLHeadingElement>(
163
+ 'h1, h2, h3, h4, h5, h6',
164
+ )) {
165
+ headings.push({
166
+ text: removeSpaces($heading.textContent),
167
+ level: Number.parseInt($heading.nodeName.replace(/h/i, ''), 10) as
168
+ 1 | 2 | 3 | 4 | 5 | 6,
169
+ });
170
+ }
171
+
172
+ const images: MainContentsData['images'] = [];
173
+ for (const $img of $main.querySelectorAll<HTMLImageElement>(
174
+ 'img, input[type="image"]',
175
+ )) {
176
+ images.push({
177
+ src: $img.src,
178
+ alt: $img.alt,
179
+ });
180
+ }
181
+
182
+ const tables: MainContentsData['tables'] = [];
183
+ for (const $table of $main.querySelectorAll<HTMLTableElement>('table')) {
184
+ tables.push({
185
+ rows: $table.querySelectorAll('tr').length,
186
+ cols: $table.querySelector('tr')?.querySelectorAll('th, td').length || 0,
187
+ hasHeader: !!$table.querySelector('thead'),
188
+ hasFooter: !!$table.querySelector('tfoot'),
189
+ hasMergedCell: !!$table.querySelector('[colspan], [rowspan]'),
190
+ });
191
+ }
192
+
193
+ const win = doc.defaultView;
194
+ const HTMLButton = win?.HTMLButtonElement;
195
+ const HTMLInput = win?.HTMLInputElement;
196
+
197
+ const buttons: MainContentsData['buttons'] = [];
198
+ for (const $el of $main.querySelectorAll(
199
+ 'button, [role="button"], [class*="button"], [class*="btn"]',
200
+ )) {
201
+ const isButton = HTMLButton ? $el instanceof HTMLButton : $el.nodeName === 'BUTTON';
202
+ const isInput = HTMLInput ? $el instanceof HTMLInput : $el.nodeName === 'INPUT';
203
+ const isFormControl = isButton || isInput;
204
+ let text = removeSpaces($el.textContent);
205
+ if (!text && isInput) {
206
+ text = removeSpaces($el.getAttribute('value'));
207
+ }
208
+ const disabledProp =
209
+ isFormControl && 'disabled' in $el
210
+ ? Boolean(($el as HTMLButtonElement).disabled)
211
+ : false;
212
+ buttons.push({
213
+ nodeName: $el.nodeName,
214
+ role: $el.getAttribute('role'),
215
+ type: isFormControl ? ($el as HTMLButtonElement | HTMLInputElement).type : null,
216
+ text,
217
+ disabled: disabledProp || $el.getAttribute('aria-disabled') === 'true',
218
+ });
219
+ }
220
+
221
+ const iframes: MainContentsData['iframes'] = [];
222
+ for (const $el of $main.querySelectorAll<HTMLIFrameElement>('iframe')) {
223
+ iframes.push({
224
+ src: $el.src,
225
+ title: $el.hasAttribute('title') ? $el.getAttribute('title') : null,
226
+ width: $el.getAttribute('width'),
227
+ height: $el.getAttribute('height'),
228
+ });
229
+ }
230
+
231
+ const videos: MainContentsData['videos'] = [];
232
+ for (const $el of $main.querySelectorAll<HTMLVideoElement>('video')) {
233
+ const posterAttr = $el.getAttribute('poster');
234
+ let poster: string | null = null;
235
+ if (posterAttr) {
236
+ try {
237
+ poster = new URL(posterAttr, doc.baseURI).href;
238
+ } catch {
239
+ poster = posterAttr;
240
+ }
241
+ }
242
+ videos.push({
243
+ src: resolveMediaSrc($el),
244
+ poster,
245
+ width: $el.width,
246
+ height: $el.height,
247
+ });
248
+ }
249
+
250
+ const audios: MainContentsData['audios'] = [];
251
+ for (const $el of $main.querySelectorAll<HTMLAudioElement>('audio')) {
252
+ audios.push({
253
+ src: resolveMediaSrc($el),
254
+ });
255
+ }
256
+
257
+ const canvases: MainContentsData['canvases'] = [];
258
+ for (const $el of $main.querySelectorAll<HTMLCanvasElement>('canvas')) {
259
+ canvases.push({
260
+ width: $el.width,
261
+ height: $el.height,
262
+ });
263
+ }
264
+
265
+ return {
266
+ title,
267
+ main: {
268
+ nodeName: $main.nodeName,
269
+ id: $main.id || null,
270
+ classList: [...$main.classList],
271
+ role: $main.getAttribute('role'),
272
+ selector: buildSelector($main),
273
+ },
274
+ wordCount: removeSpaces($main.textContent)?.length ?? 0,
275
+ bodyWordCount,
276
+ headings,
277
+ images,
278
+ tables,
279
+ buttons,
280
+ iframes,
281
+ videos,
282
+ audios,
283
+ canvases,
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Extract main-content metrics from a Puppeteer page via a single `page.evaluate`.
289
+ * @param page - Puppeteer page whose DOM has finished loading.
290
+ * @param options - Optional main-content selector override.
291
+ * @param options.mainContentSelector
292
+ * @returns Quantitative main-content data.
293
+ * @example
294
+ * ```ts
295
+ * const mainContents = await getMainContents(page, { mainContentSelector: '#page-body' });
296
+ * ```
297
+ */
298
+ export async function getMainContents(
299
+ page: Page,
300
+ options?: { mainContentSelector?: string | null },
301
+ ): Promise<MainContentsData> {
302
+ return page.evaluate(
303
+ extractMainContentsFromDocument,
304
+ options?.mainContentSelector ?? null,
305
+ );
306
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,17 @@ export type {
27
27
  ImageElement,
28
28
  SkippedPageData,
29
29
  NetworkLog,
30
+ ScrollHeightData,
31
+ MainContentsData,
32
+ MainContentsMainTag,
33
+ MainContentsHeading,
34
+ MainContentsImage,
35
+ MainContentsTable,
36
+ MainContentsButton,
37
+ MainContentsIframe,
38
+ MainContentsVideo,
39
+ MainContentsAudio,
40
+ MainContentsCanvas,
30
41
  OpenGraphMeta,
31
42
  OgArticleMeta,
32
43
  OgBookMeta,
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { isHtmlContentType } from './is-html-content-type.js';
4
+
5
+ describe('isHtmlContentType', () => {
6
+ it('accepts text/html in any letter case and with surrounding whitespace', () => {
7
+ expect(isHtmlContentType('text/html')).toBe(true);
8
+ expect(isHtmlContentType('Text/HTML')).toBe(true);
9
+ expect(isHtmlContentType(' text/html ')).toBe(true);
10
+ });
11
+
12
+ it('rejects non-HTML and null', () => {
13
+ expect(isHtmlContentType('application/pdf')).toBe(false);
14
+ expect(isHtmlContentType('text/plain')).toBe(false);
15
+ expect(isHtmlContentType(null)).toBe(false);
16
+ });
17
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Determine whether a Content-Type media type is HTML.
3
+ *
4
+ * MIME types are case-insensitive (RFC 2045). Surrounding whitespace after
5
+ * parameter stripping is also tolerated so `text/html `, `Text/HTML`, and
6
+ * `text/html; charset=utf-8` (after `;` split) all classify as HTML.
7
+ * @param contentType - The media type portion of a Content-Type header
8
+ * (parameters already stripped), or `null` when unknown.
9
+ * @returns `true` when the media type is `text/html` in any letter case.
10
+ * @example
11
+ * ```ts
12
+ * isHtmlContentType('text/html'); // true
13
+ * isHtmlContentType('Text/HTML'); // true
14
+ * isHtmlContentType('application/pdf'); // false
15
+ * // Callers should strip parameters first (e.g. split on ';') before calling.
16
+ * ```
17
+ */
18
+ export function isHtmlContentType(contentType: string | null): boolean {
19
+ return contentType !== null && contentType.trim().toLowerCase() === 'text/html';
20
+ }
@@ -0,0 +1,47 @@
1
+ import type { Page } from 'puppeteer';
2
+
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { measureScrollHeight } from './measure-scroll-height.js';
6
+
7
+ describe('measureScrollHeight', () => {
8
+ let page: Page;
9
+ let scrollHeights: number[];
10
+
11
+ beforeEach(() => {
12
+ scrollHeights = [2400, 5200];
13
+ let call = 0;
14
+ page = {
15
+ setViewport: vi.fn(() => Promise.resolve()),
16
+ evaluate: vi.fn(() => Promise.resolve(scrollHeights[call++] ?? 0)),
17
+ } as unknown as Page;
18
+ });
19
+
20
+ it('returns desktop and mobile heights without scrolling the page', async () => {
21
+ const result = await measureScrollHeight(page);
22
+
23
+ expect(result).toEqual({ desktop: 2400, mobile: 5200 });
24
+ expect(page.setViewport).toHaveBeenCalledTimes(2);
25
+ expect(page.setViewport).toHaveBeenNthCalledWith(1, {
26
+ width: 1280,
27
+ height: 800,
28
+ deviceScaleFactor: 1,
29
+ });
30
+ expect(page.setViewport).toHaveBeenNthCalledWith(2, {
31
+ width: 320,
32
+ height: 800,
33
+ deviceScaleFactor: 2,
34
+ });
35
+ });
36
+
37
+ it('returns null for a preset that throws', async () => {
38
+ page.setViewport = vi
39
+ .fn()
40
+ .mockResolvedValueOnce()
41
+ .mockRejectedValueOnce(new Error('viewport failed'));
42
+
43
+ const result = await measureScrollHeight(page);
44
+
45
+ expect(result).toEqual({ desktop: 2400, mobile: null });
46
+ });
47
+ });