portapack 0.3.0 → 0.3.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 (74) hide show
  1. package/.eslintrc.json +67 -8
  2. package/.github/workflows/ci.yml +5 -4
  3. package/.releaserc.js +25 -27
  4. package/CHANGELOG.md +12 -19
  5. package/LICENSE.md +21 -0
  6. package/README.md +34 -36
  7. package/commitlint.config.js +30 -34
  8. package/dist/cli/cli-entry.cjs +199 -135
  9. package/dist/cli/cli-entry.cjs.map +1 -1
  10. package/dist/index.d.ts +0 -3
  11. package/dist/index.js +194 -134
  12. package/dist/index.js.map +1 -1
  13. package/docs/.vitepress/config.ts +36 -34
  14. package/docs/.vitepress/sidebar-generator.ts +89 -38
  15. package/docs/cli.md +29 -82
  16. package/docs/code-of-conduct.md +7 -1
  17. package/docs/configuration.md +103 -117
  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 +76 -45
  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/site.webmanifest +1 -0
  30. package/docs/troubleshooting.md +12 -1
  31. package/examples/main.ts +7 -10
  32. package/examples/sample-project/script.js +1 -1
  33. package/jest.config.ts +8 -13
  34. package/nodemon.json +5 -10
  35. package/package.json +2 -5
  36. package/src/cli/cli-entry.ts +2 -2
  37. package/src/cli/cli.ts +21 -16
  38. package/src/cli/options.ts +127 -113
  39. package/src/core/bundler.ts +254 -221
  40. package/src/core/extractor.ts +639 -520
  41. package/src/core/minifier.ts +173 -162
  42. package/src/core/packer.ts +141 -137
  43. package/src/core/parser.ts +74 -73
  44. package/src/core/web-fetcher.ts +270 -258
  45. package/src/index.ts +18 -17
  46. package/src/types.ts +9 -11
  47. package/src/utils/font.ts +12 -6
  48. package/src/utils/logger.ts +110 -105
  49. package/src/utils/meta.ts +75 -76
  50. package/src/utils/mime.ts +50 -50
  51. package/src/utils/slugify.ts +33 -34
  52. package/tests/unit/cli/cli-entry.test.ts +72 -70
  53. package/tests/unit/cli/cli.test.ts +314 -278
  54. package/tests/unit/cli/options.test.ts +294 -301
  55. package/tests/unit/core/bundler.test.ts +426 -329
  56. package/tests/unit/core/extractor.test.ts +828 -380
  57. package/tests/unit/core/minifier.test.ts +374 -274
  58. package/tests/unit/core/packer.test.ts +298 -264
  59. package/tests/unit/core/parser.test.ts +538 -150
  60. package/tests/unit/core/web-fetcher.test.ts +389 -359
  61. package/tests/unit/index.test.ts +238 -197
  62. package/tests/unit/utils/font.test.ts +26 -21
  63. package/tests/unit/utils/logger.test.ts +267 -260
  64. package/tests/unit/utils/meta.test.ts +29 -28
  65. package/tests/unit/utils/mime.test.ts +73 -74
  66. package/tests/unit/utils/slugify.test.ts +14 -12
  67. package/tsconfig.build.json +9 -10
  68. package/tsconfig.jest.json +2 -1
  69. package/tsconfig.json +2 -2
  70. package/tsup.config.ts +8 -8
  71. package/typedoc.json +5 -9
  72. package/docs/demo.md +0 -46
  73. /package/docs/{portapack-transparent.png → public/portapack-transparent.png} +0 -0
  74. /package/docs/{portapack.jpg → public/portapack.jpg} +0 -0
@@ -7,463 +7,911 @@
7
7
  import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
8
8
  import path from 'path';
9
9
  import { fileURLToPath, pathToFileURL, URL } from 'url';
10
- import * as fs from 'fs';
10
+ // Use specific imports from 'fs' and 'fs/promises' where needed
11
+ import * as fsPromises from 'fs/promises'; // For mocking readFile
12
+ import * as fs from 'fs'; // For mocking statSync etc.
11
13
  import type { PathLike } from 'fs';
12
14
  import type { FileHandle } from 'fs/promises';
13
- import type { OpenMode, Stats, StatSyncOptions, BigIntStats } from 'node:fs';
15
+ import type { OpenMode, Stats, StatSyncOptions, BigIntStats } from 'node:fs'; // Use node: prefix
16
+ // Import types from the project
14
17
  import type { Asset, ParsedHTML } from '../../../src/types'; // Adjust path as needed
15
18
  import { LogLevel } from '../../../src/types'; // Adjust path as needed
16
19
  import { Logger } from '../../../src/utils/logger'; // Adjust path as needed
17
-
18
20
  // Import necessary axios types and the namespace
19
21
  import type {
20
- AxiosResponse,
21
- AxiosRequestConfig,
22
- AxiosError,
23
- AxiosHeaderValue,
24
- AxiosRequestHeaders,
25
- AxiosResponseHeaders,
26
- InternalAxiosRequestConfig
22
+ AxiosResponse,
23
+ AxiosRequestConfig,
24
+ AxiosError,
25
+ AxiosHeaderValue,
26
+ AxiosRequestHeaders,
27
+ AxiosResponseHeaders,
28
+ InternalAxiosRequestConfig,
27
29
  } from 'axios';
28
- import * as axiosNs from 'axios';
29
- import { AxiosHeaders } from 'axios';
30
+ import * as axiosNs from 'axios'; // Namespace import
31
+ import { AxiosHeaders } from 'axios'; // Import AxiosHeaders class if used directly
30
32
 
31
33
  // =================== MOCK SETUP ===================
32
34
 
33
35
  // --- Apply Mocks (Using jest.mock at top level) ---
36
+ // Mock the entire 'fs/promises', 'fs', and 'axios' modules
34
37
  jest.mock('fs/promises');
35
38
  jest.mock('fs');
36
39
  jest.mock('axios');
37
40
 
38
41
  // --- Define Mock Function Variable Types ---
39
- type MockedReadFileFn = jest.MockedFunction<typeof import('fs/promises').readFile>;
42
+ // Use jest.MockedFunction for type safety with mocked modules
43
+ type MockedReadFileFn = jest.MockedFunction<typeof fsPromises.readFile>;
40
44
  type MockedStatSyncFn = jest.MockedFunction<typeof fs.statSync>;
41
45
  type MockedAxiosGetFn = jest.MockedFunction<typeof axiosNs.default.get>;
42
46
 
43
47
  // --- Declare Mock Function Variables (assigned in beforeEach) ---
48
+ // These will hold the mocked functions retrieved via jest.requireMock
44
49
  let mockReadFile: MockedReadFileFn;
45
50
  let mockStatSync: MockedStatSyncFn;
46
51
  let mockAxiosGet: MockedAxiosGetFn;
47
52
 
48
53
  // --- Import Module Under Test ---
49
- import { extractAssets } from '../../../src/core/extractor'; // Adjust path
54
+ // Import after mocks are defined
55
+ import { extractAssets } from '../../../src/core/extractor'; // Adjust path as needed
50
56
 
51
57
  // ================ TEST SETUP (Constants & Mock Data - Defined Globally) ================
52
58
 
59
+ // Determine if running on Windows for path handling
53
60
  const isWindows = process.platform === 'win32';
61
+ // Define a mock base directory path based on OS
54
62
  const mockBaseDirPath = path.resolve(isWindows ? 'C:\\mock\\base\\dir' : '/mock/base/dir');
55
- const mockBaseFileUrl = pathToFileURL(mockBaseDirPath + path.sep).href;
63
+ // Create the corresponding file URL for the base directory
64
+ const mockBaseFileUrl = pathToFileURL(mockBaseDirPath + path.sep).href; // Ensure trailing slash
65
+ // Define a mock HTTP base URL
56
66
  const mockBaseHttpUrl = 'https://example.com/base/dir/';
57
67
 
68
+ // Helper function to normalize paths for consistent comparisons
58
69
  const normalizePath = (filePath: string): string => path.normalize(filePath);
59
70
 
60
- // Define filePaths globally
71
+ // Define paths for various mock files used in tests
61
72
  const filePaths = {
62
- styleCss: path.join(mockBaseDirPath, 'style.css'),
63
- scriptJs: path.join(mockBaseDirPath, 'script.js'),
64
- deepCss: path.join(mockBaseDirPath, 'css', 'deep.css'),
65
- fontFile: path.join(mockBaseDirPath, 'font', 'font.woff2'),
66
- bgImage: path.join(mockBaseDirPath, 'images', 'bg.png'),
67
- nestedImage: path.join(mockBaseDirPath, 'images', 'nested-img.png'),
68
- nonexistent: path.join(mockBaseDirPath, 'nonexistent.file'),
69
- unreadable: path.join(mockBaseDirPath, 'unreadable.file'),
70
- invalidUtf8: path.join(mockBaseDirPath, 'invalid-utf8.css'),
71
- dataUriCss: path.join(mockBaseDirPath, 'data-uri.css'),
72
- cycle1Css: path.join(mockBaseDirPath, 'cycle1.css'),
73
- cycle2Css: path.join(mockBaseDirPath, 'cycle2.css'),
74
- iterationStartCss: path.join(mockBaseDirPath, 'start.css'),
75
- complexUrlCss: path.join(mockBaseDirPath, 'complex-url.css'),
73
+ styleCss: path.join(mockBaseDirPath, 'style.css'),
74
+ scriptJs: path.join(mockBaseDirPath, 'script.js'),
75
+ deepCss: path.join(mockBaseDirPath, 'css', 'deep.css'),
76
+ fontFile: path.join(mockBaseDirPath, 'font', 'font.woff2'),
77
+ bgImage: path.join(mockBaseDirPath, 'images', 'bg.png'),
78
+ nestedImage: path.join(mockBaseDirPath, 'images', 'nested-img.png'),
79
+ nonexistent: path.join(mockBaseDirPath, 'nonexistent.file'),
80
+ unreadable: path.join(mockBaseDirPath, 'unreadable.file'),
81
+ invalidUtf8: path.join(mockBaseDirPath, 'invalid-utf8.css'),
82
+ dataUriCss: path.join(mockBaseDirPath, 'data-uri.css'),
83
+ cycle1Css: path.join(mockBaseDirPath, 'cycle1.css'),
84
+ cycle2Css: path.join(mockBaseDirPath, 'cycle2.css'),
85
+ iterationStartCss: path.join(mockBaseDirPath, 'start.css'), // For loop test
86
+ complexUrlCss: path.join(mockBaseDirPath, 'complex-url.css'), // CSS containing URL with query/fragment
76
87
  };
