portapack 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.eslintrc.json +67 -8
  2. package/.releaserc.js +25 -27
  3. package/CHANGELOG.md +14 -22
  4. package/LICENSE.md +21 -0
  5. package/README.md +22 -53
  6. package/commitlint.config.js +30 -34
  7. package/dist/cli/cli-entry.cjs +183 -98
  8. package/dist/cli/cli-entry.cjs.map +1 -1
  9. package/dist/index.d.ts +0 -3
  10. package/dist/index.js +178 -97
  11. package/dist/index.js.map +1 -1
  12. package/docs/.vitepress/config.ts +38 -33
  13. package/docs/.vitepress/sidebar-generator.ts +89 -38
  14. package/docs/architecture.md +186 -0
  15. package/docs/cli.md +23 -23
  16. package/docs/code-of-conduct.md +7 -1
  17. package/docs/configuration.md +12 -11
  18. package/docs/contributing.md +6 -2
  19. package/docs/deployment.md +10 -5
  20. package/docs/development.md +8 -5
  21. package/docs/getting-started.md +13 -13
  22. package/docs/index.md +1 -1
  23. package/docs/public/android-chrome-192x192.png +0 -0
  24. package/docs/public/android-chrome-512x512.png +0 -0
  25. package/docs/public/apple-touch-icon.png +0 -0
  26. package/docs/public/favicon-16x16.png +0 -0
  27. package/docs/public/favicon-32x32.png +0 -0
  28. package/docs/public/favicon.ico +0 -0
  29. package/docs/roadmap.md +233 -0
  30. package/docs/site.webmanifest +1 -0
  31. package/docs/troubleshooting.md +12 -1
  32. package/examples/main.ts +5 -30
  33. package/examples/sample-project/script.js +1 -1
  34. package/jest.config.ts +8 -13
  35. package/nodemon.json +5 -10
  36. package/package.json +2 -5
  37. package/src/cli/cli-entry.ts +2 -2
  38. package/src/cli/cli.ts +21 -16
  39. package/src/cli/options.ts +127 -113
  40. package/src/core/bundler.ts +253 -222
  41. package/src/core/extractor.ts +632 -565
  42. package/src/core/minifier.ts +173 -162
  43. package/src/core/packer.ts +141 -137
  44. package/src/core/parser.ts +74 -73
  45. package/src/core/web-fetcher.ts +270 -258
  46. package/src/index.ts +18 -17
  47. package/src/types.ts +9 -11
  48. package/src/utils/font.ts +12 -6
  49. package/src/utils/logger.ts +110 -105
  50. package/src/utils/meta.ts +75 -76
  51. package/src/utils/mime.ts +50 -50
  52. package/src/utils/slugify.ts +33 -34
  53. package/tests/unit/cli/cli-entry.test.ts +72 -70
  54. package/tests/unit/cli/cli.test.ts +314 -278
  55. package/tests/unit/cli/options.test.ts +294 -301
  56. package/tests/unit/core/bundler.test.ts +426 -329
  57. package/tests/unit/core/extractor.test.ts +793 -549
  58. package/tests/unit/core/minifier.test.ts +374 -274
  59. package/tests/unit/core/packer.test.ts +298 -264
  60. package/tests/unit/core/parser.test.ts +538 -150
  61. package/tests/unit/core/web-fetcher.test.ts +389 -359
  62. package/tests/unit/index.test.ts +238 -197
  63. package/tests/unit/utils/font.test.ts +26 -21
  64. package/tests/unit/utils/logger.test.ts +267 -260
  65. package/tests/unit/utils/meta.test.ts +29 -28
  66. package/tests/unit/utils/mime.test.ts +73 -74
  67. package/tests/unit/utils/slugify.test.ts +14 -12
  68. package/tsconfig.build.json +9 -10
  69. package/tsconfig.jest.json +1 -1
  70. package/tsconfig.json +2 -2
  71. package/tsup.config.ts +8 -9
  72. package/typedoc.json +5 -9
  73. /package/docs/{portapack-transparent.png → public/portapack-transparent.png} +0 -0
  74. /package/docs/{portapack.jpg → public/portapack.jpg} +0 -0
@@ -1,5 +1,3 @@
1
- // tests/unit/cli/cli.test.ts
2
-
3
1
  import { jest, describe, it, beforeEach, afterEach, expect } from '@jest/globals'; // Assuming beforeAll/afterAll aren't strictly needed for these tests unless used by setup
4
2
 
5
3
  // Import types
@@ -19,12 +17,12 @@ const mockPackFn = jest.fn<() => Promise<BuildResult>>();
19
17
 
20
18
  // Tell Jest to replace the actual modules with our mocks *before* cli.ts is imported
