portapack 0.2.1 → 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.
@@ -1,1129 +1,673 @@
1
1
  /**
2
- * @file extractor.test.ts
3
- * @description Unit tests for asset extraction logic (extractAssets function). Perseverance! 💪
4
- * @version 1.1.3 - Fixed TypeScript errors related to Jest matchers and variable names.
2
+ * @file src/core/extractor.test.ts
3
+ * @description Unit tests for asset extraction logic (extractAssets function)
5
4
  */
6
5
 
7
6
  // === Imports ===
7
+ import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
8
+ import path from 'path';
9
+ import { fileURLToPath, pathToFileURL, URL } from 'url';
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.
8
13
  import type { PathLike } from 'fs';
9
14
  import type { FileHandle } from 'fs/promises';
10
- import type { OpenMode, Stats, StatSyncOptions } from 'node:fs';
11
- import * as fs from 'fs';
12
- import path from 'path';
13
- import type { AxiosRequestConfig, AxiosResponse, AxiosError, InternalAxiosRequestConfig, AxiosRequestHeaders, AxiosHeaderValue } from 'axios';
14
- import * as axiosNs from 'axios';
15
- import { URL, fileURLToPath } from 'url';
16
- // Adjust path based on your project structure (e.g., src -> ../../..)
17
- import type { ParsedHTML, Asset } from '../../../src/types'; // Assuming types.ts is correct
18
- import { LogLevel } from '../../../src/types';
19
- import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; // Ensure expect is imported if needed explicitly
20
- // Adjust path based on your project structure
21
- import { Logger } from '../../../src/utils/logger';
22
-
23
- // =================== MOCK SETUP (Refined) ===================
24
-
25
- // --- Type Signatures for Mock Functions ---
26
- type AxiosGetSig = (url: string, config?: AxiosRequestConfig) => Promise<AxiosResponse<Buffer>>;
27
- type ReadFileBufferSig = ( path: PathLike | FileHandle, options?: { encoding?: null | undefined; flag?: OpenMode | undefined; signal?: AbortSignal | undefined; } | null | undefined ) => Promise<Buffer>;
28
- type StatSyncSig = (path: fs.PathLike, options?: StatSyncOptions | undefined) => fs.Stats;
29
-
30
- // --- Mock Functions ---
31
- const mockReadFileFn = jest.fn<ReadFileBufferSig>();
32
- const mockAxiosGetFn = jest.fn<AxiosGetSig>();
33
- const mockStatSyncFn = jest.fn<StatSyncSig>();
34
-
35
- // --- Mock Modules (BEFORE importing the code under test) ---
36
- jest.unstable_mockModule('fs/promises', () => ({ readFile: mockReadFileFn }));
37
- jest.unstable_mockModule('fs', () => ({
38
- statSync: mockStatSyncFn,
39
- // Spread other fs functions if needed, but ensure statSync is the mock
40
- ...Object.fromEntries(Object.entries(fs).filter(([key]) => key !== 'statSync')),
41
- }));
42
- jest.unstable_mockModule('axios', () => ({
43
- __esModule: true,
44
- default: { get: mockAxiosGetFn, isAxiosError: axiosNs.isAxiosError },
45
- isAxiosError: axiosNs.isAxiosError,
46
- AxiosHeaders: axiosNs.AxiosHeaders, // Ensure AxiosHeaders is exported correctly
47
- }));
48
-
49
-
50
- // --- Import Code Under Test (AFTER mocks are set up) ---
51
- const { extractAssets } = await import('../../../src/core/extractor');
52
-
53
- // --- Mock Refs (Convenience variables for the mocked functions) ---
54
- const mockedReadFile = mockReadFileFn;
55
- const mockedAxiosGet = mockAxiosGetFn;
56
- const mockedStatSync = mockStatSyncFn;
57
-
58
- // === Test Constants ===
15
+ import type { OpenMode, Stats, StatSyncOptions, BigIntStats } from 'node:fs'; // Use node: prefix
16
+ // Import types from the project
17
+ import type { Asset, ParsedHTML } from '../../../src/types'; // Adjust path as needed
18
+ import { LogLevel } from '../../../src/types'; // Adjust path as needed
19
+ import { Logger } from '../../../src/utils/logger'; // Adjust path as needed
20
+ // Import necessary axios types and the namespace
21
+ import type {
22
+ AxiosResponse,
23
+ AxiosRequestConfig,
24
+ AxiosError,
25
+ AxiosHeaderValue,
26
+ AxiosRequestHeaders,
27
+ AxiosResponseHeaders,
28
+ InternalAxiosRequestConfig
29
+ } from 'axios';
30
+ import * as axiosNs from 'axios'; // Namespace import
31
+ import { AxiosHeaders } from 'axios'; // Import AxiosHeaders class if used directly
32
+
33
+ // =================== MOCK SETUP ===================
34
+
35
+ // --- Apply Mocks (Using jest.mock at top level) ---
36
+ // Mock the entire 'fs/promises', 'fs', and 'axios' modules
37
+ jest.mock('fs/promises');
38
+ jest.mock('fs');
39
+ jest.mock('axios');
40
+
41
+ // --- Define Mock Function Variable Types ---
42
+ // Use jest.MockedFunction for type safety with mocked modules
43
+ type MockedReadFileFn = jest.MockedFunction<typeof fsPromises.readFile>;
44
+ type MockedStatSyncFn = jest.MockedFunction<typeof fs.statSync>;
45
+ type MockedAxiosGetFn = jest.MockedFunction<typeof axiosNs.default.get>;
46
+
47
+ // --- Declare Mock Function Variables (assigned in beforeEach) ---
48
+ // These will hold the mocked functions retrieved via jest.requireMock
49
+ let mockReadFile: MockedReadFileFn;
50
+ let mockStatSync: MockedStatSyncFn;
51
+ let mockAxiosGet: MockedAxiosGetFn;
52
+
53
+ // --- Import Module Under Test ---
54
+ // Import after mocks are defined
55
+ import { extractAssets } from '../../../src/core/extractor'; // Adjust path as needed
56
+
57
+ // ================ TEST SETUP (Constants & Mock Data - Defined Globally) ================
58
+
59
+ // Determine if running on Windows for path handling
59
60
  const isWindows = process.platform === 'win32';
60
- const mockBaseDir = path.resolve(isWindows ? 'C:\\mock\\base\\dir' : '/mock/base/dir');
61
- let tempMockBaseUrlFile = mockBaseDir.replace(/\\/g, '/');
62
- // Ensure correct file URL format
63
- if (isWindows && /^[A-Z]:\//i.test(tempMockBaseUrlFile)) {
64
- tempMockBaseUrlFile = 'file:///' + tempMockBaseUrlFile;
65
- } else if (!tempMockBaseUrlFile.startsWith('/')) {
66
- tempMockBaseUrlFile = '/' + tempMockBaseUrlFile; // Ensure leading slash for non-Windows absolute paths
67
- tempMockBaseUrlFile = 'file://' + tempMockBaseUrlFile;
68
- } else {
69
- tempMockBaseUrlFile = 'file://' + tempMockBaseUrlFile;
70
- }
71
- const mockBaseUrlFile = tempMockBaseUrlFile.endsWith('/') ? tempMockBaseUrlFile : tempMockBaseUrlFile + '/';
72
- const mockBaseUrlHttp = 'https://example.com/base/dir/';
73
-
74
-
75
- // --- Mock File Paths ---
76
- const styleCssPath = path.join(mockBaseDir, 'style.css');
77
- const scriptJsPath = path.join(mockBaseDir, 'script.js');
78
- const datauriCssPath = path.join(mockBaseDir, 'datauri.css');
79
- const deepCssPath = path.join(mockBaseDir, 'css', 'deep.css');
80
- const fontPath = path.join(mockBaseDir, 'font', 'relative-font.woff2');
81
- const bgImagePath = path.join(mockBaseDir, 'images', 'bg.png');
82
- const imagePath = path.join(mockBaseDir, 'image.png');
83
- const nestedImagePath = path.join(mockBaseDir, 'images', 'nested-img.png');
84
- const cycle1CssPath = path.join(mockBaseDir, 'cycle1.css');
85
- const cycle2CssPath = path.join(mockBaseDir, 'cycle2.css');
86
- const nonexistentPath = path.join(mockBaseDir, 'nonexistent.css');
87
- const unreadablePath = path.join(mockBaseDir, 'unreadable.css');
88
- const deepHtmlDirPath = path.join(mockBaseDir, 'pages', 'about');
89
- const unknownFilePath = path.join(mockBaseDir, 'file.other');
90
- const invalidUtf8CssPath = path.join(mockBaseDir, 'invalid-utf8.css');
91
-
92
- // === Test Helpers ===
93
-
94
- /** Helper to resolve file URLs */
95
- const getResolvedFileUrl = (relativePath: string): string => {
96
- try { return new URL(relativePath.replace(/\\/g, '/'), mockBaseUrlFile).href; }
97
- catch (e) { console.error(`TEST HELPER FAIL: getResolvedFileUrl failed for "<span class="math-inline">\{relativePath\}" with base "</span>{mockBaseUrlFile}": ${e}`); return `ERROR_RESOLVING_FILE_${relativePath}`; }
61
+ // Define a mock base directory path based on OS
62
+ const mockBaseDirPath = path.resolve(isWindows ? 'C:\\mock\\base\\dir' : '/mock/base/dir');
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
66
+ const mockBaseHttpUrl = 'https://example.com/base/dir/';
67
+
68
+ // Helper function to normalize paths for consistent comparisons
69
+ const normalizePath = (filePath: string): string => path.normalize(filePath);
70
+
71
+ // Define paths for various mock files used in tests
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
98
87
  };
