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.
- package/.github/workflows/ci.yml +5 -4
- package/CHANGELOG.md +20 -0
- package/README.md +81 -219
- package/dist/cli/{cli-entry.js → cli-entry.cjs} +620 -513
- package/dist/cli/cli-entry.cjs.map +1 -0
- package/dist/index.d.ts +51 -56
- package/dist/index.js +517 -458
- package/dist/index.js.map +1 -1
- package/docs/.vitepress/config.ts +0 -1
- package/docs/cli.md +108 -45
- package/docs/configuration.md +101 -116
- package/docs/getting-started.md +74 -44
- package/jest.config.ts +18 -8
- package/jest.setup.cjs +66 -146
- package/package.json +5 -5
- package/src/cli/cli-entry.ts +15 -15
- package/src/cli/cli.ts +130 -119
- package/src/core/bundler.ts +174 -63
- package/src/core/extractor.ts +364 -277
- package/src/core/web-fetcher.ts +205 -141
- package/src/index.ts +161 -224
- package/tests/unit/cli/cli-entry.test.ts +66 -77
- package/tests/unit/cli/cli.test.ts +243 -145
- package/tests/unit/core/bundler.test.ts +334 -258
- package/tests/unit/core/extractor.test.ts +608 -1064
- package/tests/unit/core/minifier.test.ts +130 -221
- package/tests/unit/core/packer.test.ts +255 -106
- package/tests/unit/core/parser.test.ts +89 -458
- package/tests/unit/core/web-fetcher.test.ts +310 -265
- package/tests/unit/index.test.ts +206 -300
- package/tests/unit/utils/logger.test.ts +32 -28
- package/tsconfig.jest.json +8 -7
- package/tsup.config.ts +34 -29
- package/dist/cli/cli-entry.js.map +0 -1
- package/docs/demo.md +0 -46
- package/output.html +0 -1
- package/site-packed.html +0 -1
- package/test-output.html +0 -0
@@ -1,1129 +1,673 @@
|
|
1
1
|
/**
|
2
|
-
* @file extractor.test.ts
|
3
|
-
* @description Unit tests for asset extraction logic (extractAssets function)
|
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
|
-
|
12
|
-
import
|
13
|
-
import
|
14
|
-
import
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
// ---
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
jest.
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
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
|
-
|
61
|
-
|
62
|
-
//
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
const
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
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
|
-
// ---
|
112
|
-
//
|
113
|
-
|
114
|
-
|
115
|
-
|
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
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
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
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
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
|
-
|
286
|
-
|
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
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
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
|
-
|
302
|
-
|
303
|
-
|
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
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
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
|
-
|
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
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
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
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
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
|
-
|
442
|
-
|
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
|
-
|
446
|
-
|
447
|
-
|
448
|
-
}
|
449
|
-
|
450
|
-
return
|
451
|
-
}
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
470
|
-
|
471
|
-
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
|
477
|
-
|
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
|
-
|
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
|
-
//
|
534
|
-
|
535
|
-
|
536
|
-
|
537
|
-
|
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
|
-
//
|
543
|
-
|
544
|
-
|
545
|
-
|
546
|
-
|
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
|
-
|
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
|
-
|
555
|
-
|
556
|
-
|
557
|
-
|
558
|
-
|
559
|
-
|
560
|
-
|
561
|
-
|
562
|
-
|
563
|
-
|
564
|
-
|
565
|
-
|
566
|
-
|
567
|
-
|
568
|
-
|
569
|
-
|
570
|
-
|
571
|
-
|
572
|
-
|
573
|
-
|
574
|
-
|
575
|
-
|
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
|
-
|
636
|
-
|
637
|
-
|
638
|
-
|
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
|
-
|
646
|
-
|
647
|
-
|
648
|
-
|
649
|
-
|
650
|
-
|
651
|
-
|
652
|
-
|
653
|
-
|
654
|
-
|
655
|
-
|
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,
|
706
|
-
|
707
|
-
expect(
|
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('
|
930
|
-
|
931
|
-
const parsed: ParsedHTML = { htmlContent:
|
932
|
-
|
933
|
-
|
934
|
-
|
935
|
-
|
936
|
-
|
937
|
-
//
|
938
|
-
|
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('
|
967
|
-
const
|
968
|
-
|
969
|
-
const
|
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
|
-
|
972
|
-
const
|
973
|
-
|
974
|
-
|
975
|
-
|
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
|
-
|
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
|
-
|
1008
|
-
|
1009
|
-
|
1010
|
-
|
1011
|
-
|
1012
|
-
|
1013
|
-
|
1014
|
-
|
1015
|
-
|
1016
|
-
|
1017
|
-
|
1018
|
-
|
1019
|
-
|
1020
|
-
|
1021
|
-
|
1022
|
-
|
1023
|
-
|
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
|
-
|
1044
|
-
|
1045
|
-
|
1046
|
-
|
1047
|
-
|
1048
|
-
|
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
|
-
|
1066
|
-
|
1067
|
-
|
1068
|
-
|
1069
|
-
|
1070
|
-
|
1071
|
-
|
1072
|
-
|
1073
|
-
expect(
|
1074
|
-
|
1075
|
-
expect(
|
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
|
-
|
1079
|
-
|
1080
|
-
const
|
1081
|
-
|
1082
|
-
|
1083
|
-
|
1084
|
-
expect(
|
1085
|
-
expect(
|
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
|
-
|
1089
|
-
|
1090
|
-
|
1091
|
-
|
1092
|
-
|
1093
|
-
|
1094
|
-
|
1095
|
-
|
1096
|
-
|
1097
|
-
|
1098
|
-
|
1099
|
-
|
1100
|
-
|
1101
|
-
|
1102
|
-
|
1103
|
-
|
1104
|
-
|
1105
|
-
|
1106
|
-
|
1107
|
-
|
1108
|
-
|
1109
|
-
|
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('
|
1118
|
-
|
1119
|
-
|
1120
|
-
|
1121
|
-
|
1122
|
-
//
|
1123
|
-
|
1124
|
-
|
1125
|
-
|
1126
|
-
|
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
|
673
|
+
}); // End describe block
|