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.
Files changed (76) hide show
  1. package/.eslintrc.json +9 -0
  2. package/.github/workflows/ci.yml +73 -0
  3. package/.github/workflows/deploy-pages.yml +56 -0
  4. package/.prettierrc +9 -0
  5. package/.releaserc.js +29 -0
  6. package/CHANGELOG.md +21 -0
  7. package/README.md +288 -0
  8. package/commitlint.config.js +36 -0
  9. package/dist/cli/cli-entry.js +1694 -0
  10. package/dist/cli/cli-entry.js.map +1 -0
  11. package/dist/index.d.ts +275 -0
  12. package/dist/index.js +1405 -0
  13. package/dist/index.js.map +1 -0
  14. package/docs/.vitepress/config.ts +89 -0
  15. package/docs/.vitepress/sidebar-generator.ts +73 -0
  16. package/docs/cli.md +117 -0
  17. package/docs/code-of-conduct.md +65 -0
  18. package/docs/configuration.md +151 -0
  19. package/docs/contributing.md +107 -0
  20. package/docs/demo.md +46 -0
  21. package/docs/deployment.md +132 -0
  22. package/docs/development.md +168 -0
  23. package/docs/getting-started.md +106 -0
  24. package/docs/index.md +40 -0
  25. package/docs/portapack-transparent.png +0 -0
  26. package/docs/portapack.jpg +0 -0
  27. package/docs/troubleshooting.md +107 -0
  28. package/examples/main.ts +118 -0
  29. package/examples/sample-project/index.html +12 -0
  30. package/examples/sample-project/logo.png +1 -0
  31. package/examples/sample-project/script.js +1 -0
  32. package/examples/sample-project/styles.css +1 -0
  33. package/jest.config.ts +124 -0
  34. package/jest.setup.cjs +211 -0
  35. package/nodemon.json +11 -0
  36. package/output.html +1 -0
  37. package/package.json +161 -0
  38. package/site-packed.html +1 -0
  39. package/src/cli/cli-entry.ts +28 -0
  40. package/src/cli/cli.ts +139 -0
  41. package/src/cli/options.ts +151 -0
  42. package/src/core/bundler.ts +201 -0
  43. package/src/core/extractor.ts +618 -0
  44. package/src/core/minifier.ts +233 -0
  45. package/src/core/packer.ts +191 -0
  46. package/src/core/parser.ts +115 -0
  47. package/src/core/web-fetcher.ts +292 -0
  48. package/src/index.ts +262 -0
  49. package/src/types.ts +163 -0
  50. package/src/utils/font.ts +41 -0
  51. package/src/utils/logger.ts +139 -0
  52. package/src/utils/meta.ts +100 -0
  53. package/src/utils/mime.ts +90 -0
  54. package/src/utils/slugify.ts +70 -0
  55. package/test-output.html +0 -0
  56. package/tests/__fixtures__/sample-project/index.html +5 -0
  57. package/tests/unit/cli/cli-entry.test.ts +104 -0
  58. package/tests/unit/cli/cli.test.ts +230 -0
  59. package/tests/unit/cli/options.test.ts +316 -0
  60. package/tests/unit/core/bundler.test.ts +287 -0
  61. package/tests/unit/core/extractor.test.ts +1129 -0
  62. package/tests/unit/core/minifier.test.ts +414 -0
  63. package/tests/unit/core/packer.test.ts +193 -0
  64. package/tests/unit/core/parser.test.ts +540 -0
  65. package/tests/unit/core/web-fetcher.test.ts +374 -0
  66. package/tests/unit/index.test.ts +339 -0
  67. package/tests/unit/utils/font.test.ts +81 -0
  68. package/tests/unit/utils/logger.test.ts +275 -0
  69. package/tests/unit/utils/meta.test.ts +70 -0
  70. package/tests/unit/utils/mime.test.ts +96 -0
  71. package/tests/unit/utils/slugify.test.ts +71 -0
  72. package/tsconfig.build.json +11 -0
  73. package/tsconfig.jest.json +17 -0
  74. package/tsconfig.json +20 -0
  75. package/tsup.config.ts +71 -0
  76. 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
+ });