@link-assistant/web-capture 1.1.2 → 1.4.2

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +17 -14
  2. package/README.md +256 -146
  3. package/bin/web-capture.js +403 -6
  4. package/package.json +11 -4
  5. package/src/animation.js +283 -0
  6. package/src/archive.js +212 -0
  7. package/src/batch.js +180 -0
  8. package/src/browser.js +150 -111
  9. package/src/docx.js +182 -0
  10. package/src/figures.js +208 -0
  11. package/src/gdocs.js +333 -0
  12. package/src/image.js +65 -8
  13. package/src/index.js +15 -0
  14. package/src/latex.js +127 -0
  15. package/src/lib.js +226 -1
  16. package/src/localize-images.js +202 -0
  17. package/src/metadata.js +333 -0
  18. package/src/pdf.js +76 -0
  19. package/src/popups.js +171 -0
  20. package/src/postprocess.js +265 -0
  21. package/src/retry.js +48 -0
  22. package/src/themed-image.js +167 -0
  23. package/src/verify.js +312 -0
  24. package/tests/e2e/docker.test.js +51 -12
  25. package/tests/e2e/process.test.js +34 -10
  26. package/tests/fixtures/habr-articles.js +45 -0
  27. package/tests/integration/api-endpoints.test.js +231 -0
  28. package/tests/integration/habr-article.test.js +337 -0
  29. package/tests/mock/index.test.js +5 -3
  30. package/tests/unit/animation.test.js +33 -0
  31. package/tests/unit/batch.test.js +114 -0
  32. package/tests/unit/browser.test.js +38 -2
  33. package/tests/unit/figures.test.js +74 -0
  34. package/tests/unit/gdocs.test.js +203 -0
  35. package/tests/unit/latex.test.js +123 -0
  36. package/tests/unit/localize-images.test.js +78 -0
  37. package/tests/unit/metadata.test.js +140 -0
  38. package/tests/unit/postprocess.test.js +108 -0
  39. package/tests/unit/retry.test.js +111 -0
  40. package/tests/unit/verify.test.js +182 -0
  41. package/.github/workflows/release.yml +0 -294
  42. package/ARCHITECTURE.md +0 -982
  43. package/LICENSE +0 -24
  44. package/apply.sh +0 -166
  45. package/docs/market-analysis-2025-05-13.md +0 -64
  46. package/scripts/changeset-version.mjs +0 -38
  47. package/scripts/create-github-release.mjs +0 -93
  48. package/scripts/create-manual-changeset.mjs +0 -88
  49. package/scripts/format-github-release.mjs +0 -83
  50. package/scripts/format-release-notes.mjs +0 -218
  51. package/scripts/instant-version-bump.mjs +0 -121
  52. package/scripts/publish-to-npm.mjs +0 -128
  53. package/scripts/setup-npm.mjs +0 -37
  54. package/scripts/validate-changeset.mjs +0 -106
  55. package/scripts/version-and-commit.mjs +0 -237
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Integration tests for the API endpoints.
3
+ *
4
+ * Tests /image, /archive, /pdf, /docx endpoints with various options.
5
+ * Uses nock to mock external requests for fast, reliable unit-level testing.
6
+ * These tests validate endpoint behavior and response formats.
7
+ */
8
+
9
+ import { jest } from '@jest/globals';
10
+ import request from 'supertest';
11
+ import nock from 'nock';
12
+ import { app } from '../../src/index.js';
13
+
14
+ jest.setTimeout(60000);
15
+
16
+ const MOCK_HTML = `<!DOCTYPE html>
17
+ <html><head><title>Test</title>
18
+ <link rel="stylesheet" href="https://example.com/style.css">
19
+ </head>
20
+ <body>
21
+ <h1>Test Page</h1>
22
+ <p>Hello world</p>
23
+ <img src="https://example.com/image.png" alt="test">
24
+ </body></html>`;
25
+
26
+ afterEach(() => {
27
+ nock.cleanAll();
28
+ });
29
+
30
+ describe('API Endpoint Tests', () => {
31
+ describe('GET /image', () => {
32
+ it('rejects invalid format', async () => {
33
+ await request(app)
34
+ .get('/image')
35
+ .query({ url: 'https://example.com/test', format: 'bmp' })
36
+ .expect(400);
37
+ });
38
+
39
+ it('returns 400 without url', async () => {
40
+ await request(app).get('/image').expect(400);
41
+ });
42
+ });
43
+
44
+ describe('GET /markdown', () => {
45
+ it('returns markdown', async () => {
46
+ nock('https://example.com').get('/md-test').reply(200, MOCK_HTML, {
47
+ 'content-type': 'text/html',
48
+ });
49
+
50
+ const res = await request(app)
51
+ .get('/markdown')
52
+ .query({ url: 'https://example.com/md-test' })
53
+ .expect(200);
54
+
55
+ expect(res.headers['content-type']).toMatch(/text\/markdown/);
56
+ expect(res.text).toContain('Test Page');
57
+ expect(res.text).toContain('Hello world');
58
+ });
59
+ });
60
+
61
+ describe('GET /archive', () => {
62
+ it('returns a ZIP archive with remote images (markdown format)', async () => {
63
+ nock('https://example.com').get('/archive-test').reply(200, MOCK_HTML, {
64
+ 'content-type': 'text/html',
65
+ });
66
+
67
+ const res = await request(app)
68
+ .get('/archive')
69
+ .query({
70
+ url: 'https://example.com/archive-test',
71
+ localImages: 'false',
72
+ documentFormat: 'markdown',
73
+ })
74
+ .buffer(true)
75
+ .parse((res, callback) => {
76
+ const chunks = [];
77
+ res.on('data', (chunk) => chunks.push(chunk));
78
+ res.on('end', () => callback(null, Buffer.concat(chunks)));
79
+ })
80
+ .expect(200);
81
+
82
+ expect(res.body).toBeInstanceOf(Buffer);
83
+ expect(res.body.length).toBeGreaterThan(50);
84
+ // ZIP signature: PK (0x50 0x4B)
85
+ expect(res.body[0]).toBe(0x50);
86
+ expect(res.body[1]).toBe(0x4b);
87
+ });
88
+
89
+ it('returns a ZIP archive with HTML format', async () => {
90
+ nock('https://example.com')
91
+ .get('/archive-html-test')
92
+ .reply(200, MOCK_HTML, {
93
+ 'content-type': 'text/html',
94
+ });
95
+
96
+ const res = await request(app)
97
+ .get('/archive')
98
+ .query({
99
+ url: 'https://example.com/archive-html-test',
100
+ localImages: 'false',
101
+ documentFormat: 'html',
102
+ })
103
+ .buffer(true)
104
+ .parse((res, callback) => {
105
+ const chunks = [];
106
+ res.on('data', (chunk) => chunks.push(chunk));
107
+ res.on('end', () => callback(null, Buffer.concat(chunks)));
108
+ })
109
+ .expect(200);
110
+
111
+ expect(res.body).toBeInstanceOf(Buffer);
112
+ expect(res.body.length).toBeGreaterThan(50);
113
+ // ZIP signature
114
+ expect(res.body[0]).toBe(0x50);
115
+ expect(res.body[1]).toBe(0x4b);
116
+ });
117
+
118
+ it('returns a ZIP archive with local images and folders', async () => {
119
+ const mockPng = Buffer.from([
120
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
121
+ ]);
122
+
123
+ nock('https://example.com')
124
+ .get('/archive-local-test')
125
+ .reply(200, MOCK_HTML, { 'content-type': 'text/html' });
126
+
127
+ nock('https://example.com')
128
+ .get('/image.png')
129
+ .reply(200, mockPng, { 'content-type': 'image/png' });
130
+
131
+ const res = await request(app)
132
+ .get('/archive')
133
+ .query({
134
+ url: 'https://example.com/archive-local-test',
135
+ localImages: 'true',
136
+ })
137
+ .buffer(true)
138
+ .parse((res, callback) => {
139
+ const chunks = [];
140
+ res.on('data', (chunk) => chunks.push(chunk));
141
+ res.on('end', () => callback(null, Buffer.concat(chunks)));
142
+ })
143
+ .expect(200);
144
+
145
+ expect(res.body).toBeInstanceOf(Buffer);
146
+ expect(res.body.length).toBeGreaterThan(50);
147
+ // ZIP signature
148
+ expect(res.body[0]).toBe(0x50);
149
+ expect(res.body[1]).toBe(0x4b);
150
+ });
151
+
152
+ it('returns a ZIP archive with HTML format and local CSS', async () => {
153
+ const mockCss = 'body { background: #fff; }';
154
+ const mockPng = Buffer.from([
155
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
156
+ ]);
157
+
158
+ nock('https://example.com')
159
+ .get('/archive-html-local')
160
+ .reply(200, MOCK_HTML, { 'content-type': 'text/html' });
161
+
162
+ nock('https://example.com')
163
+ .get('/style.css')
164
+ .reply(200, mockCss, { 'content-type': 'text/css' });
165
+
166
+ nock('https://example.com')
167
+ .get('/image.png')
168
+ .reply(200, mockPng, { 'content-type': 'image/png' });
169
+
170
+ const res = await request(app)
171
+ .get('/archive')
172
+ .query({
173
+ url: 'https://example.com/archive-html-local',
174
+ localImages: 'true',
175
+ documentFormat: 'html',
176
+ })
177
+ .buffer(true)
178
+ .parse((res, callback) => {
179
+ const chunks = [];
180
+ res.on('data', (chunk) => chunks.push(chunk));
181
+ res.on('end', () => callback(null, Buffer.concat(chunks)));
182
+ })
183
+ .expect(200);
184
+
185
+ expect(res.body).toBeInstanceOf(Buffer);
186
+ expect(res.body.length).toBeGreaterThan(50);
187
+ // ZIP signature
188
+ expect(res.body[0]).toBe(0x50);
189
+ expect(res.body[1]).toBe(0x4b);
190
+ });
191
+
192
+ it('returns 400 without url', async () => {
193
+ await request(app).get('/archive').expect(400);
194
+ });
195
+ });
196
+
197
+ describe('GET /docx', () => {
198
+ it('returns a DOCX document', async () => {
199
+ nock('https://example.com').get('/docx-test').reply(200, MOCK_HTML, {
200
+ 'content-type': 'text/html',
201
+ });
202
+
203
+ const res = await request(app)
204
+ .get('/docx')
205
+ .query({ url: 'https://example.com/docx-test' })
206
+ .buffer(true)
207
+ .parse((res, callback) => {
208
+ const chunks = [];
209
+ res.on('data', (chunk) => chunks.push(chunk));
210
+ res.on('end', () => callback(null, Buffer.concat(chunks)));
211
+ })
212
+ .expect(200);
213
+
214
+ expect(res.body).toBeInstanceOf(Buffer);
215
+ expect(res.body.length).toBeGreaterThan(100);
216
+ // DOCX is a ZIP file
217
+ expect(res.body[0]).toBe(0x50);
218
+ expect(res.body[1]).toBe(0x4b);
219
+ });
220
+
221
+ it('returns 400 without url', async () => {
222
+ await request(app).get('/docx').expect(400);
223
+ });
224
+ });
225
+
226
+ describe('GET /pdf', () => {
227
+ it('returns 400 without url', async () => {
228
+ await request(app).get('/pdf').expect(400);
229
+ });
230
+ });
231
+ });
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Integration tests for Habr article downloads.
3
+ *
4
+ * Tests that the web-capture service can successfully download real-world content
5
+ * from Habr.com using both Puppeteer and Playwright engines across all three
6
+ * archived articles from link-foundation/meta-theory.
7
+ *
8
+ * These tests hit live servers. Set HABR_INTEGRATION=true to run them.
9
+ * In CI they are enabled via the workflow environment variable.
10
+ *
11
+ * Tests use retry with exponential backoff to handle transient network failures.
12
+ * Browser instances are shared within describe blocks to reduce startup overhead.
13
+ *
14
+ * Covers:
15
+ * - Markdown conversion (fast, no browser needed)
16
+ * - PNG and JPEG screenshots (light and dark themes)
17
+ * - Full-page captures
18
+ * - Viewport configuration
19
+ * - Engine comparison
20
+ * - Archive endpoint with documentFormat option (html/markdown)
21
+ */
22
+
23
+ import { jest } from '@jest/globals';
24
+ import request from 'supertest';
25
+ import { app } from '../../src/index.js';
26
+ import { createBrowser } from '../../src/browser.js';
27
+ import { fetchHtml, convertHtmlToMarkdown } from '../../src/lib.js';
28
+ import { dismissPopups, scrollToLoadContent } from '../../src/popups.js';
29
+ import { retry } from '../../src/retry.js';
30
+ import { getAllArticles, HABR_ARTICLES } from '../fixtures/habr-articles.js';
31
+
32
+ const SKIP_LIVE =
33
+ !process.env.HABR_INTEGRATION || process.env.HABR_INTEGRATION === 'false';
34
+ const describeIfLive = SKIP_LIVE ? describe.skip : describe;
35
+
36
+ // These tests hit live servers and may take a while
37
+ jest.setTimeout(120000);
38
+
39
+ // Helper: navigate to URL with retry and exponential backoff
40
+ // Uses 'domcontentloaded' instead of 'networkidle0' because Habr pages have
41
+ // persistent ad/tracking network activity that prevents networkidle0 from firing.
42
+ async function navigateWithRetry(page, url) {
43
+ await retry(
44
+ async () => {
45
+ await page.goto(url, {
46
+ waitUntil: 'domcontentloaded',
47
+ timeout: 60000,
48
+ });
49
+ },
50
+ {
51
+ retries: 3,
52
+ baseDelay: 2000,
53
+ onRetry: (err, attempt, delay) => {
54
+ console.log(
55
+ `Navigation retry ${attempt} for ${url} after ${delay}ms: ${err.message}`
56
+ );
57
+ },
58
+ }
59
+ );
60
+ // Wait for page content and images to load after DOM is ready
61
+ await new Promise((r) => setTimeout(r, 5000));
62
+ }
63
+
64
+ describe('Habr Article Download Tests', () => {
65
+ // Use article 0.0.2 as the primary test article (English, most recent)
66
+ const primaryArticle = HABR_ARTICLES['0.0.2'];
67
+
68
+ describeIfLive('Markdown Download (fetch only)', () => {
69
+ it('downloads article 0.0.2 as markdown', async () => {
70
+ const html = await retry(() => fetchHtml(primaryArticle.url), {
71
+ retries: 3,
72
+ baseDelay: 2000,
73
+ });
74
+ expect(html.length).toBeGreaterThan(1000);
75
+
76
+ const markdown = convertHtmlToMarkdown(html, primaryArticle.url);
77
+ expect(markdown.length).toBeGreaterThan(100);
78
+ // Should contain markdown headings
79
+ expect(markdown).toMatch(/^#{1,3}\s/m);
80
+ // Should contain links
81
+ expect(markdown).toMatch(/\[.*?\]\(.*?\)/);
82
+ });
83
+
84
+ it('downloads all 3 articles as markdown', async () => {
85
+ for (const article of getAllArticles()) {
86
+ const html = await retry(() => fetchHtml(article.url), {
87
+ retries: 3,
88
+ baseDelay: 2000,
89
+ });
90
+ expect(html.length).toBeGreaterThan(1000);
91
+
92
+ const markdown = convertHtmlToMarkdown(html, article.url);
93
+ expect(markdown.length).toBeGreaterThan(100);
94
+ expect(markdown).toMatch(/^#{1,3}\s/m);
95
+ }
96
+ });
97
+ });
98
+
99
+ describeIfLive('Screenshots with Puppeteer', () => {
100
+ let browser;
101
+ let page;
102
+
103
+ beforeAll(async () => {
104
+ browser = await createBrowser('puppeteer');
105
+ page = await browser.newPage();
106
+ await page.setViewport({ width: 1280, height: 800 });
107
+ await navigateWithRetry(page, primaryArticle.url);
108
+ await dismissPopups(page);
109
+ });
110
+
111
+ afterAll(async () => {
112
+ if (browser) {
113
+ await browser.close();
114
+ }
115
+ });
116
+
117
+ it('captures article as PNG screenshot', async () => {
118
+ const buffer = await page.screenshot({ type: 'png' });
119
+ expect(buffer).toBeTruthy();
120
+ expect(buffer.length).toBeGreaterThan(10000);
121
+ // PNG signature: 137 80 78 71
122
+ expect(buffer[0]).toBe(137);
123
+ expect(buffer[1]).toBe(80);
124
+ expect(buffer[2]).toBe(78);
125
+ expect(buffer[3]).toBe(71);
126
+ });
127
+
128
+ it('captures article as JPEG screenshot', async () => {
129
+ const buffer = await page.screenshot({ type: 'jpeg', quality: 80 });
130
+ expect(buffer).toBeTruthy();
131
+ expect(buffer.length).toBeGreaterThan(5000);
132
+ // JPEG signature: 0xFF 0xD8 0xFF
133
+ expect(buffer[0]).toBe(0xff);
134
+ expect(buffer[1]).toBe(0xd8);
135
+ expect(buffer[2]).toBe(0xff);
136
+ });
137
+
138
+ it('captures full page screenshot', async () => {
139
+ await scrollToLoadContent(page);
140
+
141
+ const viewportBuffer = await page.screenshot({ type: 'png' });
142
+ const fullPageBuffer = await page.screenshot({
143
+ type: 'png',
144
+ fullPage: true,
145
+ });
146
+
147
+ // Full page should be significantly larger than viewport-only
148
+ expect(fullPageBuffer.length).toBeGreaterThan(viewportBuffer.length);
149
+ });
150
+
151
+ it('captures with custom viewport width (1920px)', async () => {
152
+ await page.setViewport({ width: 1920, height: 1080 });
153
+ const buffer = await page.screenshot({ type: 'png' });
154
+ expect(buffer).toBeTruthy();
155
+ expect(buffer.length).toBeGreaterThan(10000);
156
+ // Restore original viewport
157
+ await page.setViewport({ width: 1280, height: 800 });
158
+ });
159
+ });
160
+
161
+ describeIfLive('Screenshots with Playwright', () => {
162
+ let browser;
163
+ let page;
164
+
165
+ beforeAll(async () => {
166
+ browser = await createBrowser('playwright');
167
+ page = await browser.newPage();
168
+ await page.setViewport({ width: 1280, height: 800 });
169
+ await navigateWithRetry(page, primaryArticle.url);
170
+ await dismissPopups(page);
171
+ });
172
+
173
+ afterAll(async () => {
174
+ if (browser) {
175
+ await browser.close();
176
+ }
177
+ });
178
+
179
+ it('captures article as PNG screenshot', async () => {
180
+ const buffer = await page.screenshot({ type: 'png' });
181
+ expect(buffer).toBeTruthy();
182
+ expect(buffer.length).toBeGreaterThan(10000);
183
+ expect(buffer[0]).toBe(137);
184
+ });
185
+
186
+ it('captures article as JPEG screenshot', async () => {
187
+ const buffer = await page.screenshot({ type: 'jpeg', quality: 80 });
188
+ expect(buffer).toBeTruthy();
189
+ expect(buffer.length).toBeGreaterThan(5000);
190
+ expect(buffer[0]).toBe(0xff);
191
+ expect(buffer[1]).toBe(0xd8);
192
+ });
193
+ });
194
+
195
+ describeIfLive('Theme Support (Playwright)', () => {
196
+ it.each(['light', 'dark'])(
197
+ 'captures %s theme screenshot',
198
+ async (theme) => {
199
+ const browser = await createBrowser('playwright', {
200
+ colorScheme: theme,
201
+ });
202
+ try {
203
+ const page = await browser.newPage();
204
+ await page.setViewport({ width: 1280, height: 800 });
205
+ await navigateWithRetry(page, primaryArticle.url);
206
+ await scrollToLoadContent(page);
207
+ await dismissPopups(page);
208
+
209
+ const buffer = await page.screenshot({
210
+ type: 'png',
211
+ fullPage: true,
212
+ });
213
+ expect(buffer).toBeTruthy();
214
+ expect(buffer.length).toBeGreaterThan(50000);
215
+ } finally {
216
+ await browser.close();
217
+ }
218
+ }
219
+ );
220
+ });
221
+
222
+ describeIfLive('All Three Articles (Puppeteer)', () => {
223
+ let browser;
224
+
225
+ beforeAll(async () => {
226
+ browser = await createBrowser('puppeteer');
227
+ });
228
+
229
+ afterAll(async () => {
230
+ if (browser) {
231
+ await browser.close();
232
+ }
233
+ });
234
+
235
+ it.each(getAllArticles().map((a) => [a.version, a.url]))(
236
+ 'captures article %s as PNG screenshot',
237
+ async (version, url) => {
238
+ const page = await browser.newPage();
239
+ try {
240
+ await page.setViewport({ width: 1280, height: 800 });
241
+ await navigateWithRetry(page, url);
242
+ await dismissPopups(page);
243
+
244
+ const buffer = await page.screenshot({ type: 'png' });
245
+ expect(buffer.length).toBeGreaterThan(10000);
246
+ expect(buffer[0]).toBe(137);
247
+ } finally {
248
+ await page.close();
249
+ }
250
+ }
251
+ );
252
+ });
253
+
254
+ describeIfLive('Popup Dismissal (Playwright)', () => {
255
+ it('dismisses popups before capture', async () => {
256
+ const browser = await createBrowser('playwright');
257
+ try {
258
+ const page = await browser.newPage();
259
+ await page.setViewport({ width: 1280, height: 800 });
260
+ await navigateWithRetry(page, primaryArticle.url);
261
+
262
+ await dismissPopups(page);
263
+
264
+ const buffer = await page.screenshot({ type: 'png' });
265
+ expect(buffer.length).toBeGreaterThan(10000);
266
+ } finally {
267
+ await browser.close();
268
+ }
269
+ });
270
+ });
271
+
272
+ describeIfLive('Archive Endpoint (live)', () => {
273
+ it('creates markdown archive of article', async () => {
274
+ const res = await retry(
275
+ async () => {
276
+ const r = await request(app)
277
+ .get('/archive')
278
+ .query({
279
+ url: primaryArticle.url,
280
+ localImages: 'false',
281
+ documentFormat: 'markdown',
282
+ })
283
+ .buffer(true)
284
+ .parse((res, callback) => {
285
+ const chunks = [];
286
+ res.on('data', (chunk) => chunks.push(chunk));
287
+ res.on('end', () => callback(null, Buffer.concat(chunks)));
288
+ })
289
+ .timeout({ response: 30000 });
290
+ if (r.status !== 200) {
291
+ throw new Error(`Archive returned status ${r.status}`);
292
+ }
293
+ return r;
294
+ },
295
+ { retries: 2, baseDelay: 3000 }
296
+ );
297
+
298
+ expect(res.body).toBeInstanceOf(Buffer);
299
+ expect(res.body.length).toBeGreaterThan(50);
300
+ // ZIP signature: PK (0x50 0x4B)
301
+ expect(res.body[0]).toBe(0x50);
302
+ expect(res.body[1]).toBe(0x4b);
303
+ });
304
+
305
+ it('creates HTML archive of article', async () => {
306
+ const res = await retry(
307
+ async () => {
308
+ const r = await request(app)
309
+ .get('/archive')
310
+ .query({
311
+ url: primaryArticle.url,
312
+ localImages: 'false',
313
+ documentFormat: 'html',
314
+ })
315
+ .buffer(true)
316
+ .parse((res, callback) => {
317
+ const chunks = [];
318
+ res.on('data', (chunk) => chunks.push(chunk));
319
+ res.on('end', () => callback(null, Buffer.concat(chunks)));
320
+ })
321
+ .timeout({ response: 30000 });
322
+ if (r.status !== 200) {
323
+ throw new Error(`Archive returned status ${r.status}`);
324
+ }
325
+ return r;
326
+ },
327
+ { retries: 2, baseDelay: 3000 }
328
+ );
329
+
330
+ expect(res.body).toBeInstanceOf(Buffer);
331
+ expect(res.body.length).toBeGreaterThan(50);
332
+ // ZIP signature
333
+ expect(res.body[0]).toBe(0x50);
334
+ expect(res.body[1]).toBe(0x4b);
335
+ });
336
+ });
337
+ });
@@ -157,9 +157,7 @@ describe('Web Capture Microservice', () => {
157
157
  0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
158
158
  ]);
159
159
  expect(response.body.slice(0, 8)).toEqual(pngSignature);
160
- // Skipping strict buffer match: Puppeteer output is not deterministic
161
- // expect(response.body.equals(mockBuffer)).toBe(true);
162
- });
160
+ }, 60000);
163
161
 