77
88
 
78
89
  // --- Mock Data ---
79
- const invalidUtf8Buffer = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x80, 0x6f]);
90
+ // Buffer containing invalid UTF-8 sequence
91
+ const invalidUtf8Buffer = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x80, 0x6f]); // Contains 0x80 which is invalid in UTF-8
92
+
93
+ // Map normalized file paths to their mock content (string or Buffer)
80
94
  const mockFileContents: Record<string, string | Buffer> = {
81
- [normalizePath(filePaths.styleCss)]: '@import url("./css/deep.css");\nbody { background: url("images/bg.png"); @font-face { src: url("font/font.woff2"); } }',
82
- [normalizePath(filePaths.scriptJs)]: 'console.log("mock script");',
83
- [normalizePath(filePaths.deepCss)]: 'h1 { background: url("../images/nested-img.png"); }', // Contains nested relative path
84
- [normalizePath(filePaths.fontFile)]: Buffer.from('mock-font-data'),
85
- [normalizePath(filePaths.bgImage)]: Buffer.from('mock-image-data'),
86
- [normalizePath(filePaths.nestedImage)]: Buffer.from('mock-nested-image-data'), // Content for the nested image
87
- [normalizePath(filePaths.invalidUtf8)]: invalidUtf8Buffer,
88
- [normalizePath(filePaths.dataUriCss)]: 'body { background: url(data:image/png;base64,SHORT_DATA_URI); }',
89
- [normalizePath(filePaths.cycle1Css)]: '@import url("cycle2.css");',
90
- [normalizePath(filePaths.cycle2Css)]: '@import url("cycle1.css");',
91
- [normalizePath(filePaths.iterationStartCss)]: '@import url("gen_1.css");',
92
- [normalizePath(filePaths.complexUrlCss)]: 'body { background: url("images/bg.png?v=123#section"); }',
93
- [normalizePath(filePaths.unreadable)]: Buffer.from(''),
95
+ [normalizePath(filePaths.styleCss)]:
96
+ '@import url("./css/deep.css");\nbody { background: url("images/bg.png"); @font-face { src: url("font/font.woff2"); } }',
97
+ [normalizePath(filePaths.scriptJs)]: 'console.log("mock script");',
98
+ [normalizePath(filePaths.deepCss)]: 'h1 { background: url("../images/nested-img.png"); }', // Contains nested relative path
99
+ [normalizePath(filePaths.fontFile)]: Buffer.from('mock-font-data'), // Binary data
100
+ [normalizePath(filePaths.bgImage)]: Buffer.from('mock-image-data'), // Binary data
101
+ [normalizePath(filePaths.nestedImage)]: Buffer.from('mock-nested-image-data'), // Binary data for nested image
102
+ [normalizePath(filePaths.invalidUtf8)]: invalidUtf8Buffer, // Invalid UTF-8 buffer
103
+ [normalizePath(filePaths.dataUriCss)]:
104
+ 'body { background: url(data:image/png;base64,SHORT_DATA_URI); }', // CSS containing a data URI
105
+ [normalizePath(filePaths.cycle1Css)]: '@import url("cycle2.css");', // CSS for circular import test
106
+ [normalizePath(filePaths.cycle2Css)]: '@import url("cycle1.css");', // CSS for circular import test
107
+ [normalizePath(filePaths.iterationStartCss)]: '@import url("gen_1.css");', // Start file for iteration test
108
+ [normalizePath(filePaths.complexUrlCss)]:
109
+ 'body { background: url("images/bg.png?v=123#section"); }', // CSS with query/fragment URL
110
+ [normalizePath(filePaths.unreadable)]: Buffer.from(''), // Empty buffer for the unreadable file (content doesn't matter, error is simulated)
111
+ // Note: nonexistent file doesn't need content, its absence is simulated by the mock
94
112
  };
95
113
 
96
114
  // --- Mock Directory/File Structure ---
115
+ // Set of directories that should exist in the mock structure
97
116
  const mockDirs = new Set<string>(
98
- [ mockBaseDirPath, path.dirname(filePaths.deepCss), path.dirname(filePaths.fontFile), path.dirname(filePaths.bgImage) ].map(normalizePath)
117
+ [
118
+ mockBaseDirPath,
119
+ path.dirname(filePaths.deepCss),
120
+ path.dirname(filePaths.fontFile),
121
+ path.dirname(filePaths.bgImage),
122
+ ].map(normalizePath)
99
123
  );
124
+ // Set of files that should exist in the mock structure (used by statSync mock)
100
125
  const mockFiles = new Set<string>(
101
- Object.keys(mockFileContents).concat( [filePaths.nonexistent, filePaths.unreadable].map(normalizePath) )
126
+ // Get all keys (paths) from mockFileContents
127
+ Object.keys(mockFileContents)
128
+ // Add paths for files that should exist but might cause read errors
129
+ .concat([filePaths.unreadable].map(normalizePath))
130
+ // Note: filePaths.nonexistent is *not* added here, so statSync will fail for it
102
131
  );
103
132
 
104
133
  // --- Helpers ---
134
+ // Helper to resolve URLs consistently within tests
105
135
  const resolveUrl = (relativePath: string, baseUrl: string): string => {
106
- try { return new URL(relativePath, baseUrl).href; }
107
- catch (e) { console.error(`Resolve URL error: ${relativePath} / ${baseUrl}`); return `ERROR_RESOLVING_${relativePath}`; }
136
+ try {
137
+ return new URL(relativePath, baseUrl).href;
138
+ } catch (e) {
139
+ console.error(`Resolve URL error in test helper: ${relativePath} / ${baseUrl}`);
140
+ return `ERROR_RESOLVING_${relativePath}`;
141
+ }
108
142
  };
109
143
 
110
- type ExpectedAsset = { type: Asset['type']; url: string; content?: any; };
144
+ // Type definition for expected asset structure in assertions
145
+ type ExpectedAsset = { type: Asset['type']; url: string; content?: any };
111
146
 
147
+ // Helper function to assert that the actual assets contain the expected assets
112
148
  function expectAssetsToContain(actualAssets: Asset[], expectedAssets: ExpectedAsset[]): void {
113
- expect(actualAssets).toHaveLength(expectedAssets.length);
114
- expectedAssets.forEach(expected => {
115
- const found = actualAssets.find(asset => asset.type === expected.type && asset.url === expected.url);
116
- expect(found).toBeDefined();
117
- if (found && expected.content !== undefined) {
118
- expect(found.content).toEqual(expected.content);
119
- }
120
- });
149
+ // Check if the number of found assets matches the expected number
150
+ expect(actualAssets).toHaveLength(expectedAssets.length);
151
+ // Check each expected asset
152
+ expectedAssets.forEach(expected => {
153
+ // Find the corresponding asset in the actual results by type and URL
154
+ const found = actualAssets.find(
155
+ asset => asset.type === expected.type && asset.url === expected.url
156
+ );
157
+ // Assert that the asset was found
158
+ expect(found).toBeDefined();
159
+ // If content is expected, assert that it matches (using toEqual for deep comparison if needed)
160
+ if (found && expected.content !== undefined) {
161
+ expect(found.content).toEqual(expected.content);
162
+ }
163
+ });
121
164
  }
122
165
 
123
- interface NodeJSErrnoException extends Error { code?: string; }
124
- interface MockAxiosError extends AxiosError { isAxiosError: true; }
125
-
166
+ // Interface for Node.js errors with a 'code' property
167
+ interface NodeJSErrnoException extends Error {
168
+ code?: string;
169
+ }
170
+ // Interface to represent an Axios error structure for mocking
171
+ interface MockAxiosError extends AxiosError {
172
+ isAxiosError: true;
173
+ }
126
174
 
127
175
  // ================ MOCK IMPLEMENTATIONS (Defined Globally) ================
128
176
 
