portapack 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,14 +7,16 @@
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
22
  AxiosResponse,
@@ -25,39 +27,48 @@ import type {
25
27
  AxiosResponseHeaders,
26
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
73
  styleCss: path.join(mockBaseDirPath, 'style.css'),
63
74
  scriptJs: path.join(mockBaseDirPath, 'script.js'),
@@ -71,399 +82,592 @@ const filePaths = {
71
82
  dataUriCss: path.join(mockBaseDirPath, 'data-uri.css'),
72
83
  cycle1Css: path.join(mockBaseDirPath, 'cycle1.css'),
73
84
  cycle2Css: path.join(mockBaseDirPath, 'cycle2.css'),
74
- iterationStartCss: path.join(mockBaseDirPath, 'start.css'),
75
- complexUrlCss: path.join(mockBaseDirPath, 'complex-url.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
95
  [normalizePath(filePaths.styleCss)]: '@import url("./css/deep.css");\nbody { background: url("images/bg.png"); @font-face { src: url("font/font.woff2"); } }',
82
96
  [normalizePath(filePaths.scriptJs)]: 'console.log("mock script");',
83
97
  [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(''),
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
94
109
  };
95
110
 
96
111
  // --- Mock Directory/File Structure ---
112
+ // Set of directories that should exist in the mock structure
97
113
  const mockDirs = new Set<string>(
98
114
  [ mockBaseDirPath, path.dirname(filePaths.deepCss), path.dirname(filePaths.fontFile), path.dirname(filePaths.bgImage) ].map(normalizePath)
99
115
  );
116
+ // Set of files that should exist in the mock structure (used by statSync mock)
100
117
  const mockFiles = new Set<string>(
101
- Object.keys(mockFileContents).concat( [filePaths.nonexistent, filePaths.unreadable].map(normalizePath) )
118
+ // Get all keys (paths) from mockFileContents
119
+ Object.keys(mockFileContents)
120
+ // Add paths for files that should exist but might cause read errors
121
+ .concat([filePaths.unreadable].map(normalizePath))
122
+ // Note: filePaths.nonexistent is *not* added here, so statSync will fail for it
102
123
  );
103
124
 
104
125
  // --- Helpers ---
126
+ // Helper to resolve URLs consistently within tests
105
127
  const resolveUrl = (relativePath: string, baseUrl: string): string => {
106
128
  try { return new URL(relativePath, baseUrl).href; }
107
- catch (e) { console.error(`Resolve URL error: ${relativePath} / ${baseUrl}`); return `ERROR_RESOLVING_${relativePath}`; }
129
+ catch (e) { console.error(`Resolve URL error in test helper: ${relativePath} / ${baseUrl}`); return `ERROR_RESOLVING_${relativePath}`; }
108
130
  };
109
131
 
132
+ // Type definition for expected asset structure in assertions
110
133
  type ExpectedAsset = { type: Asset['type']; url: string; content?: any; };
111
134
 
135
+ // Helper function to assert that the actual assets contain the expected assets
112
136
  function expectAssetsToContain(actualAssets: Asset[], expectedAssets: ExpectedAsset[]): void {
137
+ // Check if the number of found assets matches the expected number
113
138
  expect(actualAssets).toHaveLength(expectedAssets.length);
139
+ // Check each expected asset
114
140
  expectedAssets.forEach(expected => {
141
+ // Find the corresponding asset in the actual results by type and URL
115
142
  const found = actualAssets.find(asset => asset.type === expected.type && asset.url === expected.url);
143
+ // Assert that the asset was found
116
144
  expect(found).toBeDefined();
145
+ // If content is expected, assert that it matches (using toEqual for deep comparison if needed)
117
146
  if (found && expected.content !== undefined) {
118
147
  expect(found.content).toEqual(expected.content);
119
148
  }
120
149
  });
121
150
  }
122
151
 
152
+ // Interface for Node.js errors with a 'code' property
123
153
  interface NodeJSErrnoException extends Error { code?: string; }
154
+ // Interface to represent an Axios error structure for mocking
124
155
  interface MockAxiosError extends AxiosError { isAxiosError: true; }
125
156
 
126
157
 
127
158
  // ================ MOCK IMPLEMENTATIONS (Defined Globally) ================
128
159
 
129
- // Defined outside beforeEach so they can access constants like filePaths
160
+ // Mock implementation for fsPromises.readFile
130
161
  const readFileMockImplementation = async (
131
162
  filePathArg: PathLike | FileHandle,
132
- options?: BufferEncoding | (({ encoding?: null; flag?: OpenMode; } & AbortSignal)) | null
163
+ options?: BufferEncoding | (({ encoding?: null; flag?: OpenMode; } & AbortSignal)) | null // Match fsPromises.readFile signature
133
164
  ): Promise<Buffer | string> => {
134
165
  let normalizedPath: string = '';
135
166
  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'); }
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'); }
141
174
  } catch(e) { console.error("Error normalizing path in readFile mock:", filePathArg, e); throw e; }
142
175
 
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; }
176
+ // console.log(`[DEBUG mockReadFileFn] Requesting normalized path: "${normalizedPath}"`); // Optional debug
148
177
 
149
- if (path.basename(normalizedPath).startsWith('gen_')) { /* ... iteration logic ... */ }
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; }
150
182
 
183
+ // Retrieve mock content based on the normalized path
151
184
  const content = mockFileContents[normalizedPath];
152
185
  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
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);
156
189
  }
