portapack 0.3.1 → 0.3.3

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 (74) hide show
  1. package/.eslintrc.json +67 -8
  2. package/.releaserc.js +25 -27
  3. package/CHANGELOG.md +14 -22
  4. package/LICENSE.md +21 -0
  5. package/README.md +22 -53
  6. package/commitlint.config.js +30 -34
  7. package/dist/cli/cli-entry.cjs +183 -98
  8. package/dist/cli/cli-entry.cjs.map +1 -1
  9. package/dist/index.d.ts +0 -3
  10. package/dist/index.js +178 -97
  11. package/dist/index.js.map +1 -1
  12. package/docs/.vitepress/config.ts +38 -33
  13. package/docs/.vitepress/sidebar-generator.ts +89 -38
  14. package/docs/architecture.md +186 -0
  15. package/docs/cli.md +23 -23
  16. package/docs/code-of-conduct.md +7 -1
  17. package/docs/configuration.md +12 -11
  18. package/docs/contributing.md +6 -2
  19. package/docs/deployment.md +10 -5
  20. package/docs/development.md +8 -5
  21. package/docs/getting-started.md +13 -13
  22. package/docs/index.md +1 -1
  23. package/docs/public/android-chrome-192x192.png +0 -0
  24. package/docs/public/android-chrome-512x512.png +0 -0
  25. package/docs/public/apple-touch-icon.png +0 -0
  26. package/docs/public/favicon-16x16.png +0 -0
  27. package/docs/public/favicon-32x32.png +0 -0
  28. package/docs/public/favicon.ico +0 -0
  29. package/docs/roadmap.md +233 -0
  30. package/docs/site.webmanifest +1 -0
  31. package/docs/troubleshooting.md +12 -1
  32. package/examples/main.ts +5 -30
  33. package/examples/sample-project/script.js +1 -1
  34. package/jest.config.ts +8 -13
  35. package/nodemon.json +5 -10
  36. package/package.json +2 -5
  37. package/src/cli/cli-entry.ts +2 -2
  38. package/src/cli/cli.ts +21 -16
  39. package/src/cli/options.ts +127 -113
  40. package/src/core/bundler.ts +253 -222
  41. package/src/core/extractor.ts +632 -565
  42. package/src/core/minifier.ts +173 -162
  43. package/src/core/packer.ts +141 -137
  44. package/src/core/parser.ts +74 -73
  45. package/src/core/web-fetcher.ts +270 -258
  46. package/src/index.ts +18 -17
  47. package/src/types.ts +9 -11
  48. package/src/utils/font.ts +12 -6
  49. package/src/utils/logger.ts +110 -105
  50. package/src/utils/meta.ts +75 -76
  51. package/src/utils/mime.ts +50 -50
  52. package/src/utils/slugify.ts +33 -34
  53. package/tests/unit/cli/cli-entry.test.ts +72 -70
  54. package/tests/unit/cli/cli.test.ts +314 -278
  55. package/tests/unit/cli/options.test.ts +294 -301
  56. package/tests/unit/core/bundler.test.ts +426 -329
  57. package/tests/unit/core/extractor.test.ts +793 -549
  58. package/tests/unit/core/minifier.test.ts +374 -274
  59. package/tests/unit/core/packer.test.ts +298 -264
  60. package/tests/unit/core/parser.test.ts +538 -150
  61. package/tests/unit/core/web-fetcher.test.ts +389 -359
  62. package/tests/unit/index.test.ts +238 -197
  63. package/tests/unit/utils/font.test.ts +26 -21
  64. package/tests/unit/utils/logger.test.ts +267 -260
  65. package/tests/unit/utils/meta.test.ts +29 -28
  66. package/tests/unit/utils/mime.test.ts +73 -74
  67. package/tests/unit/utils/slugify.test.ts +14 -12
  68. package/tsconfig.build.json +9 -10
  69. package/tsconfig.jest.json +1 -1
  70. package/tsconfig.json +2 -2
  71. package/tsup.config.ts +8 -9
  72. package/typedoc.json +5 -9
  73. /package/docs/{portapack-transparent.png → public/portapack-transparent.png} +0 -0
  74. /package/docs/{portapack.jpg → public/portapack.jpg} +0 -0
@@ -5,15 +5,15 @@
5
5
 
6
6
  // --- Type Imports ---
7
7
  import type {
8
- Page,
9
- Browser,
10
- HTTPResponse,
11
- GoToOptions,
12
- LaunchOptions,
13
- Viewport,
14
- EvaluateFunc,
15
- ElementHandle,
16
- // UserAgentMetadata
8
+ Page,
9
+ Browser,
10
+ HTTPResponse,
11
+ GoToOptions,
12
+ LaunchOptions,
13
+ Viewport,
14
+ EvaluateFunc,
15
+ ElementHandle,
16
+ // UserAgentMetadata
17
17
  } from 'puppeteer';
18
18
  import type { BuildResult, PageEntry, BundleMetadata } from '../../../src/types';
19
19
  import { Logger } from '../../../src/utils/logger';
@@ -40,9 +40,12 @@ const mockLaunch = jest.fn<(options?: LaunchOptions) => Promise<Browser>>();
40
40
  const mockWriteFile = jest.fn<typeof import('fs/promises').writeFile>();
41
41
  const mockBundleMultiPageHTMLFn = jest.fn<(pages: PageEntry[], logger?: Logger) => string>();
42
42
 
43
- jest.mock('puppeteer', () => ({ __esModule: true, launch: mockLaunch, }));
44
- jest.mock('fs/promises', () => ({ __esModule: true, writeFile: mockWriteFile, }));
45
- jest.mock('../../../src/core/bundler', () => ({ __esModule: true, bundleMultiPageHTML: mockBundleMultiPageHTMLFn, }));
43
+ jest.mock('puppeteer', () => ({ __esModule: true, launch: mockLaunch }));
44
+ jest.mock('fs/promises', () => ({ __esModule: true, writeFile: mockWriteFile }));
45
+ jest.mock('../../../src/core/bundler', () => ({
46
+ __esModule: true,
47
+ bundleMultiPageHTML: mockBundleMultiPageHTMLFn,
48
+ }));
46
49
  // ====================================================
47
50
 
48
51
  import { fetchAndPackWebPage, recursivelyBundleSite } from '../../../src/core/web-fetcher';
@@ -50,24 +53,24 @@ import { fetchAndPackWebPage, recursivelyBundleSite } from '../../../src/core/we
50
53
  jest.setTimeout(60000);
51
54
 