164
162
  it('should return 400 when URL is missing', async () => {
165
163
  const response = await request(app).get('/image');
@@ -172,6 +170,10 @@ describe('Web Capture Microservice', () => {
172
170
  describe('GET /stream', () => {
173
171
  it('should stream content from the given URL', async () => {
174
172
  const testUrl = 'https://example.com';
173
+ const testHtml = '<html><body><h1>Streamed Page</h1></body></html>';
174
+ nock(testUrl).get('/').reply(200, testHtml, {
175
+ 'content-type': 'text/html',
176
+ });
175
177
  const response = await request(app).get(`/stream?url=${testUrl}`);
176
178
  expect(response.status).toBe(200);
177
179
  expect(response.text).toMatch(/<html/i);
@@ -0,0 +1,33 @@
1
+ import { compareFrames } from '../../src/animation.js';
2
+
3
+ describe('animation module', () => {
4
+ describe('compareFrames', () => {
5
+ it('returns 1.0 for identical frames', () => {
6
+ const frame = Buffer.from([1, 2, 3, 4, 5]);
7
+ expect(compareFrames(frame, frame)).toBe(1.0);
8
+ });
9
+
10
+ it('returns 0 for completely different frames', () => {
11
+ const frame1 = Buffer.from([0, 0, 0, 0]);
12
+ const frame2 = Buffer.from([255, 255, 255, 255]);
13
+ expect(compareFrames(frame1, frame2)).toBe(0);
14
+ });
15
+
16
+ it('returns 0 for different sized frames', () => {
17
+ const frame1 = Buffer.from([1, 2, 3]);
18
+ const frame2 = Buffer.from([1, 2]);
19
+ expect(compareFrames(frame1, frame2)).toBe(0);
20
+ });
21
+
22
+ it('returns 0 for null frames', () => {
23
+ expect(compareFrames(null, Buffer.from([1]))).toBe(0);
24
+ expect(compareFrames(Buffer.from([1]), null)).toBe(0);
25
+ });
26
+
27
+ it('calculates partial similarity', () => {
28
+ const frame1 = Buffer.from([1, 2, 3, 4]);
29
+ const frame2 = Buffer.from([1, 2, 0, 0]);
30
+ expect(compareFrames(frame1, frame2)).toBe(0.5);
31
+ });
32
+ });
33
+ });