portapack 0.3.0 → 0.3.2
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/.github/workflows/ci.yml +5 -4
- package/.releaserc.js +25 -27
- package/CHANGELOG.md +12 -19
- package/LICENSE.md +21 -0
- package/README.md +34 -36
- package/commitlint.config.js +30 -34
- package/dist/cli/cli-entry.cjs +199 -135
- package/dist/cli/cli-entry.cjs.map +1 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.js +194 -134
- package/dist/index.js.map +1 -1
- package/docs/.vitepress/config.ts +36 -34
- package/docs/.vitepress/sidebar-generator.ts +89 -38
- package/docs/cli.md +29 -82
- package/docs/code-of-conduct.md +7 -1
- package/docs/configuration.md +103 -117
- package/docs/contributing.md +6 -2
- package/docs/deployment.md +10 -5
- package/docs/development.md +8 -5
- package/docs/getting-started.md +76 -45
- 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/site.webmanifest +1 -0
- package/docs/troubleshooting.md +12 -1
- package/examples/main.ts +7 -10
- 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 +254 -221
- package/src/core/extractor.ts +639 -520
- 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 +828 -380
- 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 +2 -1
- package/tsconfig.json +2 -2
- package/tsup.config.ts +8 -8
- package/typedoc.json +5 -9
- package/docs/demo.md +0 -46
- /package/docs/{portapack-transparent.png → public/portapack-transparent.png} +0 -0
- /package/docs/{portapack.jpg → public/portapack.jpg} +0 -0
@@ -7,463 +7,911 @@
|
|
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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
22
|
+
AxiosResponse,
|
23
|
+
AxiosRequestConfig,
|
24
|
+
AxiosError,
|
25
|
+
AxiosHeaderValue,
|
26
|
+
AxiosRequestHeaders,
|
27
|
+
AxiosResponseHeaders,
|
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
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
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
|
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
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
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
|
94
112
|
};
|
95
113
|
|
96
114
|
// --- Mock Directory/File Structure ---
|
115
|
+
// Set of directories that should exist in the mock structure
|
97
116
|
const mockDirs = new Set<string>(
|
98
|
-
|
117
|
+
[
|
118
|
+
mockBaseDirPath,
|
119
|
+
path.dirname(filePaths.deepCss),
|
120
|
+
path.dirname(filePaths.fontFile),
|
121
|
+
path.dirname(filePaths.bgImage),
|
122
|
+
].map(normalizePath)
|
99
123
|
);
|
124
|
+
// Set of files that should exist in the mock structure (used by statSync mock)
|
100
125
|
const mockFiles = new Set<string>(
|
101
|
-
|
126
|
+
// Get all keys (paths) from mockFileContents
|
127
|
+
Object.keys(mockFileContents)
|
128
|
+
// Add paths for files that should exist but might cause read errors
|
129
|
+
.concat([filePaths.unreadable].map(normalizePath))
|
130
|
+
// Note: filePaths.nonexistent is *not* added here, so statSync will fail for it
|
102
131
|
);
|
103
132
|
|
104
133
|
// --- Helpers ---
|
134
|
+
// Helper to resolve URLs consistently within tests
|
105
135
|
const resolveUrl = (relativePath: string, baseUrl: string): string => {
|
106
|
-
|
107
|
-
|
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
|
+
}
|
108
142
|
};
|
109
143
|
|
110
|
-
|
144
|
+
// Type definition for expected asset structure in assertions
|
145
|
+
type ExpectedAsset = { type: Asset['type']; url: string; content?: any };
|
111
146
|
|
147
|
+
// Helper function to assert that the actual assets contain the expected assets
|
112
148
|
function expectAssetsToContain(actualAssets: Asset[], expectedAssets: ExpectedAsset[]): void {
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
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
|
+
});
|
121
164
|
}
|
122
165
|
|
123
|
-
|
124
|
-
interface
|
125
|
-
|
166
|
+
// Interface for Node.js errors with a 'code' property
|
167
|
+
interface NodeJSErrnoException extends Error {
|
168
|
+
code?: string;
|
169
|
+
}
|
170
|
+
// Interface to represent an Axios error structure for mocking
|
171
|
+
interface MockAxiosError extends AxiosError {
|
172
|
+
isAxiosError: true;
|
173
|
+
}
|
126
174
|
|
127
175
|
// ================ MOCK IMPLEMENTATIONS (Defined Globally) ================
|
128
176
|
|
129
|
-
//
|
177
|
+
// Mock implementation for fsPromises.readFile
|
130
178
|
const readFileMockImplementation = async (
|
131
|
-
|
132
|
-
|
179
|
+
filePathArg: PathLike | FileHandle,
|
180
|
+
options?: BufferEncoding | ({ encoding?: null; flag?: OpenMode } & AbortSignal) | null // Match fsPromises.readFile signature
|
133
181
|
): Promise<Buffer | string> => {
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
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; }
|
148
|
-
|
149
|
-
if (path.basename(normalizedPath).startsWith('gen_')) { /* ... iteration logic ... */ }
|
150
|
-
|
151
|
-
const content = mockFileContents[normalizedPath];
|
152
|
-
if (content !== undefined) {
|
153
|
-
// **** DEBUG LOG ****
|
154
|
-
console.log(`[DEBUG mockReadFileFn] FOUND content for: "${normalizedPath}".`);
|
155
|
-
return Buffer.isBuffer(content) ? content : Buffer.from(content); // Return Buffer
|
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());
|
156
193
|
}
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
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;
|
161
239
|
};
|
162
240
|
|
241
|
+
// Mock implementation for fs.statSync
|
163
242
|
const statSyncMockImplementation = (
|
164
|
-
|
165
|
-
|
243
|
+
pathToCheck: PathLike,
|
244
|
+
options?:
|
245
|
+
| (StatSyncOptions & { bigint?: false; throwIfNoEntry?: boolean })
|
246
|
+
| { bigint: true; throwIfNoEntry?: boolean } // Match fs.statSync signature
|
166
247
|
): Stats | BigIntStats | undefined => {
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
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,
|
215
301
|
};
|
216
302
|
|
217
|
-
//
|
218
|
-
if (
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
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
|
224
333
|
}
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
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;
|
229
357
|
};
|
230
358
|
|
231
|
-
|
359
|
+
// Mock implementation for axios.get
|
232
360
|
const axiosGetMockImplementation = async (
|
233
|
-
|
234
|
-
|
361
|
+
url: string,
|
362
|
+
config?: AxiosRequestConfig // Match axios.get signature
|
235
363
|
): Promise<AxiosResponse<Buffer>> => {
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
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);
|
382
|
+
}
|
383
|
+
}
|
384
|
+
}
|
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 ...
|
246
404
|
};
|
247
|
-
|
248
|
-
|
249
|
-
|
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
|
+
},
|
250
450
|
};
|
251
|
-
|
252
|
-
//
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
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
|
+
};
|
279
487
|
};
|
280
488
|
|
281
|
-
|
282
489
|
// ================ TESTS ================
|
283
490
|
|
284
491
|
describe('extractAssets', () => {
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
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
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
expect
|
367
|
-
|
368
|
-
|
369
|
-
|
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
|
-
|
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
|