portapack 0.2.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/.eslintrc.json +9 -0
- package/.github/workflows/ci.yml +73 -0
- package/.github/workflows/deploy-pages.yml +56 -0
- package/.prettierrc +9 -0
- package/.releaserc.js +29 -0
- package/CHANGELOG.md +21 -0
- package/README.md +288 -0
- package/commitlint.config.js +36 -0
- package/dist/cli/cli-entry.js +1694 -0
- package/dist/cli/cli-entry.js.map +1 -0
- package/dist/index.d.ts +275 -0
- package/dist/index.js +1405 -0
- package/dist/index.js.map +1 -0
- package/docs/.vitepress/config.ts +89 -0
- package/docs/.vitepress/sidebar-generator.ts +73 -0
- package/docs/cli.md +117 -0
- package/docs/code-of-conduct.md +65 -0
- package/docs/configuration.md +151 -0
- package/docs/contributing.md +107 -0
- package/docs/demo.md +46 -0
- package/docs/deployment.md +132 -0
- package/docs/development.md +168 -0
- package/docs/getting-started.md +106 -0
- package/docs/index.md +40 -0
- package/docs/portapack-transparent.png +0 -0
- package/docs/portapack.jpg +0 -0
- package/docs/troubleshooting.md +107 -0
- package/examples/main.ts +118 -0
- package/examples/sample-project/index.html +12 -0
- package/examples/sample-project/logo.png +1 -0
- package/examples/sample-project/script.js +1 -0
- package/examples/sample-project/styles.css +1 -0
- package/jest.config.ts +124 -0
- package/jest.setup.cjs +211 -0
- package/nodemon.json +11 -0
- package/output.html +1 -0
- package/package.json +161 -0
- package/site-packed.html +1 -0
- package/src/cli/cli-entry.ts +28 -0
- package/src/cli/cli.ts +139 -0
- package/src/cli/options.ts +151 -0
- package/src/core/bundler.ts +201 -0
- package/src/core/extractor.ts +618 -0
- package/src/core/minifier.ts +233 -0
- package/src/core/packer.ts +191 -0
- package/src/core/parser.ts +115 -0
- package/src/core/web-fetcher.ts +292 -0
- package/src/index.ts +262 -0
- package/src/types.ts +163 -0
- package/src/utils/font.ts +41 -0
- package/src/utils/logger.ts +139 -0
- package/src/utils/meta.ts +100 -0
- package/src/utils/mime.ts +90 -0
- package/src/utils/slugify.ts +70 -0
- package/test-output.html +0 -0
- package/tests/__fixtures__/sample-project/index.html +5 -0
- package/tests/unit/cli/cli-entry.test.ts +104 -0
- package/tests/unit/cli/cli.test.ts +230 -0
- package/tests/unit/cli/options.test.ts +316 -0
- package/tests/unit/core/bundler.test.ts +287 -0
- package/tests/unit/core/extractor.test.ts +1129 -0
- package/tests/unit/core/minifier.test.ts +414 -0
- package/tests/unit/core/packer.test.ts +193 -0
- package/tests/unit/core/parser.test.ts +540 -0
- package/tests/unit/core/web-fetcher.test.ts +374 -0
- package/tests/unit/index.test.ts +339 -0
- package/tests/unit/utils/font.test.ts +81 -0
- package/tests/unit/utils/logger.test.ts +275 -0
- package/tests/unit/utils/meta.test.ts +70 -0
- package/tests/unit/utils/mime.test.ts +96 -0
- package/tests/unit/utils/slugify.test.ts +71 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.jest.json +17 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +71 -0
- package/typedoc.json +28 -0
@@ -0,0 +1,414 @@
|
|
1
|
+
/**
|
2
|
+
* @file tests/unit/core/minifier.test.ts
|
3
|
+
* @description Unit tests for the Minifier module (minifyAssets function).
|
4
|
+
* Uses jest.unstable_mockModule for mocking dependencies.
|
5
|
+
*/
|
6
|
+
|
7
|
+
// --- Imports ---
|
8
|
+
import type { Options as HtmlMinifyOptions } from 'html-minifier-terser';
|
9
|
+
import type { Options as CleanCSSOptions, Output as CleanCSSOutput } from 'clean-css';
|
10
|
+
import type { MinifyOptions, MinifyOutput } from 'terser';
|
11
|
+
import type { ParsedHTML, BundleOptions, Asset } from '../../../src/types';
|
12
|
+
import { Logger } from '../../../src/utils/logger';
|
13
|
+
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
14
|
+
|
15
|
+
// =================== MOCK SETUP ===================
|
16
|
+
|
17
|
+
const mockHtmlMinifierMinifyFn = jest.fn<any>();
|
18
|
+
const mockCleanCSSInstanceMinifyFn = jest.fn<any>();
|
19
|
+
const mockCleanCSSConstructorFn = jest.fn<any>();
|
20
|
+
const mockTerserMinifyFn = jest.fn<any>();
|
21
|
+
|
22
|
+
// Mock the dependencies BEFORE importing the module under test
|
23
|
+
jest.unstable_mockModule('html-minifier-terser', () => ({
|
24
|
+
minify: mockHtmlMinifierMinifyFn,
|
25
|
+
}));
|
26
|
+
jest.unstable_mockModule('clean-css', () => ({
|
27
|
+
// Mock the default export which is the class constructor
|
28
|
+
default: mockCleanCSSConstructorFn,
|
29
|
+
}));
|
30
|
+
jest.unstable_mockModule('terser', () => ({
|
31
|
+
minify: mockTerserMinifyFn,
|
32
|
+
}));
|
33
|
+
|
34
|
+
// ====================================================
|
35
|
+
|
36
|
+
// Import the module under test *after* mocks are set up
|
37
|
+
const { minifyAssets } = await import('../../../src/core/minifier');
|
38
|
+
const { LogLevel: LogLevelEnum } = await import('../../../src/types');
|
39
|
+
|
40
|
+
// Helper for basic CSS mock logic (can be simple for tests)
|
41
|
+
const simpleMockCssMinify = (css: string): string => {
|
42
|
+
return css
|
43
|
+
.replace(/\/\*.*?\*\//g, '') // Remove comments
|
44
|
+
.replace(/\s*([{}:;,])\s*/g, '$1') // Remove space around syntax chars
|
45
|
+
.replace(/\s+/g, ' ') // Collapse remaining whitespace
|
46
|
+
.replace(/;}/g, '}') // Remove trailing semicolons inside blocks
|
47
|
+
.trim();
|
48
|
+
}
|
49
|
+
|
50
|
+
describe('🧼 Minifier', () => {
|
51
|
+
let mockLogger: Logger;
|
52
|
+
let mockLoggerWarnFn: jest.SpiedFunction<typeof Logger.prototype.warn>;
|
53
|
+
let mockLoggerDebugFn: jest.SpiedFunction<typeof Logger.prototype.debug>;
|
54
|
+
|
55
|
+
const sampleHtmlContent = '<html> <head> <title> Test </title> </head> <body> Test Content </body> </html>';
|
56
|
+
// This is the EXPECTED output after full minification by the real library
|
57
|
+
const minifiedHtmlContent = '<html><head><title>Test</title></head><body>Test Content</body></html>';
|
58
|
+
const sampleCssContent = ' body { color: blue; /* comment */ } ';
|
59
|
+
// Expected CSS output from our simple mock helper
|
60
|
+
const minifiedCssContent = 'body{color:blue}';
|
61
|
+
const sampleJsContent = ' function hello ( name ) { console.log("hello", name ); alert ( 1 ) ; } ';
|
62
|
+
// Expected JS output (can be a fixed string for the mock)
|
63
|
+
const minifiedJsContent = 'function hello(o){console.log("hello",o),alert(1)}';
|
64
|
+
|
65
|
+
const sampleParsedInput: ParsedHTML = {
|
66
|
+
htmlContent: sampleHtmlContent,
|
67
|
+
assets: [
|
68
|
+
{ type: 'css', url: 'style.css', content: sampleCssContent },
|
69
|
+
{ type: 'js', url: 'script.js', content: sampleJsContent },
|
70
|
+
{ type: 'image', url: 'logo.png', content: 'data:image/png;base64,abc' },
|
71
|
+
]
|
72
|
+
};
|
73
|
+
|
74
|
+
beforeEach(() => {
|
75
|
+
jest.clearAllMocks(); // Clear mocks between tests
|
76
|
+
|
77
|
+
// Set up logger spies
|
78
|
+
mockLogger = new Logger(LogLevelEnum.DEBUG); // Use DEBUG to see verbose logs if needed
|
79
|
+
mockLoggerWarnFn = jest.spyOn(mockLogger, 'warn');
|
80
|
+
mockLoggerDebugFn = jest.spyOn(mockLogger, 'debug');
|
81
|
+
|
82
|
+
// --- Configure Mock Implementations ---
|
83
|
+
|
84
|
+
// FIX: HTML Mock: Directly return the expected fully minified string.
|
85
|
+
// The mock should simulate the *result* of html-minifier-terser with collapseWhitespace: true.
|
86
|
+
mockHtmlMinifierMinifyFn.mockImplementation(async (_html: string, _options?: HtmlMinifyOptions) => {
|
87
|
+
// Assume if this mock is called, the desired output is the fully minified version
|
88
|
+
return minifiedHtmlContent;
|
89
|
+
});
|
90
|
+
|
91
|
+
// Mock the CleanCSS constructor to return an object with a 'minify' method
|
92
|
+
mockCleanCSSConstructorFn.mockImplementation(() => ({
|
93
|
+
minify: mockCleanCSSInstanceMinifyFn
|
94
|
+
}));
|
95
|
+
|
96
|
+
// Default mock for successful CleanCSS run (synchronous behavior)
|
97
|
+
// Uses the simple helper defined above
|
98
|
+
mockCleanCSSInstanceMinifyFn.mockImplementation((css: string): CleanCSSOutput => {
|
99
|
+
const minifiedStyles = simpleMockCssMinify(css);
|
100
|
+
const stats = { // Provide mock stats structure
|
101
|
+
originalSize: css.length,
|
102
|
+
minifiedSize: minifiedStyles.length,
|
103
|
+
efficiency: css.length > 0 ? (css.length - minifiedStyles.length) / css.length : 0,
|
104
|
+
timeSpent: 1, // Mock time
|
105
|
+
};
|
106
|
+
// Return the structure expected by the code (CleanCSSSyncResult shape)
|
107
|
+
return { styles: minifiedStyles, errors: [], warnings: [], stats: stats };
|
108
|
+
});
|
109
|
+
|
110
|
+
// Default mock for successful Terser run (asynchronous)
|
111
|
+
mockTerserMinifyFn.mockImplementation(async (_js: string, _options?: MinifyOptions): Promise<MinifyOutput> => {
|
112
|
+
// Return the expected minified JS content for the test case
|
113
|
+
return Promise.resolve({ code: minifiedJsContent, error: undefined });
|
114
|
+
});
|
115
|
+
});
|
116
|
+
|
117
|
+
describe('Basic functionality', () => {
|
118
|
+
it('✅ leaves content unchanged when minification is disabled', async () => {
|
119
|
+
const options: BundleOptions = { minifyHtml: false, minifyCss: false, minifyJs: false };
|
120
|
+
const result = await minifyAssets(sampleParsedInput, options, mockLogger);
|
121
|
+
|
122
|
+
expect(mockHtmlMinifierMinifyFn).not.toHaveBeenCalled();
|
123
|
+
expect(mockCleanCSSConstructorFn).not.toHaveBeenCalled(); // Check constructor wasn't called
|
124
|
+
expect(mockTerserMinifyFn).not.toHaveBeenCalled();
|
125
|
+
|
126
|
+
expect(result.htmlContent).toBe(sampleHtmlContent); // Should be original
|
127
|
+
expect(result.assets.find(a => a.type === 'css')?.content).toBe(sampleCssContent); // Should be original
|
128
|
+
expect(result.assets.find(a => a.type === 'js')?.content).toBe(sampleJsContent); // Should be original
|
129
|
+
expect(result.assets.find(a => a.type === 'image')?.content).toBe('data:image/png;base64,abc'); // Should be untouched
|
130
|
+
});
|
131
|
+
|
132
|
+
// Check against the corrected expectations
|
133
|
+
it('🔧 minifies HTML, CSS, and JS with all options enabled (default)', async () => {
|
134
|
+
const result = await minifyAssets(sampleParsedInput, {}, mockLogger); // Default options enable all minification
|
135
|
+
|
136
|
+
expect(mockHtmlMinifierMinifyFn).toHaveBeenCalledTimes(1);
|
137
|
+
// Check the *instance* minify was called, implies constructor was called too
|
138
|
+
expect(mockCleanCSSInstanceMinifyFn).toHaveBeenCalledTimes(1);
|
139
|
+
expect(mockTerserMinifyFn).toHaveBeenCalledTimes(1);
|
140
|
+
|
141
|
+
// Check against the defined minified constants
|
142
|
+
expect(result.htmlContent).toBe(minifiedHtmlContent);
|
143
|
+
expect(result.assets.find(a => a.type === 'css')?.content).toBe(minifiedCssContent);
|
144
|
+
expect(result.assets.find(a => a.type === 'js')?.content).toBe(minifiedJsContent);
|
145
|
+
expect(result.assets.find(a => a.type === 'image')?.content).toBe('data:image/png;base64,abc'); // Should still be untouched
|
146
|
+
});
|
147
|
+
});
|
148
|
+
|
149
|
+
describe('Error handling', () => {
|
150
|
+
it('💥 handles broken HTML minification gracefully', async () => {
|
151
|
+
const htmlError = new Error('HTML parse error!');
|
152
|
+
// Make the HTML mock reject
|
153
|
+
mockHtmlMinifierMinifyFn.mockRejectedValueOnce(htmlError);
|
154
|
+
|
155
|
+
const result = await minifyAssets(sampleParsedInput, {}, mockLogger);
|
156
|
+
|
157
|
+
// Original HTML should be kept
|
158
|
+
expect(result.htmlContent).toBe(sampleHtmlContent);
|
159
|
+
// Other assets should still be minified if successful
|
160
|
+
expect(result.assets.find(a => a.type === 'css')?.content).toBe(minifiedCssContent);
|
161
|
+
expect(result.assets.find(a => a.type === 'js')?.content).toBe(minifiedJsContent);
|
162
|
+
// Logger should have been warned
|
163
|
+
expect(mockLoggerWarnFn).toHaveBeenCalledWith(expect.stringContaining(`HTML minification failed: ${htmlError.message}`));
|
164
|
+
// Ensure other minifiers were still called
|
165
|
+
expect(mockCleanCSSInstanceMinifyFn).toHaveBeenCalledTimes(1);
|
166
|
+
expect(mockTerserMinifyFn).toHaveBeenCalledTimes(1);
|
167
|
+
});
|
168
|
+
|
169
|
+
it('💥 handles CSS minifier failure (returning errors array)', async () => {
|
170
|
+
const cssErrorMsg = 'Invalid CSS syntax';
|
171
|
+
// Make the CSS mock return an error structure
|
172
|
+
mockCleanCSSInstanceMinifyFn.mockReturnValueOnce({
|
173
|
+
errors: [cssErrorMsg], warnings: [], styles: undefined, // No styles on error
|
174
|
+
stats: { originalSize: sampleCssContent.length, minifiedSize: 0, efficiency: 0, timeSpent: 0 }
|
175
|
+
});
|
176
|
+
|
177
|
+
const result = await minifyAssets(sampleParsedInput, {}, mockLogger);
|
178
|
+
|
179
|
+
// HTML and JS should still be minified
|
180
|
+
expect(result.htmlContent).toBe(minifiedHtmlContent);
|
181
|
+
expect(result.assets.find(a => a.type === 'js')?.content).toBe(minifiedJsContent);
|
182
|
+
// Original CSS should be kept
|
183
|
+
expect(result.assets.find(a => a.type === 'css')?.content).toBe(sampleCssContent);
|
184
|
+
// Logger should have been warned
|
185
|
+
expect(mockLoggerWarnFn).toHaveBeenCalledWith(expect.stringContaining(`CleanCSS failed for style.css: ${cssErrorMsg}`));
|
186
|
+
// Ensure other minifiers were still called
|
187
|
+
expect(mockHtmlMinifierMinifyFn).toHaveBeenCalledTimes(1);
|
188
|
+
expect(mockTerserMinifyFn).toHaveBeenCalledTimes(1);
|
189
|
+
});
|
190
|
+
|
191
|
+
it('💥 handles CSS minifier failure (throwing exception)', async () => {
|
192
|
+
const cssError = new Error('CleanCSS crashed!');
|
193
|
+
// Make the CSS mock throw
|
194
|
+
mockCleanCSSInstanceMinifyFn.mockImplementationOnce(() => { throw cssError; });
|
195
|
+
|
196
|
+
const result = await minifyAssets(sampleParsedInput, {}, mockLogger);
|
197
|
+
|
198
|
+
// HTML and JS should still be minified
|
199
|
+
expect(result.htmlContent).toBe(minifiedHtmlContent);
|
200
|
+
expect(result.assets.find(a => a.type === 'js')?.content).toBe(minifiedJsContent);
|
201
|
+
// Original CSS should be kept
|
202
|
+
expect(result.assets.find(a => a.type === 'css')?.content).toBe(sampleCssContent);
|
203
|
+
// Logger should have been warned about the catch block
|
204
|
+
expect(mockLoggerWarnFn).toHaveBeenCalledWith(expect.stringContaining(`Failed to minify asset style.css (css): ${cssError.message}`));
|
205
|
+
// Ensure other minifiers were still called
|
206
|
+
expect(mockHtmlMinifierMinifyFn).toHaveBeenCalledTimes(1);
|
207
|
+
expect(mockTerserMinifyFn).toHaveBeenCalledTimes(1);
|
208
|
+
});
|
209
|
+
|
210
|
+
it('💥 handles JS minifier failure (returning error object)', async () => {
|
211
|
+
const jsError = new Error('Terser parse error!');
|
212
|
+
// Make the JS mock return an error structure (as per Terser docs)
|
213
|
+
mockTerserMinifyFn.mockResolvedValueOnce({ code: undefined, error: jsError });
|
214
|
+
|
215
|
+
const result = await minifyAssets(sampleParsedInput, {}, mockLogger);
|
216
|
+
|
217
|
+
// HTML and CSS should still be minified
|
218
|
+
expect(result.htmlContent).toBe(minifiedHtmlContent);
|
219
|
+
expect(result.assets.find(a => a.type === 'css')?.content).toBe(minifiedCssContent);
|
220
|
+
// Original JS should be kept
|
221
|
+
expect(result.assets.find(a => a.type === 'js')?.content).toBe(sampleJsContent);
|
222
|
+
// Logger should have been warned
|
223
|
+
// Note: Your code checks for `result.code` first, then `(result as any).error`
|
224
|
+
expect(mockLoggerWarnFn).toHaveBeenCalledWith(expect.stringContaining(`Terser failed for script.js: ${jsError.message}`));
|
225
|
+
// Ensure other minifiers were still called
|
226
|
+
expect(mockHtmlMinifierMinifyFn).toHaveBeenCalledTimes(1);
|
227
|
+
expect(mockCleanCSSInstanceMinifyFn).toHaveBeenCalledTimes(1);
|
228
|
+
});
|
229
|
+
|
230
|
+
it('💥 handles JS minifier failure (throwing exception)', async () => {
|
231
|
+
const jsError = new Error('Terser crashed!');
|
232
|
+
// Make the JS mock reject
|
233
|
+
mockTerserMinifyFn.mockRejectedValueOnce(jsError);
|
234
|
+
|
235
|
+
const result = await minifyAssets(sampleParsedInput, {}, mockLogger);
|
236
|
+
|
237
|
+
// HTML and CSS should still be minified
|
238
|
+
expect(result.htmlContent).toBe(minifiedHtmlContent);
|
239
|
+
expect(result.assets.find(a => a.type === 'css')?.content).toBe(minifiedCssContent);
|
240
|
+
// Original JS should be kept
|
241
|
+
expect(result.assets.find(a => a.type === 'js')?.content).toBe(sampleJsContent);
|
242
|
+
// Logger should have been warned from the catch block
|
243
|
+
expect(mockLoggerWarnFn).toHaveBeenCalledWith(expect.stringContaining(`Failed to minify asset script.js (js): ${jsError.message}`));
|
244
|
+
// Ensure other minifiers were still called
|
245
|
+
expect(mockHtmlMinifierMinifyFn).toHaveBeenCalledTimes(1);
|
246
|
+
expect(mockCleanCSSInstanceMinifyFn).toHaveBeenCalledTimes(1);
|
247
|
+
});
|
248
|
+
|
249
|
+
it('🧼 skips minification for assets without content or empty content', async () => {
|
250
|
+
const inputWithMissingContent: ParsedHTML = {
|
251
|
+
htmlContent: sampleHtmlContent,
|
252
|
+
assets: [
|
253
|
+
{ type: 'css', url: 'style.css', content: sampleCssContent }, // Has content
|
254
|
+
{ type: 'js', url: 'missing.js' /* no content property */ },
|
255
|
+
{ type: 'css', url: 'empty.css', content: '' }, // Empty string content
|
256
|
+
{ type: 'js', url: 'script2.js', content: sampleJsContent } // Has content
|
257
|
+
]
|
258
|
+
};
|
259
|
+
const result = await minifyAssets(inputWithMissingContent, {}, mockLogger);
|
260
|
+
|
261
|
+
// Check assets individually
|
262
|
+
expect(result.assets.find(a => a.url === 'style.css')?.content).toBe(minifiedCssContent); // Minified
|
263
|
+
expect(result.assets.find(a => a.url === 'missing.js')?.content).toBeUndefined(); // Still undefined
|
264
|
+
expect(result.assets.find(a => a.url === 'empty.css')?.content).toBe(''); // Still empty
|
265
|
+
expect(result.assets.find(a => a.url === 'script2.js')?.content).toBe(minifiedJsContent); // Minified
|
266
|
+
|
267
|
+
// HTML should still be minified
|
268
|
+
expect(result.htmlContent).toBe(minifiedHtmlContent);
|
269
|
+
|
270
|
+
// Check how many times minifiers were actually called
|
271
|
+
expect(mockCleanCSSInstanceMinifyFn).toHaveBeenCalledTimes(1); // Only for style.css
|
272
|
+
expect(mockTerserMinifyFn).toHaveBeenCalledTimes(1); // Only for script2.js
|
273
|
+
expect(mockHtmlMinifierMinifyFn).toHaveBeenCalledTimes(1); // For the HTML
|
274
|
+
});
|
275
|
+
});
|
276
|
+
|
277
|
+
describe('Selective minification', () => {
|
278
|
+
it('🎛 only minifies CSS + HTML, leaves JS unchanged', async () => {
|
279
|
+
const options: BundleOptions = { minifyHtml: true, minifyCss: true, minifyJs: false };
|
280
|
+
const result = await minifyAssets(sampleParsedInput, options, mockLogger);
|
281
|
+
|
282
|
+
expect(mockHtmlMinifierMinifyFn).toHaveBeenCalledTimes(1);
|
283
|
+
expect(mockCleanCSSInstanceMinifyFn).toHaveBeenCalledTimes(1);
|
284
|
+
expect(mockTerserMinifyFn).not.toHaveBeenCalled(); // JS shouldn't be called
|
285
|
+
|
286
|
+
expect(result.htmlContent).toBe(minifiedHtmlContent); // Minified
|
287
|
+
expect(result.assets.find(a => a.type === 'css')?.content).toBe(minifiedCssContent); // Minified
|
288
|
+
expect(result.assets.find(a => a.type === 'js')?.content).toBe(sampleJsContent); // Original
|
289
|
+
});
|
290
|
+
|
291
|
+
it('🎛 only minifies JS + CSS, leaves HTML unchanged', async () => {
|
292
|
+
const options: BundleOptions = { minifyHtml: false, minifyCss: true, minifyJs: true };
|
293
|
+
const result = await minifyAssets(sampleParsedInput, options, mockLogger);
|
294
|
+
|
295
|
+
expect(mockHtmlMinifierMinifyFn).not.toHaveBeenCalled(); // HTML shouldn't be called
|
296
|
+
expect(mockCleanCSSInstanceMinifyFn).toHaveBeenCalledTimes(1);
|
297
|
+
expect(mockTerserMinifyFn).toHaveBeenCalledTimes(1);
|
298
|
+
|
299
|
+
expect(result.htmlContent).toBe(sampleHtmlContent); // Original
|
300
|
+
expect(result.assets.find(a => a.type === 'css')?.content).toBe(minifiedCssContent); // Minified
|
301
|
+
expect(result.assets.find(a => a.type === 'js')?.content).toBe(minifiedJsContent); // Minified
|
302
|
+
});
|
303
|
+
|
304
|
+
it('🎛 only minifies HTML, leaves CSS/JS unchanged', async () => {
|
305
|
+
const options: BundleOptions = { minifyHtml: true, minifyCss: false, minifyJs: false };
|
306
|
+
const result = await minifyAssets(sampleParsedInput, options, mockLogger);
|
307
|
+
|
308
|
+
expect(mockHtmlMinifierMinifyFn).toHaveBeenCalledTimes(1);
|
309
|
+
expect(mockCleanCSSInstanceMinifyFn).not.toHaveBeenCalled();
|
310
|
+
expect(mockTerserMinifyFn).not.toHaveBeenCalled();
|
311
|
+
|
312
|
+
expect(result.htmlContent).toBe(minifiedHtmlContent); // Minified
|
313
|
+
expect(result.assets.find(a => a.type === 'css')?.content).toBe(sampleCssContent); // Original
|
314
|
+
expect(result.assets.find(a => a.type === 'js')?.content).toBe(sampleJsContent); // Original
|
315
|
+
});
|
316
|
+
});
|
317
|
+
|
318
|
+
describe('Content types', () => {
|
319
|
+
it('📦 only processes css/js types, skips image/font/other', async () => {
|
320
|
+
const inputWithVariousTypes: ParsedHTML = {
|
321
|
+
htmlContent: sampleHtmlContent,
|
322
|
+
assets: [
|
323
|
+
{ type: 'css', url: 'style.css', content: sampleCssContent },
|
324
|
+
{ type: 'js', url: 'script.js', content: sampleJsContent },
|
325
|
+
{ type: 'image', url: 'logo.png', content: 'data:image/png;base64,abc' },
|
326
|
+
{ type: 'font', url: 'font.woff2', content: 'data:font/woff2;base64,def' },
|
327
|
+
{ type: 'other', url: 'data.json', content: '{"a":1}' }
|
328
|
+
]
|
329
|
+
};
|
330
|
+
const result = await minifyAssets(inputWithVariousTypes, {}, mockLogger); // Default options
|
331
|
+
|
332
|
+
expect(mockHtmlMinifierMinifyFn).toHaveBeenCalledTimes(1);
|
333
|
+
expect(mockCleanCSSInstanceMinifyFn).toHaveBeenCalledTimes(1); // Called only for CSS
|
334
|
+
expect(mockTerserMinifyFn).toHaveBeenCalledTimes(1); // Called only for JS
|
335
|
+
|
336
|
+
// Check that non-CSS/JS assets are untouched
|
337
|
+
expect(result.assets.find(a => a.type === 'image')?.content).toBe('data:image/png;base64,abc');
|
338
|
+
expect(result.assets.find(a => a.type === 'font')?.content).toBe('data:font/woff2;base64,def');
|
339
|
+
expect(result.assets.find(a => a.type === 'other')?.content).toBe('{"a":1}');
|
340
|
+
|
341
|
+
// Check CSS/JS were minified
|
342
|
+
expect(result.assets.find(a => a.type === 'css')?.content).toBe(minifiedCssContent);
|
343
|
+
expect(result.assets.find(a => a.type === 'js')?.content).toBe(minifiedJsContent);
|
344
|
+
// Check HTML was minified
|
345
|
+
expect(result.htmlContent).toBe(minifiedHtmlContent);
|
346
|
+
});
|
347
|
+
});
|
348
|
+
|
349
|
+
describe('Edge Cases', () => {
|
350
|
+
it('💨 handles empty input object gracefully', async () => {
|
351
|
+
const emptyInput: ParsedHTML = { htmlContent: '', assets: [] };
|
352
|
+
const result = await minifyAssets(emptyInput, {}, mockLogger);
|
353
|
+
|
354
|
+
expect(result.htmlContent).toBe('');
|
355
|
+
expect(result.assets).toEqual([]);
|
356
|
+
expect(mockHtmlMinifierMinifyFn).not.toHaveBeenCalled();
|
357
|
+
expect(mockCleanCSSConstructorFn).not.toHaveBeenCalled();
|
358
|
+
expect(mockTerserMinifyFn).not.toHaveBeenCalled();
|
359
|
+
expect(mockLoggerDebugFn).toHaveBeenCalledWith('Minification skipped: No content.');
|
360
|
+
});
|
361
|
+
|
362
|
+
it('💨 handles input with assets but empty HTML content string', async () => {
|
363
|
+
const input: ParsedHTML = {
|
364
|
+
htmlContent: '',
|
365
|
+
assets: [ { type: 'css', url: 'style.css', content: sampleCssContent } ]
|
366
|
+
};
|
367
|
+
const result = await minifyAssets(input, {}, mockLogger);
|
368
|
+
|
369
|
+
expect(result.htmlContent).toBe(''); // Should remain empty
|
370
|
+
expect(result.assets.find(a => a.type === 'css')?.content).toBe(minifiedCssContent); // CSS should be minified
|
371
|
+
expect(mockHtmlMinifierMinifyFn).not.toHaveBeenCalled(); // No HTML to minify
|
372
|
+
expect(mockCleanCSSInstanceMinifyFn).toHaveBeenCalledTimes(1);
|
373
|
+
expect(mockTerserMinifyFn).not.toHaveBeenCalled();
|
374
|
+
});
|
375
|
+
|
376
|
+
it('💨 handles input with HTML but empty assets array', async () => {
|
377
|
+
const input: ParsedHTML = { htmlContent: sampleHtmlContent, assets: [] };
|
378
|
+
const result = await minifyAssets(input, {}, mockLogger);
|
379
|
+
|
380
|
+
expect(result.htmlContent).toBe(minifiedHtmlContent); // HTML should be minified
|
381
|
+
expect(result.assets).toEqual([]); // Assets should remain empty
|
382
|
+
expect(mockHtmlMinifierMinifyFn).toHaveBeenCalledTimes(1);
|
383
|
+
expect(mockCleanCSSInstanceMinifyFn).not.toHaveBeenCalled(); // No CSS assets
|
384
|
+
expect(mockTerserMinifyFn).not.toHaveBeenCalled(); // No JS assets
|
385
|
+
});
|
386
|
+
|
387
|
+
// Test case for the CleanCSS warning path (no error, no styles)
|
388
|
+
it('⚠️ handles CleanCSS returning no styles without errors', async () => {
|
389
|
+
mockCleanCSSInstanceMinifyFn.mockReturnValueOnce({
|
390
|
+
errors: [], warnings: [], styles: undefined, // Simulate no styles returned
|
391
|
+
stats: { originalSize: sampleCssContent.length, minifiedSize: 0, efficiency: 0, timeSpent: 0 }
|
392
|
+
});
|
393
|
+
|
394
|
+
const result = await minifyAssets(sampleParsedInput, {}, mockLogger);
|
395
|
+
|
396
|
+
expect(result.htmlContent).toBe(minifiedHtmlContent); // HTML minified
|
397
|
+
expect(result.assets.find(a => a.type === 'css')?.content).toBe(sampleCssContent); // Original CSS kept
|
398
|
+
expect(result.assets.find(a => a.type === 'js')?.content).toBe(minifiedJsContent); // JS minified
|
399
|
+
expect(mockLoggerWarnFn).toHaveBeenCalledWith(expect.stringContaining('CleanCSS produced no styles but reported no errors for style.css. Keeping original.'));
|
400
|
+
});
|
401
|
+
|
402
|
+
// Test case for the Terser warning path (no error, no code)
|
403
|
+
it('⚠️ handles Terser returning no code without errors', async () => {
|
404
|
+
mockTerserMinifyFn.mockResolvedValueOnce({ code: undefined, error: undefined }); // Simulate no code returned
|
405
|
+
|
406
|
+
const result = await minifyAssets(sampleParsedInput, {}, mockLogger);
|
407
|
+
|
408
|
+
expect(result.htmlContent).toBe(minifiedHtmlContent); // HTML minified
|
409
|
+
expect(result.assets.find(a => a.type === 'css')?.content).toBe(minifiedCssContent); // CSS minified
|
410
|
+
expect(result.assets.find(a => a.type === 'js')?.content).toBe(sampleJsContent); // Original JS kept
|
411
|
+
expect(mockLoggerWarnFn).toHaveBeenCalledWith(expect.stringContaining('Terser produced no code but reported no errors for script.js. Keeping original.'));
|
412
|
+
});
|
413
|
+
});
|
414
|
+
});
|
@@ -0,0 +1,193 @@
|
|
1
|
+
/**
|
2
|
+
* @file tests/unit/core/packer.test.ts
|
3
|
+
* @description Unit tests for the HTML packer module (packHTML function).
|
4
|
+
* Focuses on asset inlining, handling different HTML structures, and edge cases.
|
5
|
+
*/
|
6
|
+
|
7
|
+
import * as cheerio from 'cheerio';
|
8
|
+
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
9
|
+
import { packHTML } from '../../../src/core/packer'; // Use .js if outputting JS
|
10
|
+
import { Logger } from '../../../src/utils/logger'; // Use .js if outputting JS
|
11
|
+
import { LogLevel } from '../../../src/types'; // Use .js if outputting JS
|
12
|
+
import type { ParsedHTML, Asset } from '../../../src/types'; // Use .js if outputting JS
|
13
|
+
|
14
|
+
/**
|
15
|
+
* @describe Test suite for the packHTML function in the HTML Packer module.
|
16
|
+
*/
|
17
|
+
describe('📦 HTML Packer - packHTML()', () => {
|
18
|
+
let mockLogger: Logger;
|
19
|
+
let mockLoggerDebugFn: jest.SpiedFunction<typeof Logger.prototype.debug>;
|
20
|
+
let mockLoggerWarnFn: jest.SpiedFunction<typeof Logger.prototype.warn>;
|
21
|
+
|
22
|
+
// --- Test Constants ---
|
23
|
+
const cssUrl = 'style.css';
|
24
|
+
const jsUrl = 'script.js';
|
25
|
+
const imgUrl = 'logo.png';
|
26
|
+
const videoPosterUrl = 'poster.jpg';
|
27
|
+
const videoSrcUrl = 'movie.mp4';
|
28
|
+
const missingAssetUrl = 'not-found.css';
|
29
|
+
const trickyJsUrl = 'tricky.js';
|
30
|
+
|
31
|
+
const cssContent = 'body { background: blue; }';
|
32
|
+
const jsContent = 'console.log("hello");';
|
33
|
+
const jsWithScriptTag = 'console.log("</script>"); alert("hello");';
|
34
|
+
const imgDataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; // 1x1 red pixel png
|
35
|
+
const videoDataUri = 'data:video/mp4;base64,AAAAFGZ0eXBNNFYgAAACAGlzb21pc28yYXZjMQAAAAhmcmVlAAAAGm1kYXQ='; // Minimal mp4
|
36
|
+
|
37
|
+
const sampleAssets: Asset[] = [
|
38
|
+
{ type: 'css', url: cssUrl, content: cssContent },
|
39
|
+
{ type: 'js', url: jsUrl, content: jsContent },
|
40
|
+
{ type: 'js', url: trickyJsUrl, content: jsWithScriptTag },
|
41
|
+
{ type: 'image', url: imgUrl, content: imgDataUri },
|
42
|
+
{ type: 'image', url: videoPosterUrl, content: imgDataUri }, // Using img for poster for simplicity
|
43
|
+
{ type: 'video', url: videoSrcUrl, content: videoDataUri },
|
44
|
+
{ type: 'css', url: missingAssetUrl, content: undefined }, // Asset without content
|
45
|
+
];
|
46
|
+
|
47
|
+
const baseHtml = `
|
48
|
+
<!DOCTYPE html>
|
49
|
+
<html>
|
50
|
+
<head>
|
51
|
+
<meta charset="UTF-8">
|
52
|
+
<title>Test Page</title>
|
53
|
+
<link rel="stylesheet" href="${cssUrl}">
|
54
|
+
<link rel="stylesheet" href="${missingAssetUrl}">
|
55
|
+
</head>
|
56
|
+
<body>
|
57
|
+
<h1>Hello</h1>
|
58
|
+
<img src="${imgUrl}" alt="Logo">
|
59
|
+
<video poster="${videoPosterUrl}" controls>
|
60
|
+
<source src="${videoSrcUrl}" type="video/mp4">
|
61
|
+
Your browser does not support the video tag.
|
62
|
+
</video>
|
63
|
+
<img srcset="${imgUrl} 1x, ${videoPosterUrl} 2x" alt="Srcset Image">
|
64
|
+
<input type="image" src="${imgUrl}" alt="Input image">
|
65
|
+
<script src="${jsUrl}"></script>
|
66
|
+
<script src="non-existent.js"></script>
|
67
|
+
<script> /* Inline script should be kept */ console.log('inline'); </script>
|
68
|
+
</body>
|
69
|
+
</html>
|
70
|
+
`;
|
71
|
+
|
72
|
+
const fragmentHtml = `
|
73
|
+
<div>Just a div</div>
|
74
|
+
<link rel="stylesheet" href="${cssUrl}">
|
75
|
+
<script src="${jsUrl}"></script>
|
76
|
+
`;
|
77
|
+
|
78
|
+
/**
|
79
|
+
* @beforeEach Resets mocks before each test.
|
80
|
+
*/
|
81
|
+
beforeEach(() => {
|
82
|
+
mockLogger = new Logger(LogLevel.WARN); // Use DEBUG level for tests
|
83
|
+
mockLoggerDebugFn = jest.spyOn(mockLogger, 'debug');
|
84
|
+
mockLoggerWarnFn = jest.spyOn(mockLogger, 'warn');
|
85
|
+
});
|
86
|
+
|
87
|
+
/**
|
88
|
+
* @it Tests if packHTML correctly handles HTML fragments (input without <html>, <head>, <body>).
|
89
|
+
* It verifies that Cheerio creates the basic structure, adds the base tag,
|
90
|
+
* preserves original content, and correctly inlines assets (noting that link/script tags
|
91
|
+
* from fragments often end up in the body during parsing).
|
92
|
+
*/
|
93
|
+
it('handles missing <head> and <body> (HTML fragment)', () => {
|
94
|
+
// Input is just a div, link, and script
|
95
|
+
const parsedInput: ParsedHTML = { htmlContent: fragmentHtml, assets: sampleAssets };
|
96
|
+
const result = packHTML(parsedInput, mockLogger);
|
97
|
+
const $ = cheerio.load(result);
|
98
|
+
|
99
|
+
// Verify Cheerio created the basic structure
|
100
|
+
expect($('html').length).toBe(1);
|
101
|
+
expect($('head').length).toBe(1);
|
102
|
+
expect($('body').length).toBe(1);
|
103
|
+
|
104
|
+
// Verify <base> tag was added to the created <head>
|
105
|
+
expect($('head > base[href="./"]').length).toBe(1);
|
106
|
+
|
107
|
+
// Verify the original div exists within the created <body>
|
108
|
+
expect($('body > div:contains("Just a div")').length).toBe(1);
|
109
|
+
|
110
|
+
// Verify assets were inlined into the structure where Cheerio placed the original tags.
|
111
|
+
// NOTE: Cheerio often places fragment <link> and <script> tags into the <body> it creates.
|
112
|
+
// The packer replaces them *in place*.
|
113
|
+
expect($('body > style').length).toBe(1); // <<< FIXED: Check body for style tag from fragment link
|
114
|
+
expect($('body > style').text()).toBe(cssContent); // <<< FIXED: Check body for style tag from fragment link
|
115
|
+
|
116
|
+
// JS likely also goes in body when inlining based on original fragment script placement
|
117
|
+
expect($('body > script:not([src])').length).toBe(1);
|
118
|
+
expect($('body > script:not([src])').html()).toContain(jsContent); // jsContent is 'console.log("hello");'
|
119
|
+
|
120
|
+
// Check relevant logs were called (ensureBaseTag logic)
|
121
|
+
expect(mockLoggerDebugFn).toHaveBeenCalledWith(expect.stringContaining('Loading HTML content into Cheerio'));
|
122
|
+
expect(mockLoggerDebugFn).toHaveBeenCalledWith(expect.stringContaining('Ensuring <base> tag exists...'));
|
123
|
+
// Cheerio creates <head>, so the code finds it and adds the base tag.
|
124
|
+
expect(mockLoggerDebugFn).toHaveBeenCalledWith(expect.stringContaining('Prepending <base href="./"> to <head>.'));
|
125
|
+
expect(mockLoggerDebugFn).toHaveBeenCalledWith(expect.stringContaining('Starting asset inlining...'));
|
126
|
+
// Verify the 'No <head> tag found' log IS NOT called, because Cheerio creates one.
|
127
|
+
expect(mockLoggerDebugFn).not.toHaveBeenCalledWith(expect.stringContaining('No <head> tag found'));
|
128
|
+
});
|
129
|
+
|
130
|
+
/**
|
131
|
+
* @it Tests if packHTML returns a minimal valid HTML shell when input htmlContent is empty or invalid.
|
132
|
+
*/
|
133
|
+
it('returns minimal HTML shell if input htmlContent is empty or invalid', () => {
|
134
|
+
const expectedShell = '<!DOCTYPE html><html><head><base href="./"></head><body></body></html>';
|
135
|
+
|
136
|
+
// Test with empty string
|
137
|
+
const emptyParsed: ParsedHTML = { htmlContent: '', assets: [] };
|
138
|
+
const resultEmpty = packHTML(emptyParsed, mockLogger);
|
139
|
+
expect(resultEmpty).toBe(expectedShell);
|
140
|
+
expect(mockLoggerWarnFn).toHaveBeenCalledWith(expect.stringContaining('Packer received empty or invalid htmlContent'));
|
141
|
+
|
142
|
+
mockLoggerWarnFn.mockClear(); // Reset mock for next check
|
143
|
+
|
144
|
+
// Test with null (simulating invalid input)
|
145
|
+
// @ts-expect-error Testing invalid input type deliberately
|
146
|
+
const nullParsed: ParsedHTML = { htmlContent: null, assets: [] };
|
147
|
+
const resultNull = packHTML(nullParsed, mockLogger);
|
148
|
+
expect(resultNull).toBe(expectedShell);
|
149
|
+
expect(mockLoggerWarnFn).toHaveBeenCalledWith(expect.stringContaining('Packer received empty or invalid htmlContent'));
|
150
|
+
});
|
151
|
+
|
152
|
+
/**
|
153
|
+
* @it Tests if closing script tags within JS content are correctly escaped to prevent breaking the HTML structure.
|
154
|
+
*/
|
155
|
+
it('escapes closing script tags in JS content', () => {
|
156
|
+
const assets: Asset[] = [{ type: 'js', url: trickyJsUrl, content: jsWithScriptTag }];
|
157
|
+
const html = `<html><head></head><body><script src="${trickyJsUrl}"></script></body></html>`;
|
158
|
+
const parsed: ParsedHTML = { htmlContent: html, assets };
|
159
|
+
|
160
|
+
const result = packHTML(parsed, mockLogger);
|
161
|
+
const $ = cheerio.load(result);
|
162
|
+
|
163
|
+
const scriptContent = $('script:not([src])').html();
|
164
|
+
expect(scriptContent).toContain('console.log("<\\/script>");'); // Check for escaped tag: <\/script>
|
165
|
+
expect(scriptContent).toContain('alert("hello");');
|
166
|
+
expect(scriptContent).not.toContain('</script>'); // Original unescaped should not be present
|
167
|
+
});
|
168
|
+
|
169
|
+
/**
|
170
|
+
* @it Tests if attributes (other than 'src') on original script tags are preserved when the script is inlined.
|
171
|
+
*/
|
172
|
+
it('preserves other attributes on inlined script tags', () => {
|
173
|
+
const assets: Asset[] = [{ type: 'js', url: jsUrl, content: jsContent }];
|
174
|
+
// Original script tag has type="module" and defer attributes
|
175
|
+
const html = `<html><head></head><body><script src="${jsUrl}" type="module" defer></script></body></html>`;
|
176
|
+
const parsed: ParsedHTML = { htmlContent: html, assets };
|
177
|
+
const result = packHTML(parsed, mockLogger);
|
178
|
+
const $ = cheerio.load(result);
|
179
|
+
|
180
|
+
const scriptTag = $('script:not([src])');
|
181
|
+
expect(scriptTag.length).toBe(1);
|
182
|
+
expect(scriptTag.attr('type')).toBe('module'); // Check 'type' is preserved
|
183
|
+
expect(scriptTag.attr('defer')).toBeDefined(); // Check 'defer' attribute presence
|
184
|
+
expect(scriptTag.attr('src')).toBeUndefined(); // src should be removed
|
185
|
+
expect(scriptTag.html()).toContain(jsContent); // Check content is inlined
|
186
|
+
});
|
187
|
+
|
188
|
+
// --- Potential Future Tests ---
|
189
|
+
// - HTML without <html> tag initially (covered partly by fragment test)
|
190
|
+
// - Assets with URLs containing special characters? (Cheerio/Map should handle)
|
191
|
+
// - Very large assets? (Performance not tested here)
|
192
|
+
// - Conflicting asset URLs? (Map uses last one - maybe test this explicitly?)
|
193
|
+
});
|