99
- /** Helper to resolve http URLs */
100
- const getResolvedHttpUrl = (relativePath: string): string => {
101
- try { return new URL(relativePath, mockBaseUrlHttp).href; }
102
- catch (e) { console.error(`TEST HELPER FAIL: getResolvedHttpUrl failed for "<span class="math-inline">\{relativePath\}" with base "</span>{mockBaseUrlHttp}": ${e}`); return `ERROR_RESOLVING_HTTP_${relativePath}`; }
103
- };
104
- /** Helper to convert file URL to path */
105
- const getNormalizedPathFromFileUrl = (fileUrl: string): string => {
106
- try { return path.normalize(fileURLToPath(fileUrl)); }
107
- catch (e) { console.error(`TEST HELPER FAIL: getNormalizedPathFromFileUrl failed for "${fileUrl}": ${e}`); return `ERROR_NORMALIZING_${fileUrl}`; }
108
- };
109
-
110
88
 
111
- // --- Define ExpectedAsset Type ---
112
- // This allows using Jest matchers for the 'content' property in tests without TS errors
113
- type ExpectedAsset = Omit<Partial<Asset>, 'content'> & {
114
- url: string;
115
- content?: any; // Using 'any' to allow Jest matchers like expect.stringContaining()
89
+ // --- Mock Data ---
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)
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
116
109
  };
117
110
 
