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