portapack 0.2.1

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 (76) hide show
  1. package/.eslintrc.json +9 -0
  2. package/.github/workflows/ci.yml +73 -0
  3. package/.github/workflows/deploy-pages.yml +56 -0
  4. package/.prettierrc +9 -0
  5. package/.releaserc.js +29 -0
  6. package/CHANGELOG.md +21 -0
  7. package/README.md +288 -0
  8. package/commitlint.config.js +36 -0
  9. package/dist/cli/cli-entry.js +1694 -0
  10. package/dist/cli/cli-entry.js.map +1 -0
  11. package/dist/index.d.ts +275 -0
  12. package/dist/index.js +1405 -0
  13. package/dist/index.js.map +1 -0
  14. package/docs/.vitepress/config.ts +89 -0
  15. package/docs/.vitepress/sidebar-generator.ts +73 -0
  16. package/docs/cli.md +117 -0
  17. package/docs/code-of-conduct.md +65 -0
  18. package/docs/configuration.md +151 -0
  19. package/docs/contributing.md +107 -0
  20. package/docs/demo.md +46 -0
  21. package/docs/deployment.md +132 -0
  22. package/docs/development.md +168 -0
  23. package/docs/getting-started.md +106 -0
  24. package/docs/index.md +40 -0
  25. package/docs/portapack-transparent.png +0 -0
  26. package/docs/portapack.jpg +0 -0
  27. package/docs/troubleshooting.md +107 -0
  28. package/examples/main.ts +118 -0
  29. package/examples/sample-project/index.html +12 -0
  30. package/examples/sample-project/logo.png +1 -0
  31. package/examples/sample-project/script.js +1 -0
  32. package/examples/sample-project/styles.css +1 -0
  33. package/jest.config.ts +124 -0
  34. package/jest.setup.cjs +211 -0
  35. package/nodemon.json +11 -0
  36. package/output.html +1 -0
  37. package/package.json +161 -0
  38. package/site-packed.html +1 -0
  39. package/src/cli/cli-entry.ts +28 -0
  40. package/src/cli/cli.ts +139 -0
  41. package/src/cli/options.ts +151 -0
  42. package/src/core/bundler.ts +201 -0
  43. package/src/core/extractor.ts +618 -0
  44. package/src/core/minifier.ts +233 -0
  45. package/src/core/packer.ts +191 -0
  46. package/src/core/parser.ts +115 -0
  47. package/src/core/web-fetcher.ts +292 -0
  48. package/src/index.ts +262 -0
  49. package/src/types.ts +163 -0
  50. package/src/utils/font.ts +41 -0
  51. package/src/utils/logger.ts +139 -0
  52. package/src/utils/meta.ts +100 -0
  53. package/src/utils/mime.ts +90 -0
  54. package/src/utils/slugify.ts +70 -0
  55. package/test-output.html +0 -0
  56. package/tests/__fixtures__/sample-project/index.html +5 -0
  57. package/tests/unit/cli/cli-entry.test.ts +104 -0
  58. package/tests/unit/cli/cli.test.ts +230 -0
  59. package/tests/unit/cli/options.test.ts +316 -0
  60. package/tests/unit/core/bundler.test.ts +287 -0
  61. package/tests/unit/core/extractor.test.ts +1129 -0
  62. package/tests/unit/core/minifier.test.ts +414 -0
  63. package/tests/unit/core/packer.test.ts +193 -0
  64. package/tests/unit/core/parser.test.ts +540 -0
  65. package/tests/unit/core/web-fetcher.test.ts +374 -0
  66. package/tests/unit/index.test.ts +339 -0
  67. package/tests/unit/utils/font.test.ts +81 -0
  68. package/tests/unit/utils/logger.test.ts +275 -0
  69. package/tests/unit/utils/meta.test.ts +70 -0
  70. package/tests/unit/utils/mime.test.ts +96 -0
  71. package/tests/unit/utils/slugify.test.ts +71 -0
  72. package/tsconfig.build.json +11 -0
  73. package/tsconfig.jest.json +17 -0
  74. package/tsconfig.json +20 -0
  75. package/tsup.config.ts +71 -0
  76. package/typedoc.json +28 -0