111
+ // --- Mock Directory/File Structure ---
112
+ // Set of directories that should exist in the mock structure
113
+ const mockDirs = new Set<string>(
114
+ [ mockBaseDirPath, path.dirname(filePaths.deepCss), path.dirname(filePaths.fontFile), path.dirname(filePaths.bgImage) ].map(normalizePath)
115
+ );
116
+ // Set of files that should exist in the mock structure (used by statSync mock)
117
+ const mockFiles = new Set<string>(
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
123
+ );
124
+
125
+ // --- Helpers ---
126
+ // Helper to resolve URLs consistently within tests
127
+ 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}`; }
130
+ };
118
131
 
119
- /*
120
- * Custom Jest matcher helper with improved file URL matching.
121
- * Checks if actualAssets matches expectedAssets based on URL and other properties.
122
- * Uses flexible matching strategies for file URLs.
123
- * @param {Asset[]} actualAssets - The array returned by the function under test.
124
- * @param {ExpectedAsset[]} expectedAssets - Array of expected asset objects (URL required). Uses ExpectedAsset type.
125
- */
126
- const expectAssetsToContain = (actualAssets: Asset[], expectedAssets: ExpectedAsset[]) => { // Use ExpectedAsset[] for the parameter
127
- // Log the actual assets for debugging
128
- console.log(`DEBUG: Actual Assets (${actualAssets.length}):`);
129
- actualAssets.forEach((asset, i) => console.log(` [${i}] <span class="math-inline">\{asset\.url\} \(</span>{asset.type}) ${asset.content ? `(${typeof asset.content}, ${asset.content.length} chars)` : '(no content)'}`));
130
-
131
- console.log(`DEBUG: Expected Assets (${expectedAssets.length}):`); // Corrected variable name here
132
- expectedAssets.forEach((asset, i) => console.log(` [${i}] <span class="math-inline">\{asset\.url\} \(</span>{asset.type}) ${asset.content ? `(${typeof asset.content})` : '(no content)'}`)); // Corrected variable name here
133
-
134
- const actualUrls = actualAssets.map(a => a.url);
135
-
136
- expectedAssets.forEach(expected => { // Corrected variable name here
137
- // Improved flexible matching for file URLs
138
- let actualAsset: Asset | undefined;
139
-
140
- if (expected.url.startsWith('file:')) {
141
- // Strategy 1: Match by normalized file path
142
- let expectedPath: string;
143
- try {
144
- expectedPath = fileURLToPath(expected.url);
145
- expectedPath = path.normalize(expectedPath);
146
- } catch (e) {
147
- // Fallback if URL parsing fails (e.g., invalid characters)
148
- console.warn(`[Test Helper Warning] Could not normalize expected file URL: ${expected.url}`);
149
- expectedPath = expected.url; // Use original string for comparison
150
- }
151
-
152
- actualAsset = actualAssets.find(a => {
153
- if (a.type !== expected.type) return false; // Check type first
154
-
155
- let actualPath: string;
156
- try {
157
- if (a.url.startsWith('file:')) {
158
- actualPath = fileURLToPath(a.url);
159
- actualPath = path.normalize(actualPath);
160
- return actualPath === expectedPath;
161
- }
162
- } catch (e) {
163
- // If actual URL parsing fails, log and continue (won't match)
164
- console.warn(`[Test Helper Warning] Could not normalize actual file URL: ${a.url}`);
165
- }
166
- // If not a file URL or parsing failed, it won't match a file: expected path
167
- return false;
168
- });
169
-
170
- // Strategy 2: Match by filename and type (if path match failed)
171
- if (!actualAsset) {
172
- const expectedFileName = expected.url.split('/').pop();
173
- actualAsset = actualAssets.find(a =>
174
- a.type === expected.type &&
175
- a.url.split('/').pop() === expectedFileName
176
- );
177
- if (actualAsset) console.log(`DEBUG: Matched ${expected.url} via filename strategy.`);
178
- }
179
-
180
- // Strategy 3: Match by path fragment (if filename match failed)
181
- if (!actualAsset) {
182
- const expectedPathFragment = expected.url.split('/').slice(-2).join('/');
183
- actualAsset = actualAssets.find(a =>
184
- a.type === expected.type &&
185
- a.url.includes(expectedPathFragment)
186
- );
187
- if (actualAsset) console.log(`DEBUG: Matched ${expected.url} via path fragment strategy.`);
188
- }
189
- } else {
190
- // For non-file URLs, use exact matching (or consider case-insensitivity if needed)
191
- actualAsset = actualAssets.find(a => a.url === expected.url && a.type === expected.type);
192
- }
193
-
194
- // Debug logging for asset not found
195
- if (!actualAsset) {
196
- console.error(`\n`);
197
- console.error(`!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`);
198
- console.error(`[Test Failure Debug] Asset not found in actual results!`);
199
- console.error(` => Expected URL: ${expected.url}`);
200
- console.error(` => Expected Type: ${expected.type ?? '(any)'}`);
201
- console.error(` => Actual Assets Received (${actualAssets.length}):`);
202
- actualAssets.forEach((a, i) => console.error(` [${i}]: <span class="math-inline">\{a\.url\} \(</span>{a.type})`));
203
- console.error(`!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`);
204
- console.error(`\n`);
205
- }
206
-
207
- expect(actualAsset).toBeDefined(); // Assert that the asset was found
208
-
209
- if (!actualAsset) return; // Skip further checks if asset wasn't found
210
-
211
- // Always check type (already done in find, but good practice)
212
- expect(actualAsset.type).toBe(expected.type);
213
-
214
- // Check content if specified in the expected asset
215
- if (Object.prototype.hasOwnProperty.call(expected, 'content')) {
216
- const { content: expectedContent } = expected;
217
-
218
- // Check if the expected content is a Jest asymmetric matcher
219
- const isAsymmetricMatcher = typeof expectedContent === 'object' &&
220
- expectedContent !== null &&
221
- typeof (expectedContent as any).asymmetricMatch === 'function';
222
-
223
- if (isAsymmetricMatcher) {
224
- // Use toEqual for asymmetric matchers
225
- expect(actualAsset.content).toEqual(expectedContent);
226
- } else {
227
- // Use toBe for exact value comparison (including undefined)
228
- expect(actualAsset.content).toBe(expectedContent);
229
- }
132
+ // Type definition for expected asset structure in assertions
133
+ type ExpectedAsset = { type: Asset['type']; url: string; content?: any; };
134
+
135
+ // Helper function to assert that the actual assets contain the expected assets
136
+ 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);
230
148
  }
231
149
  });
232
- };
233
- // =================== THE TESTS! ===================
234
- describe('🔍 extractAssets() - Round 8! FIGHT!', () => { // Incremented round for fun
235
-
236
- let mockLogger: Logger;
237
- let mockLoggerWarnSpy: jest.SpiedFunction<typeof mockLogger.warn>;
238
- let mockLoggerErrorSpy: jest.SpiedFunction<typeof mockLogger.error>;
239
- let mockLoggerDebugSpy: jest.SpiedFunction<typeof mockLogger.debug>;
240
- let mockLoggerInfoSpy: jest.SpiedFunction<typeof mockLogger.info>;
241
-
242
- // Example buffer for invalid UTF-8 data
243
- const invalidUtf8Buffer = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x80, 0x6f]); // "Hell\x80o"
244
-
245
- /** Sets up default mock implementations with improved path handling */
246
- const setupDefaultMocks = () => {
247
- // --- Mock fs.readFile ---
248
- mockedReadFile.mockImplementation(async (fileUrlOrPath): Promise<Buffer> => {
249
- let filePath: string = '';
250
-
251
- // Normalize the incoming path or URL to a consistent format
252
- try {
253
- if (typeof fileUrlOrPath === 'string') {
254
- filePath = fileUrlOrPath.startsWith('file:') ? fileURLToPath(fileUrlOrPath) : fileUrlOrPath;
255
- } else if (fileUrlOrPath instanceof URL && fileUrlOrPath.protocol === 'file:') {
256
- filePath = fileURLToPath(fileUrlOrPath);
257
- } else if (typeof (fileUrlOrPath as any)?.path === 'string') { // Handle FileHandle-like objects if used
258
- filePath = (fileUrlOrPath as any).path;
259
- } else {
260
- // Handle Buffer input if fs.readFile is called with a Buffer path (less common)
261
- if (Buffer.isBuffer(fileUrlOrPath)) {
262
- filePath = fileUrlOrPath.toString(); // Assume UTF-8 path string
263
- if (filePath.startsWith('file:')) {
264
- filePath = fileURLToPath(filePath);
265
- }
266
- } else {
267
- throw new Error(`[Mock readFile] Unsupported input type: ${typeof fileUrlOrPath}`);
268
- }
269
- }
270
- } catch (e: any) {
271
- console.error(`[Mock readFile Error] Failed to convert input to path: ${String(fileUrlOrPath)}`, e);
272
- const err = new Error(`[Mock readFile Error] Failed to convert input to path: ${e.message}`) as NodeJS.ErrnoException;
273
- err.code = 'EINVAL'; // Indicate invalid argument
274
- throw err;
275
- }
276
-
150
+ }
277
151
 
278
- if (!filePath) {
279
- console.error("[Mock readFile Error] Could not determine file path from input:", fileUrlOrPath);
280
- const err = new Error('Invalid file path provided to mock readFile') as NodeJS.ErrnoException;
281
- err.code = 'EINVAL';
282
- throw err;
283
- }
152
+ // Interface for Node.js errors with a 'code' property
153
+ interface NodeJSErrnoException extends Error { code?: string; }
154
+ // Interface to represent an Axios error structure for mocking
155
+ interface MockAxiosError extends AxiosError { isAxiosError: true; }
156
+
157
+
158
+ // ================ MOCK IMPLEMENTATIONS (Defined Globally) ================
159
+
160
+ // Mock implementation for fsPromises.readFile
161
+ const readFileMockImplementation = async (
162
+ filePathArg: PathLike | FileHandle,
163
+ options?: BufferEncoding | (({ encoding?: null; flag?: OpenMode; } & AbortSignal)) | null // Match fsPromises.readFile signature
164
+ ): 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);
189
+ }
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
+ };
284
195
 
285
- // Normalize path for consistent comparison
286
- const normalizedPath = path.normalize(filePath);
196
+ // Mock implementation for fs.statSync
197
+ const statSyncMockImplementation = (
198
+ pathToCheck: PathLike,
199
+ options?: StatSyncOptions & { bigint?: false; throwIfNoEntry?: boolean } | { bigint: true; throwIfNoEntry?: boolean } // Match fs.statSync signature
200
+ ): 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
+ };
287
229
 
288
- // Define the content map with proper mock content for all expected files
289
- const contentMap: Record<string, string | Buffer> = {
290
- [path.normalize(styleCssPath)]: `@import url("./css/deep.css");
291
- body {
292
- background: url("images/bg.png");
293
- font-family: "CustomFont", sans-serif;
294
- /* Example of font definition */
295
- @font-face {
296
- font-family: 'MyWebFont';
297
- src: url('font/relative-font.woff2') format('woff2');
298
- font-weight: 600;
299
- font-style: normal;
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
300
245
  }
301
- }`, // Added @font-face example
302
- [path.normalize(scriptJsPath)]: `console.log("mock script");`,
303
- [path.normalize(datauriCssPath)]: `body {
304
- background: url("image.png");
305
- background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==');
306
- }`,
307
- [path.normalize(deepCssPath)]: `h1 {
308
- background: url("../images/nested-img.png"); /* Relative path from deep.css */
309
- color: blue;
310
- }`,
311
- [path.normalize(fontPath)]: Buffer.from('mock-woff2-font-data-for-mywebfont'), // Make font data unique if needed
312
- [path.normalize(bgImagePath)]: Buffer.from('mock-png-bg-data-abcdef'), // Make image data unique
313
- [path.normalize(imagePath)]: Buffer.from('mock-png-data-image-12345'), // Make image data unique
314
- [path.normalize(nestedImagePath)]: Buffer.from('mock-png-nested-img-data-xyz'), // Make image data unique
315
- [path.normalize(cycle1CssPath)]: `@import url("cycle2.css");`,
316
- [path.normalize(cycle2CssPath)]: `@import url("cycle1.css");`,
317
- [path.normalize(invalidUtf8CssPath)]: invalidUtf8Buffer,
318
- [path.normalize(unknownFilePath)]: invalidUtf8Buffer, // For the 'other' type test
319
- };
320
-
321
- // Specific error cases
322
- if (normalizedPath === path.normalize(nonexistentPath)) {
323
- const err = new Error(`ENOENT: no such file or directory, open '${normalizedPath}'`) as NodeJS.ErrnoException;
324
- err.code = 'ENOENT';
325
- throw err;
326
- }
327
-
328
- if (normalizedPath === path.normalize(unreadablePath)) {
329
- const err = new Error(`EACCES: permission denied, open '${normalizedPath}'`) as NodeJS.ErrnoException;
330
- err.code = 'EACCES';
331
- throw err;
332
- }
333
-
334
- // Loop detection for the "iteration limit" test
335
- if (normalizedPath.includes('generated_')) {
336
- const match = normalizedPath.match(/generated_(\d+)\.css$/);
337
- const counter = match ? parseInt(match[1], 10) : 0;
338
- if (counter >= 1005) { // Prevent infinite loop in mock itself
339
- console.warn(`[Mock readFile Warning] Stopping generation for limit test at ${counter}`);
340
- return Buffer.from(`/* Limit Reached in Mock */`);
341
- }
342
- const nextUniqueRelativeUrl = `generated_${counter + 1}.css`;
343
- return Buffer.from(`@import url("${nextUniqueRelativeUrl}"); /* Cycle ${normalizedPath} */`);
344
- }
345
-
346
- // Return the mapped content or a fallback/error
347
- const content = contentMap[normalizedPath];
348
- if (content !== undefined) {
349
- return Buffer.isBuffer(content) ? content : Buffer.from(content);
350
- } else {
351
- // If the file wasn't explicitly mapped, treat it as non-existent for tests
352
- console.warn(`[Test Mock Warning] fs.readFile mock throwing ENOENT for unmapped path: ${normalizedPath}`);
353
- const err = new Error(`ENOENT: no such file or directory, open '${normalizedPath}' (unmapped in test mock)`) as NodeJS.ErrnoException;
354
- err.code = 'ENOENT';
355
- throw err;
356
- // Alternatively, return default content if some tests expect reads for other files:
357
- // console.warn(`[Test Mock Warning] fs.readFile mock returning default content for unexpected path: ${normalizedPath}`);
358
- // return Buffer.from(`/* Default Mock Content for: ${normalizedPath} */`);
359
- }
360
- });
361
-
362
- // --- Mock axios.get ---
363
- mockedAxiosGet.mockImplementation(async (url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<Buffer>> => {
364
- const { AxiosHeaders } = axiosNs; // Ensure AxiosHeaders is accessible
365
- let dataBuffer = Buffer.from(`/* Mock HTTP Response: ${url} */`);
366
- let contentType = 'text/plain';
367
- let status = 200;
368
- let statusText = 'OK';
369
- const responseHeaders = new AxiosHeaders();
246
+ // Otherwise, return a standard Stats-compatible object
247
+ return baseProps as Stats;
248
+ };
370
249
 
371
- // Helper to safely create header record for config
372
- const createSafeHeaderRecord = (h: any): Record<string, AxiosHeaderValue> => {
373
- const hr: Record<string, AxiosHeaderValue> = {};
374
- if (h) {
375
- for (const k in h) {
376
- if (Object.prototype.hasOwnProperty.call(h, k)) {
377
- const v = h[k];
378
- // Ensure header values are primitives or arrays of primitives
379
- if (v !== undefined && v !== null && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || Array.isArray(v))) {
380
- hr[k] = v as AxiosHeaderValue;
381
- } else if (v !== undefined && v !== null) {
382
- console.warn(`[Mock Axios Header Warning] Skipping non-primitive header value for key "${k}":`, v);
383
- }
384
- }
385
- }
386
- }
387
- return hr;
388
- };
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
389
254
 
390
- const requestConfigHeaders = new AxiosHeaders(createSafeHeaderRecord(config?.headers));
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;
259
+ };
391
260
 
392
- // Simulate different responses based on URL
393
- if (url.includes('fail.net') || url.includes('timeout.net')) {
394
- status = url.includes('fail.net') ? 500 : 408;
395
- statusText = url.includes('fail.net') ? 'Internal Server Error' : 'Request Timeout';
396
- const e = new Error(`Mock ${status} for ${url}`) as AxiosError<Buffer>;
397
- const cfg: InternalAxiosRequestConfig = {
398
- ...(config ?? {}),
399
- headers: requestConfigHeaders, // Use the created AxiosHeaders object
400
- url: url,
401
- method: 'get'
402
- };
403
- e.config = cfg;
404
- e.response = {
405
- data: Buffer.from(statusText),
406
- status,
407
- statusText,
408
- headers: new AxiosHeaders(), // Use AxiosHeaders for response too
409
- config: cfg
410
- };
411
- e.request = {}; // Mock request object
412
- e.isAxiosError = true; // Crucial for axios error handling
413
- if (url.includes('timeout.net')) {
414
- e.code = 'ECONNABORTED'; // Simulate timeout code
415
- e.message = `timeout of ${config?.timeout ?? 10000}ms exceeded`; // Simulate timeout message
261
+ // Mock implementation for axios.get
262
+ 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);
416
283
  }
417
- throw e; // Throw the mocked Axios error
418
- } else if (url.includes('style.css')) { // e.g., https://example.com/styles/style.css
419
- contentType = 'text/css';
420
- dataBuffer = Buffer.from(`body { background: url("/img/remote-bg.jpg?v=1"); color: red; } /* Remote CSS */`);
421
- } else if (url.includes('script.js')) { // e.g., https://okay.net/script.js
422
- contentType = 'application/javascript';
423
- dataBuffer = Buffer.from(`console.log('remote script');`);
424
- } else if (url.includes('logo.png')) { // e.g., https://example.com/images/logo.png
425
- contentType = 'image/png';
426
- dataBuffer = Buffer.from('mock-remote-png-logo-data-abc'); // Unique data
427
- } else if (url.includes('remote-bg.jpg')) { // e.g., https://example.com/img/remote-bg.jpg?v=1
428
- contentType = 'image/jpeg';
429
- dataBuffer = Buffer.from('mock-remote-jpg-bg-data-def'); // Unique data
430
284
  }
431
- // Add more cases as needed for other remote URLs in tests
432
-
433
- responseHeaders.set('content-type', contentType);
434
- const responseConfig: InternalAxiosRequestConfig = {
435
- ...(config ?? {}),
436
- headers: requestConfigHeaders, // Use the created AxiosHeaders object
437
- url: url,
438
- method: 'get'
439
- };
285
+ }
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
+ };
440
308
 
441
- const mockResponse: AxiosResponse<Buffer> = {
442
- data: dataBuffer,
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
443
330
  status,
444
331
  statusText,
445
- headers: responseHeaders, // Use AxiosHeaders instance
446
- config: responseConfig, // Use the internal config type
447
- request: {} // Mock request object
448
- };
449
-
450
- return Promise.resolve(mockResponse);
451
- });
452
-
453
- // --- Mock fs.statSync --- (improved path handling)
454
- mockedStatSync.mockImplementation((p: fs.PathLike, options?: StatSyncOptions | undefined): fs.Stats => {
455
- let mockPath: string;
456
- try {
457
- // Handle URL objects, strings, and Buffers robustly
458
- if (p instanceof URL) {
459
- mockPath = fileURLToPath(p);
460
- } else if (typeof p === 'string') {
461
- // If it's already a file URL, convert it
462
- mockPath = p.startsWith('file:') ? fileURLToPath(p) : p;
463
- } else if (Buffer.isBuffer(p)) {
464
- mockPath = p.toString(); // Assume UTF-8 path
465
- if (mockPath.startsWith('file:')) {
466
- mockPath = fileURLToPath(mockPath);
467
- }
468
- }
469
- else {
470
- throw new Error(`Unsupported path type: ${typeof p}`);
471
- }
472
- mockPath = path.normalize(mockPath); // Normalize after determining the path string
473
- } catch (e: any) {
474
- console.error(`[Mock statSync Error] Failed to convert path: ${String(p)}`, e);
475
- const err = new Error(`ENOENT: invalid path for stat, stat '${String(p)}'. ${e.message}`) as NodeJS.ErrnoException;
476
- err.code = 'ENOENT'; // Or potentially 'EINVAL' depending on error
477
- if (options?.throwIfNoEntry === false) return undefined as unknown as fs.Stats; // Handle option
478
- throw err;
479
- }
480
-
481
- // Define known directories and files using normalized paths
482
- const dirPaths = new Set([
483
- mockBaseDir,
484
- path.join(mockBaseDir, 'css'),
485
- path.join(mockBaseDir, 'font'),
486
- path.join(mockBaseDir, 'images'),
487
- deepHtmlDirPath // directory containing the deep HTML file
488
- ].map(d => path.normalize(d)));
489
-
490
- const filePaths = new Set([
491
- styleCssPath, scriptJsPath, datauriCssPath, deepCssPath,
492
- fontPath, bgImagePath, imagePath, nestedImagePath,
493
- cycle1CssPath, cycle2CssPath, nonexistentPath, unreadablePath,
494
- unknownFilePath, invalidUtf8CssPath
495
- ].map(f => path.normalize(f)));
496
-
497
- // Handle dynamically generated files for the limit test
498
- if (mockPath.includes('generated_')) {
499
- // Assume these are files for the purpose of the test
500
- return { isDirectory: () => false, isFile: () => true } as fs.Stats;
501
- }
502
-
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
341
+ }
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)
363
+ };
364
+ };
503
365
 
504
- if (dirPaths.has(mockPath)) {
505
- return { isDirectory: () => true, isFile: () => false } as fs.Stats;
506
- }
507
366
 
508
- if (filePaths.has(mockPath)) {
509
- // For the nonexistentPath, statSync should throw ENOENT *unless* throwIfNoEntry is false
510
- if (mockPath === path.normalize(nonexistentPath) && options?.throwIfNoEntry !== false) {
511
- const err = new Error(`ENOENT: no such file or directory, stat '${mockPath}'`) as NodeJS.ErrnoException;
512
- err.code = 'ENOENT';
513
- throw err;
514
- }
515
- // For all other known files (including nonexistent if throwIfNoEntry is false), return file stats
516
- return { isDirectory: () => false, isFile: () => true } as fs.Stats;
517
- }
518
-
519
- // If path is not recognized, throw ENOENT unless throwIfNoEntry is false
520
- if (options?.throwIfNoEntry === false) {
521
- return undefined as unknown as fs.Stats;
522
- } else {
523
- console.warn(`[Test Mock Warning] fs.statSync mock throwing ENOENT for unrecognized path: ${mockPath}`);
524
- const err = new Error(`ENOENT: no such file or directory, stat '${mockPath}' (unmapped in test mock)`) as NodeJS.ErrnoException;
525
- err.code = 'ENOENT';
526
- throw err;
527
- }
528
- });
529
- };
367
+ // ================ TESTS ================
530
368
 
369
+ 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>;
531
374
 
532
375
  beforeEach(() => {
533
- // Use desired log level for testing
534
- mockLogger = new Logger(LogLevel.WARN); // Use DEBUG to see more logs during test runs
535
-
536
- // Spy on logger methods
537
- mockLoggerDebugSpy = jest.spyOn(mockLogger, 'debug');
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
538
390
  mockLoggerWarnSpy = jest.spyOn(mockLogger, 'warn');
539
391
  mockLoggerErrorSpy = jest.spyOn(mockLogger, 'error');
540
- mockLoggerInfoSpy = jest.spyOn(mockLogger, 'info');
541
392
 
542
- // Clear mocks and setup defaults before each test
543
- mockReadFileFn.mockClear();
544
- mockAxiosGetFn.mockClear();
545
- mockedStatSync.mockClear();
546
- setupDefaultMocks();
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);
547
399
  });
548
400
 
549
401
  afterEach(() => {
550
- jest.restoreAllMocks(); // Restore original implementations
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();
551
406
  });
552
407
 
408
+ // ================ Test Cases ================
553
409
 
554
- // === Core Functionality Tests ===
555
-
556
- // it('✅ embeds content when embedAssets = true', async () => {
557
- // const parsed: ParsedHTML = { htmlContent: `<link href="style.css"><script src="script.js"></script>`, assets: [ { type: 'css', url: 'style.css' }, { type: 'js', url: 'script.js' } ] };
558
- // const result = await extractAssets(parsed, true, mockBaseDir, mockLogger);
559
-
560
- // // Use ExpectedAsset[] for the expected array
561
- // const assets: ExpectedAsset[] = [
562
- // { url: getResolvedFileUrl('style.css'), type: 'css', content: expect.stringContaining('@import') },
563
- // { url: getResolvedFileUrl('script.js'), type: 'js', content: 'console.log("mock script");' },
564
- // { url: getResolvedFileUrl('css/deep.css'), type: 'css', content: expect.stringContaining('../images/nested-img.png') }, // Asset from @import
565
- // { url: getResolvedFileUrl('font/relative-font.woff2'), type: 'font', content: expect.stringMatching(/^data:font\/woff2;base64,/) }, // Asset from url() in style.css -> @font-face
566
- // { url: getResolvedFileUrl('images/bg.png'), type: 'image', content: expect.stringMatching(/^data:image\/png;base64,/) }, // Asset from url() in style.css
567
- // { url: getResolvedFileUrl('images/nested-img.png'), type: 'image', content: expect.stringMatching(/^data:image\/png;base64,/) }, // Asset from url() in deep.css
568
- // ];
569
- // const sortedExpected = [...assets].sort((a, b) => a.url.localeCompare(b.url));
570
- // const sortedActual = [...result.assets].sort((a, b) => a.url.localeCompare(b.url));
571
-
572
- // expectAssetsToContain(sortedActual, sortedExpected);
573
- // // Expect reads for: style.css, deep.css, bg.png, relative-font.woff2, nested-img.png, script.js
574
- // expect(mockedReadFile).toHaveBeenCalledTimes(6);
575
- // expect(mockedStatSync).toHaveBeenCalledWith(mockBaseDir); // Initial base dir check
576
- // // Optionally check statSync for specific files/dirs if needed
577
- // });
578
-
579
- // it('🚫 skips embedding but discovers nested when embedAssets = false', async () => {
580
- // const parsed: ParsedHTML = { htmlContent: `<link href="style.css">`, assets: [{ type: 'css', url: 'style.css' }] };
581
- // const result = await extractAssets(parsed, false, mockBaseDir, mockLogger); // embedAssets = false
582
-
583
- // // Only style.css should be read to find nested assets
584
- // expect(mockedReadFile).toHaveBeenCalledTimes(1);
585
- // expect(mockedReadFile).toHaveBeenCalledWith(expect.stringContaining(styleCssPath)); // Or use normalized path check
586
- // expect(mockedStatSync).toHaveBeenCalledWith(mockBaseDir);
587
-
588
- // // Expected assets: initial CSS + discovered nested ones, all with undefined content
589
- // const assets: ExpectedAsset[] = [
590
- // { url: getResolvedFileUrl('style.css'), type: 'css', content: undefined },
591
- // { url: getResolvedFileUrl('css/deep.css'), type: 'css', content: undefined }, // Discovered via @import
592
- // { url: getResolvedFileUrl('font/relative-font.woff2'), type: 'font', content: undefined }, // Discovered via url()
593
- // { url: getResolvedFileUrl('images/bg.png'), type: 'image', content: undefined }, // Discovered via url()
594
- // { url: getResolvedFileUrl('images/nested-img.png'), type: 'image', content: undefined } // Discovered via url() in deep.css
595
- // ];
596
- // const sortedExpected = [...assets].sort((a, b) => a.url.localeCompare(b.url));
597
- // const sortedActual = [...result.assets].sort((a, b) => a.url.localeCompare(b.url));
598
-
599
- // expectAssetsToContain(sortedActual, sortedExpected);
600
- // expect(result.assets.every(a => a.content === undefined)).toBe(true); // Verify no content was embedded
601
- // });
602
-
603
-
604
- it('🧩 discovers assets only from initial parsed list if no nesting and embedAssets = false', async () => {
605
- // Override readFile mock for this specific test to return CSS without nesting
606
- mockedReadFile.mockImplementation(async (p): Promise<Buffer> => {
607
- let filePath = '';
608
- if (p instanceof URL) filePath = fileURLToPath(p);
609
- else if (typeof p === 'string') filePath = p.startsWith('file:') ? fileURLToPath(p) : p;
610
- else filePath = Buffer.isBuffer(p) ? p.toString() : String(p); // Handle Buffer/other cases simply
611
-
612
- const normalizedPath = path.normalize(filePath);
613
-
614
- if (normalizedPath === path.normalize(styleCssPath)) {
615
- return Buffer.from('body { color: blue; } /* No nested URLs */');
616
- }
617
- if (normalizedPath === path.normalize(imagePath)) {
618
- // This read shouldn't happen if embedAssets is false
619
- console.warn("UNEXPECTED READ for imagePath in 'no nesting / embed false' test");
620
- return Buffer.from('mock-png-data-image-should-not-be-read');
621
- }
622
- // If any other path is requested, throw ENOENT
623
- const err = new Error(`ENOENT: Unexpected read in test: ${normalizedPath}`) as NodeJS.ErrnoException;
624
- err.code = 'ENOENT';
625
- throw err;
626
- });
627
-
628
- const parsed: ParsedHTML = { htmlContent: `<link href="style.css"><img src="image.png">`, assets: [ { type: 'css', url: 'style.css' }, { type: 'image', url: 'image.png' } ] };
629
- const result = await extractAssets(parsed, false, mockBaseDir, mockLogger); // embedAssets = false
630
-
631
- const assets: ExpectedAsset[] = [
632
- { url: getResolvedFileUrl('style.css'), type: 'css', content: undefined },
633
- { url: getResolvedFileUrl('image.png'), type: 'image', content: undefined }, // Initial asset, not embedded
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,/) }
634
432
  ];
635
- const sortedExpected = [...assets].sort((a, b) => a.url.localeCompare(b.url));
636
- const sortedActual = [...result.assets].sort((a, b) => a.url.localeCompare(b.url));
637
-
638
- expectAssetsToContain(sortedActual, sortedExpected);
639
- expect(mockedReadFile).toHaveBeenCalledTimes(1); // Only the CSS file should be read to check for nesting
640
- expect(mockedReadFile).toHaveBeenCalledWith(expect.stringContaining(styleCssPath)); // Verify the correct file was read
641
- expect(mockedStatSync).toHaveBeenCalledWith(mockBaseDir); // Base dir check
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
642
437
  });
643
438
 
644
-
645
- // it('📦 extracts nested CSS url() and @import assets recursively with embedding', async () => {
646
- // // This test is similar to the first one, just focusing on nesting.
647
- // const parsed: ParsedHTML = { htmlContent: `<link href="style.css">`, assets: [{ type: 'css', url: 'style.css' }] };
648
- // const result = await extractAssets(parsed, true, mockBaseDir, mockLogger); // embed = true
649
-
650
- // const assets: ExpectedAsset[] = [
651
- // { url: getResolvedFileUrl('style.css'), type: 'css', content: expect.stringContaining('@import') }, // Contains the import and url()s
652
- // { url: getResolvedFileUrl('css/deep.css'), type: 'css', content: expect.stringContaining('../images/nested-img.png') }, // Nested CSS content
653
- // { url: getResolvedFileUrl('font/relative-font.woff2'), type: 'font', content: expect.stringMatching(/^data:font\/woff2;base64,/) }, // Nested font
654
- // { url: getResolvedFileUrl('images/bg.png'), type: 'image', content: expect.stringMatching(/^data:image\/png;base64,/) }, // Nested image from style.css
655
- // { url: getResolvedFileUrl('images/nested-img.png'), type: 'image', content: expect.stringMatching(/^data:image\/png;base64,/) } // Nested image from deep.css
656
- // ];
657
- // const sortedExpected = [...assets].sort((a, b) => a.url.localeCompare(b.url));
658
- // const sortedActual = [...result.assets].sort((a, b) => a.url.localeCompare(b.url));
659
-
660
- // expectAssetsToContain(sortedActual, sortedExpected);
661
- // // Expect reads for: style.css, deep.css, bg.png, relative-font.woff2, nested-img.png
662
- // expect(mockedReadFile).toHaveBeenCalledTimes(5);
663
- // expect(mockedStatSync).toHaveBeenCalledWith(mockBaseDir);
664
- // });
665
-
666
-
667
- // it('📍 resolves relative URLs correctly from CSS context', async () => {
668
- // // The HTML references ./css/deep.css relative to mockBaseDir
669
- // // deep.css references ../images/nested-img.png relative to its *own* location (mockBaseDir/css/)
670
- // const parsed: ParsedHTML = { htmlContent: `<link href="./css/deep.css">`, assets: [{ type: 'css', url: './css/deep.css' }] };
671
- // const result = await extractAssets(parsed, true, mockBaseDir, mockLogger);
672
-
673
- // const expectedCssUrl = getResolvedFileUrl('css/deep.css');
674
- // // The nested image URL should resolve relative to mockBaseDir, becoming mockBaseDir/images/nested-img.png
675
- // const expectedImageUrl = getResolvedFileUrl('images/nested-img.png');
676
-
677
- // const assets: ExpectedAsset[] = [
678
- // { url: expectedCssUrl, type: 'css', content: expect.stringContaining('../images/nested-img.png') }, // Original content
679
- // { url: expectedImageUrl, type: 'image', content: expect.stringMatching(/^data:image\/png;base64,/) } // Resolved and embedded
680
- // ];
681
- // const sortedExpected = [...assets].sort((a, b) => a.url.localeCompare(b.url));
682
- // const sortedActual = [...result.assets].sort((a, b) => a.url.localeCompare(b.url));
683
-
684
- // expectAssetsToContain(sortedActual, sortedExpected);
685
- // // Expect reads for: deep.css, nested-img.png
686
- // expect(mockedReadFile).toHaveBeenCalledTimes(2);
687
- // // Check specific read calls
688
- // expect(mockedReadFile).toHaveBeenCalledWith(expect.stringContaining(path.normalize(deepCssPath)));
689
- // // Check that a call was made containing the nested image path fragment
690
- // expect(mockedReadFile.mock.calls.some(call => String(call[0]).includes(path.normalize('images/nested-img.png')))).toBe(true);
691
- // expect(mockedStatSync).toHaveBeenCalledWith(mockBaseDir);
692
- // });
693
-
694
-
695
- it('📁 resolves local paths against basePath from HTML context', async () => {
696
- // Simple case: image relative to mockBaseDir
697
- const parsed: ParsedHTML = { htmlContent: `<img src="image.png">`, assets: [{ type: 'image', url: 'image.png' }] };
698
- const result = await extractAssets(parsed, true, mockBaseDir, mockLogger);
699
-
700
- const expectedImageUrl = getResolvedFileUrl('image.png');
701
- const assets: ExpectedAsset[] = [
702
- { url: expectedImageUrl, type: 'image', content: expect.stringMatching(/^data:image\/png;base64,/) }
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 }
703
451
  ];
704
-
705
- expectAssetsToContain(result.assets, assets); // No need to sort for single asset
706
- expect(mockedReadFile).toHaveBeenCalledTimes(1);
707
- expect(mockedReadFile).toHaveBeenCalledWith(expect.stringContaining(path.normalize(imagePath)));
708
- expect(mockedStatSync).toHaveBeenCalledWith(mockBaseDir);
709
- });
710
-
711
-
712
- it('🌍 resolves remote assets using base URL from HTML context', async () => {
713
- // HTML is at https://example.com/pages/about.html
714
- // CSS link is ../styles/style.css -> resolves to https://example.com/styles/style.css
715
- // Img link is /images/logo.png -> resolves to https://example.com/images/logo.png
716
- // style.css contains url("/img/remote-bg.jpg?v=1") -> resolves relative to CSS: https://example.com/img/remote-bg.jpg?v=1
717
- const remoteHtmlUrl = 'https://example.com/pages/about.html';
718
- const parsed: ParsedHTML = {
719
- htmlContent: `<html><head><link rel="stylesheet" href="../styles/style.css"></head><body><img src="/images/logo.png"></body></html>`,
720
- assets: [
721
- { type: 'css', url: '../styles/style.css' }, // Relative URL
722
- { type: 'image', url: '/images/logo.png' } // Absolute path URL
723
- ]
724
- };
725
-
726
- const expectedCssUrl = 'https://example.com/styles/style.css';
727
- const expectedLogoUrl = 'https://example.com/images/logo.png';
728
- // This URL is found *inside* the mocked style.css content
729
- const expectedNestedImageUrl = 'https://example.com/img/remote-bg.jpg?v=1';
730
-
731
- const result = await extractAssets(parsed, true, remoteHtmlUrl, mockLogger); // Use remote URL as base
732
-
733
- const assets: ExpectedAsset[] = [
734
- { url: expectedCssUrl, type: 'css', content: expect.stringContaining('remote-bg.jpg') }, // Fetched CSS content
735
- { url: expectedLogoUrl, type: 'image', content: expect.stringMatching(/^data:image\/png;base64,/) }, // Embedded logo
736
- { url: expectedNestedImageUrl, type: 'image', content: expect.stringMatching(/^data:image\/jpeg;base64,/) } // Embedded nested image from CSS
737
- ];
738
- const sortedExpected = [...assets].sort((a,b)=>a.url.localeCompare(b.url));
739
- const sortedActual = [...result.assets].sort((a,b)=>a.url.localeCompare(b.url));
740
-
741
- expectAssetsToContain(sortedActual, sortedExpected);
742
- // Expect 3 axios calls: style.css, logo.png, remote-bg.jpg
743
- expect(mockedAxiosGet).toHaveBeenCalledTimes(3);
744
- expect(mockedAxiosGet).toHaveBeenCalledWith(expectedCssUrl, expect.any(Object)); // Axios called with resolved URL
745
- expect(mockedAxiosGet).toHaveBeenCalledWith(expectedLogoUrl, expect.any(Object)); // Axios called with resolved URL
746
- expect(mockedAxiosGet).toHaveBeenCalledWith(expectedNestedImageUrl, expect.any(Object)); // Axios called with resolved nested URL
747
- expect(mockedReadFile).not.toHaveBeenCalled(); // No local file reads
748
- expect(mockedStatSync).not.toHaveBeenCalled(); // No local stat calls
749
- });
750
-
751
-
752
- // it('🧠 handles deep nested relative local paths from HTML context', async () => {
753
- // // HTML is notionally in mockBaseDir/pages/about/index.html (using deepHtmlDirPath as base)
754
- // // Link is ../../css/deep.css -> resolves to mockBaseDir/css/deep.css
755
- // // deep.css contains ../images/nested-img.png -> resolves to mockBaseDir/images/nested-img.png
756
- // const parsed: ParsedHTML = { htmlContent: `<link href="../../css/deep.css">`, assets: [{ type: 'css', url: '../../css/deep.css' }] };
757
- // const result = await extractAssets(parsed, true, deepHtmlDirPath, mockLogger); // Use deep path as base
758
-
759
- // const expectedCssUrl = getResolvedFileUrl('css/deep.css'); // Resolves correctly relative to mockBaseDir
760
- // const expectedNestedImageUrl = getResolvedFileUrl('images/nested-img.png'); // Resolves correctly relative to mockBaseDir
761
-
762
- // const assets: ExpectedAsset[] = [
763
- // { url: expectedCssUrl, type: 'css', content: expect.stringContaining('../images/nested-img.png') },
764
- // { url: expectedNestedImageUrl, type: 'image', content: expect.stringMatching(/^data:image\/png;base64,/) }
765
- // ];
766
- // const sortedExpected = [...assets].sort((a, b) => a.url.localeCompare(b.url));
767
- // const sortedActual = [...result.assets].sort((a, b) => a.url.localeCompare(b.url));
768
-
769
- // expectAssetsToContain(sortedActual, sortedExpected);
770
- // // Expect reads for: deep.css, nested-img.png
771
- // expect(mockedReadFile).toHaveBeenCalledTimes(2);
772
- // // Check that the correct resolved paths were read
773
- // expect(mockedReadFile).toHaveBeenCalledWith(expect.stringContaining(path.normalize(deepCssPath)));
774
- // expect(mockedReadFile.mock.calls.some(call => String(call[0]).includes(path.normalize('images/nested-img.png')))).toBe(true);
775
- // expect(mockedStatSync).toHaveBeenCalledWith(deepHtmlDirPath); // Initial base check
776
- // });
777
-
778
-
779
- // it('🧼 skips base64 data URIs but processes other assets normally', async () => {
780
- // // HTML has a link to datauri.css and an embedded data URI image
781
- // // datauri.css links to image.png
782
- // const parsed: ParsedHTML = {
783
- // htmlContent: `<link href="datauri.css"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==">`,
784
- // assets: [
785
- // { type: 'css', url: 'datauri.css' },
786
- // // Note: The parser *should not* produce an asset for the data URI img src
787
- // ]
788
- // };
789
- // const result = await extractAssets(parsed, true, mockBaseDir, mockLogger);
790
-
791
- // // Expected assets: datauri.css and the image.png it links to
792
- // const assets: ExpectedAsset[] = [
793
- // { url: getResolvedFileUrl('datauri.css'), type: 'css', content: expect.stringContaining('url("image.png")') },
794
- // { url: getResolvedFileUrl('image.png'), type: 'image', content: expect.stringMatching(/^data:image\/png;base64,/) }
795
- // ];
796
- // const sortedExpected = [...assets].sort((a, b) => a.url.localeCompare(b.url));
797
- // const sortedActual = [...result.assets].sort((a, b) => a.url.localeCompare(b.url));
798
-
799
- // expectAssetsToContain(sortedActual, sortedExpected);
800
- // // Check that no asset with a 'data:' URL was included in the final list
801
- // expect(result.assets.some(a => a.url.startsWith('data:'))).toBe(false);
802
- // // Expect reads for: datauri.css, image.png
803
- // expect(mockedReadFile).toHaveBeenCalledTimes(2);
804
- // expect(mockedStatSync).toHaveBeenCalledWith(mockBaseDir);
805
- // });
806
-
807
-
808
- it('⚠️ handles remote asset fetch errors gracefully (network)', async () => {
809
- const errorUrl = 'https://fail.net/style.css'; // Mocked to return 500
810
- const successUrl = 'https://okay.net/script.js'; // Mocked to return 200
811
- const parsed: ParsedHTML = {
812
- htmlContent: `<link href="<span class="math-inline">\{errorUrl\}"\><script src\="</span>{successUrl}"></script>`,
813
- assets: [{ type: 'css', url: errorUrl }, { type: 'js', url: successUrl }]
814
- };
815
- const result = await extractAssets(parsed, true, 'https://base.com/', mockLogger); // Base URL for context
816
-
817
- // Expect the failed asset with undefined content, and the successful one embedded
818
- const assets: ExpectedAsset[] = [
819
- { url: errorUrl, type: 'css', content: undefined }, // Failed fetch
820
- { url: successUrl, type: 'js', content: expect.stringContaining('remote script') } // Successful fetch
821
- ];
822
- const sortedExpected = [...assets].sort((a,b)=>a.url.localeCompare(b.url));
823
- const sortedActual = [...result.assets].sort((a,b)=>a.url.localeCompare(b.url));
824
-
825
- expectAssetsToContain(sortedActual, sortedExpected);
826
- // Both URLs should have been attempted
827
- expect(mockedAxiosGet).toHaveBeenCalledTimes(2);
828
- expect(mockedAxiosGet).toHaveBeenCalledWith(errorUrl, expect.any(Object));
829
- expect(mockedAxiosGet).toHaveBeenCalledWith(successUrl, expect.any(Object));
830
- // Expect a warning log for the failed asset
831
- expect(mockLoggerWarnSpy).toHaveBeenCalledTimes(1);
832
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`Failed to fetch remote asset ${errorUrl}: Status 500`));
833
- expect(mockLoggerErrorSpy).not.toHaveBeenCalled(); // Should be a warning, not an error
834
- });
835
-
836
-
837
- /* it('⚠️ handles local asset fetch errors gracefully (file not found)', async () => {
838
- const errorPath = 'nonexistent.css'; // Mocked to throw ENOENT on read
839
- const successPath = 'style.css'; // Mocked to read successfully (and contains nested assets)
840
- const parsed: ParsedHTML = {
841
- htmlContent: `<link href="<span class="math-inline">\{errorPath\}"\><link href\="</span>{successPath}">`,
842
- assets: [{ type: 'css', url: errorPath }, { type: 'css', url: successPath }]
843
- };
844
- const result = await extractAssets(parsed, true, mockBaseDir, mockLogger);
845
-
846
- const resolvedErrorUrl = getResolvedFileUrl(errorPath);
847
- const resolvedSuccessUrl = getResolvedFileUrl(successPath);
848
- // Nested assets from the *successful* style.css read
849
- const resolvedNestedCssUrl = getResolvedFileUrl('css/deep.css');
850
- const resolvedNestedFontUrl = getResolvedFileUrl('font/relative-font.woff2');
851
- const resolvedNestedImageUrl = getResolvedFileUrl('images/bg.png');
852
- const resolvedDeepNestedImageUrl = getResolvedFileUrl('images/nested-img.png'); // From deep.css
853
-
854
- const assets: ExpectedAsset[] = [
855
- { url: resolvedErrorUrl, type: 'css', content: undefined }, // Failed read
856
- { url: resolvedSuccessUrl, type: 'css', content: expect.stringContaining('@import') }, // Successful read
857
- // Nested assets from style.css and deep.css
858
- { url: resolvedNestedCssUrl, type: 'css', content: expect.stringContaining('../images/nested-img.png') },
859
- { url: resolvedNestedFontUrl, type: 'font', content: expect.stringMatching(/^data:font\/woff2;base64,/) },
860
- { url: resolvedNestedImageUrl, type: 'image', content: expect.stringMatching(/^data:image\/png;base64,/) },
861
- { url: resolvedDeepNestedImageUrl, type: 'image', content: expect.stringMatching(/^data:image\/png;base64,/) },
862
- ];
863
- const sortedExpected = [...assets].sort((a, b) => a.url.localeCompare(b.url));
864
- const sortedActual = [...result.assets].sort((a, b) => a.url.localeCompare(b.url));
865
-
866
- expectAssetsToContain(sortedActual, sortedExpected);
867
- // Expect reads attempted for: nonexistent.css, style.css, deep.css, bg.png, relative-font.woff2, nested-img.png
868
- expect(mockedReadFile).toHaveBeenCalledTimes(6);
869
- // Verify the failed path was attempted
870
- expect(mockedReadFile).toHaveBeenCalledWith(expect.stringContaining(path.normalize(nonexistentPath)));
871
- // Expect a warning log for the ENOENT error
872
- expect(mockLoggerWarnSpy).toHaveBeenCalledTimes(1);
873
- // Use the normalized path in the log message check for consistency
874
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`File not found (ENOENT) for asset: ${path.normalize(nonexistentPath)}`));
875
- expect(result.assets.filter(a => a.content !== undefined).length).toBe(5); // 5 assets should have content
876
- expect(mockedStatSync).toHaveBeenCalledWith(mockBaseDir); // Base dir check
877
- }); */
878
-
879
-
880
- // it('🔄 handles asset cycles gracefully (visitedUrls set)', async () => {
881
- // // cycle1.css imports cycle2.css, cycle2.css imports cycle1.css
882
- // const parsed: ParsedHTML = { htmlContent: `<link href="cycle1.css">`, assets: [{ type: 'css', url: 'cycle1.css' }] };
883
- // const result = await extractAssets(parsed, true, mockBaseDir, mockLogger);
884
-
885
- // const resolvedCss1Url = getResolvedFileUrl('cycle1.css');
886
- // const resolvedCss2Url = getResolvedFileUrl('cycle2.css');
887
-
888
- // // Both CSS files should be present with their original content
889
- // const assets: ExpectedAsset[] = [
890
- // { url: resolvedCss1Url, type: 'css', content: expect.stringContaining('@import url("cycle2.css")') },
891
- // { url: resolvedCss2Url, type: 'css', content: expect.stringContaining('@import url("cycle1.css")') }
892
- // ];
893
- // const sortedExpected = [...assets].sort((a, b) => a.url.localeCompare(b.url));
894
- // const sortedActual = [...result.assets].sort((a, b) => a.url.localeCompare(b.url));
895
-
896
- // expectAssetsToContain(sortedActual, sortedExpected);
897
- // // Each file should be read exactly once due to the visitedUrls mechanism
898
- // expect(mockedReadFile).toHaveBeenCalledTimes(2);
899
- // expect(mockedReadFile).toHaveBeenCalledWith(expect.stringContaining(path.normalize(cycle1CssPath)));
900
- // expect(mockedReadFile).toHaveBeenCalledWith(expect.stringContaining(path.normalize(cycle2CssPath)));
901
- // // No warnings or errors about cycles or limits should be logged
902
- // expect(mockLoggerWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining('cycle'));
903
- // expect(mockLoggerErrorSpy).not.toHaveBeenCalledWith(expect.stringContaining('infinite loop'));
904
- // expect(mockLoggerErrorSpy).not.toHaveBeenCalledWith(expect.stringContaining('limit hit'));
905
- // expect(mockedStatSync).toHaveBeenCalledWith(mockBaseDir);
906
- // });
907
-
908
-
909
- // =================== EDGE CASE / COVERAGE TESTS ===================
910
-
911
- it('⚠️ handles non-ENOENT local file read errors (EACCES)', async () => {
912
- const parsed: ParsedHTML = { htmlContent: `<link href="unreadable.css">`, assets: [{ type: 'css', url: 'unreadable.css' }] };
913
- const result = await extractAssets(parsed, true, mockBaseDir, mockLogger); // embed=true
914
-
915
- expect(result.assets).toHaveLength(1); // Asset should still be listed
916
- const asset = result.assets[0];
917
- expect(asset.url).toBe(getResolvedFileUrl('unreadable.css'));
918
- expect(asset.content).toBeUndefined(); // Content fetching failed
919
- expect(asset.type).toBe('css');
920
-
921
- // Expect a warning about the EACCES error
922
- expect(mockLoggerWarnSpy).toHaveBeenCalledTimes(1);
923
- // Use normalized path in expectation
924
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`Permission denied (EACCES) reading asset: ${path.normalize(unreadablePath)}`));
925
- expect(mockLoggerErrorSpy).not.toHaveBeenCalled(); // Should not be a fatal error
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
926
456
  });
