messi-crawler 1.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.
Files changed (73) hide show
  1. package/README.md +201 -0
  2. package/dist/cli/renderer.js +71 -0
  3. package/dist/config.js +18 -0
  4. package/dist/db/clear.js +16 -0
  5. package/dist/db/client.js +20 -0
  6. package/dist/db/queries.js +179 -0
  7. package/dist/frontier/frontier.js +44 -0
  8. package/dist/frontier/logger.js +65 -0
  9. package/dist/frontier/robots.js +46 -0
  10. package/dist/frontier/scheduler.js +98 -0
  11. package/dist/index.js +533 -0
  12. package/dist/normalizer.js +33 -0
  13. package/dist/output/db-strategy.js +16 -0
  14. package/dist/output/index.js +23 -0
  15. package/dist/output/pdf-strategy.js +316 -0
  16. package/dist/output/strategy.js +1 -0
  17. package/dist/security/ssrf.js +45 -0
  18. package/dist/security/validate-url.js +41 -0
  19. package/dist/seed.js +14 -0
  20. package/dist/setup.js +148 -0
  21. package/dist/test/client.test.js +33 -0
  22. package/dist/test/downloader.test.js +84 -0
  23. package/dist/test/extractor.test.js +126 -0
  24. package/dist/test/frontier.test.js +43 -0
  25. package/dist/test/logger.test.js +55 -0
  26. package/dist/test/normalizer.test.js +36 -0
  27. package/dist/test/pdf-strategy.test.js +68 -0
  28. package/dist/test/queries.test.js +173 -0
  29. package/dist/test/robots.test.js +46 -0
  30. package/dist/test/scheduler.test.js +73 -0
  31. package/dist/test/seed.test.js +26 -0
  32. package/dist/test/worker.test.js +118 -0
  33. package/dist/worker/downloader.js +114 -0
  34. package/dist/worker/extractor.js +197 -0
  35. package/dist/worker/worker.js +87 -0
  36. package/package.json +48 -0
  37. package/seeds.txt +4 -0
  38. package/src/cli/renderer.ts +83 -0
  39. package/src/config.ts +22 -0
  40. package/src/db/clear.ts +16 -0
  41. package/src/db/client.ts +26 -0
  42. package/src/db/queries.ts +255 -0
  43. package/src/db/schema.sql +43 -0
  44. package/src/frontier/frontier.ts +60 -0
  45. package/src/frontier/logger.ts +75 -0
  46. package/src/frontier/robots.ts +50 -0
  47. package/src/frontier/scheduler.ts +119 -0
  48. package/src/index.ts +596 -0
  49. package/src/normalizer.ts +37 -0
  50. package/src/output/db-strategy.ts +20 -0
  51. package/src/output/index.ts +32 -0
  52. package/src/output/pdf-strategy.ts +388 -0
  53. package/src/output/strategy.ts +16 -0
  54. package/src/security/ssrf.ts +48 -0
  55. package/src/security/validate-url.ts +49 -0
  56. package/src/seed.ts +18 -0
  57. package/src/setup.ts +170 -0
  58. package/src/test/client.test.ts +38 -0
  59. package/src/test/downloader.test.ts +101 -0
  60. package/src/test/extractor.test.ts +139 -0
  61. package/src/test/frontier.test.ts +53 -0
  62. package/src/test/logger.test.ts +71 -0
  63. package/src/test/normalizer.test.ts +43 -0
  64. package/src/test/pdf-strategy.test.ts +84 -0
  65. package/src/test/queries.test.ts +247 -0
  66. package/src/test/robots.test.ts +56 -0
  67. package/src/test/scheduler.test.ts +90 -0
  68. package/src/test/seed.test.ts +35 -0
  69. package/src/test/worker.test.ts +144 -0
  70. package/src/worker/downloader.ts +149 -0
  71. package/src/worker/extractor.ts +235 -0
  72. package/src/worker/worker.ts +100 -0
  73. package/tsconfig.json +15 -0
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ // Mock pg module before importing client
4
+ vi.mock("pg", () => {
5
+ const queryMock = vi.fn().mockResolvedValue({ rows: [] });
6
+ const endMock = vi.fn().mockResolvedValue(undefined);
7
+ class PoolMock {
8
+ query = queryMock;
9
+ end = endMock;
10
+ }
11
+ return {
12
+ default: {
13
+ Pool: PoolMock,
14
+ },
15
+ Pool: PoolMock,
16
+ };
17
+ });
18
+
19
+ import { pool, query, closePool } from "../db/client.js";
20
+
21
+ describe("Database Client", () => {
22
+ it("should expose pool and query function", async () => {
23
+ expect(pool).toBeDefined();
24
+ expect(query).toBeDefined();
25
+ expect(closePool).toBeDefined();
26
+ });
27
+
28
+ it("should delegate query call to pool", async () => {
29
+ const res = await query("SELECT 1");
30
+ expect(res).toEqual({ rows: [] });
31
+ expect(pool.query).toHaveBeenCalledWith("SELECT 1", undefined);
32
+ });
33
+
34
+ it("should call end on pool when closing", async () => {
35
+ await closePool();
36
+ expect(pool.end).toHaveBeenCalled();
37
+ });
38
+ });
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Mock undici request function
4
+ vi.mock("undici", () => {
5
+ return {
6
+ request: vi.fn(),
7
+ };
8
+ });
9
+
10
+ vi.mock("../config.js", () => {
11
+ return {
12
+ config: {
13
+ REQUEST_TIMEOUT_MS: 1000,
14
+ MAX_REDIRECTS: 2,
15
+ },
16
+ };
17
+ });
18
+
19
+ import { request } from "undici";
20
+ import { downloadPage } from "../worker/downloader.js";
21
+
22
+ const mockedRequest = vi.mocked(request);
23
+
24
+ describe("HTTP Downloader", () => {
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ it("should successfully download HTML page", async () => {
30
+ mockedRequest.mockResolvedValue({
31
+ statusCode: 200,
32
+ headers: { "content-type": "text/html" },
33
+ body: { text: async () => "<html>Hello</html>" },
34
+ } as any);
35
+
36
+ const result = await downloadPage("https://react.dev");
37
+
38
+ expect(result).toEqual({
39
+ url: "https://react.dev",
40
+ html: "<html>Hello</html>",
41
+ statusCode: 200,
42
+ });
43
+ expect(mockedRequest).toHaveBeenCalledTimes(1);
44
+ });
45
+
46
+ it("should follow redirects manually and return final URL", async () => {
47
+ mockedRequest
48
+ .mockResolvedValueOnce({
49
+ statusCode: 301,
50
+ headers: { location: "https://react.dev/docs" },
51
+ body: { text: async () => "" },
52
+ } as any)
53
+ .mockResolvedValueOnce({
54
+ statusCode: 200,
55
+ headers: { "content-type": "text/html; charset=utf-8" },
56
+ body: { text: async () => "docs html" },
57
+ } as any);
58
+
59
+ const result = await downloadPage("https://react.dev");
60
+
61
+ expect(result).toEqual({
62
+ url: "https://react.dev/docs",
63
+ html: "docs html",
64
+ statusCode: 200,
65
+ });
66
+ expect(mockedRequest).toHaveBeenCalledTimes(2);
67
+ expect(mockedRequest).toHaveBeenNthCalledWith(1, "https://react.dev", expect.any(Object));
68
+ expect(mockedRequest).toHaveBeenNthCalledWith(2, "https://react.dev/docs", expect.any(Object));
69
+ });
70
+
71
+ it("should throw error if redirect limit is exceeded", async () => {
72
+ mockedRequest.mockResolvedValue({
73
+ statusCode: 302,
74
+ headers: { location: "https://react.dev/loop" },
75
+ body: { text: async () => "" },
76
+ } as any);
77
+
78
+ await expect(downloadPage("https://react.dev")).rejects.toThrow("Too many redirects");
79
+ expect(mockedRequest).toHaveBeenCalledTimes(3); // 1 initial + 2 redirects (max redirects is 2)
80
+ });
81
+
82
+ it("should throw error for non-200 HTTP status code", async () => {
83
+ mockedRequest.mockResolvedValue({
84
+ statusCode: 404,
85
+ headers: {},
86
+ body: { text: async () => "Not Found" },
87
+ } as any);
88
+
89
+ await expect(downloadPage("https://react.dev")).rejects.toThrow("HTTP status 404");
90
+ });
91
+
92
+ it("should throw error for non-HTML content types", async () => {
93
+ mockedRequest.mockResolvedValue({
94
+ statusCode: 200,
95
+ headers: { "content-type": "application/json" },
96
+ body: { text: async () => "{}" },
97
+ } as any);
98
+
99
+ await expect(downloadPage("https://react.dev")).rejects.toThrow("Non-HTML content type");
100
+ });
101
+ });
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { extractPageData } from "../worker/extractor.js";
3
+
4
+ describe("HTML Extractor", () => {
5
+ it("should extract metadata, headings, clean text, and links", () => {
6
+ const sampleHtml = `
7
+ <!DOCTYPE html>
8
+ <html>
9
+ <head>
10
+ <title>Test Page Title</title>
11
+ <meta name="description" content="This is a test description.">
12
+ <link rel="canonical" href="https://example.com/canonical-url">
13
+ </head>
14
+ <body>
15
+ <style>body { color: red; }</style>
16
+ <h1>Heading One</h1>
17
+ <h2>Heading Two</h2>
18
+ <h3>Heading Three</h3>
19
+ <p>This is some body text. <a href="/about">About Us</a> and <a href="https://google.com">Google</a>.</p>
20
+ <script>console.log("hello");</script>
21
+ </body>
22
+ </html>
23
+ `;
24
+
25
+ const result = extractPageData(sampleHtml);
26
+
27
+ expect(result.title).toBe("Test Page Title");
28
+ expect(result.description).toBe("This is a test description.");
29
+ expect(result.canonicalUrl).toBe("https://example.com/canonical-url");
30
+ expect(result.headings).toEqual({
31
+ h1: ["Heading One"],
32
+ h2: ["Heading Two"],
33
+ h3: ["Heading Three"],
34
+ });
35
+ // Style and script tags should be stripped, only body paragraph and headings remain
36
+ expect(result.textContent).toContain("Heading One Heading Two Heading Three This is some body text. About Us and Google.");
37
+ expect(result.textContent).not.toContain("color: red");
38
+ expect(result.textContent).not.toContain("console.log");
39
+
40
+ expect(result.links).toEqual(["/about", "https://google.com"]);
41
+ });
42
+
43
+ it("should handle missing tags gracefully", () => {
44
+ const sampleHtml = `
45
+ <html>
46
+ <body>
47
+ <p>Just some text</p>
48
+ </body>
49
+ </html>
50
+ `;
51
+
52
+ const result = extractPageData(sampleHtml);
53
+
54
+ expect(result.title).toBeNull();
55
+ expect(result.description).toBeNull();
56
+ expect(result.canonicalUrl).toBeNull();
57
+ expect(result.headings).toEqual({ h1: [], h2: [], h3: [] });
58
+ expect(result.textContent).toBe("Just some text");
59
+ expect(result.links).toEqual([]);
60
+ });
61
+
62
+ it("should select main content via tags (article/main/role=main) and remove chrome", () => {
63
+ const html = `
64
+ <html>
65
+ <body>
66
+ <header><nav>Header navigation links</nav></header>
67
+ <div role="main">
68
+ <article>
69
+ <h1>Article Title</h1>
70
+ <p>This is the actual article content.</p>
71
+ <footer>Article footer inside main</footer>
72
+ </article>
73
+ </div>
74
+ <footer>Site footer chrome</footer>
75
+ </body>
76
+ </html>
77
+ `;
78
+ const result = extractPageData(html);
79
+ // Note: article footer and header nav should be removed
80
+ expect(result.textContent).toBe("Article Title This is the actual article content.");
81
+ expect(result.textContent).not.toContain("Header navigation links");
82
+ expect(result.textContent).not.toContain("Site footer chrome");
83
+ });
84
+
85
+ it("should select main content via text density score when no tag is present", () => {
86
+ const html = `
87
+ <html>
88
+ <body>
89
+ <div class="sidebar">
90
+ <p>Nav 1</p>
91
+ <p>Nav 2</p>
92
+ </div>
93
+ <div class="content">
94
+ <p>This is a much longer paragraph with a lot of text to ensure it has a higher text density compared to the sidebar. It contains many words and represents the main article body.</p>
95
+ <p>Another paragraph to increase text density even more.</p>
96
+ </div>
97
+ </body>
98
+ </html>
99
+ `;
100
+ const result = extractPageData(html);
101
+ expect(result.textContent).toContain("This is a much longer paragraph");
102
+ expect(result.textContent).not.toContain("Nav 1");
103
+ });
104
+
105
+ it("should extract structured blocks and resolve image URLs", () => {
106
+ const html = `
107
+ <html>
108
+ <body>
109
+ <article>
110
+ <h1>Title</h1>
111
+ <p>Intro paragraph.</p>
112
+ <ul>
113
+ <li>Item A</li>
114
+ <li>Item B</li>
115
+ </ul>
116
+ <img src="/assets/photo.jpg" alt="A nice photo">
117
+ </article>
118
+ </body>
119
+ </html>
120
+ `;
121
+ const result = extractPageData(html, "https://example.com/blog/post-1");
122
+
123
+ expect(result.blocks).toBeDefined();
124
+ expect(result.blocks!.length).toBe(4);
125
+ expect(result.blocks![0]).toEqual({ type: "heading", level: 1, text: "Title" });
126
+ expect(result.blocks![1]).toEqual({ type: "paragraph", text: "Intro paragraph." });
127
+ expect(result.blocks![2]).toEqual({ type: "list", items: ["Item A", "Item B"] });
128
+ expect(result.blocks![3]).toEqual({
129
+ type: "image",
130
+ src: "https://example.com/assets/photo.jpg",
131
+ alt: "A nice photo",
132
+ });
133
+
134
+ expect(result.images).toEqual([
135
+ { src: "https://example.com/assets/photo.jpg", alt: "A nice photo" },
136
+ ]);
137
+ });
138
+ });
139
+
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Mock the client module
4
+ vi.mock("../db/client.js", () => {
5
+ return {
6
+ query: vi.fn(),
7
+ };
8
+ });
9
+
10
+ import { query } from "../db/client.js";
11
+ import { getPendingDomains, getPendingCounts } from "../frontier/frontier.js";
12
+
13
+ const mockedQuery = vi.mocked(query);
14
+
15
+ describe("URL Frontier", () => {
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ });
19
+
20
+ describe("getPendingDomains", () => {
21
+ it("should query and return active pending domains", async () => {
22
+ mockedQuery.mockResolvedValue({
23
+ rows: [{ domain: "react.dev" }, { domain: "typescriptlang.org" }],
24
+ } as any);
25
+
26
+ const domains = await getPendingDomains();
27
+
28
+ expect(mockedQuery).toHaveBeenCalledTimes(1);
29
+ expect(mockedQuery).toHaveBeenCalledWith(expect.stringContaining("SELECT DISTINCT domain"));
30
+ expect(domains).toEqual(["react.dev", "typescriptlang.org"]);
31
+ });
32
+ });
33
+
34
+ describe("getPendingCounts", () => {
35
+ it("should query and return count breakdown of pending domains", async () => {
36
+ mockedQuery.mockResolvedValue({
37
+ rows: [
38
+ { domain: "react.dev", count: "10" },
39
+ { domain: "typescriptlang.org", count: "5" },
40
+ ],
41
+ } as any);
42
+
43
+ const counts = await getPendingCounts();
44
+
45
+ expect(mockedQuery).toHaveBeenCalledTimes(1);
46
+ expect(mockedQuery).toHaveBeenCalledWith(expect.stringContaining("COUNT(*)"));
47
+ expect(counts).toEqual({
48
+ "react.dev": 10,
49
+ "typescriptlang.org": 5,
50
+ });
51
+ });
52
+ });
53
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ vi.mock("../db/queries.js", () => {
4
+ return {
5
+ getGlobalStats: vi.fn(),
6
+ refreshDomainStats: vi.fn(),
7
+ getDomainStats: vi.fn(),
8
+ };
9
+ });
10
+
11
+ import { getGlobalStats, refreshDomainStats, getDomainStats } from "../db/queries.js";
12
+ import { startProgressLogger, stopProgressLogger } from "../frontier/logger.js";
13
+
14
+ const mockGetGlobalStats = vi.mocked(getGlobalStats);
15
+ const mockRefreshDomainStats = vi.mocked(refreshDomainStats);
16
+ const mockGetDomainStats = vi.mocked(getDomainStats);
17
+
18
+ describe("Progress Logger", () => {
19
+ beforeEach(() => {
20
+ vi.useFakeTimers();
21
+ vi.clearAllMocks();
22
+ vi.spyOn(console, "log").mockImplementation(() => {});
23
+ vi.spyOn(console, "error").mockImplementation(() => {});
24
+ });
25
+
26
+ afterEach(() => {
27
+ stopProgressLogger();
28
+ vi.useRealTimers();
29
+ });
30
+
31
+ it("should initialize stats and periodically log progress report", async () => {
32
+ mockGetGlobalStats.mockResolvedValue({ pending: 10, fetching: 2, done: 20, failed: 1 });
33
+ mockGetDomainStats.mockResolvedValue([
34
+ {
35
+ domain: "react.dev",
36
+ pending_count: 10,
37
+ fetching_count: 2,
38
+ done_count: 20,
39
+ failed_count: 1,
40
+ last_crawled_at: new Date("2026-06-05T12:00:00Z"),
41
+ },
42
+ ]);
43
+
44
+ await startProgressLogger(5000);
45
+
46
+ // Initial query should be called to establish baseline
47
+ expect(mockGetGlobalStats).toHaveBeenCalledTimes(1);
48
+
49
+ // Fast-forward 5 seconds
50
+ await vi.advanceTimersByTimeAsync(5000);
51
+
52
+ expect(mockRefreshDomainStats).toHaveBeenCalledTimes(1);
53
+ expect(mockGetGlobalStats).toHaveBeenCalledTimes(2);
54
+ expect(mockGetDomainStats).toHaveBeenCalledTimes(1);
55
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Crawler Progress Report"));
56
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining("PENDING : 10"));
57
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining("react.dev"));
58
+ });
59
+
60
+ it("should handle query errors gracefully", async () => {
61
+ mockGetGlobalStats.mockRejectedValue(new Error("Database connection lost"));
62
+ await startProgressLogger(5000);
63
+
64
+ await vi.advanceTimersByTimeAsync(5000);
65
+
66
+ expect(console.error).toHaveBeenCalledWith(
67
+ "Error generating crawler progress logs:",
68
+ expect.any(Error)
69
+ );
70
+ });
71
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { normalizeURL, getDomain } from "../normalizer.js";
3
+
4
+ describe("URL Normalizer", () => {
5
+ describe("normalizeURL", () => {
6
+ it("should resolve relative URLs against base URL", () => {
7
+ expect(normalizeURL("/relative/path", "https://react.dev")).toBe("https://react.dev/relative/path");
8
+ expect(normalizeURL("relative/path", "https://react.dev/sub/")).toBe("https://react.dev/sub/relative/path");
9
+ });
10
+
11
+ it("should strip trailing slash (including bare domain)", () => {
12
+ expect(normalizeURL("https://example.com/", "https://react.dev")).toBe("https://example.com");
13
+ expect(normalizeURL("https://example.com/about/", "https://react.dev")).toBe("https://example.com/about");
14
+ });
15
+
16
+ it("should strip fragments", () => {
17
+ expect(normalizeURL("https://example.com#section", "https://react.dev")).toBe("https://example.com");
18
+ expect(normalizeURL("https://example.com/about#team", "https://react.dev")).toBe("https://example.com/about");
19
+ });
20
+
21
+ it("should lowercase scheme and host", () => {
22
+ expect(normalizeURL("HTTPS://EXAMPLE.COM/About", "https://react.dev")).toBe("https://example.com/About");
23
+ });
24
+
25
+ it("should filter out unsupported protocols", () => {
26
+ expect(normalizeURL("ftp://example.com", "https://react.dev")).toBeNull();
27
+ expect(normalizeURL("javascript:void(0)", "https://react.dev")).toBeNull();
28
+ expect(normalizeURL("mailto:test@example.com", "https://react.dev")).toBeNull();
29
+ });
30
+
31
+ it("should preserve query parameters", () => {
32
+ expect(normalizeURL("https://example.com/search?q=typescript", "https://react.dev")).toBe("https://example.com/search?q=typescript");
33
+ });
34
+ });
35
+
36
+ describe("getDomain", () => {
37
+ it("should extract hostname correctly", () => {
38
+ expect(getDomain("https://react.dev/docs/getting-started")).toBe("react.dev");
39
+ expect(getDomain("http://localhost:3000/test")).toBe("localhost");
40
+ expect(getDomain("invalid-url")).toBeNull();
41
+ });
42
+ });
43
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { PdfStrategy } from "../output/pdf-strategy.js";
5
+
6
+ // Mock downloader
7
+ vi.mock("../worker/downloader.js", () => {
8
+ return {
9
+ downloadImage: vi.fn(),
10
+ };
11
+ });
12
+
13
+ // Mock db queries
14
+ vi.mock("../db/queries.js", () => {
15
+ return {
16
+ markDone: vi.fn().mockResolvedValue(undefined),
17
+ };
18
+ });
19
+
20
+ import { downloadImage } from "../worker/downloader.js";
21
+ import { markDone } from "../db/queries.js";
22
+
23
+ const mockDownloadImage = vi.mocked(downloadImage);
24
+
25
+ describe("PdfStrategy Integration", () => {
26
+ let strategy: PdfStrategy;
27
+
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+
31
+ // Mock downloadImage to return a valid 1x1 PNG buffer
32
+ const oneByOnePng = Buffer.from(
33
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
34
+ "base64"
35
+ );
36
+ mockDownloadImage.mockResolvedValue(oneByOnePng);
37
+ strategy = new PdfStrategy();
38
+ });
39
+
40
+ afterEach(() => {
41
+ // Delete only the specific file generated by this test strategy instance
42
+ if (strategy && (strategy as any).pdfPath) {
43
+ const filePath = (strategy as any).pdfPath;
44
+ if (fs.existsSync(filePath)) {
45
+ try {
46
+ fs.unlinkSync(filePath);
47
+ } catch {
48
+ // Ignore if file cannot be deleted
49
+ }
50
+ }
51
+ }
52
+ });
53
+
54
+ it("should successfully generate a PDF document from structured blocks", async () => {
55
+ await strategy.init();
56
+
57
+ await strategy.save(1, "https://react.dev/docs", {
58
+ title: "React Documentation",
59
+ description: "Learn React library",
60
+ canonicalUrl: "https://react.dev/docs",
61
+ headings: { h1: ["React"], h2: [], h3: [] },
62
+ textContent: "Learn React details...",
63
+ blocks: [
64
+ { type: "heading", level: 1, text: "React Basics" },
65
+ { type: "paragraph", text: "React is a JavaScript library for building user interfaces." },
66
+ { type: "list", items: ["Component-Based", "Declarative UI", "Learn Once, Write Anywhere"] },
67
+ { type: "image", src: "https://react.dev/logo.png", alt: "React Logo" },
68
+ ],
69
+ images: [
70
+ { src: "https://react.dev/logo.png", alt: "React Logo" },
71
+ ],
72
+ });
73
+
74
+ await strategy.finish();
75
+
76
+ // Verify markDone was called
77
+ expect(markDone).toHaveBeenCalledTimes(1);
78
+
79
+ // Verify PDF file was written to the output folder
80
+ const pdfPath = (strategy as any).pdfPath;
81
+ expect(pdfPath).toBeDefined();
82
+ expect(fs.existsSync(pdfPath)).toBe(true);
83
+ });
84
+ });