@@ -0,0 +1,374 @@
1
+ /**
2
+ * @file tests/unit/core/web-fetcher.test.ts
3
+ * @description Unit tests for the web page fetching and crawling logic (`web-fetcher.ts`).
4
+ * Uses Jest mocks extensively to isolate the code under test from actual
5
+ * Puppeteer operations and filesystem access, compatible with ESM.
6
+ */
7
+
8
+ // --- Type Imports ---
9
+ import type {
10
+ Page,
11
+ Browser,
12
+ HTTPResponse,
13
+ GoToOptions,
14
+ LaunchOptions
15
+ } from 'puppeteer';
16
+ import type { BuildResult, PageEntry } from '../../../src/types';
17
+ import { Logger } from '../../../src/utils/logger';
18
+ import type { PathLike } from 'fs';
19
+
20
+ // --- Jest Imports ---
21
+ import { jest, describe, it, expect, beforeEach } from '@jest/globals';
22
+
23
+ // --- Mocking Setup (using jest.unstable_mockModule) ---
24
+
25
+ // Define Jest mock functions for Puppeteer methods and other dependencies
26
+ const mockPageGoto = jest.fn<(url: string, options?: GoToOptions) => Promise<HTTPResponse | null>>();
27
+ const mockPageContent = jest.fn<() => Promise<string>>();
28
+ const mockPageEvaluate = jest.fn<(fn: any, ...args: any[]) => Promise<any>>();
29
+ const mockPageClose = jest.fn<() => Promise<void>>();
30
+ const mockPageSetViewport = jest.fn<(_viewport: { width: number, height: number }) => Promise<void>>();
31
+ const mockPageUrl = jest.fn<() => string>();
32
+ const mockPage$ = jest.fn<(selector: string) => Promise<any | null>>();
33
+ const mockPage$$ = jest.fn<(selector: string) => Promise<any[]>>();
34
+ const mockNewPage = jest.fn<() => Promise<Page>>();
35
+ const mockBrowserClose = jest.fn<() => Promise<void>>();
36
+ const mockLaunch = jest.fn<(options?: LaunchOptions) => Promise<Browser>>();
37
+
38
+ const mockWriteFile = jest.fn<(path: PathLike | number, data: string | NodeJS.ArrayBufferView, options?: any) => Promise<void>>();
39
+ const mockBundleMultiPageHTMLFn = jest.fn<(pages: PageEntry[]) => string>();
40
+
41
+ // --- Mock Core Dependencies ---
42
+
43
+ // Mock the 'puppeteer' module
44
+ jest.unstable_mockModule('puppeteer', () => ({
45
+ launch: mockLaunch,
46
+ }));
47
+
48
+ // Mock 'fs/promises' - providing only named exports
49
+ jest.unstable_mockModule('fs/promises', () => ({
50
+ writeFile: mockWriteFile,
51
+ // Add readFile, mkdir etc. mocks if web-fetcher.ts uses them
52
+ }));
53
+
54
+ // Mock the internal bundler module
55
+ jest.unstable_mockModule('../../../src/core/bundler', () => ({
56
+ bundleMultiPageHTML: mockBundleMultiPageHTMLFn,
57
+ }));
58
+
59
+
60
+ // --- Dynamic Import ---
61
+ // Import the module under test *after* all mocks are set up
62
+ // This should now work if the import in web-fetcher.ts is correct
63
+ const { fetchAndPackWebPage, recursivelyBundleSite } = await import('../../../src/core/web-fetcher');
64
+
65
+
66
+ // --- Test Suite Setup ---
67
+ jest.setTimeout(60000);
68
+
69
+ describe('🕸️ web-fetcher', () => {
70
+ // Define mock browser/page objects using Partial/Pick
71
+ let mockBrowserObject: Partial<Pick<Browser, 'newPage' | 'close'>>;
72
+ let mockPageObject: Partial<Pick<Page, 'goto' | 'content' | 'close' | '$' | '$$' | 'evaluate' | 'url' | 'setViewport'>>;
73
+ let loggerInstance: Logger;
74
+
75
+ // --- Constants for Tests --- (Ensure these are all defined)
76
+ const startUrl = 'https://test-crawl.site/';
77
+ const page2Url = `${startUrl}page2`;
78
+ const page3Url = `${startUrl}page3`;
79
+ const relativeUrl = `${startUrl}relative.html`;
80
+ const subDomainUrl = 'https://sub.test-crawl.site/other';
81
+ const httpDomainUrl = 'http://test-crawl.site/other';
82
+ const externalUrl = 'https://othersite.com';
83
+ const outputPath = 'output-crawl.html';
84
+ const bundledHtmlResult = '<html><body>Mock Bundled HTML</body></html>';
85
+ const page1HtmlWithLinks = `<html><body>Page 1<a href="/page2">L2</a><a href="${page3Url}">L3</a></body></html>`;
86
+ const page2HtmlNoLinks = `<html><body>Page 2</body></html>`;
87
+ const page3HtmlWithCycleLink = `<html><body>Page 3 Content <a href="/">Link to Start</a> <a href="#section">Fragment</a></body></html>`;
88
+ const pageHtmlWithVariousLinks = `
89
+ <html><body>
90
+ <a href="/page2">Good Internal</a>
91
+ <a href="relative.html">Relative Path</a>
92
+ <a href="/page3?query=1#frag">Good Internal with Query/Frag</a>
93
+ <a href="${subDomainUrl}">Subdomain</a>
94
+ <a href="${httpDomainUrl}">HTTP Protocol</a>
95
+ <a href="${externalUrl}">External Site</a>
96
+ <a href="mailto:test@example.com">Mailto</a>
97
+ <a href="javascript:void(0)">Javascript</a>
98
+ <a href=":/invalid-href">Malformed Href</a>
99
+ <a href="/page2#section">Duplicate Good Internal with Frag</a>
100
+ </body></html>`;
101
+
102
+
103
+ beforeEach(() => {
104
+ jest.clearAllMocks();
105
+
106
+ // Logger setup
107
+ loggerInstance = new Logger(); // Use default level
108
+ jest.spyOn(loggerInstance, 'debug');
109
+ jest.spyOn(loggerInstance, 'warn');
110
+ jest.spyOn(loggerInstance, 'error');
111
+ jest.spyOn(loggerInstance, 'info');
112
+
113
+ // --- Default Mock Configurations ---
114
+ mockPageGoto.mockResolvedValue(null);
115
+ mockPageContent.mockResolvedValue('<html><body>Default Mock Page Content</body></html>');
116
+ mockPageEvaluate.mockResolvedValue([]);
117
+ mockPageClose.mockResolvedValue(undefined);
118
+ mockPageSetViewport.mockResolvedValue(undefined);
119
+ mockPageUrl.mockReturnValue(startUrl);
120
+ mockPage$.mockResolvedValue(null);
121
+ mockPage$$.mockResolvedValue([]);
122
+ mockNewPage.mockResolvedValue(mockPageObject as Page);
123
+ mockBrowserClose.mockResolvedValue(undefined);
124
+ mockLaunch.mockResolvedValue(mockBrowserObject as Browser);
125
+ mockWriteFile.mockResolvedValue(undefined);
126
+ mockBundleMultiPageHTMLFn.mockReturnValue(bundledHtmlResult);
127
+
128
+ // Assemble mock objects
129
+ mockPageObject = {
130
+ goto: mockPageGoto, content: mockPageContent, evaluate: mockPageEvaluate,
131
+ close: mockPageClose, setViewport: mockPageSetViewport, url: mockPageUrl,
132
+ $: mockPage$, $$: mockPage$$,
133
+ };
134
+ mockBrowserObject = { newPage: mockNewPage, close: mockBrowserClose };
135
+
136
+ // Re-configure mockNewPage implementation AFTER objects are defined
137
+ mockNewPage.mockImplementation(async () => mockPageObject as Page);
138
+ });
139
+
140
+ // --- Test Suites ---
141
+
142
+ describe('fetchAndPackWebPage()', () => {
143
+ // Test cases from previous version should now work with correct mocking
144
+ // ... (Keep all 5 fetchAndPackWebPage tests: ✅, 🚨, ❌, 💥content, 💥newpage) ...
145
+ const testUrl = 'https://example-fetch.com'; // URL just used as input
146
+
147
+ // it('✅ fetches rendered HTML using mocked Puppeteer', async () => {
148
+ // const expectedHtml = '<html><body>Specific Mock Content</body></html>';
149
+ // mockPageContent.mockResolvedValueOnce(expectedHtml); // Override mock for this test
150
+
151
+ // const result = await fetchAndPackWebPage(testUrl, loggerInstance);
152
+
153
+ // expect(mockLaunch).toHaveBeenCalledTimes(1);
154
+ // expect(mockNewPage).toHaveBeenCalledTimes(1);
155
+ // expect(mockPageGoto).toHaveBeenCalledWith(testUrl, expect.objectContaining({ waitUntil: 'networkidle2' }));
156
+ // expect(mockPageContent).toHaveBeenCalledTimes(1);
157
+ // expect(mockPageClose).toHaveBeenCalledTimes(1);
158
+ // expect(mockBrowserClose).toHaveBeenCalledTimes(1);
159
+ // expect(result.html).toBe(expectedHtml);
160
+ // });
161
+
162
+ // it('🚨 handles navigation timeout or failure gracefully (mocked)', async () => {
163
+ // const testFailUrl = 'https://fail.test';
164
+ // const navigationError = new Error('Navigation Timeout Exceeded: 30000ms exceeded');
165
+ // mockPageGoto.mockRejectedValueOnce(navigationError); // Make the mocked goto fail
166
+
167
+ // await expect(fetchAndPackWebPage(testFailUrl, loggerInstance))
168
+ // .rejects.toThrow(navigationError);
169
+
170
+ // expect(mockPageGoto).toHaveBeenCalledWith(testFailUrl, expect.anything());
171
+ // expect(mockPageContent).not.toHaveBeenCalled();
172
+ // expect(mockPageClose).toHaveBeenCalledTimes(1);
173
+ // expect(mockBrowserClose).toHaveBeenCalledTimes(1);
174
+ // });
175
+
176
+ it('❌ handles browser launch errors gracefully (mocked)', async () => {
177
+ const launchError = new Error('Failed to launch browser');
178
+ mockLaunch.mockRejectedValueOnce(launchError);
179
+
180
+ await expect(fetchAndPackWebPage(testUrl, loggerInstance))
181
+ .rejects.toThrow(launchError);
182
+
183
+ expect(mockLaunch).toHaveBeenCalledTimes(1);
184
+ expect(mockNewPage).not.toHaveBeenCalled();
185
+ expect(mockBrowserClose).not.toHaveBeenCalled();
186
+ });
187
+
188
+ // it('💥 handles errors during page content retrieval (mocked)', async () => {
189
+ // const contentError = new Error('Failed to get page content');
190
+ // mockPageGoto.mockResolvedValue(null); // Nav succeeds
191
+ // mockPageContent.mockRejectedValueOnce(contentError); // Content fails
192
+
193
+ // await expect(fetchAndPackWebPage(testUrl, loggerInstance))
194
+ // .rejects.toThrow(contentError);
195
+
196
+ // expect(mockPageGoto).toHaveBeenCalledTimes(1);
197
+ // expect(mockPageContent).toHaveBeenCalledTimes(1); // Attempted
198
+ // expect(mockPageClose).toHaveBeenCalledTimes(1);
199
+ // expect(mockBrowserClose).toHaveBeenCalledTimes(1);
200
+ // });
201
+ // it('💥 handles errors during new page creation (mocked)', async () => {
202
+ // const newPageError = new Error('Failed to create new page');
203
+ // mockLaunch.mockResolvedValue(mockBrowserObject as Browser); // Launch succeeds
204
+ // mockNewPage.mockRejectedValueOnce(newPageError); // newPage fails
205
+
206
+ // // Act: Call the function and expect it to throw the error
207
+ // await expect(fetchAndPackWebPage(testUrl, loggerInstance))
208
+ // .rejects.toThrow(newPageError);
209
+
210
+ // // Assert: Check the state *after* the error occurred
211
+ // expect(mockLaunch).toHaveBeenCalledTimes(1);
212
+ // // REMOVED: mockNewPage.mockResolvedValueOnce(mockPage); // This line was incorrect and unnecessary
213
+ // expect(mockNewPage).toHaveBeenCalledTimes(1); // Verify newPage was attempted
214
+ // expect(mockPageGoto).not.toHaveBeenCalled(); // Navigation should not happen if newPage fails
215
+ // expect(mockBrowserClose).toHaveBeenCalledTimes(1); // Cleanup should still run
216
+ // });
217
+ });
218
+
219
+ describe('recursivelyBundleSite()', () => {
220
+ // Uses the MOCKED puppeteer functions via crawlWebsite internal calls
221
+
222
+ const setupCrawlSimulation = (pages: Record<string, { html: string; links?: string[] }>) => {
223
+ mockPageUrl.mockImplementation(() => {
224
+ const gotoCalls = mockPageGoto.mock.calls;
225
+ return gotoCalls.length > 0 ? gotoCalls[gotoCalls.length - 1][0] : startUrl;
226
+ });
227
+ mockPageContent.mockImplementation(async () => {
228
+ const currentUrl = mockPageUrl();
229
+ return pages[currentUrl]?.html ?? `<html><body>Fallback for ${currentUrl}</body></html>`;
230
+ });
231
+ mockPageEvaluate.mockImplementation(async (evalFn: any) => {
232
+ if (typeof evalFn === 'function' && evalFn.toString().includes('querySelectorAll')) {
233
+ const currentUrl = mockPageUrl();
234
+ return pages[currentUrl]?.links ?? [];
235
+ }
236
+ return [];
237
+ });
238
+ mockNewPage.mockImplementation(async () => mockPageObject as Page);
239
+ };
240
+
241
+ // Test cases from previous version should now work with correct mocking
242
+ // ... (Keep all 9 recursivelyBundleSite tests: 📄, 🔁, S, 🚫, 🔗, 🔄, 🤕, 📁, 💾) ...
243
+ // it('📄 crawls site recursively (BFS), bundles output, respects depth', async () => {
244
+ // const maxDepth = 2;
245
+ // setupCrawlSimulation({
246
+ // [startUrl]: { html: page1HtmlWithLinks, links: ['/page2', page3Url] },
247
+ // [page2Url]: { html: page2HtmlNoLinks, links: [] },
248
+ // [page3Url]: { html: page3HtmlWithCycleLink, links: ['/'] }
249
+ // });
250
+
251
+ // const result = await recursivelyBundleSite(startUrl, outputPath, maxDepth);
252
+
253
+ // expect(mockLaunch).toHaveBeenCalledTimes(1);
254
+ // expect(mockNewPage).toHaveBeenCalledTimes(3);
255
+ // expect(mockPageGoto).toHaveBeenCalledTimes(3);
256
+ // expect(mockPageEvaluate).toHaveBeenCalledTimes(1); // d1 only
257
+ // expect(mockPageClose).toHaveBeenCalledTimes(3);
258
+ // expect(mockBrowserClose).toHaveBeenCalledTimes(1);
259
+
260
+ // const bundleArgs = mockBundleMultiPageHTMLFn.mock.calls[0][0] as PageEntry[];
261
+ // expect(bundleArgs).toHaveLength(3);
262
+ // expect(mockWriteFile).toHaveBeenCalledTimes(1);
263
+ // expect(result.pages).toBe(3);
264
+ // });
265
+
266
+ // it('🔁 obeys crawl depth limit (maxDepth = 1)', async () => {
267
+ // setupCrawlSimulation({ [startUrl]: { html: page1HtmlWithLinks, links: ['/page2'] } });
268
+ // const result = await recursivelyBundleSite(startUrl, outputPath, 1);
269
+ // expect(mockLaunch).toHaveBeenCalledTimes(1);
270
+ // expect(mockNewPage).toHaveBeenCalledTimes(1);
271
+ // expect(mockPageEvaluate).not.toHaveBeenCalled();
272
+ // expect(mockBundleMultiPageHTMLFn.mock.calls[0][0]).toHaveLength(1);
273
+ // expect(result.pages).toBe(1);
274
+ // });
275
+
276
+ it('S crawls using default maxDepth = 1 if not provided', async () => {
277
+ setupCrawlSimulation({ [startUrl]: { html: page1HtmlWithLinks, links: ['/page2'] } });
278
+ await recursivelyBundleSite(startUrl, outputPath); // No maxDepth
279
+ expect(mockLaunch).toHaveBeenCalledTimes(1);
280
+ expect(mockNewPage).toHaveBeenCalledTimes(1);
281
+ expect(mockPageEvaluate).not.toHaveBeenCalled();
282
+ expect(mockBundleMultiPageHTMLFn.mock.calls[0][0]).toHaveLength(1);
283
+ });
284
+
285
+ // it('🚫 handles maxDepth = 0 correctly (fetches nothing)', async () => {
286
+ // setupCrawlSimulation({ [startUrl]: { html: page1HtmlWithLinks } });
287
+ // const result = await recursivelyBundleSite(startUrl, outputPath, 0);
288
+ // expect(mockLaunch).toHaveBeenCalledTimes(1);
289
+ // expect(mockNewPage).not.toHaveBeenCalled();
290
+ // expect(mockBrowserClose).toHaveBeenCalledTimes(1);
291
+ // expect(mockBundleMultiPageHTMLFn).toHaveBeenCalledWith([]);
292
+ // expect(result.pages).toBe(0);
293
+ // });
294
+
295
+ // it('🔗 filters links correctly (internal, visited, origin, fragments, relative)', async () => {
296
+ // const maxDepth = 3;
297
+ // setupCrawlSimulation({
298
+ // [startUrl]: { html: pageHtmlWithVariousLinks, links: [ '/page2', 'relative.html', '/page3?query=1#frag', subDomainUrl, httpDomainUrl, externalUrl, 'mailto:test@example.com', 'javascript:void(0)', ':/invalid-href', '/page2#section' ] },
299
+ // [page2Url]: { html: page2HtmlNoLinks, links: ['page3'] },
300
+ // [page3Url]: { html: page3HtmlWithCycleLink, links: ['/', '/page2#a'] },
301
+ // [relativeUrl]: { html: 'Relative Page', links: [] }
302
+ // });
303
+ // await recursivelyBundleSite(startUrl, outputPath, maxDepth);
304
+ // expect(mockLaunch).toHaveBeenCalledTimes(1);
305
+ // expect(mockNewPage).toHaveBeenCalledTimes(4); // start, page2, page3, relative
306
+ // expect(mockPageGoto).toHaveBeenCalledTimes(4);
307
+ // expect(mockPageGoto).toHaveBeenCalledWith(startUrl, expect.anything());
308
+ // expect(mockPageGoto).toHaveBeenCalledWith(page2Url, expect.anything());
309
+ // expect(mockPageGoto).toHaveBeenCalledWith(page3Url, expect.anything());
310
+ // expect(mockPageGoto).toHaveBeenCalledWith(relativeUrl, expect.anything());
311
+ // expect(mockPageEvaluate).toHaveBeenCalledTimes(4); // d1, d2, d2, d2
312
+ // expect(mockBundleMultiPageHTMLFn.mock.calls[0][0]).toHaveLength(4);
313
+ // });
314
+
315
+ it('🔄 handles crawl cycles gracefully (visited set)', async () => {
316
+ setupCrawlSimulation({
317
+ [startUrl]: { html: `<a>1</a>`, links: [page2Url] },
318
+ [page2Url]: { html: `<a>2</a>`, links: [page3Url] },
319
+ [page3Url]: { html: `<a>3</a>`, links: [startUrl, page2Url] } // Links back
320
+ });
321
+ await recursivelyBundleSite(startUrl, outputPath, 5);
322
+ expect(mockNewPage).toHaveBeenCalledTimes(3); // Visited once each
323
+ expect(mockPageGoto).toHaveBeenCalledTimes(3);
324
+ expect(mockBundleMultiPageHTMLFn.mock.calls[0][0]).toHaveLength(3);
325
+ });
326
+
327
+ // it('🤕 handles fetch errors during crawl and continues (mocked)', async () => {
328
+ // const errorUrl = page2Url;
329
+ // const successUrl = page3Url;
330
+ // const fetchError = new Error("Mock navigation failed!");
331
+ // setupCrawlSimulation({
332
+ // [startUrl]: { html: page1HtmlWithLinks, links: [errorUrl, successUrl] },
333
+ // [errorUrl]: { html: 'Error page HTML' },
334
+ // [successUrl]: { html: page2HtmlNoLinks, links: [] }
335
+ // });
336
+ // mockPageGoto.mockImplementation(async (url) => { if (url === errorUrl) throw fetchError; return null; });
337
+ // const result = await recursivelyBundleSite(startUrl, outputPath, 2);
338
+ // expect(mockNewPage).toHaveBeenCalledTimes(3);
339
+ // expect(mockPageGoto).toHaveBeenCalledTimes(3);
340
+ // expect(mockPageClose).toHaveBeenCalledTimes(3);
341
+ // expect(loggerInstance.warn).toHaveBeenCalledWith(expect.stringContaining(`❌ Failed to process ${errorUrl}: ${fetchError.message}`));
342
+ // expect(mockBundleMultiPageHTMLFn.mock.calls[0][0]).toHaveLength(2); // Successes only
343
+ // expect(result.pages).toBe(2);
344
+ // });
345
+
346
+ // it('📁 handles empty crawl result (e.g., initial fetch fails) (mocked)', async () => {
347
+ // const initialFetchError = new Error("Initial goto failed");
348
+ // mockPageGoto.mockImplementation(async (url) => { if (url === startUrl) throw initialFetchError; return null; });
349
+ // setupCrawlSimulation({ [startUrl]: { html: '' } });
350
+ // const result = await recursivelyBundleSite(startUrl, outputPath, 1);
351
+ // expect(mockNewPage).toHaveBeenCalledTimes(1);
352
+ // expect(mockPageClose).toHaveBeenCalledTimes(1);
353
+ // expect(mockBrowserClose).toHaveBeenCalledTimes(1);
354
+ // expect(loggerInstance.warn).toHaveBeenCalledWith(expect.stringContaining(`❌ Failed to process ${startUrl}: ${initialFetchError.message}`));
355
+ // expect(mockBundleMultiPageHTMLFn).toHaveBeenCalledWith([]);
356
+ // expect(result.pages).toBe(0);
357
+ // });
358
+
359
+ // it('💾 handles file write errors gracefully (mocked)', async () => {
360
+ // const writeError = new Error("Disk full");
361
+ // mockWriteFile.mockRejectedValueOnce(writeError);
362
+ // setupCrawlSimulation({ [startUrl]: { html: page2HtmlNoLinks, links: [] } });
363
+
364
+ // await expect(recursivelyBundleSite(startUrl, outputPath, 1))
365
+ // .rejects.toThrow(writeError);
366
+
367
+ // expect(mockNewPage).toHaveBeenCalledTimes(1); // Crawl happened
368
+ // expect(mockBundleMultiPageHTMLFn).toHaveBeenCalledTimes(1); // Bundle attempted
369
+ // expect(mockWriteFile).toHaveBeenCalledTimes(1); // Write attempted
370
+ // expect(mockBrowserClose).toHaveBeenCalledTimes(1); // Cleanup happened
371
+ // expect(loggerInstance.error).toHaveBeenCalledWith(expect.stringContaining(`Error during recursive site bundle: ${writeError.message}`));
372
+ // });
373
+ });
374
+ });