927
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
+ });
928
481
 
929
- it('⚠️ handles non-Axios remote fetch errors (e.g., invalid URL object passed to fetch)', async () => {
930
- const invalidUrlString = 'invalid-protocol://weird'; // Not http/https
931
- const parsed: ParsedHTML = { htmlContent: ``, assets: [{ type: 'js', url: invalidUrlString }] };
932
-
933
- // This test relies on the internal URL parsing/validation within fetchAsset/resolveAssetUrl
934
- const result = await extractAssets(parsed, true, 'https://base.com/', mockLogger);
935
-
936
- // --- Assert Results & Logging ---
937
- // The primary check is the warning log from fetchAsset (or resolveAssetUrl if it catches it earlier)
938
- // Expect a warning because the protocol is unsupported for fetching.
939
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(
940
- `Unsupported protocol "invalid-protocol:" in URL: ${invalidUrlString}` // Message from fetchAsset
941
- ));
942
- expect(mockLoggerErrorSpy).not.toHaveBeenCalled(); // Should not be a fatal error
943
-
944
-
945
- // The asset might be included in the list but without content, or excluded entirely
946
- // depending on where the error occurs (resolution vs. fetching).
947
- // Let's check if it's present but without content.
948
- const foundAsset = result.assets.find(a => a.url === invalidUrlString);
949
- expect(foundAsset).toBeDefined(); // It should likely still be in the list from the initial parse
950
- if (foundAsset) {
951
- expect(foundAsset.content).toBeUndefined(); // Content fetching would fail
952
- expect(foundAsset.type).toBe('js');
953
- expect(result.assets).toHaveLength(1); // Only this asset was processed
954
- } else {
955
- // If resolveAssetUrl failed very early, the list might be empty
956
- expect(result.assets).toHaveLength(0);
957
- }
958
-
959
-
960
- // Fetching (Axios or fs) should not have been attempted
961
- expect(mockedAxiosGet).not.toHaveBeenCalled();
962
- expect(mockedReadFile).not.toHaveBeenCalled();
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)}`));
963
492
  });
964
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
+ });
965
509
 
966
- it('⚠️ handles network timeout errors specifically', async () => {
967
- const timeoutUrl = 'https://timeout.net/resource.png'; // Mocked to throw timeout AxiosError
968
- const parsed: ParsedHTML = { htmlContent: `<img src="${timeoutUrl}">`, assets: [{ type: 'image', url: timeoutUrl }] };
969
- const result = await extractAssets(parsed, true, 'https://timeout.net/', mockLogger); // Base URL context
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
+ });
970
525
 
971
- expect(result.assets).toHaveLength(1); // Asset is listed
972
- const asset = result.assets[0];
973
- expect(asset.url).toBe(timeoutUrl);
974
- expect(asset.content).toBeUndefined(); // Fetch failed due to timeout
975
- expect(asset.type).toBe('image');
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
+ });
976
543
 
977
- // Expect a specific warning log for the timeout
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
978
560
  expect(mockLoggerWarnSpy).toHaveBeenCalledTimes(1);
979
- // Check the log message includes the status, code, and timeout duration from the mock error
980
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(
981
- `Failed to fetch remote asset ${timeoutUrl}: Status 408 - Request Timeout. Code: ECONNABORTED, Message: timeout of 10000ms exceeded`
982
- ));
983
- expect(mockLoggerErrorSpy).not.toHaveBeenCalled();
984
- });
985
-
986
- it('❓ handles unsupported URL protocols (e.g., ftp)', async () => {
987
- const ftpUrl = 'ftp://example.com/image.jpg'; const parsed: ParsedHTML = { htmlContent: `<img src="${ftpUrl}">`, assets: [{ type: 'image', url: ftpUrl }] };
988
- const result = await extractAssets(parsed, true, mockBaseDir, mockLogger);
989
- expect(result.assets).toHaveLength(1); expect(result.assets[0].url).toBe(ftpUrl); expect(result.assets[0].content).toBeUndefined(); expect(mockLoggerWarnSpy).toHaveBeenCalledTimes(1);
990
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`Unsupported protocol "ftp:" in URL: ${ftpUrl}`));
991
- expect(mockedAxiosGet).not.toHaveBeenCalled(); expect(mockedReadFile).not.toHaveBeenCalled();
992
561
  });
993
- it('🤔 handles assets with "other" types (attempts text, falls back to base64 if invalid UTF-8)', async () => {
994
- const otherFileUrl = getResolvedFileUrl('file.other');
995
-
996
- // Clear mocks for this specific test
997
- mockReadFileFn.mockReset();
998
- mockLoggerWarnSpy.mockClear();
999
- mockLoggerDebugSpy.mockClear();
1000
-
1001
- // Clear any previous mocks if needed (though beforeEach should handle it)
1002
- mockedReadFile.mockClear();
1003
-
1004
- mockedReadFile.mockImplementation(async (fileUrlOrPath): Promise<Buffer> => {
1005
- let filePath: string = '';
1006
562
 
1007
- // --- Determine the actual file path string ---
1008
- // (This logic should handle various ways fs.readFile might be called)
1009
- if (typeof fileUrlOrPath === 'string') {
1010
- // Handle both file URLs and regular paths passed as strings
1011
- try {
1012
- filePath = fileUrlOrPath.startsWith('file:') ? fileURLToPath(fileUrlOrPath) : fileUrlOrPath;
1013
- } catch (e) {
1014
- console.error(`[DEBUG MOCK readFile - other type test] Error converting string path/URL: ${fileUrlOrPath}`, e);
1015
- throw new Error(`Could not derive path from string: ${fileUrlOrPath}`);
1016
- }
1017
- } else if (fileUrlOrPath instanceof URL && fileUrlOrPath.protocol === 'file:') {
1018
- // Handle URL objects
1019
- try {
1020
- filePath = fileURLToPath(fileUrlOrPath);
1021
- } catch (e) {
1022
- console.error(`[DEBUG MOCK readFile - other type test] Error converting URL object: ${fileUrlOrPath.href}`, e);
1023
- throw new Error(`Could not derive path from URL object: ${fileUrlOrPath.href}`);
1024
- }
1025
- } else if (typeof (fileUrlOrPath as any)?.path === 'string') { // Basic check for FileHandle-like object
1026
- filePath = (fileUrlOrPath as any).path;
1027
- } else {
1028
- // Log or throw for unexpected input types
1029
- const inputDesc = typeof fileUrlOrPath === 'object' ? JSON.stringify(fileUrlOrPath) : String(fileUrlOrPath);
1030
- console.error(`[DEBUG MOCK readFile - other type test] Unexpected input type: ${typeof fileUrlOrPath}, value: ${inputDesc}`);
1031
- throw new Error(`Unexpected input type to readFile mock: ${typeof fileUrlOrPath}`);
1032
- }
1033
-
1034
- // Normalize for consistent comparison
1035
- const normalizedPath = path.normalize(filePath);
1036
- const normalizedUnknownFilePath = path.normalize(unknownFilePath); // Normalize the target path too
1037
-
1038
- // Log what's being requested (optional, but helpful)
1039
- // Remember: console is mocked, might need DEBUG=true env var to see this
1040
- console.log(`[DEBUG MOCK readFile - other type test] Requested normalized path: ${normalizedPath}`);
1041
- console.log(`[DEBUG MOCK readFile - other type test] Comparing against: ${normalizedUnknownFilePath}`);
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
+ });
1042
581
 
1043
- // --- Specific Check for this Test ---
1044
- // Compare normalized requested path with the normalized path of 'file.other'
1045
- if (normalizedPath === normalizedUnknownFilePath) {
1046
- console.log(`[DEBUG MOCK readFile - other type test] MATCH! Returning invalidUtf8Buffer for ${normalizedPath}`);
1047
- // Make sure 'invalidUtf8Buffer' is accessible here (defined outside/above)
1048
- return invalidUtf8Buffer;
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}");`);
1049
599
  }
