@d-zero/beholder 2.1.5 → 2.1.6

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,293 @@
1
+ import type { ElementHandle, Page } from 'puppeteer';
2
+
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import {
6
+ DEFAULT_DOM_EVALUATION_TIMEOUT,
7
+ getAnchorList,
8
+ getImageList,
9
+ getMeta,
10
+ getProp,
11
+ } from './dom-evaluation.js';
12
+
13
+ afterEach(() => {
14
+ vi.useRealTimers();
15
+ });
16
+
17
+ /**
18
+ * Builds a minimal `Page` mock whose `evaluate` resolves with the given value.
19
+ * @param value
20
+ */
21
+ function mockPageEvaluate(value: unknown): Page {
22
+ return {
23
+ evaluate: () => Promise.resolve(value),
24
+ } as unknown as Page;
25
+ }
26
+
27
+ /**
28
+ * Builds an `ElementHandle` mock returning the given property value.
29
+ * @param value
30
+ */
31
+ function mockElementHandle(value: unknown): ElementHandle<Element> {
32
+ return {
33
+ getProperty: () =>
34
+ Promise.resolve({
35
+ jsonValue: () => Promise.resolve(value),
36
+ }),
37
+ } as unknown as ElementHandle<Element>;
38
+ }
39
+
40
+ describe('getMeta', () => {
41
+ it('maps raw evaluation result into a Meta object and parses robots directives', async () => {
42
+ const page = mockPageEvaluate({
43
+ title: 'Example',
44
+ lang: 'ja',
45
+ description: 'desc',
46
+ keywords: 'a,b',
47
+ robots: 'noindex, NOFOLLOW',
48
+ canonical: 'https://example.com/',
49
+ alternate: 'https://example.com/en',
50
+ 'og:type': 'website',
51
+ 'og:title': 'OG Title',
52
+ 'og:site_name': 'Site',
53
+ 'og:description': 'OG desc',
54
+ 'og:url': 'https://example.com/',
55
+ 'og:image': 'https://example.com/img.png',
56
+ 'twitter:card': 'summary',
57
+ });
58
+
59
+ const meta = await getMeta(page);
60
+
61
+ expect(meta).toStrictEqual({
62
+ title: 'Example',
63
+ lang: 'ja',
64
+ description: 'desc',
65
+ keywords: 'a,b',
66
+ noindex: true,
67
+ nofollow: true,
68
+ noarchive: false,
69
+ canonical: 'https://example.com/',
70
+ alternate: 'https://example.com/en',
71
+ 'og:type': 'website',
72
+ 'og:title': 'OG Title',
73
+ 'og:site_name': 'Site',
74
+ 'og:description': 'OG desc',
75
+ 'og:url': 'https://example.com/',
76
+ 'og:image': 'https://example.com/img.png',
77
+ 'twitter:card': 'summary',
78
+ });
79
+ });
80
+
81
+ it('returns a minimal fallback when evaluation rejects', async () => {
82
+ const page = {
83
+ evaluate: () => Promise.reject(new Error('execution context destroyed')),
84
+ } as unknown as Page;
85
+
86
+ const meta = await getMeta(page);
87
+
88
+ expect(meta).toStrictEqual({ title: '' });
89
+ });
90
+
91
+ it('returns a minimal fallback when the main thread is unresponsive (timeout)', async () => {
92
+ vi.useFakeTimers();
93
+ const page = {
94
+ // Never resolves — simulates a blocked main thread.
95
+ evaluate: () => new Promise(() => {}),
96
+ } as unknown as Page;
97
+
98
+ const promise = getMeta(page, 5000);
99
+ await vi.advanceTimersByTimeAsync(5000);
100
+ const meta = await promise;
101
+
102
+ expect(meta).toStrictEqual({ title: '' });
103
+ expect(vi.getTimerCount()).toBe(0);
104
+ });
105
+ });
106
+
107
+ describe('getImageList', () => {
108
+ it('maps raw images, deriving isLazy and recording the viewport width', async () => {
109
+ const page = mockPageEvaluate([
110
+ {
111
+ src: 'https://example.com/a.png',
112
+ currentSrc: 'https://example.com/a.png',
113
+ alt: 'A',
114
+ width: 100,
115
+ height: 50,
116
+ naturalWidth: 200,
117
+ naturalHeight: 100,
118
+ loading: 'LAZY',
119
+ sourceCode: '<img>',
120
+ },
121
+ {
122
+ src: 'https://example.com/b.png',
123
+ currentSrc: 'https://example.com/b.png',
124
+ alt: 'B',
125
+ width: 0,
126
+ height: 0,
127
+ naturalWidth: 0,
128
+ naturalHeight: 0,
129
+ loading: 'eager',
130
+ sourceCode: '<img>',
131
+ },
132
+ ]);
133
+
134
+ const images = await getImageList(page, 375);
135
+
136
+ expect(images).toStrictEqual([
137
+ {
138
+ src: 'https://example.com/a.png',
139
+ currentSrc: 'https://example.com/a.png',
140
+ alt: 'A',
141
+ width: 100,
142
+ height: 50,
143
+ naturalWidth: 200,
144
+ naturalHeight: 100,
145
+ isLazy: true,
146
+ viewportWidth: 375,
147
+ sourceCode: '<img>',
148
+ },
149
+ {
150
+ src: 'https://example.com/b.png',
151
+ currentSrc: 'https://example.com/b.png',
152
+ alt: 'B',
153
+ width: 0,
154
+ height: 0,
155
+ naturalWidth: 0,
156
+ naturalHeight: 0,
157
+ isLazy: false,
158
+ viewportWidth: 375,
159
+ sourceCode: '<img>',
160
+ },
161
+ ]);
162
+ });
163
+
164
+ it('returns an empty array when extraction rejects', async () => {
165
+ const page = {
166
+ evaluate: () => Promise.reject(new Error('execution context destroyed')),
167
+ } as unknown as Page;
168
+
169
+ const images = await getImageList(page, 375);
170
+
171
+ expect(images).toStrictEqual([]);
172
+ });
173
+
174
+ it('returns an empty array (not a failure fallback) for a page with no images', async () => {
175
+ const page = mockPageEvaluate([]);
176
+
177
+ const images = await getImageList(page, 375);
178
+
179
+ expect(images).toStrictEqual([]);
180
+ });
181
+
182
+ it('returns an empty array when extraction times out', async () => {
183
+ vi.useFakeTimers();
184
+ const page = {
185
+ evaluate: () => new Promise(() => {}),
186
+ } as unknown as Page;
187
+
188
+ const promise = getImageList(page, 375, 5000);
189
+ await vi.advanceTimersByTimeAsync(5000);
190
+ const images = await promise;
191
+
192
+ expect(images).toStrictEqual([]);
193
+ expect(vi.getTimerCount()).toBe(0);
194
+ });
195
+ });
196
+
197
+ describe('getProp', () => {
198
+ it('returns the property value and clears the loser-side timer', async () => {
199
+ vi.useFakeTimers();
200
+ const $el = mockElementHandle('hello');
201
+
202
+ const result = await getProp({ $el, propName: 'textContent', fallback: '' });
203
+
204
+ expect(result).toBe('hello');
205
+ // raceWithTimeout must clear the timeout it lost so it cannot keep the event loop alive.
206
+ expect(vi.getTimerCount()).toBe(0);
207
+ });
208
+
209
+ it('returns the fallback when property retrieval throws', async () => {
210
+ const $el = {
211
+ getProperty: () => Promise.reject(new Error('detached')),
212
+ } as unknown as ElementHandle<Element>;
213
+
214
+ const result = await getProp({ $el, propName: 'textContent', fallback: 'fb' });
215
+
216
+ expect(result).toBe('fb');
217
+ });
218
+
219
+ it('returns the fallback when retrieval hangs past the timeout', async () => {
220
+ vi.useFakeTimers();
221
+ const $el = {
222
+ getProperty: () => new Promise(() => {}),
223
+ } as unknown as ElementHandle<Element>;
224
+
225
+ const promise = getProp({ $el, propName: 'textContent', fallback: 'fb' }, 5000);
226
+ await vi.advanceTimersByTimeAsync(5000);
227
+ const result = await promise;
228
+
229
+ expect(result).toBe('fb');
230
+ expect(vi.getTimerCount()).toBe(0);
231
+ });
232
+ });
233
+
234
+ describe('getAnchorList', () => {
235
+ it('resolves the href and prefers the accessible name from the accessibility tree', async () => {
236
+ const $anchor = mockElementHandle('https://example.com/page');
237
+ const page = {
238
+ $$: () => Promise.resolve([$anchor]),
239
+ accessibility: {
240
+ snapshot: () => Promise.resolve({ name: 'Accessible Name' }),
241
+ },
242
+ } as unknown as Page;
243
+
244
+ const anchors = await getAnchorList(page);
245
+
246
+ expect(anchors).toHaveLength(1);
247
+ expect(anchors[0]?.textContent).toBe('Accessible Name');
248
+ expect(anchors[0]?.href.href).toBe('https://example.com/page');
249
+ });
250
+
251
+ it('falls back to trimmed textContent when the accessibility tree has no node', async () => {
252
+ const $anchor = {
253
+ getProperty: vi
254
+ .fn()
255
+ // First getProp call reads `href`, second reads `textContent`.
256
+ .mockResolvedValueOnce({
257
+ jsonValue: () => Promise.resolve('https://example.com/page'),
258
+ })
259
+ .mockResolvedValueOnce({ jsonValue: () => Promise.resolve(' Link text ') }),
260
+ } as unknown as ElementHandle<Element>;
261
+ const page = {
262
+ $$: () => Promise.resolve([$anchor]),
263
+ accessibility: {
264
+ snapshot: () => Promise.resolve(null),
265
+ },
266
+ } as unknown as Page;
267
+
268
+ const anchors = await getAnchorList(page);
269
+
270
+ expect(anchors).toHaveLength(1);
271
+ expect(anchors[0]?.textContent).toBe('Link text');
272
+ });
273
+
274
+ it('skips non-HTTP links', async () => {
275
+ const $anchor = mockElementHandle('javascript:void(0)');
276
+ const page = {
277
+ $$: () => Promise.resolve([$anchor]),
278
+ accessibility: {
279
+ snapshot: () => Promise.resolve(null),
280
+ },
281
+ } as unknown as Page;
282
+
283
+ const anchors = await getAnchorList(page);
284
+
285
+ expect(anchors).toStrictEqual([]);
286
+ });
287
+ });
288
+
289
+ describe('DEFAULT_DOM_EVALUATION_TIMEOUT', () => {
290
+ it('defaults to 30 seconds', () => {
291
+ expect(DEFAULT_DOM_EVALUATION_TIMEOUT).toBe(30_000);
292
+ });
293
+ });