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
@@ -19,13 +19,13 @@ import { LogLevel } from '../../../src/types'; // Adjust path as needed
19
19
  import { Logger } from '../../../src/utils/logger'; // Adjust path as needed
20
20
  // Import necessary axios types and the namespace
21
21
  import type {
22
- AxiosResponse,
23
- AxiosRequestConfig,
24
- AxiosError,
25
- AxiosHeaderValue,
26
- AxiosRequestHeaders,
27
- AxiosResponseHeaders,
28
- InternalAxiosRequestConfig
22
+ AxiosResponse,
23
+ AxiosRequestConfig,
24
+ AxiosError,
25
+ AxiosHeaderValue,
26
+ AxiosRequestHeaders,
27
+ AxiosResponseHeaders,
28
+ InternalAxiosRequestConfig,
29
29
  } from 'axios';
30
30
  import * as axiosNs from 'axios'; // Namespace import
31
31
  import { AxiosHeaders } from 'axios'; // Import AxiosHeaders class if used directly
@@ -70,20 +70,20 @@ const normalizePath = (filePath: string): string => path.normalize(filePath);
70
70
 
71
71
  // Define paths for various mock files used in tests
72
72
  const filePaths = {
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
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
87
87
  };
88
88
 
89
89
  // --- Mock Data ---
@@ -92,582 +92,826 @@ const invalidUtf8Buffer = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x80, 0x6f]); //
92
92
 
93
93
  // Map normalized file paths to their mock content (string or Buffer)
94
94
  const mockFileContents: Record<string, string | Buffer> = {
95
- [normalizePath(filePaths.styleCss)]: '@import url("./css/deep.css");\nbody { background: url("images/bg.png"); @font-face { src: url("font/font.woff2"); } }',
96
- [normalizePath(filePaths.scriptJs)]: 'console.log("mock script");',
97
- [normalizePath(filePaths.deepCss)]: 'h1 { background: url("../images/nested-img.png"); }', // Contains nested relative path
98
- [normalizePath(filePaths.fontFile)]: Buffer.from('mock-font-data'), // Binary data
99
- [normalizePath(filePaths.bgImage)]: Buffer.from('mock-image-data'), // Binary data
100
- [normalizePath(filePaths.nestedImage)]: Buffer.from('mock-nested-image-data'), // Binary data for nested image
101
- [normalizePath(filePaths.invalidUtf8)]: invalidUtf8Buffer, // Invalid UTF-8 buffer
102
- [normalizePath(filePaths.dataUriCss)]: 'body { background: url(data:image/png;base64,SHORT_DATA_URI); }', // CSS containing a data URI
103
- [normalizePath(filePaths.cycle1Css)]: '@import url("cycle2.css");', // CSS for circular import test
104
- [normalizePath(filePaths.cycle2Css)]: '@import url("cycle1.css");', // CSS for circular import test
105
- [normalizePath(filePaths.iterationStartCss)]: '@import url("gen_1.css");', // Start file for iteration test
106
- [normalizePath(filePaths.complexUrlCss)]: 'body { background: url("images/bg.png?v=123#section"); }', // CSS with query/fragment URL
107
- [normalizePath(filePaths.unreadable)]: Buffer.from(''), // Empty buffer for the unreadable file (content doesn't matter, error is simulated)
108
- // Note: nonexistent file doesn't need content, its absence is simulated by the mock
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
109
112
  };
110
113
 
111
114
  // --- Mock Directory/File Structure ---
112
115
  // Set of directories that should exist in the mock structure
113
116
  const mockDirs = new Set<string>(
114
- [ 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)
115
123
  );
116
124
  // Set of files that should exist in the mock structure (used by statSync mock)
117
125
  const mockFiles = new Set<string>(
118
- // Get all keys (paths) from mockFileContents
119
- Object.keys(mockFileContents)
126
+ // Get all keys (paths) from mockFileContents
127
+ Object.keys(mockFileContents)
120
128
  // Add paths for files that should exist but might cause read errors
121
129
  .concat([filePaths.unreadable].map(normalizePath))
122
- // Note: filePaths.nonexistent is *not* added here, so statSync will fail for it
130
+ // Note: filePaths.nonexistent is *not* added here, so statSync will fail for it
123
131
  );
124
132
 
125
133
  // --- Helpers ---
126
134
  // Helper to resolve URLs consistently within tests