157
190
 
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;
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;
161
194
  };
162
195
 
196
+ // Mock implementation for fs.statSync
163
197
  const statSyncMockImplementation = (
164
198
  pathToCheck: PathLike,
165
- options?: StatSyncOptions & { bigint?: false; throwIfNoEntry?: boolean } | { bigint: true; throwIfNoEntry?: boolean }
199
+ options?: StatSyncOptions & { bigint?: false; throwIfNoEntry?: boolean } | { bigint: true; throwIfNoEntry?: boolean } // Match fs.statSync signature
166
200
  ): Stats | BigIntStats | undefined => {
167
- // FIX 7: Initialize normalizedPath
168
201
  let normalizedPath: string = '';
169
202
  try {
203
+ // Normalize the input path
170
204
  if (pathToCheck instanceof URL) { normalizedPath = normalizePath(fileURLToPath(pathToCheck)); }
171
205
  else if (typeof pathToCheck === 'string') { normalizedPath = normalizePath(pathToCheck.startsWith('file:') ? fileURLToPath(pathToCheck) : pathToCheck); }
172
206
  else if (Buffer.isBuffer(pathToCheck)) { normalizedPath = normalizePath(pathToCheck.toString()); }
173
207
  else { throw new Error(`Unsupported statSync input type in mock: ${typeof pathToCheck}`); }
174
208
  } catch(e) {
175
209
  console.error("Error normalizing path in statSync mock:", pathToCheck, e);
210
+ // Handle throwIfNoEntry option if normalization fails
176
211
  if (options?.throwIfNoEntry === false) { return undefined; }
177
- throw e;
212
+ throw e; // Re-throw normalization error if throwIfNoEntry is not false
178
213
  }
179
214
 
180
- // Helper to create mock Stats/BigIntStats object
215
+ // Helper to create a mock Stats or BigIntStats object
181
216
  const createStats = (isFile: boolean): Stats | BigIntStats => {
182
- // Base properties common to both or primarily for Stats (numbers)
217
+ // Base properties common to both Stats and BigIntStats
183
218
  const baseProps = {
184
- dev: 0, ino: 0, mode: 0, nlink: 1, uid: 0, gid: 0, rdev: 0,
219
+ dev: 0, ino: 0, mode: isFile ? 33188 : 16877, /* file vs dir mode */ nlink: 1, uid: 0, gid: 0, rdev: 0,
185
220
  blksize: 4096, blocks: 8,
186
221
  atimeMs: Date.now(), mtimeMs: Date.now(), ctimeMs: Date.now(), birthtimeMs: Date.now(),
187
222
  atime: new Date(), mtime: new Date(), ctime: new Date(), birthtime: new Date(),
188
223
  isFile: () => isFile, isDirectory: () => !isFile,
189
224
  isBlockDevice: () => false, isCharacterDevice: () => false,
190
225
  isSymbolicLink: () => false, isFIFO: () => false, isSocket: () => false,
226
+ // Calculate size based on mock content or default
191
227
  size: isFile ? (mockFileContents[normalizedPath]?.length ?? 100) : 4096
192
228
  };
193
229
 
230
+ // If bigint option is true, return a BigIntStats-compatible object
194
231
  if (options?.bigint) {
195
- // Construct the BigIntStats-compatible object
196
- // Include boolean methods, Date objects, and BigInt versions of numeric props
197
232
  return {
198
233
  isFile: baseProps.isFile, isDirectory: baseProps.isDirectory,
199
234
  isBlockDevice: baseProps.isBlockDevice, isCharacterDevice: baseProps.isCharacterDevice,
200
235
  isSymbolicLink: baseProps.isSymbolicLink, isFIFO: baseProps.isFIFO, isSocket: baseProps.isSocket,
201
236
  atime: baseProps.atime, mtime: baseProps.mtime, ctime: baseProps.ctime, birthtime: baseProps.birthtime,
202
- // Convert numbers to BigInt
203
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),
204
238
  blksize: BigInt(baseProps.blksize), blocks: BigInt(baseProps.blocks), size: BigInt(baseProps.size),
205
- // Use Ns suffix and BigInt for time
239
+ // Convert milliseconds to nanoseconds BigInt
206
240
  atimeNs: BigInt(Math.floor(baseProps.atimeMs * 1e6)),
207
241
  mtimeNs: BigInt(Math.floor(baseProps.mtimeMs * 1e6)),
208
242
  ctimeNs: BigInt(Math.floor(baseProps.ctimeMs * 1e6)),
209
243
  birthtimeNs: BigInt(Math.floor(baseProps.birthtimeMs * 1e6)),
210
- // ** OMIT number ms versions like atimeMs **
211
- } as BigIntStats; // Cast the carefully constructed object
244
+ } as BigIntStats; // Cast to satisfy the type
212
245
  }