1050
-
1051
- // Fallback for any other unexpected file reads *within this specific test*
1052
- // This helps catch if the test is trying to read other files unexpectedly
1053
- console.warn(`[DEBUG MOCK readFile - other type test] Unexpected path requested: ${normalizedPath}. Returning default.`);
1054
- // You could throw an error here instead if NO other file should be read
1055
- // throw new Error(`Unexpected file read requested in 'other type' test: ${normalizedPath}`);
1056
- return Buffer.from(`/* Default content for unexpected path in 'other type' test: ${normalizedPath} */`);
1057
- });
1058
-
1059
- // Run the test
1060
- const parsed: ParsedHTML = {
1061
- htmlContent: `<a href="file.other">Link</a>`,
1062
- assets: [{ type: 'other' as Asset['type'], url: 'file.other' }]
600
+ const error: NodeJSErrnoException = new Error(`ENOENT (Mock Iteration): Unexpected path ${normalizedPath}`); error.code = 'ENOENT'; throw error;
1063
601
  };
1064
-
1065
- const resultInvalid = await extractAssets(parsed, true, mockBaseDir, mockLogger);
1066
-
1067
- // Debug logging
1068
- console.log('Actual asset content:', resultInvalid.assets[0].content);
1069
- console.log('Expected base64:', `data:application/octet-stream;base64,${invalidUtf8Buffer.toString('base64')}`);
1070
-
1071
- // Assertions
1072
- expect(resultInvalid.assets).toHaveLength(1);
1073
- expect(resultInvalid.assets[0].url).toBe(otherFileUrl);
1074
- expect(resultInvalid.assets[0].type).toBe('other');
1075
- expect(resultInvalid.assets[0].content).toBe(`data:application/octet-stream;base64,${invalidUtf8Buffer.toString('base64')}`);
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'));
1076
614
  });