127
135
  const resolveUrl = (relativePath: string, baseUrl: string): string => {
128
- try { return new URL(relativePath, baseUrl).href; }
129
- catch (e) { console.error(`Resolve URL error in test helper: ${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
+ }
130
142
  };
131
143
 
132
144
  // Type definition for expected asset structure in assertions
133
- type ExpectedAsset = { type: Asset['type']; url: string; content?: any; };
145
+ type ExpectedAsset = { type: Asset['type']; url: string; content?: any };
134
146
 
135
147
  // Helper function to assert that the actual assets contain the expected assets
136
148
  function expectAssetsToContain(actualAssets: Asset[], expectedAssets: ExpectedAsset[]): void {
137
- // Check if the number of found assets matches the expected number
138
- expect(actualAssets).toHaveLength(expectedAssets.length);
139
- // Check each expected asset
140
- expectedAssets.forEach(expected => {
141
- // Find the corresponding asset in the actual results by type and URL
142
- const found = actualAssets.find(asset => asset.type === expected.type && asset.url === expected.url);
143
- // Assert that the asset was found
144
- expect(found).toBeDefined();
145
- // If content is expected, assert that it matches (using toEqual for deep comparison if needed)
146
- if (found && expected.content !== undefined) {
147
- expect(found.content).toEqual(expected.content);
148
- }
149
- });
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
+ });
150
164
  }
151
165
 
152
166
  // Interface for Node.js errors with a 'code' property
153
- interface NodeJSErrnoException extends Error { code?: string; }
167
+ interface NodeJSErrnoException extends Error {
168
+ code?: string;
169
+ }
154
170
  // Interface to represent an Axios error structure for mocking
155
- interface MockAxiosError extends AxiosError { isAxiosError: true; }
156
-
171
+ interface MockAxiosError extends AxiosError {
172
+ isAxiosError: true;
173
+ }
157
174
 
158
175
  // ================ MOCK IMPLEMENTATIONS (Defined Globally) ================
159
176
 
160
177
  // Mock implementation for fsPromises.readFile
161
178
  const readFileMockImplementation = async (
162
- filePathArg: PathLike | FileHandle,
163
- options?: BufferEncoding | (({ encoding?: null; flag?: OpenMode; } & AbortSignal)) | null // Match fsPromises.readFile signature
179
+ filePathArg: PathLike | FileHandle,
180
+ options?: BufferEncoding | ({ encoding?: null; flag?: OpenMode } & AbortSignal) | null // Match fsPromises.readFile signature
164
181
  ): Promise<Buffer | string> => {
165
- let normalizedPath: string = '';
166
- try {
167
- // Normalize the input path regardless of whether it's a string, URL, Buffer, or FileHandle
168
- if (filePathArg instanceof URL) { normalizedPath = normalizePath(fileURLToPath(filePathArg)); }
169
- else if (typeof filePathArg === 'string') { normalizedPath = normalizePath(filePathArg.startsWith('file:') ? fileURLToPath(filePathArg) : filePathArg); }
170
- else if (Buffer.isBuffer(filePathArg)) { normalizedPath = normalizePath(filePathArg.toString()); }
171
- // Rudimentary check for FileHandle-like object (adjust if using actual FileHandles)
172
- else if (typeof (filePathArg as any)?.read === 'function') { normalizedPath = normalizePath((filePathArg as any).path || String(filePathArg)); }
173
- else { throw new Error('Unsupported readFile input type in mock'); }
174
- } catch(e) { console.error("Error normalizing path in readFile mock:", filePathArg, e); throw e; }
175
-
176
- // console.log(`[DEBUG mockReadFileFn] Requesting normalized path: "${normalizedPath}"`); // Optional debug
177
-
178
- // Simulate ENOENT (file not found) error
179
- if (normalizedPath === normalizePath(filePaths.nonexistent)) { const error: NodeJSErrnoException = new Error(`ENOENT: no such file or directory, open '${normalizedPath}'`); error.code = 'ENOENT'; throw error; }
180
- // Simulate EACCES (permission denied) error
181
- if (normalizedPath === normalizePath(filePaths.unreadable)) { const error: NodeJSErrnoException = new Error(`EACCES: permission denied, open '${normalizedPath}'`); error.code = 'EACCES'; throw error; }
182
-
183
- // Retrieve mock content based on the normalized path
184
- const content = mockFileContents[normalizedPath];
185
- if (content !== undefined) {
186
- // console.log(`[DEBUG mockReadFileFn] FOUND content for: "${normalizedPath}".`); // Optional debug
187
- // Always return a Buffer, as the actual readFile would
188
- return Buffer.isBuffer(content) ? content : Buffer.from(content);
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());
189
193
  }
190
-
191
- // If content not found in mock map, simulate ENOENT
192
- // console.log(`[DEBUG mockReadFileFn] NOT FOUND content for: "${normalizedPath}". Available keys: ${Object.keys(mockFileContents).join(', ')}`); // Optional debug
193
- const error: NodeJSErrnoException = new Error(`ENOENT (Mock): Content not found for ${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;
194
239
  };
195
240
 
196
241
  // Mock implementation for fs.statSync
197
242
  const statSyncMockImplementation = (
198
- pathToCheck: PathLike,
199
- options?: StatSyncOptions & { bigint?: false; throwIfNoEntry?: boolean } | { bigint: true; throwIfNoEntry?: boolean } // Match fs.statSync signature
243
+ pathToCheck: PathLike,
244
+ options?:
245
+ | (StatSyncOptions & { bigint?: false; throwIfNoEntry?: boolean })
246
+ | { bigint: true; throwIfNoEntry?: boolean } // Match fs.statSync signature
200
247
  ): Stats | BigIntStats | undefined => {
201
- let normalizedPath: string = '';
202
- try {
203
- // Normalize the input path
204
- if (pathToCheck instanceof URL) { normalizedPath = normalizePath(fileURLToPath(pathToCheck)); }
205
- else if (typeof pathToCheck === 'string') { normalizedPath = normalizePath(pathToCheck.startsWith('file:') ? fileURLToPath(pathToCheck) : pathToCheck); }
206
- else if (Buffer.isBuffer(pathToCheck)) { normalizedPath = normalizePath(pathToCheck.toString()); }
207
- else { throw new Error(`Unsupported statSync input type in mock: ${typeof pathToCheck}`); }
208
- } catch(e) {
209
- console.error("Error normalizing path in statSync mock:", pathToCheck, e);
210
- // Handle throwIfNoEntry option if normalization fails
211
- if (options?.throwIfNoEntry === false) { return undefined; }
212
- throw e; // Re-throw normalization error if throwIfNoEntry is not false
213
- }
214
-
215
- // Helper to create a mock Stats or BigIntStats object
216
- const createStats = (isFile: boolean): Stats | BigIntStats => {
217
- // Base properties common to both Stats and BigIntStats
218
- const baseProps = {
219
- dev: 0, ino: 0, mode: isFile ? 33188 : 16877, /* file vs dir mode */ nlink: 1, uid: 0, gid: 0, rdev: 0,
220
- blksize: 4096, blocks: 8,
221
- atimeMs: Date.now(), mtimeMs: Date.now(), ctimeMs: Date.now(), birthtimeMs: Date.now(),
222
- atime: new Date(), mtime: new Date(), ctime: new Date(), birthtime: new Date(),
223
- isFile: () => isFile, isDirectory: () => !isFile,
224
- isBlockDevice: () => false, isCharacterDevice: () => false,
225
- isSymbolicLink: () => false, isFIFO: () => false, isSocket: () => false,
226
- // Calculate size based on mock content or default
227
- size: isFile ? (mockFileContents[normalizedPath]?.length ?? 100) : 4096
228
- };
229
-
230
- // If bigint option is true, return a BigIntStats-compatible object
231
- if (options?.bigint) {
232
- return {
233
- isFile: baseProps.isFile, isDirectory: baseProps.isDirectory,
234
- isBlockDevice: baseProps.isBlockDevice, isCharacterDevice: baseProps.isCharacterDevice,
235
- isSymbolicLink: baseProps.isSymbolicLink, isFIFO: baseProps.isFIFO, isSocket: baseProps.isSocket,
236
- atime: baseProps.atime, mtime: baseProps.mtime, ctime: baseProps.ctime, birthtime: baseProps.birthtime,
237
- 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),
238
- blksize: BigInt(baseProps.blksize), blocks: BigInt(baseProps.blocks), size: BigInt(baseProps.size),
239
- // Convert milliseconds to nanoseconds BigInt
240
- atimeNs: BigInt(Math.floor(baseProps.atimeMs * 1e6)),
241
- mtimeNs: BigInt(Math.floor(baseProps.mtimeMs * 1e6)),
242
- ctimeNs: BigInt(Math.floor(baseProps.ctimeMs * 1e6)),
243
- birthtimeNs: BigInt(Math.floor(baseProps.birthtimeMs * 1e6)),
244
- } as BigIntStats; // Cast to satisfy the type
245
- }
246
- // Otherwise, return a standard Stats-compatible object
247
- 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,
248
301
  };
249
302
 
250
- // Check if the normalized path represents a mocked directory
251
- if (mockDirs.has(normalizedPath)) { return createStats(false); } // It's a directory
252
- // Check if the normalized path represents a mocked file (or generated file in loop test)
253
- if (mockFiles.has(normalizedPath) || path.basename(normalizedPath).startsWith('gen_')) { return createStats(true); } // It's a file
254
-
255
- // Path not found in mocks
256
- if (options?.throwIfNoEntry === false) { return undefined; } // Return undefined if not throwing
257
- // Throw ENOENT error if path not found and not suppressed
258
- const error: NodeJSErrnoException = new Error(`ENOENT (Mock): statSync path not found: ${normalizedPath}`); error.code = 'ENOENT'; throw error;
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
333
+ }
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;
259
357
  };
260
358
 
261
359
  // Mock implementation for axios.get
262
360
  const axiosGetMockImplementation = async (
263
- url: string,
264
- config?: AxiosRequestConfig // Match axios.get signature
265
- ): Promise<AxiosResponse<Buffer>> => { // Return Buffer data
266
- // console.log(`[DEBUG mockAxiosGet] Requesting URL: "${url}"`); // Optional debug
267
-
268
- const { AxiosHeaders } = axiosNs; // Use the AxiosHeaders class from the namespace
269
- let dataBuffer: Buffer; // Content will be a Buffer
270
- let contentType = 'text/plain'; // Default content type
271
- let status = 200; // Default success status
272
- let statusText = 'OK'; // Default success status text
273
-
274
- // Helper to create mock Axios request headers
275
- const getRequestHeaders = (reqConfig?: AxiosRequestConfig): AxiosRequestHeaders => {
276
- const headers = new AxiosHeaders(); // Instantiate AxiosHeaders
277
- if (reqConfig?.headers) {
278
- // Copy headers from config if provided
279
- for (const key in reqConfig.headers) {
280
- if (Object.prototype.hasOwnProperty.call(reqConfig.headers, key)) {
281
- // Use AxiosHeaders methods for setting headers
282
- headers.set(key, reqConfig.headers[key] as AxiosHeaderValue);
283
- }
284
- }
361
+ url: string,
362
+ config?: AxiosRequestConfig // Match axios.get signature
363
+ ): Promise<AxiosResponse<Buffer>> => {
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);
285
382
  }
286
- return headers;
287
- };
288
- // Helper to create mock InternalAxiosRequestConfig
289
- const createInternalConfig = (reqConfig?: AxiosRequestConfig): InternalAxiosRequestConfig => {
290
- const requestHeaders = getRequestHeaders(reqConfig);
291
- // Construct the config object, ensuring headers is an AxiosHeaders instance
292
- // Need to satisfy the complex InternalAxiosRequestConfig type
293
- const internalConfig: InternalAxiosRequestConfig = {
294
- url: url,
295
- method: 'get',
296
- ...(reqConfig || {}), // Spread original config
297
- headers: requestHeaders, // Overwrite headers with AxiosHeaders instance
298
- // Add other potentially required fields with default values if needed
299
- // baseURL: reqConfig?.baseURL || '',
300
- // params: reqConfig?.params || {},
301
- // data: reqConfig?.data,
302
- // timeout: reqConfig?.timeout || 0,
303
- // responseType: reqConfig?.responseType || 'json',
304
- // ... add others based on Axios version and usage ...
305
- };
306
- return internalConfig;
307
- };
308
-
309
- // Simulate errors based on URL content
310
- if (url.includes('error')) { status = 404; statusText = 'Not Found'; }
311
- // Simulate timeout using status code 408 and setting error code later
312
- if (url.includes('timeout')) { status = 408; statusText = 'Request Timeout'; }
313
-
314
- // If simulating an error status
315
- if (status !== 200) {
316
- const errorConfig = createInternalConfig(config);
317
- // *** Create a plain object that mimics AxiosError ***
318
- const error: any = { // Use 'any' for flexibility in mock creation
319
- // Base Error properties (optional but good practice)
320
- name: 'Error', // Keep it generic or 'AxiosError'
321
- message: status === 404 ? `Request failed with status code 404` : `Timeout of ${config?.timeout || 'unknown'}ms exceeded`,
322
- stack: (new Error()).stack, // Capture a stack trace
323
-
324
- // AxiosError specific properties
325
- isAxiosError: true, // Explicitly set the flag Axios checks
326
- code: status === 408 ? 'ECONNABORTED' : undefined, // Set code correctly
327
- config: errorConfig, // Attach the config
328
- request: {}, // Mock request object if needed
329
- response: { // Attach the mock response
330
- status,
331
- statusText,
332
- data: Buffer.from(statusText), // Mock data
333
- headers: new AxiosHeaders(),
334
- config: errorConfig
335
- },
336
- // Add a basic toJSON if needed by any code consuming the error
337
- toJSON: function () { return { message: this.message, code: this.code }; }
338
- };
339
- // console.log(`[DEBUG mockAxiosGet] Simulating ERROR object:`, error); // Optional debug
340
- throw error; // Throw the simulated error object
383
+ }
341
384
  }