213
- // Return the object compatible with standard Stats
246
+ // Otherwise, return a standard Stats-compatible object
214
247
  return baseProps as Stats;
215
248
  };
216
249
 
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);
224
- }
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
225
254
 
226
- // Path not found
227
- if (options?.throwIfNoEntry === false) { return undefined; }
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
228
258
  const error: NodeJSErrnoException = new Error(`ENOENT (Mock): statSync path not found: ${normalizedPath}`); error.code = 'ENOENT'; throw error;
229
259
  };
230
260
 
231
-
261
+ // Mock implementation for axios.get
232
262
  const axiosGetMockImplementation = async (
233
263
  url: string,
234
- config?: AxiosRequestConfig
235
- ): Promise<AxiosResponse<Buffer>> => {
236
- // **** DEBUG LOG ****
237
- console.log(`[DEBUG mockAxiosGet] Requesting URL: "${url}"`);
264
+ config?: AxiosRequestConfig // Match axios.get signature
265
+ ): Promise<AxiosResponse<Buffer>> => { // Return Buffer data
266
+ // console.log(`[DEBUG mockAxiosGet] Requesting URL: "${url}"`); // Optional debug
238
267
 
239
- const { AxiosHeaders } = axiosNs;
240
- let dataBuffer: Buffer; let contentType = 'text/plain'; let status = 200; let statusText = 'OK';
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
241
273
 
274
+ // Helper to create mock Axios request headers
242
275
  const getRequestHeaders = (reqConfig?: AxiosRequestConfig): AxiosRequestHeaders => {
243
- const headers = new AxiosHeaders();
244
- if (reqConfig?.headers) { for (const key in reqConfig.headers) { /* ... copy headers ... */ } }
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
+ }
285
+ }
245
286
  return headers;
246
287
  };
288
+ // Helper to create mock InternalAxiosRequestConfig
247
289
  const createInternalConfig = (reqConfig?: AxiosRequestConfig): InternalAxiosRequestConfig => {
248
290
  const requestHeaders = getRequestHeaders(reqConfig);
249
- return { url: url, method: 'get', ...(reqConfig || {}), headers: requestHeaders, };
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;
250
307
  };
251
308
 
252
- // Simulate errors
309
+ // Simulate errors based on URL content
253
310
  if (url.includes('error')) { status = 404; statusText = 'Not Found'; }
311
+ // Simulate timeout using status code 408 and setting error code later
254
312
  if (url.includes('timeout')) { status = 408; statusText = 'Request Timeout'; }
255
313
 
314
+ // If simulating an error status
256
315
  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;
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
264
341
  }
265
342
 
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
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'; }
268
345
  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
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
272
349
 
350
+ // Create mock response configuration and headers
273
351
  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: {} };
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)
363
+ };
279
364
  };
280
365
 
281
366
 
282
367
  // ================ TESTS ================
283
368
 
