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