342
-
343
- // Simulate successful responses with appropriate content and type based on URL
344
- if (url.includes('/styles/main.css')) { dataBuffer = Buffer.from('body { background: url("/images/remote-bg.jpg"); }'); contentType = 'text/css'; }
345
- else if (url.includes('/js/script.js')) { dataBuffer = Buffer.from('console.log("remote script");'); contentType = 'application/javascript'; }
346
- else if (url.includes('/js/lib.js')) { dataBuffer = Buffer.from('console.log("remote lib");'); contentType = 'application/javascript'; } // Handle protocol-relative case
347
- else if (url.includes('/images/remote-bg.jpg')) { dataBuffer = Buffer.from('mock-remote-image-data'); contentType = 'image/jpeg'; }
348
- else { dataBuffer = Buffer.from(`Mock content for ${url}`); } // Default fallback content
349
-
350
- // Create mock response configuration and headers
351
- const responseConfig = createInternalConfig(config);
352
- const responseHeaders = new AxiosHeaders({ 'content-type': contentType }); // Use AxiosHeaders
353
-
354
- // console.log(`[DEBUG mockAxiosGet] Simulating SUCCESS for URL: "${url}", ContentType: ${contentType}`); // Optional debug
355
- // Return the successful AxiosResponse object
356
- return {
357
- data: dataBuffer, // Data as Buffer
358
- status: 200,
359
- statusText: 'OK',
360
- headers: responseHeaders, // AxiosHeaders object
361
- config: responseConfig, // InternalAxiosRequestConfig object
362
- request: {} // Mock request object (can be empty or more detailed if needed)
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 ...
404
+ };
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
+ },
363
450
  };
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
+ };
364
487
  };