284
369
  describe('extractAssets', () => {
370
+ // Declare variables for logger and its spies
285
371
  let mockLogger: Logger;
286
372
  let mockLoggerWarnSpy: jest.SpiedFunction<typeof mockLogger.warn>;
287
373
  let mockLoggerErrorSpy: jest.SpiedFunction<typeof mockLogger.error>;
288
374
 
289
375
  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);
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
297
390
  mockLoggerWarnSpy = jest.spyOn(mockLogger, 'warn');
298
391
  mockLoggerErrorSpy = jest.spyOn(mockLogger, 'error');
299
392
 
300
393
  // --- Assign Mock Implementations ---
301
- // Use 'as any' as robust workaround for complex TS signature mismatches if needed
394
+ // Set the implementation for the mocked functions for this test run
395
+ // Use 'as any' to bypass strict type checking
302
396
  mockReadFile.mockImplementation(readFileMockImplementation as any);
303
397
  mockStatSync.mockImplementation(statSyncMockImplementation as any);
304
398
  mockAxiosGet.mockImplementation(axiosGetMockImplementation as any);
305
399
  });
306
400
 
307
401
  afterEach(() => {
402
+ // Clear mock calls and reset implementations between tests
308
403
  jest.clearAllMocks();
404
+ // Restore original implementations spied on with jest.spyOn (like the logger spies)
309
405
  jest.restoreAllMocks();
310
406
  });
311
407
 
312
408
  // ================ Test Cases ================
313
409
 
314
410
  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
- });
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
+ });
345
457
 
346
458
  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
- });
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
+ });
362
481
 
363
482
  it('should handle ENOENT errors when reading local files', async () => {
483
+ // HTML references a file that doesn't exist in the mock setup
364
484
  const parsed: ParsedHTML = { htmlContent: '<link href="nonexistent.file">', assets: [{ type: 'css', url: 'nonexistent.file' }] };
485
+ // Call extractor
365
486
  const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
487
+ // Expect the asset list to contain the entry, but with undefined content
366
488
  expect(result.assets).toHaveLength(1);
367
489
  expect(result.assets[0].content).toBeUndefined();
490
+ // Expect a warning log indicating the file was not found
368
491
  expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`File not found (ENOENT) for asset: ${normalizePath(filePaths.nonexistent)}`));
369
492
  });
370
493
 
371
494
  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
- });
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
+ });
379
509
 
380
510
  it('should handle HTTP errors when fetching remote assets', async () => {
381
511
  const remoteUrl = 'https://example.com/page.html';
512
+ // Resolve the URL that will trigger a 404 in the axios mock
382
513
  const errorCssUrl = resolveUrl('styles/error.css', remoteUrl);
514
+ // HTML referencing the error URL
383
515
  const parsed: ParsedHTML = { htmlContent: `<link href="${errorCssUrl}">`, assets: [{ type: 'css', url: errorCssUrl }] };
516
+ // Call extractor
384
517
  const result = await extractAssets(parsed, true, remoteUrl, mockLogger);
518
+ // Expect the asset with undefined content
385
519
  expect(result.assets).toHaveLength(1);
386
520
  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'));
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
+ );
389
524
  });
390
525
 
391
- it('should handle timeout errors when fetching remote assets', async () => {
526
+ it('should handle timeout errors when fetching remote assets', async () => {
392
527
  const remoteUrl = 'https://example.com/page.html';
528
+ // Resolve the URL that triggers a timeout (ECONNABORTED) in the axios mock
393
529
  const timeoutCssUrl = resolveUrl('styles/timeout.css', remoteUrl);
530
+ // HTML referencing the timeout URL
394
531
  const parsed: ParsedHTML = { htmlContent: `<link href="${timeoutCssUrl}">`, assets: [{ type: 'css', url: timeoutCssUrl }] };
532
+ // Call extractor
395
533
  const result = await extractAssets(parsed, true, remoteUrl, mockLogger);
534
+ // Expect the asset with undefined content
396
535
  expect(result.assets).toHaveLength(1);
397
536
  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'));
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
+ );
400
542
  });
401
543
 
402
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
403
546
  const parsed: ParsedHTML = { htmlContent: '<link href="invalid-utf8.css">', assets: [{ type: 'css', url: 'invalid-utf8.css' }] };
547
+ // Call extractor
404
548
  const result = await extractAssets(parsed, true, mockBaseFileUrl, mockLogger);
549
+ // Resolve the expected URL
405
550
  const expectedUrl = resolveUrl('invalid-utf8.css', mockBaseFileUrl);
551
+ // Expect one asset in the result
406
552
  expect(result.assets).toHaveLength(1);
553
+ // Expect the content to be a data URI containing the base64 representation of the original buffer
407
554
  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.`));
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);
410
561
  });
411
562
 
412
563
  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
- });
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
+ });
424
581
 
425
582
  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
- });
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
+ });
433
615
 
434
616
  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
- });
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
+ });
441
629
 
442
630
  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
- });
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
+ });
456
653
 
457
654
  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
- });
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
+ });
468
672
 
469
673
  }); // End describe block