1077
615
 
1078
- // Skipping this test as the spy capture seems unreliable in this env/setup
1079
- it.skip('⚠️ warns if base URL cannot be determined for relative paths from HTML', async () => {
1080
- const invalidInput = 'invalid-protocol://test'; const parsed: ParsedHTML = { htmlContent: `<img src="relative/image.png">`, assets: [{ type: 'image', url: 'relative/image.png' }] };
1081
- await extractAssets(parsed, true, invalidInput, mockLogger);
1082
- expect(mockLoggerErrorSpy).toHaveBeenCalledTimes(1); // Expect ERROR log from determineBaseUrl catch
1083
- expect(mockLoggerErrorSpy).toHaveBeenCalledWith(expect.stringContaining(`💀 Failed to determine base URL for "${invalidInput}"`));
1084
- expect(mockLoggerWarnSpy).toHaveBeenCalledTimes(1); // Expect WARNING log from resolveAssetUrl
1085
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`Cannot resolve relative URL "relative/image.png" - Base context URL was not provided or determined.`));
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();
1086
628
  });
1087
629
 
1088
- // it('⚠️ handles failure to determine CSS base URL gracefully', async () => {
1089
- // const invalidCssFileUrl = 'file:///__INVALID_PATH_CHARACTERS__?query#hash'; mockedReadFile.mockImplementation(async (p) => Buffer.from('body { color: red; }'));
1090
- // const parsed: ParsedHTML = { htmlContent: `<link href="${invalidCssFileUrl}">`, assets: [{ type: 'css', url: invalidCssFileUrl }] };
1091
- // await extractAssets(parsed, true, mockBaseDir, mockLogger);
1092
- // expect(mockLoggerErrorSpy).toHaveBeenCalledTimes(1); // Expect ERROR log from fetchAsset
1093
- // expect(mockLoggerErrorSpy).toHaveBeenCalledWith(expect.stringContaining(`Could not convert file URL to path: ${invalidCssFileUrl}. Error:`));
1094
- // expect(mockLoggerWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining(`Could not determine base URL context for CSS file`)); // Should not log this warning
1095
- // });
1096
-
1097
- // it('⚠️ handles failure to decode CSS content for parsing (logs warning, embeds base64)', async () => {
1098
- // const invalidCssUrl = getResolvedFileUrl('invalid-utf8.css');
1099
- // const parsed: ParsedHTML = { htmlContent: `<link href="invalid-utf8.css">`, assets: [{ type: 'css', url: 'invalid-utf8.css' }] };
1100
- // const result = await extractAssets(parsed, true, mockBaseDir, mockLogger);
1101
- // // --- Verify Logging ---
1102
- // // **CORRECTED EXPECTATION ORDER:**
1103
- // // 1. Expect the warning about failed decoding *for parsing* (logged first)
1104
- // expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(
1105
- // `Failed to decode CSS content for parsing ${invalidCssUrl} due to invalid UTF-8 sequences.` // <-- Corrected Expectation
1106
- // ));
1107
- // // 2. Also expect the warning about falling back to base64 for the *embedding* part (logged later)
1108
- // expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining(
1109
- // `Could not represent css ${invalidCssUrl} as valid UTF-8 text, falling back to base64 data URI.`
1110
- // ));
1111
- // // 3. Expect exactly these two warnings
1112
- // expect(mockLoggerWarnSpy).toHaveBeenCalledTimes(2);
1113
- // // --- Verify Asset ---
1114
- // expect(result.assets).toHaveLength(1); expect(result.assets[0].url).toBe(invalidCssUrl); expect(result.assets[0].content).toBe(`data:text/css;base64,${invalidUtf8Buffer.toString('base64')}`);
1115
- // });
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
+ });
1116
653
 
