portapack 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +5 -4
- package/CHANGELOG.md +20 -0
- package/README.md +81 -219
- package/dist/cli/{cli-entry.js → cli-entry.cjs} +620 -513
- package/dist/cli/cli-entry.cjs.map +1 -0
- package/dist/index.d.ts +51 -56
- package/dist/index.js +517 -458
- package/dist/index.js.map +1 -1
- package/docs/.vitepress/config.ts +0 -1
- package/docs/cli.md +108 -45
- package/docs/configuration.md +101 -116
- package/docs/getting-started.md +74 -44
- package/jest.config.ts +18 -8
- package/jest.setup.cjs +66 -146
- package/package.json +5 -5
- package/src/cli/cli-entry.ts +15 -15
- package/src/cli/cli.ts +130 -119
- package/src/core/bundler.ts +174 -63
- package/src/core/extractor.ts +364 -277
- package/src/core/web-fetcher.ts +205 -141
- package/src/index.ts +161 -224
- package/tests/unit/cli/cli-entry.test.ts +66 -77
- package/tests/unit/cli/cli.test.ts +243 -145
- package/tests/unit/core/bundler.test.ts +334 -258
- package/tests/unit/core/extractor.test.ts +608 -1064
- package/tests/unit/core/minifier.test.ts +130 -221
- package/tests/unit/core/packer.test.ts +255 -106
- package/tests/unit/core/parser.test.ts +89 -458
- package/tests/unit/core/web-fetcher.test.ts +310 -265
- package/tests/unit/index.test.ts +206 -300
- package/tests/unit/utils/logger.test.ts +32 -28
- package/tsconfig.jest.json +8 -7
- package/tsup.config.ts +34 -29
- package/dist/cli/cli-entry.js.map +0 -1
- package/docs/demo.md +0 -46
- package/output.html +0 -1
- package/site-packed.html +0 -1
- package/test-output.html +0 -0
package/src/index.ts
CHANGED
@@ -1,262 +1,199 @@
|
|
1
1
|
/**
|
2
|
-
* @file
|
3
|
-
* @description
|
4
|
-
*
|
5
|
-
*
|
6
|
-
*
|
7
|
-
* It coordinates calls to various core modules (parser, extractor, minifier, packer, web-fetcher, bundler).
|
2
|
+
* @file index.ts
|
3
|
+
* @description Public API surface for PortaPack.
|
4
|
+
* Exposes the unified `pack()` method and advanced helpers like recursive crawling and multi-page bundling.
|
5
|
+
* @version 1.0.0 - (Add version if applicable)
|
6
|
+
* @date 2025-04-11
|
8
7
|
*/
|
9
8
|
|
10
|
-
|
9
|
+
import { fetchAndPackWebPage as coreFetchAndPack, recursivelyBundleSite as coreRecursivelyBundleSite } from './core/web-fetcher';
|
11
10
|
import { parseHTML } from './core/parser';
|
12
11
|
import { extractAssets } from './core/extractor';
|
13
12
|
import { minifyAssets } from './core/minifier';
|
14
13
|
import { packHTML } from './core/packer';
|
15
|
-
|
16
|
-
|
17
|
-
fetchAndPackWebPage as coreFetchAndPack,
|
18
|
-
recursivelyBundleSite as coreRecursivelyBundleSite
|
19
|
-
} from './core/web-fetcher';
|
20
|
-
// Core bundler module (for multi-page)
|
21
|
-
import { bundleMultiPageHTML as coreBundleMultiPageHTML } from './core/bundler';
|
22
|
-
// Utilities
|
23
|
-
import { BuildTimer } from './utils/meta';
|
14
|
+
import { bundleMultiPageHTML } from './core/bundler';
|
15
|
+
|
24
16
|
import { Logger } from './utils/logger';
|
17
|
+
import { BuildTimer } from './utils/meta';
|
25
18
|
|
26
|
-
// Types
|
27
19
|
import type {
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
20
|
+
BundleOptions,
|
21
|
+
BundleMetadata,
|
22
|
+
BuildResult,
|
23
|
+
CLIResult,
|
24
|
+
CLIOptions,
|
25
|
+
LogLevel,
|
26
|
+
LogLevelName,
|
27
|
+
ParsedHTML,
|
28
|
+
Asset,
|
29
|
+
PageEntry,
|
32
30
|
} from './types';
|
33
31
|
|
34
32
|
/**
|
35
|
-
*
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
33
|
+
* Options specifically for the top-level pack function, allowing logger injection.
|
34
|
+
*/
|
35
|
+
interface PackOptions extends BundleOptions {
|
36
|
+
/** Optional custom logger instance to use instead of the default console logger. */
|
37
|
+
loggerInstance?: Logger;
|
38
|
+
}
|
39
|
+
|
40
|
+
/**
|
41
|
+
* Unified high-level API: bundle a local file or remote URL (with optional recursion).
|
42
|
+
* Creates its own logger based on `options.logLevel` unless `options.loggerInstance` is provided.
|
42
43
|
*
|
43
|
-
* @
|
44
|
-
* @param {
|
45
|
-
* @
|
46
|
-
*
|
47
|
-
* @param {Logger} [loggerInstance] - Optional pre-configured logger instance to use.
|
48
|
-
* @returns {Promise<BuildResult>} A promise resolving to an object containing the final HTML string
|
49
|
-
* and metadata (`BundleMetadata`) about the bundling process (input, size, time, assets, errors).
|
50
|
-
* @throws {Error} Throws errors if file reading, parsing, required asset fetching, or processing fails critically.
|
44
|
+
* @param {string} input - File path or remote URL (http/https).
|
45
|
+
* @param {Partial<PackOptions>} [options={}] - Configuration options, including optional `loggerInstance`, `recursive` depth, `logLevel`, etc.
|
46
|
+
* @returns {Promise<BuildResult>} A Promise resolving to an object containing the bundled HTML (`html`) and build metadata (`metadata`).
|
47
|
+
* @throws Will throw an error if the input protocol is unsupported or file reading/network fetching fails.
|
51
48
|
*/
|
52
|
-
export async function
|
53
|
-
|
54
|
-
|
55
|
-
loggerInstance?: Logger // Allow passing logger
|
49
|
+
export async function pack(
|
50
|
+
input: string,
|
51
|
+
options: Partial<PackOptions> = {}
|
56
52
|
): Promise<BuildResult> {
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
logger.info(`Input is a local file path. Starting local processing pipeline...`);
|
80
|
-
// Determine base path for resolving relative assets. Default to input file's path.
|
81
|
-
const basePath = options.baseUrl || input;
|
82
|
-
logger.debug(`Using base path for asset resolution: ${basePath}`);
|
83
|
-
|
84
|
-
try {
|
85
|
-
// Execute the core processing steps sequentially, passing the logger
|
86
|
-
const parsed = await parseHTML(input, logger);
|
87
|
-
const enriched = await extractAssets(parsed, options.embedAssets ?? true, basePath, logger);
|
88
|
-
const minified = await minifyAssets(enriched, options, logger); // Pass full options
|
89
|
-
const finalHtml = packHTML(minified, logger);
|
90
|
-
|
91
|
-
// Finalize metadata using the timer.
|
92
|
-
// Pass assetCount calculated from the final list of processed assets.
|
93
|
-
const metadata = timer.finish(finalHtml, {
|
94
|
-
assetCount: minified.assets.length
|
95
|
-
// FIX: Removed incorrect attempt to get errors from logger
|
96
|
-
// Errors collected by the timer itself (via timer.addError) will be included automatically.
|
97
|
-
});
|
98
|
-
logger.info(`Local processing complete. Input: ${input}, Size: ${metadata.outputSize} bytes, Assets: ${metadata.assetCount}, Time: ${metadata.buildTimeMs}ms`);
|
99
|
-
if (metadata.errors && metadata.errors.length > 0) {
|
100
|
-
logger.warn(`Completed with ${metadata.errors.length} warning(s) logged in metadata.`);
|
101
|
-
}
|
102
|
-
|
103
|
-
// Include any errors collected *by the timer* in the result
|
104
|
-
return { html: finalHtml, metadata };
|
105
|
-
|
106
|
-
} catch (error: any) {
|
107
|
-
logger.error(`Error during local processing for ${input}: ${error.message}`);
|
108
|
-
throw error; // Re-throw critical errors
|
109
|
-
}
|
53
|
+
const logger = options.loggerInstance || new Logger(options.logLevel);
|
54
|
+
const isHttp = /^https?:\/\//i.test(input);
|
55
|
+
|
56
|
+
// Check if it contains '://' but isn't http(s) -> likely unsupported protocol
|
57
|
+
// Allow anything else (including relative/absolute paths without explicit protocols)
|
58
|
+
if (!isHttp && /:\/\//.test(input) && !input.startsWith('file://')) {
|
59
|
+
const errorMsg = `Unsupported protocol or input type: ${input}`;
|
60
|
+
logger.error(errorMsg);
|
61
|
+
throw new Error(errorMsg);
|
62
|
+
}
|
63
|
+
|
64
|
+
const isRemote = /^https?:\/\//i.test(input); // Check again after validation
|
65
|
+
const recursive = options.recursive === true || typeof options.recursive === 'number';
|
66
|
+
|
67
|
+
if (isRemote && recursive) {
|
68
|
+
const depth = typeof options.recursive === 'number' ? options.recursive : 1;
|
69
|
+
logger.info(`Starting recursive fetch for ${input} up to depth ${depth}`);
|
70
|
+
return generateRecursivePortableHTML(input, depth, options, logger);
|
71
|
+
}
|
72
|
+
|
73
|
+
logger.info(`Starting single page processing for: ${input}`);
|
74
|
+
return generatePortableHTML(input, options, logger);
|
110
75
|
}
|
111
76
|
|
112
77
|
/**
|
113
|
-
*
|
114
|
-
*
|
115
|
-
*
|
78
|
+
* Bundle a single HTML file or URL without recursive crawling.
|
79
|
+
* Handles both local file paths and remote HTTP/HTTPS URLs.
|
80
|
+
* If `loggerInstance` is not provided, it creates its own logger based on `options.logLevel`.
|
116
81
|
*
|
117
|
-
* @
|
118
|
-
* @param {
|
119
|
-
* @param {
|
120
|
-
* @
|
121
|
-
* @
|
122
|
-
* @returns {Promise<BuildResult>} A promise resolving to an object containing the bundled multi-page HTML string
|
123
|
-
* and metadata (`BundleMetadata`) about the crawl and bundling process.
|
124
|
-
* @throws {Error} Throws errors if the initial URL is invalid, crawling fails, or bundling fails.
|
82
|
+
* @param {string} input - Local file path or remote URL (http/https).
|
83
|
+
* @param {BundleOptions} [options={}] - Configuration options.
|
84
|
+
* @param {Logger} [loggerInstance] - Optional external logger instance.
|
85
|
+
* @returns {Promise<BuildResult>} A Promise resolving to the build result.
|
86
|
+
* @throws Errors during file reading, network fetching, parsing, or asset processing.
|
125
87
|
*/
|
126
|
-
export async function
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
loggerInstance?: Logger // Allow passing logger
|
88
|
+
export async function generatePortableHTML(
|
89
|
+
input: string,
|
90
|
+
options: BundleOptions = {},
|
91
|
+
loggerInstance?: Logger
|
131
92
|
): Promise<BuildResult> {
|
132
|
-
|
133
|
-
|
134
|
-
logger.info(`Generating recursive portable HTML for: ${url}, Max Depth: ${depth}`);
|
135
|
-
const timer = new BuildTimer(url);
|
136
|
-
|
137
|
-
if (!/^https?:\/\//i.test(url)) {
|
138
|
-
const errMsg = `Invalid input URL for recursive bundling: ${url}. Must start with http(s)://`;
|
139
|
-
logger.error(errMsg);
|
140
|
-
throw new Error(errMsg);
|
141
|
-
}
|
142
|
-
|
143
|
-
// Placeholder output path for core function (consider removing if core doesn't need it)
|
144
|
-
const internalOutputPathPlaceholder = `${new URL(url).hostname}_recursive.html`;
|
93
|
+
const logger = loggerInstance || new Logger(options.logLevel);
|
94
|
+
const timer = new BuildTimer(input);
|
145
95
|
|
96
|
+
if (/^https?:\/\//i.test(input)) {
|
97
|
+
logger.info(`Workspaceing remote page: ${input}`); // Corrected typo "Workspaceing" -> "Fetching"
|
146
98
|
try {
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
// Finalize metadata
|
153
|
-
timer.setPageCount(pages); // Store page count
|
154
|
-
const metadata = timer.finish(html, {
|
155
|
-
assetCount: 0, // NOTE: Asset count across multiple pages is not currently aggregated.
|
156
|
-
pagesBundled: pages
|
157
|
-
// TODO: Potentially collect errors from the core function if it returns them
|
158
|
-
});
|
159
|
-
logger.info(`Recursive bundling complete. Input: ${url}, Size: ${metadata.outputSize} bytes, Pages: ${metadata.pagesBundled}, Time: ${metadata.buildTimeMs}ms`);
|
160
|
-
if (metadata.errors && metadata.errors.length > 0) {
|
161
|
-
logger.warn(`Completed with ${metadata.errors.length} warning(s) logged in metadata.`);
|
162
|
-
}
|
163
|
-
|
164
|
-
return { html, metadata };
|
165
|
-
|
99
|
+
const result = await coreFetchAndPack(input, logger);
|
100
|
+
const metadata = timer.finish(result.html, result.metadata);
|
101
|
+
logger.info(`Finished fetching and packing remote page: ${input}`);
|
102
|
+
return { html: result.html, metadata };
|
166
103
|
} catch (error: any) {
|
167
|
-
logger.error(`Error
|
168
|
-
|
169
|
-
logger.error(`Cause: ${error.cause.message}`);
|
170
|
-
}
|
171
|
-
throw error; // Re-throw
|
104
|
+
logger.error(`Error fetching remote page ${input}: ${error.message}`);
|
105
|
+
throw error;
|
172
106
|
}
|
107
|
+
}
|
108
|
+
|
109
|
+
logger.info(`Processing local file: ${input}`);
|
110
|
+
try {
|
111
|
+
const baseUrl = options.baseUrl || input;
|
112
|
+
// **CRITICAL: These calls MUST use the mocked versions provided by Jest**
|
113
|
+
const parsed = await parseHTML(input, logger);
|
114
|
+
const enriched = await extractAssets(parsed, options.embedAssets ?? true, baseUrl, logger);
|
115
|
+
const minified = await minifyAssets(enriched, options, logger);
|
116
|
+
const finalHtml = packHTML(minified, logger);
|
117
|
+
|
118
|
+
const metadata = timer.finish(finalHtml, {
|
119
|
+
assetCount: minified.assets.length,
|
120
|
+
});
|
121
|
+
logger.info(`Finished processing local file: ${input}`);
|
122
|
+
return { html: finalHtml, metadata };
|
123
|
+
} catch (error: any) {
|
124
|
+
logger.error(`Error processing local file ${input}: ${error.message}`);
|
125
|
+
throw error;
|
126
|
+
}
|
173
127
|
}
|
174
128
|
|
175
129
|
/**
|
176
|
-
*
|
177
|
-
*
|
178
|
-
* It does *not* process assets within the fetched HTML.
|
130
|
+
* Recursively crawl a remote website starting from a URL and bundle it.
|
131
|
+
* If `loggerInstance` is not provided, it creates its own logger based on `options.logLevel`.
|
179
132
|
*
|
180
|
-
* @
|
181
|
-
* @param {
|
182
|
-
* @param {BundleOptions} [options={}] - Configuration options
|
183
|
-
* @param {Logger} [loggerInstance] - Optional
|
184
|
-
* @returns {Promise<BuildResult>} A
|
185
|
-
*
|
186
|
-
* @throws {Error} Propagates errors directly from the core fetching function or if URL is invalid.
|
133
|
+
* @param {string} url - The starting URL (must be http/https).
|
134
|
+
* @param {number} [depth=1] - Maximum recursion depth (0 for only the entry page, 1 for entry + links, etc.).
|
135
|
+
* @param {BundleOptions} [options={}] - Configuration options.
|
136
|
+
* @param {Logger} [loggerInstance] - Optional external logger instance.
|
137
|
+
* @returns {Promise<BuildResult>} A Promise resolving to the build result containing the multi-page bundled HTML.
|
138
|
+
* @throws Errors during network fetching, parsing, or bundling.
|
187
139
|
*/
|
188
|
-
export async function
|
189
|
-
|
190
|
-
|
191
|
-
|
140
|
+
export async function generateRecursivePortableHTML(
|
141
|
+
url: string,
|
142
|
+
depth = 1,
|
143
|
+
options: BundleOptions = {},
|
144
|
+
loggerInstance?: Logger
|
192
145
|
): Promise<BuildResult> {
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
// Return HTML from core result, but use metadata finalized by this wrapper
|
221
|
-
return { html: result.html, metadata };
|
222
|
-
} catch (error: any) {
|
223
|
-
logger.error(`Error during single page fetch for ${url}: ${error.message}`);
|
224
|
-
throw error; // Re-throw original error
|
225
|
-
}
|
146
|
+
const logger = loggerInstance || new Logger(options.logLevel);
|
147
|
+
const timer = new BuildTimer(url);
|
148
|
+
|
149
|
+
if (!/^https?:\/\//i.test(url)) {
|
150
|
+
const errorMsg = `Invalid URL for recursive bundling. Must start with http:// or https://. Received: ${url}`;
|
151
|
+
logger.error(errorMsg);
|
152
|
+
throw new Error(errorMsg);
|
153
|
+
}
|
154
|
+
|
155
|
+
logger.info(`Starting recursive bundle for ${url} up to depth ${depth}`);
|
156
|
+
try {
|
157
|
+
// **CRITICAL: This call MUST use the mocked version provided by Jest**
|
158
|
+
const { html, pages } = await coreRecursivelyBundleSite(url, 'output.html', depth, logger);
|
159
|
+
timer.setPageCount(pages);
|
160
|
+
|
161
|
+
const metadata = timer.finish(html, {
|
162
|
+
assetCount: 0,
|
163
|
+
pagesBundled: pages,
|
164
|
+
});
|
165
|
+
|
166
|
+
logger.info(`Finished recursive bundle for ${url}. Bundled ${pages} pages.`);
|
167
|
+
return { html, metadata };
|
168
|
+
} catch (error: any) {
|
169
|
+
logger.error(`Error during recursive bundle for ${url}: ${error.message}`);
|
170
|
+
throw error;
|
171
|
+
}
|
226
172
|
}
|
227
173
|
|
228
174
|
/**
|
229
|
-
*
|
230
|
-
*
|
231
|
-
* This function does not perform any asset processing on the input HTML strings.
|
232
|
-
*
|
233
|
-
* @export
|
234
|
-
* @param {PageEntry[]} pages - An array of page objects, where each object has a `url` (for slug generation)
|
235
|
-
* and `html` (the content for that page).
|
236
|
-
* @param {BundleOptions} [options={}] - Configuration options, primarily used for `logLevel`.
|
237
|
-
* @param {Logger} [loggerInstance] - Optional pre-configured logger instance.
|
238
|
-
* @returns {string} A single HTML string representing the bundled multi-page document.
|
175
|
+
* Create a multipage HTML bundle directly from provided page entries (HTML content and metadata).
|
176
|
+
* Re-exported from the core bundler module.
|
239
177
|
*/
|
240
|
-
export
|
241
|
-
pages: PageEntry[],
|
242
|
-
options: BundleOptions = {},
|
243
|
-
loggerInstance?: Logger // Allow passing an existing logger
|
244
|
-
): string {
|
245
|
-
// Use passed logger or create a new one
|
246
|
-
const logger = loggerInstance || new Logger(options.logLevel);
|
247
|
-
logger.info(`Bundling ${pages.length} provided pages into multi-page HTML...`);
|
178
|
+
export { bundleMultiPageHTML };
|
248
179
|
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
logger.info(`Multi-page bundling complete.`);
|
254
|
-
return bundledHtml;
|
255
|
-
} catch (error: any) {
|
256
|
-
logger.error(`Error during multi-page bundling: ${error.message}`);
|
257
|
-
throw error; // Re-throw error
|
258
|
-
}
|
259
|
-
}
|
180
|
+
/**
|
181
|
+
* Re-export the Logger class so users can potentially create and pass their own instances.
|
182
|
+
*/
|
183
|
+
export { Logger } from './utils/logger';
|
260
184
|
|
261
|
-
|
262
|
-
|
185
|
+
/**
|
186
|
+
* Re-export shared types for consumers of the library.
|
187
|
+
*/
|
188
|
+
export type {
|
189
|
+
BundleOptions,
|
190
|
+
BundleMetadata,
|
191
|
+
BuildResult,
|
192
|
+
CLIResult,
|
193
|
+
CLIOptions,
|
194
|
+
LogLevel,
|
195
|
+
LogLevelName,
|
196
|
+
ParsedHTML,
|
197
|
+
Asset,
|
198
|
+
PageEntry,
|
199
|
+
};
|
@@ -1,104 +1,93 @@
|
|
1
|
-
|
2
|
-
* @file tests/unit/cli/cli-entry.test.ts
|
3
|
-
* @description Unit tests for the main CLI entry point (assuming it calls cli.main).
|
4
|
-
* Focuses on argument passing and process exit behavior.
|
5
|
-
*/
|
6
|
-
|
1
|
+
// tests/unit/cli/cli-entry.test.ts
|
7
2
|
import type { CLIResult } from '../../../src/types';
|
8
3
|
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
9
4
|
|
10
|
-
//
|
11
|
-
|
5
|
+
// --- Mock Setup ---
|
6
|
+
// Mock the main function from cli.ts that startCLI calls
|
7
|
+
const mockRunCliFn = jest.fn<() => Promise<CLIResult>>();
|
12
8
|
|
13
|
-
jest.
|
14
|
-
|
15
|
-
|
9
|
+
jest.mock('../../../src/cli/cli', () => ({
|
10
|
+
__esModule: true,
|
11
|
+
runCli: mockRunCliFn,
|
12
|
+
main: mockRunCliFn, // Mock both exports for safety
|
16
13
|
}));
|
17
14
|
|
18
|
-
//
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
15
|
+
// Spy on process methods IF startCLI interacts with them directly
|
16
|
+
// NOTE: In the current cli-entry.ts, startCLI *doesn't* directly call exit/write.
|
17
|
+
// The if (require.main === module) block does. So spying here might not be needed
|
18
|
+
// for testing startCLI's direct behavior, but can be kept if needed for other tests.
|
19
|
+
let exitSpy: jest.SpiedFunction<typeof process.exit>;
|
20
|
+
let stdoutSpy: jest.SpiedFunction<typeof process.stdout.write>;
|
21
|
+
let stderrSpy: jest.SpiedFunction<typeof process.stderr.write>;
|
22
|
+
// --- End Mock Setup ---
|
23
23
|
|
24
|
+
// Import the function to test *AFTER* mocks
|
25
|
+
import { startCLI } from '../../../src/cli/cli-entry';
|
24
26
|
|
25
|
-
describe('CLI Entry Point', () => {
|
26
|
-
const originalArgv = process.argv;
|
27
|
+
describe('CLI Entry Point Function (startCLI)', () => {
|
28
|
+
const originalArgv = [...process.argv]; // Clone original argv
|
27
29
|
|
28
30
|
beforeEach(() => {
|
29
31
|
jest.clearAllMocks();
|
30
|
-
process.argv = ['node', 'cli-entry.js'];
|
31
|
-
mockMainFunction.mockResolvedValue({ exitCode: 0, stdout: 'Success', stderr: '' });
|
32
|
-
|
33
|
-
// Apply 'as any' cast HERE (Line 42 approx) during initial spy setup
|
34
|
-
// This is the setup requested to avoid the persistent TS2345 error.
|
35
|
-
exitMock = jest.spyOn(process, 'exit')
|
36
|
-
.mockImplementation(((code?: number): never => { // Use actual signature inside
|
37
|
-
// Default implementation throws to catch unexpected calls
|
38
|
-
throw new Error(`process.exit(${code}) called unexpectedly`);
|
39
|
-
}) as any); // <<< CAST TO ANY HERE
|
40
|
-
});
|
41
32
|
|
42
|
-
|
43
|
-
process.argv =
|
44
|
-
});
|
33
|
+
// Reset argv for each test (startCLI uses process.argv internally)
|
34
|
+
process.argv = ['node', '/path/to/cli-entry.js', 'default-arg'];
|
45
35
|
|
46
|
-
|
47
|
-
|
48
|
-
process.argv = testArgs;
|
49
|
-
const { main } = await import('../../../src/cli/cli');
|
50
|
-
await main(testArgs); // Call the mocked main/runCli
|
51
|
-
expect(mockMainFunction).toHaveBeenCalledWith(testArgs);
|
52
|
-
// Expect exit not to be called (default mock throws if called)
|
53
|
-
});
|
36
|
+
// Default mock implementation for the dependency (runCli)
|
37
|
+
mockRunCliFn.mockResolvedValue({ exitCode: 0, stdout: 'Default Success', stderr: '' });
|
54
38
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
process.
|
39
|
+
// Spies (optional for testing startCLI directly, but good practice)
|
40
|
+
exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
41
|
+
stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
42
|
+
stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
43
|
+
});
|
59
44
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
}) as any); // <<< CAST TO ANY on override
|
45
|
+
afterEach(() => {
|
46
|
+
process.argv = originalArgv; // Restore original argv
|
47
|
+
// jest.restoreAllMocks(); // Usually covered by clearAllMocks
|
48
|
+
});
|
65
49
|
|
66
|
-
|
67
|
-
const
|
50
|
+
it('should call the underlying runCli function with process.argv', async () => {
|
51
|
+
const testArgs = ['node', 'cli-entry.js', 'input.file', '-o', 'output.file'];
|
52
|
+
process.argv = testArgs; // Set specific argv for this test
|
68
53
|
|
69
|
-
|
70
|
-
process.exit(result.exitCode); // Calls the non-throwing mock
|
71
|
-
}
|
54
|
+
await startCLI(); // Execute the function exported from cli-entry
|
72
55
|
|
73
|
-
expect(
|
74
|
-
|
56
|
+
expect(mockRunCliFn).toHaveBeenCalledTimes(1);
|
57
|
+
// Verify runCli received the arguments startCLI got from process.argv
|
58
|
+
expect(mockRunCliFn).toHaveBeenCalledWith(testArgs);
|
59
|
+
// startCLI itself doesn't call process.exit or write, the surrounding block does
|
60
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
61
|
+
expect(stdoutSpy).not.toHaveBeenCalled();
|
62
|
+
expect(stderrSpy).not.toHaveBeenCalled();
|
75
63
|
});
|
76
64
|
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
const result = await runCli(testArgs);
|
65
|
+
it('should return the result object from runCli', async () => {
|
66
|
+
const expectedResult: CLIResult = { exitCode: 2, stdout: 'Programmatic output', stderr: 'Some warning' };
|
67
|
+
mockRunCliFn.mockResolvedValue(expectedResult); // Configure mock return
|
68
|
+
process.argv = ['node', 'cli.js', 'input.html']; // Set argv for this call
|
82
69
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
70
|
+
// Call startCLI and check its return value
|
71
|
+
const result = await startCLI();
|
72
|
+
|
73
|
+
expect(result).toEqual(expectedResult); // Verify return value
|
74
|
+
expect(mockRunCliFn).toHaveBeenCalledTimes(1);
|
75
|
+
expect(mockRunCliFn).toHaveBeenCalledWith(process.argv); // Check args passed
|
76
|
+
expect(exitSpy).not.toHaveBeenCalled(); // startCLI doesn't exit
|
87
77
|
});
|
88
78
|
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
79
|
+
it('should return the rejected promise if runCli rejects', async () => {
|
80
|
+
const testArgs = ['node', 'cli.js', 'crash'];
|
81
|
+
process.argv = testArgs;
|
82
|
+
const testError = new Error('Unhandled crash');
|
83
|
+
mockRunCliFn.mockRejectedValue(testError); // Configure mock rejection
|
94
84
|
|
95
|
-
|
96
|
-
|
97
|
-
// expect(result.exitCode).toBe(1); // Expect exit code 1
|
98
|
-
// expect(result.stderr).toContain(`💥 Error: ${testError.message}`); // Expect error logged
|
85
|
+
// Expect startCLI itself to reject when runCli rejects
|
86
|
+
await expect(startCLI()).rejects.toThrow(testError);
|
99
87
|
|
100
|
-
|
101
|
-
|
102
|
-
|
88
|
+
expect(mockRunCliFn).toHaveBeenCalledTimes(1);
|
89
|
+
expect(mockRunCliFn).toHaveBeenCalledWith(testArgs);
|
90
|
+
expect(exitSpy).not.toHaveBeenCalled(); // No exit from startCLI
|
91
|
+
});
|
103
92
|
|
104
93
|
});
|