@infly/libs 2.0.38 → 2.0.42

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.
@@ -0,0 +1,267 @@
1
+ const assert = require("node:assert/strict");
2
+ const fs = require("node:fs");
3
+ const os = require("node:os");
4
+ const path = require("node:path");
5
+ const test = require("node:test");
6
+
7
+ const {
8
+ loadConfig,
9
+ resolveBuildPlatforms,
10
+ runProjectCommand,
11
+ } = require("./project-command");
12
+ const { EXIT_SELECTION } = require("./select-option");
13
+
14
+ const config = {
15
+ targets: {
16
+ SQ223: { label: "宿迁贰贰叁" },
17
+ QDXY: { label: "青岛信跃" },
18
+ XZYA: { label: "徐州沂埃平台" },
19
+ },
20
+ dev: {
21
+ command: ["pnpm", "exec", "vue-cli-service", "serve"],
22
+ },
23
+ build: {
24
+ before: ["infly-libs", "beforeBuild"],
25
+ command: ["pnpm", "exec", "vue-cli-service", "build"],
26
+ after: ["infly-libs", "afterBuild"],
27
+ environments: {
28
+ prod: { label: "正式环境", mode: "production", args: [] },
29
+ stage: { label: "测试环境", mode: "staging", args: ["--mode", "staging"] },
30
+ },
31
+ presets: {
32
+ all: { label: "批量构建", targets: ["SQ223", "QDXY", "XZYA"] },
33
+ },
34
+ },
35
+ };
36
+
37
+ test("resolveBuildPlatforms derives platforms from shared target keys", () => {
38
+ assert.deepEqual(resolveBuildPlatforms(config.targets, config.build, "SQ223"), ["SQ223"]);
39
+ assert.deepEqual(resolveBuildPlatforms(config.targets, config.build, "all"), [
40
+ "SQ223",
41
+ "QDXY",
42
+ "XZYA",
43
+ ]);
44
+ });
45
+
46
+ test("runProjectCommand accepts target arrays and string dev commands", async () => {
47
+ const calls = [];
48
+ const compactConfig = {
49
+ targets: ["QDXY"],
50
+ dev: { command: "pnpm exec vue-cli-service serve" },
51
+ };
52
+
53
+ await runProjectCommand(
54
+ { action: "dev", target: "QDXY", config: compactConfig, configDir: "C:/project" },
55
+ { runCommand: (command, args, options) => calls.push({ command, args, options }) },
56
+ );
57
+
58
+ assert.equal(calls[0].command, "pnpm");
59
+ assert.deepEqual(calls[0].args, ["exec", "vue-cli-service", "serve"]);
60
+ assert.equal(calls[0].options.env.VUE_APP_PLATFORM, "QDXY");
61
+ });
62
+
63
+ test("runProjectCommand accepts string build lifecycle commands", async () => {
64
+ const calls = [];
65
+ const compactConfig = {
66
+ targets: { SQ223: {} },
67
+ build: {
68
+ before: "infly-libs beforeBuild",
69
+ command: "pnpm exec vue-cli-service build",
70
+ after: "infly-libs afterBuild",
71
+ environments: ["stage:staging"],
72
+ },
73
+ };
74
+
75
+ await runProjectCommand(
76
+ { action: "build", target: "SQ223", env: "stage", config: compactConfig, configDir: "C:/project" },
77
+ { runCommand: (command, args, options) => calls.push({ command, args, options }) },
78
+ );
79
+
80
+ assert.deepEqual(
81
+ calls.map(({ command, args }) => ({ command, args })),
82
+ [
83
+ { command: "infly-libs", args: ["beforeBuild", "--mode", "staging"] },
84
+ { command: "pnpm", args: ["exec", "vue-cli-service", "build", "--mode", "staging"] },
85
+ { command: "infly-libs", args: ["afterBuild", "--mode", "staging"] },
86
+ ],
87
+ );
88
+ });
89
+
90
+ test("loadConfig reads project commands from package.json infly config", () => {
91
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "project-config-discovery-"));
92
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({
93
+ name: "fixture",
94
+ infly: {
95
+ buildConfigs: { enabled: true },
96
+ targets: { QDXY: { label: "青岛信跃" } },
97
+ dev: { marker: "dev" },
98
+ build: { marker: "build" },
99
+ },
100
+ }));
101
+
102
+ try {
103
+ const loaded = loadConfig(dir);
104
+ assert.deepEqual(loaded.config, {
105
+ targets: { QDXY: { label: "青岛信跃" } },
106
+ dev: { marker: "dev" },
107
+ build: { marker: "build" },
108
+ });
109
+ assert.equal(loaded.configDir, dir);
110
+ } finally {
111
+ fs.rmSync(dir, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ test("runProjectCommand starts a selected dev target", async () => {
116
+ const calls = [];
117
+
118
+ await runProjectCommand(
119
+ { action: "dev", target: "QDXY", config, configDir: "C:/project" },
120
+ { runCommand: (command, args, options) => calls.push({ command, args, options }) },
121
+ );
122
+
123
+ assert.equal(calls.length, 1);
124
+ assert.equal(calls[0].command, "pnpm");
125
+ assert.deepEqual(calls[0].args, ["exec", "vue-cli-service", "serve"]);
126
+ assert.equal(calls[0].options.cwd, "C:/project");
127
+ assert.equal(calls[0].options.env.VUE_APP_PLATFORM, "QDXY");
128
+ });
129
+
130
+ test("runProjectCommand executes lifecycle for every selected build platform", async () => {
131
+ const calls = [];
132
+
133
+ await runProjectCommand(
134
+ { action: "build", target: "SQ223", env: "stage", config, configDir: "C:/project" },
135
+ { runCommand: (command, args, options) => calls.push({ command, args, options }) },
136
+ );
137
+
138
+ assert.deepEqual(
139
+ calls.map(({ command, args, options }) => ({
140
+ command,
141
+ args,
142
+ platform: options.env.VUE_APP_PLATFORM,
143
+ })),
144
+ [
145
+ { command: "infly-libs", args: ["beforeBuild", "--mode", "staging"], platform: "SQ223" },
146
+ {
147
+ command: "pnpm",
148
+ args: ["exec", "vue-cli-service", "build", "--mode", "staging"],
149
+ platform: "SQ223",
150
+ },
151
+ { command: "infly-libs", args: ["afterBuild", "--mode", "staging"], platform: "SQ223" },
152
+ ],
153
+ );
154
+ });
155
+
156
+ test("runProjectCommand prompts when action, environment, and target are omitted", async () => {
157
+ const answers = ["build", "stage", "QDXY"];
158
+ const prompts = [];
159
+ const calls = [];
160
+
161
+ await runProjectCommand(
162
+ { config, configDir: "C:/project", dryRun: true },
163
+ {
164
+ selectOption: async (message, choices) => {
165
+ prompts.push({ message, choices });
166
+ return answers.shift();
167
+ },
168
+ runCommand: (...args) => calls.push(args),
169
+ },
170
+ );
171
+
172
+ assert.deepEqual(prompts.map(({ message }) => message), [
173
+ "请选择操作",
174
+ "请选择构建环境",
175
+ "请选择目标平台",
176
+ ]);
177
+ assert.equal(calls.length, 0);
178
+ });
179
+
180
+ test("runProjectCommand exits cleanly when the user chooses exit", async () => {
181
+ const logs = [];
182
+ const result = await runProjectCommand(
183
+ { config, configDir: "C:/project" },
184
+ {
185
+ selectOption: async () => EXIT_SELECTION,
186
+ log: (message) => logs.push(message),
187
+ runCommand: () => assert.fail("no command should run after exit"),
188
+ },
189
+ );
190
+
191
+ assert.deepEqual(result, { cancelled: true });
192
+ assert.deepEqual(logs, ["已退出,未执行任何操作。"]);
193
+ });
194
+
195
+ test("postal project scripts keep defaults non-interactive and platform scripts interactive", () => {
196
+ const appDir = path.resolve(__dirname, "../../../apps/postal-benefits-platform");
197
+ const packageJson = require(path.join(appDir, "package.json"));
198
+ const scripts = packageJson.scripts;
199
+
200
+ assert.equal(scripts.dev, "vue-cli-service serve");
201
+ assert.equal(scripts["project"], "infly-libs project");
202
+ assert.equal(scripts["dev:platform"], "infly-libs project dev");
203
+ assert.equal(scripts["build:prod"], "infly-libs beforeBuild && vue-cli-service build && infly-libs afterBuild");
204
+ assert.equal(scripts["build:stage"], "infly-libs beforeBuild && vue-cli-service build --mode staging && infly-libs afterBuild");
205
+ assert.equal(scripts["build:prod:platform"], "infly-libs project build --env prod");
206
+ assert.equal(scripts["build:stage:platform"], "infly-libs project build --env stage");
207
+ });
208
+
209
+ test("postal package shares one target map across dev and build", () => {
210
+ const appDir = path.resolve(__dirname, "../../../apps/postal-benefits-platform");
211
+ const projectConfig = require(path.join(appDir, "package.json")).infly;
212
+
213
+ assert.equal(projectConfig.dev.targets, undefined);
214
+ assert.equal(projectConfig.build.targets, undefined);
215
+ assert.deepEqual(projectConfig.targets, [
216
+ "DEFAULT",
217
+ "MER",
218
+ "JXEE",
219
+ "JXEELEVEL",
220
+ "SQ223",
221
+ "SQ223LEVEL",
222
+ "QDXY",
223
+ "XZYA",
224
+ ]);
225
+ assert.deepEqual(projectConfig.build.environments, ["prod:production", "stage:staging"]);
226
+ assert.deepEqual(projectConfig.build.presets.all.targets, [
227
+ "SQ223",
228
+ "SQ223LEVEL",
229
+ "QDXY",
230
+ "XZYA",
231
+ ]);
232
+ assert.doesNotMatch(JSON.stringify(projectConfig), /益爱圈/);
233
+ });
234
+
235
+ test("project build requires an explicit environment outside a TTY", async () => {
236
+ await assert.rejects(
237
+ () => runProjectCommand({
238
+ action: "build",
239
+ target: "QDXY",
240
+ dryRun: true,
241
+ configObject: {
242
+ targets: { QDXY: { label: "青岛信跃" } },
243
+ build: {
244
+ before: ["infly-libs", "beforeBuild"],
245
+ command: ["pnpm", "build"],
246
+ after: ["infly-libs", "afterBuild"],
247
+ environments: { prod: { label: "正式环境", mode: "production", args: [] } },
248
+ },
249
+ },
250
+ configDir: ".",
251
+ }),
252
+ /interactive selection requires a TTY/,
253
+ );
254
+ });
255
+ test("runProjectCommand rejects project config file paths", async () => {
256
+ await assert.rejects(
257
+ () => runProjectCommand({ action: "dev", config: "legacy.config.js" }),
258
+ /--config is not supported for project commands/,
259
+ );
260
+ });
261
+
262
+ test("runProjectCommand rejects unknown targets before executing commands", async () => {
263
+ await assert.rejects(
264
+ () => runProjectCommand({ action: "dev", target: "missing", config, configDir: "." }),
265
+ /Unknown dev target: missing/,
266
+ );
267
+ });
@@ -0,0 +1,60 @@
1
+ const EXIT_SELECTION = "__exit__";
2
+
3
+ function isReadlineClosedError(error) {
4
+ return error?.code === "ERR_USE_AFTER_CLOSE";
5
+ }
6
+
7
+ function makePromptCancellationSafe(prompt) {
8
+ const unsafeStop = prompt.stop;
9
+ if (typeof unsafeStop !== "function") return;
10
+
11
+ prompt.removeListener("close", unsafeStop);
12
+ const safeStop = () => {
13
+ try {
14
+ unsafeStop();
15
+ } catch (error) {
16
+ if (!isReadlineClosedError(error)) throw error;
17
+ }
18
+ };
19
+ prompt.stop = safeStop;
20
+ prompt.once("close", safeStop);
21
+ }
22
+
23
+ async function selectOption(message, choices, dependencies = {}) {
24
+ const stdin = dependencies.stdin || process.stdin;
25
+ const stdout = dependencies.stdout || process.stdout;
26
+ if (!stdin.isTTY || !stdout.isTTY) {
27
+ throw new Error(`${message}: interactive selection requires a TTY; pass an explicit option.`);
28
+ }
29
+
30
+ const Select = dependencies.Select || require("enquirer").Select;
31
+ const prompt = new Select({
32
+ name: "selection",
33
+ message,
34
+ stdin,
35
+ stdout,
36
+ choices: [
37
+ ...choices.map((choice) => ({
38
+ name: choice.value,
39
+ message: choice.label,
40
+ })),
41
+ { name: EXIT_SELECTION, message: "退出" },
42
+ ],
43
+ });
44
+
45
+ prompt.once("start", makePromptCancellationSafe);
46
+ try {
47
+ return await prompt.run();
48
+ } catch (error) {
49
+ if (!error || error.name === "CancelPromptError" || isReadlineClosedError(error)) {
50
+ return EXIT_SELECTION;
51
+ }
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ module.exports = {
57
+ EXIT_SELECTION,
58
+ makePromptCancellationSafe,
59
+ selectOption,
60
+ };
@@ -0,0 +1,52 @@
1
+ const assert = require("node:assert/strict");
2
+ const { EventEmitter } = require("node:events");
3
+ const test = require("node:test");
4
+
5
+ const {
6
+ EXIT_SELECTION,
7
+ makePromptCancellationSafe,
8
+ selectOption,
9
+ } = require("./select-option");
10
+
11
+ test("makePromptCancellationSafe ignores readline cleanup after it was closed", () => {
12
+ const prompt = new EventEmitter();
13
+ const unsafeStop = () => {
14
+ const error = new Error("readline was closed");
15
+ error.code = "ERR_USE_AFTER_CLOSE";
16
+ throw error;
17
+ };
18
+ prompt.stop = unsafeStop;
19
+ prompt.once("close", unsafeStop);
20
+
21
+ makePromptCancellationSafe(prompt);
22
+
23
+ assert.doesNotThrow(() => prompt.emit("close"));
24
+ });
25
+
26
+ test("selectOption offers exit and converts cancellation into an exit result", async () => {
27
+ let promptOptions;
28
+ class CancelledSelect extends EventEmitter {
29
+ constructor(options) {
30
+ super();
31
+ promptOptions = options;
32
+ }
33
+
34
+ run() {
35
+ const error = new Error("cancelled");
36
+ error.name = "CancelPromptError";
37
+ return Promise.reject(error);
38
+ }
39
+ }
40
+
41
+ const result = await selectOption(
42
+ "请选择操作",
43
+ [{ value: "dev", label: "启动开发环境" }],
44
+ { Select: CancelledSelect, stdin: { isTTY: true }, stdout: { isTTY: true } },
45
+ );
46
+
47
+ assert.equal(result, EXIT_SELECTION);
48
+ assert.deepEqual(promptOptions.choices.at(-1), {
49
+ name: EXIT_SELECTION,
50
+ message: "退出",
51
+ });
52
+ });