365
488
 
366
-
367
489
  // ================ TESTS ================
368
490
 
369
491
  describe('extractAssets', () => {
370
- // Declare variables for logger and its spies
371
- let mockLogger: Logger;
372
- let mockLoggerWarnSpy: jest.SpiedFunction<typeof mockLogger.warn>;
373
- let mockLoggerErrorSpy: jest.SpiedFunction<typeof mockLogger.error>;
374
-
375
- beforeEach(() => {
376
- // --- Retrieve Mocked Functions from Modules ---
377
- // Use jest.requireMock to get the mocked versions of the modules
378
- // Cast to the specific mocked function type for type safety
379
- const fsPromisesMock = jest.requireMock('fs/promises') as typeof fsPromises;
380
- mockReadFile = fsPromisesMock.readFile as MockedReadFileFn;
381
- const fsMock = jest.requireMock('fs') as typeof fs;
382
- mockStatSync = fsMock.statSync as MockedStatSyncFn;
383
- const axiosMock = jest.requireMock('axios') as typeof axiosNs;
384
- mockAxiosGet = axiosMock.default.get as MockedAxiosGetFn;
385
-
386
- // --- Setup Logger and Spies ---
387
- // Create a new Logger instance for each test (set level low for debugging if needed)
388
- mockLogger = new Logger(LogLevel.WARN); // Or LogLevel.DEBUG
389
- // Spy on the warn and error methods of this logger instance
390
- mockLoggerWarnSpy = jest.spyOn(mockLogger, 'warn');
391
- mockLoggerErrorSpy = jest.spyOn(mockLogger, 'error');
392
-
393
- // --- Assign Mock Implementations ---
394
- // Set the implementation for the mocked functions for this test run
395
- // Use 'as any' to bypass strict type checking
396
- mockReadFile.mockImplementation(readFileMockImplementation as any);
397
- mockStatSync.mockImplementation(statSyncMockImplementation as any);
398
- mockAxiosGet.mockImplementation(axiosGetMockImplementation as any);
399
- });
400
-
401
- afterEach(() => {
402
- // Clear mock calls and reset implementations between tests
403
- jest.clearAllMocks();
404
- // Restore original implementations spied on with jest.spyOn (like the logger spies)
405
- jest.restoreAllMocks();
406
- });
407
-
408
- // ================ Test Cases ================
409
-
410
- it('should extract and embed assets from local HTML file', async () => {
411
- // Define the initial parsed HTML structure
412
- const parsed: ParsedHTML = {
413
- htmlContent: '<link href="style.css"><script src="script.js">',
414
- assets: [ { type: 'css', url: 'style.css' }, { type: 'js', url: 'script.js' } ] // Assets found directly in HTML
415
- };
416
- // Call the function under test
417
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger); // embedAssets = true
418
- // Define the expected final assets, including nested ones
419
- const expectedAssets: ExpectedAsset[] = [
420
- // Top-level CSS (content should be text)
421
- { type: 'css', url: resolveUrl('style.css', mockBaseFileUrl), content: expect.stringContaining('@import url("./css/deep.css");') },
422
- // Top-level JS (content should be text)
423
- { type: 'js', url: resolveUrl('script.js', mockBaseFileUrl), content: 'console.log("mock script");' },
424
- // Nested CSS from style.css (content should be text)
425
- { type: 'css', url: resolveUrl('css/deep.css', mockBaseFileUrl), content: expect.stringContaining('nested-img.png') },
426
- // Image referenced in style.css (content should be data URI)
427
- { type: 'image', url: resolveUrl('images/bg.png', mockBaseFileUrl), content: expect.stringMatching(/^data:image\/png;base64,/) },
428
- // Font referenced in style.css (content should be data URI)
429
- { type: 'font', url: resolveUrl('font/font.woff2', mockBaseFileUrl), content: expect.stringMatching(/^data:font\/woff2;base64,/) },
430
- // Image referenced in deep.css (content should be data URI)
431
- { type: 'image', url: resolveUrl('images/nested-img.png', mockBaseFileUrl), content: expect.stringMatching(/^data:image\/png;base64,/) }
432
- ];
433
- // Assert the final assets match the expected structure and content
434
- expectAssetsToContain(result.assets, expectedAssets);
435
- // Check how many times readFile was called (should be once for each unique file)
436
- expect(mockReadFile).toHaveBeenCalledTimes(6); // style.css, script.js, deep.css, bg.png, font.woff2, nested-img.png
437
- });
438
-
439
- it('should discover assets without embedding when embedAssets is false', async () => {
440
- // Initial HTML with one CSS link
441
- const parsed: ParsedHTML = { htmlContent: '<link href="style.css">', assets: [{ type: 'css', url: 'style.css' }] };
442
- // Call with embedAssets = false
443
- const result = await extractAssets(parsed, false, mockBaseFileUrl, mockLogger);
444
- // Expected assets should include all discovered URLs, but content should be undefined
445
- const expectedAssets: ExpectedAsset[] = [
446
- { type: 'css', url: resolveUrl('style.css', mockBaseFileUrl), content: undefined },
447
- { type: 'css', url: resolveUrl('css/deep.css', mockBaseFileUrl), content: undefined },
448
- { type: 'image', url: resolveUrl('images/bg.png', mockBaseFileUrl), content: undefined },
449
- { type: 'font', url: resolveUrl('font/font.woff2', mockBaseFileUrl), content: undefined },
450
- { type: 'image', url: resolveUrl('images/nested-img.png', mockBaseFileUrl), content: undefined }
451
- ];
452
- // Assert the structure matches
453
- expectAssetsToContain(result.assets, expectedAssets);
454
- // readFile should only be called for CSS files (to parse them), not for images/fonts when not embedding
455
- expect(mockReadFile).toHaveBeenCalledTimes(2); // Only style.css, deep.css
456
- });
457
-
458
- it('should handle remote assets and their nested dependencies', async () => {
459
- // Define a remote base URL
460
- const remoteUrl = 'https://example.com/page.html';
461
- // Initial HTML structure with remote assets
462
- const parsed: ParsedHTML = {
463
- htmlContent: '<link href="styles/main.css"><script src="/js/script.js">', // Relative and absolute paths
464
- assets: [ { type: 'css', url: 'styles/main.css' }, { type: 'js', url: '/js/script.js' } ]
465
- };
466
- // Call with embedAssets = true
467
- const result = await extractAssets(parsed, true, remoteUrl, mockLogger);
468
- // Expected assets, including nested remote image from the mocked CSS content
469
- const expectedAssets: ExpectedAsset[] = [
470
- { type: 'css', url: resolveUrl('styles/main.css', remoteUrl), content: expect.stringContaining('background') }, // Text content
471
- { type: 'js', url: resolveUrl('/js/script.js', remoteUrl), content: 'console.log("remote script");' }, // Text content
472
- { type: 'image', url: resolveUrl('/images/remote-bg.jpg', remoteUrl), content: expect.stringMatching(/^data:image\/jpeg;base64,/) } // Data URI
473
- ];
474
- // Assert the results
475
- expectAssetsToContain(result.assets, expectedAssets);
476
- // Check that axios.get was called for each remote asset
477
- expect(mockAxiosGet).toHaveBeenCalledTimes(3); // main.css, script.js, remote-bg.jpg
478
- // Ensure local file reading was not attempted
479
- expect(mockReadFile).not.toHaveBeenCalled();
480
- });
481
-
482
- it('should handle ENOENT errors when reading local files', async () => {
483
- // HTML references a file that doesn't exist in the mock setup
484
- const parsed: ParsedHTML = { htmlContent: '<link href="nonexistent.file">', assets: [{ type: 'css', url: 'nonexistent.file' }] };
485
- // Call extractor
486
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
487
- // Expect the asset list to contain the entry, but with undefined content
488
- expect(result.assets).toHaveLength(1);
489
- expect(result.assets[0].content).toBeUndefined();
490
- // Expect a warning log indicating the file was not found
491
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`File not found (ENOENT) for asset: ${normalizePath(filePaths.nonexistent)}`));
492
- });
493
-
494
- it('should handle permission denied errors when reading local files', async () => {
495
- // HTML references a file set up to trigger EACCES in the mock readFile
496
- const parsed: ParsedHTML = { htmlContent: '<link href="unreadable.file">', assets: [{ type: 'css', url: 'unreadable.file' }] };
497
- // Call extractor
498
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
499
- // Expect the asset with undefined content
500
- expect(result.assets).toHaveLength(1);
501
- expect(result.assets[0].content).toBeUndefined();
502
- // *** CORRECTED EXPECTATION ***: Check for the specific EACCES warning message logged by the updated extractor.ts
503
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(
504
- expect.stringContaining(`Permission denied (EACCES) reading asset: ${normalizePath(filePaths.unreadable)}`)
505
- );
506
- // *** CORRECTED EXPECTATION ***: Verify readFile was called with ONLY the path argument
507
- expect(mockReadFile).toHaveBeenCalledWith(normalizePath(filePaths.unreadable));
508
- });
509
-
510
- it('should handle HTTP errors when fetching remote assets', async () => {
511
- const remoteUrl = 'https://example.com/page.html';
512
- // Resolve the URL that will trigger a 404 in the axios mock
513
- const errorCssUrl = resolveUrl('styles/error.css', remoteUrl);
514
- // HTML referencing the error URL
515
- const parsed: ParsedHTML = { htmlContent: `<link href="${errorCssUrl}">`, assets: [{ type: 'css', url: errorCssUrl }] };
516
- // Call extractor
517
- const result = await extractAssets(parsed, true, remoteUrl, mockLogger);
518
- // Expect the asset with undefined content
519
- expect(result.assets).toHaveLength(1);
520
- expect(result.assets[0].content).toBeUndefined();
521
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(
522
- expect.stringContaining(`Failed to fetch remote asset ${errorCssUrl}: Request failed with status code 404 (Code: N/A)`) // Changed undefined to N/A
523
- );
524
- });
525
-
526
- it('should handle timeout errors when fetching remote assets', async () => {
527
- const remoteUrl = 'https://example.com/page.html';
528
- // Resolve the URL that triggers a timeout (ECONNABORTED) in the axios mock
529
- const timeoutCssUrl = resolveUrl('styles/timeout.css', remoteUrl);
530
- // HTML referencing the timeout URL
531
- const parsed: ParsedHTML = { htmlContent: `<link href="${timeoutCssUrl}">`, assets: [{ type: 'css', url: timeoutCssUrl }] };
532
- // Call extractor
533
- const result = await extractAssets(parsed, true, remoteUrl, mockLogger);
534
- // Expect the asset with undefined content
535
- expect(result.assets).toHaveLength(1);
536
- expect(result.assets[0].content).toBeUndefined();
537
- // *** CORRECTED EXPECTATION ***: Check for the specific warning logged by the updated fetchAsset, including the code
538
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(
539
- // Match the simplified log format exactly
540
- expect.stringContaining(`Failed to fetch remote asset ${timeoutCssUrl}: Timeout of 10000ms exceeded (Code: ECONNABORTED)`)
541
- );
542
- });
543
-
544
- it('should handle invalid UTF-8 in CSS files by falling back to base64', async () => {
545
- // HTML referencing the CSS file with invalid UTF-8 content
546
- const parsed: ParsedHTML = { htmlContent: '<link href="invalid-utf8.css">', assets: [{ type: 'css', url: 'invalid-utf8.css' }] };
547
- // Call extractor
548
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
549
- // Resolve the expected URL
550
- const expectedUrl = resolveUrl('invalid-utf8.css', mockBaseFileUrl);
551
- // Expect one asset in the result
552
- expect(result.assets).toHaveLength(1);
553
- // Expect the content to be a data URI containing the base64 representation of the original buffer
554
- expect(result.assets[0].content).toEqual(`data:text/css;base64,${invalidUtf8Buffer.toString('base64')}`);
555
- // *** CORRECTED EXPECTATION (from previous step) ***: Expect the single, combined warning message
556
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(
557
- expect.stringContaining(`Could not decode css asset ${expectedUrl} as valid UTF-8 text. Falling back to base64 data URI.`)
558
- );
559
- // Ensure only one warning related to this was logged
560
- expect(mockLoggerWarnSpy).toHaveBeenCalledTimes(1);
561
- });
562
-
563
- it('should avoid circular references in CSS imports', async () => {
564
- // HTML referencing the start of a CSS import cycle
565
- const parsed: ParsedHTML = { htmlContent: '<link href="cycle1.css">', assets: [{ type: 'css', url: 'cycle1.css' }] };
566
- // Call extractor
567
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
568
- // Expected assets: both cycle1.css and cycle2.css should be found, but the loop should terminate
569
- const expectedAssets: ExpectedAsset[] = [
570
- { type: 'css', url: resolveUrl('cycle1.css', mockBaseFileUrl), content: expect.stringContaining('@import url("cycle2.css");') },
571
- { type: 'css', url: resolveUrl('cycle2.css', mockBaseFileUrl), content: expect.stringContaining('@import url("cycle1.css");') } // Should find cycle2
572
- ];
573
- // Assert the expected assets were found
574
- expectAssetsToContain(result.assets, expectedAssets);
575
- // Ensure no loop limit error was logged
576
- expect(mockLoggerErrorSpy).not.toHaveBeenCalled(); // Check error spy specifically
577
- // *** CORRECTED EXPECTATION ***: Verify both CSS files were read (with only one argument)
578
- expect(mockReadFile).toHaveBeenCalledWith(normalizePath(filePaths.cycle1Css));
579
- expect(mockReadFile).toHaveBeenCalledWith(normalizePath(filePaths.cycle2Css));
580
- });
581
-
582
- it('should enforce maximum iteration limit to prevent infinite loops', async () => {
583
- // *** CORRECTED MOCK IMPLEMENTATION ***: Mock readFile to generate new CSS files endlessly
584
- const iterationTestReadFileMock = async (filePathArg: PathLike | FileHandle): Promise<Buffer | string> => {
585
- let normalizedPath: string = '';
586
- try {
587
- if (filePathArg instanceof URL) { normalizedPath = path.normalize(fileURLToPath(filePathArg)); }
588
- else if (typeof filePathArg === 'string') { normalizedPath = path.normalize(filePathArg.startsWith('file:') ? fileURLToPath(filePathArg) : filePathArg); }
589
- else { throw new Error('Unsupported readFile input type in iteration mock'); }
590
- } catch(e) { console.error("Error normalizing path in iteration mock:", filePathArg, e); throw e; }
591
- const filename = path.basename(normalizedPath);
592
- const match = filename.match(/gen_(\d+)\.css$/);
593
- const isStart = filename === 'start.css';
594
- if (match || isStart) {
595
- const currentNum = match ? parseInt(match[1], 10) : 0;
596
- const nextNum = currentNum + 1;
597
- const nextFileName = `gen_${nextNum}.css`;
598
- return Buffer.from(`@import url("${nextFileName}");`);
599
- }
600
- const error: NodeJSErrnoException = new Error(`ENOENT (Mock Iteration): Unexpected path ${normalizedPath}`); error.code = 'ENOENT'; throw error;
601
- };
602
- mockReadFile.mockImplementation(iterationTestReadFileMock as any);
603
-
604
- const parsed: ParsedHTML = { htmlContent: '<link href="start.css">', assets: [{ type: 'css', url: 'start.css' }] };
605
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
606
-
607
- expect(result.assets.length).toBeGreaterThan(0);
608
- // *** CORRECTED EXPECTATION ***: Check that the ERROR logger was called exactly TWICE
609
- expect(mockLoggerErrorSpy).toHaveBeenCalledTimes(2);
610
- // *** CORRECTED EXPECTATION ***: Check that the FIRST error message contains the loop limit text
611
- expect(mockLoggerErrorSpy).toHaveBeenNthCalledWith(1, expect.stringContaining('Asset extraction loop limit hit'));
612
- // *** CORRECTED EXPECTATION ***: Check the SECOND error message contains the remaining queue text
613
- expect(mockLoggerErrorSpy).toHaveBeenNthCalledWith(2, expect.stringContaining('Remaining queue sample'));
614
- });
615
-
616
- it('should handle data URIs in CSS without trying to fetch them', async () => {
617
- // HTML referencing CSS that contains a data URI
618
- const parsed: ParsedHTML = { htmlContent: '<link href="data-uri.css">', assets: [{ type: 'css', url: 'data-uri.css' }] };
619
- // Call extractor
620
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
621
- // Expect only the CSS file itself to be in the final assets
622
- expect(result.assets).toHaveLength(1);
623
- expect(result.assets[0].url).toEqual(resolveUrl('data-uri.css', mockBaseFileUrl));
624
- // *** CORRECTED EXPECTATION ***: Verify the CSS file was read (with only one argument)
625
- expect(mockReadFile).toHaveBeenCalledWith(normalizePath(filePaths.dataUriCss));
626
- // Crucially, verify that no HTTP request was made (axios mock shouldn't be called)
627
- expect(mockAxiosGet).not.toHaveBeenCalled();
628
- });
629
-
630
- it('should handle CSS URLs with query parameters and fragments correctly', async () => {
631
- // HTML referencing CSS which contains a URL with query/fragment
632
- const parsed: ParsedHTML = { htmlContent: '<link href="complex-url.css">', assets: [{ type: 'css', url: 'complex-url.css' }] };
633
- // Call extractor
634
- const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
635
- // Define the expected resolved URLs
636
- const expectedCssUrl = resolveUrl('complex-url.css', mockBaseFileUrl);
637
- // The URL for the nested asset keeps the query/fragment
638
- const expectedBgUrlWithQuery = resolveUrl('images/bg.png?v=123#section', mockBaseFileUrl);
639
- // The path used to *fetch* the asset should NOT have the query/fragment
640
- const expectedBgFetchPath = normalizePath(filePaths.bgImage);
641
- // Define expected assets
642
- const expectedAssets: ExpectedAsset[] = [
643
- { type: 'css', url: expectedCssUrl, content: expect.stringContaining('images/bg.png?v=123#section') }, // CSS content as text
644
- { type: 'image', url: expectedBgUrlWithQuery, content: expect.stringMatching(/^data:image\/png;base64,/) } // Image as data URI
645
- ];
646
- // Assert results
647
- expectAssetsToContain(result.assets, expectedAssets);
648
- // *** CORRECTED EXPECTATION ***: Check that the correct files were read (with only one argument)
649
- expect(mockReadFile).toHaveBeenCalledWith(normalizePath(filePaths.complexUrlCss));
650
- // *** CORRECTED EXPECTATION ***: Verify the *fetch path* was used (with only one argument)
651
- expect(mockReadFile).toHaveBeenCalledWith(expectedBgFetchPath);
652
- });
653
-
654
- it('should properly resolve protocol-relative URLs using the base URL protocol', async () => {
655
- // Define an HTTPS base URL for the HTML
656
- const htmlBase = 'https://mysite.com/page.html';
657
- // HTML contains a protocol-relative script URL (starts with //)
658
- const parsed: ParsedHTML = { htmlContent: '<script src="//example.com/js/lib.js"></script>', assets: [{ type: 'js', url: '//example.com/js/lib.js' }] };
659
- // Call extractor
660
- const result = await extractAssets(parsed, true, htmlBase, mockLogger);
661
- // Expect the protocol-relative URL to be resolved using the base URL's protocol (https)
662
- const expectedUrl = 'https://example.com/js/lib.js';
663
- // Define expected assets
664
- const expectedAssets: ExpectedAsset[] = [
665
- { type: 'js', url: expectedUrl, content: 'console.log("remote lib");' } // Content comes from Axios mock
666
- ];
667
- // Assert results
668
- expectAssetsToContain(result.assets, expectedAssets);
669
- // Verify axios was called with the correctly resolved HTTPS URL
670
- expect(mockAxiosGet).toHaveBeenCalledWith(expectedUrl, expect.anything());
671
- });
672
-
673
- }); // 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