52
55
  describe('🕸️ web-fetcher', () => {
53
- let mockBrowserObject: Partial<Browser>;
54
- let mockPageObject: Partial<Page>;
55
- let loggerInstance: Logger;
56
-
57
- // --- Constants ---
58
- const startUrl = 'https://test-crawl.site/';
59
- const page2Url = `${startUrl}page2`;
60
- const page3Url = `${startUrl}page3`;
61
- const relativeUrl = `${startUrl}relative.html`; // Absolute for mock key
62
- const subDomainUrl = 'https://sub.test-crawl.site/other';
63
- const httpDomainUrl = 'http://test-crawl.site/other';
64
- const externalUrl = 'https://othersite.com';
65
- const outputPath = 'output-crawl.html';
66
- const bundledHtmlResult = '<html><body>Mock Bundled HTML</body></html>';
67
- const page1HtmlWithLinks = `<html><body>Page 1<a href="/page2">L2</a><a href="${page3Url}">L3</a></body></html>`;
68
- const page2HtmlNoLinks = `<html><body>Page 2</body></html>`;
69
- const page3HtmlWithCycleLink = `<html><body>Page 3 Content <a href="/">Link to Start</a> <a href="#section">Fragment</a></body></html>`;
70
- const pageHtmlWithVariousLinks = `
56
+ let mockBrowserObject: Partial<Browser>;
57
+ let mockPageObject: Partial<Page>;
58
+ let loggerInstance: Logger;
59
+
60
+ // --- Constants ---
61
+ const startUrl = 'https://test-crawl.site/';
62
+ const page2Url = `${startUrl}page2`;
63
+ const page3Url = `${startUrl}page3`;
64
+ const relativeUrl = `${startUrl}relative.html`; // Absolute for mock key
65
+ const subDomainUrl = 'https://sub.test-crawl.site/other';
66
+ const httpDomainUrl = 'http://test-crawl.site/other';
67
+ const externalUrl = 'https://othersite.com';
68
+ const outputPath = 'output-crawl.html';
69
+ const bundledHtmlResult = '<html><body>Mock Bundled HTML</body></html>';
70
+ const page1HtmlWithLinks = `<html><body>Page 1<a href="/page2">L2</a><a href="${page3Url}">L3</a></body></html>`;
71
+ const page2HtmlNoLinks = `<html><body>Page 2</body></html>`;
72
+ const page3HtmlWithCycleLink = `<html><body>Page 3 Content <a href="/">Link to Start</a> <a href="#section">Fragment</a></body></html>`;
73
+ const pageHtmlWithVariousLinks = `
71
74
  <html><body>
72
75
  <a href="/page2">Good Internal</a>
73
76
  <a href="relative.html">Relative Path</a>
@@ -81,339 +84,366 @@ describe('🕸️ web-fetcher', () => {
81
84
  <a href="/page2#section">Duplicate Good Internal with Frag</a>
82
85
  </body></html>`;
83
86
 
87
+ beforeEach(() => {
88
+ jest.clearAllMocks();
89
+ loggerInstance = new Logger(); // Set to DEBUG for verbose mock logs if needed
90
+ jest.spyOn(loggerInstance, 'debug');
91
+ jest.spyOn(loggerInstance, 'warn');
92
+ jest.spyOn(loggerInstance, 'error');
93
+ jest.spyOn(loggerInstance, 'info');
94
+
95
+ // Assemble mock objects
96
+ mockPageObject = {
97
+ goto: mockPageGoto,
98
+ content: mockPageContent,
99
+ evaluate: mockPageEvaluate as any,
100
+ close: mockPageClose,
101
+ setViewport: mockPageSetViewport,
102
+ url: mockPageUrl,
103
+ $: mockPage$ as any,
104
+ $$: mockPage$$ as any,
105
+ isClosed: mockPageIsClosed,
106
+ setUserAgent: mockPageSetUserAgent,
107
+ };
108
+ mockBrowserObject = {
109
+ newPage: mockNewPage,
110
+ close: mockBrowserClose,
111
+ process: mockBrowserProcess,
112
+ };
113
+
114
+ // Default Mock Configurations
115
+ mockPageGoto.mockResolvedValue(null);
116
+ mockPageContent.mockResolvedValue('<html><body>Default Mock Page Content</body></html>');
117
+ mockPageEvaluate.mockResolvedValue([]); // Default to no links
118
+ mockPageClose.mockResolvedValue(undefined);
119
+ mockPageSetViewport.mockResolvedValue(undefined);
120
+ mockPageUrl.mockReturnValue(startUrl); // Default URL initially
121
+ mockPage$.mockResolvedValue(null);
122
+ mockPage$$.mockResolvedValue([]);
123
+ mockPageIsClosed.mockReturnValue(false);
124
+ mockPageSetUserAgent.mockResolvedValue(undefined);
125
+ mockNewPage.mockResolvedValue(mockPageObject as Page); // Ensure newPage returns the configured mock object
126
+ mockBrowserClose.mockResolvedValue(undefined);
127
+ mockLaunch.mockResolvedValue(mockBrowserObject as Browser);
128
+ mockWriteFile.mockResolvedValue(undefined);
129
+ mockBundleMultiPageHTMLFn.mockReturnValue(bundledHtmlResult);
130
+ });
131
+
132
+ // --- Test Suites ---
133
+
134
+ describe('fetchAndPackWebPage()', () => {
135
+ const testUrl = 'https://example-fetch.com';
136
+ // --- fetchAndPackWebPage tests ---
137
+ it('✅ fetches rendered HTML using mocked Puppeteer', async () => {
138
+ const expectedHtml = '<html><body>Specific Mock Content</body></html>';
139
+ mockPageContent.mockResolvedValueOnce(expectedHtml);
140
+ const result = await fetchAndPackWebPage(testUrl, loggerInstance);
141
+ expect(mockLaunch).toHaveBeenCalledTimes(1);
142
+ expect(mockNewPage).toHaveBeenCalledTimes(1);
143
+ expect(mockPageGoto).toHaveBeenCalledWith(
144
+ testUrl,
145
+ expect.objectContaining({ waitUntil: 'networkidle2', timeout: 30000 })
146
+ );
147
+ expect(mockPageContent).toHaveBeenCalledTimes(1);
148
+ expect(mockPageClose).toHaveBeenCalledTimes(1);
149
+ expect(mockBrowserClose).toHaveBeenCalledTimes(1);
150
+ expect(result.html).toBe(expectedHtml);
151
+ });
152
+ it('✅ handles custom timeout and userAgent options', async () => {
153
+ const customTimeout = 15000;
154
+ const customUA = 'TestAgent/1.0';
155
+ mockPageContent.mockResolvedValueOnce('Custom UA Page');
156
+ await fetchAndPackWebPage(testUrl, loggerInstance, customTimeout, customUA);
157
+ expect(mockLaunch).toHaveBeenCalledTimes(1);
158
+ expect(mockNewPage).toHaveBeenCalledTimes(1);
159
+ expect(mockPageSetUserAgent).toHaveBeenCalledWith(customUA);
160
+ expect(mockPageGoto).toHaveBeenCalledWith(
161
+ testUrl,
162
+ expect.objectContaining({ timeout: customTimeout })
163
+ );
164
+ expect(mockPageClose).toHaveBeenCalledTimes(1);
165
+ expect(mockBrowserClose).toHaveBeenCalledTimes(1);
166
+ });
167
+ it('🚨 handles navigation timeout or failure gracefully (mocked)', async () => {
168
+ const testFailUrl = 'https://fail.test';
169
+ const navigationError = new Error('Navigation Timeout Exceeded: 30000ms exceeded');
170
+ mockPageGoto.mockImplementationOnce(async url => {
171
+ if (url === testFailUrl) throw navigationError;
172
+ return null;
173
+ });
174
+ await expect(fetchAndPackWebPage(testFailUrl, loggerInstance)).rejects.toThrow(
175
+ navigationError
176
+ );
177
+ expect(mockPageGoto).toHaveBeenCalledWith(testFailUrl, expect.anything());
178
+ expect(mockPageContent).not.toHaveBeenCalled();
179
+ expect(mockPageClose).toHaveBeenCalledTimes(1);
180
+ expect(mockBrowserClose).toHaveBeenCalledTimes(1);
181
+ });
182
+ it('❌ handles browser launch errors gracefully (mocked)', async () => {
183
+ const launchError = new Error('Failed to launch browser');
184
+ mockLaunch.mockRejectedValueOnce(launchError);
185
+ await expect(fetchAndPackWebPage(testUrl, loggerInstance)).rejects.toThrow(launchError);
186
+ expect(mockLaunch).toHaveBeenCalledTimes(1);
187
+ expect(mockNewPage).not.toHaveBeenCalled();
188
+ expect(mockBrowserClose).not.toHaveBeenCalled();
189
+ });
190
+ it('💥 handles errors during page content retrieval (mocked)', async () => {
191
+ const contentError = new Error('Failed to get page content');
192
+ mockPageGoto.mockResolvedValue(null);
193
+ mockPageContent.mockRejectedValueOnce(contentError);
194
+ await expect(fetchAndPackWebPage(testUrl, loggerInstance)).rejects.toThrow(contentError);
195
+ expect(mockPageGoto).toHaveBeenCalledTimes(1);
196
+ expect(mockPageContent).toHaveBeenCalledTimes(1);
197
+ expect(mockPageClose).toHaveBeenCalledTimes(1);
198
+ expect(mockBrowserClose).toHaveBeenCalledTimes(1);
199
+ });
200
+ it('💥 handles errors during new page creation (mocked)', async () => {
201
+ const newPageError = new Error('Failed to create new page');
202
+ mockLaunch.mockResolvedValue(mockBrowserObject as Browser);
203
+ mockNewPage.mockRejectedValueOnce(newPageError);
204
+ await expect(fetchAndPackWebPage(testUrl, loggerInstance)).rejects.toThrow(newPageError);
205
+ expect(mockLaunch).toHaveBeenCalledTimes(1);
206
+ expect(mockNewPage).toHaveBeenCalledTimes(1);
207
+ expect(mockPageGoto).not.toHaveBeenCalled();
208
+ expect(mockBrowserClose).toHaveBeenCalledTimes(1);
209
+ });
210
+ });
211
+
212
+ describe('recursivelyBundleSite()', () => {
213
+ // Helper function using the mocks - STATEFUL EVALUATE (Revised)
214
+ const setupCrawlSimulation = (pages: Record<string, { html: string; links?: string[] }>) => {
215
+ // State variable *within* the helper scope
216
+ let currentSimulatedUrl = '';
217
+
218
+ // Reset mocks each time setup is called
219
+ mockPageUrl.mockReset();
220
+ mockPageContent.mockReset();
221
+ mockPageEvaluate.mockReset();
222
+ mockPageGoto.mockReset();
223
+ mockNewPage.mockReset();
224
+
225
+ // newPage returns the shared page object
226
+ mockNewPage.mockImplementation(async () => mockPageObject as Page);
227
+
228
+ // goto updates the state variable *within this scope*
229
+ mockPageGoto.mockImplementation(async (url: string): Promise<HTTPResponse | null> => {
230
+ console.log(`DEBUG MOCK [Helper]: page.goto setting current URL to: ${url}`);
231
+ currentSimulatedUrl = url; // Update the variable in *this* closure
232
+ return null;
233
+ });
234
+
235
+ // url reads the state variable *from this scope*
236
+ mockPageUrl.mockImplementation((): string => {
237
+ return currentSimulatedUrl || startUrl;
238
+ });
239
+
240
+ // content reads the state variable *from this scope*
241
+ mockPageContent.mockImplementation(async (): Promise<string> => {
242
+ const urlNow = currentSimulatedUrl || startUrl;
243
+ return pages[urlNow]?.html ?? `<html><body>Fallback for ${urlNow}</body></html>`;
244
+ });
245
+
246
+ // evaluate reads state *from this scope* and returns links
247
+ // Needs 'as any' cast on the implementation due to complex signature
248
+ (mockPageEvaluate as any).mockImplementation(async () => {
249
+ const urlNow = currentSimulatedUrl || startUrl; // Read state from this closure
250
+ const links = pages[urlNow]?.links ?? []; // Get links based on current state
251
+ console.log(
252
+ `DEBUG MOCK [Helper-Stateful]: page.evaluate for ${urlNow}. Returning links: ${JSON.stringify(links)}`
253
+ );
254
+ return links; // Return only links
255
+ });
256
+ };
257
+
258
+ // --- recursivelyBundleSite tests ---
259
+ it('📄 crawls site recursively (BFS), bundles output, respects depth', async () => {
260
+ const maxDepth = 2;
261
+ setupCrawlSimulation({
262
+ [startUrl]: { html: page1HtmlWithLinks, links: ['/page2', page3Url] }, // Links for startUrl
263
+ [page2Url]: { html: page2HtmlNoLinks, links: [] }, // No links for page2
264
+ [page3Url]: { html: page3HtmlWithCycleLink, links: ['/'] }, // Link back for page3
265
+ });
266
+
267
+ const result = await recursivelyBundleSite(startUrl, outputPath, maxDepth, loggerInstance);
268
+
269
+ expect(mockLaunch).toHaveBeenCalledTimes(1);
270
+ // Check calls - SHOULD WORK NOW
271
+ expect(mockNewPage).toHaveBeenCalledTimes(3); // start, page2, page3
272
+ expect(mockPageGoto).toHaveBeenCalledTimes(3); // start, page2, page3
273
+ expect(mockPageEvaluate).toHaveBeenCalledTimes(1); // Only called for startUrl (depth 1 < maxDepth 2)
274
+ expect(mockPageClose).toHaveBeenCalledTimes(3);
275
+ expect(mockBrowserClose).toHaveBeenCalledTimes(1);
276
+ expect(mockBundleMultiPageHTMLFn).toHaveBeenCalledTimes(1);
277
+ const bundleArgs = mockBundleMultiPageHTMLFn.mock.calls[0][0] as PageEntry[];
278
+ expect(bundleArgs).toHaveLength(3); // Should collect all 3 pages
279
+ expect(result.pages).toBe(3);
280
+ });
84
281
 
85
- beforeEach(() => {
86
- jest.clearAllMocks();
87
- loggerInstance = new Logger(); // Set to DEBUG for verbose mock logs if needed
88
- jest.spyOn(loggerInstance, 'debug');
89
- jest.spyOn(loggerInstance, 'warn');
90
- jest.spyOn(loggerInstance, 'error');
91
- jest.spyOn(loggerInstance, 'info');
92
-
93
- // Assemble mock objects
94
- mockPageObject = {
95
- goto: mockPageGoto, content: mockPageContent, evaluate: mockPageEvaluate as any,
96
- close: mockPageClose, setViewport: mockPageSetViewport, url: mockPageUrl,
97
- $: mockPage$ as any, $$: mockPage$$ as any, isClosed: mockPageIsClosed,
98
- setUserAgent: mockPageSetUserAgent
99
- };
100
- mockBrowserObject = { newPage: mockNewPage, close: mockBrowserClose, process: mockBrowserProcess };
101
-
102
- // Default Mock Configurations
103
- mockPageGoto.mockResolvedValue(null);
104
- mockPageContent.mockResolvedValue('<html><body>Default Mock Page Content</body></html>');
105
- mockPageEvaluate.mockResolvedValue([]); // Default to no links
106
- mockPageClose.mockResolvedValue(undefined);
107
- mockPageSetViewport.mockResolvedValue(undefined);
108
- mockPageUrl.mockReturnValue(startUrl); // Default URL initially
109
- mockPage$.mockResolvedValue(null);
110
- mockPage$$.mockResolvedValue([]);
111
- mockPageIsClosed.mockReturnValue(false);
112
- mockPageSetUserAgent.mockResolvedValue(undefined);
113
- mockNewPage.mockResolvedValue(mockPageObject as Page); // Ensure newPage returns the configured mock object
114
- mockBrowserClose.mockResolvedValue(undefined);
115
- mockLaunch.mockResolvedValue(mockBrowserObject as Browser);
116
- mockWriteFile.mockResolvedValue(undefined);
117
- mockBundleMultiPageHTMLFn.mockReturnValue(bundledHtmlResult);
282
+ it('🔁 obeys crawl depth limit (maxDepth = 1)', async () => {
283
+ setupCrawlSimulation({ [startUrl]: { html: page1HtmlWithLinks, links: ['/page2'] } });
284
+ const result = await recursivelyBundleSite(startUrl, outputPath, 1, loggerInstance);
285
+ expect(mockNewPage).toHaveBeenCalledTimes(1); // Only startUrl
286
+ expect(mockPageEvaluate).not.toHaveBeenCalled(); // Depth 1 not < maxDepth 1
287
+ expect(result.pages).toBe(1);
118
288
  });
119
289
 
120
- // --- Test Suites ---
121
-
122
-
123
- describe('fetchAndPackWebPage()', () => {
124
- const testUrl = 'https://example-fetch.com';
125
- // --- fetchAndPackWebPage tests ---
126
- it('✅ fetches rendered HTML using mocked Puppeteer', async () => {
127
- const expectedHtml = '<html><body>Specific Mock Content</body></html>';
128
- mockPageContent.mockResolvedValueOnce(expectedHtml);
129
- const result = await fetchAndPackWebPage(testUrl, loggerInstance);
130
- expect(mockLaunch).toHaveBeenCalledTimes(1);
131
- expect(mockNewPage).toHaveBeenCalledTimes(1);
132
- expect(mockPageGoto).toHaveBeenCalledWith(testUrl, expect.objectContaining({ waitUntil: 'networkidle2', timeout: 30000 }));
133
- expect(mockPageContent).toHaveBeenCalledTimes(1);
134
- expect(mockPageClose).toHaveBeenCalledTimes(1);
135
- expect(mockBrowserClose).toHaveBeenCalledTimes(1);
136
- expect(result.html).toBe(expectedHtml);
137
- });
138
- it('✅ handles custom timeout and userAgent options', async () => {
139
- const customTimeout = 15000;
140
- const customUA = "TestAgent/1.0";
141
- mockPageContent.mockResolvedValueOnce("Custom UA Page");
142
- await fetchAndPackWebPage(testUrl, loggerInstance, customTimeout, customUA);
143
- expect(mockLaunch).toHaveBeenCalledTimes(1);
144
- expect(mockNewPage).toHaveBeenCalledTimes(1);
145
- expect(mockPageSetUserAgent).toHaveBeenCalledWith(customUA);
146
- expect(mockPageGoto).toHaveBeenCalledWith(testUrl, expect.objectContaining({ timeout: customTimeout }));
147
- expect(mockPageClose).toHaveBeenCalledTimes(1);
148
- expect(mockBrowserClose).toHaveBeenCalledTimes(1);
149
- });
150
- it('🚨 handles navigation timeout or failure gracefully (mocked)', async () => {
151
- const testFailUrl = 'https://fail.test';
152
- const navigationError = new Error('Navigation Timeout Exceeded: 30000ms exceeded');
153
- mockPageGoto.mockImplementationOnce(async (url) => { if (url === testFailUrl) throw navigationError; return null; });
154
- await expect(fetchAndPackWebPage(testFailUrl, loggerInstance)).rejects.toThrow(navigationError);
155
- expect(mockPageGoto).toHaveBeenCalledWith(testFailUrl, expect.anything());
156
- expect(mockPageContent).not.toHaveBeenCalled();
157
- expect(mockPageClose).toHaveBeenCalledTimes(1);
158
- expect(mockBrowserClose).toHaveBeenCalledTimes(1);
159
- });
160
- it('❌ handles browser launch errors gracefully (mocked)', async () => {
161
- const launchError = new Error('Failed to launch browser');
162
- mockLaunch.mockRejectedValueOnce(launchError);
163
- await expect(fetchAndPackWebPage(testUrl, loggerInstance)).rejects.toThrow(launchError);
164
- expect(mockLaunch).toHaveBeenCalledTimes(1);
165
- expect(mockNewPage).not.toHaveBeenCalled();
166
- expect(mockBrowserClose).not.toHaveBeenCalled();
167
- });
168
- it('💥 handles errors during page content retrieval (mocked)', async () => {
169
- const contentError = new Error('Failed to get page content');
170
- mockPageGoto.mockResolvedValue(null);
171
- mockPageContent.mockRejectedValueOnce(contentError);
172
- await expect(fetchAndPackWebPage(testUrl, loggerInstance)).rejects.toThrow(contentError);
173
- expect(mockPageGoto).toHaveBeenCalledTimes(1);
174
- expect(mockPageContent).toHaveBeenCalledTimes(1);
175
- expect(mockPageClose).toHaveBeenCalledTimes(1);
176
- expect(mockBrowserClose).toHaveBeenCalledTimes(1);
177
- });
178
- it('💥 handles errors during new page creation (mocked)', async () => {
179
- const newPageError = new Error('Failed to create new page');
180
- mockLaunch.mockResolvedValue(mockBrowserObject as Browser);
181
- mockNewPage.mockRejectedValueOnce(newPageError);
182
- await expect(fetchAndPackWebPage(testUrl, loggerInstance)).rejects.toThrow(newPageError);
183
- expect(mockLaunch).toHaveBeenCalledTimes(1);
184
- expect(mockNewPage).toHaveBeenCalledTimes(1);
185
- expect(mockPageGoto).not.toHaveBeenCalled();
186
- expect(mockBrowserClose).toHaveBeenCalledTimes(1);
187
- });
290
+ it('S crawls using default maxDepth = 1 if not provided', async () => {
291
+ setupCrawlSimulation({ [startUrl]: { html: page1HtmlWithLinks, links: ['/page2'] } });
292
+ await recursivelyBundleSite(startUrl, outputPath, undefined, loggerInstance);
293
+ expect(mockNewPage).toHaveBeenCalledTimes(1);
294
+ expect(mockPageEvaluate).not.toHaveBeenCalled();
188
295
  });
189
296
 
297
+ it('🚫 handles maxDepth = 0 correctly (fetches nothing, bundles nothing)', async () => {
298
+ const result = await recursivelyBundleSite(startUrl, outputPath, 0, loggerInstance);
299
+ expect(mockLaunch).not.toHaveBeenCalled();
300
+ expect(result.pages).toBe(0);
301
+ });
302
+
303
+ // it('🔗 filters links correctly (internal, visited, origin, fragments, relative)', async () => {
304
+ // const maxDepth = 3;
305
+ // // Setup simulation with a mix of links
306
+ // setupCrawlSimulation({
307
+ // [startUrl]: { html: pageHtmlWithVariousLinks, links: [ '/page2', 'relative.html', '/page3?query=1#frag', subDomainUrl, httpDomainUrl, externalUrl, 'mailto:t@e.com', 'javascript:void(0)', ':/bad', '/page2#section'] },
308
+ // [page2Url]: { html: page2HtmlNoLinks, links: ['/page3'] }, // Needs absolute path for key
309
+ // [page3Url]: { html: page3HtmlWithCycleLink, links: ['/', '/page2#a'] },
310
+ // [relativeUrl]: { html: 'Relative Page', links: [] } // Needs absolute path for key
311
+ // });
312
+ // await recursivelyBundleSite(startUrl, outputPath, maxDepth, loggerInstance);
313
+
314
+ // expect(mockNewPage).toHaveBeenCalledTimes(4); // startUrl, page2Url, relativeUrl, page3Url
315
+ // expect(mockPageGoto).toHaveBeenCalledTimes(4);
316
+ // // Evaluate called if depth < maxDepth
317
+ // // startUrl (d1<3), page2Url (d2<3), relativeUrl (d2<3), page3Url (d3==3, NO)
318
+ // expect(mockPageEvaluate).toHaveBeenCalledTimes(3);
319
+ // expect(mockBundleMultiPageHTMLFn.mock.calls[0][0]).toHaveLength(4); // All 4 valid internal pages collected
320
+ // });
321
+
322
+ it('🔄 handles crawl cycles gracefully (visited set)', async () => {
323
+ setupCrawlSimulation({
324
+ [startUrl]: { html: `<a>1</a>`, links: [page2Url] },
325
+ [page2Url]: { html: `<a>2</a>`, links: [page3Url] },
326
+ [page3Url]: { html: `<a>3</a>`, links: [startUrl, page2Url] }, // Links back
327
+ });
328
+ await recursivelyBundleSite(startUrl, outputPath, 5, loggerInstance);
329
+ expect(mockNewPage).toHaveBeenCalledTimes(3); // Each visited only once
330
+ expect(mockPageGoto).toHaveBeenCalledTimes(3);
331
+ // Evaluate called if depth < maxDepth
332
+ // start (d1<5), page2 (d2<5), page3 (d3<5) -> YES for all 3
333
+ expect(mockPageEvaluate).toHaveBeenCalledTimes(3);
334
+ expect(mockBundleMultiPageHTMLFn.mock.calls[0][0]).toHaveLength(3);
335
+ });
336
+
337
+ // it('🤕 handles fetch errors during crawl and continues (mocked)', async () => {
338
+ // const errorUrl = page2Url;
339
+ // const successUrl = page3Url;
340
+ // const fetchError = new Error("Mock navigation failed!");
341
+
342
+ // // Define the structure of the page data value
343
+ // interface MockPageData {
344
+ // html: string;
345
+ // links?: string[];
346
+ // }
347
+
348
+ // // Explicitly type pagesData using Record<string, MockPageData>
349
+ // const pagesData: Record<string, MockPageData> = {
350
+ // [startUrl]: { html: `<html><body>Page 1 <a href="${errorUrl}">L2</a> <a href="${successUrl}">L3</a></body></html>`, links: [errorUrl, successUrl] },
351
+ // // No entry for errorUrl
352
+ // [successUrl]: { html: page2HtmlNoLinks, links: [] } // Page 3 successfully fetched
353
+ // };
354
+ // let currentUrlForTest = ''; // Local state for this test's mock
355
+
356
+ // // Configure mocks directly for this test scenario
357
+ // mockNewPage.mockImplementation(async () => mockPageObject as Page);
358
+ // mockPageGoto.mockImplementation(async (url: string) => {
359
+ // console.log(`[DEBUG MOCK - Error Test]: page.goto attempting: ${url}`);
360
+ // currentUrlForTest = url;
361
+ // if (url === errorUrl) {
362
+ // console.log(`[DEBUG MOCK - Error Test]: Throwing for ${url}`);
363
+ // throw fetchError;
364
+ // }
365
+ // console.log(`[DEBUG MOCK - Error Test]: Goto success for ${url}`);
366
+ // return null;
367
+ // });
368
+ // mockPageUrl.mockImplementation(() => currentUrlForTest);
369
+
370
+ // // These lines should now be type-safe because pagesData is a Record<string, ...>
371
+ // mockPageContent.mockImplementation(async () => pagesData[currentUrlForTest]?.html ?? `<html><body>Mock Fallback for ${currentUrlForTest}</body></html>`);
372
+ // const mockPageEvaluate = jest.fn<any>(); // Use any to simplify mock typing
373
+ // // Run the function
374
+ // const result = await recursivelyBundleSite(startUrl, outputPath, 2, loggerInstance);
375
+
376
+ // // Assertions (remain the same)
377
+ // expect(mockNewPage).toHaveBeenCalledTimes(3);
378
+ // expect(mockPageGoto).toHaveBeenCalledTimes(3);
379
+ // expect(mockPageClose).toHaveBeenCalledTimes(3);
380
+ // expect(mockBrowserClose).toHaveBeenCalledTimes(1);
381
+ // expect(loggerInstance.warn).toHaveBeenCalledTimes(1);
382
+ // expect(loggerInstance.warn).toHaveBeenCalledWith(expect.stringContaining(`❌ Failed to process ${errorUrl}: ${fetchError.message}`));
383
+ // expect(mockBundleMultiPageHTMLFn).toHaveBeenCalledTimes(1);
384
+ // const bundledPages = mockBundleMultiPageHTMLFn.mock.calls[0][0];
385
+ // expect(bundledPages).toHaveLength(2);
386
+ // expect(bundledPages.find(p => p.url === startUrl)).toBeDefined();
387
+ // expect(bundledPages.find(p => p.url === successUrl)).toBeDefined();
388
+ // expect(result.pages).toBe(2);
389
+ // });
390
+
391
+ it('📁 handles empty crawl result (e.g., initial fetch fails) (mocked)', async () => {
392
+ const initialFetchError = new Error('Initial goto failed');
393
+
394
+ // Specific mock setup for this test
395
+ // No need for pagesData as the first fetch fails
396
+ mockNewPage.mockImplementation(async () => mockPageObject as Page);
397
+ mockPageGoto.mockImplementation(async (url: string) => {
398
+ console.log(`[DEBUG MOCK - Initial Fail Test]: page.goto attempting: ${url}`);
399
+ if (url === startUrl) {
400
+ console.log(`[DEBUG MOCK - Initial Fail Test]: Throwing for ${url}`);
401
+ throw initialFetchError;
402
+ }
403
+ // Should not be called for other URLs in this test scenario
404
+ console.error(
405
+ `[DEBUG MOCK - Initial Fail Test]: ERROR - goto called unexpectedly for ${url}`
406
+ );
407
+ return null;
408
+ });
409
+ // Other mocks (content, evaluate) shouldn't be called if goto fails first
410
+
411
+ // Run the function
412
+ const result = await recursivelyBundleSite(startUrl, outputPath, 1, loggerInstance);
413
+
414
+ // Assertions
415
+ expect(mockLaunch).toHaveBeenCalledTimes(1);
416
+ expect(mockNewPage).toHaveBeenCalledTimes(1); // Attempted to open one page
417
+ expect(mockPageGoto).toHaveBeenCalledTimes(1); // Attempted to navigate once
418
+ expect(mockPageGoto).toHaveBeenCalledWith(startUrl, expect.anything());
419
+ expect(mockPageClose).toHaveBeenCalledTimes(1); // The single page attempt should be closed
420
+ expect(mockBrowserClose).toHaveBeenCalledTimes(1);
421
+
422
+ expect(loggerInstance.warn).toHaveBeenCalledTimes(2);
423
+ expect(loggerInstance.warn).toHaveBeenCalledWith(
424
+ expect.stringContaining(`❌ Failed to process ${startUrl}: ${initialFetchError.message}`)
425
+ ); // Check message
426
+
427
+ expect(mockBundleMultiPageHTMLFn).toHaveBeenCalledTimes(1);
428
+ expect(mockBundleMultiPageHTMLFn).toHaveBeenCalledWith([], loggerInstance); // Ensure it bundles an empty array
429
+
430
+ expect(mockWriteFile).toHaveBeenCalledTimes(1); // Should still write the (empty) bundle
431
+ expect(result.pages).toBe(0); // Verify returned page count
432
+ });
433
+
434
+ it('💾 handles file write errors gracefully (mocked)', async () => {
435
+ const writeError = new Error('Disk full');
436
+ mockWriteFile.mockRejectedValueOnce(writeError);
437
+ setupCrawlSimulation({ [startUrl]: { html: page2HtmlNoLinks, links: [] } });
438
+
439
+ await expect(recursivelyBundleSite(startUrl, outputPath, 1, loggerInstance)).rejects.toThrow(
440
+ writeError
441
+ );
190
442
 
191
- describe('recursivelyBundleSite()', () => {
192
- // Helper function using the mocks - STATEFUL EVALUATE (Revised)
193
- const setupCrawlSimulation = (pages: Record<string, { html: string; links?: string[] }>) => {
194
- // State variable *within* the helper scope
195
- let currentSimulatedUrl = '';
196
-
197
- // Reset mocks each time setup is called
198
- mockPageUrl.mockReset(); mockPageContent.mockReset();
199
- mockPageEvaluate.mockReset(); mockPageGoto.mockReset();
200
- mockNewPage.mockReset();
201
-
202
- // newPage returns the shared page object
203
- mockNewPage.mockImplementation(async () => mockPageObject as Page);
204
-
205
- // goto updates the state variable *within this scope*
206
- mockPageGoto.mockImplementation(async (url: string): Promise<HTTPResponse | null> => {
207
- console.log(`DEBUG MOCK [Helper]: page.goto setting current URL to: ${url}`);
208
- currentSimulatedUrl = url; // Update the variable in *this* closure
209
- return null;
210
- });
211
-
212
- // url reads the state variable *from this scope*
213
- mockPageUrl.mockImplementation((): string => {
214
- return currentSimulatedUrl || startUrl;
215
- });
216
-
217
- // content reads the state variable *from this scope*
218
- mockPageContent.mockImplementation(async (): Promise<string> => {
219
- const urlNow = currentSimulatedUrl || startUrl;
220
- return pages[urlNow]?.html ?? `<html><body>Fallback for ${urlNow}</body></html>`;
221
- });
222
-
223
- // evaluate reads state *from this scope* and returns links
224
- // Needs 'as any' cast on the implementation due to complex signature
225
- (mockPageEvaluate as any).mockImplementation(async () => {
226
- const urlNow = currentSimulatedUrl || startUrl; // Read state from this closure
227
- const links = pages[urlNow]?.links ?? []; // Get links based on current state
228
- console.log(`DEBUG MOCK [Helper-Stateful]: page.evaluate for ${urlNow}. Returning links: ${JSON.stringify(links)}`);
229
- return links; // Return only links
230
- });
231
- };
232
-
233
-
234
- // --- recursivelyBundleSite tests ---
235
- it('📄 crawls site recursively (BFS), bundles output, respects depth', async () => {
236
- const maxDepth = 2;
237
- setupCrawlSimulation({
238
- [startUrl]: { html: page1HtmlWithLinks, links: ['/page2', page3Url] }, // Links for startUrl
239
- [page2Url]: { html: page2HtmlNoLinks, links: [] }, // No links for page2
240
- [page3Url]: { html: page3HtmlWithCycleLink, links: ['/'] } // Link back for page3
241
- });
242
-
243
- const result = await recursivelyBundleSite(startUrl, outputPath, maxDepth, loggerInstance);
244
-
245
- expect(mockLaunch).toHaveBeenCalledTimes(1);
246
- // Check calls - SHOULD WORK NOW
247
- expect(mockNewPage).toHaveBeenCalledTimes(3); // start, page2, page3
248
- expect(mockPageGoto).toHaveBeenCalledTimes(3); // start, page2, page3
249
- expect(mockPageEvaluate).toHaveBeenCalledTimes(1); // Only called for startUrl (depth 1 < maxDepth 2)
250
- expect(mockPageClose).toHaveBeenCalledTimes(3);
251
- expect(mockBrowserClose).toHaveBeenCalledTimes(1);
252
- expect(mockBundleMultiPageHTMLFn).toHaveBeenCalledTimes(1);
253
- const bundleArgs = mockBundleMultiPageHTMLFn.mock.calls[0][0] as PageEntry[];
254
- expect(bundleArgs).toHaveLength(3); // Should collect all 3 pages
255
- expect(result.pages).toBe(3);
256
- });
257
-
258
- it('🔁 obeys crawl depth limit (maxDepth = 1)', async () => {
259
- setupCrawlSimulation({ [startUrl]: { html: page1HtmlWithLinks, links: ['/page2'] } });
260
- const result = await recursivelyBundleSite(startUrl, outputPath, 1, loggerInstance);
261
- expect(mockNewPage).toHaveBeenCalledTimes(1); // Only startUrl
262
- expect(mockPageEvaluate).not.toHaveBeenCalled(); // Depth 1 not < maxDepth 1
263
- expect(result.pages).toBe(1);
264
- });
265
-
266
- it('S crawls using default maxDepth = 1 if not provided', async () => {
267
- setupCrawlSimulation({ [startUrl]: { html: page1HtmlWithLinks, links: ['/page2'] } });
268
- await recursivelyBundleSite(startUrl, outputPath, undefined, loggerInstance);
269
- expect(mockNewPage).toHaveBeenCalledTimes(1);
270
- expect(mockPageEvaluate).not.toHaveBeenCalled();
271
- });
272
-
273
- it('🚫 handles maxDepth = 0 correctly (fetches nothing, bundles nothing)', async () => {
274
- const result = await recursivelyBundleSite(startUrl, outputPath, 0, loggerInstance);
275
- expect(mockLaunch).not.toHaveBeenCalled();
276
- expect(result.pages).toBe(0);
277
- });
278
-
279
- // it('🔗 filters links correctly (internal, visited, origin, fragments, relative)', async () => {
280
- // const maxDepth = 3;
281
- // // Setup simulation with a mix of links
282
- // setupCrawlSimulation({
283
- // [startUrl]: { html: pageHtmlWithVariousLinks, links: [ '/page2', 'relative.html', '/page3?query=1#frag', subDomainUrl, httpDomainUrl, externalUrl, 'mailto:t@e.com', 'javascript:void(0)', ':/bad', '/page2#section'] },
284
- // [page2Url]: { html: page2HtmlNoLinks, links: ['/page3'] }, // Needs absolute path for key
285
- // [page3Url]: { html: page3HtmlWithCycleLink, links: ['/', '/page2#a'] },
286
- // [relativeUrl]: { html: 'Relative Page', links: [] } // Needs absolute path for key
287
- // });
288
- // await recursivelyBundleSite(startUrl, outputPath, maxDepth, loggerInstance);
289
-
290
- // expect(mockNewPage).toHaveBeenCalledTimes(4); // startUrl, page2Url, relativeUrl, page3Url
291
- // expect(mockPageGoto).toHaveBeenCalledTimes(4);
292
- // // Evaluate called if depth < maxDepth
293
- // // startUrl (d1<3), page2Url (d2<3), relativeUrl (d2<3), page3Url (d3==3, NO)
294
- // expect(mockPageEvaluate).toHaveBeenCalledTimes(3);
295
- // expect(mockBundleMultiPageHTMLFn.mock.calls[0][0]).toHaveLength(4); // All 4 valid internal pages collected
296
- // });
297
-
298
-
299
- it('🔄 handles crawl cycles gracefully (visited set)', async () => {
300
- setupCrawlSimulation({
301
- [startUrl]: { html: `<a>1</a>`, links: [page2Url] },
302
- [page2Url]: { html: `<a>2</a>`, links: [page3Url] },
303
- [page3Url]: { html: `<a>3</a>`, links: [startUrl, page2Url] } // Links back
304
- });
305
- await recursivelyBundleSite(startUrl, outputPath, 5, loggerInstance);
306
- expect(mockNewPage).toHaveBeenCalledTimes(3); // Each visited only once
307
- expect(mockPageGoto).toHaveBeenCalledTimes(3);
308
- // Evaluate called if depth < maxDepth
309
- // start (d1<5), page2 (d2<5), page3 (d3<5) -> YES for all 3
310
- expect(mockPageEvaluate).toHaveBeenCalledTimes(3);
311
- expect(mockBundleMultiPageHTMLFn.mock.calls[0][0]).toHaveLength(3);
312
- });
313
-
314
- // it('🤕 handles fetch errors during crawl and continues (mocked)', async () => {
315
- // const errorUrl = page2Url;
316
- // const successUrl = page3Url;
317
- // const fetchError = new Error("Mock navigation failed!");
318
-
319
- // // Define the structure of the page data value
320
- // interface MockPageData {
321
- // html: string;
322
- // links?: string[];
323
- // }
324
-
325
- // // Explicitly type pagesData using Record<string, MockPageData>
326
- // const pagesData: Record<string, MockPageData> = {
327
- // [startUrl]: { html: `<html><body>Page 1 <a href="${errorUrl}">L2</a> <a href="${successUrl}">L3</a></body></html>`, links: [errorUrl, successUrl] },
328
- // // No entry for errorUrl
329
- // [successUrl]: { html: page2HtmlNoLinks, links: [] } // Page 3 successfully fetched
330
- // };
331
- // let currentUrlForTest = ''; // Local state for this test's mock
332
-
333
- // // Configure mocks directly for this test scenario
334
- // mockNewPage.mockImplementation(async () => mockPageObject as Page);
335
- // mockPageGoto.mockImplementation(async (url: string) => {
336
- // console.log(`[DEBUG MOCK - Error Test]: page.goto attempting: ${url}`);
337
- // currentUrlForTest = url;
338
- // if (url === errorUrl) {
339
- // console.log(`[DEBUG MOCK - Error Test]: Throwing for ${url}`);
340
- // throw fetchError;
341
- // }
342
- // console.log(`[DEBUG MOCK - Error Test]: Goto success for ${url}`);
343
- // return null;
344
- // });
345
- // mockPageUrl.mockImplementation(() => currentUrlForTest);
346
-
347
- // // These lines should now be type-safe because pagesData is a Record<string, ...>
348
- // mockPageContent.mockImplementation(async () => pagesData[currentUrlForTest]?.html ?? `<html><body>Mock Fallback for ${currentUrlForTest}</body></html>`);
349
- // const mockPageEvaluate = jest.fn<any>(); // Use any to simplify mock typing
350
- // // Run the function
351
- // const result = await recursivelyBundleSite(startUrl, outputPath, 2, loggerInstance);
352
-
353
- // // Assertions (remain the same)
354
- // expect(mockNewPage).toHaveBeenCalledTimes(3);
355
- // expect(mockPageGoto).toHaveBeenCalledTimes(3);
356
- // expect(mockPageClose).toHaveBeenCalledTimes(3);
357
- // expect(mockBrowserClose).toHaveBeenCalledTimes(1);
358
- // expect(loggerInstance.warn).toHaveBeenCalledTimes(1);
359
- // expect(loggerInstance.warn).toHaveBeenCalledWith(expect.stringContaining(`❌ Failed to process ${errorUrl}: ${fetchError.message}`));
360
- // expect(mockBundleMultiPageHTMLFn).toHaveBeenCalledTimes(1);
361
- // const bundledPages = mockBundleMultiPageHTMLFn.mock.calls[0][0];
362
- // expect(bundledPages).toHaveLength(2);
363
- // expect(bundledPages.find(p => p.url === startUrl)).toBeDefined();
364
- // expect(bundledPages.find(p => p.url === successUrl)).toBeDefined();
365
- // expect(result.pages).toBe(2);
366
- // });
367
-
368
- it('📁 handles empty crawl result (e.g., initial fetch fails) (mocked)', async () => {
369
- const initialFetchError = new Error("Initial goto failed");
370
-
371
- // Specific mock setup for this test
372
- // No need for pagesData as the first fetch fails
373
- mockNewPage.mockImplementation(async () => mockPageObject as Page);
374
- mockPageGoto.mockImplementation(async (url: string) => {
375
- console.log(`[DEBUG MOCK - Initial Fail Test]: page.goto attempting: ${url}`);
376
- if (url === startUrl) {
377
- console.log(`[DEBUG MOCK - Initial Fail Test]: Throwing for ${url}`);
378
- throw initialFetchError;
379
- }
380
- // Should not be called for other URLs in this test scenario
381
- console.error(`[DEBUG MOCK - Initial Fail Test]: ERROR - goto called unexpectedly for ${url}`);
382
- return null;
383
- });
384
- // Other mocks (content, evaluate) shouldn't be called if goto fails first
385
-
386
- // Run the function
387
- const result = await recursivelyBundleSite(startUrl, outputPath, 1, loggerInstance);
388
-
389
- // Assertions
390
- expect(mockLaunch).toHaveBeenCalledTimes(1);
391
- expect(mockNewPage).toHaveBeenCalledTimes(1); // Attempted to open one page
392
- expect(mockPageGoto).toHaveBeenCalledTimes(1); // Attempted to navigate once
393
- expect(mockPageGoto).toHaveBeenCalledWith(startUrl, expect.anything());
394
- expect(mockPageClose).toHaveBeenCalledTimes(1); // The single page attempt should be closed
395
- expect(mockBrowserClose).toHaveBeenCalledTimes(1);
396
-
397
- expect(loggerInstance.warn).toHaveBeenCalledTimes(2);
398
- expect(loggerInstance.warn).toHaveBeenCalledWith(expect.stringContaining(`❌ Failed to process ${startUrl}: ${initialFetchError.message}`)); // Check message
399
-
400
- expect(mockBundleMultiPageHTMLFn).toHaveBeenCalledTimes(1);
401
- expect(mockBundleMultiPageHTMLFn).toHaveBeenCalledWith([], loggerInstance); // Ensure it bundles an empty array
402
-
403
- expect(mockWriteFile).toHaveBeenCalledTimes(1); // Should still write the (empty) bundle
404
- expect(result.pages).toBe(0); // Verify returned page count
405
- });
406
-
407
- it('💾 handles file write errors gracefully (mocked)', async () => {
408
- const writeError = new Error("Disk full");
409
- mockWriteFile.mockRejectedValueOnce(writeError);
410
- setupCrawlSimulation({ [startUrl]: { html: page2HtmlNoLinks, links: [] } });
411
-
412
- await expect(recursivelyBundleSite(startUrl, outputPath, 1, loggerInstance))
413
- .rejects.toThrow(writeError);
414
-
415
- expect(mockWriteFile).toHaveBeenCalledTimes(1);
416
- expect(loggerInstance.error).toHaveBeenCalledWith(expect.stringContaining(`Error during recursive site bundle: ${writeError.message}`));
417
- });
443
+ expect(mockWriteFile).toHaveBeenCalledTimes(1);
444
+ expect(loggerInstance.error).toHaveBeenCalledWith(
445
+ expect.stringContaining(`Error during recursive site bundle: ${writeError.message}`)
446
+ );
418
447
  });
419
- });
448
+ });
449
+ });