1117
- it('🛑 hits iteration limit if asset queue keeps growing (logs error)', async () => {
1118
- let counter = 0; const generateUniqueUrl = (baseUrl: string) => `generated_${counter++}.css`;
1119
- mockedReadFile.mockImplementation(async (p) => { const requestingUrl = p instanceof URL ? p.href : p.toString(); let baseUrlForNesting = mockBaseUrlFile; if (requestingUrl.startsWith('file:')) { try { baseUrlForNesting = new URL('.', requestingUrl).href; } catch {} } const nextUniqueRelativeUrl = generateUniqueUrl(requestingUrl); return Buffer.from(`@import url("${nextUniqueRelativeUrl}"); /* Cycle ${counter} */`); });
1120
- const parsed: ParsedHTML = { htmlContent: ``, assets: [{ type: 'css', url: 'start.css' }] };
1121
- await extractAssets(parsed, true, mockBaseDir, mockLogger);
1122
- // --- Verify Logging ---
1123
- expect(mockLoggerErrorSpy).toHaveBeenCalled(); // <-- Corrected Expectation (don't check times=1)
1124
- expect(mockLoggerErrorSpy).toHaveBeenCalledWith(expect.stringContaining("🛑 Asset extraction loop limit hit (1000)!"));
1125
- // --- Verify Mock Calls ---
1126
- expect(mockedReadFile.mock.calls.length).toBeGreaterThanOrEqual(1000); expect(mockedReadFile.mock.calls.length).toBeLessThan(1010);
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());
1127
671
  });
1128
672
 
1129
- }); // End describe suite
673
+ }); // End describe block