129
- // Defined outside beforeEach so they can access constants like filePaths
177
+ // Mock implementation for fsPromises.readFile
130
178
  const readFileMockImplementation = async (
131
- filePathArg: PathLike | FileHandle,
132
- options?: BufferEncoding | (({ encoding?: null; flag?: OpenMode; } & AbortSignal)) | null
179
+ filePathArg: PathLike | FileHandle,
180
+ options?: BufferEncoding | ({ encoding?: null; flag?: OpenMode } & AbortSignal) | null // Match fsPromises.readFile signature
133
181
  ): Promise<Buffer | string> => {
134
- let normalizedPath: string = '';
135
- try {
136
- if (filePathArg instanceof URL) { normalizedPath = normalizePath(fileURLToPath(filePathArg)); }
137
- else if (typeof filePathArg === 'string') { normalizedPath = normalizePath(filePathArg.startsWith('file:') ? fileURLToPath(filePathArg) : filePathArg); }
138
- else if (Buffer.isBuffer(filePathArg)) { normalizedPath = normalizePath(filePathArg.toString()); }
139
- else if (typeof (filePathArg as FileHandle)?.read === 'function') { normalizedPath = normalizePath((filePathArg as any).path || String(filePathArg)); }
140
- else { throw new Error('Unsupported readFile input type'); }
141
- } catch(e) { console.error("Error normalizing path in readFile mock:", filePathArg, e); throw e; }
142
-
143
- // **** DEBUG LOG ****
144
- console.log(`[DEBUG mockReadFileFn] Requesting normalized path: "${normalizedPath}"`);
145
-
146
- if (normalizedPath === normalizePath(filePaths.nonexistent)) { const error: NodeJSErrnoException = new Error(`ENOENT`); error.code = 'ENOENT'; throw error; }
147
- if (normalizedPath === normalizePath(filePaths.unreadable)) { const error: NodeJSErrnoException = new Error(`EACCES`); error.code = 'EACCES'; throw error; }
148
-
149
- if (path.basename(normalizedPath).startsWith('gen_')) { /* ... iteration logic ... */ }
150
-
151
- const content = mockFileContents[normalizedPath];
152
- if (content !== undefined) {
153
- // **** DEBUG LOG ****
154
- console.log(`[DEBUG mockReadFileFn] FOUND content for: "${normalizedPath}".`);
155
- return Buffer.isBuffer(content) ? content : Buffer.from(content); // Return Buffer
182
+ let normalizedPath: string = '';
183
+ try {
184
+ // Normalize the input path regardless of whether it's a string, URL, Buffer, or FileHandle
185
+ if (filePathArg instanceof URL) {
186
+ normalizedPath = normalizePath(fileURLToPath(filePathArg));
187
+ } else if (typeof filePathArg === 'string') {
188
+ normalizedPath = normalizePath(
189
+ filePathArg.startsWith('file:') ? fileURLToPath(filePathArg) : filePathArg
190
+ );
191
+ } else if (Buffer.isBuffer(filePathArg)) {
192
+ normalizedPath = normalizePath(filePathArg.toString());
156
193
  }
157
-
158
- // **** DEBUG LOG ****
159
- console.log(`[DEBUG mockReadFileFn] NOT FOUND content for: "${normalizedPath}". Available keys: ${Object.keys(mockFileContents).join(', ')}`);
160
- const error: NodeJSErrnoException = new Error(`ENOENT (Mock): ${normalizedPath}`); error.code = 'ENOENT'; throw error;
194
+ // Rudimentary check for FileHandle-like object (adjust if using actual FileHandles)
195
+ else if (typeof (filePathArg as any)?.read === 'function') {
196
+ normalizedPath = normalizePath((filePathArg as any).path || String(filePathArg));
197
+ } else {
198
+ throw new Error('Unsupported readFile input type in mock');
199
+ }
200
+ } catch (e) {
201
+ console.error('Error normalizing path in readFile mock:', filePathArg, e);
202
+ throw e;
203
+ }
204
+
205
+ // console.log(`[DEBUG mockReadFileFn] Requesting normalized path: "${normalizedPath}"`); // Optional debug
206
+
207
+ // Simulate ENOENT (file not found) error
208
+ if (normalizedPath === normalizePath(filePaths.nonexistent)) {
209
+ const error: NodeJSErrnoException = new Error(
210
+ `ENOENT: no such file or directory, open '${normalizedPath}'`
211
+ );
212
+ error.code = 'ENOENT';
213
+ throw error;
214
+ }
215
+ // Simulate EACCES (permission denied) error
216
+ if (normalizedPath === normalizePath(filePaths.unreadable)) {
217
+ const error: NodeJSErrnoException = new Error(
218
+ `EACCES: permission denied, open '${normalizedPath}'`
219
+ );
220
+ error.code = 'EACCES';
221
+ throw error;
222
+ }
223
+
224
+ // Retrieve mock content based on the normalized path
225
+ const content = mockFileContents[normalizedPath];
226
+ if (content !== undefined) {
227
+ // console.log(`[DEBUG mockReadFileFn] FOUND content for: "${normalizedPath}".`); // Optional debug
228
+ // Always return a Buffer, as the actual readFile would
229
+ return Buffer.isBuffer(content) ? content : Buffer.from(content);
230
+ }
231
+
232
+ // If content not found in mock map, simulate ENOENT
233
+ // console.log(`[DEBUG mockReadFileFn] NOT FOUND content for: "${normalizedPath}". Available keys: ${Object.keys(mockFileContents).join(', ')}`); // Optional debug
234
+ const error: NodeJSErrnoException = new Error(
235
+ `ENOENT (Mock): Content not found for ${normalizedPath}`
236
+ );
237
+ error.code = 'ENOENT';
238
+ throw error;
161
239
  };
162
240
 
241
+ // Mock implementation for fs.statSync
163
242
  const statSyncMockImplementation = (
164
- pathToCheck: PathLike,
165
- options?: StatSyncOptions & { bigint?: false; throwIfNoEntry?: boolean } | { bigint: true; throwIfNoEntry?: boolean }
243
+ pathToCheck: PathLike,
244
+ options?:
245
+ | (StatSyncOptions & { bigint?: false; throwIfNoEntry?: boolean })
246
+ | { bigint: true; throwIfNoEntry?: boolean } // Match fs.statSync signature
166
247
  ): Stats | BigIntStats | undefined => {
167
- // FIX 7: Initialize normalizedPath
168
- let normalizedPath: string = '';
169
- try {
170
- if (pathToCheck instanceof URL) { normalizedPath = normalizePath(fileURLToPath(pathToCheck)); }
171
- else if (typeof pathToCheck === 'string') { normalizedPath = normalizePath(pathToCheck.startsWith('file:') ? fileURLToPath(pathToCheck) : pathToCheck); }
172
- else if (Buffer.isBuffer(pathToCheck)) { normalizedPath = normalizePath(pathToCheck.toString()); }
173
- else { throw new Error(`Unsupported statSync input type in mock: ${typeof pathToCheck}`); }
174
- } catch(e) {
175
- console.error("Error normalizing path in statSync mock:", pathToCheck, e);
176
- if (options?.throwIfNoEntry === false) { return undefined; }
177
- throw e;
178
- }
179
-
180
- // Helper to create mock Stats/BigIntStats object
181
- const createStats = (isFile: boolean): Stats | BigIntStats => {
182
- // Base properties common to both or primarily for Stats (numbers)
183
- const baseProps = {
184
- dev: 0, ino: 0, mode: 0, nlink: 1, uid: 0, gid: 0, rdev: 0,
185
- blksize: 4096, blocks: 8,
186
- atimeMs: Date.now(), mtimeMs: Date.now(), ctimeMs: Date.now(), birthtimeMs: Date.now(),
187
- atime: new Date(), mtime: new Date(), ctime: new Date(), birthtime: new Date(),
188
- isFile: () => isFile, isDirectory: () => !isFile,
189
- isBlockDevice: () => false, isCharacterDevice: () => false,
190
- isSymbolicLink: () => false, isFIFO: () => false, isSocket: () => false,
191
- size: isFile ? (mockFileContents[normalizedPath]?.length ?? 100) : 4096
192
- };
193
-
194
- if (options?.bigint) {
195
- // Construct the BigIntStats-compatible object
196
- // Include boolean methods, Date objects, and BigInt versions of numeric props
197
- return {
198
- isFile: baseProps.isFile, isDirectory: baseProps.isDirectory,
199
- isBlockDevice: baseProps.isBlockDevice, isCharacterDevice: baseProps.isCharacterDevice,
200
- isSymbolicLink: baseProps.isSymbolicLink, isFIFO: baseProps.isFIFO, isSocket: baseProps.isSocket,
201
- atime: baseProps.atime, mtime: baseProps.mtime, ctime: baseProps.ctime, birthtime: baseProps.birthtime,
202
- // Convert numbers to BigInt
203
- dev: BigInt(baseProps.dev), ino: BigInt(baseProps.ino), mode: BigInt(baseProps.mode), nlink: BigInt(baseProps.nlink), uid: BigInt(baseProps.uid), gid: BigInt(baseProps.gid), rdev: BigInt(baseProps.rdev),
204
- blksize: BigInt(baseProps.blksize), blocks: BigInt(baseProps.blocks), size: BigInt(baseProps.size),
205
- // Use Ns suffix and BigInt for time
206
- atimeNs: BigInt(Math.floor(baseProps.atimeMs * 1e6)),
207
- mtimeNs: BigInt(Math.floor(baseProps.mtimeMs * 1e6)),
208
- ctimeNs: BigInt(Math.floor(baseProps.ctimeMs * 1e6)),
209
- birthtimeNs: BigInt(Math.floor(baseProps.birthtimeMs * 1e6)),
210
- // ** OMIT number ms versions like atimeMs **
211
- } as BigIntStats; // Cast the carefully constructed object
212
- }
213
- // Return the object compatible with standard Stats
214
- return baseProps as Stats;
248
+ let normalizedPath: string = '';
249
+ try {
250
+ // Normalize the input path
251
+ if (pathToCheck instanceof URL) {
252
+ normalizedPath = normalizePath(fileURLToPath(pathToCheck));
253
+ } else if (typeof pathToCheck === 'string') {
254
+ normalizedPath = normalizePath(
255
+ pathToCheck.startsWith('file:') ? fileURLToPath(pathToCheck) : pathToCheck
256
+ );
257
+ } else if (Buffer.isBuffer(pathToCheck)) {
258
+ normalizedPath = normalizePath(pathToCheck.toString());
259
+ } else {
260
+ throw new Error(`Unsupported statSync input type in mock: ${typeof pathToCheck}`);
261
+ }
262
+ } catch (e) {
263
+ console.error('Error normalizing path in statSync mock:', pathToCheck, e);
264
+ // Handle throwIfNoEntry option if normalization fails
265
+ if (options?.throwIfNoEntry === false) {
266
+ return undefined;
267
+ }
268
+ throw e; // Re-throw normalization error if throwIfNoEntry is not false
269
+ }
270
+
271
+ // Helper to create a mock Stats or BigIntStats object
272
+ const createStats = (isFile: boolean): Stats | BigIntStats => {
273
+ // Base properties common to both Stats and BigIntStats
274
+ const baseProps = {
275
+ dev: 0,
276
+ ino: 0,
277
+ mode: isFile ? 33188 : 16877,
278
+ /* file vs dir mode */ nlink: 1,
279
+ uid: 0,
280
+ gid: 0,
281
+ rdev: 0,
282
+ blksize: 4096,
283
+ blocks: 8,
284
+ atimeMs: Date.now(),
285
+ mtimeMs: Date.now(),
286
+ ctimeMs: Date.now(),
287
+ birthtimeMs: Date.now(),
288
+ atime: new Date(),
289
+ mtime: new Date(),
290
+ ctime: new Date(),
291
+ birthtime: new Date(),
292
+ isFile: () => isFile,
293
+ isDirectory: () => !isFile,
294
+ isBlockDevice: () => false,
295
+ isCharacterDevice: () => false,
296
+ isSymbolicLink: () => false,
297
+ isFIFO: () => false,
298
+ isSocket: () => false,
299
+ // Calculate size based on mock content or default
300
+ size: isFile ? (mockFileContents[normalizedPath]?.length ?? 100) : 4096,
215
301
  };
216
302
 
217
- // Determine if path exists in mocks and call createStats
218
- if (mockDirs.has(normalizedPath)) { return createStats(false); } // Is Directory
219
- if (mockFiles.has(normalizedPath) || path.basename(normalizedPath).startsWith('gen_')) { // Is File
220
- if (normalizedPath === normalizePath(filePaths.nonexistent) && options?.throwIfNoEntry !== false) {
221
- const error: NodeJSErrnoException = new Error(`ENOENT: no such file or directory, stat '${normalizedPath}'`); error.code = 'ENOENT'; throw error;
222
- }
223
- return createStats(true);
303
+ // If bigint option is true, return a BigIntStats-compatible object
304
+ if (options?.bigint) {
305
+ return {
306
+ isFile: baseProps.isFile,
307
+ isDirectory: baseProps.isDirectory,
308
+ isBlockDevice: baseProps.isBlockDevice,
309
+ isCharacterDevice: baseProps.isCharacterDevice,
310
+ isSymbolicLink: baseProps.isSymbolicLink,
311
+ isFIFO: baseProps.isFIFO,
312
+ isSocket: baseProps.isSocket,
313
+ atime: baseProps.atime,
314
+ mtime: baseProps.mtime,
315
+ ctime: baseProps.ctime,
316
+ birthtime: baseProps.birthtime,
317
+ dev: BigInt(baseProps.dev),
318
+ ino: BigInt(baseProps.ino),
319
+ mode: BigInt(baseProps.mode),
320
+ nlink: BigInt(baseProps.nlink),
321
+ uid: BigInt(baseProps.uid),
322
+ gid: BigInt(baseProps.gid),
323
+ rdev: BigInt(baseProps.rdev),
324
+ blksize: BigInt(baseProps.blksize),
325
+ blocks: BigInt(baseProps.blocks),
326
+ size: BigInt(baseProps.size),
327
+ // Convert milliseconds to nanoseconds BigInt
328
+ atimeNs: BigInt(Math.floor(baseProps.atimeMs * 1e6)),
329
+ mtimeNs: BigInt(Math.floor(baseProps.mtimeMs * 1e6)),
330
+ ctimeNs: BigInt(Math.floor(baseProps.ctimeMs * 1e6)),
331
+ birthtimeNs: BigInt(Math.floor(baseProps.birthtimeMs * 1e6)),
332
+ } as BigIntStats; // Cast to satisfy the type
224
333
  }
225
-
226
- // Path not found
227
- if (options?.throwIfNoEntry === false) { return undefined; }
228
- const error: NodeJSErrnoException = new Error(`ENOENT (Mock): statSync path not found: ${normalizedPath}`); error.code = 'ENOENT'; throw error;
334
+ // Otherwise, return a standard Stats-compatible object
335
+ return baseProps as Stats;
336
+ };
337
+
338
+ // Check if the normalized path represents a mocked directory
339
+ if (mockDirs.has(normalizedPath)) {
340
+ return createStats(false);
341
+ } // It's a directory
342
+ // Check if the normalized path represents a mocked file (or generated file in loop test)
343
+ if (mockFiles.has(normalizedPath) || path.basename(normalizedPath).startsWith('gen_')) {
344
+ return createStats(true);
345
+ } // It's a file
346
+
347
+ // Path not found in mocks
348
+ if (options?.throwIfNoEntry === false) {
349
+ return undefined;
350
+ } // Return undefined if not throwing
351
+ // Throw ENOENT error if path not found and not suppressed
352
+ const error: NodeJSErrnoException = new Error(
353
+ `ENOENT (Mock): statSync path not found: ${normalizedPath}`
354
+ );
355
+ error.code = 'ENOENT';
356
+ throw error;
229
357
  };
230
358
 
231
-
359
+ // Mock implementation for axios.get
232
360
  const axiosGetMockImplementation = async (
233
- url: string,
234
- config?: AxiosRequestConfig
361
+ url: string,
362
+ config?: AxiosRequestConfig // Match axios.get signature
235
363
  ): Promise<AxiosResponse<Buffer>> => {
236
- // **** DEBUG LOG ****
237
- console.log(`[DEBUG mockAxiosGet] Requesting URL: "${url}"`);
238
-
239
- const { AxiosHeaders } = axiosNs;
240
- let dataBuffer: Buffer; let contentType = 'text/plain'; let status = 200; let statusText = 'OK';
241
-
242
- const getRequestHeaders = (reqConfig?: AxiosRequestConfig): AxiosRequestHeaders => {
243
- const headers = new AxiosHeaders();
244
- if (reqConfig?.headers) { for (const key in reqConfig.headers) { /* ... copy headers ... */ } }
245
- return headers;
364
+ // Return Buffer data
365
+ // console.log(`[DEBUG mockAxiosGet] Requesting URL: "${url}"`); // Optional debug
366
+
367
+ const { AxiosHeaders } = axiosNs; // Use the AxiosHeaders class from the namespace
368
+ let dataBuffer: Buffer; // Content will be a Buffer
369
+ let contentType = 'text/plain'; // Default content type
370
+ let status = 200; // Default success status
371
+ let statusText = 'OK'; // Default success status text
372
+
373
+ // Helper to create mock Axios request headers
374
+ const getRequestHeaders = (reqConfig?: AxiosRequestConfig): AxiosRequestHeaders => {
375
+ const headers = new AxiosHeaders(); // Instantiate AxiosHeaders
376
+ if (reqConfig?.headers) {
377
+ // Copy headers from config if provided
378
+ for (const key in reqConfig.headers) {
379
+ if (Object.prototype.hasOwnProperty.call(reqConfig.headers, key)) {
380
+ // Use AxiosHeaders methods for setting headers
381
+ headers.set(key, reqConfig.headers[key] as AxiosHeaderValue);
382
+ }
383
+ }
384
+ }
385
+ return headers;
386
+ };
387
+ // Helper to create mock InternalAxiosRequestConfig
388
+ const createInternalConfig = (reqConfig?: AxiosRequestConfig): InternalAxiosRequestConfig => {
389
+ const requestHeaders = getRequestHeaders(reqConfig);
390
+ // Construct the config object, ensuring headers is an AxiosHeaders instance
391
+ // Need to satisfy the complex InternalAxiosRequestConfig type
392
+ const internalConfig: InternalAxiosRequestConfig = {
393
+ url: url,
394
+ method: 'get',
395
+ ...(reqConfig || {}), // Spread original config
396
+ headers: requestHeaders, // Overwrite headers with AxiosHeaders instance
397
+ // Add other potentially required fields with default values if needed
398
+ // baseURL: reqConfig?.baseURL || '',
399
+ // params: reqConfig?.params || {},
400
+ // data: reqConfig?.data,
401
+ // timeout: reqConfig?.timeout || 0,
402
+ // responseType: reqConfig?.responseType || 'json',
403
+ // ... add others based on Axios version and usage ...
246
404
  };
247
- const createInternalConfig = (reqConfig?: AxiosRequestConfig): InternalAxiosRequestConfig => {
248
- const requestHeaders = getRequestHeaders(reqConfig);
249
- return { url: url, method: 'get', ...(reqConfig || {}), headers: requestHeaders, };
405
+ return internalConfig;
406
+ };
407
+
408
+ // Simulate errors based on URL content
409
+ if (url.includes('error')) {
410
+ status = 404;
411
+ statusText = 'Not Found';
412
+ }
413
+ // Simulate timeout using status code 408 and setting error code later
414
+ if (url.includes('timeout')) {
415
+ status = 408;
416
+ statusText = 'Request Timeout';
417
+ }
418
+
419
+ // If simulating an error status
420
+ if (status !== 200) {
421
+ const errorConfig = createInternalConfig(config);
422
+ // *** Create a plain object that mimics AxiosError ***
423
+ const error: any = {
424
+ // Use 'any' for flexibility in mock creation
425
+ // Base Error properties (optional but good practice)
426
+ name: 'Error', // Keep it generic or 'AxiosError'
427
+ message:
428
+ status === 404
429
+ ? `Request failed with status code 404`
430
+ : `Timeout of ${config?.timeout || 'unknown'}ms exceeded`,
431
+ stack: new Error().stack, // Capture a stack trace
432
+
433
+ // AxiosError specific properties
434
+ isAxiosError: true, // Explicitly set the flag Axios checks
435
+ code: status === 408 ? 'ECONNABORTED' : undefined, // Set code correctly
436
+ config: errorConfig, // Attach the config
437
+ request: {}, // Mock request object if needed
438
+ response: {
439
+ // Attach the mock response
440
+ status,
441
+ statusText,
442
+ data: Buffer.from(statusText), // Mock data
443
+ headers: new AxiosHeaders(),
444
+ config: errorConfig,
445
+ },
446
+ // Add a basic toJSON if needed by any code consuming the error
447
+ toJSON: function () {
448
+ return { message: this.message, code: this.code };
449
+ },
250
450
  };
251
-
252
- // Simulate errors
253
- if (url.includes('error')) { status = 404; statusText = 'Not Found'; }
254
- if (url.includes('timeout')) { status = 408; statusText = 'Request Timeout'; }
255
-
256
- if (status !== 200) {
257
- const error = new Error(status === 404 ? `404 Not Found` : `Timeout`) as MockAxiosError;
258
- error.isAxiosError = true; error.code = status === 408 ? 'ECONNABORTED' : undefined;
259
- const errorConfig = createInternalConfig(config); error.config = errorConfig;
260
- error.response = { status, statusText, data: Buffer.from(statusText), headers: new AxiosHeaders(), config: errorConfig };
261
- // **** DEBUG LOG ****
262
- console.log(`[DEBUG mockAxiosGet] Simulating ERROR for URL: "${url}", Status: ${status}`);
263
- throw error;
264
- }
265
-
266
- // Simulate success content
267
- if (url.includes('/styles/main.css')) { dataBuffer = Buffer.from('body { background: url("/images/remote-bg.jpg"); }'); contentType = 'text/css'; } // Match specific URL if needed
268
- else if (url.includes('/js/script.js')) { dataBuffer = Buffer.from('console.log("remote script");'); contentType = 'application/javascript'; }
269
- else if (url.includes('/js/lib.js')) { dataBuffer = Buffer.from('console.log("remote script");'); contentType = 'application/javascript'; } // Handle protocol-relative case
270
- else if (url.includes('/images/remote-bg.jpg')) { dataBuffer = Buffer.from('mock-remote-image-data'); contentType = 'image/jpeg'; } // Match specific nested remote URL
271
- else { dataBuffer = Buffer.from(`Mock content for ${url}`); } // Default fallback
272
-
273
- const responseConfig = createInternalConfig(config);
274
- const responseHeaders = new AxiosHeaders({ 'content-type': contentType });
275
-
276
- // **** DEBUG LOG ****
277
- console.log(`[DEBUG mockAxiosGet] Simulating SUCCESS for URL: "${url}", ContentType: ${contentType}`);
278
- return { data: dataBuffer, status: 200, statusText: 'OK', headers: responseHeaders, config: responseConfig, request: {} };
451
+ // console.log(`[DEBUG mockAxiosGet] Simulating ERROR object:`, error); // Optional debug
452
+ throw error; // Throw the simulated error object
453
+ }
454
+
455
+ // Simulate successful responses with appropriate content and type based on URL
456
+ if (url.includes('/styles/main.css')) {
457
+ dataBuffer = Buffer.from('body { background: url("/images/remote-bg.jpg"); }');
458
+ contentType = 'text/css';
459
+ } else if (url.includes('/js/script.js')) {
460
+ dataBuffer = Buffer.from('console.log("remote script");');
461
+ contentType = 'application/javascript';
462
+ } else if (url.includes('/js/lib.js')) {
463
+ dataBuffer = Buffer.from('console.log("remote lib");');
464
+ contentType = 'application/javascript';
465
+ } // Handle protocol-relative case
466
+ else if (url.includes('/images/remote-bg.jpg')) {
467
+ dataBuffer = Buffer.from('mock-remote-image-data');
468
+ contentType = 'image/jpeg';
469
+ } else {
470
+ dataBuffer = Buffer.from(`Mock content for ${url}`);
471
+ } // Default fallback content
472
+
473
+ // Create mock response configuration and headers
474
+ const responseConfig = createInternalConfig(config);
475
+ const responseHeaders = new AxiosHeaders({ 'content-type': contentType }); // Use AxiosHeaders
476
+
477
+ // console.log(`[DEBUG mockAxiosGet] Simulating SUCCESS for URL: "${url}", ContentType: ${contentType}`); // Optional debug
478
+ // Return the successful AxiosResponse object
479
+ return {
480
+ data: dataBuffer, // Data as Buffer
481
+ status: 200,
482
+ statusText: 'OK',
483
+ headers: responseHeaders, // AxiosHeaders object
484
+ config: responseConfig, // InternalAxiosRequestConfig object
485
+ request: {}, // Mock request object (can be empty or more detailed if needed)
486
+ };
279
487
  };
280
488
 
281
-
282
489
  // ================ TESTS ================
283
490
 
284
491
  describe('extractAssets', () => {
285
- let mockLogger: Logger;
286
- let mockLoggerWarnSpy: jest.SpiedFunction<typeof mockLogger.warn>;
287
- let mockLoggerErrorSpy: jest.SpiedFunction<typeof mockLogger.error>;
288
-
289
- beforeEach(() => {
290
- // --- Retrieve Mocked Functions ---
291
- mockReadFile = (jest.requireMock('fs/promises') as typeof import('fs/promises')).readFile as MockedReadFileFn;
292
- mockStatSync = (jest.requireMock('fs') as typeof fs).statSync as MockedStatSyncFn;
293
- mockAxiosGet = (jest.requireMock('axios') as typeof axiosNs).default.get as MockedAxiosGetFn;
294
-
295
- // --- Setup Logger ---
296
- mockLogger = new Logger(LogLevel.WARN);
297
- mockLoggerWarnSpy = jest.spyOn(mockLogger, 'warn');
298
- mockLoggerErrorSpy = jest.spyOn(mockLogger, 'error');
299
-
300
- // --- Assign Mock Implementations ---
301
- // Use 'as any' as robust workaround for complex TS signature mismatches if needed
302
- mockReadFile.mockImplementation(readFileMockImplementation as any);
303
- mockStatSync.mockImplementation(statSyncMockImplementation as any);
304
- mockAxiosGet.mockImplementation(axiosGetMockImplementation as any);
305
- });
306
-
307
- afterEach(() => {
308
- jest.clearAllMocks();
309
- jest.restoreAllMocks();
310
- });
311
-
312
- // ================ Test Cases ================
313
-
314
- it('should extract and embed assets from local HTML file', async () => {
315
- const parsed: ParsedHTML = {
316
- htmlContent: '<link href="style.css"><script src="script.js">',
317
- assets: [ { type: 'css', url: 'style.css' }, { type: 'js', url: 'script.js' } ]
318
- };
319
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
320
- const expectedAssets: ExpectedAsset[] = [
321
- { type: 'css', url: resolveUrl('style.css', mockBaseFileUrl), content: expect.stringContaining('@import') },
322
- { type: 'js', url: resolveUrl('script.js', mockBaseFileUrl), content: 'console.log("mock script");' },
323
- { type: 'css', url: resolveUrl('css/deep.css', mockBaseFileUrl), content: expect.stringContaining('nested-img.png') },
324
- { type: 'image', url: resolveUrl('images/bg.png', mockBaseFileUrl), content: expect.stringMatching(/^data:image\/png;base64,/) },
325
- { type: 'font', url: resolveUrl('font/font.woff2', mockBaseFileUrl), content: expect.stringMatching(/^data:font\/woff2;base64,/) },
326
- { type: 'image', url: resolveUrl('images/nested-img.png', mockBaseFileUrl), content: expect.stringMatching(/^data:image\/png;base64,/) }
327
- ];
328
- expectAssetsToContain(result.assets, expectedAssets);
329
- expect(mockReadFile).toHaveBeenCalledTimes(6);
330
- });
331
-
332
- it('should discover assets without embedding when embedAssets is false', async () => {
333
- const parsed: ParsedHTML = { htmlContent: '<link href="style.css">', assets: [{ type: 'css', url: 'style.css' }] };
334
- const result = await extractAssets(parsed, false, mockBaseFileUrl, mockLogger);
335
- const expectedAssets: ExpectedAsset[] = [
336
- { type: 'css', url: resolveUrl('style.css', mockBaseFileUrl), content: undefined },
337
- { type: 'css', url: resolveUrl('css/deep.css', mockBaseFileUrl), content: undefined },
338
- { type: 'image', url: resolveUrl('images/bg.png', mockBaseFileUrl), content: undefined },
339
- { type: 'font', url: resolveUrl('font/font.woff2', mockBaseFileUrl), content: undefined },
340
- { type: 'image', url: resolveUrl('images/nested-img.png', mockBaseFileUrl), content: undefined }
341
- ];
342
- expectAssetsToContain(result.assets, expectedAssets);
343
- expect(mockReadFile).toHaveBeenCalledTimes(2); // style.css, deep.css
344
- });
345
-
346
- it('should handle remote assets and their nested dependencies', async () => {
347
- const remoteUrl = 'https://example.com/page.html';
348
- const parsed: ParsedHTML = {
349
- htmlContent: '<link href="styles/main.css"><script src="/js/script.js">',
350
- assets: [ { type: 'css', url: 'styles/main.css' }, { type: 'js', url: '/js/script.js' } ]
351
- };
352
- const result = await extractAssets(parsed, true, remoteUrl, mockLogger);
353
- const expectedAssets: ExpectedAsset[] = [
354
- { type: 'css', url: resolveUrl('styles/main.css', remoteUrl), content: expect.stringContaining('background') },
355
- { type: 'js', url: resolveUrl('/js/script.js', remoteUrl), content: 'console.log("remote script");' },
356
- { type: 'image', url: resolveUrl('/images/remote-bg.jpg', remoteUrl), content: expect.stringMatching(/^data:image\/jpeg;base64,/) }
357
- ];
358
- expectAssetsToContain(result.assets, expectedAssets);
359
- expect(mockAxiosGet).toHaveBeenCalledTimes(3);
360
- expect(mockReadFile).not.toHaveBeenCalled();
361
- });
362
-
363
- it('should handle ENOENT errors when reading local files', async () => {
364
- const parsed: ParsedHTML = { htmlContent: '<link href="nonexistent.file">', assets: [{ type: 'css', url: 'nonexistent.file' }] };
365
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
366
- expect(result.assets).toHaveLength(1);
367
- expect(result.assets[0].content).toBeUndefined();
368
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`File not found (ENOENT) for asset: ${normalizePath(filePaths.nonexistent)}`));
369
- });
370
-
371
- it('should handle permission denied errors when reading local files', async () => {
372
- const parsed: ParsedHTML = { htmlContent: '<link href="unreadable.file">', assets: [{ type: 'css', url: 'unreadable.file' }] };
373
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
374
- expect(result.assets).toHaveLength(1);
375
- // Adjusted expectation based on actual logging behavior observed previously
376
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`Failed to read local asset ${normalizePath(filePaths.unreadable)}: EACCES`));
377
- expect(mockReadFile).toHaveBeenCalledWith(normalizePath(filePaths.unreadable));
378
- });
379
-
380
- it('should handle HTTP errors when fetching remote assets', async () => {
381
- const remoteUrl = 'https://example.com/page.html';
382
- const errorCssUrl = resolveUrl('styles/error.css', remoteUrl);
383
- const parsed: ParsedHTML = { htmlContent: `<link href="${errorCssUrl}">`, assets: [{ type: 'css', url: errorCssUrl }] };
384
- const result = await extractAssets(parsed, true, remoteUrl, mockLogger);
385
- expect(result.assets).toHaveLength(1);
386
- expect(result.assets[0].content).toBeUndefined();
387
- // Adjusted assertion to match actual log format
388
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`Failed to fetch remote asset ${errorCssUrl}`) && expect.stringContaining('Status 404') && expect.stringContaining('Not Found'));
389
- });
390
-
391
- it('should handle timeout errors when fetching remote assets', async () => {
392
- const remoteUrl = 'https://example.com/page.html';
393
- const timeoutCssUrl = resolveUrl('styles/timeout.css', remoteUrl);
394
- const parsed: ParsedHTML = { htmlContent: `<link href="${timeoutCssUrl}">`, assets: [{ type: 'css', url: timeoutCssUrl }] };
395
- const result = await extractAssets(parsed, true, remoteUrl, mockLogger);
396
- expect(result.assets).toHaveLength(1);
397
- expect(result.assets[0].content).toBeUndefined();
398
- // Adjusted assertion to match actual log format
399
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`Failed to fetch remote asset ${timeoutCssUrl}`) && expect.stringContaining('Timeout') && expect.stringContaining('ECONNABORTED'));
400
- });
401
-
402
- it('should handle invalid UTF-8 in CSS files by falling back to base64', async () => {
403
- const parsed: ParsedHTML = { htmlContent: '<link href="invalid-utf8.css">', assets: [{ type: 'css', url: 'invalid-utf8.css' }] };
404
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
405
- const expectedUrl = resolveUrl('invalid-utf8.css', mockBaseFileUrl);
406
- expect(result.assets).toHaveLength(1);
407
- expect(result.assets[0].content).toEqual(`data:text/css;base64,${invalidUtf8Buffer.toString('base64')}`);
408
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`Could not decode css ${expectedUrl} as valid UTF-8 text.`));
409
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`Falling back to base64 data URI.`));
410
- });
411
-
412
- it('should avoid circular references in CSS imports', async () => {
413
- const parsed: ParsedHTML = { htmlContent: '<link href="cycle1.css">', assets: [{ type: 'css', url: 'cycle1.css' }] };
414
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
415
- const expectedAssets: ExpectedAsset[] = [
416
- { type: 'css', url: resolveUrl('cycle1.css', mockBaseFileUrl), content: expect.stringContaining('@import url("cycle2.css")') },
417
- { type: 'css', url: resolveUrl('cycle2.css', mockBaseFileUrl), content: expect.stringContaining('@import url("cycle1.css")') } // Should find cycle2
418
- ];
419
- expectAssetsToContain(result.assets, expectedAssets);
420
- expect(mockLoggerErrorSpy).not.toHaveBeenCalledWith(expect.stringContaining('limit'));
421
- expect(mockReadFile).toHaveBeenCalledWith(normalizePath(filePaths.cycle1Css));
422
- expect(mockReadFile).toHaveBeenCalledWith(normalizePath(filePaths.cycle2Css)); // Check cycle2 was read
423
- });
424
-
425
- it('should enforce maximum iteration limit to prevent infinite loops', async () => {
426
- const iterationTestReadFileMock = async (filePathArg: PathLike | FileHandle): Promise<Buffer | string> => { /* ... iteration logic ... */ return Buffer.from(''); }; // Needs full logic
427
- mockReadFile.mockImplementation(iterationTestReadFileMock as any);
428
- const parsed: ParsedHTML = { htmlContent: '<link href="start.css">', assets: [{ type: 'css', url: 'start.css' }] };
429
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
430
- expect(result.assets.length).toBeGreaterThan(0);
431
- expect(mockLoggerErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Asset extraction loop limit hit'));
432
- });
433
-
434
- it('should handle data URIs in CSS without trying to fetch them', async () => {
435
- const parsed: ParsedHTML = { htmlContent: '<link href="data-uri.css">', assets: [{ type: 'css', url: 'data-uri.css' }] };
436
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
437
- expect(result.assets).toHaveLength(1);
438
- expect(mockReadFile).toHaveBeenCalledWith(normalizePath(filePaths.dataUriCss));
439
- expect(mockAxiosGet).not.toHaveBeenCalled();
440
- });
441
-
442
- it('should handle CSS URLs with query parameters and fragments correctly', async () => {
443
- const parsed: ParsedHTML = { htmlContent: '<link href="complex-url.css">', assets: [{ type: 'css', url: 'complex-url.css' }] };
444
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
445
- const expectedCssUrl = resolveUrl('complex-url.css', mockBaseFileUrl);
446
- const expectedBgUrlWithQuery = resolveUrl('images/bg.png?v=123#section', mockBaseFileUrl);
447
- const expectedBgFetchPath = normalizePath(filePaths.bgImage);
448
- const expectedAssets: ExpectedAsset[] = [
449
- { type: 'css', url: expectedCssUrl, content: expect.stringContaining('images/bg.png?v=123#section') },
450
- { type: 'image', url: expectedBgUrlWithQuery, content: expect.stringMatching(/^data:image\/png;base64,/) } // Assumes bgImage mock returns PNG data
451
- ];
452
- expectAssetsToContain(result.assets, expectedAssets);
453
- expect(mockReadFile).toHaveBeenCalledWith(normalizePath(filePaths.complexUrlCss));
454
- expect(mockReadFile).toHaveBeenCalledWith(expectedBgFetchPath); // Check fetch path
455
- });
456
-
457
- it('should properly resolve protocol-relative URLs using the base URL protocol', async () => {
458
- const htmlBase = 'https://mysite.com/page.html';
459
- const parsed: ParsedHTML = { htmlContent: '<script src="//example.com/js/lib.js"></script>', assets: [{ type: 'js', url: '//example.com/js/lib.js' }] };
460
- const result = await extractAssets(parsed, true, htmlBase, mockLogger);
461
- const expectedUrl = 'https://example.com/js/lib.js';
462
- const expectedAssets: ExpectedAsset[] = [
463
- { type: 'js', url: expectedUrl, content: 'console.log("remote script");' } // Content from Axios mock
464
- ];
465
- expectAssetsToContain(result.assets, expectedAssets);
466
- expect(mockAxiosGet).toHaveBeenCalledWith(expectedUrl, expect.anything());
467
- });
468
-
469
- }); // End describe block
492
+ // Declare variables for logger and its spies
493
+ let mockLogger: Logger;
494
+ let mockLoggerWarnSpy: jest.SpiedFunction<typeof mockLogger.warn>;
495
+ let mockLoggerErrorSpy: jest.SpiedFunction<typeof mockLogger.error>;
496
+
497
+ beforeEach(() => {
498
+ // --- Retrieve Mocked Functions from Modules ---
499
+ // Use jest.requireMock to get the mocked versions of the modules
500
+ // Cast to the specific mocked function type for type safety
501
+ const fsPromisesMock = jest.requireMock('fs/promises') as typeof fsPromises;
502
+ mockReadFile = fsPromisesMock.readFile as MockedReadFileFn;
503
+ const fsMock = jest.requireMock('fs') as typeof fs;
504
+ mockStatSync = fsMock.statSync as MockedStatSyncFn;
505
+ const axiosMock = jest.requireMock('axios') as typeof axiosNs;
506
+ mockAxiosGet = axiosMock.default.get as MockedAxiosGetFn;
507
+
508
+ // --- Setup Logger and Spies ---
509
+ // Create a new Logger instance for each test (set level low for debugging if needed)
510
+ mockLogger = new Logger(LogLevel.WARN); // Or LogLevel.DEBUG
511
+ // Spy on the warn and error methods of this logger instance
512
+ mockLoggerWarnSpy = jest.spyOn(mockLogger, 'warn');
513
+ mockLoggerErrorSpy = jest.spyOn(mockLogger, 'error');
514
+
515
+ // --- Assign Mock Implementations ---
516
+ // Set the implementation for the mocked functions for this test run
517
+ // Use 'as any' to bypass strict type checking
518
+ mockReadFile.mockImplementation(readFileMockImplementation as any);
519
+ mockStatSync.mockImplementation(statSyncMockImplementation as any);
520
+ mockAxiosGet.mockImplementation(axiosGetMockImplementation as any);
521
+ });
522
+
523
+ afterEach(() => {
524
+ // Clear mock calls and reset implementations between tests
525
+ jest.clearAllMocks();
526
+ // Restore original implementations spied on with jest.spyOn (like the logger spies)
527
+ jest.restoreAllMocks();
528
+ });
529
+
530
+ // ================ Test Cases ================
531
+
532
+ it('should extract and embed assets from local HTML file', async () => {
533
+ // Define the initial parsed HTML structure
534
+ const parsed: ParsedHTML = {
535
+ htmlContent: '<link href="style.css"><script src="script.js">',
536
+ assets: [
537
+ { type: 'css', url: 'style.css' },
538
+ { type: 'js', url: 'script.js' },
539
+ ], // Assets found directly in HTML
540
+ };
541
+ // Call the function under test
542
+ const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger); // embedAssets = true
543
+ // Define the expected final assets, including nested ones
544
+ const expectedAssets: ExpectedAsset[] = [
545
+ // Top-level CSS (content should be text)
546
+ {
547
+ type: 'css',
548
+ url: resolveUrl('style.css', mockBaseFileUrl),
549
+ content: expect.stringContaining('@import url("./css/deep.css");'),
550
+ },
551
+ // Top-level JS (content should be text)
552
+ {
553
+ type: 'js',
554
+ url: resolveUrl('script.js', mockBaseFileUrl),
555
+ content: 'console.log("mock script");',
556
+ },
557
+ // Nested CSS from style.css (content should be text)
558
+ {
559
+ type: 'css',
560
+ url: resolveUrl('css/deep.css', mockBaseFileUrl),
561
+ content: expect.stringContaining('nested-img.png'),
562
+ },
563
+ // Image referenced in style.css (content should be data URI)
564
+ {
565
+ type: 'image',
566
+ url: resolveUrl('images/bg.png', mockBaseFileUrl),
567
+ content: expect.stringMatching(/^data:image\/png;base64,/),
568
+ },
569
+ // Font referenced in style.css (content should be data URI)
570
+ {
571
+ type: 'font',
572
+ url: resolveUrl('font/font.woff2', mockBaseFileUrl),
573
+ content: expect.stringMatching(/^data:font\/woff2;base64,/),
574
+ },
575
+ // Image referenced in deep.css (content should be data URI)
576
+ {
577
+ type: 'image',
578
+ url: resolveUrl('images/nested-img.png', mockBaseFileUrl),
579
+ content: expect.stringMatching(/^data:image\/png;base64,/),
580
+ },
581
+ ];
582
+ // Assert the final assets match the expected structure and content
583
+ expectAssetsToContain(result.assets, expectedAssets);
584
+ // Check how many times readFile was called (should be once for each unique file)
585
+ expect(mockReadFile).toHaveBeenCalledTimes(6); // style.css, script.js, deep.css, bg.png, font.woff2, nested-img.png
586
+ });
587
+
588
+ it('should discover assets without embedding when embedAssets is false', async () => {
589
+ // Initial HTML with one CSS link
590
+ const parsed: ParsedHTML = {
591
+ htmlContent: '<link href="style.css">',
592
+ assets: [{ type: 'css', url: 'style.css' }],
593
+ };
594
+ // Call with embedAssets = false
595
+ const result = await extractAssets(parsed, false, mockBaseFileUrl, mockLogger);
596
+ // Expected assets should include all discovered URLs, but content should be undefined
597
+ const expectedAssets: ExpectedAsset[] = [
598
+ { type: 'css', url: resolveUrl('style.css', mockBaseFileUrl), content: undefined },
599
+ { type: 'css', url: resolveUrl('css/deep.css', mockBaseFileUrl), content: undefined },
600
+ { type: 'image', url: resolveUrl('images/bg.png', mockBaseFileUrl), content: undefined },
601
+ { type: 'font', url: resolveUrl('font/font.woff2', mockBaseFileUrl), content: undefined },
602
+ {
603
+ type: 'image',
604
+ url: resolveUrl('images/nested-img.png', mockBaseFileUrl),
605
+ content: undefined,
606
+ },
607
+ ];
608
+ // Assert the structure matches
609
+ expectAssetsToContain(result.assets, expectedAssets);
610
+ // readFile should only be called for CSS files (to parse them), not for images/fonts when not embedding
611
+ expect(mockReadFile).toHaveBeenCalledTimes(2); // Only style.css, deep.css
612
+ });
613
+
614
+ it('should handle remote assets and their nested dependencies', async () => {
615
+ // Define a remote base URL
616
+ const remoteUrl = 'https://example.com/page.html';
617
+ // Initial HTML structure with remote assets
618
+ const parsed: ParsedHTML = {
619
+ htmlContent: '<link href="styles/main.css"><script src="/js/script.js">', // Relative and absolute paths
620
+ assets: [
621
+ { type: 'css', url: 'styles/main.css' },
622
+ { type: 'js', url: '/js/script.js' },
623
+ ],
624
+ };
625
+ // Call with embedAssets = true
626
+ const result = await extractAssets(parsed, true, remoteUrl, mockLogger);
627
+ // Expected assets, including nested remote image from the mocked CSS content
628
+ const expectedAssets: ExpectedAsset[] = [
629
+ {
630
+ type: 'css',
631
+ url: resolveUrl('styles/main.css', remoteUrl),
632
+ content: expect.stringContaining('background'),
633
+ }, // Text content
634
+ {
635
+ type: 'js',
636
+ url: resolveUrl('/js/script.js', remoteUrl),
637
+ content: 'console.log("remote script");',
638
+ }, // Text content
639
+ {
640
+ type: 'image',
641
+ url: resolveUrl('/images/remote-bg.jpg', remoteUrl),
642
+ content: expect.stringMatching(/^data:image\/jpeg;base64,/),
643
+ }, // Data URI
644
+ ];
645
+ // Assert the results
646
+ expectAssetsToContain(result.assets, expectedAssets);
647
+ // Check that axios.get was called for each remote asset
648
+ expect(mockAxiosGet).toHaveBeenCalledTimes(3); // main.css, script.js, remote-bg.jpg
649
+ // Ensure local file reading was not attempted
650
+ expect(mockReadFile).not.toHaveBeenCalled();
651
+ });
652
+
653
+ it('should handle ENOENT errors when reading local files', async () => {
654
+ // HTML references a file that doesn't exist in the mock setup
655
+ const parsed: ParsedHTML = {
656
+ htmlContent: '<link href="nonexistent.file">',
657
+ assets: [{ type: 'css', url: 'nonexistent.file' }],
658
+ };
659
+ // Call extractor
660
+ const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
661
+ // Expect the asset list to contain the entry, but with undefined content
662
+ expect(result.assets).toHaveLength(1);
663
+ expect(result.assets[0].content).toBeUndefined();
664
+ // Expect a warning log indicating the file was not found
665
+ expect(mockLoggerWarnSpy).toHaveBeenCalledWith(
666
+ expect.stringContaining(
667
+ `File not found (ENOENT) for asset: ${normalizePath(filePaths.nonexistent)}`
668
+ )
669
+ );
670
+ });
671
+
672
+ it('should handle permission denied errors when reading local files', async () => {
673
+ // HTML references a file set up to trigger EACCES in the mock readFile
674
+ const parsed: ParsedHTML = {
675
+ htmlContent: '<link href="unreadable.file">',
676
+ assets: [{ type: 'css', url: 'unreadable.file' }],
677
+ };
678
+ // Call extractor
679
+ const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
680
+ // Expect the asset with undefined content
681
+ expect(result.assets).toHaveLength(1);
682
+ expect(result.assets[0].content).toBeUndefined();
683
+ // *** CORRECTED EXPECTATION ***: Check for the specific EACCES warning message logged by the updated extractor.ts
684
+ expect(mockLoggerWarnSpy).toHaveBeenCalledWith(
685
+ expect.stringContaining(
686
+ `Permission denied (EACCES) reading asset: ${normalizePath(filePaths.unreadable)}`
687
+ )
688
+ );
689
+ expect(mockReadFile).toHaveBeenCalledWith(normalizePath(filePaths.unreadable));
690
+ });
691
+
692
+ it('should handle HTTP errors when fetching remote assets', async () => {
693
+ const remoteUrl = 'https://example.com/page.html';
694
+ // Resolve the URL that will trigger a 404 in the axios mock
695
+ const errorCssUrl = resolveUrl('styles/error.css', remoteUrl);
696
+ // HTML referencing the error URL
697
+ const parsed: ParsedHTML = {
698
+ htmlContent: `<link href="${errorCssUrl}">`,
699
+ assets: [{ type: 'css', url: errorCssUrl }],
700
+ };
701
+ // Call extractor
702
+ const result = await extractAssets(parsed, true, remoteUrl, mockLogger);
703
+ // Expect the asset with undefined content
704
+ expect(result.assets).toHaveLength(1);
705
+ expect(result.assets[0].content).toBeUndefined();
706
+ expect(mockLoggerWarnSpy).toHaveBeenCalledWith(
707
+ expect.stringContaining(
708
+ `Failed to fetch remote asset ${errorCssUrl}: Request failed with status code 404 (Code: N/A)`
709
+ ) // Changed undefined to N/A
710
+ );
711
+ });
712
+
713
+ it('should handle timeout errors when fetching remote assets', async () => {
714
+ const remoteUrl = 'https://example.com/page.html';
715
+ // Resolve the URL that triggers a timeout (ECONNABORTED) in the axios mock
716
+ const timeoutCssUrl = resolveUrl('styles/timeout.css', remoteUrl);
717
+ // HTML referencing the timeout URL
718
+ const parsed: ParsedHTML = {
719
+ htmlContent: `<link href="${timeoutCssUrl}">`,
720
+ assets: [{ type: 'css', url: timeoutCssUrl }],
721
+ };
722
+ // Call extractor
723
+ const result = await extractAssets(parsed, true, remoteUrl, mockLogger);
724
+ // Expect the asset with undefined content
725
+ expect(result.assets).toHaveLength(1);
726
+ expect(result.assets[0].content).toBeUndefined();
727
+ // *** CORRECTED EXPECTATION ***: Check for the specific warning logged by the updated fetchAsset, including the code
728
+ expect(mockLoggerWarnSpy).toHaveBeenCalledWith(
729
+ // Match the simplified log format exactly
730
+ expect.stringContaining(
731
+ `Failed to fetch remote asset ${timeoutCssUrl}: Timeout of 10000ms exceeded (Code: ECONNABORTED)`
732
+ )
733
+ );
734
+ });
735
+
736
+ it('should handle invalid UTF-8 in CSS files by falling back to base64', async () => {
737
+ // HTML referencing the CSS file with invalid UTF-8 content
738
+ const parsed: ParsedHTML = {
739
+ htmlContent: '<link href="invalid-utf8.css">',
740
+ assets: [{ type: 'css', url: 'invalid-utf8.css' }],
741
+ };
742
+ // Call extractor
743
+ const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
744
+ // Resolve the expected URL
745
+ const expectedUrl = resolveUrl('invalid-utf8.css', mockBaseFileUrl);
746
+ // Expect one asset in the result
747
+ expect(result.assets).toHaveLength(1);
748
+ // Expect the content to be a data URI containing the base64 representation of the original buffer
749
+ expect(result.assets[0].content).toEqual(
750
+ `data:text/css;base64,${invalidUtf8Buffer.toString('base64')}`
751
+ );
752
+ expect(mockLoggerWarnSpy).toHaveBeenCalledWith(
753
+ expect.stringContaining(
754
+ `Could not decode css asset ${expectedUrl} as valid UTF-8 text. Falling back to base64 data URI.`
755
+ )
756
+ );
757
+ // Ensure only one warning related to this was logged
758
+ expect(mockLoggerWarnSpy).toHaveBeenCalledTimes(1);
759
+ });
760
+
761
+ it('should avoid circular references in CSS imports', async () => {
762
+ // HTML referencing the start of a CSS import cycle
763
+ const parsed: ParsedHTML = {
764
+ htmlContent: '<link href="cycle1.css">',
765
+ assets: [{ type: 'css', url: 'cycle1.css' }],
766
+ };
767
+ // Call extractor
768
+ const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
769
+ // Expected assets: both cycle1.css and cycle2.css should be found, but the loop should terminate
770
+ const expectedAssets: ExpectedAsset[] = [
771
+ {
772
+ type: 'css',
773
+ url: resolveUrl('cycle1.css', mockBaseFileUrl),
774
+ content: expect.stringContaining('@import url("cycle2.css");'),
775
+ },
776
+ {
777
+ type: 'css',
778
+ url: resolveUrl('cycle2.css', mockBaseFileUrl),
779
+ content: expect.stringContaining('@import url("cycle1.css");'),
780
+ }, // Should find cycle2
781
+ ];
782
+ // Assert the expected assets were found
783
+ expectAssetsToContain(result.assets, expectedAssets);
784
+ // Ensure no loop limit error was logged
785
+ expect(mockLoggerErrorSpy).not.toHaveBeenCalled(); // Check error spy specifically
786
+ // *** CORRECTED EXPECTATION ***: Verify both CSS files were read (with only one argument)
787
+ expect(mockReadFile).toHaveBeenCalledWith(normalizePath(filePaths.cycle1Css));
788
+ expect(mockReadFile).toHaveBeenCalledWith(normalizePath(filePaths.cycle2Css));
789
+ });
790
+
791
+ it('should enforce maximum iteration limit to prevent infinite loops', async () => {
792
+ // *** CORRECTED MOCK IMPLEMENTATION ***: Mock readFile to generate new CSS files endlessly
793
+ const iterationTestReadFileMock = async (
794
+ filePathArg: PathLike | FileHandle
795
+ ): Promise<Buffer | string> => {
796
+ let normalizedPath: string = '';
797
+ try {
798
+ if (filePathArg instanceof URL) {
799
+ normalizedPath = path.normalize(fileURLToPath(filePathArg));
800
+ } else if (typeof filePathArg === 'string') {
801
+ normalizedPath = path.normalize(
802
+ filePathArg.startsWith('file:') ? fileURLToPath(filePathArg) : filePathArg
803
+ );
804
+ } else {
805
+ throw new Error('Unsupported readFile input type in iteration mock');
806
+ }
807
+ } catch (e) {
808
+ console.error('Error normalizing path in iteration mock:', filePathArg, e);
809
+ throw e;
810
+ }
811
+ const filename = path.basename(normalizedPath);
812
+ const match = filename.match(/gen_(\d+)\.css$/);
813
+ const isStart = filename === 'start.css';
814
+ if (match || isStart) {
815
+ const currentNum = match ? parseInt(match[1], 10) : 0;
816
+ const nextNum = currentNum + 1;
817
+ const nextFileName = `gen_${nextNum}.css`;
818
+ return Buffer.from(`@import url("${nextFileName}");`);
819
+ }
820
+ const error: NodeJSErrnoException = new Error(
821
+ `ENOENT (Mock Iteration): Unexpected path ${normalizedPath}`
822
+ );
823
+ error.code = 'ENOENT';
824
+ throw error;
825
+ };
826
+ mockReadFile.mockImplementation(iterationTestReadFileMock as any);
827
+
828
+ const parsed: ParsedHTML = {
829
+ htmlContent: '<link href="start.css">',
830
+ assets: [{ type: 'css', url: 'start.css' }],
831
+ };
832
+ const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
833
+
834
+ expect(result.assets.length).toBeGreaterThan(0);
835
+ expect(mockLoggerErrorSpy).toHaveBeenCalledTimes(2);
836
+ expect(mockLoggerErrorSpy).toHaveBeenNthCalledWith(
837
+ 1,
838
+ expect.stringContaining('Asset extraction loop limit hit')
839
+ );
840
+ expect(mockLoggerErrorSpy).toHaveBeenNthCalledWith(
841
+ 2,
842
+ expect.stringContaining('Remaining queue sample')
843
+ );
844
+ });
845
+
846
+ it('should handle data URIs in CSS without trying to fetch them', async () => {
847
+ // HTML referencing CSS that contains a data URI
848
+ const parsed: ParsedHTML = {
849
+ htmlContent: '<link href="data-uri.css">',
850
+ assets: [{ type: 'css', url: 'data-uri.css' }],
851
+ };
852
+ // Call extractor
853
+ const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
854
+ // Expect only the CSS file itself to be in the final assets
855
+ expect(result.assets).toHaveLength(1);
856
+ expect(result.assets[0].url).toEqual(resolveUrl('data-uri.css', mockBaseFileUrl));
857
+ expect(mockReadFile).toHaveBeenCalledWith(normalizePath(filePaths.dataUriCss));
858
+ // Crucially, verify that no HTTP request was made (axios mock shouldn't be called)
859
+ expect(mockAxiosGet).not.toHaveBeenCalled();
860
+ });
861
+
862
+ it('should handle CSS URLs with query parameters and fragments correctly', async () => {
863
+ // HTML referencing CSS which contains a URL with query/fragment
864
+ const parsed: ParsedHTML = {
865
+ htmlContent: '<link href="complex-url.css">',
866
+ assets: [{ type: 'css', url: 'complex-url.css' }],
867
+ };
868
+ // Call extractor
869
+ const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
870
+ // Define the expected resolved URLs
871
+ const expectedCssUrl = resolveUrl('complex-url.css', mockBaseFileUrl);
872
+ // The URL for the nested asset keeps the query/fragment
873
+ const expectedBgUrlWithQuery = resolveUrl('images/bg.png?v=123#section', mockBaseFileUrl);
874
+ // The path used to *fetch* the asset should NOT have the query/fragment
875
+ const expectedBgFetchPath = normalizePath(filePaths.bgImage);
876
+ // Define expected assets
877
+ const expectedAssets: ExpectedAsset[] = [
878
+ {
879
+ type: 'css',
880
+ url: expectedCssUrl,
881
+ content: expect.stringContaining('images/bg.png?v=123#section'),
882
+ }, // CSS content as text
883
+ {
884
+ type: 'image',
885
+ url: expectedBgUrlWithQuery,
886
+ content: expect.stringMatching(/^data:image\/png;base64,/),
887
+ }, // Image as data URI
888
+ ];
889
+ // Assert results
890
+ expectAssetsToContain(result.assets, expectedAssets);
891
+ expect(mockReadFile).toHaveBeenCalledWith(normalizePath(filePaths.complexUrlCss));
892
+ // *** CORRECTED EXPECTATION ***: Verify the *fetch path* was used (with only one argument)
893
+ expect(mockReadFile).toHaveBeenCalledWith(expectedBgFetchPath);
894
+ });
895
+
896
+ it('should properly resolve protocol-relative URLs using the base URL protocol', async () => {
897
+ // Define an HTTPS base URL for the HTML
898
+ const htmlBase = 'https://mysite.com/page.html';
899
+ // HTML contains a protocol-relative script URL (starts with //)
900
+ const parsed: ParsedHTML = {
901
+ htmlContent: '<script src="//example.com/js/lib.js"></script>',
902
+ assets: [{ type: 'js', url: '//example.com/js/lib.js' }],
903
+ };
904
+ // Call extractor
905
+ const result = await extractAssets(parsed, true, htmlBase, mockLogger);
906
+ // Expect the protocol-relative URL to be resolved using the base URL's protocol (https)
907
+ const expectedUrl = 'https://example.com/js/lib.js';
908
+ // Define expected assets
909
+ const expectedAssets: ExpectedAsset[] = [
910
+ { type: 'js', url: expectedUrl, content: 'console.log("remote lib");' }, // Content comes from Axios mock
911
+ ];
912
+ // Assert results
913
+ expectAssetsToContain(result.assets, expectedAssets);
914
+ // Verify axios was called with the correctly resolved HTTPS URL
915
+ expect(mockAxiosGet).toHaveBeenCalledWith(expectedUrl, expect.anything());
916
+ });
917
+ }); // End describe block