21
19
  jest.mock('../../../src/cli/options', () => ({
22
- __esModule: true,
23
- parseOptions: mockParseOptions
20
+ __esModule: true,
21
+ parseOptions: mockParseOptions,
24
22
  }));
25
23
  jest.mock('../../../src/index', () => ({
26
- __esModule: true,
27
- pack: mockPackFn
24
+ __esModule: true,
25
+ pack: mockPackFn,
28
26
  }));
29
27
 
30
28
  // --- Import the module under test AFTER mocks are defined ---
@@ -33,296 +31,334 @@ import { runCli } from '../../../src/cli/cli';
33
31
 
34
32
  // --- Test Suite ---
35
33
  describe('CLI Runner Logic', () => {
36
-
37
- // --- Declare Spies for FS module (used by cli.ts) ---
38
- let writeFileSyncSpy: jest.SpiedFunction<typeof fs.writeFileSync>;
39
- let existsSyncSpy: jest.SpiedFunction<typeof fs.existsSync>;
40
- let readFileSyncSpy: jest.SpiedFunction<typeof fs.readFileSync>;
41
-
42
- // Helper to create mock metadata
43
- const createMockMetadata = (input: string, overrides: Partial<BundleMetadata> = {}): BundleMetadata => ({
44
- input: input,
45
- assetCount: 5,
46
- outputSize: 1024,
47
- buildTimeMs: 100,
48
- pagesBundled: undefined, // Default to undefined
49
- errors: [], // Default to empty errors/warnings array
50
- ...overrides,
34
+ // --- Declare Spies for FS module (used by cli.ts) ---
35
+ let writeFileSyncSpy: jest.SpiedFunction<typeof fs.writeFileSync>;
36
+ let existsSyncSpy: jest.SpiedFunction<typeof fs.existsSync>;
37
+ let readFileSyncSpy: jest.SpiedFunction<typeof fs.readFileSync>;
38
+
39
+ // Helper to create mock metadata
40
+ const createMockMetadata = (
41
+ input: string,
42
+ overrides: Partial<BundleMetadata> = {}
43
+ ): BundleMetadata => ({
44
+ input: input,
45
+ assetCount: 5,
46
+ outputSize: 1024,
47
+ buildTimeMs: 100,
48
+ pagesBundled: undefined, // Default to undefined
49
+ errors: [], // Default to empty errors/warnings array
50
+ ...overrides,
51
+ });
52
+
53
+ // Default options structure returned by the mock parseOptions
54
+ const defaultCliOptions: CLIOptions = {
55
+ input: 'default.html',
56
+ output: undefined,
57
+ recursive: false,
58
+ dryRun: false,
59
+ logLevel: LogLevel.INFO,
60
+ verbose: false,
61
+ embedAssets: true,
62
+ minifyHtml: true,
63
+ minifyCss: true,
64
+ minifyJs: true,
65
+ };
66
+
67
+ beforeEach(() => {
68
+ // --- Reset ALL Mocks and Spies ---
69
+ // Clears call history etc. between tests
70
+ jest.clearAllMocks();
71
+
72
+ // --- Setup Spies on FS module ---
73
+ // Mock implementations for fs functions called by cli.ts
74
+ writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); // Don't actually write files
75
+ existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true); // Assume files exist by default
76
+
77
+ // Mock readFileSync to handle the package.json read required by getPackageJson (internal to cli.ts)
78
+ readFileSyncSpy = jest.spyOn(fs, 'readFileSync').mockImplementation((pathInput, options) => {
79
+ const pathStr = String(pathInput); // Ensure path is treated as a string
80
+ // Check if it's trying to read package.json (adjust check if needed)
81
+ if (pathStr.includes('portapack') && pathStr.endsWith('package.json')) {
82
+ return JSON.stringify({ version: '1.0.0-test' }); // Return mock version
83
+ }
84
+ // If cli.ts needed to read other files, mock them here too
85
+ // if (pathStr.endsWith('some-other-expected-file.txt')) { return 'file content'; }
86
+
87
+ // Throw an error for any unexpected file reads during tests
88
+ throw new Error(`Unexpected readFileSync call in test: ${pathStr}`);
51
89
  });
52
90
 
53
- // Default options structure returned by the mock parseOptions
54
- const defaultCliOptions: CLIOptions = {
55
- input: 'default.html',
56
- output: undefined,
57
- recursive: false,
58
- dryRun: false,
59
- logLevel: LogLevel.INFO,
60
- verbose: false,
61
- embedAssets: true,
62
- minifyHtml: true,
63
- minifyCss: true,
64
- minifyJs: true
91
+ // --- Default mock implementations for dependencies ---
92
+ // Ensure parseOptions returns a valid structure by default
93
+ mockParseOptions.mockReturnValue(defaultCliOptions);
94
+ // Ensure pack resolves successfully by default
95
+ mockPackFn.mockResolvedValue({
96
+ html: '<html>Default Pack Result</html>',
97
+ metadata: createMockMetadata(defaultCliOptions.input ?? 'default.html'), // Use input from default options
98
+ });
99
+ });
100
+
101
+ afterEach(() => {
102
+ // Restore original implementations spied on by jest.spyOn
103
+ // Important to avoid mocks leaking between test files
104
+ jest.restoreAllMocks();
105
+ });
106
+
107
+ // --- Tests ---
108
+
109
+ it('calls parseOptions with process arguments', async () => {
110
+ const testArgs = ['node', 'cli.js', 'test-input.html', '-o', 'test-output.html'];
111
+ const specificOptions = {
112
+ ...defaultCliOptions,
113
+ input: 'test-input.html',
114
+ output: 'test-output.html',
65
115
  };
66
-
67
- beforeEach(() => {
68
- // --- Reset ALL Mocks and Spies ---
69
- // Clears call history etc. between tests
70
- jest.clearAllMocks();
71
-
72
- // --- Setup Spies on FS module ---
73
- // Mock implementations for fs functions called by cli.ts
74
- writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => { }); // Don't actually write files
75
- existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true); // Assume files exist by default
76
-
77
- // Mock readFileSync to handle the package.json read required by getPackageJson (internal to cli.ts)
78
- readFileSyncSpy = jest.spyOn(fs, 'readFileSync')
79
- .mockImplementation((pathInput, options) => {
80
- const pathStr = String(pathInput); // Ensure path is treated as a string
81
- // Check if it's trying to read package.json (adjust check if needed)
82
- if (pathStr.includes('portapack') && pathStr.endsWith('package.json')) {
83
- return JSON.stringify({ version: "1.0.0-test" }); // Return mock version
84
- }
85
- // If cli.ts needed to read other files, mock them here too
86
- // if (pathStr.endsWith('some-other-expected-file.txt')) { return 'file content'; }
87
-
88
- // Throw an error for any unexpected file reads during tests
89
- throw new Error(`Unexpected readFileSync call in test: ${pathStr}`);
90
- });
91
-
92
- // --- Default mock implementations for dependencies ---
93
- // Ensure parseOptions returns a valid structure by default
94
- mockParseOptions.mockReturnValue(defaultCliOptions);
95
- // Ensure pack resolves successfully by default
96
- mockPackFn.mockResolvedValue({
97
- html: '<html>Default Pack Result</html>',
98
- metadata: createMockMetadata(defaultCliOptions.input ?? 'default.html') // Use input from default options
99
- });
116
+ mockParseOptions.mockReturnValueOnce(specificOptions); // Override default for this test
117
+ // Configure pack mock for this specific input if its result matters
118
+ mockPackFn.mockResolvedValueOnce({
119
+ html: '<html>Specific Test Result</html>',
120
+ metadata: createMockMetadata('test-input.html'),
100
121
  });
101
122
 
102
- afterEach(() => {
103
- // Restore original implementations spied on by jest.spyOn
104
- // Important to avoid mocks leaking between test files
105
- jest.restoreAllMocks();
123
+ await runCli(testArgs);
124
+
125
+ expect(mockParseOptions).toHaveBeenCalledTimes(1);
126
+ expect(mockParseOptions).toHaveBeenCalledWith(testArgs);
127
+ // Verify pack was called because parseOptions returned valid input and not dry-run
128
+ expect(mockPackFn).toHaveBeenCalledTimes(1);
129
+ // Check if pack was called with the correct arguments derived from options
130
+ expect(mockPackFn).toHaveBeenCalledWith(specificOptions.input, specificOptions);
131
+ });
132
+
133
+ it('calls pack for non-recursive input and writes file', async () => {
134
+ const args = ['node', 'cli.js', 'index.html', '-o', 'output.html'];
135
+ const mockCliOptions: CLIOptions = {
136
+ ...defaultCliOptions,
137
+ input: 'index.html',
138
+ output: 'output.html', // Explicit output path
139
+ recursive: false,
140
+ };
141
+ const specificMetadata = createMockMetadata('index.html', {
142
+ assetCount: 3,
143
+ outputSize: 2048,
144
+ buildTimeMs: 55,
106
145
  });
107
-
108
- // --- Tests ---
109
-
110
- it('calls parseOptions with process arguments', async () => {
111
- const testArgs = ['node', 'cli.js', 'test-input.html', '-o', 'test-output.html'];
112
- const specificOptions = { ...defaultCliOptions, input: 'test-input.html', output: 'test-output.html' };
113
- mockParseOptions.mockReturnValueOnce(specificOptions); // Override default for this test
114
- // Configure pack mock for this specific input if its result matters
115
- mockPackFn.mockResolvedValueOnce({ html: '<html>Specific Test Result</html>', metadata: createMockMetadata('test-input.html') });
116
-
117
- await runCli(testArgs);
118
-
119
- expect(mockParseOptions).toHaveBeenCalledTimes(1);
120
- expect(mockParseOptions).toHaveBeenCalledWith(testArgs);
121
- // Verify pack was called because parseOptions returned valid input and not dry-run
122
- expect(mockPackFn).toHaveBeenCalledTimes(1);
123
- // Check if pack was called with the correct arguments derived from options
124
- expect(mockPackFn).toHaveBeenCalledWith(specificOptions.input, specificOptions);
146
+ mockParseOptions.mockReturnValueOnce(mockCliOptions); // Use specific options for this test
147
+ mockPackFn.mockResolvedValueOnce({
148
+ // Use specific pack result for this test
149
+ html: '<html>Packed Non-Recursive</html>',
150
+ metadata: specificMetadata,
125
151
  });
126
152
 
127
- it('calls pack for non-recursive input and writes file', async () => {
128
- const args = ['node', 'cli.js', 'index.html', '-o', 'output.html'];
129
- const mockCliOptions: CLIOptions = {
130
- ...defaultCliOptions,
131
- input: 'index.html',
132
- output: 'output.html', // Explicit output path
133
- recursive: false
134
- };
135
- const specificMetadata = createMockMetadata('index.html', { assetCount: 3, outputSize: 2048, buildTimeMs: 55 });
136
- mockParseOptions.mockReturnValueOnce(mockCliOptions); // Use specific options for this test
137
- mockPackFn.mockResolvedValueOnce({ // Use specific pack result for this test
138
- html: '<html>Packed Non-Recursive</html>',
139
- metadata: specificMetadata
140
- });
141
-
142
- const result = await runCli(args);
143
-
144
- // Check that parseOptions was called
145
- expect(mockParseOptions).toHaveBeenCalledWith(args);
146
- // Check pack was called correctly
147
- expect(mockPackFn).toHaveBeenCalledTimes(1);
148
- expect(mockPackFn).toHaveBeenCalledWith(mockCliOptions.input, mockCliOptions); // Called with input and options object
149
- // Check file write happened
150
- expect(writeFileSyncSpy).toHaveBeenCalledTimes(1);
151
- expect(writeFileSyncSpy).toHaveBeenCalledWith('output.html', '<html>Packed Non-Recursive</html>', 'utf-8');
152
- // Check result object
153
- expect(result.exitCode).toBe(0);
154
- expect(result.stdout).toContain('✅ Packed: index.html → output.html');
155
- expect(result.stdout).toContain('📦 Size: 2.00 KB'); // Check stats based on mock metadata
153
+ const result = await runCli(args);
154
+
155
+ // Check that parseOptions was called
156
+ expect(mockParseOptions).toHaveBeenCalledWith(args);
157
+ // Check pack was called correctly
158
+ expect(mockPackFn).toHaveBeenCalledTimes(1);
159
+ expect(mockPackFn).toHaveBeenCalledWith(mockCliOptions.input, mockCliOptions); // Called with input and options object
160
+ // Check file write happened
161
+ expect(writeFileSyncSpy).toHaveBeenCalledTimes(1);
162
+ expect(writeFileSyncSpy).toHaveBeenCalledWith(
163
+ 'output.html',
164
+ '<html>Packed Non-Recursive</html>',
165
+ 'utf-8'
166
+ );
167
+ // Check result object
168
+ expect(result.exitCode).toBe(0);
169
+ expect(result.stdout).toContain('✅ Packed: index.html → output.html');
170
+ expect(result.stdout).toContain('📦 Size: 2.00 KB'); // Check stats based on mock metadata
171
+ });
172
+
173
+ it('calls pack for recursive input (boolean) and writes file', async () => {
174
+ const args = ['node', 'cli.js', 'https://site.com', '-r', '-o', 'site-packed.html'];
175
+ const mockCliOptions: CLIOptions = {
176
+ ...defaultCliOptions,
177
+ input: 'https://site.com',
178
+ output: 'site-packed.html',
179
+ recursive: true, // Recursive flag is true
180
+ };
181
+ const specificMetadata = createMockMetadata('https://site.com', {
182
+ pagesBundled: 3,
183
+ assetCount: 10,
156
184
  });
157
-
158
- it('calls pack for recursive input (boolean) and writes file', async () => {
159
- const args = ['node', 'cli.js', 'https://site.com', '-r', '-o', 'site-packed.html'];
160
- const mockCliOptions: CLIOptions = {
161
- ...defaultCliOptions,
162
- input: 'https://site.com',
163
- output: 'site-packed.html',
164
- recursive: true, // Recursive flag is true
165
- };
166
- const specificMetadata = createMockMetadata('https://site.com', { pagesBundled: 3, assetCount: 10 });
167
- mockParseOptions.mockReturnValueOnce(mockCliOptions);
168
- mockPackFn.mockResolvedValueOnce({
169
- html: '<html>Packed Recursive Boolean</html>',
170
- metadata: specificMetadata
171
- });
172
-
173
- const result = await runCli(args);
174
-
175
- expect(mockParseOptions).toHaveBeenCalledWith(args);
176
- // Check pack was called
177
- expect(mockPackFn).toHaveBeenCalledTimes(1);
178
- expect(mockPackFn).toHaveBeenCalledWith(mockCliOptions.input, mockCliOptions);
179
- // Check file write
180
- expect(writeFileSyncSpy).toHaveBeenCalledTimes(1);
181
- expect(writeFileSyncSpy).toHaveBeenCalledWith('site-packed.html', '<html>Packed Recursive Boolean</html>', 'utf-8');
182
- // Check result object and specific recursive output
183
- expect(result.exitCode).toBe(0);
184
- expect(result.stdout).toContain('✅ Packed: https://site.com → site-packed.html');
185
- expect(result.stdout).toContain('🧩 Pages: 3'); // Check pages bundled output
185
+ mockParseOptions.mockReturnValueOnce(mockCliOptions);
186
+ mockPackFn.mockResolvedValueOnce({
187
+ html: '<html>Packed Recursive Boolean</html>',
188
+ metadata: specificMetadata,
186
189
  });
187
190
 
188
- it('calls pack for recursive input (number) and writes file', async () => {
189
- const args = ['node', 'cli.js', 'https://site.com', '-r', '2', '-o', 'site-packed.html'];
190
- const mockCliOptions: CLIOptions = {
191
- ...defaultCliOptions,
192
- input: 'https://site.com',
193
- output: 'site-packed.html',
194
- recursive: 2, // Recursive flag is number
195
- };
196
- const specificMetadata = createMockMetadata('https://site.com', { pagesBundled: 5 });
197
- mockParseOptions.mockReturnValueOnce(mockCliOptions);
198
- mockPackFn.mockResolvedValueOnce({
199
- html: '<html>Packed Recursive Number</html>',
200
- metadata: specificMetadata
201
- });
202
-
203
- const result = await runCli(args);
204
-
205
- expect(mockParseOptions).toHaveBeenCalledWith(args);
206
- // Check pack was called
207
- expect(mockPackFn).toHaveBeenCalledTimes(1);
208
- expect(mockPackFn).toHaveBeenCalledWith(mockCliOptions.input, mockCliOptions);
209
- // Check file write
210
- expect(writeFileSyncSpy).toHaveBeenCalledTimes(1);
211
- expect(writeFileSyncSpy).toHaveBeenCalledWith('site-packed.html', '<html>Packed Recursive Number</html>', 'utf-8');
212
- // Check result object and specific recursive output
213
- expect(result.exitCode).toBe(0);
214
- expect(result.stdout).toContain('✅ Packed: https://site.com → site-packed.html');
215
- expect(result.stdout).toContain('🧩 Pages: 5');
191
+ const result = await runCli(args);
192
+
193
+ expect(mockParseOptions).toHaveBeenCalledWith(args);
194
+ // Check pack was called
195
+ expect(mockPackFn).toHaveBeenCalledTimes(1);
196
+ expect(mockPackFn).toHaveBeenCalledWith(mockCliOptions.input, mockCliOptions);
197
+ // Check file write
198
+ expect(writeFileSyncSpy).toHaveBeenCalledTimes(1);
199
+ expect(writeFileSyncSpy).toHaveBeenCalledWith(
200
+ 'site-packed.html',
201
+ '<html>Packed Recursive Boolean</html>',
202
+ 'utf-8'
203
+ );
204
+ // Check result object and specific recursive output
205
+ expect(result.exitCode).toBe(0);
206
+ expect(result.stdout).toContain('✅ Packed: https://site.com → site-packed.html');
207
+ expect(result.stdout).toContain('🧩 Pages: 3'); // Check pages bundled output
208
+ });
209
+
210
+ it('calls pack for recursive input (number) and writes file', async () => {
211
+ const args = ['node', 'cli.js', 'https://site.com', '-r', '2', '-o', 'site-packed.html'];
212
+ const mockCliOptions: CLIOptions = {
213
+ ...defaultCliOptions,
214
+ input: 'https://site.com',
215
+ output: 'site-packed.html',
216
+ recursive: 2, // Recursive flag is number
217
+ };
218
+ const specificMetadata = createMockMetadata('https://site.com', { pagesBundled: 5 });
219
+ mockParseOptions.mockReturnValueOnce(mockCliOptions);
220
+ mockPackFn.mockResolvedValueOnce({
221
+ html: '<html>Packed Recursive Number</html>',
222
+ metadata: specificMetadata,
216
223
  });
217
224
 
218
- it('returns error if input is missing', async () => {
219
- const args = ['node', 'cli.js']; // No input provided
220
- // Simulate parseOptions returning options without an input
221
- mockParseOptions.mockReturnValueOnce({ ...defaultCliOptions, input: undefined });
222
-
223
- const result = await runCli(args);
224
-
225
- expect(mockParseOptions).toHaveBeenCalledWith(args);
226
- // Check for early exit
227
- expect(result.exitCode).toBe(1);
228
- expect(result.stderr).toContain(' Missing input file or URL');
229
- // Ensure pack and writeFile were not called
230
- expect(mockPackFn).not.toHaveBeenCalled();
231
- expect(writeFileSyncSpy).not.toHaveBeenCalled();
225
+ const result = await runCli(args);
226
+
227
+ expect(mockParseOptions).toHaveBeenCalledWith(args);
228
+ // Check pack was called
229
+ expect(mockPackFn).toHaveBeenCalledTimes(1);
230
+ expect(mockPackFn).toHaveBeenCalledWith(mockCliOptions.input, mockCliOptions);
231
+ // Check file write
232
+ expect(writeFileSyncSpy).toHaveBeenCalledTimes(1);
233
+ expect(writeFileSyncSpy).toHaveBeenCalledWith(
234
+ 'site-packed.html',
235
+ '<html>Packed Recursive Number</html>',
236
+ 'utf-8'
237
+ );
238
+ // Check result object and specific recursive output
239
+ expect(result.exitCode).toBe(0);
240
+ expect(result.stdout).toContain('✅ Packed: https://site.com → site-packed.html');
241
+ expect(result.stdout).toContain('🧩 Pages: 5');
242
+ });
243
+
244
+ it('returns error if input is missing', async () => {
245
+ const args = ['node', 'cli.js']; // No input provided
246
+ // Simulate parseOptions returning options without an input
247
+ mockParseOptions.mockReturnValueOnce({ ...defaultCliOptions, input: undefined });
248
+
249
+ const result = await runCli(args);
250
+
251
+ expect(mockParseOptions).toHaveBeenCalledWith(args);
252
+ // Check for early exit
253
+ expect(result.exitCode).toBe(1);
254
+ expect(result.stderr).toContain('❌ Missing input file or URL');
255
+ // Ensure pack and writeFile were not called
256
+ expect(mockPackFn).not.toHaveBeenCalled();
257
+ expect(writeFileSyncSpy).not.toHaveBeenCalled();
258
+ });
259
+
260
+ it('skips processing and writing in dry-run mode', async () => {
261
+ const args = ['node', 'cli.js', 'index.html', '--dry-run'];
262
+ // Simulate parseOptions returning dryRun: true
263
+ mockParseOptions.mockReturnValueOnce({
264
+ ...defaultCliOptions,
265
+ input: 'index.html',
266
+ dryRun: true,
232
267
  });
233
268
 
234
- it('skips processing and writing in dry-run mode', async () => {
235
- const args = ['node', 'cli.js', 'index.html', '--dry-run'];
236
- // Simulate parseOptions returning dryRun: true
237
- mockParseOptions.mockReturnValueOnce({ ...defaultCliOptions, input: 'index.html', dryRun: true });
238
-
239
- const result = await runCli(args);
240
-
241
- expect(mockParseOptions).toHaveBeenCalledWith(args);
242
- // Ensure pack and writeFile were not called due to dry run
243
- expect(mockPackFn).not.toHaveBeenCalled();
244
- expect(writeFileSyncSpy).not.toHaveBeenCalled();
245
- // Check for successful exit code and dry run message
246
- expect(result.exitCode).toBe(0);
247
- expect(result.stdout).toContain('💡 Dry run mode');
269
+ const result = await runCli(args);
270
+
271
+ expect(mockParseOptions).toHaveBeenCalledWith(args);
272
+ // Ensure pack and writeFile were not called due to dry run
273
+ expect(mockPackFn).not.toHaveBeenCalled();
274
+ expect(writeFileSyncSpy).not.toHaveBeenCalled();
275
+ // Check for successful exit code and dry run message
276
+ expect(result.exitCode).toBe(0);
277
+ expect(result.stdout).toContain('💡 Dry run mode');
278
+ });
279
+
280
+ it('handles unexpected errors from pack() gracefully', async () => {
281
+ const args = ['node', 'cli.js', './valid-input.html'];
282
+ const errorMessage = 'Something broke during processing';
283
+ // Use valid options, but make pack() reject
284
+ mockParseOptions.mockReturnValueOnce({ ...defaultCliOptions, input: './valid-input.html' });
285
+ mockPackFn.mockRejectedValueOnce(new Error(errorMessage)); // Simulate error from pack
286
+
287
+ const result = await runCli(args);
288
+
289
+ expect(mockParseOptions).toHaveBeenCalledWith(args);
290
+ expect(mockPackFn).toHaveBeenCalledTimes(1); // Ensure pack was called
291
+ // Check for error exit code and message
292
+ expect(result.exitCode).toBe(1);
293
+ expect(result.stderr).toContain(`💥 Error: ${errorMessage}`);
294
+ // Ensure file was not written on error
295
+ expect(writeFileSyncSpy).not.toHaveBeenCalled();
296
+ });
297
+
298
+ it('displays warnings from metadata in stderr', async () => {
299
+ const args = ['node', 'cli.js', 'index.html', '-o', 'output.html']; // Ensure output path is set
300
+ const warningMessage = 'Asset not found: missing.css';
301
+ const metadataWithWarning = createMockMetadata('index.html', { errors: [warningMessage] }); // Add error/warning to metadata
302
+ mockParseOptions.mockReturnValueOnce({
303
+ ...defaultCliOptions,
304
+ input: 'index.html',
305
+ output: 'output.html',
248
306
  });
249
-
250
- it('handles unexpected errors from pack() gracefully', async () => {
251
- const args = ['node', 'cli.js', './valid-input.html'];
252
- const errorMessage = 'Something broke during processing';
253
- // Use valid options, but make pack() reject
254
- mockParseOptions.mockReturnValueOnce({ ...defaultCliOptions, input: './valid-input.html' });
255
- mockPackFn.mockRejectedValueOnce(new Error(errorMessage)); // Simulate error from pack
256
-
257
- const result = await runCli(args);
258
-
259
- expect(mockParseOptions).toHaveBeenCalledWith(args);
260
- expect(mockPackFn).toHaveBeenCalledTimes(1); // Ensure pack was called
261
- // Check for error exit code and message
262
- expect(result.exitCode).toBe(1);
263
- expect(result.stderr).toContain(`💥 Error: ${errorMessage}`);
264
- // Ensure file was not written on error
265
- expect(writeFileSyncSpy).not.toHaveBeenCalled();
307
+ mockPackFn.mockResolvedValueOnce({
308
+ html: '<html>Warning</html>',
309
+ metadata: metadataWithWarning,
310
+ }); // Return metadata with warning
311
+
312
+ const result = await runCli(args);
313
+
314
+ expect(mockParseOptions).toHaveBeenCalledWith(args);
315
+ expect(mockPackFn).toHaveBeenCalledTimes(1);
316
+ expect(writeFileSyncSpy).toHaveBeenCalledTimes(1); // Verify write still happens
317
+ expect(writeFileSyncSpy).toHaveBeenCalledWith('output.html', '<html>Warning</html>', 'utf-8');
318
+ // Should exit 0 even with warnings
319
+ expect(result.exitCode).toBe(0);
320
+ // Standard output should still show success
321
+ expect(result.stdout).toContain('✅ Packed: index.html → output.html');
322
+ expect(result.stderr).toMatch(/⚠️\s+1\s+warning\(s\):/);
323
+ // Warnings should be logged to stderr
324
+ expect(result.stderr).toContain(`- ${warningMessage}`);
325
+ });
326
+
327
+ it('displays verbose startup logs when --verbose is used', async () => {
328
+ const args = ['node', 'cli.js', 'index.html', '--verbose', '-o', 'out.html']; // Add verbose flag
329
+ const verboseOptions: CLIOptions = {
330
+ ...defaultCliOptions,
331
+ input: 'index.html',
332
+ output: 'out.html', // Explicit output needed for logs
333
+ verbose: true,
334
+ logLevel: LogLevel.DEBUG, // Verbose usually implies a lower log level from parseOptions
335
+ };
336
+ mockParseOptions.mockReturnValueOnce(verboseOptions);
337
+ // We still need pack to resolve successfully
338
+ mockPackFn.mockResolvedValue({
339
+ html: '<!DOCTYPE html><html><body>Mock HTML</body></html>',
340
+ metadata: createMockMetadata('index.html'), // Use simple metadata
266
341
  });
267
342
 
268
- it('displays warnings from metadata in stderr', async () => {
269
- const args = ['node', 'cli.js', 'index.html', '-o', 'output.html']; // Ensure output path is set
270
- const warningMessage = 'Asset not found: missing.css';
271
- const metadataWithWarning = createMockMetadata('index.html', { errors: [warningMessage] }); // Add error/warning to metadata
272
- mockParseOptions.mockReturnValueOnce({ ...defaultCliOptions, input: 'index.html', output: 'output.html' });
273
- mockPackFn.mockResolvedValueOnce({ html: '<html>Warning</html>', metadata: metadataWithWarning }); // Return metadata with warning
274
-
275
- const result = await runCli(args);
276
-
277
- expect(mockParseOptions).toHaveBeenCalledWith(args);
278
- expect(mockPackFn).toHaveBeenCalledTimes(1);
279
- expect(writeFileSyncSpy).toHaveBeenCalledTimes(1); // Verify write still happens
280
- expect(writeFileSyncSpy).toHaveBeenCalledWith('output.html', '<html>Warning</html>', 'utf-8');
281
- // Should exit 0 even with warnings
282
- expect(result.exitCode).toBe(0);
283
- // Standard output should still show success
284
- expect(result.stdout).toContain('✅ Packed: index.html → output.html');
285
- expect(result.stderr).toMatch(/⚠️\s+1\s+warning\(s\):/);
286
- // Warnings should be logged to stderr
287
- expect(result.stderr).toContain(`- ${warningMessage}`);
288
- });
343
+ const result = await runCli(args);
289
344
 
290
- it('displays verbose startup logs when --verbose is used', async () => {
291
- const args = ['node', 'cli.js', 'index.html', '--verbose', '-o', 'out.html']; // Add verbose flag
292
- const verboseOptions: CLIOptions = {
293
- ...defaultCliOptions,
294
- input: 'index.html',
295
- output: 'out.html', // Explicit output needed for logs
296
- verbose: true,
297
- logLevel: LogLevel.DEBUG // Verbose usually implies a lower log level from parseOptions
298
- };
299
- mockParseOptions.mockReturnValueOnce(verboseOptions);
300
- // We still need pack to resolve successfully
301
- mockPackFn.mockResolvedValue({
302
- html: '<!DOCTYPE html><html><body>Mock HTML</body></html>',
303
- metadata: createMockMetadata('index.html') // Use simple metadata
304
- });
305
-
306
- const result = await runCli(args);
307
-
308
- const expectedVersionString = `📦 PortaPack v${packageJson.version}`; // Get version dynamically
309
-
310
- expect(mockParseOptions).toHaveBeenCalledWith(args);
311
- expect(mockPackFn).toHaveBeenCalledTimes(1);
312
- expect(writeFileSyncSpy).toHaveBeenCalledTimes(1); // Write should still occur
313
-
314
- // Check stdout for verbose logs
315
- expect(result.stdout).toContain(expectedVersionString);
316
- expect(result.stdout).toContain('📥 Input: index.html');
317
- expect(result.stdout).toContain('📤 Output: out.html'); // Checks resolved output path
318
- expect(result.stdout).toContain('Recursive: false'); // Check other options logged
319
- expect(result.stdout).toContain(`Log Level: ${verboseOptions.logLevel}`); // Check logged log level
320
-
321
- // Check for standard success logs as well
322
- expect(result.stdout).toContain('✅ Packed: index.html → out.html');
323
- expect(result.exitCode).toBe(0);
324
- });
345
+ const expectedVersionString = `📦 PortaPack v${packageJson.version}`; // Get version dynamically
346
+
347
+ expect(mockParseOptions).toHaveBeenCalledWith(args);
348
+ expect(mockPackFn).toHaveBeenCalledTimes(1);
349
+ expect(writeFileSyncSpy).toHaveBeenCalledTimes(1); // Write should still occur
350
+
351
+ // Check stdout for verbose logs
352
+ expect(result.stdout).toContain(expectedVersionString);
353
+ expect(result.stdout).toContain('📥 Input: index.html');
354
+ expect(result.stdout).toContain('📤 Output: out.html'); // Checks resolved output path
355
+ expect(result.stdout).toContain('Recursive: false'); // Check other options logged
356
+ expect(result.stdout).toContain(`Log Level: ${verboseOptions.logLevel}`); // Check logged log level
325
357
 
326
- // Add more tests for other options, edge cases, different log levels etc.
358
+ // Check for standard success logs as well
359
+ expect(result.stdout).toContain('✅ Packed: index.html → out.html');
360
+ expect(result.exitCode).toBe(0);
361
+ });
327
362
 
328
- });
363
+ // Add more tests for other options, edge cases, different log levels etc.
364
+ });