@oniroproject/oniro-app 0.17.0 → 0.19.0
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/README.md +687 -31
- package/dist/oniro-app.js +3450 -559
- package/dist/oniro-app.js.map +1 -1
- package/package.json +2 -2
- package/templates/EmptyAbility/entry/src/ohosTest/ets/test/Index.test.ets +33 -0
- package/templates/EmptyAbility/entry/src/ohosTest/ets/test/List.test.ets +3 -1
- package/templates/EmptyAbility/entry/src/ohosTest/module.json5 +1 -1
- package/templates/HarmonyOSApp/entry/src/ohosTest/ets/test/Index.test.ets +33 -0
- package/templates/HarmonyOSApp/entry/src/ohosTest/ets/test/List.test.ets +3 -1
- package/templates/HarmonyOSApp/entry/src/ohosTest/module.json5 +1 -1
- package/templates/NativeCpp/entry/src/ohosTest/ets/test/List.test.ets +3 -1
- package/templates/NativeCpp/entry/src/ohosTest/ets/test/Napi.test.ets +15 -0
- package/templates/NativeCpp/entry/src/ohosTest/module.json5 +1 -1
package/dist/oniro-app.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/bin/oniro-app.ts
|
|
4
|
-
import { Command as
|
|
5
|
-
import { OniroError as
|
|
4
|
+
import { Command as Command11, CommanderError } from "commander";
|
|
5
|
+
import { OniroError as OniroError7 } from "@oniroproject/core";
|
|
6
6
|
|
|
7
7
|
// src/commands/sdk.ts
|
|
8
8
|
import * as fs from "fs";
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
OHOS_DAILY_BUILDS_URL,
|
|
13
13
|
downloadAndInstallSdk,
|
|
14
14
|
getOsFolder,
|
|
15
|
+
getHarmonyOsSdkListing,
|
|
15
16
|
getSdkRootDir,
|
|
16
17
|
getSupportedSdksForUi,
|
|
17
18
|
installSdkFromArchive,
|
|
@@ -47,16 +48,16 @@ function createCliProgress() {
|
|
|
47
48
|
let lastEmitted = -1;
|
|
48
49
|
let lastMessage = "";
|
|
49
50
|
return {
|
|
50
|
-
report({ message, increment }) {
|
|
51
|
+
report({ message: message2, increment }) {
|
|
51
52
|
if (typeof increment === "number") total = Math.min(100, total + increment);
|
|
52
53
|
const pct = total > 0 ? Math.round(total) : null;
|
|
53
|
-
const msg =
|
|
54
|
+
const msg = message2 ?? lastMessage;
|
|
54
55
|
if (pct === lastEmitted && msg === lastMessage) return;
|
|
55
56
|
const prefix = pct === null ? "[progress]" : `[progress ${pct}%]`;
|
|
56
57
|
process.stderr.write(`${prefix} ${msg}
|
|
57
58
|
`);
|
|
58
59
|
lastEmitted = pct ?? lastEmitted;
|
|
59
|
-
if (
|
|
60
|
+
if (message2) lastMessage = message2;
|
|
60
61
|
}
|
|
61
62
|
};
|
|
62
63
|
}
|
|
@@ -78,7 +79,9 @@ var ENV_VAR_BY_KEY = {
|
|
|
78
79
|
applicationCertPath: "ONIRO_APPLICATION_CERT_PATH",
|
|
79
80
|
harmonyosSdkPath: "ONIRO_HARMONYOS_SDK_PATH",
|
|
80
81
|
harmonyosAuthDir: "ONIRO_HARMONYOS_AUTH_DIR",
|
|
81
|
-
harmonyosSigningDir: "ONIRO_HARMONYOS_SIGNING_DIR"
|
|
82
|
+
harmonyosSigningDir: "ONIRO_HARMONYOS_SIGNING_DIR",
|
|
83
|
+
cacheDir: "ONIRO_CACHE_DIR",
|
|
84
|
+
docsDir: "ONIRO_DOCS_DIR"
|
|
82
85
|
};
|
|
83
86
|
function createEnvConfig(env = process.env) {
|
|
84
87
|
return {
|
|
@@ -106,9 +109,23 @@ function getRuntime() {
|
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
// src/commands/sdk.ts
|
|
112
|
+
var HARMONYOS_SOURCE = {
|
|
113
|
+
cmdtools: "from the command-line tools",
|
|
114
|
+
"deveco-studio": "from DevEco Studio",
|
|
115
|
+
configured: "from ONIRO_HARMONYOS_SDK_PATH"
|
|
116
|
+
};
|
|
117
|
+
function formatHarmonyOsSdkNote(listing) {
|
|
118
|
+
const note = "HarmonyOS SDKs are not managed by `sdk`: they ship inside the HarmonyOS command-line tools (`oniro-app cmdtools`) or DevEco Studio.";
|
|
119
|
+
if (!listing) return `${note}
|
|
120
|
+
No HarmonyOS SDK found; \`oniro-app cmdtools install\` installs one.`;
|
|
121
|
+
const name = listing.displayName ?? `HarmonyOS ${listing.version ?? ""}`.trim();
|
|
122
|
+
const from = listing.source ? `, ${HARMONYOS_SOURCE[listing.source]}` : "";
|
|
123
|
+
return `${note}
|
|
124
|
+
* ${name.padEnd(17)} api ${listing.api ?? "?"} ${listing.path}${from}`;
|
|
125
|
+
}
|
|
109
126
|
var FULL_SDK_TIP = `Tip: the full SDK (with system APIs) is not on the release mirror. Download an ohos-sdk-full archive from the OpenHarmony daily builds, ${OHOS_DAILY_BUILDS_URL}, and install it with \`oniro-app sdk install --from-archive <archive>\`.`;
|
|
110
127
|
function registerSdkCommand(program) {
|
|
111
|
-
const sdk = program.command("sdk").description("Manage OpenHarmony SDK installs.");
|
|
128
|
+
const sdk = program.command("sdk").description("Manage OpenHarmony SDK installs. HarmonyOS SDKs come with the command-line tools (`cmdtools`).");
|
|
112
129
|
sdk.command("install [version]").description(
|
|
113
130
|
"Download and install an OpenHarmony SDK (skips if already installed), or install one from a local archive with --from-archive."
|
|
114
131
|
).option(
|
|
@@ -166,16 +183,22 @@ ${FULL_SDK_TIP}`).action(async (version, opts) => {
|
|
|
166
183
|
});
|
|
167
184
|
logger.info("SDK installed.");
|
|
168
185
|
});
|
|
169
|
-
sdk.command("list").description(
|
|
186
|
+
sdk.command("list").description(
|
|
187
|
+
"List known OpenHarmony SDK versions and whether they are installed on this machine, then the HarmonyOS SDK, which comes with the command-line tools (`cmdtools`) or DevEco Studio, if one is found."
|
|
188
|
+
).option("--json", "Emit machine-readable JSON instead of a table.").action((opts) => {
|
|
170
189
|
const { config } = getRuntime();
|
|
171
190
|
const sdks = getSupportedSdksForUi(config);
|
|
191
|
+
const harmonyOs = getHarmonyOsSdkListing(config);
|
|
172
192
|
if (opts.json) {
|
|
173
|
-
|
|
193
|
+
const rows2 = [...sdks.map((s) => ({ ...s, flavour: "OpenHarmony" })), ...harmonyOs ? [harmonyOs] : []];
|
|
194
|
+
process.stdout.write(`${JSON.stringify(rows2, null, 2)}
|
|
174
195
|
`);
|
|
175
196
|
return;
|
|
176
197
|
}
|
|
177
198
|
const rows = sdks.map((s) => `${s.installed ? "*" : " "} ${s.version.padEnd(8)} api ${s.api}`);
|
|
178
199
|
process.stdout.write(`${rows.join("\n")}
|
|
200
|
+
|
|
201
|
+
${formatHarmonyOsSdkNote(harmonyOs)}
|
|
179
202
|
`);
|
|
180
203
|
});
|
|
181
204
|
sdk.command("remove <api>").description("Remove an installed SDK by API level (e.g. 18, 20).").action((api) => {
|
|
@@ -190,24 +213,40 @@ ${FULL_SDK_TIP}`).action(async (version, opts) => {
|
|
|
190
213
|
}
|
|
191
214
|
|
|
192
215
|
// src/commands/cmdtools.ts
|
|
193
|
-
import {
|
|
216
|
+
import {
|
|
217
|
+
describeCmdTools,
|
|
218
|
+
getCmdToolsStatus,
|
|
219
|
+
installCmdTools,
|
|
220
|
+
removeCmdTools
|
|
221
|
+
} from "@oniroproject/core";
|
|
222
|
+
function formatCmdToolsSdk(sdk) {
|
|
223
|
+
const name = [sdk.displayName, sdk.apiVersion ? `API ${sdk.apiVersion}` : void 0].filter(Boolean).join(", ");
|
|
224
|
+
return `${name || "SDK"}${sdk.build ? ` (${sdk.build})` : ""} at ${sdk.path}`;
|
|
225
|
+
}
|
|
194
226
|
function registerCmdToolsCommand(program) {
|
|
195
|
-
const cmdtools = program.command("cmdtools").description(
|
|
196
|
-
|
|
227
|
+
const cmdtools = program.command("cmdtools").description(
|
|
228
|
+
"Manage the command-line tools (hvigorw, ohpm, codelinter, hdc). Their HarmonyOS edition also supplies the HarmonyOS SDK, which `sdk` does not manage."
|
|
229
|
+
);
|
|
230
|
+
cmdtools.command("install").description(
|
|
231
|
+
"Install the command-line tools (hvigorw, ohpm, codelinter, hdc and, in the HarmonyOS edition, the HarmonyOS SDK). Skips if already installed."
|
|
232
|
+
).option("--from-zip <path>", "Install from a local ZIP instead of downloading.").option("--force", "Reinstall even if the tools are already present.").option(
|
|
197
233
|
"--tmp-dir <path>",
|
|
198
234
|
"Directory for download/extract temporaries (default: ONIRO_TMP_DIR, else .oniro-tmp next to the install target). Use this when that filesystem is short on space."
|
|
199
235
|
).action(async (opts) => {
|
|
200
236
|
const { config, logger, progress } = getRuntime();
|
|
201
|
-
|
|
202
|
-
|
|
237
|
+
const current = getCmdToolsStatus(config);
|
|
238
|
+
if (current.installed && !opts.force) {
|
|
239
|
+
logger.info(
|
|
240
|
+
`Command-line tools ${describeCmdTools(current)} already installed at ${current.path}; pass --force to replace them.`
|
|
241
|
+
);
|
|
203
242
|
return;
|
|
204
243
|
}
|
|
205
|
-
logger.info("Installing
|
|
206
|
-
await installCmdTools({ config, progress, logger, localZipPath: opts.fromZip, tmpDir: opts.tmpDir });
|
|
207
|
-
logger.info(
|
|
244
|
+
logger.info("Installing command-line tools...");
|
|
245
|
+
const result = await installCmdTools({ config, progress, logger, localZipPath: opts.fromZip, tmpDir: opts.tmpDir });
|
|
246
|
+
logger.info(`Command-line tools ${describeCmdTools(result.installed)} installed at ${result.path}.`);
|
|
208
247
|
});
|
|
209
248
|
cmdtools.command("status").description(
|
|
210
|
-
"Report whether the command-line tools are installed, their version
|
|
249
|
+
"Report whether the command-line tools are installed, their version, flavour (HarmonyOS or OpenHarmony), the SDK they bundle (name, API level), their tools' versions (hvigor, ohpm, ...), and the hdc binary every device command uses."
|
|
211
250
|
).option("--json", "Emit machine-readable JSON.").action((opts) => {
|
|
212
251
|
const { config } = getRuntime();
|
|
213
252
|
const status = getCmdToolsStatus(config);
|
|
@@ -216,11 +255,15 @@ function registerCmdToolsCommand(program) {
|
|
|
216
255
|
`);
|
|
217
256
|
return;
|
|
218
257
|
}
|
|
219
|
-
process.stdout.write(`${status.status}
|
|
258
|
+
process.stdout.write(`${status.version ? `Installed ${describeCmdTools(status)}` : status.status}
|
|
220
259
|
`);
|
|
221
260
|
process.stdout.write(`path: ${status.path}
|
|
222
261
|
`);
|
|
223
262
|
if (status.flavour) process.stdout.write(`flavour: ${status.flavour}
|
|
263
|
+
`);
|
|
264
|
+
if (status.sdk) process.stdout.write(`sdk: ${formatCmdToolsSdk(status.sdk)}
|
|
265
|
+
`);
|
|
266
|
+
if (status.tools?.hvigor) process.stdout.write(`hvigor: ${status.tools.hvigor}
|
|
224
267
|
`);
|
|
225
268
|
process.stdout.write(`hdc: ${status.hdc.path}${status.hdc.exists ? "" : " (missing)"}
|
|
226
269
|
`);
|
|
@@ -234,15 +277,56 @@ function registerCmdToolsCommand(program) {
|
|
|
234
277
|
}
|
|
235
278
|
|
|
236
279
|
// src/commands/emulator.ts
|
|
280
|
+
import { InvalidArgumentError } from "commander";
|
|
237
281
|
import {
|
|
282
|
+
MAX_POOL_INSTANCE,
|
|
283
|
+
POOL_DEFAULT_MEMORY,
|
|
284
|
+
POOL_DEFAULT_RESOLUTION,
|
|
285
|
+
POOL_DEFAULT_SMP,
|
|
286
|
+
DEFAULT_BOOT_TIMEOUT_SECONDS,
|
|
238
287
|
attemptHdcConnection,
|
|
239
288
|
getEmulatorStatus,
|
|
289
|
+
getPoolInstanceStatus,
|
|
240
290
|
installEmulator,
|
|
241
291
|
isEmulatorInstalled,
|
|
242
292
|
removeEmulator,
|
|
243
293
|
startEmulator,
|
|
244
|
-
|
|
294
|
+
startPoolInstance,
|
|
295
|
+
stopEmulator,
|
|
296
|
+
stopPoolInstance
|
|
245
297
|
} from "@oniroproject/core";
|
|
298
|
+
function parseInstance(v) {
|
|
299
|
+
const n = Number(v);
|
|
300
|
+
if (!/^\d+$/.test(v) || n > MAX_POOL_INSTANCE) {
|
|
301
|
+
throw new InvalidArgumentError(`expected an integer from 0 to ${MAX_POOL_INSTANCE}.`);
|
|
302
|
+
}
|
|
303
|
+
return n;
|
|
304
|
+
}
|
|
305
|
+
function parseSeconds(v) {
|
|
306
|
+
if (!/^\d+$/.test(v)) throw new InvalidArgumentError("expected a whole number of seconds.");
|
|
307
|
+
return Number(v);
|
|
308
|
+
}
|
|
309
|
+
function parseSmp(v) {
|
|
310
|
+
if (!/^\d+$/.test(v) || Number(v) < 1) throw new InvalidArgumentError("expected a positive integer.");
|
|
311
|
+
return Number(v);
|
|
312
|
+
}
|
|
313
|
+
var ACCELS = ["auto", "kvm", "hvf", "whpx", "tcg"];
|
|
314
|
+
function parseAccel(v) {
|
|
315
|
+
if (!ACCELS.includes(v)) throw new InvalidArgumentError(`expected one of ${ACCELS.join(", ")}.`);
|
|
316
|
+
return v;
|
|
317
|
+
}
|
|
318
|
+
function formatInstanceRow(i) {
|
|
319
|
+
const fields = [String(i.instance), i.state, `hdc=${i.address}`];
|
|
320
|
+
if (i.vncPort) fields.push(`vnc=127.0.0.1:${i.vncPort}`);
|
|
321
|
+
if (i.instance > 0 && i.serialPort) fields.push(`serial=127.0.0.1:${i.serialPort}`);
|
|
322
|
+
if (i.pid) fields.push(`pid=${i.pid}`);
|
|
323
|
+
if (i.smp) fields.push(`cpus=${i.smp}`);
|
|
324
|
+
if (i.memory) fields.push(`mem=${i.memory}`);
|
|
325
|
+
if (i.bootSeconds !== void 0) fields.push(`boot=${i.bootSeconds}s`);
|
|
326
|
+
return fields.join(" ");
|
|
327
|
+
}
|
|
328
|
+
var POOL_ONLY_OPTIONS = ["memory", "smp", "resolution", "window", "fresh", "bootTimeout", "qemu", "accel", "json"];
|
|
329
|
+
var flagName = (key) => `--${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
246
330
|
function registerEmulatorCommand(program) {
|
|
247
331
|
const emu = program.command("emulator").description("Manage the Oniro emulator.");
|
|
248
332
|
emu.command("install").description("Download and install the Oniro emulator (QEMU-based). Skips if already installed.").option("--force", "Reinstall even if the emulator is already present.").option(
|
|
@@ -258,24 +342,71 @@ function registerEmulatorCommand(program) {
|
|
|
258
342
|
await installEmulator({ config, progress, logger, tmpDir: opts.tmpDir });
|
|
259
343
|
logger.info("Emulator installed.");
|
|
260
344
|
});
|
|
261
|
-
emu.command("start").description(
|
|
345
|
+
emu.command("start").description(
|
|
346
|
+
"Start the Oniro emulator and detach, so the CLI can exit while it keeps running. Without --instance (or with --instance 0) it runs the bundled launcher (run.sh / run.bat). --instance <n> (1..99) starts pool instance n instead: its own QEMU on copy-on-write overlays of the images, hdc on 127.0.0.1:55555+n, headless VNC on 127.0.0.1:5900+n, serial console on telnet 127.0.0.1:4444+n."
|
|
347
|
+
).option("--instance <n>", "Pool instance number (1..99). 0 or unset: the default emulator via run.sh.", parseInstance).option("--headless", "Launch headless (VNC + telnet serial, no local window). Required in CI. Pool instances are headless unless --window.").option("--log <path>", "Redirect launcher stdout/stderr to this file. Defaults to discarding output (pool instances: <instance dir>/qemu.log).").option("--wait-for-hdc <seconds>", "Default emulator: wait up to N seconds for hdc to connect (restarts the hdc server). 0 = do not wait.", (v) => parseInt(v, 10), 0).option("--wait-ready <seconds>", "Wait up to N seconds until the guest reports bootevent.boot.completed=true. Connects with hdc tconn only, never restarting the hdc server.", parseSeconds).option("--connect <host:port>", "Default emulator: override the launcher's hdc port-forward bind address. Pass 0.0.0.0:55555 on hosts where QEMU refuses to bind 127.0.0.1 (some CI runners).").option("--memory <size>", `Pool: guest RAM in QEMU syntax (default ${POOL_DEFAULT_MEMORY}).`).option("--smp <n>", `Pool: virtual CPUs (default ${POOL_DEFAULT_SMP}).`, parseSmp).option("--resolution <WxH>", `Pool: display resolution (default ${POOL_DEFAULT_RESOLUTION}).`).option("--window", "Pool: show a local SDL window instead of VNC.").option("--fresh", "Pool: discard the instance's overlays first (factory reset).").option("--boot-timeout <seconds>", `Pool: seconds after launch past which a boot that has not completed is reported stuck (default ${DEFAULT_BOOT_TIMEOUT_SECONDS}).`, parseSeconds).option("--qemu <path>", "Pool: qemu-system-x86_64 binary (default QEMU_BIN, else PATH); qemu-img is looked up next to it.").option("--accel <kind>", `Pool: accelerator, one of ${ACCELS.join(", ")} (default auto: KVM on Linux, as run.sh).`, parseAccel).option("--json", "Pool: print the instance's status as JSON when done.").action(async (opts) => {
|
|
262
348
|
const { config, logger } = getRuntime();
|
|
263
|
-
|
|
349
|
+
if (opts.instance === void 0 || opts.instance === 0) {
|
|
350
|
+
const poolOnly = POOL_ONLY_OPTIONS.filter((k) => opts[k] !== void 0);
|
|
351
|
+
if (poolOnly.length > 0) {
|
|
352
|
+
throw new InvalidArgumentError(
|
|
353
|
+
`${poolOnly.map(flagName).join(", ")} ${poolOnly.length > 1 ? "apply" : "applies"} only to pool instances (--instance 1..${MAX_POOL_INSTANCE}).`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
await startEmulator({
|
|
357
|
+
config,
|
|
358
|
+
logger,
|
|
359
|
+
headless: opts.headless === true,
|
|
360
|
+
logFile: opts.log,
|
|
361
|
+
waitForHdcSeconds: opts.waitForHdc,
|
|
362
|
+
waitReadySeconds: opts.waitReady,
|
|
363
|
+
connect: opts.connect
|
|
364
|
+
});
|
|
365
|
+
logger.info("Emulator start command complete.");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (opts.connect || opts.waitForHdc > 0) {
|
|
369
|
+
throw new InvalidArgumentError(
|
|
370
|
+
"--connect and --wait-for-hdc apply to the default emulator only; a pool instance listens on 127.0.0.1:55555+n, wait with --wait-ready."
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
const status = await startPoolInstance({
|
|
264
374
|
config,
|
|
265
375
|
logger,
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
376
|
+
instance: opts.instance,
|
|
377
|
+
memory: opts.memory,
|
|
378
|
+
smp: opts.smp,
|
|
379
|
+
resolution: opts.resolution,
|
|
380
|
+
headless: opts.window !== true,
|
|
381
|
+
fresh: opts.fresh === true,
|
|
382
|
+
waitReadySeconds: opts.waitReady,
|
|
383
|
+
bootTimeoutSeconds: opts.bootTimeout,
|
|
384
|
+
qemuBin: opts.qemu,
|
|
385
|
+
accel: opts.accel,
|
|
386
|
+
logFile: opts.log
|
|
270
387
|
});
|
|
271
|
-
|
|
388
|
+
if (opts.json) process.stdout.write(`${JSON.stringify(status, null, 2)}
|
|
389
|
+
`);
|
|
390
|
+
else logger.info(`Instance ${status.instance}: ${status.state} (${status.address}).`);
|
|
272
391
|
});
|
|
273
392
|
emu.command("status").description(
|
|
274
|
-
"Report whether the Oniro emulator is installed, running, and connected to hdc, without starting it. Exits 1 when
|
|
275
|
-
).option("--address <addr>", "hdc address the emulator listens on", "127.0.0.1:55555").option("--json", "Emit machine-readable JSON.").action(async (opts) => {
|
|
393
|
+
"Report whether the Oniro emulator is installed, running, and connected to hdc, without starting it, and list every instance (the default emulator as 0, then each pool instance) with its health: stopped, booting, ready, hdc-lost or stuck. Exits 1 when the emulator is not installed."
|
|
394
|
+
).option("--address <addr>", "hdc address the default emulator listens on", "127.0.0.1:55555").option("--instance <n>", "Report only this instance (0 = the default emulator).", parseInstance).option("--json", "Emit machine-readable JSON: `instances` lists every instance; with --instance, that instance alone.").action(async (opts) => {
|
|
276
395
|
const { config, logger } = getRuntime();
|
|
396
|
+
const print = (one) => {
|
|
397
|
+
process.stdout.write(opts.json ? `${JSON.stringify(one, null, 2)}
|
|
398
|
+
` : `${formatInstanceRow(one)}
|
|
399
|
+
`);
|
|
400
|
+
};
|
|
401
|
+
if (opts.instance !== void 0 && opts.instance > 0) {
|
|
402
|
+
print(await getPoolInstanceStatus(config, opts.instance, { logger }));
|
|
403
|
+
if (!isEmulatorInstalled(config)) process.exitCode = 1;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
277
406
|
const status = await getEmulatorStatus(config, { address: opts.address, logger });
|
|
278
|
-
if (opts.
|
|
407
|
+
if (opts.instance === 0) {
|
|
408
|
+
print(status.instances[0]);
|
|
409
|
+
} else if (opts.json) {
|
|
279
410
|
process.stdout.write(`${JSON.stringify(status, null, 2)}
|
|
280
411
|
`);
|
|
281
412
|
} else {
|
|
@@ -285,13 +416,26 @@ running: ${status.running ? "yes" : "no"}
|
|
|
285
416
|
connected: ${status.connected ? "yes" : "no"} (${status.address})
|
|
286
417
|
`
|
|
287
418
|
);
|
|
419
|
+
if (status.instances.length > 1) {
|
|
420
|
+
process.stdout.write(`instances:
|
|
421
|
+
${status.instances.map((i) => ` ${formatInstanceRow(i)}`).join("\n")}
|
|
422
|
+
`);
|
|
423
|
+
}
|
|
288
424
|
}
|
|
289
425
|
if (!status.installed) process.exitCode = 1;
|
|
290
426
|
});
|
|
291
|
-
emu.command("stop").description(
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
logger
|
|
427
|
+
emu.command("stop").description(
|
|
428
|
+
"Without --instance: kill running emulator processes (every qemu-system-x86_64, pool instances included). With --instance <n>: stop only that pool instance's QEMU and drop its target from hdc."
|
|
429
|
+
).option("--instance <n>", "Pool instance to stop (1..99).", parseInstance).option("--discard", "With --instance: also delete the instance's overlays, log and state.").action(async (opts) => {
|
|
430
|
+
const { config, logger } = getRuntime();
|
|
431
|
+
if (opts.instance === void 0 || opts.instance === 0) {
|
|
432
|
+
if (opts.discard) throw new InvalidArgumentError(`--discard needs --instance <n> (1..${MAX_POOL_INSTANCE}).`);
|
|
433
|
+
await stopEmulator(logger);
|
|
434
|
+
logger.info("Emulator stopped.");
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const stopped = await stopPoolInstance({ config, instance: opts.instance, discard: opts.discard === true, logger });
|
|
438
|
+
if (!stopped) logger.info(`Instance ${opts.instance} was not running.`);
|
|
295
439
|
});
|
|
296
440
|
emu.command("connect").description("Attempt to connect hdc to the emulator.").option("--address <addr>", "hdc address to connect to", "127.0.0.1:55555").action(async (opts) => {
|
|
297
441
|
const { config, logger } = getRuntime();
|
|
@@ -311,45 +455,80 @@ connected: ${status.connected ? "yes" : "no"} (${status.address})
|
|
|
311
455
|
|
|
312
456
|
// src/commands/build.ts
|
|
313
457
|
import * as path2 from "path";
|
|
314
|
-
import {
|
|
458
|
+
import {
|
|
459
|
+
OniroError,
|
|
460
|
+
buildWithDiagnostics,
|
|
461
|
+
downloadAndInstallSdk as downloadAndInstallSdk2
|
|
462
|
+
} from "@oniroproject/core";
|
|
463
|
+
async function runBuild(projectDir, opts) {
|
|
464
|
+
const { config, logger, progress } = getRuntime();
|
|
465
|
+
const dir = path2.resolve(projectDir ?? process.cwd());
|
|
466
|
+
logger.info(`Building project at ${dir}...`);
|
|
467
|
+
const build2 = () => buildWithDiagnostics({
|
|
468
|
+
config,
|
|
469
|
+
projectDir: dir,
|
|
470
|
+
product: opts.product,
|
|
471
|
+
module: opts.module,
|
|
472
|
+
mode: opts.mode,
|
|
473
|
+
task: opts.task,
|
|
474
|
+
autoInstallDeps: opts.deps,
|
|
475
|
+
parallel: opts.parallel,
|
|
476
|
+
logger,
|
|
477
|
+
// The logger already echoes spawn output; this hook is for streaming to other UIs.
|
|
478
|
+
onOutput: () => {
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
let report = await build2();
|
|
482
|
+
if (!report.ok && report.missingSdk?.version && opts.installSdk) {
|
|
483
|
+
const { version, api } = report.missingSdk;
|
|
484
|
+
logger.info(`Installing OpenHarmony SDK ${version} (api ${api}), which the project compiles against...`);
|
|
485
|
+
await downloadAndInstallSdk2({ config, version, api, progress, logger });
|
|
486
|
+
report = await build2();
|
|
487
|
+
}
|
|
488
|
+
return report;
|
|
489
|
+
}
|
|
315
490
|
function registerBuildCommand(program) {
|
|
316
491
|
program.command("build [project-dir]").description(
|
|
317
|
-
'Build an OpenHarmony or HarmonyOS app via hvigorw. Defaults to the current directory. Projects declaring runtimeOS "HarmonyOS" build against the HarmonyOS SDK (ONIRO_HARMONYOS_SDK_PATH or an installed DevEco Studio). An OpenHarmony project fails early, naming the release to install, when the SDK for its compileSdkVersion is missing.'
|
|
318
|
-
).option("--product <product>", "hvigor product name", "default").option("--module <module>", "restrict the build to a specific module").option("--mode <mode>", "hvigor build mode (e.g. release, debug)").option("--task <task>", "hvigor task to run", "assembleHap").option("--no-deps", "skip the automatic `ohpm install --all` when oh_modules/ is missing").option("--no-parallel", "build modules serially (default: parallel)").option("--install-sdk", "install the OpenHarmony SDK the project compiles against when it is missing, then build").option(
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
projectDir: dir,
|
|
325
|
-
product: opts.product,
|
|
326
|
-
module: opts.module,
|
|
327
|
-
mode: opts.mode,
|
|
328
|
-
task: opts.task,
|
|
329
|
-
autoInstallDeps: opts.deps,
|
|
330
|
-
parallel: opts.parallel,
|
|
331
|
-
logger,
|
|
332
|
-
// The logger already echoes spawn output; this hook is for streaming to other UIs.
|
|
333
|
-
onOutput: () => {
|
|
334
|
-
}
|
|
335
|
-
});
|
|
336
|
-
let result;
|
|
492
|
+
'Build an OpenHarmony or HarmonyOS app via hvigorw. Defaults to the current directory. Projects declaring runtimeOS "HarmonyOS" build against the HarmonyOS SDK (ONIRO_HARMONYOS_SDK_PATH or an installed DevEco Studio). An OpenHarmony project fails early, naming the release to install, when the SDK for its compileSdkVersion is missing. With --json, one result object is printed whether the build passes or fails; a failed build exits 1.'
|
|
493
|
+
).option("--product <product>", "hvigor product name", "default").option("--module <module>", "restrict the build to a specific module").option("--mode <mode>", "hvigor build mode (e.g. release, debug)").option("--task <task>", "hvigor task to run", "assembleHap").option("--no-deps", "skip the automatic `ohpm install --all` when oh_modules/ is missing").option("--no-parallel", "build modules serially (default: parallel)").option("--install-sdk", "install the OpenHarmony SDK the project compiles against when it is missing, then build").option(
|
|
494
|
+
"--json",
|
|
495
|
+
"print the result as JSON: ok, diagnostics (file, line, column, code, rule, message), summary, failedTask, discoveredHaps, warnings"
|
|
496
|
+
).action(async (projectDir, opts) => {
|
|
497
|
+
const { logger } = getRuntime();
|
|
498
|
+
let report;
|
|
337
499
|
try {
|
|
338
|
-
|
|
500
|
+
report = await runBuild(projectDir, opts);
|
|
339
501
|
} catch (err) {
|
|
340
|
-
if (
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
502
|
+
if (opts.json) {
|
|
503
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
504
|
+
process.stdout.write(`${JSON.stringify({ ok: false, exitCode: 1, error, diagnostics: [] }, null, 2)}
|
|
505
|
+
`);
|
|
506
|
+
}
|
|
507
|
+
throw err;
|
|
344
508
|
}
|
|
345
|
-
for (const w of
|
|
509
|
+
for (const w of report.warnings) logger.warn(w);
|
|
346
510
|
if (opts.json) {
|
|
347
|
-
process.stdout.write(`${JSON.stringify(
|
|
511
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}
|
|
348
512
|
`);
|
|
349
513
|
}
|
|
514
|
+
if (!report.ok) {
|
|
515
|
+
const listed = formatDiagnostics(report.diagnostics);
|
|
516
|
+
if (listed) logger.error(`Build failed${report.failedTask ? ` in ${report.failedTask}` : ""}:
|
|
517
|
+
${listed}`);
|
|
518
|
+
throw new OniroError(report.error ?? "Build failed.");
|
|
519
|
+
}
|
|
350
520
|
logger.info("Build complete.");
|
|
351
521
|
});
|
|
352
522
|
}
|
|
523
|
+
function formatDiagnostics(diagnostics, max = 20) {
|
|
524
|
+
const sorted = [...diagnostics].sort((a, b) => a.severity === b.severity ? 0 : a.severity === "error" ? -1 : 1);
|
|
525
|
+
const lines = sorted.slice(0, max).map((d) => {
|
|
526
|
+
const at = d.file ? `${d.file}${d.line ? `:${d.line}${d.column ? `:${d.column}` : ""}` : ""} ` : "";
|
|
527
|
+
return ` ${at}${d.severity} ${d.source}${d.code ? ` ${d.code}` : ""}: ${d.message}${d.rule ? ` (${d.rule})` : ""}`;
|
|
528
|
+
});
|
|
529
|
+
if (sorted.length > max) lines.push(` \u2026 and ${sorted.length - max} more`);
|
|
530
|
+
return lines.join("\n");
|
|
531
|
+
}
|
|
353
532
|
|
|
354
533
|
// src/commands/sign.ts
|
|
355
534
|
import { Option } from "commander";
|
|
@@ -357,7 +536,7 @@ import * as path3 from "path";
|
|
|
357
536
|
import {
|
|
358
537
|
APL_VALUES,
|
|
359
538
|
APP_FEATURE_VALUES,
|
|
360
|
-
OniroError,
|
|
539
|
+
OniroError as OniroError2,
|
|
361
540
|
createHarmonyOsSession,
|
|
362
541
|
detectRuntimeOs,
|
|
363
542
|
generateSigningConfigs,
|
|
@@ -365,8 +544,18 @@ import {
|
|
|
365
544
|
harmonyOsSignHap,
|
|
366
545
|
signOpenHarmonyHap,
|
|
367
546
|
prepareSigning,
|
|
368
|
-
getOhosBaseSdkHome
|
|
547
|
+
getOhosBaseSdkHome,
|
|
548
|
+
describeProfileValidity,
|
|
549
|
+
describeSigningMaterial
|
|
369
550
|
} from "@oniroproject/core";
|
|
551
|
+
function profileSummary(result) {
|
|
552
|
+
const validity = describeProfileValidity({ type: result.profileType, notAfter: result.notAfter });
|
|
553
|
+
return [`profile names ${result.deviceIds.length} device(s)`, validity].filter(Boolean).join(", ");
|
|
554
|
+
}
|
|
555
|
+
function materialSuffix(summary) {
|
|
556
|
+
const text = describeSigningMaterial(summary);
|
|
557
|
+
return text ? ` (${text})` : "";
|
|
558
|
+
}
|
|
370
559
|
function parseAclsList(value) {
|
|
371
560
|
return value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
372
561
|
}
|
|
@@ -379,7 +568,8 @@ function registerSignCommand(program) {
|
|
|
379
568
|
"Overwrites the `signingConfigs` block in build-profile.json5 (other keys preserved).",
|
|
380
569
|
"HarmonyOS projects need --harmonyos instead, which has AppGallery Connect issue the",
|
|
381
570
|
"certificate and profile for a signed-in Huawei account (`oniro-app auth login`).",
|
|
382
|
-
"With --hap <file>, signs a prebuilt HAP instead of a project (with --harmonyos, for HarmonyOS)."
|
|
571
|
+
"With --hap <file>, signs a prebuilt HAP instead of a project (with --harmonyos, for HarmonyOS).",
|
|
572
|
+
"Ends by naming the profile type and expiry; `oniro-app app verify <hap>` checks what signed a built HAP."
|
|
383
573
|
].join(" ")
|
|
384
574
|
).addOption(
|
|
385
575
|
new Option(
|
|
@@ -415,11 +605,11 @@ function registerSignCommand(program) {
|
|
|
415
605
|
const { config, logger } = getRuntime();
|
|
416
606
|
const dir = path3.resolve(projectDir ?? process.cwd());
|
|
417
607
|
const passwords = opts.storePassword || opts.keyPassword ? { store: opts.storePassword ?? "123456", key: opts.keyPassword ?? "123456" } : void 0;
|
|
418
|
-
if (opts.out && !opts.hap) throw new
|
|
608
|
+
if (opts.out && !opts.hap) throw new OniroError2("--out names where `--hap` writes the signed HAP; pass --hap too.");
|
|
419
609
|
if (opts.hap) {
|
|
420
|
-
if (projectDir) throw new
|
|
610
|
+
if (projectDir) throw new OniroError2("Pass either a project directory or --hap, not both.");
|
|
421
611
|
if (opts.product || opts.bootstrap) {
|
|
422
|
-
throw new
|
|
612
|
+
throw new OniroError2(`--${opts.product ? "product" : "bootstrap"} applies to a project, not to --hap.`);
|
|
423
613
|
}
|
|
424
614
|
const ignored = opts.harmonyos ? [
|
|
425
615
|
opts.apl !== "normal" && "--apl",
|
|
@@ -430,7 +620,7 @@ function registerSignCommand(program) {
|
|
|
430
620
|
] : [opts.teamId && "--team-id", opts.force && "--force"];
|
|
431
621
|
const unsupported = ignored.filter((flag) => typeof flag === "string");
|
|
432
622
|
if (unsupported.length > 0) {
|
|
433
|
-
throw new
|
|
623
|
+
throw new OniroError2(
|
|
434
624
|
`${unsupported.join(", ")} ${unsupported.length > 1 ? "do" : "does"} not apply to ${opts.harmonyos ? "--harmonyos --hap" : "--hap without --harmonyos"}.`
|
|
435
625
|
);
|
|
436
626
|
}
|
|
@@ -447,7 +637,7 @@ function registerSignCommand(program) {
|
|
|
447
637
|
applicationCertPath: config.get("applicationCertPath", "") || void 0,
|
|
448
638
|
logger
|
|
449
639
|
});
|
|
450
|
-
logger.info(`Signed ${result2.bundleName} into ${result2.outPath}.`);
|
|
640
|
+
logger.info(`Signed ${result2.bundleName} into ${result2.outPath}${materialSuffix(result2.material)}.`);
|
|
451
641
|
return;
|
|
452
642
|
}
|
|
453
643
|
const result = await harmonyOsSignHap({
|
|
@@ -459,7 +649,7 @@ function registerSignCommand(program) {
|
|
|
459
649
|
force: opts.force,
|
|
460
650
|
logger
|
|
461
651
|
});
|
|
462
|
-
logger.info(`Signed ${result.bundleName} into ${result.outPath} (
|
|
652
|
+
logger.info(`Signed ${result.bundleName} into ${result.outPath} (${profileSummary(result)}).`);
|
|
463
653
|
return;
|
|
464
654
|
}
|
|
465
655
|
if (opts.harmonyos) {
|
|
@@ -472,11 +662,11 @@ function registerSignCommand(program) {
|
|
|
472
662
|
force: opts.force,
|
|
473
663
|
logger
|
|
474
664
|
});
|
|
475
|
-
logger.info(`Signing configs ready for ${result.bundleName} (
|
|
665
|
+
logger.info(`Signing configs ready for ${result.bundleName} (${profileSummary(result)}).`);
|
|
476
666
|
return;
|
|
477
667
|
}
|
|
478
668
|
if (detectRuntimeOs(dir, opts.product) === "HarmonyOS") {
|
|
479
|
-
throw new
|
|
669
|
+
throw new OniroError2(
|
|
480
670
|
`${dir} targets HarmonyOS, which needs a certificate from AppGallery Connect: run \`oniro-app auth login\` once, then \`oniro-app sign --harmonyos\`.`
|
|
481
671
|
);
|
|
482
672
|
}
|
|
@@ -496,7 +686,7 @@ function registerSignCommand(program) {
|
|
|
496
686
|
}
|
|
497
687
|
const sdkHome = getOhosBaseSdkHome(config);
|
|
498
688
|
logger.info(`Generating signing configs in ${dir} using SDK at ${sdkHome}...`);
|
|
499
|
-
generateSigningConfigs({
|
|
689
|
+
const generated = generateSigningConfigs({
|
|
500
690
|
projectDir: dir,
|
|
501
691
|
sdkHome,
|
|
502
692
|
apl: opts.apl,
|
|
@@ -506,32 +696,96 @@ function registerSignCommand(program) {
|
|
|
506
696
|
passwords,
|
|
507
697
|
logger
|
|
508
698
|
});
|
|
509
|
-
logger.info(
|
|
699
|
+
logger.info(`Signing configs generated${materialSuffix(generated)}.`);
|
|
510
700
|
}
|
|
511
701
|
);
|
|
512
702
|
}
|
|
513
703
|
|
|
514
704
|
// src/commands/auth.ts
|
|
705
|
+
import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
|
|
706
|
+
import * as readline from "readline";
|
|
515
707
|
import { HUAWEI_SITES, createHarmonyOsSession as createHarmonyOsSession2 } from "@oniroproject/core";
|
|
516
708
|
function session() {
|
|
517
709
|
const { config, logger } = getRuntime();
|
|
518
|
-
return createHarmonyOsSession2({
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
710
|
+
return createHarmonyOsSession2({ config, logger });
|
|
711
|
+
}
|
|
712
|
+
function parsePort(value) {
|
|
713
|
+
const port = Number(value);
|
|
714
|
+
if (!/^\d+$/.test(value) || port < 1 || port > 65535) {
|
|
715
|
+
throw new InvalidArgumentError2("expected a port number from 1 to 65535.");
|
|
716
|
+
}
|
|
717
|
+
return port;
|
|
718
|
+
}
|
|
719
|
+
function browserInstructions(url) {
|
|
720
|
+
return `Opening the Huawei sign-in page. If no browser opens, visit:
|
|
522
721
|
${url}
|
|
523
|
-
|
|
524
|
-
|
|
722
|
+
`;
|
|
723
|
+
}
|
|
724
|
+
function manualInstructions(url, callbackUrl, port) {
|
|
725
|
+
return [
|
|
726
|
+
"Open this address in a browser, on this machine or any other, and sign in:",
|
|
727
|
+
` ${url}`,
|
|
728
|
+
`The browser is then sent to ${callbackUrl}?... If that page does not load (the browser is not on`,
|
|
729
|
+
"this machine), copy the whole address from its address bar, paste it here and press Enter.",
|
|
730
|
+
`(Or forward the port first, from the machine with the browser: ssh -L ${port}:127.0.0.1:${port} <this-host>.)`,
|
|
731
|
+
""
|
|
732
|
+
].join("\n");
|
|
525
733
|
}
|
|
526
734
|
function registerAuthCommand(program) {
|
|
527
735
|
const auth = program.command("auth").description(
|
|
528
736
|
"Sign in to a Huawei developer account, which HarmonyOS signing (`oniro-app sign --harmonyos`) needs. OpenHarmony signing needs no account. The token is stored encrypted under ONIRO_HARMONYOS_AUTH_DIR (default ~/.oniro/harmonyos)."
|
|
529
737
|
);
|
|
530
|
-
auth.command("login").description(
|
|
531
|
-
|
|
532
|
-
|
|
738
|
+
auth.command("login").description(
|
|
739
|
+
"Sign in through the browser, which redirects back to a loopback port on this machine. With the browser on another machine, either pass --manual and paste the address the browser ends up on, or fix the port with --port and forward it with `ssh -L <port>:127.0.0.1:<port>`."
|
|
740
|
+
).option("--timeout <seconds>", "How long to wait for the browser sign-in. Default 600.", (v) => Number(v)).option(
|
|
741
|
+
"--port <n>",
|
|
742
|
+
"Loopback port the sign-in page redirects to, instead of a random one, so an ssh tunnel can be set up beforehand: `ssh -L <n>:127.0.0.1:<n> <this-host>` from the machine with the browser (same port both ends).",
|
|
743
|
+
parsePort
|
|
744
|
+
).option(
|
|
745
|
+
"--no-browser",
|
|
746
|
+
"Do not open a browser. Print the sign-in URL, then finish the sign-in from the callback address (`http://127.0.0.1:<port>/callback?...`, or just its query) pasted on stdin, or from the loopback port itself, whichever comes first."
|
|
747
|
+
).option("--manual", "Same as --no-browser.").option("--print-url", "Also write the sign-in URL to stdout, alone on its line, before waiting.").option(
|
|
748
|
+
"--json",
|
|
749
|
+
'Write JSON lines to stdout: {"loginUrl","port","callbackUrl"} before waiting, then the account ({"userId","userName","site","tokenPath"}) once signed in.'
|
|
750
|
+
).action(
|
|
751
|
+
async (opts) => {
|
|
752
|
+
const { config, logger } = getRuntime();
|
|
753
|
+
const manual = opts.manual || !opts.browser;
|
|
754
|
+
const s = createHarmonyOsSession2({
|
|
755
|
+
config,
|
|
756
|
+
logger,
|
|
757
|
+
onLoginUrl: (url, { port }) => {
|
|
758
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback`;
|
|
759
|
+
if (opts.json) process.stdout.write(`${JSON.stringify({ loginUrl: url, port, callbackUrl })}
|
|
533
760
|
`);
|
|
534
|
-
|
|
761
|
+
else if (opts.printUrl) process.stdout.write(`${url}
|
|
762
|
+
`);
|
|
763
|
+
process.stderr.write(manual ? manualInstructions(url, callbackUrl, port) : browserInstructions(url));
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
const stdin = manual ? readline.createInterface({ input: process.stdin, terminal: false }) : void 0;
|
|
767
|
+
let user;
|
|
768
|
+
try {
|
|
769
|
+
user = await s.login({
|
|
770
|
+
timeoutMs: opts.timeout ? Math.max(1, opts.timeout) * 1e3 : void 0,
|
|
771
|
+
port: opts.port,
|
|
772
|
+
openBrowser: !manual,
|
|
773
|
+
pastedCallbacks: stdin
|
|
774
|
+
});
|
|
775
|
+
} finally {
|
|
776
|
+
stdin?.close();
|
|
777
|
+
if (stdin) process.stdin.destroy();
|
|
778
|
+
}
|
|
779
|
+
if (opts.json) {
|
|
780
|
+
const status = { userId: user.userId, userName: user.userName, site: user.siteCode, tokenPath: s.tokenPath };
|
|
781
|
+
process.stdout.write(`${JSON.stringify(status)}
|
|
782
|
+
`);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
process.stdout.write(`${user.userName || user.userId}
|
|
786
|
+
`);
|
|
787
|
+
}
|
|
788
|
+
);
|
|
535
789
|
auth.command("logout").description("Sign out and delete the stored token.").action(async () => {
|
|
536
790
|
const signedOut = await session().logout();
|
|
537
791
|
getRuntime().logger.info(signedOut ? "Signed out." : "Not signed in; nothing to do.");
|
|
@@ -574,6 +828,7 @@ function registerAuthCommand(program) {
|
|
|
574
828
|
}
|
|
575
829
|
|
|
576
830
|
// src/commands/app.ts
|
|
831
|
+
import * as os2 from "os";
|
|
577
832
|
import * as path4 from "path";
|
|
578
833
|
import {
|
|
579
834
|
installApp,
|
|
@@ -584,164 +839,444 @@ import {
|
|
|
584
839
|
existingHapOrUnsigned,
|
|
585
840
|
getBundleName,
|
|
586
841
|
readHapBundleName,
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
"Install a built .hap on the connected device/emulator via hdc. Takes a project dir (default: the current one), whose newest signed HAP is installed, else its unsigned one, or a .hap file. Fails when the bundle manager rejects the HAP, and says how to sign when a target refuses an unsigned one."
|
|
593
|
-
).option("--hap <path>", "Override the .hap path (relative to project-dir or absolute).").option("--device <serial>", "Target device serial.").action(async (target, opts) => {
|
|
594
|
-
const { config, logger } = getRuntime();
|
|
595
|
-
const isHap = target !== void 0 && /\.hap$/i.test(target);
|
|
596
|
-
const dir = isHap ? process.cwd() : path4.resolve(target ?? process.cwd());
|
|
597
|
-
const hapPath = isHap ? path4.resolve(target) : opts.hap;
|
|
598
|
-
logger.info(`Installing ${hapPath ?? `app from ${dir}`}...`);
|
|
599
|
-
const result = await installApp({ config, projectDir: dir, hapPath, deviceSerial: opts.device, logger });
|
|
600
|
-
logger.info(`Installed ${result.hapPath}.`);
|
|
601
|
-
});
|
|
602
|
-
app.command("launch [project-dir]").description(
|
|
603
|
-
"Launch an app on the connected device/emulator. Project mode (default): resolves bundle + main ability from the project. Bundle mode (--bundle): launch an already-installed app by bundle name, no project needed."
|
|
604
|
-
).option("--bundle <bundle>", "Launch an already-installed app by bundle name (no project needed).").option("--module <module>", "Module folder name (project mode).", "entry").option("--ability <name>", "Explicit ability to launch (defaults to the module mainElement in project mode, or the installed bundle main ability in --bundle mode).").option("--device <serial>", "Target device serial.").action(async (projectDir, opts) => {
|
|
605
|
-
const { config, logger } = getRuntime();
|
|
606
|
-
if (opts.bundle) {
|
|
607
|
-
logger.info(`Launching installed bundle ${opts.bundle}...`);
|
|
608
|
-
await launchApp({ config, bundleName: opts.bundle, abilityName: opts.ability, deviceSerial: opts.device, logger });
|
|
609
|
-
} else {
|
|
610
|
-
const dir = path4.resolve(projectDir ?? process.cwd());
|
|
611
|
-
logger.info(`Launching app from ${dir}...`);
|
|
612
|
-
await launchApp({ config, projectDir: dir, moduleName: opts.module, abilityName: opts.ability, deviceSerial: opts.device, logger });
|
|
613
|
-
}
|
|
614
|
-
logger.info("App launched.");
|
|
615
|
-
});
|
|
616
|
-
app.command("apply [project-dir-or-hap]").description(
|
|
617
|
-
"Install a HAP (a project dir, default the current one, or a .hap file) and verify the running process took the change. Refuses a HAP of another bundle. Handles sign-info mismatch, asset-cache invalidation, and persistent-bundle restart."
|
|
618
|
-
).option(
|
|
619
|
-
"--bundle <bundle>",
|
|
620
|
-
"Bundle name to apply changes to. Defaults to the bundleName in the .hap file, else in the project AppScope/app.json5."
|
|
621
|
-
).option("--module <module>", "Module to resolve the HAP from (multi-module projects).").option("--hap <path>", "Explicit .hap path (relative to project-dir or absolute).").option("--installed-hap <path>", "Local path to the currently-installed HAP, to enable the asset-cache reboot.").option("--system", "Treat as a persistent/system bundle (refuses uninstall on sign-info mismatch).").option("--allow-uninstall", "Permit uninstalling a system bundle on sign-info mismatch (dangerous).").option("--device <serial>", "Target device serial.").option("--json", "Emit the result as JSON.").action(async (target, opts) => {
|
|
622
|
-
const { config, logger } = getRuntime();
|
|
623
|
-
const isHap = target !== void 0 && /\.hap$/i.test(target);
|
|
624
|
-
if (isHap && opts.hap) throw new OniroError2("Pass the HAP either as the argument or with --hap, not both.");
|
|
625
|
-
const dir = isHap ? process.cwd() : path4.resolve(target ?? process.cwd());
|
|
626
|
-
const hapPath = isHap ? path4.resolve(target) : opts.hap;
|
|
627
|
-
let bundle = opts.bundle;
|
|
628
|
-
if (!bundle && isHap) {
|
|
629
|
-
const hap = existingHapOrUnsigned(hapPath);
|
|
630
|
-
if (!hap) throw new OniroError2(`HAP not found at ${hapPath}.`);
|
|
631
|
-
bundle = await readHapBundleName(hap.path).catch(() => null) ?? void 0;
|
|
632
|
-
if (!bundle) throw new OniroError2(`Could not read the bundle name from ${hap.path}. Pass --bundle.`);
|
|
633
|
-
}
|
|
634
|
-
bundle ??= getBundleName(dir);
|
|
635
|
-
const result = await applyChanges({
|
|
636
|
-
config,
|
|
637
|
-
bundle,
|
|
638
|
-
hapPath,
|
|
639
|
-
projectDir: dir,
|
|
640
|
-
module: opts.module,
|
|
641
|
-
installedHapPath: opts.installedHap,
|
|
642
|
-
isSystemBundle: opts.system,
|
|
643
|
-
allowUninstall: opts.allowUninstall,
|
|
644
|
-
deviceSerial: opts.device,
|
|
645
|
-
logger
|
|
646
|
-
});
|
|
647
|
-
if (opts.json) {
|
|
648
|
-
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
649
|
-
`);
|
|
650
|
-
} else {
|
|
651
|
-
logger.info(
|
|
652
|
-
`apply: method=${result.method} pid ${result.preInstallPid ?? "-"} -> ${result.postInstallPid ?? "-"}${result.cacheCleared ? " (cache cleared)" : ""}`
|
|
653
|
-
);
|
|
654
|
-
}
|
|
655
|
-
});
|
|
656
|
-
app.command("uninstall <bundle>").description("Uninstall an app by bundle name.").option("--device <serial>", "Target device serial.").action(async (bundle, opts) => {
|
|
657
|
-
const { config, logger } = getRuntime();
|
|
658
|
-
await uninstallApp({ config, bundle, deviceSerial: opts.device, logger });
|
|
659
|
-
logger.info(`Uninstalled ${bundle}.`);
|
|
660
|
-
});
|
|
661
|
-
app.command("stop <bundle>").description("Force-stop an app by bundle name.").option("--device <serial>", "Target device serial.").action(async (bundle, opts) => {
|
|
662
|
-
const { config, logger } = getRuntime();
|
|
663
|
-
await forceStop({ config, bundle, deviceSerial: opts.device, logger });
|
|
664
|
-
logger.info(`Stopped ${bundle}.`);
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
// src/commands/create.ts
|
|
669
|
-
import * as path6 from "path";
|
|
670
|
-
import {
|
|
671
|
-
ALL_SDKS as ALL_SDKS2,
|
|
672
|
-
createScaffold,
|
|
673
|
-
isValidBundleName,
|
|
674
|
-
isValidProjectName
|
|
842
|
+
smokeCheck,
|
|
843
|
+
latestCrash,
|
|
844
|
+
wakeAndUnlock,
|
|
845
|
+
DEFAULT_SMOKE_DELAY_MS,
|
|
846
|
+
OniroError as OniroError3
|
|
675
847
|
} from "@oniroproject/core";
|
|
676
848
|
|
|
677
|
-
// src/lib/
|
|
678
|
-
import
|
|
679
|
-
import
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
const
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
849
|
+
// src/lib/screenshotImage.ts
|
|
850
|
+
import sharp from "sharp";
|
|
851
|
+
import { compareImages } from "@oniroproject/core";
|
|
852
|
+
function buildGridSvg(width, height) {
|
|
853
|
+
const lineColor = "rgba(255,80,80,0.55)";
|
|
854
|
+
const labelBg = "rgba(0,0,0,0.7)";
|
|
855
|
+
const labelFg = "#fff";
|
|
856
|
+
const fontSize = Math.max(10, Math.round(Math.min(width, height) / 60));
|
|
857
|
+
const padding = Math.round(fontSize * 0.4);
|
|
858
|
+
const labels = [];
|
|
859
|
+
const lines = [];
|
|
860
|
+
for (let i = 0; i <= 10; i++) {
|
|
861
|
+
const f = i / 10;
|
|
862
|
+
const x = Math.round(f * (width - 1));
|
|
863
|
+
const y = Math.round(f * (height - 1));
|
|
864
|
+
if (i > 0 && i < 10) {
|
|
865
|
+
lines.push(`<line x1="${x}" y1="0" x2="${x}" y2="${height}" stroke="${lineColor}" stroke-width="1"/>`);
|
|
866
|
+
lines.push(`<line x1="0" y1="${y}" x2="${width}" y2="${y}" stroke="${lineColor}" stroke-width="1"/>`);
|
|
867
|
+
}
|
|
868
|
+
const tag = f.toFixed(1);
|
|
869
|
+
const tx = i === 0 ? padding : i === 10 ? width - padding : x;
|
|
870
|
+
const anchorX = i === 0 ? "start" : i === 10 ? "end" : "middle";
|
|
871
|
+
labels.push(
|
|
872
|
+
`<text x="${tx}" y="${fontSize + padding}" font-family="monospace" font-size="${fontSize}" font-weight="bold" fill="${labelFg}" stroke="${labelBg}" stroke-width="3" paint-order="stroke" text-anchor="${anchorX}">${tag}</text>`
|
|
873
|
+
);
|
|
874
|
+
const ty = i === 0 ? fontSize + padding : i === 10 ? height - padding : y + fontSize / 3;
|
|
875
|
+
labels.push(
|
|
876
|
+
`<text x="${padding}" y="${ty}" font-family="monospace" font-size="${fontSize}" font-weight="bold" fill="${labelFg}" stroke="${labelBg}" stroke-width="3" paint-order="stroke" text-anchor="start">${tag}</text>`
|
|
877
|
+
);
|
|
691
878
|
}
|
|
692
|
-
|
|
879
|
+
labels.push(
|
|
880
|
+
`<text x="${width / 2}" y="${fontSize * 2.4 + padding}" font-family="monospace" font-size="${fontSize}" font-weight="bold" fill="${labelFg}" stroke="${labelBg}" stroke-width="3" paint-order="stroke" text-anchor="middle">X \u2192</text>`
|
|
881
|
+
);
|
|
882
|
+
labels.push(
|
|
883
|
+
`<text x="${padding}" y="${fontSize * 2.6 + padding}" font-family="monospace" font-size="${fontSize}" font-weight="bold" fill="${labelFg}" stroke="${labelBg}" stroke-width="3" paint-order="stroke" text-anchor="start">Y \u2193</text>`
|
|
884
|
+
);
|
|
885
|
+
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${lines.join("")}${labels.join("")}</svg>`;
|
|
693
886
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
const templateRoot = getBundledTemplateRoot();
|
|
710
|
-
const result = await createScaffold({
|
|
711
|
-
config,
|
|
712
|
-
templateId: opts.template,
|
|
713
|
-
projectName: opts.name,
|
|
714
|
-
bundleName: opts.bundle,
|
|
715
|
-
location: path6.resolve(opts.location),
|
|
716
|
-
sdkApi,
|
|
717
|
-
moduleName: opts.module,
|
|
718
|
-
templateRoot,
|
|
719
|
-
overwrite: opts.overwrite,
|
|
720
|
-
logger
|
|
721
|
-
});
|
|
722
|
-
logger.info(`Project created at ${result.projectDir}`);
|
|
723
|
-
process.stdout.write(`${result.projectDir}
|
|
724
|
-
`);
|
|
725
|
-
});
|
|
887
|
+
async function renderWithGrid(jpeg2, opts) {
|
|
888
|
+
const meta = await sharp(jpeg2).metadata();
|
|
889
|
+
const originalWidth = meta.width ?? 0;
|
|
890
|
+
const originalHeight = meta.height ?? 0;
|
|
891
|
+
const longest = Math.max(originalWidth, originalHeight) || 1;
|
|
892
|
+
const scale = Math.min(1, opts.maxDim / longest);
|
|
893
|
+
const width = Math.max(1, Math.round(originalWidth * scale));
|
|
894
|
+
const height = Math.max(1, Math.round(originalHeight * scale));
|
|
895
|
+
const resized = await sharp(jpeg2).resize(width, height).jpeg({ quality: 80 }).toBuffer();
|
|
896
|
+
if (!opts.grid) {
|
|
897
|
+
return { buf: resized, width, height, originalWidth, originalHeight };
|
|
898
|
+
}
|
|
899
|
+
const svg = buildGridSvg(width, height);
|
|
900
|
+
const buf = await sharp(resized).composite([{ input: Buffer.from(svg) }]).jpeg({ quality: 80 }).toBuffer();
|
|
901
|
+
return { buf, width, height, originalWidth, originalHeight };
|
|
726
902
|
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
903
|
+
function parseCropRect(spec) {
|
|
904
|
+
const parts = spec.split(",").map((s) => Number(s.trim()));
|
|
905
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 1)) {
|
|
906
|
+
throw new Error(`--crop expects four 0-1 fractions "x1,y1,x2,y2"; got "${spec}".`);
|
|
907
|
+
}
|
|
908
|
+
const [x1, y1, x2, y2] = parts;
|
|
909
|
+
if (!(x2 > x1 && y2 > y1)) throw new Error(`--crop needs x1 < x2 and y1 < y2; got "${spec}".`);
|
|
910
|
+
return [x1, y1, x2, y2];
|
|
911
|
+
}
|
|
912
|
+
function padCropRect(r, pad) {
|
|
913
|
+
return [Math.max(0, r[0] - pad), Math.max(0, r[1] - pad), Math.min(1, r[2] + pad), Math.min(1, r[3] + pad)];
|
|
914
|
+
}
|
|
915
|
+
async function cropImage(jpeg2, rect, opts) {
|
|
916
|
+
const meta = await sharp(jpeg2).metadata();
|
|
917
|
+
const w = meta.width ?? 1;
|
|
918
|
+
const h = meta.height ?? 1;
|
|
919
|
+
const left = Math.min(w - 1, Math.floor(rect[0] * w));
|
|
920
|
+
const top = Math.min(h - 1, Math.floor(rect[1] * h));
|
|
921
|
+
const cropWidth = Math.max(1, Math.min(w, Math.ceil(rect[2] * w)) - left);
|
|
922
|
+
const cropHeight = Math.max(1, Math.min(h, Math.ceil(rect[3] * h)) - top);
|
|
923
|
+
const scale = Math.min(1, opts.maxDim / Math.max(cropWidth, cropHeight));
|
|
924
|
+
const width = Math.max(1, Math.round(cropWidth * scale));
|
|
925
|
+
const height = Math.max(1, Math.round(cropHeight * scale));
|
|
926
|
+
const buf = await sharp(jpeg2).extract({ left, top, width: cropWidth, height: cropHeight }).resize(width, height).jpeg({ quality: 85 }).toBuffer();
|
|
927
|
+
return { buf, width, height, left, top, cropWidth, cropHeight };
|
|
928
|
+
}
|
|
929
|
+
function buildTileLabelSvg(width, height, idx, diff) {
|
|
930
|
+
const fontSize = Math.max(12, Math.round(Math.min(width, height) / 16));
|
|
931
|
+
const pad = Math.round(fontSize * 0.4);
|
|
932
|
+
let label = `#${idx}`;
|
|
933
|
+
if (typeof diff === "number") label += ` \u0394${diff.toFixed(2)}`;
|
|
934
|
+
const boxW = Math.round(label.length * fontSize * 0.62) + pad * 2;
|
|
935
|
+
const boxH = fontSize + pad * 2;
|
|
936
|
+
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"><rect x="0" y="0" width="${boxW}" height="${boxH}" fill="rgba(0,0,0,0.6)"/><text x="${pad}" y="${fontSize + pad - 1}" font-family="monospace" font-size="${fontSize}" font-weight="bold" fill="#fff" stroke="rgba(0,0,0,0.85)" stroke-width="3" paint-order="stroke" text-anchor="start">${label}</text></svg>`;
|
|
937
|
+
}
|
|
938
|
+
async function buildContactSheet(frames, opts) {
|
|
939
|
+
if (frames.length === 0) throw new Error("buildContactSheet: no frames");
|
|
940
|
+
const count = frames.length;
|
|
941
|
+
const cols = Math.max(1, opts.cols ?? Math.ceil(Math.sqrt(count)));
|
|
942
|
+
const rows = Math.ceil(count / cols);
|
|
943
|
+
const gap = opts.gap ?? 8;
|
|
944
|
+
const meta0 = await sharp(frames[0]).metadata();
|
|
945
|
+
const fw = meta0.width ?? 1;
|
|
946
|
+
const fh = meta0.height ?? 1;
|
|
947
|
+
const longest = Math.max(fw, fh) || 1;
|
|
948
|
+
const tileScale = Math.min(1, opts.maxDim / cols / longest);
|
|
949
|
+
const tileWidth = Math.max(1, Math.round(fw * tileScale));
|
|
950
|
+
const tileHeight = Math.max(1, Math.round(fh * tileScale));
|
|
951
|
+
const tiles = await Promise.all(
|
|
952
|
+
frames.map((frame, i) => {
|
|
953
|
+
const label = Buffer.from(buildTileLabelSvg(tileWidth, tileHeight, i, opts.diffs?.[i]));
|
|
954
|
+
return sharp(frame).resize(tileWidth, tileHeight, { fit: "fill" }).composite([{ input: label, top: 0, left: 0 }]).png().toBuffer();
|
|
955
|
+
})
|
|
956
|
+
);
|
|
957
|
+
const width = cols * tileWidth + (cols + 1) * gap;
|
|
958
|
+
const height = rows * tileHeight + (rows + 1) * gap;
|
|
959
|
+
const placements = tiles.map((input2, i) => {
|
|
960
|
+
const r = Math.floor(i / cols);
|
|
961
|
+
const c = i % cols;
|
|
962
|
+
return { input: input2, left: gap + c * (tileWidth + gap), top: gap + r * (tileHeight + gap) };
|
|
963
|
+
});
|
|
964
|
+
const buf = await sharp({
|
|
965
|
+
create: { width, height, channels: 3, background: { r: 20, g: 20, b: 20 } }
|
|
966
|
+
}).composite(placements).jpeg({ quality: 80 }).toBuffer();
|
|
967
|
+
return { buf, width, height, cols, rows, tileWidth, tileHeight, count };
|
|
968
|
+
}
|
|
969
|
+
async function frameDiffs(frames, opts = {}) {
|
|
970
|
+
if (frames.length === 0) return [];
|
|
971
|
+
const sample = opts.sample ?? 32;
|
|
972
|
+
const thumbs = await Promise.all(
|
|
973
|
+
frames.map((f) => sharp(f).greyscale().resize(sample, sample, { fit: "fill" }).raw().toBuffer())
|
|
974
|
+
);
|
|
975
|
+
const out = [0];
|
|
976
|
+
for (let i = 1; i < thumbs.length; i++) {
|
|
977
|
+
const a = thumbs[i - 1];
|
|
978
|
+
const b = thumbs[i];
|
|
979
|
+
if (!a || !b) {
|
|
980
|
+
out.push(0);
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
const n = Math.min(a.length, b.length) || 1;
|
|
984
|
+
let s = 0;
|
|
985
|
+
for (let k = 0; k < n; k++) s += Math.abs((a[k] ?? 0) - (b[k] ?? 0));
|
|
986
|
+
out.push(+(s / n / 255).toFixed(4));
|
|
987
|
+
}
|
|
988
|
+
return out;
|
|
989
|
+
}
|
|
990
|
+
async function decodeGreyscale(jpeg2, maxDim = 512) {
|
|
991
|
+
const { data, info } = await sharp(jpeg2).greyscale().resize(maxDim, maxDim, { fit: "inside", withoutEnlargement: true }).raw().toBuffer({ resolveWithObject: true });
|
|
992
|
+
const n = info.width * info.height;
|
|
993
|
+
const grey = new Uint8Array(n);
|
|
994
|
+
for (let i = 0; i < n; i++) grey[i] = data[i * info.channels];
|
|
995
|
+
return { width: info.width, height: info.height, data: grey };
|
|
996
|
+
}
|
|
997
|
+
async function diffScreenshots(baseline, current) {
|
|
998
|
+
const [a, b] = await Promise.all([decodeGreyscale(baseline), decodeGreyscale(current)]);
|
|
999
|
+
const [, meanDiff] = await frameDiffs([baseline, current]);
|
|
1000
|
+
return { ...compareImages(a, b), meanDiff: meanDiff ?? 0 };
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// src/commands/appVerify.ts
|
|
1004
|
+
import { formatHapVerification, verifyHap, verifyHapExitCode } from "@oniroproject/core";
|
|
1005
|
+
function registerAppVerifyCommand(app) {
|
|
1006
|
+
app.command("verify <hap>").description(
|
|
1007
|
+
"Verify a HAP's signature with the SDK's hap-sign-tool (`verify-app`), then report what signed it: the certificate chain and its root, whether it is OpenHarmony development material or a HarmonyOS certificate from AppGallery Connect (root Huawei CBG Root CA G2), and the provisioning profile: type, bundle name, apl, app-feature, devices, validity and days left. Exits 0 when valid, 1 when unsigned, invalid, expired or mismatched, 2 when it could not verify (no java or hap-sign-tool.jar). Needs java."
|
|
1008
|
+
).option("--json", "Emit the result as JSON.").action(async (hap, opts) => {
|
|
1009
|
+
const { config, logger } = getRuntime();
|
|
1010
|
+
const result = await verifyHap({ hapPath: hap, config, logger });
|
|
1011
|
+
process.stdout.write(opts.json ? `${JSON.stringify(result, null, 2)}
|
|
1012
|
+
` : formatHapVerification(result));
|
|
1013
|
+
process.exitCode = verifyHapExitCode(result);
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// src/commands/app.ts
|
|
1018
|
+
function addSmokeOptions(cmd) {
|
|
1019
|
+
return cmd.option(
|
|
1020
|
+
"--smoke",
|
|
1021
|
+
"After launch, wait for the process, take a screenshot after --smoke-delay, and fail (exit 1) when the process died or the screen is blank."
|
|
1022
|
+
).option("--smoke-delay <ms>", "With --smoke: wait between the process appearing and the screenshot.", String(DEFAULT_SMOKE_DELAY_MS)).option("--smoke-screenshot <file>", "With --smoke: where to write the screenshot (default: a file in the system temp dir).");
|
|
1023
|
+
}
|
|
1024
|
+
function smokeDelayFrom(raw) {
|
|
1025
|
+
const n = Number(raw);
|
|
1026
|
+
if (!Number.isFinite(n) || n < 0) throw new OniroError3(`--smoke-delay expects a number of milliseconds; got "${raw}".`);
|
|
1027
|
+
return Math.round(n);
|
|
1028
|
+
}
|
|
1029
|
+
async function wakeForSmoke(config, logger, device) {
|
|
1030
|
+
const screen = await wakeAndUnlock({ config, deviceSerial: device, logger }).catch((err) => {
|
|
1031
|
+
logger.warn(`Could not wake the screen: ${err.message}`);
|
|
1032
|
+
return null;
|
|
1033
|
+
});
|
|
1034
|
+
if (screen?.locked) logger.warn("The lock screen asks for a credential; the smoke screenshot will show it.");
|
|
1035
|
+
}
|
|
1036
|
+
var CRASH_CLOCK_SLACK_MS = 2e3;
|
|
1037
|
+
var CRASH_WAIT_MS = 15e3;
|
|
1038
|
+
function runSmoke(config, logger, bundle, since, opts) {
|
|
1039
|
+
const screenshotPath = opts.smokeScreenshot ? path4.resolve(opts.smokeScreenshot) : path4.join(os2.tmpdir(), `oniro-smoke-${bundle}-${since.getTime()}.jpeg`);
|
|
1040
|
+
return smokeCheck({
|
|
1041
|
+
config,
|
|
1042
|
+
bundle,
|
|
1043
|
+
since,
|
|
1044
|
+
delayMs: smokeDelayFrom(opts.smokeDelay),
|
|
1045
|
+
screenshotPath,
|
|
1046
|
+
decodeGreyscale: (jpeg2) => decodeGreyscale(jpeg2),
|
|
1047
|
+
crashProbe: (probe) => latestCrash({
|
|
1048
|
+
config: probe.config,
|
|
1049
|
+
bundle: probe.bundle,
|
|
1050
|
+
// Faultlog times are the device's clock, which need not agree with the host's:
|
|
1051
|
+
// ask for "as long ago as the launch" on the device's clock, plus a little slack.
|
|
1052
|
+
since: (Date.now() - probe.since.getTime() + CRASH_CLOCK_SLACK_MS) / 1e3,
|
|
1053
|
+
// hiview writes the faultlog seconds after a death; a live app is looked up once.
|
|
1054
|
+
waitMs: probe.alive ? 0 : CRASH_WAIT_MS,
|
|
1055
|
+
deviceSerial: probe.deviceSerial,
|
|
1056
|
+
logger: probe.logger
|
|
1057
|
+
}),
|
|
1058
|
+
deviceSerial: opts.device,
|
|
1059
|
+
logger
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
function formatSmoke(s) {
|
|
1063
|
+
const parts = [
|
|
1064
|
+
s.pid === null ? "no process" : `pid ${s.pid} ${s.alive ? "alive" : "died"}`,
|
|
1065
|
+
s.blankScreen === null ? "blank check skipped" : `${s.blankScreen ? "blank" : "not blank"} (score ${s.blankScore})`
|
|
1066
|
+
];
|
|
1067
|
+
if (s.crash) {
|
|
1068
|
+
const where = s.crash.file ? ` at ${s.crash.file}:${s.crash.line ?? "?"}` : "";
|
|
1069
|
+
parts.push(`${s.crash.kind ?? "crash"}${s.crash.reason ? ` (${s.crash.reason})` : ""}${where}`);
|
|
1070
|
+
}
|
|
1071
|
+
if (s.screenshot) parts.push(`screenshot ${s.screenshot}`);
|
|
1072
|
+
return `smoke: ${s.passed ? "passed" : `FAILED (${s.failures.join("; ")})`}: ${parts.join(", ")}`;
|
|
1073
|
+
}
|
|
1074
|
+
async function launchWithSmoke(rt2, p) {
|
|
1075
|
+
const { config, logger } = rt2;
|
|
1076
|
+
const smokeDelay = p.smokeDelay ?? DEFAULT_SMOKE_DELAY_MS;
|
|
1077
|
+
if (p.smoke) {
|
|
1078
|
+
smokeDelayFrom(smokeDelay);
|
|
1079
|
+
await wakeForSmoke(config, logger, p.device);
|
|
1080
|
+
}
|
|
1081
|
+
const since = /* @__PURE__ */ new Date();
|
|
1082
|
+
let bundle;
|
|
1083
|
+
if (p.bundle) {
|
|
1084
|
+
bundle = p.bundle;
|
|
1085
|
+
logger.info(`Launching installed bundle ${p.bundle}...`);
|
|
1086
|
+
await launchApp({ config, bundleName: p.bundle, abilityName: p.ability, deviceSerial: p.device, logger });
|
|
1087
|
+
} else {
|
|
1088
|
+
const dir = path4.resolve(p.projectDir ?? process.cwd());
|
|
1089
|
+
bundle = getBundleName(dir);
|
|
1090
|
+
logger.info(`Launching app from ${dir}...`);
|
|
1091
|
+
await launchApp({
|
|
1092
|
+
config,
|
|
1093
|
+
projectDir: dir,
|
|
1094
|
+
moduleName: p.module ?? "entry",
|
|
1095
|
+
abilityName: p.ability,
|
|
1096
|
+
deviceSerial: p.device,
|
|
1097
|
+
logger
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
logger.info("App launched.");
|
|
1101
|
+
if (!p.smoke) return { bundle };
|
|
1102
|
+
const smoke = await runSmoke(config, logger, bundle, since, { ...p, smokeDelay });
|
|
1103
|
+
return { bundle, smoke };
|
|
1104
|
+
}
|
|
1105
|
+
async function applyWithSmoke(rt2, p) {
|
|
1106
|
+
const { config, logger } = rt2;
|
|
1107
|
+
const smokeDelay = p.smokeDelay ?? DEFAULT_SMOKE_DELAY_MS;
|
|
1108
|
+
if (p.smoke) smokeDelayFrom(smokeDelay);
|
|
1109
|
+
const target = p.target;
|
|
1110
|
+
const isHap = target !== void 0 && /\.hap$/i.test(target);
|
|
1111
|
+
if (isHap && p.hap) throw new OniroError3("Pass the HAP either as the argument or with --hap, not both.");
|
|
1112
|
+
const dir = isHap ? process.cwd() : path4.resolve(target ?? process.cwd());
|
|
1113
|
+
const hapPath = isHap ? path4.resolve(target) : p.hap;
|
|
1114
|
+
let bundle = p.bundle;
|
|
1115
|
+
if (!bundle && isHap) {
|
|
1116
|
+
const hap = existingHapOrUnsigned(hapPath);
|
|
1117
|
+
if (!hap) throw new OniroError3(`HAP not found at ${hapPath}.`);
|
|
1118
|
+
bundle = await readHapBundleName(hap.path).catch(() => null) ?? void 0;
|
|
1119
|
+
if (!bundle) throw new OniroError3(`Could not read the bundle name from ${hap.path}. Pass --bundle.`);
|
|
1120
|
+
}
|
|
1121
|
+
bundle ??= getBundleName(dir);
|
|
1122
|
+
const result = await applyChanges({
|
|
1123
|
+
config,
|
|
1124
|
+
bundle,
|
|
1125
|
+
hapPath,
|
|
1126
|
+
projectDir: dir,
|
|
1127
|
+
module: p.module,
|
|
1128
|
+
installedHapPath: p.installedHap,
|
|
1129
|
+
isSystemBundle: p.system,
|
|
1130
|
+
allowUninstall: p.allowUninstall,
|
|
1131
|
+
deviceSerial: p.device,
|
|
1132
|
+
logger
|
|
1133
|
+
});
|
|
1134
|
+
if (!p.smoke) return result;
|
|
1135
|
+
await wakeForSmoke(config, logger, p.device);
|
|
1136
|
+
const since = /* @__PURE__ */ new Date();
|
|
1137
|
+
logger.info(`Launching ${bundle} for the smoke check...`);
|
|
1138
|
+
await launchApp({ config, bundleName: bundle, deviceSerial: p.device, logger });
|
|
1139
|
+
const smoke = await runSmoke(config, logger, bundle, since, { ...p, smokeDelay });
|
|
1140
|
+
return { ...result, smoke };
|
|
1141
|
+
}
|
|
1142
|
+
function registerAppCommand(program) {
|
|
1143
|
+
const app = program.command("app").description("Install, launch, and manage built apps on a connected device/emulator.");
|
|
1144
|
+
app.command("install [project-dir-or-hap]").description(
|
|
1145
|
+
"Install a built .hap on the connected device/emulator via hdc. Takes a project dir (default: the current one), whose newest signed HAP is installed, else its unsigned one, or a .hap file. Fails when the bundle manager rejects the HAP, and says how to sign when a target refuses an unsigned one."
|
|
1146
|
+
).option("--hap <path>", "Override the .hap path (relative to project-dir or absolute).").option("--device <serial>", "Target device serial.").action(async (target, opts) => {
|
|
1147
|
+
const { config, logger } = getRuntime();
|
|
1148
|
+
const isHap = target !== void 0 && /\.hap$/i.test(target);
|
|
1149
|
+
const dir = isHap ? process.cwd() : path4.resolve(target ?? process.cwd());
|
|
1150
|
+
const hapPath = isHap ? path4.resolve(target) : opts.hap;
|
|
1151
|
+
logger.info(`Installing ${hapPath ?? `app from ${dir}`}...`);
|
|
1152
|
+
const result = await installApp({ config, projectDir: dir, hapPath, deviceSerial: opts.device, logger });
|
|
1153
|
+
logger.info(`Installed ${result.hapPath}.`);
|
|
1154
|
+
});
|
|
1155
|
+
const launch2 = app.command("launch [project-dir]").description(
|
|
1156
|
+
"Launch an app on the connected device/emulator. Project mode (default): resolves bundle + main ability from the project. Bundle mode (--bundle): launch an already-installed app by bundle name, no project needed."
|
|
1157
|
+
).option("--bundle <bundle>", "Launch an already-installed app by bundle name (no project needed).").option("--module <module>", "Module folder name (project mode).", "entry").option("--ability <name>", "Explicit ability to launch (defaults to the module mainElement in project mode, or the installed bundle main ability in --bundle mode).").option("--device <serial>", "Target device serial.");
|
|
1158
|
+
addSmokeOptions(launch2).option("--json", "With --smoke: emit `{ bundle, smoke }` as JSON.").action(async (projectDir, opts) => {
|
|
1159
|
+
const { config, logger } = getRuntime();
|
|
1160
|
+
const { bundle, smoke } = await launchWithSmoke({ config, logger }, { ...opts, projectDir });
|
|
1161
|
+
if (!smoke) return;
|
|
1162
|
+
if (opts.json) process.stdout.write(`${JSON.stringify({ bundle, smoke }, null, 2)}
|
|
1163
|
+
`);
|
|
1164
|
+
else process.stdout.write(`${formatSmoke(smoke)}
|
|
1165
|
+
`);
|
|
1166
|
+
if (!smoke.passed) process.exitCode = 1;
|
|
1167
|
+
});
|
|
1168
|
+
const apply2 = app.command("apply [project-dir-or-hap]").description(
|
|
1169
|
+
"Install a HAP (a project dir, default the current one, or a .hap file) and verify the running process took the change. Refuses a HAP of another bundle. Handles sign-info mismatch, asset-cache invalidation, and persistent-bundle restart."
|
|
1170
|
+
).option(
|
|
1171
|
+
"--bundle <bundle>",
|
|
1172
|
+
"Bundle name to apply changes to. Defaults to the bundleName in the .hap file, else in the project AppScope/app.json5."
|
|
1173
|
+
).option("--module <module>", "Module to resolve the HAP from (multi-module projects).").option("--hap <path>", "Explicit .hap path (relative to project-dir or absolute).").option("--installed-hap <path>", "Local path to the currently-installed HAP, to enable the asset-cache reboot.").option("--system", "Treat as a persistent/system bundle (refuses uninstall on sign-info mismatch).").option("--allow-uninstall", "Permit uninstalling a system bundle on sign-info mismatch (dangerous).").option("--device <serial>", "Target device serial.").option("--json", "Emit the result as JSON (with --smoke, plus a `smoke` object).");
|
|
1174
|
+
addSmokeOptions(apply2).action(async (target, opts) => {
|
|
1175
|
+
const { config, logger } = getRuntime();
|
|
1176
|
+
const report = await applyWithSmoke({ config, logger }, { ...opts, target });
|
|
1177
|
+
const { smoke, ...result } = report;
|
|
1178
|
+
if (opts.json) {
|
|
1179
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}
|
|
1180
|
+
`);
|
|
1181
|
+
} else {
|
|
1182
|
+
logger.info(
|
|
1183
|
+
`apply: method=${result.method} pid ${result.preInstallPid ?? "-"} -> ${result.postInstallPid ?? "-"}${result.cacheCleared ? " (cache cleared)" : ""}`
|
|
1184
|
+
);
|
|
1185
|
+
if (smoke) process.stdout.write(`${formatSmoke(smoke)}
|
|
1186
|
+
`);
|
|
1187
|
+
}
|
|
1188
|
+
if (smoke && !smoke.passed) process.exitCode = 1;
|
|
1189
|
+
});
|
|
1190
|
+
app.command("uninstall <bundle>").description("Uninstall an app by bundle name.").option("--device <serial>", "Target device serial.").action(async (bundle, opts) => {
|
|
1191
|
+
const { config, logger } = getRuntime();
|
|
1192
|
+
await uninstallApp({ config, bundle, deviceSerial: opts.device, logger });
|
|
1193
|
+
logger.info(`Uninstalled ${bundle}.`);
|
|
1194
|
+
});
|
|
1195
|
+
app.command("stop <bundle>").description("Force-stop an app by bundle name.").option("--device <serial>", "Target device serial.").action(async (bundle, opts) => {
|
|
1196
|
+
const { config, logger } = getRuntime();
|
|
1197
|
+
await forceStop({ config, bundle, deviceSerial: opts.device, logger });
|
|
1198
|
+
logger.info(`Stopped ${bundle}.`);
|
|
1199
|
+
});
|
|
1200
|
+
registerAppVerifyCommand(app);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// src/commands/create.ts
|
|
1204
|
+
import * as path6 from "path";
|
|
1205
|
+
import {
|
|
1206
|
+
ALL_SDKS as ALL_SDKS2,
|
|
1207
|
+
createScaffold,
|
|
1208
|
+
isValidBundleName,
|
|
1209
|
+
isValidProjectName
|
|
1210
|
+
} from "@oniroproject/core";
|
|
1211
|
+
|
|
1212
|
+
// src/lib/templateRoot.ts
|
|
1213
|
+
import * as fs2 from "fs";
|
|
1214
|
+
import * as path5 from "path";
|
|
1215
|
+
import { fileURLToPath } from "url";
|
|
1216
|
+
function getBundledTemplateRoot() {
|
|
1217
|
+
const here = path5.dirname(fileURLToPath(import.meta.url));
|
|
1218
|
+
const candidates = [
|
|
1219
|
+
path5.resolve(here, "../templates"),
|
|
1220
|
+
// dist/oniro-app.js → ../templates
|
|
1221
|
+
path5.resolve(here, "../../templates")
|
|
1222
|
+
// src/bin/oniro-app.ts → ../../templates
|
|
1223
|
+
];
|
|
1224
|
+
for (const c of candidates) {
|
|
1225
|
+
if (fs2.existsSync(c)) return c;
|
|
1226
|
+
}
|
|
1227
|
+
return candidates[0];
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// src/commands/create.ts
|
|
1231
|
+
function registerCreateCommand(program) {
|
|
1232
|
+
program.command("create").description("Scaffold a new Oniro/OpenHarmony or HarmonyOS app from a template. All flags are required (non-interactive).").requiredOption("--name <name>", "Project folder name (letters, digits, ._- only)").requiredOption("--bundle <bundleName>", "Bundle name in reverse-DNS form, e.g. com.example.myapp").requiredOption("--location <dir>", "Parent directory where the new project folder will be created").requiredOption("--sdk <api>", "Target SDK API level (e.g. 18, 20)").option("--template <id>", "Template id", "EmptyAbility").option("--module <name>", "Module folder name (defaults to the template default, usually `entry`)").option("--overwrite", "Replace the destination if it already exists").action(async (opts) => {
|
|
1233
|
+
const { config, logger } = getRuntime();
|
|
1234
|
+
if (!isValidProjectName(opts.name)) {
|
|
1235
|
+
throw new Error(`Invalid --name '${opts.name}'. Use letters/numbers/._- and no slashes.`);
|
|
1236
|
+
}
|
|
1237
|
+
if (!isValidBundleName(opts.bundle)) {
|
|
1238
|
+
throw new Error(`Invalid --bundle '${opts.bundle}'. Example: com.example.myapp`);
|
|
1239
|
+
}
|
|
1240
|
+
const sdkApi = Number(opts.sdk);
|
|
1241
|
+
if (!Number.isFinite(sdkApi)) {
|
|
1242
|
+
throw new Error(`--sdk must be a numeric api level. Known: ${ALL_SDKS2.map((s) => s.api).join(", ")}`);
|
|
1243
|
+
}
|
|
1244
|
+
const templateRoot = getBundledTemplateRoot();
|
|
1245
|
+
const result = await createScaffold({
|
|
1246
|
+
config,
|
|
1247
|
+
templateId: opts.template,
|
|
1248
|
+
projectName: opts.name,
|
|
1249
|
+
bundleName: opts.bundle,
|
|
1250
|
+
location: path6.resolve(opts.location),
|
|
1251
|
+
sdkApi,
|
|
1252
|
+
moduleName: opts.module,
|
|
1253
|
+
templateRoot,
|
|
1254
|
+
overwrite: opts.overwrite,
|
|
1255
|
+
logger
|
|
1256
|
+
});
|
|
1257
|
+
logger.info(`Project created at ${result.projectDir}`);
|
|
1258
|
+
process.stdout.write(`${result.projectDir}
|
|
1259
|
+
`);
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// src/commands/templates.ts
|
|
1264
|
+
import { listTemplates } from "@oniroproject/core";
|
|
730
1265
|
function registerTemplatesCommand(program) {
|
|
731
1266
|
const templates = program.command("templates").description("Inspect the templates bundled with this CLI.");
|
|
732
1267
|
templates.command("list").description("List available project templates.").option("--json", "Emit machine-readable JSON.").action((opts) => {
|
|
733
1268
|
const root = getBundledTemplateRoot();
|
|
734
|
-
const
|
|
1269
|
+
const list2 = listTemplates(root);
|
|
735
1270
|
if (opts.json) {
|
|
736
|
-
process.stdout.write(`${JSON.stringify(
|
|
1271
|
+
process.stdout.write(`${JSON.stringify(list2, null, 2)}
|
|
737
1272
|
`);
|
|
738
1273
|
return;
|
|
739
1274
|
}
|
|
740
|
-
if (
|
|
1275
|
+
if (list2.length === 0) {
|
|
741
1276
|
process.stdout.write("No templates available.\n");
|
|
742
1277
|
return;
|
|
743
1278
|
}
|
|
744
|
-
const rows =
|
|
1279
|
+
const rows = list2.map((t) => `${t.id.padEnd(20)} ${t.label.padEnd(24)} ${t.description}`);
|
|
745
1280
|
process.stdout.write(`${rows.join("\n")}
|
|
746
1281
|
`);
|
|
747
1282
|
});
|
|
@@ -774,6 +1309,9 @@ function registerRebootCommand(program) {
|
|
|
774
1309
|
import { waitForLog, waitForBoot, waitForBundle, waitForTarget, describeLayoutNode } from "@oniroproject/core";
|
|
775
1310
|
|
|
776
1311
|
// src/lib/target.ts
|
|
1312
|
+
import { parseSelector } from "@oniroproject/core";
|
|
1313
|
+
var SELECTOR_HELP = `An attribute selector such as "type=Button,text*=Sign" (keys text, desc, hint, id, key, type, enabled, focused, checked, selected, visible, clickable, longClickable, checkable, scrollable; operators = *= ^= $= /=regex; a [n] suffix picks the n-th match, 0-based, [-1] or [last] the last), or "xpath=//Button[@text='OK']".`;
|
|
1314
|
+
var WINDOW_HELP = "Restrict to one window: a window id from `dump layout --flat` ([window <id> \u2026]) or `dump windows`.";
|
|
777
1315
|
function addTargetOptions(cmd, verb, opts = {}) {
|
|
778
1316
|
cmd.option(
|
|
779
1317
|
"--target <text>",
|
|
@@ -782,10 +1320,20 @@ function addTargetOptions(cmd, verb, opts = {}) {
|
|
|
782
1320
|
if (opts.index !== false) {
|
|
783
1321
|
cmd.option("--target-index <n>", "Pick the n-th (0-based) of several matching elements.");
|
|
784
1322
|
}
|
|
1323
|
+
cmd.option("--selector <expr>", `Instead of --target*: ${SELECTOR_HELP}`).option("--window <id>", WINDOW_HELP);
|
|
785
1324
|
return cmd;
|
|
786
1325
|
}
|
|
1326
|
+
function parseSelectorOption(raw) {
|
|
1327
|
+
return raw === void 0 ? void 0 : parseSelector(raw);
|
|
1328
|
+
}
|
|
787
1329
|
function selectorFrom(opts) {
|
|
788
|
-
|
|
1330
|
+
const plain = opts.target !== void 0 || opts.targetId !== void 0 || opts.targetType !== void 0;
|
|
1331
|
+
if (opts.selector !== void 0) {
|
|
1332
|
+
if (plain) throw new Error("Use either --selector or --target / --target-id / --target-type, not both.");
|
|
1333
|
+
if (opts.targetIndex !== void 0) throw new Error("--target-index does not apply to --selector; append [n] to the selector.");
|
|
1334
|
+
return parseSelector(opts.selector);
|
|
1335
|
+
}
|
|
1336
|
+
if (!plain) return void 0;
|
|
789
1337
|
return { text: opts.target, id: opts.targetId, type: opts.targetType };
|
|
790
1338
|
}
|
|
791
1339
|
function targetIndexFrom(opts) {
|
|
@@ -794,6 +1342,12 @@ function targetIndexFrom(opts) {
|
|
|
794
1342
|
if (!Number.isInteger(n) || n < 0) throw new Error(`--target-index must be a non-negative integer; got ${opts.targetIndex}.`);
|
|
795
1343
|
return n;
|
|
796
1344
|
}
|
|
1345
|
+
function windowIdFrom(opts) {
|
|
1346
|
+
if (opts.window === void 0) return void 0;
|
|
1347
|
+
const n = Number(opts.window);
|
|
1348
|
+
if (!Number.isInteger(n) || n < 0) throw new Error(`--window must be a window id (a non-negative integer); got ${opts.window}.`);
|
|
1349
|
+
return n;
|
|
1350
|
+
}
|
|
797
1351
|
|
|
798
1352
|
// src/lib/logSince.ts
|
|
799
1353
|
function parseSince(value) {
|
|
@@ -808,18 +1362,20 @@ function parseSince(value) {
|
|
|
808
1362
|
|
|
809
1363
|
// src/commands/wait.ts
|
|
810
1364
|
function registerWaitCommand(program) {
|
|
811
|
-
const
|
|
812
|
-
"Wait for a device condition: a log line (--log), boot (--boot), a bundle to start (--bundle), or an on-screen element (--target / --target-id / --target-type)."
|
|
1365
|
+
const wait2 = program.command("wait").description(
|
|
1366
|
+
"Wait for a device condition: a log line (--log), boot (--boot), a bundle to start (--bundle), or an on-screen element (--target / --target-id / --target-type, or --selector)."
|
|
813
1367
|
).option("--log <pattern>", "Resolve when a hilog line (tag: message) logged after the command starts matches this regex.").option(
|
|
814
1368
|
"--since <seconds>",
|
|
815
1369
|
'With --log: also match lines logged up to N seconds before the command started, measured on the device clock; "all" matches everything still in the device log buffer.'
|
|
816
1370
|
).option("--boot", "Resolve when the device is reachable (optionally also --pid-of).").option("--bundle <bundle>", "With --log: filter to this bundle. Alone: wait until this bundle is running.").option("--pid-of <name>", "With --boot: also require this process to have a pid.").option("--domain <domain>", "With --log: hilog domain filter (e.g. 0xD003900).");
|
|
817
|
-
addTargetOptions(
|
|
818
|
-
|
|
1371
|
+
addTargetOptions(wait2, "Resolve when", { index: false });
|
|
1372
|
+
wait2.option("--gone", "With --target* or --selector: resolve when no uncovered element matches any more.").option("--interval <ms>", "With --target* or --selector: poll interval, ms (each poll is a layout dump).", "500").option("--timeout <ms>", "Max wait, ms.", "30000").option("--device <serial>", "Target device serial.").option("--json", "With --log, --target* or --selector: emit the matched entry/element as JSON.").action(async (opts) => {
|
|
819
1373
|
const { config, logger } = getRuntime();
|
|
820
1374
|
const timeoutMs = Number(opts.timeout);
|
|
821
1375
|
const selector = selectorFrom(opts);
|
|
1376
|
+
const windowId = windowIdFrom(opts);
|
|
822
1377
|
if (opts.since !== void 0 && !opts.log) throw new Error("--since applies only to --log.");
|
|
1378
|
+
if (windowId !== void 0 && !selector) throw new Error("--window applies only to --target* and --selector.");
|
|
823
1379
|
if (opts.log) {
|
|
824
1380
|
const entry = await waitForLog({
|
|
825
1381
|
config,
|
|
@@ -846,6 +1402,7 @@ function registerWaitCommand(program) {
|
|
|
846
1402
|
config,
|
|
847
1403
|
selector,
|
|
848
1404
|
gone: opts.gone,
|
|
1405
|
+
windowId,
|
|
849
1406
|
timeoutMs,
|
|
850
1407
|
pollMs: Number(opts.interval) || 500,
|
|
851
1408
|
deviceSerial: opts.device,
|
|
@@ -865,7 +1422,9 @@ function registerWaitCommand(program) {
|
|
|
865
1422
|
logger.info(`Bundle ${opts.bundle} is running.`);
|
|
866
1423
|
return;
|
|
867
1424
|
}
|
|
868
|
-
throw new Error(
|
|
1425
|
+
throw new Error(
|
|
1426
|
+
"Specify one of --log <pattern>, --boot, --bundle <bundle>, --target <text> (or --target-id / --target-type), or --selector <expr>."
|
|
1427
|
+
);
|
|
869
1428
|
});
|
|
870
1429
|
}
|
|
871
1430
|
|
|
@@ -902,150 +1461,7 @@ function registerWatchCommand(program) {
|
|
|
902
1461
|
// src/commands/screenshot.ts
|
|
903
1462
|
import * as fs3 from "fs";
|
|
904
1463
|
import * as path7 from "path";
|
|
905
|
-
import { takeScreenshot, captureBurst, locateTarget, describeLayoutNode as describeLayoutNode2 } from "@oniroproject/core";
|
|
906
|
-
|
|
907
|
-
// src/lib/screenshotImage.ts
|
|
908
|
-
import sharp from "sharp";
|
|
909
|
-
function buildGridSvg(width, height) {
|
|
910
|
-
const lineColor = "rgba(255,80,80,0.55)";
|
|
911
|
-
const labelBg = "rgba(0,0,0,0.7)";
|
|
912
|
-
const labelFg = "#fff";
|
|
913
|
-
const fontSize = Math.max(10, Math.round(Math.min(width, height) / 60));
|
|
914
|
-
const padding = Math.round(fontSize * 0.4);
|
|
915
|
-
const labels = [];
|
|
916
|
-
const lines = [];
|
|
917
|
-
for (let i = 0; i <= 10; i++) {
|
|
918
|
-
const f = i / 10;
|
|
919
|
-
const x = Math.round(f * (width - 1));
|
|
920
|
-
const y = Math.round(f * (height - 1));
|
|
921
|
-
if (i > 0 && i < 10) {
|
|
922
|
-
lines.push(`<line x1="${x}" y1="0" x2="${x}" y2="${height}" stroke="${lineColor}" stroke-width="1"/>`);
|
|
923
|
-
lines.push(`<line x1="0" y1="${y}" x2="${width}" y2="${y}" stroke="${lineColor}" stroke-width="1"/>`);
|
|
924
|
-
}
|
|
925
|
-
const tag = f.toFixed(1);
|
|
926
|
-
const tx = i === 0 ? padding : i === 10 ? width - padding : x;
|
|
927
|
-
const anchorX = i === 0 ? "start" : i === 10 ? "end" : "middle";
|
|
928
|
-
labels.push(
|
|
929
|
-
`<text x="${tx}" y="${fontSize + padding}" font-family="monospace" font-size="${fontSize}" font-weight="bold" fill="${labelFg}" stroke="${labelBg}" stroke-width="3" paint-order="stroke" text-anchor="${anchorX}">${tag}</text>`
|
|
930
|
-
);
|
|
931
|
-
const ty = i === 0 ? fontSize + padding : i === 10 ? height - padding : y + fontSize / 3;
|
|
932
|
-
labels.push(
|
|
933
|
-
`<text x="${padding}" y="${ty}" font-family="monospace" font-size="${fontSize}" font-weight="bold" fill="${labelFg}" stroke="${labelBg}" stroke-width="3" paint-order="stroke" text-anchor="start">${tag}</text>`
|
|
934
|
-
);
|
|
935
|
-
}
|
|
936
|
-
labels.push(
|
|
937
|
-
`<text x="${width / 2}" y="${fontSize * 2.4 + padding}" font-family="monospace" font-size="${fontSize}" font-weight="bold" fill="${labelFg}" stroke="${labelBg}" stroke-width="3" paint-order="stroke" text-anchor="middle">X \u2192</text>`
|
|
938
|
-
);
|
|
939
|
-
labels.push(
|
|
940
|
-
`<text x="${padding}" y="${fontSize * 2.6 + padding}" font-family="monospace" font-size="${fontSize}" font-weight="bold" fill="${labelFg}" stroke="${labelBg}" stroke-width="3" paint-order="stroke" text-anchor="start">Y \u2193</text>`
|
|
941
|
-
);
|
|
942
|
-
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${lines.join("")}${labels.join("")}</svg>`;
|
|
943
|
-
}
|
|
944
|
-
async function renderWithGrid(jpeg, opts) {
|
|
945
|
-
const meta = await sharp(jpeg).metadata();
|
|
946
|
-
const originalWidth = meta.width ?? 0;
|
|
947
|
-
const originalHeight = meta.height ?? 0;
|
|
948
|
-
const longest = Math.max(originalWidth, originalHeight) || 1;
|
|
949
|
-
const scale = Math.min(1, opts.maxDim / longest);
|
|
950
|
-
const width = Math.max(1, Math.round(originalWidth * scale));
|
|
951
|
-
const height = Math.max(1, Math.round(originalHeight * scale));
|
|
952
|
-
const resized = await sharp(jpeg).resize(width, height).jpeg({ quality: 80 }).toBuffer();
|
|
953
|
-
if (!opts.grid) {
|
|
954
|
-
return { buf: resized, width, height, originalWidth, originalHeight };
|
|
955
|
-
}
|
|
956
|
-
const svg = buildGridSvg(width, height);
|
|
957
|
-
const buf = await sharp(resized).composite([{ input: Buffer.from(svg) }]).jpeg({ quality: 80 }).toBuffer();
|
|
958
|
-
return { buf, width, height, originalWidth, originalHeight };
|
|
959
|
-
}
|
|
960
|
-
function parseCropRect(spec) {
|
|
961
|
-
const parts = spec.split(",").map((s) => Number(s.trim()));
|
|
962
|
-
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 1)) {
|
|
963
|
-
throw new Error(`--crop expects four 0-1 fractions "x1,y1,x2,y2"; got "${spec}".`);
|
|
964
|
-
}
|
|
965
|
-
const [x1, y1, x2, y2] = parts;
|
|
966
|
-
if (!(x2 > x1 && y2 > y1)) throw new Error(`--crop needs x1 < x2 and y1 < y2; got "${spec}".`);
|
|
967
|
-
return [x1, y1, x2, y2];
|
|
968
|
-
}
|
|
969
|
-
function padCropRect(r, pad) {
|
|
970
|
-
return [Math.max(0, r[0] - pad), Math.max(0, r[1] - pad), Math.min(1, r[2] + pad), Math.min(1, r[3] + pad)];
|
|
971
|
-
}
|
|
972
|
-
async function cropImage(jpeg, rect, opts) {
|
|
973
|
-
const meta = await sharp(jpeg).metadata();
|
|
974
|
-
const w = meta.width ?? 1;
|
|
975
|
-
const h = meta.height ?? 1;
|
|
976
|
-
const left = Math.min(w - 1, Math.floor(rect[0] * w));
|
|
977
|
-
const top = Math.min(h - 1, Math.floor(rect[1] * h));
|
|
978
|
-
const cropWidth = Math.max(1, Math.min(w, Math.ceil(rect[2] * w)) - left);
|
|
979
|
-
const cropHeight = Math.max(1, Math.min(h, Math.ceil(rect[3] * h)) - top);
|
|
980
|
-
const scale = Math.min(1, opts.maxDim / Math.max(cropWidth, cropHeight));
|
|
981
|
-
const width = Math.max(1, Math.round(cropWidth * scale));
|
|
982
|
-
const height = Math.max(1, Math.round(cropHeight * scale));
|
|
983
|
-
const buf = await sharp(jpeg).extract({ left, top, width: cropWidth, height: cropHeight }).resize(width, height).jpeg({ quality: 85 }).toBuffer();
|
|
984
|
-
return { buf, width, height, left, top, cropWidth, cropHeight };
|
|
985
|
-
}
|
|
986
|
-
function buildTileLabelSvg(width, height, idx, diff) {
|
|
987
|
-
const fontSize = Math.max(12, Math.round(Math.min(width, height) / 16));
|
|
988
|
-
const pad = Math.round(fontSize * 0.4);
|
|
989
|
-
let label = `#${idx}`;
|
|
990
|
-
if (typeof diff === "number") label += ` \u0394${diff.toFixed(2)}`;
|
|
991
|
-
const boxW = Math.round(label.length * fontSize * 0.62) + pad * 2;
|
|
992
|
-
const boxH = fontSize + pad * 2;
|
|
993
|
-
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"><rect x="0" y="0" width="${boxW}" height="${boxH}" fill="rgba(0,0,0,0.6)"/><text x="${pad}" y="${fontSize + pad - 1}" font-family="monospace" font-size="${fontSize}" font-weight="bold" fill="#fff" stroke="rgba(0,0,0,0.85)" stroke-width="3" paint-order="stroke" text-anchor="start">${label}</text></svg>`;
|
|
994
|
-
}
|
|
995
|
-
async function buildContactSheet(frames, opts) {
|
|
996
|
-
if (frames.length === 0) throw new Error("buildContactSheet: no frames");
|
|
997
|
-
const count = frames.length;
|
|
998
|
-
const cols = Math.max(1, opts.cols ?? Math.ceil(Math.sqrt(count)));
|
|
999
|
-
const rows = Math.ceil(count / cols);
|
|
1000
|
-
const gap = opts.gap ?? 8;
|
|
1001
|
-
const meta0 = await sharp(frames[0]).metadata();
|
|
1002
|
-
const fw = meta0.width ?? 1;
|
|
1003
|
-
const fh = meta0.height ?? 1;
|
|
1004
|
-
const longest = Math.max(fw, fh) || 1;
|
|
1005
|
-
const tileScale = Math.min(1, opts.maxDim / cols / longest);
|
|
1006
|
-
const tileWidth = Math.max(1, Math.round(fw * tileScale));
|
|
1007
|
-
const tileHeight = Math.max(1, Math.round(fh * tileScale));
|
|
1008
|
-
const tiles = await Promise.all(
|
|
1009
|
-
frames.map((frame, i) => {
|
|
1010
|
-
const label = Buffer.from(buildTileLabelSvg(tileWidth, tileHeight, i, opts.diffs?.[i]));
|
|
1011
|
-
return sharp(frame).resize(tileWidth, tileHeight, { fit: "fill" }).composite([{ input: label, top: 0, left: 0 }]).png().toBuffer();
|
|
1012
|
-
})
|
|
1013
|
-
);
|
|
1014
|
-
const width = cols * tileWidth + (cols + 1) * gap;
|
|
1015
|
-
const height = rows * tileHeight + (rows + 1) * gap;
|
|
1016
|
-
const placements = tiles.map((input, i) => {
|
|
1017
|
-
const r = Math.floor(i / cols);
|
|
1018
|
-
const c = i % cols;
|
|
1019
|
-
return { input, left: gap + c * (tileWidth + gap), top: gap + r * (tileHeight + gap) };
|
|
1020
|
-
});
|
|
1021
|
-
const buf = await sharp({
|
|
1022
|
-
create: { width, height, channels: 3, background: { r: 20, g: 20, b: 20 } }
|
|
1023
|
-
}).composite(placements).jpeg({ quality: 80 }).toBuffer();
|
|
1024
|
-
return { buf, width, height, cols, rows, tileWidth, tileHeight, count };
|
|
1025
|
-
}
|
|
1026
|
-
async function frameDiffs(frames, opts = {}) {
|
|
1027
|
-
if (frames.length === 0) return [];
|
|
1028
|
-
const sample = opts.sample ?? 32;
|
|
1029
|
-
const thumbs = await Promise.all(
|
|
1030
|
-
frames.map((f) => sharp(f).greyscale().resize(sample, sample, { fit: "fill" }).raw().toBuffer())
|
|
1031
|
-
);
|
|
1032
|
-
const out = [0];
|
|
1033
|
-
for (let i = 1; i < thumbs.length; i++) {
|
|
1034
|
-
const a = thumbs[i - 1];
|
|
1035
|
-
const b = thumbs[i];
|
|
1036
|
-
if (!a || !b) {
|
|
1037
|
-
out.push(0);
|
|
1038
|
-
continue;
|
|
1039
|
-
}
|
|
1040
|
-
const n = Math.min(a.length, b.length) || 1;
|
|
1041
|
-
let s = 0;
|
|
1042
|
-
for (let k = 0; k < n; k++) s += Math.abs((a[k] ?? 0) - (b[k] ?? 0));
|
|
1043
|
-
out.push(+(s / n / 255).toFixed(4));
|
|
1044
|
-
}
|
|
1045
|
-
return out;
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
// src/commands/screenshot.ts
|
|
1464
|
+
import { takeScreenshot, captureBurst, locateTarget, describeLayoutNode as describeLayoutNode2, dumpLayout, listLayoutWindows } from "@oniroproject/core";
|
|
1049
1465
|
var DEFAULT_CONTACT_SHEET_FRAMES = 8;
|
|
1050
1466
|
var TARGET_CROP_PAD = 0.02;
|
|
1051
1467
|
function clampMaxDim(raw) {
|
|
@@ -1054,19 +1470,53 @@ function clampMaxDim(raw) {
|
|
|
1054
1470
|
return Math.max(256, Math.min(4096, Math.round(n)));
|
|
1055
1471
|
}
|
|
1056
1472
|
function registerScreenshotCommand(program) {
|
|
1057
|
-
const
|
|
1058
|
-
"Capture the screen. Default: full-resolution raw JPEG. --grid: downscaled JPEG with a 10x10 grid (axes 0.0-1.0) for picking tap coordinates. --crop / --target: a full-resolution crop of one region or
|
|
1473
|
+
const screenshot2 = program.command("screenshot").description(
|
|
1474
|
+
"Capture the screen. Default: full-resolution raw JPEG. --grid: downscaled JPEG with a 10x10 grid (axes 0.0-1.0) for picking tap coordinates. --crop / --target / --selector / --window: a full-resolution crop of one region, element or window, for checking detail cheaply. --contact-sheet: capture a burst and tile it into one image with per-frame change diffs. --diff: compare the screen with a baseline JPEG (perceptual-hash distance, pixel-change ratio)."
|
|
1059
1475
|
).option("-o, --output <file>", "Output file path.", "screenshot.jpeg").option("--grid", "Downscale (to --max-dim) and overlay a 10x10 coordinate grid.").option("--max-dim <px>", "Longest-side cap for --grid / --crop / --target / --contact-sheet (256-4096).", "1024").option("--crop <x1,y1,x2,y2>", "Crop to this region, given as 0-1 fractions of the screen.");
|
|
1060
|
-
addTargetOptions(
|
|
1061
|
-
|
|
1476
|
+
addTargetOptions(screenshot2, "Crop to");
|
|
1477
|
+
screenshot2.option("--contact-sheet", "Capture a burst and composite it into one tiled contact sheet.").option("--burst <count>", "Capture N frames; writes <base>-<i><ext> (or feeds --contact-sheet).").option("--interval <ms>", "Delay between burst frames, ms.", "50").option(
|
|
1478
|
+
"--diff <baseline.jpeg>",
|
|
1479
|
+
"Capture the screen (written to --output) and compare it with this baseline: pHash and dHash distance, pixel-change ratio, mean change."
|
|
1480
|
+
).option("--json", "Emit the full result object as JSON on stdout (with --contact-sheet or --diff).").option("--device <serial>", "Target device serial.").action(async (opts, command) => {
|
|
1062
1481
|
const { config, logger } = getRuntime();
|
|
1063
1482
|
const maxDim = clampMaxDim(opts.maxDim);
|
|
1064
1483
|
const intervalMs = Number(opts.interval) || 50;
|
|
1065
1484
|
const selector = selectorFrom(opts);
|
|
1066
|
-
|
|
1067
|
-
|
|
1485
|
+
const windowId = windowIdFrom(opts);
|
|
1486
|
+
if (opts.diff !== void 0) {
|
|
1487
|
+
if (opts.crop !== void 0 || selector || windowId !== void 0 || opts.grid || opts.contactSheet || opts.burst) {
|
|
1488
|
+
throw new Error(
|
|
1489
|
+
"--diff cannot be combined with --crop, --target* / --selector / --window, --grid, --contact-sheet, or --burst."
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
let baseline;
|
|
1493
|
+
try {
|
|
1494
|
+
baseline = fs3.readFileSync(opts.diff);
|
|
1495
|
+
} catch (err) {
|
|
1496
|
+
throw new Error(`Cannot read the baseline ${opts.diff}: ${err.message}`);
|
|
1497
|
+
}
|
|
1498
|
+
const shot2 = await takeScreenshot({ config, deviceSerial: opts.device, logger });
|
|
1499
|
+
fs3.writeFileSync(opts.output, shot2.pixels);
|
|
1500
|
+
const diff = await diffScreenshots(baseline, shot2.pixels);
|
|
1501
|
+
const result = { baseline: opts.diff, current: opts.output, ...diff };
|
|
1502
|
+
if (opts.json) {
|
|
1503
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1504
|
+
`);
|
|
1505
|
+
} else {
|
|
1506
|
+
process.stdout.write(
|
|
1507
|
+
`${diff.same ? "same" : "different"}: pHash ${diff.phashDistance}/64, dHash ${diff.dhashDistance}/64, ${(diff.pixelChangeRatio * 100).toFixed(2)}% of pixels changed, mean change ${diff.meanDiff.toFixed(4)}${diff.sizeMismatch ? " (sizes differ)" : ""}
|
|
1508
|
+
`
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
logger.info(`Wrote ${opts.output} (${shot2.width}x${shot2.height}).`);
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
if (opts.crop !== void 0 || selector || windowId !== void 0) {
|
|
1515
|
+
if (opts.crop !== void 0 && (selector || windowId !== void 0)) {
|
|
1516
|
+
throw new Error("Use either --crop or --target* / --selector / --window, not both.");
|
|
1517
|
+
}
|
|
1068
1518
|
if (opts.grid || opts.contactSheet || opts.burst) {
|
|
1069
|
-
throw new Error("--crop / --target cannot be combined with --grid, --contact-sheet, or --burst.");
|
|
1519
|
+
throw new Error("--crop / --target / --selector / --window cannot be combined with --grid, --contact-sheet, or --burst.");
|
|
1070
1520
|
}
|
|
1071
1521
|
let rect;
|
|
1072
1522
|
let label;
|
|
@@ -1075,6 +1525,7 @@ function registerScreenshotCommand(program) {
|
|
|
1075
1525
|
config,
|
|
1076
1526
|
selector,
|
|
1077
1527
|
index: targetIndexFrom(opts),
|
|
1528
|
+
windowId,
|
|
1078
1529
|
hideKeyboard: false,
|
|
1079
1530
|
deviceSerial: opts.device,
|
|
1080
1531
|
logger
|
|
@@ -1082,6 +1533,12 @@ function registerScreenshotCommand(program) {
|
|
|
1082
1533
|
if (!located.node.b) throw new Error(`${describeLayoutNode2(located.node)} has no bounds to crop to.`);
|
|
1083
1534
|
rect = padCropRect(located.node.b, TARGET_CROP_PAD);
|
|
1084
1535
|
label = describeLayoutNode2(located.node);
|
|
1536
|
+
} else if (windowId !== void 0) {
|
|
1537
|
+
const { tree } = await dumpLayout({ config, windowId, deviceSerial: opts.device, logger });
|
|
1538
|
+
const win = listLayoutWindows(tree).find((w) => w.windowId === windowId);
|
|
1539
|
+
if (!win?.b) throw new Error(`Window ${windowId} has no bounds to crop to.`);
|
|
1540
|
+
rect = win.b;
|
|
1541
|
+
label = `window ${windowId}${win.bundle ? ` (${win.bundle})` : ""}`;
|
|
1085
1542
|
} else {
|
|
1086
1543
|
rect = parseCropRect(opts.crop);
|
|
1087
1544
|
label = rect.map((n) => n.toFixed(3)).join(",");
|
|
@@ -1164,7 +1621,16 @@ function registerScreenshotCommand(program) {
|
|
|
1164
1621
|
}
|
|
1165
1622
|
|
|
1166
1623
|
// src/commands/dump.ts
|
|
1167
|
-
import
|
|
1624
|
+
import * as fs4 from "fs";
|
|
1625
|
+
import {
|
|
1626
|
+
diffLayouts,
|
|
1627
|
+
dumpLayout as dumpLayout2,
|
|
1628
|
+
findLayoutNodes,
|
|
1629
|
+
listWindows,
|
|
1630
|
+
parseLayoutBaseline,
|
|
1631
|
+
selectableTree,
|
|
1632
|
+
summarizeLayout
|
|
1633
|
+
} from "@oniroproject/core";
|
|
1168
1634
|
|
|
1169
1635
|
// src/lib/layoutText.ts
|
|
1170
1636
|
import { flattenLayout } from "@oniroproject/core";
|
|
@@ -1182,6 +1648,7 @@ function flags(n) {
|
|
|
1182
1648
|
if (n.sel) out.push("selected");
|
|
1183
1649
|
if (n.focus) out.push("focus");
|
|
1184
1650
|
if (n.disabled) out.push("disabled");
|
|
1651
|
+
if (n.hidden) out.push("hidden");
|
|
1185
1652
|
if (n.covered) out.push("covered");
|
|
1186
1653
|
return out;
|
|
1187
1654
|
}
|
|
@@ -1200,6 +1667,9 @@ function isListed(n) {
|
|
|
1200
1667
|
const labelled = n.text !== void 0 || n.desc !== void 0 || n.hint !== void 0;
|
|
1201
1668
|
return labelled || !!(n.click || n.longClick || n.scroll || n.check || n.focus);
|
|
1202
1669
|
}
|
|
1670
|
+
function windowLabel(windowId, bundle) {
|
|
1671
|
+
return [windowId, bundle].filter((x) => x !== void 0).join(" ");
|
|
1672
|
+
}
|
|
1203
1673
|
function formatFlatLayout(result) {
|
|
1204
1674
|
const lines = [];
|
|
1205
1675
|
const d = result.display;
|
|
@@ -1208,13 +1678,15 @@ function formatFlatLayout(result) {
|
|
|
1208
1678
|
);
|
|
1209
1679
|
const keyboardBundle = result.keyboard?.shown ? result.keyboard.bundle : void 0;
|
|
1210
1680
|
let window;
|
|
1681
|
+
let windowId;
|
|
1211
1682
|
for (const n of flattenLayout(result.tree)) {
|
|
1212
|
-
if (n.window !== window) {
|
|
1683
|
+
if (n.window !== window || n.windowId !== windowId) {
|
|
1213
1684
|
window = n.window;
|
|
1685
|
+
windowId = n.windowId;
|
|
1214
1686
|
if (window !== void 0 && window === keyboardBundle) {
|
|
1215
|
-
lines.push(`[keyboard ${window}] up \u2014 dismiss with input --type hideKeyboard`);
|
|
1216
|
-
} else if (window !== void 0) {
|
|
1217
|
-
lines.push(`[window ${window}]`);
|
|
1687
|
+
lines.push(`[keyboard ${windowLabel(windowId, window)}] up \u2014 dismiss with input --type hideKeyboard`);
|
|
1688
|
+
} else if (window !== void 0 || windowId !== void 0) {
|
|
1689
|
+
lines.push(`[window ${windowLabel(windowId, window)}]`);
|
|
1218
1690
|
}
|
|
1219
1691
|
}
|
|
1220
1692
|
if (window !== void 0 && window === keyboardBundle) continue;
|
|
@@ -1224,26 +1696,98 @@ function formatFlatLayout(result) {
|
|
|
1224
1696
|
return `${lines.join("\n")}
|
|
1225
1697
|
`;
|
|
1226
1698
|
}
|
|
1699
|
+
function formatSelectorMatches(display, selector, matches) {
|
|
1700
|
+
const lines = [
|
|
1701
|
+
`# display ${display ? `${display.width}x${display.height}` : "unknown"}; ${matches.length} element(s) match ${JSON.stringify(selector)}${matches.length > 1 ? "; append [n] to the selector to act on one" : ""}`
|
|
1702
|
+
];
|
|
1703
|
+
matches.forEach((n, i) => {
|
|
1704
|
+
const win = windowLabel(n.windowId, n.window);
|
|
1705
|
+
lines.push(`[${i}] ${formatLayoutLine(n)}${win ? ` (window ${win})` : ""}`);
|
|
1706
|
+
});
|
|
1707
|
+
return `${lines.join("\n")}
|
|
1708
|
+
`;
|
|
1709
|
+
}
|
|
1227
1710
|
|
|
1228
1711
|
// src/commands/dump.ts
|
|
1712
|
+
function readBaseline(file) {
|
|
1713
|
+
let text;
|
|
1714
|
+
try {
|
|
1715
|
+
text = fs4.readFileSync(file, "utf8");
|
|
1716
|
+
} catch (err) {
|
|
1717
|
+
throw new Error(`Cannot read the layout baseline ${file}: ${err.message}`);
|
|
1718
|
+
}
|
|
1719
|
+
try {
|
|
1720
|
+
return parseLayoutBaseline(JSON.parse(text));
|
|
1721
|
+
} catch (err) {
|
|
1722
|
+
throw new Error(`${file} is not a saved \`dump layout\` JSON: ${err.message}`);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
function formatWindows(windows) {
|
|
1726
|
+
const lines = ["# id name pid type z-order [x y w h]; z-order -1 = not shown; `--window <id>` restricts dump/input/wait/screenshot"];
|
|
1727
|
+
for (const w of windows) {
|
|
1728
|
+
const rect = w.rect ? ` [${w.rect.join(" ")}]` : "";
|
|
1729
|
+
lines.push(`${w.id} ${w.name} pid=${w.pid} type=${w.type} z=${w.zOrder}${rect}${w.focused ? " focused" : ""}`);
|
|
1730
|
+
}
|
|
1731
|
+
return `${lines.join("\n")}
|
|
1732
|
+
`;
|
|
1733
|
+
}
|
|
1229
1734
|
function registerDumpCommand(program) {
|
|
1230
1735
|
program.command("dump [target]").description(
|
|
1231
|
-
"Dump device state.
|
|
1232
|
-
).option("--bundle <bundle>", "Filter the layout to a single window/bundle.").option("--raw", "Return the unpruned layout tree.").option("--flat", "One line per element (type, label, center as 0-1 fractions, flags) instead of a JSON tree.").option("--
|
|
1736
|
+
"Dump device state. `dump layout` (the default target): JSON by default, or one line per element with --flat; --selector lists only the matching elements, --summary counts elements per window, --diff compares the live layout with one saved earlier. `dump windows`: the window list (ids for --window)."
|
|
1737
|
+
).option("--bundle <bundle>", "Filter the layout to a single window/bundle.").option("--raw", "Return the unpruned layout tree.").option("--flat", "One line per element (type, label, center as 0-1 fractions, flags) instead of a JSON tree.").option("--summary", "Per window: element, content, clickable and text-node counts (JSON).").option(
|
|
1738
|
+
"--diff <baseline.json>",
|
|
1739
|
+
"Compare with a saved `dump layout` JSON: matched elements with IoU, missing, extra, and a 0-1 similarity (JSON)."
|
|
1740
|
+
).option("--selector <expr>", `List only the elements matching this expression, numbered for its [n] suffix. ${SELECTOR_HELP}`).option("--window <id>", WINDOW_HELP).option("--json", "dump windows: print JSON instead of one line per window.").option("--device <serial>", "Target device serial.").action(async (target, opts) => {
|
|
1233
1741
|
const { config, logger } = getRuntime();
|
|
1234
1742
|
const what = target ?? "layout";
|
|
1235
|
-
if (what
|
|
1743
|
+
if (what === "windows") {
|
|
1744
|
+
const windows = await listWindows({ config, deviceSerial: opts.device, logger });
|
|
1745
|
+
process.stdout.write(opts.json ? `${JSON.stringify(windows, null, 2)}
|
|
1746
|
+
` : formatWindows(windows));
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
if (what !== "layout") throw new Error(`Unknown dump target "${what}". Supported: layout, windows.`);
|
|
1236
1750
|
if (opts.raw && opts.flat) throw new Error("--raw and --flat cannot be combined.");
|
|
1237
|
-
const
|
|
1751
|
+
const selector = parseSelectorOption(opts.selector);
|
|
1752
|
+
if (selector && opts.raw) throw new Error("--selector matches the pruned layout; it cannot be combined with --raw.");
|
|
1753
|
+
const modes = [opts.flat && "--flat", opts.summary && "--summary", opts.diff !== void 0 && "--diff"].filter(Boolean);
|
|
1754
|
+
if (modes.length > 1) throw new Error(`${modes.join(" and ")} cannot be combined.`);
|
|
1755
|
+
if (opts.raw && modes.length > 0) throw new Error(`--raw cannot be combined with ${modes[0]}.`);
|
|
1756
|
+
if (selector && (opts.summary || opts.diff !== void 0)) {
|
|
1757
|
+
throw new Error(`--selector cannot be combined with ${opts.summary ? "--summary" : "--diff"}.`);
|
|
1758
|
+
}
|
|
1759
|
+
const baseline = opts.diff !== void 0 ? readBaseline(opts.diff) : void 0;
|
|
1760
|
+
const result = await dumpLayout2({
|
|
1238
1761
|
config,
|
|
1239
1762
|
bundleName: opts.bundle,
|
|
1763
|
+
windowId: windowIdFrom(opts),
|
|
1240
1764
|
prune: !opts.raw,
|
|
1241
1765
|
detectKeyboard: !opts.raw,
|
|
1242
1766
|
deviceSerial: opts.device,
|
|
1243
1767
|
logger
|
|
1244
1768
|
});
|
|
1245
|
-
|
|
1246
|
-
|
|
1769
|
+
if (selector) {
|
|
1770
|
+
const matches = findLayoutNodes(selectableTree(result, windowIdFrom(opts)), selector);
|
|
1771
|
+
process.stdout.write(
|
|
1772
|
+
opts.flat ? formatSelectorMatches(result.display, selector.raw, matches) : `${JSON.stringify({ display: result.display, selector: selector.raw, matches }, null, 2)}
|
|
1773
|
+
`
|
|
1774
|
+
);
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
let out;
|
|
1778
|
+
if (opts.flat) out = formatFlatLayout(result);
|
|
1779
|
+
else if (opts.summary) out = `${JSON.stringify(summarizeLayout(result), null, 2)}
|
|
1780
|
+
`;
|
|
1781
|
+
else if (baseline) {
|
|
1782
|
+
const diff = diffLayouts(baseline, result);
|
|
1783
|
+
logger.info(
|
|
1784
|
+
`layout similarity ${diff.similarity.toFixed(3)}: ${diff.matched.length} matched (${diff.moved} moved, ${diff.changed} changed), ${diff.missing.length} missing, ${diff.extra.length} extra.`
|
|
1785
|
+
);
|
|
1786
|
+
out = `${JSON.stringify(diff, null, 2)}
|
|
1787
|
+
`;
|
|
1788
|
+
} else out = `${JSON.stringify(result, null, 2)}
|
|
1789
|
+
`;
|
|
1790
|
+
process.stdout.write(out);
|
|
1247
1791
|
});
|
|
1248
1792
|
}
|
|
1249
1793
|
|
|
@@ -1252,9 +1796,9 @@ import { getDeviceInfo, listDevices } from "@oniroproject/core";
|
|
|
1252
1796
|
function formatLongDevice(d, info) {
|
|
1253
1797
|
const head = `${d.serial} ${d.connection ?? ""} ${d.status}`;
|
|
1254
1798
|
if (!info) return head;
|
|
1255
|
-
const
|
|
1799
|
+
const os5 = [info.osFamily, info.osFamily === "HarmonyOS" ? info.osDistVersion : void 0].filter(Boolean).join(" ");
|
|
1256
1800
|
const fields = [
|
|
1257
|
-
["os",
|
|
1801
|
+
["os", os5 || void 0],
|
|
1258
1802
|
["api", info.sdkApiVersion],
|
|
1259
1803
|
["abi", info.abi],
|
|
1260
1804
|
["type", info.deviceType],
|
|
@@ -1264,34 +1808,40 @@ function formatLongDevice(d, info) {
|
|
|
1264
1808
|
const rest = fields.filter(([, v]) => v).map(([k, v]) => `${k}=${/\s/.test(v) ? JSON.stringify(v) : v}`);
|
|
1265
1809
|
return [head, ...rest].join(" ");
|
|
1266
1810
|
}
|
|
1811
|
+
async function queryDevices(long) {
|
|
1812
|
+
const { config, logger } = getRuntime();
|
|
1813
|
+
const devices2 = await listDevices(config);
|
|
1814
|
+
const infos = /* @__PURE__ */ new Map();
|
|
1815
|
+
if (long) {
|
|
1816
|
+
for (const d of devices2.filter((x) => x.status === "Connected")) {
|
|
1817
|
+
try {
|
|
1818
|
+
infos.set(d.serial, await getDeviceInfo({ config, deviceSerial: d.serial, logger }));
|
|
1819
|
+
} catch (err) {
|
|
1820
|
+
logger.warn(`Could not query ${d.serial}: ${err.message}`);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
return { devices: devices2, infos };
|
|
1825
|
+
}
|
|
1826
|
+
function devicesJson(devices2, infos, long) {
|
|
1827
|
+
return long ? devices2.map((d) => ({ ...d, info: infos.get(d.serial) ?? null })) : devices2;
|
|
1828
|
+
}
|
|
1267
1829
|
function registerDevicesCommand(program) {
|
|
1268
1830
|
program.command("devices").description("List connected hdc devices.").option(
|
|
1269
1831
|
"--long",
|
|
1270
1832
|
"Also ask each connected device for its OS family (HarmonyOS / OpenHarmony), version, API level, CPU ABI, device type and model."
|
|
1271
1833
|
).option("--json", "Emit machine-readable JSON.").action(async (opts) => {
|
|
1272
|
-
const {
|
|
1273
|
-
const devices = await listDevices(config);
|
|
1274
|
-
const infos = /* @__PURE__ */ new Map();
|
|
1275
|
-
if (opts.long) {
|
|
1276
|
-
for (const d of devices.filter((x) => x.status === "Connected")) {
|
|
1277
|
-
try {
|
|
1278
|
-
infos.set(d.serial, await getDeviceInfo({ config, deviceSerial: d.serial, logger }));
|
|
1279
|
-
} catch (err) {
|
|
1280
|
-
logger.warn(`Could not query ${d.serial}: ${err.message}`);
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1834
|
+
const { devices: devices2, infos } = await queryDevices(!!opts.long);
|
|
1284
1835
|
if (opts.json) {
|
|
1285
|
-
|
|
1286
|
-
process.stdout.write(`${JSON.stringify(out, null, 2)}
|
|
1836
|
+
process.stdout.write(`${JSON.stringify(devicesJson(devices2, infos, !!opts.long), null, 2)}
|
|
1287
1837
|
`);
|
|
1288
1838
|
return;
|
|
1289
1839
|
}
|
|
1290
|
-
if (
|
|
1840
|
+
if (devices2.length === 0) {
|
|
1291
1841
|
process.stdout.write("(no devices)\n");
|
|
1292
1842
|
return;
|
|
1293
1843
|
}
|
|
1294
|
-
for (const d of
|
|
1844
|
+
for (const d of devices2) {
|
|
1295
1845
|
process.stdout.write(`${opts.long ? formatLongDevice(d, infos.get(d.serial)) : `${d.serial} ${d.connection ?? ""} ${d.status}`}
|
|
1296
1846
|
`);
|
|
1297
1847
|
}
|
|
@@ -1319,7 +1869,8 @@ function registerFileCommand(program) {
|
|
|
1319
1869
|
}
|
|
1320
1870
|
|
|
1321
1871
|
// src/commands/lint.ts
|
|
1322
|
-
import
|
|
1872
|
+
import { InvalidArgumentError as InvalidArgumentError3 } from "commander";
|
|
1873
|
+
import * as path9 from "path";
|
|
1323
1874
|
import {
|
|
1324
1875
|
detectCmdToolsFlavour,
|
|
1325
1876
|
detectRuntimeOs as detectRuntimeOs2,
|
|
@@ -1327,6 +1878,76 @@ import {
|
|
|
1327
1878
|
missingProjectRootFiles,
|
|
1328
1879
|
runCodelinter
|
|
1329
1880
|
} from "@oniroproject/core";
|
|
1881
|
+
|
|
1882
|
+
// src/commands/check.ts
|
|
1883
|
+
import * as path8 from "path";
|
|
1884
|
+
import { checkArkts } from "@oniroproject/core";
|
|
1885
|
+
function arktsCheckExitCode(report) {
|
|
1886
|
+
if (!report.completed) return 2;
|
|
1887
|
+
return report.ok ? 0 : 1;
|
|
1888
|
+
}
|
|
1889
|
+
function arktsCheckSummaryLine(report) {
|
|
1890
|
+
const s = report.summary;
|
|
1891
|
+
const fixed = s.fixed ? `, ${s.fixed} fixed` : "";
|
|
1892
|
+
return `${s.errors} errors, ${s.warnings} warnings${fixed} in ${s.files} files (${(report.durationMs / 1e3).toFixed(1)} s).`;
|
|
1893
|
+
}
|
|
1894
|
+
async function arktsCheckOrError(projectDir, opts) {
|
|
1895
|
+
const { config, logger } = getRuntime();
|
|
1896
|
+
const dir = path8.resolve(projectDir ?? process.cwd());
|
|
1897
|
+
try {
|
|
1898
|
+
return await checkArkts({ config, projectDir: dir, files: opts.files, fix: opts.fix, sdkDir: opts.sdk, logger });
|
|
1899
|
+
} catch (err) {
|
|
1900
|
+
return { ok: false, completed: false, error: err instanceof Error ? err.message : String(err), diagnostics: [] };
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
async function runArktsCheckCommand(projectDir, opts) {
|
|
1904
|
+
const { logger } = getRuntime();
|
|
1905
|
+
const report = await arktsCheckOrError(projectDir, opts);
|
|
1906
|
+
if (!("summary" in report)) {
|
|
1907
|
+
if (opts.json) process.stdout.write(`${JSON.stringify(report, null, 2)}
|
|
1908
|
+
`);
|
|
1909
|
+
logger.error(report.error);
|
|
1910
|
+
return 2;
|
|
1911
|
+
}
|
|
1912
|
+
if (opts.json) {
|
|
1913
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}
|
|
1914
|
+
`);
|
|
1915
|
+
} else if (report.completed) {
|
|
1916
|
+
if (report.diagnostics.length) process.stdout.write(`${formatDiagnostics(report.diagnostics, Infinity)}
|
|
1917
|
+
`);
|
|
1918
|
+
for (const d of report.fixed) {
|
|
1919
|
+
process.stdout.write(` fixed ${d.file}:${d.line}:${d.column} ${d.message}
|
|
1920
|
+
`);
|
|
1921
|
+
}
|
|
1922
|
+
if (report.alsoModified.length) {
|
|
1923
|
+
process.stdout.write(`--fix also edited: ${report.alsoModified.join(", ")}
|
|
1924
|
+
`);
|
|
1925
|
+
}
|
|
1926
|
+
process.stdout.write(`${arktsCheckSummaryLine(report)}
|
|
1927
|
+
`);
|
|
1928
|
+
}
|
|
1929
|
+
if (!report.completed) logger.error(report.error ?? "The ArkTS checker did not complete, so nothing was checked.");
|
|
1930
|
+
return arktsCheckExitCode(report);
|
|
1931
|
+
}
|
|
1932
|
+
function registerCheckCommand(program) {
|
|
1933
|
+
const check = program.command("check").description("Static checks that need no build and no device.");
|
|
1934
|
+
check.command("arkts [project-dir]").description(
|
|
1935
|
+
"Type-check and lint ArkTS with the SDK's own ArkTS checker (what the build's CompileArkTS runs), plus the ArkUI component-model and project-config rules the build enforces later (V1/V2 decorators, @Entry root, route maps, resources, permissions), in seconds and without a build. Uses the SDK the project compiles with. Exits 0 when clean, 1 on an error, 2 when the checker could not run (then nothing is known)."
|
|
1936
|
+
).option(
|
|
1937
|
+
"--files <files...>",
|
|
1938
|
+
"Check only these .ets files (project-wide rules such as main_pages.json and module.json5 are then skipped)."
|
|
1939
|
+
).option(
|
|
1940
|
+
"--fix",
|
|
1941
|
+
"Apply the fixes the checker can verify (the compiler's `Did you mean` suggestions, import paths, a missing `export`, the @Component/@ComponentV2 decorator), re-check, and revert any fix that adds an error."
|
|
1942
|
+
).option("--sdk <dir>", "SDK directory to check against (holding ets/), instead of the one compileSdkVersion names.").option(
|
|
1943
|
+
"--json",
|
|
1944
|
+
"Print the report as JSON: ok, completed, diagnostics (severity, source, rule, message, file, line, column), fixed, alsoModified, summary, sdk, durationMs, error."
|
|
1945
|
+
).action(async (projectDir, opts) => {
|
|
1946
|
+
process.exitCode = await runArktsCheckCommand(projectDir, opts);
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// src/commands/lint.ts
|
|
1330
1951
|
function lintTotals(result) {
|
|
1331
1952
|
if (result.summary) return result.summary;
|
|
1332
1953
|
const count = (s) => result.findings.filter((f) => f.severity === s).length;
|
|
@@ -1337,19 +1958,34 @@ function lintTotals(result) {
|
|
|
1337
1958
|
suggestions: count("suggestion")
|
|
1338
1959
|
};
|
|
1339
1960
|
}
|
|
1340
|
-
function lintExitCode(result) {
|
|
1961
|
+
function lintExitCode(result, allowIncomplete = false) {
|
|
1341
1962
|
if (lintTotals(result).errors > 0) return 1;
|
|
1342
|
-
|
|
1963
|
+
if (lintFailed(result)) return result.code;
|
|
1964
|
+
return result.incomplete && !allowIncomplete ? 2 : 0;
|
|
1343
1965
|
}
|
|
1344
1966
|
function lintFailed(result) {
|
|
1345
1967
|
return result.summary === null && result.findings.length === 0 && result.code !== 0;
|
|
1346
1968
|
}
|
|
1347
1969
|
function registerLintCommand(program) {
|
|
1348
1970
|
program.command("lint [project-dir]").description(
|
|
1349
|
-
"Run the OpenHarmony codelinter on a project. Defaults to the current directory. Exits 1 on error-severity findings or when codelinter fails to run; warnings alone exit 0."
|
|
1350
|
-
).option("--files <globs...>", "Specific files/globs to lint (default: the whole project).").option("--json", "Emit the report (findings, summary, notices, incomplete) as JSON.").
|
|
1971
|
+
"Run the OpenHarmony codelinter on a project. Defaults to the current directory. Exits 1 on error-severity findings or when codelinter fails to run, 2 when codelinter reports that it skipped checks (incomplete), 0 otherwise; warnings alone exit 0. `--engine arkts` runs `check arkts` instead."
|
|
1972
|
+
).option("--files <globs...>", "Specific files/globs to lint (default: the whole project).").option("--json", "Emit the report (findings, summary, notices, incomplete) as JSON.").option(
|
|
1973
|
+
"--engine <engine>",
|
|
1974
|
+
"`codelinter`, or `arkts` for the ArkTS checker of `check arkts` (same options, report and exit codes).",
|
|
1975
|
+
"codelinter"
|
|
1976
|
+
).option(
|
|
1977
|
+
"--allow-incomplete",
|
|
1978
|
+
"Exit 0 rather than 2 when codelinter reports that it skipped checks (e.g. the tools flavour differs from runtimeOS)."
|
|
1979
|
+
).action(async (projectDir, opts) => {
|
|
1980
|
+
if (opts.engine === "arkts") {
|
|
1981
|
+
process.exitCode = await runArktsCheckCommand(projectDir, { files: opts.files, json: opts.json });
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
if (opts.engine !== "codelinter") {
|
|
1985
|
+
throw new InvalidArgumentError3(`Unknown --engine ${opts.engine}: use codelinter or arkts.`);
|
|
1986
|
+
}
|
|
1351
1987
|
const { config, logger } = getRuntime();
|
|
1352
|
-
const dir =
|
|
1988
|
+
const dir = path9.resolve(projectDir ?? process.cwd());
|
|
1353
1989
|
const result = await runCodelinter({ config, projectDir: dir, files: opts.files, logger });
|
|
1354
1990
|
const totals = lintTotals(result);
|
|
1355
1991
|
const failed = lintFailed(result);
|
|
@@ -1386,65 +2022,272 @@ function registerLintCommand(program) {
|
|
|
1386
2022
|
);
|
|
1387
2023
|
}
|
|
1388
2024
|
}
|
|
1389
|
-
|
|
2025
|
+
if (result.incomplete && !opts.allowIncomplete && lintTotals(result).errors === 0 && !failed) {
|
|
2026
|
+
logger.error(
|
|
2027
|
+
"codelinter reported an incomplete run, so a clean result is not evidence the code is clean: exiting 2. Pass --allow-incomplete to exit 0, or use `oniro-app check arkts`, which does not depend on codelinter."
|
|
2028
|
+
);
|
|
2029
|
+
}
|
|
2030
|
+
process.exitCode = lintExitCode(result, opts.allowIncomplete);
|
|
1390
2031
|
});
|
|
1391
2032
|
}
|
|
1392
2033
|
|
|
1393
|
-
// src/commands/
|
|
1394
|
-
import {
|
|
1395
|
-
import
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
"
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
"
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
const
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
2034
|
+
// src/commands/test.ts
|
|
2035
|
+
import { InvalidArgumentError as InvalidArgumentError4 } from "commander";
|
|
2036
|
+
import * as path10 from "path";
|
|
2037
|
+
import { runTests } from "@oniroproject/core";
|
|
2038
|
+
function positiveMs(raw) {
|
|
2039
|
+
const n = Number(raw);
|
|
2040
|
+
if (!Number.isFinite(n) || n <= 0) throw new InvalidArgumentError4("Expected a positive number of milliseconds.");
|
|
2041
|
+
return Math.floor(n);
|
|
2042
|
+
}
|
|
2043
|
+
function testExitCode(report) {
|
|
2044
|
+
return report.outcome === "passed" ? 0 : report.outcome === "failed" ? 1 : 2;
|
|
2045
|
+
}
|
|
2046
|
+
function projectFrame(stack) {
|
|
2047
|
+
for (const line of stack?.split("\n") ?? []) {
|
|
2048
|
+
if (/oh_modules|@ohos\/hypium/.test(line)) continue;
|
|
2049
|
+
const m = /\(([^()]+:\d+:\d+)\)\s*$/.exec(line.trim());
|
|
2050
|
+
if (m) return m[1];
|
|
2051
|
+
}
|
|
2052
|
+
return void 0;
|
|
2053
|
+
}
|
|
2054
|
+
function formatTestCase(t) {
|
|
2055
|
+
const label = { pass: "PASS", fail: "FAIL", error: "ERROR", ignore: "SKIP", unknown: `CODE ${t.statusCode}` }[t.status];
|
|
2056
|
+
const time = t.durationMs !== void 0 ? ` (${t.durationMs} ms)` : "";
|
|
2057
|
+
let line = `${label} ${t.suite} > ${t.name}${time}`;
|
|
2058
|
+
if (t.status !== "pass" && t.message) line += `
|
|
2059
|
+
${t.message}`;
|
|
2060
|
+
const at = t.status !== "pass" ? projectFrame(t.stack) : void 0;
|
|
2061
|
+
if (at) line += `
|
|
2062
|
+
at ${at}`;
|
|
2063
|
+
return line;
|
|
2064
|
+
}
|
|
2065
|
+
function registerTestCommand(program) {
|
|
2066
|
+
program.command("test [project-dir]").description(
|
|
2067
|
+
"Build a module's main and ohosTest HAPs, install both on the device, run the suite with `aa test` (Hypium, OpenHarmonyTestRunner) and report each case. Defaults to the current directory. Exits 0 when every case passed, 1 when a case failed or threw, 2 when the run did not complete (build failed, test module not found, timeout, no device). The test module is left installed."
|
|
2068
|
+
).option("--module <module>", "Module whose src/ohosTest suite runs.", "entry").option("--product <product>", "hvigor product name.", "default").option("--class <filter>", "Run only these cases: `Suite`, `Suite#case`, or several joined by commas.").option("--timeout <ms>", "Per-case timeout passed to the runner.", positiveMs, 15e3).option("--run-timeout <ms>", "Wall-clock limit for the whole run; aa test can hang when the app dies.", positiveMs, 6e5).option("--no-build", "Use the HAPs already built instead of building them.").option("--no-wake", "Leave the screen as it is (by default it is turned on, a lock screen without a credential is swiped away, and it is kept on during the run).").option("--mode <mode>", "hvigor build mode (e.g. debug).").option("--no-deps", "Skip the automatic `ohpm install --all` when oh_modules/ is missing.").option("--device <serial>", "Target device serial.").option(
|
|
2069
|
+
"--json",
|
|
2070
|
+
"Print the report as JSON: ok, outcome, tests (suite, name, status, statusCode, durationMs, message, stack), summary, resultCode, haps, log, error."
|
|
2071
|
+
).action(async (projectDir, opts) => {
|
|
1423
2072
|
const { config, logger } = getRuntime();
|
|
1424
|
-
const
|
|
2073
|
+
const dir = path10.resolve(projectDir ?? process.cwd());
|
|
2074
|
+
const report = await runTests({
|
|
1425
2075
|
config,
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
pxY2: num(opts.y2),
|
|
1437
|
-
speed: num(opts.speed),
|
|
1438
|
-
key: opts.key,
|
|
1439
|
-
text: opts.text,
|
|
1440
|
-
clear: opts.clear,
|
|
1441
|
-
hideKeyboard: opts.hideKeyboard,
|
|
2076
|
+
projectDir: dir,
|
|
2077
|
+
module: opts.module,
|
|
2078
|
+
product: opts.product,
|
|
2079
|
+
testClass: opts.class,
|
|
2080
|
+
caseTimeoutMs: opts.timeout,
|
|
2081
|
+
runTimeoutMs: opts.runTimeout,
|
|
2082
|
+
build: opts.build,
|
|
2083
|
+
wakeScreen: opts.wake,
|
|
2084
|
+
mode: opts.mode,
|
|
2085
|
+
autoInstallDeps: opts.deps,
|
|
1442
2086
|
deviceSerial: opts.device,
|
|
1443
2087
|
logger
|
|
1444
2088
|
});
|
|
1445
|
-
if (opts.
|
|
1446
|
-
|
|
1447
|
-
|
|
2089
|
+
if (opts.json) {
|
|
2090
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}
|
|
2091
|
+
`);
|
|
2092
|
+
} else {
|
|
2093
|
+
for (const t of report.tests) process.stdout.write(`${formatTestCase(t)}
|
|
2094
|
+
`);
|
|
2095
|
+
const s = report.summary;
|
|
2096
|
+
if (report.tests.length || report.outcome !== "runner-error") {
|
|
2097
|
+
process.stdout.write(
|
|
2098
|
+
`${s.run} run: ${s.pass} passed, ${s.fail} failed, ${s.error} errors, ${s.ignore} ignored.
|
|
2099
|
+
`
|
|
2100
|
+
);
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
for (const w of report.warnings) logger.warn(w);
|
|
2104
|
+
if (report.error) {
|
|
2105
|
+
const listed = report.diagnostics ? formatDiagnostics(report.diagnostics) : "";
|
|
2106
|
+
logger.error(`${report.error}${listed ? `
|
|
2107
|
+
${listed}` : ""}`);
|
|
2108
|
+
}
|
|
2109
|
+
process.exitCode = testExitCode(report);
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// src/commands/doctor.ts
|
|
2114
|
+
import * as fs5 from "fs";
|
|
2115
|
+
import * as path11 from "path";
|
|
2116
|
+
import { runDoctor } from "@oniroproject/core";
|
|
2117
|
+
function formatDoctorCheck(c) {
|
|
2118
|
+
const line = `${c.status.padEnd(5)} ${c.id.padEnd(16)} ${c.detail}${c.fixed ? " (fixed)" : ""}`;
|
|
2119
|
+
return c.fix && c.status !== "ok" && c.status !== "skip" ? `${line}
|
|
2120
|
+
${" ".repeat(23)}fix: ${c.fix}` : line;
|
|
2121
|
+
}
|
|
2122
|
+
function doctorExitCode(report) {
|
|
2123
|
+
return report.ok ? 0 : 1;
|
|
2124
|
+
}
|
|
2125
|
+
function registerDoctorCommand(program) {
|
|
2126
|
+
program.command("doctor [project-dir]").description(
|
|
2127
|
+
"Check that this machine can build, check, sign, install and test: node, java, the SDK the project compiles with, the command-line tools (complete, their version, their flavour against runtimeOS), a `check arkts` self-test, hdc and a reachable device at the project's API, signing material, free disk space. Each check is ok, warn, fail or skip, with a fix. Uses the project in the current directory when there is one. Exits 1 when any check fails."
|
|
2128
|
+
).option("--json", "Print the report as JSON: ok, checks (id, status, detail, fix, fixed), project, versions.").option("--fix", "Install the project's missing OpenHarmony SDK.").option("--no-device", "Skip the device check (for a machine that only builds).").option("--serial <serial>", "The device to check, when several are connected.").option("--no-arkts-check", "Skip the `check arkts` self-test.").action(async (projectDir, opts) => {
|
|
2129
|
+
const { config, logger, progress } = getRuntime();
|
|
2130
|
+
let dir;
|
|
2131
|
+
if (projectDir) {
|
|
2132
|
+
dir = path11.resolve(projectDir);
|
|
2133
|
+
} else if (fs5.existsSync(path11.join(process.cwd(), "build-profile.json5"))) {
|
|
2134
|
+
dir = process.cwd();
|
|
2135
|
+
}
|
|
2136
|
+
const report = await runDoctor({
|
|
2137
|
+
config,
|
|
2138
|
+
projectDir: dir,
|
|
2139
|
+
fix: opts.fix,
|
|
2140
|
+
device: opts.device,
|
|
2141
|
+
deviceSerial: opts.serial,
|
|
2142
|
+
arktsSelfTest: opts.arktsCheck,
|
|
2143
|
+
logger,
|
|
2144
|
+
progress
|
|
2145
|
+
});
|
|
2146
|
+
if (opts.json) {
|
|
2147
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}
|
|
2148
|
+
`);
|
|
2149
|
+
} else {
|
|
2150
|
+
if (report.project) process.stdout.write(`Project ${report.project.dir} (${report.project.runtimeOS})
|
|
2151
|
+
`);
|
|
2152
|
+
for (const c of report.checks) process.stdout.write(`${formatDoctorCheck(c)}
|
|
2153
|
+
`);
|
|
2154
|
+
const failed = report.checks.filter((c) => c.status === "fail").length;
|
|
2155
|
+
process.stdout.write(failed ? `${failed} check(s) failed.
|
|
2156
|
+
` : "No check failed.\n");
|
|
2157
|
+
}
|
|
2158
|
+
process.exitCode = doctorExitCode(report);
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
// src/commands/screens.ts
|
|
2163
|
+
import * as path12 from "path";
|
|
2164
|
+
import { inventoryScreens } from "@oniroproject/core";
|
|
2165
|
+
function formatTable(header, rows) {
|
|
2166
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
2167
|
+
const line = (cells) => cells.map((c, i) => i === cells.length - 1 ? c : c.padEnd(widths[i] ?? 0)).join(" ").trimEnd();
|
|
2168
|
+
return [line(header), ...rows.map(line)].join("\n");
|
|
2169
|
+
}
|
|
2170
|
+
function formatScreens(inv) {
|
|
2171
|
+
const out = [];
|
|
2172
|
+
const multiModule = inv.modules.length > 1;
|
|
2173
|
+
out.push(`Pages (${inv.pages.length})`);
|
|
2174
|
+
if (inv.pages.length) {
|
|
2175
|
+
out.push(
|
|
2176
|
+
formatTable(
|
|
2177
|
+
["NAME", ...multiModule ? ["MODULE"] : [], "KIND", "COMPONENT", "NODES", "ROUTES OUT", "FILE"],
|
|
2178
|
+
inv.pages.map((p) => [
|
|
2179
|
+
p.registered ? p.name : `${p.name} (unregistered)`,
|
|
2180
|
+
...multiModule ? [p.module] : [],
|
|
2181
|
+
p.kind,
|
|
2182
|
+
p.component ?? "-",
|
|
2183
|
+
String(p.contentNodes),
|
|
2184
|
+
p.routesOut.join(", ") || "-",
|
|
2185
|
+
`${p.file}:${p.line}`
|
|
2186
|
+
])
|
|
2187
|
+
)
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
2190
|
+
out.push("", `Routes (${inv.routes.length})`);
|
|
2191
|
+
if (inv.routes.length) {
|
|
2192
|
+
out.push(
|
|
2193
|
+
formatTable(
|
|
2194
|
+
["FROM", "TO", "VIA", "FILE"],
|
|
2195
|
+
inv.routes.map((r) => [r.from, r.dynamic ? `${r.to} (dynamic)` : r.to, r.via, `${r.file}:${r.line}`])
|
|
2196
|
+
)
|
|
2197
|
+
);
|
|
2198
|
+
}
|
|
2199
|
+
out.push("", `Placeholders (${inv.placeholders.length})`);
|
|
2200
|
+
if (inv.placeholders.length) {
|
|
2201
|
+
out.push(formatTable(["FILE", "KIND", "TEXT"], inv.placeholders.map((p) => [`${p.file}:${p.line}`, p.kind, p.text])));
|
|
2202
|
+
}
|
|
2203
|
+
if (inv.warnings.length) {
|
|
2204
|
+
out.push("", `Warnings (${inv.warnings.length})`);
|
|
2205
|
+
for (const w of inv.warnings) out.push(w);
|
|
2206
|
+
}
|
|
2207
|
+
return out.join("\n");
|
|
2208
|
+
}
|
|
2209
|
+
function registerScreensCommand(program) {
|
|
2210
|
+
program.command("screens [project-dir]").description(
|
|
2211
|
+
"Static inventory of the pages and routes of a project, from its sources, without a build or a device: @Entry pages (main_pages.json, @Entry({ routeName })), NavDestination pages (route maps and .navDestination() builders that dispatch on the route name), the router.pushUrl/replaceUrl/pushNamedRoute and NavPathStack.pushPath*/replacePath*/pushDestination* call sites, the content nodes each page renders (component calls through its custom components and @Builders, layout containers excluded), and placeholder markers (TODO/FIXME, placeholder-like text, empty build()). Reads every module in build-profile.json5. Exits 0 (it is an inventory, not a gate) unless the project cannot be read."
|
|
2212
|
+
).option(
|
|
2213
|
+
"--json",
|
|
2214
|
+
"Print the inventory as JSON: pages (name, module, file, line, component, entry, kind, registered, contentNodes, routesOut), routes (from, to, file, line, via, dynamic), placeholders (file, line, text, kind), warnings, modules, projectDir."
|
|
2215
|
+
).action((projectDir, opts) => {
|
|
2216
|
+
const { logger } = getRuntime();
|
|
2217
|
+
const dir = path12.resolve(projectDir ?? process.cwd());
|
|
2218
|
+
let inv;
|
|
2219
|
+
try {
|
|
2220
|
+
inv = inventoryScreens({ projectDir: dir });
|
|
2221
|
+
} catch (err) {
|
|
2222
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
2223
|
+
if (opts.json) process.stdout.write(`${JSON.stringify({ error }, null, 2)}
|
|
2224
|
+
`);
|
|
2225
|
+
logger.error(error);
|
|
2226
|
+
process.exitCode = 1;
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
process.stdout.write(opts.json ? `${JSON.stringify(inv, null, 2)}
|
|
2230
|
+
` : `${formatScreens(inv)}
|
|
2231
|
+
`);
|
|
2232
|
+
});
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
// src/commands/input.ts
|
|
2236
|
+
import { Option as Option3 } from "commander";
|
|
2237
|
+
import {
|
|
2238
|
+
performInput,
|
|
2239
|
+
sendGesture,
|
|
2240
|
+
getDisplaySize,
|
|
2241
|
+
describeLayoutNode as describeLayoutNode3
|
|
2242
|
+
} from "@oniroproject/core";
|
|
2243
|
+
var INPUT_TYPES = [
|
|
2244
|
+
"click",
|
|
2245
|
+
"doubleClick",
|
|
2246
|
+
"longClick",
|
|
2247
|
+
"swipe",
|
|
2248
|
+
"drag",
|
|
2249
|
+
"fling",
|
|
2250
|
+
"keyEvent",
|
|
2251
|
+
"inputText",
|
|
2252
|
+
"clearText",
|
|
2253
|
+
"hideKeyboard"
|
|
2254
|
+
];
|
|
2255
|
+
var num = (v) => v === void 0 ? void 0 : Number(v);
|
|
2256
|
+
function registerInputCommand(program) {
|
|
2257
|
+
const input2 = program.command("input").description(
|
|
2258
|
+
"Inject a UI input event via uitest uiInput. Position it with --target or --selector (an on-screen element), --fx/--fy (0-1 fractions of the display, as in `dump layout` and the screenshot grid), or --x/--y (pixels)."
|
|
2259
|
+
).addOption(new Option3("--type <type>", "Input type.").choices(INPUT_TYPES).makeOptionMandatory());
|
|
2260
|
+
addTargetOptions(input2, "Act on");
|
|
2261
|
+
input2.option("--fx <fraction>", "Start X as a 0-1 fraction of the display width.").option("--fy <fraction>", "Start Y as a 0-1 fraction of the display height.").option("--fx2 <fraction>", "End X fraction (swipe/drag/fling).").option("--fy2 <fraction>", "End Y fraction (swipe/drag/fling).").option("--x <px>", "X pixel (click/swipe start).").option("--y <px>", "Y pixel (click/swipe start).").option("--x2 <px>", "End X pixel (swipe/drag/fling).").option("--y2 <px>", "End Y pixel (swipe/drag/fling).").option("--speed <px-per-s>", "Velocity for swipe/drag/fling.").option(
|
|
2262
|
+
"--key <key>",
|
|
2263
|
+
"Key for keyEvent: Back/Home/Power, a name (Delete, Enter, Tab, Escape, MoveHome, MoveEnd, \u2026), a letter, a KeyCode id, or a combo like Ctrl+A."
|
|
2264
|
+
).option("--text <text>", "Text for inputText.").option("--clear", "inputText: clear the field (Ctrl+A, Delete) before typing.").option("--hide-keyboard", "Dismiss the soft keyboard afterwards, if it is up.").option("--device <serial>", "Target device serial.").action(async (opts) => {
|
|
2265
|
+
const { config, logger } = getRuntime();
|
|
2266
|
+
const result = await performInput({
|
|
2267
|
+
config,
|
|
2268
|
+
type: opts.type,
|
|
2269
|
+
target: selectorFrom(opts),
|
|
2270
|
+
targetIndex: targetIndexFrom(opts),
|
|
2271
|
+
windowId: windowIdFrom(opts),
|
|
2272
|
+
fx: num(opts.fx),
|
|
2273
|
+
fy: num(opts.fy),
|
|
2274
|
+
fx2: num(opts.fx2),
|
|
2275
|
+
fy2: num(opts.fy2),
|
|
2276
|
+
pxX: num(opts.x),
|
|
2277
|
+
pxY: num(opts.y),
|
|
2278
|
+
pxX2: num(opts.x2),
|
|
2279
|
+
pxY2: num(opts.y2),
|
|
2280
|
+
speed: num(opts.speed),
|
|
2281
|
+
key: opts.key,
|
|
2282
|
+
text: opts.text,
|
|
2283
|
+
clear: opts.clear,
|
|
2284
|
+
hideKeyboard: opts.hideKeyboard,
|
|
2285
|
+
deviceSerial: opts.device,
|
|
2286
|
+
logger
|
|
2287
|
+
});
|
|
2288
|
+
if (opts.type === "hideKeyboard") {
|
|
2289
|
+
logger.info(result.keyboardHidden ? "Dismissed the keyboard." : "The keyboard was not shown.");
|
|
2290
|
+
return;
|
|
1448
2291
|
}
|
|
1449
2292
|
const at = result.x !== void 0 ? ` at ${result.x},${result.y}` : "";
|
|
1450
2293
|
const on = result.target ? ` on ${describeLayoutNode3(result.target)}` : "";
|
|
@@ -1475,13 +2318,14 @@ function registerInputCommand(program) {
|
|
|
1475
2318
|
}
|
|
1476
2319
|
|
|
1477
2320
|
// src/commands/logs.ts
|
|
1478
|
-
import * as
|
|
1479
|
-
import * as
|
|
1480
|
-
import
|
|
2321
|
+
import * as fs8 from "fs/promises";
|
|
2322
|
+
import * as os3 from "os";
|
|
2323
|
+
import * as path15 from "path";
|
|
2324
|
+
import { analyzeFaultLog, captureLog, latestCrash as latestCrash2 } from "@oniroproject/core";
|
|
1481
2325
|
|
|
1482
2326
|
// src/lib/logBundle.ts
|
|
1483
|
-
import * as
|
|
1484
|
-
import * as
|
|
2327
|
+
import * as fs6 from "fs/promises";
|
|
2328
|
+
import * as path13 from "path";
|
|
1485
2329
|
import { deflateRawSync } from "zlib";
|
|
1486
2330
|
import {
|
|
1487
2331
|
VERSION,
|
|
@@ -1514,7 +2358,7 @@ function validateDate(date, time) {
|
|
|
1514
2358
|
const value = new Date(Date.UTC(2e3, Number(monthText) - 1, Number(dayText), Number(hourText), Number(minuteText), Number(secondText), Number(millisecondText)));
|
|
1515
2359
|
return value.getUTCMonth() === Number(monthText) - 1 && value.getUTCDate() === Number(dayText) && Number(hourText) < 24 && Number(minuteText) < 60 && Number(secondText) < 60;
|
|
1516
2360
|
}
|
|
1517
|
-
function filterHilog(
|
|
2361
|
+
function filterHilog(input2, filters) {
|
|
1518
2362
|
const criteria = {};
|
|
1519
2363
|
if (filters.pid) criteria.pid = filters.pid.replace(/^0+(?=\d)/, "");
|
|
1520
2364
|
if (filters.tag) criteria.tag = filters.tag;
|
|
@@ -1523,7 +2367,7 @@ function filterHilog(input, filters) {
|
|
|
1523
2367
|
let sawRecord = false;
|
|
1524
2368
|
let keep = false;
|
|
1525
2369
|
let output = "";
|
|
1526
|
-
for (const line of
|
|
2370
|
+
for (const line of input2.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
|
|
1527
2371
|
const content = line.replace(/[\r\n]+$/, "");
|
|
1528
2372
|
const match = HILOG_RECORD.exec(content);
|
|
1529
2373
|
if (match?.groups) {
|
|
@@ -1576,34 +2420,34 @@ function decodeText(data, relativePath) {
|
|
|
1576
2420
|
return text;
|
|
1577
2421
|
}
|
|
1578
2422
|
async function redactTree(source, destination, serials, boundedHilog) {
|
|
1579
|
-
await
|
|
2423
|
+
await fs6.mkdir(destination, { recursive: false, mode: 448 });
|
|
1580
2424
|
try {
|
|
1581
2425
|
const visit = async (directory) => {
|
|
1582
|
-
for (const entry of await
|
|
1583
|
-
const sourcePath =
|
|
1584
|
-
const relative2 =
|
|
1585
|
-
const portableRelative = relative2.split(
|
|
1586
|
-
const destinationPath =
|
|
2426
|
+
for (const entry of await fs6.readdir(directory, { withFileTypes: true })) {
|
|
2427
|
+
const sourcePath = path13.join(directory, entry.name);
|
|
2428
|
+
const relative2 = path13.relative(source, sourcePath);
|
|
2429
|
+
const portableRelative = relative2.split(path13.sep).join("/");
|
|
2430
|
+
const destinationPath = path13.join(destination, relative2);
|
|
1587
2431
|
if (entry.isDirectory()) {
|
|
1588
|
-
await
|
|
2432
|
+
await fs6.mkdir(destinationPath, { recursive: true, mode: 448 });
|
|
1589
2433
|
await visit(sourcePath);
|
|
1590
2434
|
} else if (entry.isFile()) {
|
|
1591
2435
|
if (portableRelative === "logs/hilog.txt") {
|
|
1592
2436
|
const text2 = boundedHilog ?? "HiLog omitted from this archive because the requested duration could not be reliably applied.\n";
|
|
1593
|
-
await
|
|
1594
|
-
await
|
|
2437
|
+
await fs6.mkdir(path13.dirname(destinationPath), { recursive: true, mode: 448 });
|
|
2438
|
+
await fs6.writeFile(destinationPath, redactLogText(text2, serials), { encoding: "utf8", mode: 384 });
|
|
1595
2439
|
continue;
|
|
1596
2440
|
}
|
|
1597
2441
|
if (portableRelative === "logs/hilog-filtered.txt" && boundedHilog === null) continue;
|
|
1598
|
-
const text = decodeText(await
|
|
1599
|
-
await
|
|
1600
|
-
await
|
|
2442
|
+
const text = decodeText(await fs6.readFile(sourcePath), relative2);
|
|
2443
|
+
await fs6.mkdir(path13.dirname(destinationPath), { recursive: true, mode: 448 });
|
|
2444
|
+
await fs6.writeFile(destinationPath, redactLogText(text, serials), { encoding: "utf8", mode: 384 });
|
|
1601
2445
|
}
|
|
1602
2446
|
}
|
|
1603
2447
|
};
|
|
1604
2448
|
await visit(source);
|
|
1605
2449
|
} catch (error) {
|
|
1606
|
-
await
|
|
2450
|
+
await fs6.rm(destination, { recursive: true, force: true });
|
|
1607
2451
|
throw error;
|
|
1608
2452
|
}
|
|
1609
2453
|
}
|
|
@@ -1628,8 +2472,8 @@ function dosDateTime(date) {
|
|
|
1628
2472
|
async function writeZip(source, archivePath) {
|
|
1629
2473
|
const files = [];
|
|
1630
2474
|
const visit = async (directory) => {
|
|
1631
|
-
for (const entry of await
|
|
1632
|
-
const filePath =
|
|
2475
|
+
for (const entry of await fs6.readdir(directory, { withFileTypes: true })) {
|
|
2476
|
+
const filePath = path13.join(directory, entry.name);
|
|
1633
2477
|
if (entry.isDirectory()) await visit(filePath);
|
|
1634
2478
|
else if (entry.isFile()) files.push(filePath);
|
|
1635
2479
|
}
|
|
@@ -1640,8 +2484,8 @@ async function writeZip(source, archivePath) {
|
|
|
1640
2484
|
let offset = 0;
|
|
1641
2485
|
const stamp = dosDateTime(/* @__PURE__ */ new Date());
|
|
1642
2486
|
for (const filePath of files) {
|
|
1643
|
-
const name = Buffer.from(
|
|
1644
|
-
const data = await
|
|
2487
|
+
const name = Buffer.from(path13.relative(source, filePath).split(path13.sep).join("/"), "utf8");
|
|
2488
|
+
const data = await fs6.readFile(filePath);
|
|
1645
2489
|
const compressed = deflateRawSync(data);
|
|
1646
2490
|
const checksum = crc32(data);
|
|
1647
2491
|
const local = Buffer.alloc(30);
|
|
@@ -1688,7 +2532,7 @@ async function writeZip(source, archivePath) {
|
|
|
1688
2532
|
end.writeUInt32LE(centralDirectory.length, 12);
|
|
1689
2533
|
end.writeUInt32LE(offset, 16);
|
|
1690
2534
|
end.writeUInt16LE(0, 20);
|
|
1691
|
-
await
|
|
2535
|
+
await fs6.writeFile(archivePath, Buffer.concat([...localParts, centralDirectory, end]), { flag: "wx", mode: 384 });
|
|
1692
2536
|
}
|
|
1693
2537
|
function commandFailure(result) {
|
|
1694
2538
|
if (result.code !== 0) return `hdc exited with code ${result.code}`;
|
|
@@ -1700,12 +2544,12 @@ function errorMessage(error) {
|
|
|
1700
2544
|
return error instanceof Error ? error.message : String(error);
|
|
1701
2545
|
}
|
|
1702
2546
|
async function uniqueBundleDirectory(outputDirectory, stamp) {
|
|
1703
|
-
await
|
|
2547
|
+
await fs6.mkdir(outputDirectory, { recursive: true, mode: 448 });
|
|
1704
2548
|
for (let suffix = 0; ; suffix++) {
|
|
1705
2549
|
const name = `incident-${stamp}${suffix ? `-${suffix}` : ""}`;
|
|
1706
|
-
const directory =
|
|
2550
|
+
const directory = path13.join(outputDirectory, name);
|
|
1707
2551
|
try {
|
|
1708
|
-
await
|
|
2552
|
+
await fs6.mkdir(directory, { mode: 448 });
|
|
1709
2553
|
return directory;
|
|
1710
2554
|
} catch (error) {
|
|
1711
2555
|
if (error.code !== "EEXIST") throw error;
|
|
@@ -1725,7 +2569,7 @@ async function collectLogBundle(options) {
|
|
|
1725
2569
|
}
|
|
1726
2570
|
const started = /* @__PURE__ */ new Date();
|
|
1727
2571
|
const stamp = started.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
1728
|
-
const bundleDirectory = await uniqueBundleDirectory(
|
|
2572
|
+
const bundleDirectory = await uniqueBundleDirectory(path13.resolve(options.outputDirectory), stamp);
|
|
1729
2573
|
const records = [];
|
|
1730
2574
|
const serials = [];
|
|
1731
2575
|
let replacementWarning = false;
|
|
@@ -1766,14 +2610,14 @@ async function collectLogBundle(options) {
|
|
|
1766
2610
|
]
|
|
1767
2611
|
};
|
|
1768
2612
|
const run = async (name, relativeOutput, args, deviceSerial) => {
|
|
1769
|
-
const outputPath =
|
|
1770
|
-
await
|
|
2613
|
+
const outputPath = path13.join(bundleDirectory, relativeOutput);
|
|
2614
|
+
await fs6.mkdir(path13.dirname(outputPath), { recursive: true, mode: 448 });
|
|
1771
2615
|
let result = null;
|
|
1772
2616
|
let failure = null;
|
|
1773
2617
|
try {
|
|
1774
2618
|
result = await execute(args, deviceSerial);
|
|
1775
|
-
await
|
|
1776
|
-
if (result.stderr) await
|
|
2619
|
+
await fs6.writeFile(outputPath, result.stdout, { encoding: "utf8", mode: 384 });
|
|
2620
|
+
if (result.stderr) await fs6.writeFile(`${outputPath}.stderr.txt`, result.stderr, { encoding: "utf8", mode: 384 });
|
|
1777
2621
|
if (result.stdout.includes("\uFFFD") || result.stderr.includes("\uFFFD")) {
|
|
1778
2622
|
replacementWarning = true;
|
|
1779
2623
|
manifest.notes.push(`${name} output contains Unicode replacement characters; review the redacted evidence before sharing.`);
|
|
@@ -1781,7 +2625,7 @@ async function collectLogBundle(options) {
|
|
|
1781
2625
|
failure = commandFailure(result);
|
|
1782
2626
|
} catch (error) {
|
|
1783
2627
|
failure = errorMessage(error);
|
|
1784
|
-
await
|
|
2628
|
+
await fs6.writeFile(outputPath, "", { encoding: "utf8", mode: 384 });
|
|
1785
2629
|
}
|
|
1786
2630
|
const record = {
|
|
1787
2631
|
name,
|
|
@@ -1795,7 +2639,7 @@ async function collectLogBundle(options) {
|
|
|
1795
2639
|
return record;
|
|
1796
2640
|
};
|
|
1797
2641
|
const targetRecord = await run("list-targets", "device/targets.txt", ["list", "targets", "-v"]);
|
|
1798
|
-
const targetOutput = await
|
|
2642
|
+
const targetOutput = await fs6.readFile(path13.join(bundleDirectory, "device/targets.txt"), "utf8").catch(() => "");
|
|
1799
2643
|
const targets = parseDeviceList(targetOutput).filter((target) => target.status === "Connected" || target.status === "Unknown");
|
|
1800
2644
|
serials.push(...parseDeviceList(targetOutput).map((target) => target.serial));
|
|
1801
2645
|
const requestedDevice = resolveDeviceSerial(options.deviceSerial);
|
|
@@ -1837,9 +2681,9 @@ async function collectLogBundle(options) {
|
|
|
1837
2681
|
const hostElapsedMs = Date.now() - captureStartedHostMs;
|
|
1838
2682
|
if (deviceTimeBefore.status === "success" && hilog.status === "success" && deviceTimeAfter.status === "success") {
|
|
1839
2683
|
const [beforeText, afterText, fullHilog] = await Promise.all([
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
2684
|
+
fs6.readFile(path13.join(bundleDirectory, "device/device-time-before.txt"), "utf8"),
|
|
2685
|
+
fs6.readFile(path13.join(bundleDirectory, "device/device-time-after.txt"), "utf8"),
|
|
2686
|
+
fs6.readFile(path13.join(bundleDirectory, "logs/hilog.txt"), "utf8")
|
|
1843
2687
|
]);
|
|
1844
2688
|
const window = filterHilogWindow(fullHilog, beforeText, afterText, durationSeconds, hostElapsedMs);
|
|
1845
2689
|
if (window.text === null) durationFailureReason = window.reason;
|
|
@@ -1867,7 +2711,7 @@ async function collectLogBundle(options) {
|
|
|
1867
2711
|
} else if (boundedHilog !== null && (options.pid || options.tag || options.level)) {
|
|
1868
2712
|
const filtered = filterHilog(boundedHilog, { pid: options.pid, tag: options.tag, level: options.level });
|
|
1869
2713
|
if (filtered) {
|
|
1870
|
-
await
|
|
2714
|
+
await fs6.writeFile(path13.join(bundleDirectory, "logs/hilog-filtered.txt"), filtered.text, { encoding: "utf8", mode: 384 });
|
|
1871
2715
|
const status = manifest.filterStatus;
|
|
1872
2716
|
for (const key of filtered.applied) status[key] = "applied";
|
|
1873
2717
|
} else {
|
|
@@ -1883,11 +2727,11 @@ async function collectLogBundle(options) {
|
|
|
1883
2727
|
manifest.collectionEndUtc = ended.toISOString();
|
|
1884
2728
|
const files = records.flatMap((record) => [record.output, record.stderr].filter((file) => Boolean(file)));
|
|
1885
2729
|
files.push("manifest.json", "notes.txt");
|
|
1886
|
-
if (await
|
|
2730
|
+
if (await fs6.access(path13.join(bundleDirectory, "logs/hilog-filtered.txt")).then(() => true, () => false)) {
|
|
1887
2731
|
files.push("logs/hilog-filtered.txt");
|
|
1888
2732
|
}
|
|
1889
2733
|
manifest.files = files;
|
|
1890
|
-
await
|
|
2734
|
+
await fs6.writeFile(path13.join(bundleDirectory, "notes.txt"), "Add reproduction steps and precise event timestamps here.\n", { encoding: "utf8", mode: 384 });
|
|
1891
2735
|
const durationFailure = !durationApplied;
|
|
1892
2736
|
const criticalFailure = Boolean(targetError) || records.some((record) => record.name === "hilog-snapshot" && record.status !== "success");
|
|
1893
2737
|
const anyCommandFailure = records.some((record) => record.status !== "success");
|
|
@@ -1898,16 +2742,16 @@ async function collectLogBundle(options) {
|
|
|
1898
2742
|
if (options.redact) {
|
|
1899
2743
|
redactedDirectory = `${bundleDirectory}-redacted`;
|
|
1900
2744
|
manifest.redactionStatus = replacementWarning ? "review-required" : "success";
|
|
1901
|
-
manifest.archiveStatus = { status: "success", path: `${
|
|
1902
|
-
await
|
|
2745
|
+
manifest.archiveStatus = { status: "success", path: `${path13.basename(redactedDirectory)}.zip` };
|
|
2746
|
+
await fs6.writeFile(path13.join(bundleDirectory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
1903
2747
|
`, { encoding: "utf8", mode: 384 });
|
|
1904
2748
|
await redactTree(bundleDirectory, redactedDirectory, serials, boundedHilog);
|
|
1905
2749
|
archivePath = `${redactedDirectory}.zip`;
|
|
1906
2750
|
await writeZip(redactedDirectory, archivePath);
|
|
1907
2751
|
} else {
|
|
1908
2752
|
archivePath = `${bundleDirectory}.zip`;
|
|
1909
|
-
manifest.archiveStatus = { status: "success", path:
|
|
1910
|
-
await
|
|
2753
|
+
manifest.archiveStatus = { status: "success", path: path13.basename(archivePath) };
|
|
2754
|
+
await fs6.writeFile(path13.join(bundleDirectory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
1911
2755
|
`, { encoding: "utf8", mode: 384 });
|
|
1912
2756
|
await writeZip(bundleDirectory, archivePath);
|
|
1913
2757
|
}
|
|
@@ -1917,9 +2761,9 @@ async function collectLogBundle(options) {
|
|
|
1917
2761
|
manifest.redactionStatus = options.redact ? "failed" : "not-requested";
|
|
1918
2762
|
manifest.archiveStatus = { status: "failed", path: null };
|
|
1919
2763
|
manifest.notes.push(bundleError);
|
|
1920
|
-
await
|
|
1921
|
-
if (redactedDirectory) await
|
|
1922
|
-
await
|
|
2764
|
+
await fs6.rm(`${redactedDirectory ?? bundleDirectory}.zip`, { force: true }).catch(() => void 0);
|
|
2765
|
+
if (redactedDirectory) await fs6.rm(redactedDirectory, { recursive: true, force: true }).catch(() => void 0);
|
|
2766
|
+
await fs6.writeFile(path13.join(bundleDirectory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
1923
2767
|
`, { encoding: "utf8", mode: 384 });
|
|
1924
2768
|
redactedDirectory = null;
|
|
1925
2769
|
}
|
|
@@ -1951,7 +2795,7 @@ function parseHilogTimestamp(datePart, timePart, referenceMs) {
|
|
|
1951
2795
|
const closest = candidates[0];
|
|
1952
2796
|
return closest !== void 0 && closest <= referenceMs + 2e3 ? closest : null;
|
|
1953
2797
|
}
|
|
1954
|
-
function filterHilogWindow(
|
|
2798
|
+
function filterHilogWindow(input2, deviceClockBefore, deviceClockAfter, durationSeconds, hostElapsedMs) {
|
|
1955
2799
|
if (!Number.isSafeInteger(durationSeconds) || durationSeconds < 1 || durationSeconds > 31536e3) {
|
|
1956
2800
|
return { text: null, reason: "requested duration is invalid" };
|
|
1957
2801
|
}
|
|
@@ -1967,7 +2811,7 @@ function filterHilogWindow(input, deviceClockBefore, deviceClockAfter, durationS
|
|
|
1967
2811
|
let sawRecord = false;
|
|
1968
2812
|
let keep = false;
|
|
1969
2813
|
let output = "";
|
|
1970
|
-
for (const line of
|
|
2814
|
+
for (const line of input2.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
|
|
1971
2815
|
const content = line.replace(/[\r\n]+$/, "");
|
|
1972
2816
|
const match = HILOG_RECORD.exec(content);
|
|
1973
2817
|
if (match?.groups) {
|
|
@@ -1992,6 +2836,53 @@ function filterHilogWindow(input, deviceClockBefore, deviceClockAfter, durationS
|
|
|
1992
2836
|
return sawRecord ? { text: output } : { text: null, reason: "no recognized timestamped HiLog records were found" };
|
|
1993
2837
|
}
|
|
1994
2838
|
|
|
2839
|
+
// src/lib/crashReport.ts
|
|
2840
|
+
import * as fs7 from "fs";
|
|
2841
|
+
import * as path14 from "path";
|
|
2842
|
+
import { getBundleName as getBundleName2 } from "@oniroproject/core";
|
|
2843
|
+
function parseCrashSince(value) {
|
|
2844
|
+
if (value === void 0) return void 0;
|
|
2845
|
+
const trimmed = value.trim();
|
|
2846
|
+
if (/^\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
2847
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) {
|
|
2848
|
+
const date = new Date(trimmed);
|
|
2849
|
+
if (!Number.isNaN(date.getTime())) return date;
|
|
2850
|
+
}
|
|
2851
|
+
throw new Error(`--since takes a number of seconds or an ISO 8601 time (2026-11-05T12:30:00+01:00), not "${value}".`);
|
|
2852
|
+
}
|
|
2853
|
+
function projectBundle(dir) {
|
|
2854
|
+
if (!fs7.existsSync(path14.join(dir, "AppScope", "app.json5"))) return void 0;
|
|
2855
|
+
try {
|
|
2856
|
+
return getBundleName2(dir);
|
|
2857
|
+
} catch {
|
|
2858
|
+
return void 0;
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
function formatCrashVerdict(v) {
|
|
2862
|
+
const lines = [];
|
|
2863
|
+
const kind = v.subtype ? `${v.kind} (${v.subtype})` : v.kind;
|
|
2864
|
+
const who = [v.bundle ?? v.process, v.pid !== null ? `pid ${v.pid}` : null].filter(Boolean).join(" ");
|
|
2865
|
+
lines.push(`${v.crashed ? "CRASH" : "NO CRASH"} ${kind}, ${v.confidence} confidence${who ? `: ${who}` : ""}${v.time ? ` at ${v.time}` : ""}`);
|
|
2866
|
+
if (v.jsError) lines.push(`${v.jsError.name}: ${v.jsError.message}`);
|
|
2867
|
+
else if (v.reason) lines.push(`Reason: ${v.reason}`);
|
|
2868
|
+
if (v.lastFatalMessage) lines.push(`Last fatal message: ${v.lastFatalMessage}`);
|
|
2869
|
+
if (v.file) lines.push(`At ${v.file}:${v.line}:${v.column}`);
|
|
2870
|
+
if (v.topFrame) lines.push(`Top frame: ${v.topFrame}`);
|
|
2871
|
+
if (v.projectFrame && v.projectFrame !== v.topFrame) lines.push(`Project frame: ${v.projectFrame}`);
|
|
2872
|
+
const shown = v.stack.slice(0, 12);
|
|
2873
|
+
if (shown.length > 0) lines.push("Stack:", ...shown.map((f) => ` ${f}`));
|
|
2874
|
+
if (v.stack.length > shown.length) lines.push(` \u2026 ${v.stack.length - shown.length} more`);
|
|
2875
|
+
for (const edge of v.binderChain) {
|
|
2876
|
+
lines.push(`Binder: ${edge.from} -> ${edge.to} waits ${edge.waitSeconds.toFixed(2)} s${edge.frzState ? ` frz_state:${edge.frzState}` : ""}`);
|
|
2877
|
+
}
|
|
2878
|
+
for (const cycle of v.deadlocks) lines.push(`Binder deadlock: ${cycle}`);
|
|
2879
|
+
for (const note of v.notes) lines.push(`Note: ${note}`);
|
|
2880
|
+
if (v.path) lines.push(`Faultlog: ${v.path}`);
|
|
2881
|
+
if (v.rawPath && v.rawPath !== v.path) lines.push(`Saved to: ${v.rawPath}`);
|
|
2882
|
+
return `${lines.join("\n")}
|
|
2883
|
+
`;
|
|
2884
|
+
}
|
|
2885
|
+
|
|
1995
2886
|
// src/commands/logs.ts
|
|
1996
2887
|
function registerLogsCommand(program) {
|
|
1997
2888
|
const logs = program.command("logs").description(
|
|
@@ -1999,7 +2890,7 @@ function registerLogsCommand(program) {
|
|
|
1999
2890
|
).option("-o, --output <file>", "File to append the lines to (required).").option("--for <ms>", "Stop after this many ms (default: run until interrupted).").option("--grep <regex>", "Keep only lines matching this regex (default: all).").option("--clear", "Clear the device log buffer first (hilog -r), so the file covers only this session.").option("--domain <domain>", "hilog domain filter (e.g. 0xD003900).").option("--device <serial>", "Target device serial.").action(async (opts) => {
|
|
2000
2891
|
if (!opts.output) logs.error("error: required option '-o, --output <file>' not specified");
|
|
2001
2892
|
const { config, logger } = getRuntime();
|
|
2002
|
-
const file =
|
|
2893
|
+
const file = path15.resolve(opts.output);
|
|
2003
2894
|
const abort = new AbortController();
|
|
2004
2895
|
const stop = () => abort.abort();
|
|
2005
2896
|
process.once("SIGINT", stop);
|
|
@@ -2020,7 +2911,7 @@ function registerLogsCommand(program) {
|
|
|
2020
2911
|
process.stdout.write(`${result.file}
|
|
2021
2912
|
`);
|
|
2022
2913
|
});
|
|
2023
|
-
logs.command("collect").description("Create a diagnostic bundle from a connected device.").allowExcessArguments(false).option("-o, --output <directory>", "Parent directory for incident bundles.",
|
|
2914
|
+
logs.command("collect").description("Create a diagnostic bundle from a connected device.").allowExcessArguments(false).option("-o, --output <directory>", "Parent directory for incident bundles.", path15.join(os3.homedir(), ".oniro", "incident-bundles")).option("--device <serial>", "Target device serial.").option("--package <bundle>", "Collect faultlog entries for this application bundle.").option("--pid <pid>", "Create a filtered HiLog view for this process.").option("--tag <tag>", "Create a filtered HiLog view for this tag.").option("--level <level>", "Create a filtered HiLog view for this level (V/D/I/W/E/F).").option("--duration <seconds>", "Keep HiLog records from the last N seconds (default 600).", "600").option("--redact", "Redact sensitive values and archive only the redacted bundle.").option("--json", "Emit bundle paths and status as JSON.").action(async (opts) => {
|
|
2024
2915
|
const { config } = getRuntime();
|
|
2025
2916
|
const result = await collectLogBundle({
|
|
2026
2917
|
config,
|
|
@@ -2041,6 +2932,34 @@ function registerLogsCommand(program) {
|
|
|
2041
2932
|
throw new Error(`Bundle is partial; inspect manifest.json. ${result.error ?? "One or more collectors failed."}`);
|
|
2042
2933
|
}
|
|
2043
2934
|
});
|
|
2935
|
+
logs.command("crash").description(
|
|
2936
|
+
"Fetch the newest faultlog of an app from the device and say what happened: a native crash (cpp_crash), an uncaught ArkTS/JS error (js_crash), a freeze (app_freeze), or none. Reports the reason, the top frame, the first frame in the project's own code with file:line:column, the JS error and its stack, the native frames and, for a freeze, the binder chain. The bundle defaults to the project in the current directory, else any app. Lists through `hidumper -s 1201 -a \"-p Faultlogger\"`, and also reads faultloggerd's native crash records that hiview did not turn into a faultlog. Exits 0 whether or not there was a crash; check `crashed`."
|
|
2937
|
+
).allowExcessArguments(false).option("--bundle <bundle>", "App to look for (default: the bundleName of the project in the current directory, else any process).").option("--since <s|ISO>", "Only faults from the last N seconds (device clock), or since an ISO 8601 time.").option("--wait <ms>", "Keep looking this long when there is no fault yet; hiview writes the faultlog a few seconds after the process dies (default: look once).").option("--path <file>", "Analyse this local faultlog (or faultloggerd .json crash record) instead of fetching one.").option("-o, --output <directory>", "Where to save the fetched faultlog.", path15.join(os3.homedir(), ".oniro", "faultlogs")).option("--device <serial>", "Target device serial.").option(
|
|
2938
|
+
"--json",
|
|
2939
|
+
"Print the verdict as JSON: crashed, kind, subtype, confidence, bundle, pid, time, reason, topFrame, projectFrame, file, line, column, jsError {name, message, stack}, stack, nativeFrames, jsFrames, binderChain, deadlocks, notes, name, path, rawPath."
|
|
2940
|
+
).action(async (opts) => {
|
|
2941
|
+
const since = parseCrashSince(opts.since);
|
|
2942
|
+
const waitMs = opts.wait !== void 0 ? Number(opts.wait) : 0;
|
|
2943
|
+
if (!Number.isFinite(waitMs) || waitMs < 0) throw new Error(`--wait takes a number of milliseconds, not "${opts.wait}".`);
|
|
2944
|
+
let verdict;
|
|
2945
|
+
const bundle = opts.bundle ?? (opts.path ? void 0 : projectBundle(process.cwd()));
|
|
2946
|
+
if (opts.path) {
|
|
2947
|
+
const file = path15.resolve(opts.path);
|
|
2948
|
+
verdict = analyzeFaultLog(await fs8.readFile(file, "utf8"), { name: path15.basename(file), path: file, rawPath: file });
|
|
2949
|
+
} else {
|
|
2950
|
+
const { config, logger } = getRuntime();
|
|
2951
|
+
logger.info(`Looking for the newest fault of ${bundle ?? "any process"}${opts.since ? ` since ${opts.since}` : ""}...`);
|
|
2952
|
+
verdict = await latestCrash2({ config, bundle, since, waitMs, deviceSerial: opts.device, saveDir: opts.output, logger });
|
|
2953
|
+
}
|
|
2954
|
+
if (opts.json) {
|
|
2955
|
+
const none = { crashed: false, kind: null, bundle: bundle ?? null, since: since instanceof Date ? since.toISOString() : since ?? null };
|
|
2956
|
+
process.stdout.write(`${JSON.stringify(verdict ?? none, null, 2)}
|
|
2957
|
+
`);
|
|
2958
|
+
} else {
|
|
2959
|
+
process.stdout.write(verdict ? formatCrashVerdict(verdict) : `No fault for ${bundle ?? "any process"}${opts.since ? ` since ${opts.since}` : ""}.
|
|
2960
|
+
`);
|
|
2961
|
+
}
|
|
2962
|
+
});
|
|
2044
2963
|
}
|
|
2045
2964
|
|
|
2046
2965
|
// src/commands/hdc.ts
|
|
@@ -2059,58 +2978,2030 @@ function registerHdcCommand(program) {
|
|
|
2059
2978
|
`);
|
|
2060
2979
|
return;
|
|
2061
2980
|
}
|
|
2062
|
-
await new Promise((
|
|
2981
|
+
await new Promise((resolve20, reject) => {
|
|
2063
2982
|
const child = spawnCompat(hdc, hdcPassthroughArgv(args, opts.device), { stdio: "inherit" });
|
|
2064
2983
|
child.on("error", (err) => reject(new Error(`Failed to run ${hdc}: ${err.message}`)));
|
|
2065
2984
|
child.on("close", (code) => {
|
|
2066
2985
|
process.exitCode = code ?? 1;
|
|
2067
|
-
|
|
2986
|
+
resolve20();
|
|
2068
2987
|
});
|
|
2069
2988
|
});
|
|
2070
2989
|
});
|
|
2071
2990
|
}
|
|
2072
2991
|
|
|
2073
|
-
// src/
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2992
|
+
// src/commands/api.ts
|
|
2993
|
+
import * as path16 from "path";
|
|
2994
|
+
import {
|
|
2995
|
+
findApiInOtherSdks,
|
|
2996
|
+
getApiIndexCacheDir,
|
|
2997
|
+
loadApiIndex,
|
|
2998
|
+
resolveApiSdk,
|
|
2999
|
+
searchApi,
|
|
3000
|
+
showApi,
|
|
3001
|
+
suggestApiSymbols,
|
|
3002
|
+
OniroError as OniroError4
|
|
3003
|
+
} from "@oniroproject/core";
|
|
3004
|
+
var KINDS = [
|
|
3005
|
+
"namespace",
|
|
3006
|
+
"class",
|
|
3007
|
+
"interface",
|
|
3008
|
+
"enum",
|
|
3009
|
+
"enumMember",
|
|
3010
|
+
"type",
|
|
3011
|
+
"function",
|
|
3012
|
+
"method",
|
|
3013
|
+
"constructor",
|
|
3014
|
+
"property",
|
|
3015
|
+
"variable",
|
|
3016
|
+
"component",
|
|
3017
|
+
"decorator",
|
|
3018
|
+
"macro",
|
|
3019
|
+
"typedef",
|
|
3020
|
+
"struct"
|
|
3021
|
+
];
|
|
3022
|
+
function parseApi(value) {
|
|
3023
|
+
if (value === void 0) return void 0;
|
|
3024
|
+
const n = Number(value);
|
|
3025
|
+
if (!Number.isInteger(n) || n <= 0) throw new OniroError4(`--api expects an API level such as 20, got "${value}".`);
|
|
3026
|
+
return n;
|
|
2100
3027
|
}
|
|
2101
|
-
|
|
2102
|
-
|
|
3028
|
+
function parseKinds(values) {
|
|
3029
|
+
if (!values?.length) return void 0;
|
|
3030
|
+
const out = [];
|
|
3031
|
+
for (const v of values.flatMap((x) => x.split(","))) {
|
|
3032
|
+
const k = KINDS.find((kind) => kind.toLowerCase() === v.trim().toLowerCase());
|
|
3033
|
+
if (!k) throw new OniroError4(`Unknown --kind "${v}". Kinds: ${KINDS.join(", ")}.`);
|
|
3034
|
+
out.push(k);
|
|
3035
|
+
}
|
|
3036
|
+
return out;
|
|
3037
|
+
}
|
|
3038
|
+
function selectSdk(opts) {
|
|
3039
|
+
const { config, logger } = getRuntime();
|
|
3040
|
+
const selection = resolveApiSdk(config, {
|
|
3041
|
+
sdkDir: opts.sdk,
|
|
3042
|
+
api: parseApi(opts.api),
|
|
3043
|
+
projectDir: opts.project ? path16.resolve(opts.project) : process.cwd()
|
|
3044
|
+
});
|
|
3045
|
+
if (selection.note) logger.warn(selection.note);
|
|
3046
|
+
return selection;
|
|
3047
|
+
}
|
|
3048
|
+
function openIndex(sdkDir, rebuild) {
|
|
3049
|
+
const { config, logger } = getRuntime();
|
|
3050
|
+
return loadApiIndex(sdkDir, { cacheDir: getApiIndexCacheDir(config), rebuild, logger });
|
|
3051
|
+
}
|
|
3052
|
+
function describeIndex(index) {
|
|
3053
|
+
const api = index.api !== void 0 ? `API ${index.api}` : index.sdkDir;
|
|
3054
|
+
return index.sdkVersion ? `${api} (${index.sdkVersion})` : api;
|
|
3055
|
+
}
|
|
3056
|
+
function apiEntryFacts(e) {
|
|
3057
|
+
const parts = [e.kind];
|
|
3058
|
+
if (e.kit) parts.push(e.kit);
|
|
3059
|
+
if (e.since !== void 0) parts.push(`since ${e.sinceText ?? e.since}`);
|
|
3060
|
+
if (e.deprecated !== void 0) {
|
|
3061
|
+
const dep = e.deprecated ? `deprecated ${e.deprecated}` : "deprecated";
|
|
3062
|
+
parts.push(e.useInstead ? `${dep}, use ${e.useInstead}` : dep);
|
|
3063
|
+
}
|
|
3064
|
+
if (e.systemApi) parts.push("systemapi");
|
|
3065
|
+
return parts.join(" ");
|
|
3066
|
+
}
|
|
3067
|
+
function formatApiEntry(e, sdkDir) {
|
|
3068
|
+
const lines = [`${e.qualifiedName} ${apiEntryFacts(e)}`];
|
|
3069
|
+
lines.push(` ${e.signature}${e.overloads ? ` (+${e.overloads} overload${e.overloads > 1 ? "s" : ""})` : ""}`);
|
|
3070
|
+
if (e.doc) lines.push(` ${e.doc}`);
|
|
3071
|
+
const where = [`${path16.join(sdkDir, e.file)}:${e.line}`, e.module];
|
|
3072
|
+
if (e.import) where.push(e.import);
|
|
3073
|
+
else if (e.global) where.push("global, no import");
|
|
3074
|
+
if (e.syscap) where.push(e.syscap);
|
|
3075
|
+
lines.push(` ${where.join(" ")}`);
|
|
3076
|
+
return lines.join("\n");
|
|
3077
|
+
}
|
|
3078
|
+
function jsonEntry(e, sdkDir) {
|
|
3079
|
+
return { ...e, path: path16.join(sdkDir, e.file) };
|
|
3080
|
+
}
|
|
3081
|
+
function sdkJson(index, sel) {
|
|
3082
|
+
return { dir: index.sdkDir, api: index.api, version: index.sdkVersion, source: sel.source };
|
|
3083
|
+
}
|
|
3084
|
+
function runApiSearch(query, opts) {
|
|
3085
|
+
const sel = selectSdk(opts);
|
|
3086
|
+
const { index } = openIndex(sel.sdkDir, opts.rebuild);
|
|
3087
|
+
const lang = opts.lang?.toLowerCase();
|
|
3088
|
+
if (lang && lang !== "arkts" && lang !== "c") throw new OniroError4(`--lang expects arkts or c, got "${opts.lang}".`);
|
|
3089
|
+
const limit = Number(opts.limit ?? 20);
|
|
3090
|
+
if (!Number.isInteger(limit) || limit <= 0) throw new OniroError4(`--limit expects a positive number, got "${opts.limit}".`);
|
|
3091
|
+
const result = searchApi(index, query, {
|
|
3092
|
+
kit: opts.kit,
|
|
3093
|
+
kinds: parseKinds(opts.kind),
|
|
3094
|
+
language: lang,
|
|
3095
|
+
noDeprecated: opts.deprecated === false,
|
|
3096
|
+
noSystemApi: opts.systemapi === false,
|
|
3097
|
+
limit
|
|
3098
|
+
});
|
|
3099
|
+
const json = {
|
|
3100
|
+
sdk: sdkJson(index, sel),
|
|
3101
|
+
query: result.query,
|
|
3102
|
+
total: result.total,
|
|
3103
|
+
hits: result.hits.map((h) => jsonEntry(h, index.sdkDir))
|
|
3104
|
+
};
|
|
3105
|
+
return { json, index, hits: result.hits, total: result.total };
|
|
3106
|
+
}
|
|
3107
|
+
function runApiShow(symbol, opts) {
|
|
3108
|
+
const sel = selectSdk(opts);
|
|
3109
|
+
const { index } = openIndex(sel.sdkDir, opts.rebuild);
|
|
3110
|
+
const r = showApi(index, symbol, { kit: opts.kit });
|
|
3111
|
+
let alsoIn = [];
|
|
3112
|
+
let suggestions = [];
|
|
3113
|
+
if (!r.found) {
|
|
3114
|
+
const { config, logger } = getRuntime();
|
|
3115
|
+
if (opts.otherSdks !== false) {
|
|
3116
|
+
alsoIn = findApiInOtherSdks(config, symbol, index.sdkDir, {
|
|
3117
|
+
cacheDir: getApiIndexCacheDir(config),
|
|
3118
|
+
kit: opts.kit,
|
|
3119
|
+
logger
|
|
3120
|
+
});
|
|
3121
|
+
}
|
|
3122
|
+
suggestions = suggestApiSymbols(index, symbol);
|
|
3123
|
+
}
|
|
3124
|
+
const json = {
|
|
3125
|
+
sdk: sdkJson(index, sel),
|
|
3126
|
+
symbol,
|
|
3127
|
+
found: r.found,
|
|
3128
|
+
entries: r.entries.map((e) => jsonEntry(e, index.sdkDir))
|
|
3129
|
+
};
|
|
3130
|
+
if (!r.found) {
|
|
3131
|
+
json.alsoIn = alsoIn;
|
|
3132
|
+
json.suggestions = suggestions;
|
|
3133
|
+
}
|
|
3134
|
+
return { json, index, found: r.found, entries: r.entries, alsoIn, suggestions };
|
|
3135
|
+
}
|
|
3136
|
+
function registerApiCommand(program) {
|
|
3137
|
+
const api = program.command("api").description(
|
|
3138
|
+
"Look up the SDK's declarations: the ArkTS API (`ets/api`, `ets/arkts`, the ArkUI components in `ets/component`, with the kit that exports each symbol) and the Node-API C headers, at one API level. The index is built on first use (a few seconds) and cached under ~/.oniro/cache (ONIRO_CACHE_DIR)."
|
|
3139
|
+
);
|
|
3140
|
+
const sdkOptions = (cmd) => cmd.option(
|
|
3141
|
+
"--api <level>",
|
|
3142
|
+
"The SDK API level to read. Default: the project's compileSdkVersion (from the current directory or --project), else the newest installed SDK."
|
|
3143
|
+
).option("--sdk <dir>", "An SDK API directory (holding ets/) to read instead.").option("--project <dir>", "The project whose compileSdkVersion picks the SDK (default: the current directory).").option("--rebuild", "Rebuild the index even if the cached one is current.");
|
|
3144
|
+
sdkOptions(
|
|
3145
|
+
api.command("search <query...>").description(
|
|
3146
|
+
"Ranked search: every word must match a symbol name (exact name > prefix > camelCase part > substring) or its doc line. A dotted word matches qualified names (`router.pushUrl`); a leading @ is ignored (`@Local`)."
|
|
3147
|
+
)
|
|
3148
|
+
).option("--kit <name>", "Only symbols this kit exports (`ArkUI` or `@kit.ArkUI`).").option("--kind <kinds...>", `Only these kinds: ${KINDS.join(", ")}.`).option("--lang <lang>", "Only `arkts` declarations or `c` (Node-API headers).").option("--limit <n>", "At most n hits.", "20").option("--no-deprecated", "Leave out @deprecated symbols.").option("--no-systemapi", "Leave out @systemapi symbols (system apps only).").option(
|
|
3149
|
+
"--json",
|
|
3150
|
+
"Print JSON: sdk (dir, api, version, source), query, total, hits (name, qualifiedName, kind, kit, import, module, since, deprecated, deprecatedSince, useInstead, syscap, systemApi, file, path, line, signature, doc, overloads, score)."
|
|
3151
|
+
).action((words, opts) => {
|
|
3152
|
+
const { json, index, hits, total } = runApiSearch(words.join(" "), opts);
|
|
3153
|
+
if (opts.json) {
|
|
3154
|
+
process.stdout.write(`${JSON.stringify(json, null, 2)}
|
|
3155
|
+
`);
|
|
3156
|
+
} else {
|
|
3157
|
+
for (const h of hits) process.stdout.write(`${formatApiEntry(h, index.sdkDir)}
|
|
3158
|
+
|
|
3159
|
+
`);
|
|
3160
|
+
const shown = hits.length < total ? `${hits.length} of ${total}` : `${total}`;
|
|
3161
|
+
process.stdout.write(`${shown} match${total === 1 ? "" : "es"} in ${describeIndex(index)}.
|
|
3162
|
+
`);
|
|
3163
|
+
}
|
|
3164
|
+
process.exitCode = total ? 0 : 1;
|
|
3165
|
+
});
|
|
3166
|
+
sdkOptions(
|
|
3167
|
+
api.command("show <symbol>").description(
|
|
3168
|
+
"Exact lookup: is `symbol` declared at this API level? Takes a qualified name (`NavPathStack.pushPathByName`, `router.pushUrl`, `Column`, `@Local`, `napi_create_threadsafe_function`); when none matches, names that end with it (`Preferences.get`). Prints every declaration (overloads included). Exits 1 when it is not declared, naming the other installed SDKs that declare it and close matches."
|
|
3169
|
+
)
|
|
3170
|
+
).option("--kit <name>", "Only declarations this kit exports.").option("--no-other-sdks", "On a miss, do not look in the other installed SDKs.").option(
|
|
3171
|
+
"--json",
|
|
3172
|
+
"Print JSON: sdk, symbol, found, entries (the `search` hit fields), and on a miss alsoIn ([{api, since}]) and suggestions (qualified names)."
|
|
3173
|
+
).action((symbol, opts) => {
|
|
3174
|
+
const { json, index, found, entries, alsoIn, suggestions } = runApiShow(symbol, opts);
|
|
3175
|
+
if (opts.json) {
|
|
3176
|
+
process.stdout.write(`${JSON.stringify(json, null, 2)}
|
|
3177
|
+
`);
|
|
3178
|
+
} else if (found) {
|
|
3179
|
+
for (const e of entries) process.stdout.write(`${formatApiEntry(e, index.sdkDir)}
|
|
3180
|
+
|
|
3181
|
+
`);
|
|
3182
|
+
process.stdout.write(
|
|
3183
|
+
`${symbol} is declared in ${describeIndex(index)} (${entries.length} declaration${entries.length === 1 ? "" : "s"}).
|
|
3184
|
+
`
|
|
3185
|
+
);
|
|
3186
|
+
} else {
|
|
3187
|
+
process.stdout.write(`${symbol} is not declared in ${describeIndex(index)}.
|
|
3188
|
+
`);
|
|
3189
|
+
for (const a of alsoIn) {
|
|
3190
|
+
process.stdout.write(` declared in the installed API ${a.api} SDK${a.since !== void 0 ? ` (@since ${a.since})` : ""}
|
|
3191
|
+
`);
|
|
3192
|
+
}
|
|
3193
|
+
if (suggestions.length) process.stdout.write(` close matches: ${suggestions.join(", ")}
|
|
3194
|
+
`);
|
|
3195
|
+
}
|
|
3196
|
+
process.exitCode = found ? 0 : 1;
|
|
3197
|
+
});
|
|
3198
|
+
sdkOptions(
|
|
3199
|
+
api.command("index").description("Build (or refresh) the index of one SDK and print its size, for warming a cache.")
|
|
3200
|
+
).option("--json", "Print JSON: sdk, entries, files, buildMs, cached, cachePath, loadMs.").action((opts) => {
|
|
3201
|
+
const sel = selectSdk(opts);
|
|
3202
|
+
const loaded = openIndex(sel.sdkDir, opts.rebuild);
|
|
3203
|
+
const { index } = loaded;
|
|
3204
|
+
if (opts.json) {
|
|
3205
|
+
const out = {
|
|
3206
|
+
sdk: { dir: index.sdkDir, api: index.api, version: index.sdkVersion, source: sel.source },
|
|
3207
|
+
entries: index.entries.length,
|
|
3208
|
+
files: index.files,
|
|
3209
|
+
buildMs: index.buildMs,
|
|
3210
|
+
builtAt: index.builtAt,
|
|
3211
|
+
cached: loaded.cached,
|
|
3212
|
+
cachePath: loaded.cachePath,
|
|
3213
|
+
loadMs: loaded.loadMs
|
|
3214
|
+
};
|
|
3215
|
+
process.stdout.write(`${JSON.stringify(out, null, 2)}
|
|
3216
|
+
`);
|
|
3217
|
+
} else {
|
|
3218
|
+
process.stdout.write(
|
|
3219
|
+
`${describeIndex(index)}: ${index.entries.length} declarations from ${index.files} files, ${loaded.cached ? `cached (built ${index.builtAt})` : `built in ${index.buildMs} ms`}, ${loaded.cachePath}
|
|
3220
|
+
`
|
|
3221
|
+
);
|
|
3222
|
+
}
|
|
3223
|
+
});
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
// src/commands/docs.ts
|
|
3227
|
+
import {
|
|
3228
|
+
DocsIndex,
|
|
3229
|
+
docsDirFor,
|
|
3230
|
+
docsRefFor,
|
|
3231
|
+
docsTree,
|
|
3232
|
+
readSdkRelease,
|
|
3233
|
+
syncDocs,
|
|
3234
|
+
OniroError as OniroError5
|
|
3235
|
+
} from "@oniroproject/core";
|
|
3236
|
+
function selectDocs(opts) {
|
|
3237
|
+
const { config, logger } = getRuntime();
|
|
3238
|
+
if (opts.ref) return { ref: opts.ref, dir: docsDirFor(config, opts.ref) };
|
|
3239
|
+
const api = opts.api !== void 0 ? Number(opts.api) : void 0;
|
|
3240
|
+
let sel;
|
|
2103
3241
|
try {
|
|
2104
|
-
|
|
3242
|
+
sel = selectSdk(opts);
|
|
2105
3243
|
} catch (err) {
|
|
2106
|
-
|
|
2107
|
-
|
|
3244
|
+
if (api === void 0 || !Number.isInteger(api) || api <= 0) throw err;
|
|
3245
|
+
const ref2 = docsRefFor({ api });
|
|
3246
|
+
logger.warn(`The API ${api} SDK is not installed; taking the ${ref2} docs for it.`);
|
|
3247
|
+
return { ref: ref2, dir: docsDirFor(config, ref2), sdk: { api } };
|
|
3248
|
+
}
|
|
3249
|
+
const release = readSdkRelease(sel.sdkDir);
|
|
3250
|
+
const ref = docsRefFor({ api: release.api ?? sel.api, version: release.version });
|
|
3251
|
+
return { ref, dir: docsDirFor(config, ref), sdk: { dir: sel.sdkDir, api: release.api ?? sel.api, version: release.version } };
|
|
3252
|
+
}
|
|
3253
|
+
function docsJson(sel, tree) {
|
|
3254
|
+
return { ref: sel.ref, dir: sel.dir, tree, sdk: sel.sdk };
|
|
3255
|
+
}
|
|
3256
|
+
function openDocs(sel) {
|
|
3257
|
+
const tree = docsTree(sel.dir);
|
|
3258
|
+
if (!tree) {
|
|
3259
|
+
const flag = sel.sdk?.api !== void 0 ? ` --api ${sel.sdk.api}` : sel.ref ? ` --ref ${sel.ref}` : "";
|
|
3260
|
+
throw new OniroError5(
|
|
3261
|
+
`The ${sel.ref} docs are not synced (no ${sel.dir}). Run \`oniro-app docs sync${flag}\` first (a partial clone of gitcode.com/openharmony/docs, about 100 MB).`
|
|
3262
|
+
);
|
|
3263
|
+
}
|
|
3264
|
+
return { tree, index: DocsIndex.load(tree) };
|
|
3265
|
+
}
|
|
3266
|
+
function runDocsSearch(query, opts) {
|
|
3267
|
+
const sel = selectDocs(opts);
|
|
3268
|
+
const { tree, index } = openDocs(sel);
|
|
3269
|
+
const limit = opts.limit !== void 0 ? Number(opts.limit) : void 0;
|
|
3270
|
+
if (limit !== void 0 && (!Number.isInteger(limit) || limit <= 0)) {
|
|
3271
|
+
throw new OniroError5(`--limit expects a positive number, got "${opts.limit}".`);
|
|
3272
|
+
}
|
|
3273
|
+
const result = index.search(query, { limit, section: opts.section });
|
|
3274
|
+
return { json: { docs: docsJson(sel, tree), ...result }, result, tree };
|
|
3275
|
+
}
|
|
3276
|
+
function runDocsShow(docPath, opts) {
|
|
3277
|
+
const sel = selectDocs(opts);
|
|
3278
|
+
const { tree, index } = openDocs(sel);
|
|
3279
|
+
const maxLines = opts.lines !== void 0 ? Number(opts.lines) : void 0;
|
|
3280
|
+
if (maxLines !== void 0 && (!Number.isInteger(maxLines) || maxLines <= 0)) {
|
|
3281
|
+
throw new OniroError5(`--lines expects a positive number, got "${opts.lines}".`);
|
|
3282
|
+
}
|
|
3283
|
+
const result = index.show(docPath, { heading: opts.heading, maxLines });
|
|
3284
|
+
return { json: { docs: docsJson(sel, tree), ...result }, result, tree };
|
|
3285
|
+
}
|
|
3286
|
+
async function runDocsSync(opts) {
|
|
3287
|
+
const { logger } = getRuntime();
|
|
3288
|
+
const sel = selectDocs(opts);
|
|
3289
|
+
const result = await syncDocs({ ref: sel.ref, dir: sel.dir, logger });
|
|
3290
|
+
const tree = docsTree(sel.dir);
|
|
3291
|
+
if (tree) DocsIndex.forget(tree);
|
|
3292
|
+
return { json: { docs: docsJson(sel, tree ?? sel.dir), sdk: sel.sdk, ...result }, result, sel };
|
|
3293
|
+
}
|
|
3294
|
+
function formatDocHit(h) {
|
|
3295
|
+
const lines = [`${h.path} \u2014 ${h.title}`];
|
|
3296
|
+
if (h.headings.length) lines.push(` sections: ${h.headings.map((x) => `"${x.text}" (line ${x.line})`).join(", ")}`);
|
|
3297
|
+
if (h.snippet) lines.push(` ${h.snippet}`);
|
|
3298
|
+
return lines.join("\n");
|
|
3299
|
+
}
|
|
3300
|
+
function registerDocsCommand(program) {
|
|
3301
|
+
const docs = program.command("docs").description(
|
|
3302
|
+
"Search the OpenHarmony application-development documentation (gitcode.com/openharmony/docs, `en/application-dev`) of the release matching the SDK: usage guides, kit API prose, business error codes and platform behaviour, which the SDK typings behind `api search` do not say. `docs sync` clones the release's tree once (a partial, sparse clone without figures, about 100 MB) under <sdkRootDir>/docs (ONIRO_DOCS_DIR)."
|
|
3303
|
+
);
|
|
3304
|
+
const docsOptions = (cmd) => cmd.option(
|
|
3305
|
+
"--api <level>",
|
|
3306
|
+
"The SDK API level whose release picks the docs. Default: the project's compileSdkVersion (from the current directory or --project), else the newest installed SDK."
|
|
3307
|
+
).option("--sdk <dir>", "An SDK API directory (holding ets/) whose version picks the docs.").option("--project <dir>", "The project whose compileSdkVersion picks the SDK (default: the current directory).").option("--ref <branch>", "A docs branch or tag (`OpenHarmony-6.1-Release`, `master`) instead of the SDK-chosen one.");
|
|
3308
|
+
docsOptions(
|
|
3309
|
+
docs.command("sync").description(
|
|
3310
|
+
"Clone the docs of the release matching the SDK (6.1.0.x \u2192 OpenHarmony-6.1-Release), or bring an existing clone up to date. Needs git."
|
|
3311
|
+
)
|
|
3312
|
+
).option("--json", "Print JSON: docs (ref, dir, tree, sdk), files, updated, commit.").action(async (opts) => {
|
|
3313
|
+
const { json, result, sel } = await runDocsSync(opts);
|
|
3314
|
+
if (opts.json) process.stdout.write(`${JSON.stringify(json, null, 2)}
|
|
3315
|
+
`);
|
|
3316
|
+
else {
|
|
3317
|
+
process.stdout.write(
|
|
3318
|
+
`${result.updated ? "Updated" : "Cloned"} the ${sel.ref} docs${result.commit ? ` (${result.commit})` : ""}: ${result.files} documents in ${result.dir}
|
|
3319
|
+
`
|
|
3320
|
+
);
|
|
3321
|
+
}
|
|
3322
|
+
});
|
|
3323
|
+
docsOptions(
|
|
3324
|
+
docs.command("search <query...>").description(
|
|
3325
|
+
"Ranked search: documents containing every word (any word when none does), scored by where the words occur (title, headings, path, body); a camelCase or dotted identifier also matches by its parts. Each hit names the sections that match, to read with `docs show`. Exits 1 when nothing matches."
|
|
3326
|
+
)
|
|
3327
|
+
).option("--limit <n>", "At most n hits.", "8").option("--section <dir>", "Only documents under this subtree: `ui`, `database`, `reference/errorcodes`, `application-models`.").option("--json", "Print JSON: docs (ref, dir, tree, sdk), query, terms, total, hits (path, title, score, headings, snippet).").action((words, opts) => {
|
|
3328
|
+
const { json, result, tree } = runDocsSearch(words.join(" "), opts);
|
|
3329
|
+
if (opts.json) {
|
|
3330
|
+
process.stdout.write(`${JSON.stringify(json, null, 2)}
|
|
3331
|
+
`);
|
|
3332
|
+
} else {
|
|
3333
|
+
for (const h of result.hits) process.stdout.write(`${formatDocHit(h)}
|
|
3334
|
+
|
|
3335
|
+
`);
|
|
3336
|
+
const shown = result.hits.length < result.total ? `${result.hits.length} of ${result.total}` : `${result.total}`;
|
|
3337
|
+
process.stdout.write(
|
|
3338
|
+
result.total ? `${shown} document${result.total === 1 ? "" : "s"} match; paths are relative to ${tree}.
|
|
3339
|
+
` : "No document matches; try fewer or different words (component or kit names, decorators, an error code).\n"
|
|
3340
|
+
);
|
|
3341
|
+
}
|
|
3342
|
+
process.exitCode = result.total ? 0 : 1;
|
|
3343
|
+
});
|
|
3344
|
+
docsOptions(
|
|
3345
|
+
docs.command("show <path>").description(
|
|
3346
|
+
"One document (a path `docs search` printed, relative to en/application-dev), or one section of it by heading. Images and inline HTML are dropped; long documents are cut at --lines with a list of their sections. Exits 1 when the document or heading does not exist, suggesting close paths or the headings."
|
|
3347
|
+
)
|
|
3348
|
+
).option("--heading <text>", "A heading (or a distinctive part of it, case-insensitive): only that section.").option("--lines <n>", "At most n lines (default 250).").option(
|
|
3349
|
+
"--json",
|
|
3350
|
+
"Print JSON: docs (ref, dir, tree, sdk), found, path, text, truncated, lines, headings; on a miss error and suggestions."
|
|
3351
|
+
).action((docPath, opts) => {
|
|
3352
|
+
const { json, result } = runDocsShow(docPath, opts);
|
|
3353
|
+
if (opts.json) {
|
|
3354
|
+
process.stdout.write(`${JSON.stringify(json, null, 2)}
|
|
2108
3355
|
`);
|
|
2109
|
-
if (
|
|
2110
|
-
process.
|
|
3356
|
+
} else if (result.found) {
|
|
3357
|
+
process.stdout.write(`${result.text}
|
|
3358
|
+
`);
|
|
3359
|
+
if (!opts.heading && result.headings.length) {
|
|
3360
|
+
process.stdout.write(`
|
|
3361
|
+
[sections: ${result.headings.slice(0, 40).map((h) => h.text).join(" | ")}]
|
|
3362
|
+
`);
|
|
3363
|
+
}
|
|
3364
|
+
} else {
|
|
3365
|
+
process.stdout.write(`${result.error}
|
|
2111
3366
|
`);
|
|
3367
|
+
if (result.suggestions.length) {
|
|
3368
|
+
process.stdout.write(` ${opts.heading ? "headings" : "close paths"}: ${result.suggestions.join(" | ")}
|
|
3369
|
+
`);
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
process.exitCode = result.found ? 0 : 1;
|
|
3373
|
+
});
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
// src/commands/serve.ts
|
|
3377
|
+
import * as fs10 from "fs";
|
|
3378
|
+
import * as path19 from "path";
|
|
3379
|
+
import { OniroError as OniroError6 } from "@oniroproject/core";
|
|
3380
|
+
|
|
3381
|
+
// src/mcp/protocol.ts
|
|
3382
|
+
import * as readline2 from "readline";
|
|
3383
|
+
|
|
3384
|
+
// src/mcp/schema.ts
|
|
3385
|
+
function typeOk(type, v) {
|
|
3386
|
+
switch (type) {
|
|
3387
|
+
case void 0:
|
|
3388
|
+
return true;
|
|
3389
|
+
case "object":
|
|
3390
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3391
|
+
case "array":
|
|
3392
|
+
return Array.isArray(v);
|
|
3393
|
+
case "integer":
|
|
3394
|
+
return typeof v === "number" && Number.isInteger(v);
|
|
3395
|
+
case "number":
|
|
3396
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
3397
|
+
default:
|
|
3398
|
+
return typeof v === type;
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
function validateArgs(schema, value, at = "") {
|
|
3402
|
+
const where = at || "arguments";
|
|
3403
|
+
if (!typeOk(schema.type, value)) return [`${where} must be ${schema.type === "integer" ? "an integer" : `a ${schema.type}`}`];
|
|
3404
|
+
const out = [];
|
|
3405
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
3406
|
+
out.push(`${where} must be one of ${schema.enum.map((e) => JSON.stringify(e)).join(", ")}`);
|
|
3407
|
+
}
|
|
3408
|
+
if (typeof value === "number") {
|
|
3409
|
+
if (schema.minimum !== void 0 && value < schema.minimum) out.push(`${where} must be >= ${schema.minimum}`);
|
|
3410
|
+
if (schema.maximum !== void 0 && value > schema.maximum) out.push(`${where} must be <= ${schema.maximum}`);
|
|
3411
|
+
}
|
|
3412
|
+
if (Array.isArray(value)) {
|
|
3413
|
+
if (schema.minItems !== void 0 && value.length < schema.minItems) {
|
|
3414
|
+
out.push(`${where} needs at least ${schema.minItems} item${schema.minItems === 1 ? "" : "s"}`);
|
|
3415
|
+
}
|
|
3416
|
+
if (schema.items) value.forEach((item, i) => out.push(...validateArgs(schema.items, item, `${where}[${i}]`)));
|
|
3417
|
+
}
|
|
3418
|
+
if (schema.type === "object" && typeof value === "object" && value !== null) {
|
|
3419
|
+
const obj = value;
|
|
3420
|
+
const props = schema.properties ?? {};
|
|
3421
|
+
for (const key of schema.required ?? []) {
|
|
3422
|
+
if (obj[key] === void 0 || obj[key] === null) out.push(`${at ? `${at}.` : ""}${key} is required`);
|
|
3423
|
+
}
|
|
3424
|
+
for (const [key, v] of Object.entries(obj)) {
|
|
3425
|
+
const prop = props[key];
|
|
3426
|
+
if (!prop) {
|
|
3427
|
+
if (schema.additionalProperties === false) {
|
|
3428
|
+
out.push(`unknown property ${at ? `${at}.` : ""}${key} (expected: ${Object.keys(props).join(", ") || "none"})`);
|
|
3429
|
+
}
|
|
3430
|
+
continue;
|
|
3431
|
+
}
|
|
3432
|
+
if (v === void 0 || v === null) continue;
|
|
3433
|
+
out.push(...validateArgs(prop, v, `${at ? `${at}.` : ""}${key}`));
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
return out;
|
|
3437
|
+
}
|
|
3438
|
+
function applyDefaults(schema, args) {
|
|
3439
|
+
const out = {};
|
|
3440
|
+
for (const [key, v] of Object.entries(args)) if (v !== null && v !== void 0) out[key] = v;
|
|
3441
|
+
for (const [key, prop] of Object.entries(schema.properties ?? {})) {
|
|
3442
|
+
if (out[key] === void 0 && prop.default !== void 0) out[key] = prop.default;
|
|
3443
|
+
}
|
|
3444
|
+
return out;
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
// src/mcp/protocol.ts
|
|
3448
|
+
var SUPPORTED_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
3449
|
+
var STRUCTURED_CONTENT_SINCE = "2025-06-18";
|
|
3450
|
+
var RPC_PARSE_ERROR = -32700;
|
|
3451
|
+
var RPC_INVALID_REQUEST = -32600;
|
|
3452
|
+
var RPC_METHOD_NOT_FOUND = -32601;
|
|
3453
|
+
var RPC_INVALID_PARAMS = -32602;
|
|
3454
|
+
var RPC_INTERNAL_ERROR = -32603;
|
|
3455
|
+
function rpcError(id, code, message2) {
|
|
3456
|
+
return { jsonrpc: "2.0", id, error: { code, message: message2 } };
|
|
3457
|
+
}
|
|
3458
|
+
function isObject(v) {
|
|
3459
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3460
|
+
}
|
|
3461
|
+
function errorResult(message2, structured) {
|
|
3462
|
+
return {
|
|
3463
|
+
content: [{ type: "text", text: message2 }],
|
|
3464
|
+
...structured ? { structuredContent: structured } : {},
|
|
3465
|
+
isError: true
|
|
3466
|
+
};
|
|
3467
|
+
}
|
|
3468
|
+
var McpServer = class {
|
|
3469
|
+
constructor(opts) {
|
|
3470
|
+
this.opts = opts;
|
|
3471
|
+
for (const t of opts.tools) this.tools.set(t.name, t);
|
|
3472
|
+
}
|
|
3473
|
+
opts;
|
|
3474
|
+
tools = /* @__PURE__ */ new Map();
|
|
3475
|
+
protocolVersion;
|
|
3476
|
+
/** The version agreed in `initialize`, or undefined before it. */
|
|
3477
|
+
get negotiatedVersion() {
|
|
3478
|
+
return this.protocolVersion;
|
|
3479
|
+
}
|
|
3480
|
+
/**
|
|
3481
|
+
* Handle one parsed JSON-RPC message (or a batch, which the 2025-03-26
|
|
3482
|
+
* revision allows). Returns the response to send, or undefined for a
|
|
3483
|
+
* notification.
|
|
3484
|
+
*/
|
|
3485
|
+
async handle(message2) {
|
|
3486
|
+
if (Array.isArray(message2)) {
|
|
3487
|
+
if (message2.length === 0) return rpcError(null, RPC_INVALID_REQUEST, "Empty batch.");
|
|
3488
|
+
const out = (await Promise.all(message2.map((m) => this.handleOne(m)))).filter(
|
|
3489
|
+
(r) => r !== void 0
|
|
3490
|
+
);
|
|
3491
|
+
return out.length ? out : void 0;
|
|
3492
|
+
}
|
|
3493
|
+
return this.handleOne(message2);
|
|
3494
|
+
}
|
|
3495
|
+
async handleOne(msg) {
|
|
3496
|
+
if (!isObject(msg) || msg.jsonrpc !== "2.0") {
|
|
3497
|
+
return rpcError(isObject(msg) && isId(msg.id) ? msg.id : null, RPC_INVALID_REQUEST, "Not a JSON-RPC 2.0 message.");
|
|
3498
|
+
}
|
|
3499
|
+
const hasId = "id" in msg;
|
|
3500
|
+
if (typeof msg.method !== "string") {
|
|
3501
|
+
if (hasId && ("result" in msg || "error" in msg)) return void 0;
|
|
3502
|
+
return rpcError(isId(msg.id) ? msg.id : null, RPC_INVALID_REQUEST, "Missing method.");
|
|
3503
|
+
}
|
|
3504
|
+
if (!hasId) {
|
|
3505
|
+
this.onNotification(msg.method);
|
|
3506
|
+
return void 0;
|
|
3507
|
+
}
|
|
3508
|
+
if (!isId(msg.id) || msg.id === null) return rpcError(null, RPC_INVALID_REQUEST, "The id must be a string or a number.");
|
|
3509
|
+
const id = msg.id;
|
|
3510
|
+
const params = msg.params === void 0 ? {} : msg.params;
|
|
3511
|
+
if (!isObject(params)) return rpcError(id, RPC_INVALID_PARAMS, "params must be an object.");
|
|
3512
|
+
try {
|
|
3513
|
+
return await this.dispatch(id, msg.method, params);
|
|
3514
|
+
} catch (err) {
|
|
3515
|
+
this.opts.logger.error(`mcp: ${msg.method} failed: ${err.stack ?? String(err)}`);
|
|
3516
|
+
return rpcError(id, RPC_INTERNAL_ERROR, err instanceof Error ? err.message : String(err));
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
onNotification(method) {
|
|
3520
|
+
if (method === "notifications/initialized") this.opts.logger.debug("mcp: client initialized");
|
|
3521
|
+
}
|
|
3522
|
+
async dispatch(id, method, params) {
|
|
3523
|
+
const ok = (result) => ({ jsonrpc: "2.0", id, result });
|
|
3524
|
+
if (method === "initialize") return ok(this.initialize(params));
|
|
3525
|
+
if (method === "ping") return ok({});
|
|
3526
|
+
if (this.protocolVersion === void 0) {
|
|
3527
|
+
return rpcError(id, RPC_INVALID_REQUEST, `Received ${method} before initialize.`);
|
|
3528
|
+
}
|
|
3529
|
+
if (method === "tools/list") {
|
|
3530
|
+
return ok({
|
|
3531
|
+
tools: [...this.tools.values()].map((t) => ({
|
|
3532
|
+
name: t.name,
|
|
3533
|
+
...t.title ? { title: t.title } : {},
|
|
3534
|
+
description: t.description,
|
|
3535
|
+
inputSchema: t.inputSchema,
|
|
3536
|
+
...t.annotations ? { annotations: t.annotations } : {}
|
|
3537
|
+
}))
|
|
3538
|
+
});
|
|
3539
|
+
}
|
|
3540
|
+
if (method === "tools/call") {
|
|
3541
|
+
const name = params.name;
|
|
3542
|
+
if (typeof name !== "string") return rpcError(id, RPC_INVALID_PARAMS, "tools/call needs a tool name.");
|
|
3543
|
+
const tool = this.tools.get(name);
|
|
3544
|
+
if (!tool) return rpcError(id, RPC_INVALID_PARAMS, `Unknown tool: ${name}. Tools: ${[...this.tools.keys()].join(", ")}.`);
|
|
3545
|
+
return ok(this.shape(await this.callTool(tool, params.arguments)));
|
|
3546
|
+
}
|
|
3547
|
+
return rpcError(id, RPC_METHOD_NOT_FOUND, `Method not found: ${method}`);
|
|
3548
|
+
}
|
|
3549
|
+
initialize(params) {
|
|
3550
|
+
const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : void 0;
|
|
3551
|
+
const version = asked && SUPPORTED_PROTOCOL_VERSIONS.includes(asked) ? asked : SUPPORTED_PROTOCOL_VERSIONS[0];
|
|
3552
|
+
this.protocolVersion = version;
|
|
3553
|
+
const client = isObject(params.clientInfo) ? `${params.clientInfo.name ?? "?"} ${params.clientInfo.version ?? ""}` : "client";
|
|
3554
|
+
this.opts.logger.info(`mcp: ${client.trim()} connected (protocol ${version}, asked ${asked ?? "none"})`);
|
|
3555
|
+
return {
|
|
3556
|
+
protocolVersion: version,
|
|
3557
|
+
capabilities: { tools: { listChanged: false } },
|
|
3558
|
+
serverInfo: { name: this.opts.name, version: this.opts.version },
|
|
3559
|
+
...this.opts.instructions ? { instructions: this.opts.instructions } : {}
|
|
3560
|
+
};
|
|
3561
|
+
}
|
|
3562
|
+
/** Validate, fill defaults, run. A tool that throws or gets bad arguments yields an `isError` result, not an RPC error. */
|
|
3563
|
+
async callTool(tool, rawArgs) {
|
|
3564
|
+
const args = rawArgs === void 0 || rawArgs === null ? {} : rawArgs;
|
|
3565
|
+
if (!isObject(args)) return errorResult(`Invalid arguments for ${tool.name}: arguments must be an object.`);
|
|
3566
|
+
const problems = validateArgs(tool.inputSchema, args);
|
|
3567
|
+
if (problems.length) return errorResult(`Invalid arguments for ${tool.name}: ${problems.join("; ")}.`);
|
|
3568
|
+
const started = Date.now();
|
|
3569
|
+
this.opts.logger.info(`mcp: ${tool.name} ${JSON.stringify(args)}`);
|
|
3570
|
+
try {
|
|
3571
|
+
const result = await tool.call(applyDefaults(tool.inputSchema, args), this.opts.context);
|
|
3572
|
+
this.opts.logger.info(`mcp: ${tool.name} ${result.isError ? "failed" : "done"} in ${Date.now() - started} ms`);
|
|
3573
|
+
return result;
|
|
3574
|
+
} catch (err) {
|
|
3575
|
+
const message2 = err instanceof Error ? err.message : String(err);
|
|
3576
|
+
this.opts.logger.info(`mcp: ${tool.name} threw in ${Date.now() - started} ms: ${message2}`);
|
|
3577
|
+
if (process.env.ONIRO_DEBUG === "1" && err instanceof Error && err.stack) this.opts.logger.debug(err.stack);
|
|
3578
|
+
return errorResult(message2, { error: message2 });
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
/** Clients before 2025-06-18 know no `structuredContent`; the text block carries the same JSON. */
|
|
3582
|
+
shape(result) {
|
|
3583
|
+
if (result.structuredContent && (this.protocolVersion ?? "") < STRUCTURED_CONTENT_SINCE) {
|
|
3584
|
+
const { structuredContent: _dropped, ...rest } = result;
|
|
3585
|
+
return rest;
|
|
3586
|
+
}
|
|
3587
|
+
return result;
|
|
3588
|
+
}
|
|
3589
|
+
};
|
|
3590
|
+
function isId(v) {
|
|
3591
|
+
return typeof v === "string" || typeof v === "number" || v === null;
|
|
3592
|
+
}
|
|
3593
|
+
async function serveStdio(server, opts = {}) {
|
|
3594
|
+
const input2 = opts.input ?? process.stdin;
|
|
3595
|
+
const write = opts.write ?? ((line) => void process.stdout.write(line));
|
|
3596
|
+
const send = (msg) => write(`${JSON.stringify(msg)}
|
|
3597
|
+
`);
|
|
3598
|
+
const pending = /* @__PURE__ */ new Set();
|
|
3599
|
+
const rl = readline2.createInterface({ input: input2, crlfDelay: Infinity, terminal: false });
|
|
3600
|
+
for await (const line of rl) {
|
|
3601
|
+
const text = line.trim();
|
|
3602
|
+
if (!text) continue;
|
|
3603
|
+
let message2;
|
|
3604
|
+
try {
|
|
3605
|
+
message2 = JSON.parse(text);
|
|
3606
|
+
} catch {
|
|
3607
|
+
send(rpcError(null, RPC_PARSE_ERROR, "Parse error: each line must be one JSON-RPC message."));
|
|
3608
|
+
continue;
|
|
3609
|
+
}
|
|
3610
|
+
const p = server.handle(message2).then(
|
|
3611
|
+
(response) => {
|
|
3612
|
+
if (response !== void 0) send(response);
|
|
3613
|
+
},
|
|
3614
|
+
() => {
|
|
3615
|
+
}
|
|
3616
|
+
);
|
|
3617
|
+
pending.add(p);
|
|
3618
|
+
void p.finally(() => pending.delete(p));
|
|
3619
|
+
}
|
|
3620
|
+
await Promise.all(pending);
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
// src/mcp/tools.ts
|
|
3624
|
+
import * as fs9 from "fs";
|
|
3625
|
+
import * as os4 from "os";
|
|
3626
|
+
import * as path18 from "path";
|
|
3627
|
+
import {
|
|
3628
|
+
analyzeFaultLog as analyzeFaultLog2,
|
|
3629
|
+
captureBurst as captureBurst2,
|
|
3630
|
+
describeLayoutNode as describeLayoutNode4,
|
|
3631
|
+
findLayoutNodes as findLayoutNodes2,
|
|
3632
|
+
listLayoutWindows as listLayoutWindows2,
|
|
3633
|
+
selectableTree as selectableTree2,
|
|
3634
|
+
diffLayouts as diffLayouts2,
|
|
3635
|
+
dumpLayout as dumpLayout3,
|
|
3636
|
+
inventoryScreens as inventoryScreens2,
|
|
3637
|
+
latestCrash as latestCrash3,
|
|
3638
|
+
locateTarget as locateTarget2,
|
|
3639
|
+
parseLayoutBaseline as parseLayoutBaseline2,
|
|
3640
|
+
parseSelector as parseSelector2,
|
|
3641
|
+
performInput as performInput2,
|
|
3642
|
+
runDoctor as runDoctor2,
|
|
3643
|
+
runTests as runTests2,
|
|
3644
|
+
summarizeLayout as summarizeLayout2,
|
|
3645
|
+
takeScreenshot as takeScreenshot2,
|
|
3646
|
+
waitForBoot as waitForBoot2,
|
|
3647
|
+
waitForBundle as waitForBundle2,
|
|
3648
|
+
waitForLog as waitForLog2,
|
|
3649
|
+
waitForTarget as waitForTarget2,
|
|
3650
|
+
DEFAULT_SMOKE_DELAY_MS as DEFAULT_SMOKE_DELAY_MS2,
|
|
3651
|
+
DEFAULT_TRACE_BUFFER_KB as DEFAULT_TRACE_BUFFER_KB2,
|
|
3652
|
+
DEFAULT_TRACE_CATEGORIES as DEFAULT_TRACE_CATEGORIES2,
|
|
3653
|
+
evaluatePerfGate as evaluatePerfGate2,
|
|
3654
|
+
perfTrace as perfTrace2,
|
|
3655
|
+
readFlowFile as readFlowFile2
|
|
3656
|
+
} from "@oniroproject/core";
|
|
3657
|
+
|
|
3658
|
+
// src/commands/perf.ts
|
|
3659
|
+
import { InvalidArgumentError as InvalidArgumentError5 } from "commander";
|
|
3660
|
+
import * as path17 from "path";
|
|
3661
|
+
import {
|
|
3662
|
+
DEFAULT_PERF_FLOW_TAIL_MS,
|
|
3663
|
+
DEFAULT_PERF_FOR_MS,
|
|
3664
|
+
DEFAULT_TRACE_BUFFER_KB,
|
|
3665
|
+
DEFAULT_TRACE_CATEGORIES,
|
|
3666
|
+
evaluatePerfGate,
|
|
3667
|
+
parseTraceFile,
|
|
3668
|
+
perfSliceFilter,
|
|
3669
|
+
perfTrace,
|
|
3670
|
+
readFlowFile,
|
|
3671
|
+
summarizeTrace
|
|
3672
|
+
} from "@oniroproject/core";
|
|
3673
|
+
|
|
3674
|
+
// src/lib/interrupt.ts
|
|
3675
|
+
async function withInterrupt(logger, what, fn) {
|
|
3676
|
+
const controller = new AbortController();
|
|
3677
|
+
const onSigint = () => {
|
|
3678
|
+
if (controller.signal.aborted) process.exit(130);
|
|
3679
|
+
logger.info(`${what}\u2026 (Ctrl+C again to quit at once)`);
|
|
3680
|
+
controller.abort();
|
|
3681
|
+
};
|
|
3682
|
+
process.on("SIGINT", onSigint);
|
|
3683
|
+
try {
|
|
3684
|
+
return await fn(controller.signal);
|
|
3685
|
+
} finally {
|
|
3686
|
+
process.off("SIGINT", onSigint);
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
// src/commands/perf.ts
|
|
3691
|
+
function positiveMs2(raw) {
|
|
3692
|
+
const n = Number(raw);
|
|
3693
|
+
if (!Number.isFinite(n) || n <= 0) throw new InvalidArgumentError5("Expected a positive number of milliseconds.");
|
|
3694
|
+
return Math.floor(n);
|
|
3695
|
+
}
|
|
3696
|
+
function rate(raw) {
|
|
3697
|
+
const n = Number(raw);
|
|
3698
|
+
if (!Number.isFinite(n) || n < 0 || n > 1) throw new InvalidArgumentError5("Expected a rate between 0 and 1, e.g. 0.05.");
|
|
3699
|
+
return n;
|
|
3700
|
+
}
|
|
3701
|
+
function positiveInt(raw) {
|
|
3702
|
+
const n = Number(raw);
|
|
3703
|
+
if (!Number.isInteger(n) || n <= 0) throw new InvalidArgumentError5("Expected a positive integer.");
|
|
3704
|
+
return n;
|
|
3705
|
+
}
|
|
3706
|
+
function positiveNumber(raw) {
|
|
3707
|
+
const n = Number(raw);
|
|
3708
|
+
if (!Number.isFinite(n) || n <= 0) throw new InvalidArgumentError5("Expected a positive number.");
|
|
3709
|
+
return n;
|
|
3710
|
+
}
|
|
3711
|
+
function list(raw) {
|
|
3712
|
+
return raw.split(/[,\s]+/).filter(Boolean);
|
|
3713
|
+
}
|
|
3714
|
+
function perfExitCode(report, gate) {
|
|
3715
|
+
if (!report.ok) return 2;
|
|
3716
|
+
return gate.passed ? 0 : 1;
|
|
3717
|
+
}
|
|
3718
|
+
var ms = (n) => `${Math.round(n * 10) / 10} ms`;
|
|
3719
|
+
function formatPerfReport(r, gate) {
|
|
3720
|
+
const lines = [
|
|
3721
|
+
`${r.bundle}${r.pid !== null ? ` (pid ${r.pid})` : ""}: ${(r.durationMs / 1e3).toFixed(1)} s captured` + (r.refreshRateHz ? `, display ${r.refreshRateHz} Hz (${ms(1e3 / r.refreshRateHz)} per frame)` : "")
|
|
3722
|
+
];
|
|
3723
|
+
const f = r.frames;
|
|
3724
|
+
if (f) {
|
|
3725
|
+
lines.push(
|
|
3726
|
+
` frames: ${f.total}, janky ${f.janky} (${(f.jankRate * 100).toFixed(1)}%), p50 ${ms(f.p50Ms)}, p95 ${ms(f.p95Ms)}, max ${ms(f.maxMs)}, ${f.missedVsyncs} vsync(s) missed`
|
|
3727
|
+
);
|
|
3728
|
+
if (f.longest.length) lines.push(` longest: ${f.longest.map((l) => `${ms(l.ms)} at +${(l.atMs / 1e3).toFixed(2)} s`).join(", ")}`);
|
|
3729
|
+
}
|
|
3730
|
+
if (r.startup) {
|
|
3731
|
+
const s = r.startup;
|
|
3732
|
+
lines.push(
|
|
3733
|
+
` startup: cold ${ms(s.coldMs)} (${s.from} \u2192 ${s.to})` + (s.processMs !== null ? `, process up after ${ms(s.processMs)}` : "") + (s.firstFrameMs !== null ? `, first frame ${ms(s.firstFrameMs)}` : "")
|
|
3734
|
+
);
|
|
3735
|
+
}
|
|
3736
|
+
if (r.mainThread) {
|
|
3737
|
+
lines.push(` main thread: ${ms(r.mainThread.busyMs)} in traced work${r.mainThread.cpuMs !== null ? `, ${ms(r.mainThread.cpuMs)} on CPU` : ""}`);
|
|
3738
|
+
}
|
|
3739
|
+
for (const s of r.scenes) {
|
|
3740
|
+
const parts = [
|
|
3741
|
+
s.maxFrameMs !== void 0 ? `max frame ${s.maxFrameMs} ms` : "",
|
|
3742
|
+
s.maxHitchMs !== void 0 ? `max hitch ${s.maxHitchMs} ms` : "",
|
|
3743
|
+
s.hitchMsPerS !== void 0 ? `hitch ${Math.round(s.hitchMsPerS * 10) / 10} ms/s` : "",
|
|
3744
|
+
s.responseMs !== void 0 ? `response ${s.responseMs} ms` : ""
|
|
3745
|
+
].filter(Boolean);
|
|
3746
|
+
lines.push(` RenderService ${s.scene}${s.page ? ` ${s.page}` : ""}: ${parts.join(", ")}`);
|
|
3747
|
+
}
|
|
3748
|
+
if (r.flow) {
|
|
3749
|
+
lines.push(
|
|
3750
|
+
r.flow.outcome === "passed" ? ` flow: passed (${r.flow.steps.length} step(s))` : ` flow: ${r.flow.outcome} at step ${r.flow.failedStep ?? "?"} (${r.flow.reason ?? r.flow.error ?? ""}): ${r.flow.message ?? ""}`
|
|
3751
|
+
);
|
|
3752
|
+
}
|
|
3753
|
+
if (r.hiperf) {
|
|
3754
|
+
if ("error" in r.hiperf) lines.push(` hiperf: ${r.hiperf.error}`);
|
|
3755
|
+
else {
|
|
3756
|
+
lines.push(` hiperf: ${r.hiperf.samples} sample(s)${r.hiperf.event ? ` of ${r.hiperf.event}` : ""}${r.hiperf.data ? `, data ${r.hiperf.data}` : ""}`);
|
|
3757
|
+
for (const t of r.hiperf.top.slice(0, 10)) lines.push(` ${t.percent.toFixed(2).padStart(6)}% ${t.func} (${t.dso})`);
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
if (r.trace) lines.push(` trace: ${r.trace}`);
|
|
3761
|
+
for (const n of r.notes) lines.push(` note: ${n}`);
|
|
3762
|
+
if (gate && !gate.passed) for (const fail of gate.failures) lines.push(`GATE FAILED: ${fail}`);
|
|
3763
|
+
return lines.join("\n");
|
|
3764
|
+
}
|
|
3765
|
+
function registerPerfCommand(program) {
|
|
3766
|
+
const perf = program.command("perf").description("Measure app performance on the device: frames, jank and startup from a hitrace capture.");
|
|
3767
|
+
perf.command("trace").description(
|
|
3768
|
+
"Capture a hitrace trace while the app cold-launches (--launch), a flow is replayed (--flow), or for a fixed time (--for); pull it and summarise it for the bundle: its UI frames (janky when one takes longer than the display's refresh interval, from its vsync to done), p50/p95/max frame time, cold-start time (launch request to the first frame composited), main-thread load and RenderService jank reports. Exits 0 when the capture completed, 1 when --max-jank-rate or --max-startup-ms is exceeded (or the flow diverged), 2 when it could not capture."
|
|
3769
|
+
).option("--flow <file>", "Replay this flow file (see `replay`) while tracing; its bundle is the default --bundle.").option("--launch", "Force-stop the app, start tracing, then cold-launch it and report its startup time.").option(
|
|
3770
|
+
"--for <ms>",
|
|
3771
|
+
`Without --flow: how long to capture (after the launch with --launch), default ${DEFAULT_PERF_FOR_MS}. With --flow: how long to keep capturing after it, default ${DEFAULT_PERF_FLOW_TAIL_MS}.`,
|
|
3772
|
+
positiveMs2
|
|
3773
|
+
).option("--bundle <bundle>", "The app to measure (default: the flow's bundle, else the project in the current directory).").option("--ability <name>", "Ability --launch starts (default: the main ability).").option("--out <file>", "Where to save the trace (default: a file in the system temp dir).").option("--categories <list>", `hitrace categories, comma-separated (default ${DEFAULT_TRACE_CATEGORIES.join(",")}; add sched for main-thread CPU time).`, list).option("--buffer-kb <kb>", "hitrace buffer size in KB.", positiveInt, DEFAULT_TRACE_BUFFER_KB).option("--hiperf", "Also sample the app's process with hiperf during the capture and list its top functions.").option("--max-jank-rate <r>", "Exit 1 when the share of janky frames is above this (0..1).", rate).option("--max-startup-ms <ms>", "Exit 1 when the cold start takes longer than this (needs --launch).", positiveMs2).option("--step-timeout <ms>", "With --flow: how long to wait for a step's selector and expect.", positiveMs2).option("--fallback", "With --flow: act on a step's fallback point when its selector is not found.").option("--no-wake", "Leave the screen as it is (by default, with --launch or --flow, it is woken, a credential-less lock screen swiped away, and kept on).").option("--device <serial>", "Target device serial.").option("--json", "Print the report as JSON: ok, bundle, durationMs, trace, refreshRateHz, frames, startup, flow, pid, mainThread, scenes, hiperf, notes, gate, error.").action(async (opts) => {
|
|
3774
|
+
const { config, logger } = getRuntime();
|
|
3775
|
+
const thresholds = { maxJankRate: opts.maxJankRate, maxStartupMs: opts.maxStartupMs };
|
|
3776
|
+
let report;
|
|
3777
|
+
try {
|
|
3778
|
+
const flow = opts.flow ? readFlowFile(path17.resolve(opts.flow)) : void 0;
|
|
3779
|
+
report = await withInterrupt(logger, "stopping the trace", (abortSignal) => perfTrace({
|
|
3780
|
+
config,
|
|
3781
|
+
logger,
|
|
3782
|
+
abortSignal,
|
|
3783
|
+
deviceSerial: opts.device,
|
|
3784
|
+
bundle: opts.bundle ?? flow?.bundle ?? projectBundle(process.cwd()),
|
|
3785
|
+
ability: opts.ability,
|
|
3786
|
+
launch: opts.launch,
|
|
3787
|
+
flow,
|
|
3788
|
+
replay: {
|
|
3789
|
+
stepTimeoutMs: opts.stepTimeout,
|
|
3790
|
+
fallback: opts.fallback,
|
|
3791
|
+
onStep: opts.json ? void 0 : (s) => logger.info(`flow step ${s.index} ${s.action} ${s.ok ? "ok" : "FAILED"}`)
|
|
3792
|
+
},
|
|
3793
|
+
forMs: opts.for,
|
|
3794
|
+
out: opts.out ? path17.resolve(opts.out) : void 0,
|
|
3795
|
+
categories: opts.categories,
|
|
3796
|
+
bufferKb: opts.bufferKb,
|
|
3797
|
+
hiperf: opts.hiperf,
|
|
3798
|
+
wake: opts.wake
|
|
3799
|
+
}));
|
|
3800
|
+
} catch (err) {
|
|
3801
|
+
report = emptyReport(opts.bundle ?? "", err.message);
|
|
3802
|
+
}
|
|
3803
|
+
const gate = evaluatePerfGate(report, thresholds);
|
|
3804
|
+
if (opts.json) process.stdout.write(`${JSON.stringify({ ...report, gate }, null, 2)}
|
|
3805
|
+
`);
|
|
3806
|
+
else if (report.ok) process.stdout.write(`${formatPerfReport(report, gate)}
|
|
3807
|
+
`);
|
|
3808
|
+
if (report.error) logger.error(report.error);
|
|
3809
|
+
process.exitCode = perfExitCode(report, gate);
|
|
3810
|
+
});
|
|
3811
|
+
perf.command("summary <trace>").description(
|
|
3812
|
+
"Summarise a hitrace text trace taken earlier (by `perf trace --out`, or `hitrace -o` by hand) for a bundle, the same way `perf trace` does. Exit codes as `perf trace`."
|
|
3813
|
+
).option("--bundle <bundle>", "The app to summarise (default: the project in the current directory).").option("--launched", "The trace holds a cold launch: report startup and leave the first frame out of the frame stats.").option("--pid <pid>", "The app's pid, when the trace holds several of its processes.", positiveInt).option("--refresh-rate <hz>", "Refresh rate to use when the trace has no vsync period.", positiveNumber).option("--max-jank-rate <r>", "Exit 1 when the share of janky frames is above this (0..1).", rate).option("--max-startup-ms <ms>", "Exit 1 when the cold start takes longer than this.", positiveMs2).option("--json", "Print the summary as JSON.").action(async (trace, opts) => {
|
|
3814
|
+
const { logger } = getRuntime();
|
|
3815
|
+
const bundle = opts.bundle ?? projectBundle(process.cwd());
|
|
3816
|
+
const file = path17.resolve(trace);
|
|
3817
|
+
let report;
|
|
3818
|
+
if (!bundle) report = emptyReport("", "No bundle to summarise: pass --bundle.");
|
|
3819
|
+
else {
|
|
3820
|
+
try {
|
|
3821
|
+
const model = await parseTraceFile(file, { keepSlice: perfSliceFilter });
|
|
3822
|
+
const s = summarizeTrace(model, { bundle, launched: opts.launched, pid: opts.pid, refreshRateHz: opts.refreshRate });
|
|
3823
|
+
report = { ...emptyReport(bundle), ...s, ok: true, trace: file };
|
|
3824
|
+
} catch (err) {
|
|
3825
|
+
report = emptyReport(bundle, `Could not read ${file}: ${err.message}`);
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
const gate = evaluatePerfGate(report, { maxJankRate: opts.maxJankRate, maxStartupMs: opts.maxStartupMs });
|
|
3829
|
+
if (opts.json) process.stdout.write(`${JSON.stringify({ ...report, gate }, null, 2)}
|
|
3830
|
+
`);
|
|
3831
|
+
else if (report.ok) process.stdout.write(`${formatPerfReport(report, gate)}
|
|
3832
|
+
`);
|
|
3833
|
+
if (report.error) logger.error(report.error);
|
|
3834
|
+
process.exitCode = perfExitCode(report, gate);
|
|
3835
|
+
});
|
|
3836
|
+
}
|
|
3837
|
+
function emptyReport(bundle, error) {
|
|
3838
|
+
return {
|
|
3839
|
+
ok: false,
|
|
3840
|
+
bundle,
|
|
3841
|
+
durationMs: 0,
|
|
3842
|
+
trace: null,
|
|
3843
|
+
refreshRateHz: null,
|
|
3844
|
+
frames: null,
|
|
3845
|
+
startup: null,
|
|
3846
|
+
flow: null,
|
|
3847
|
+
pid: null,
|
|
3848
|
+
mainThread: null,
|
|
3849
|
+
scenes: [],
|
|
3850
|
+
categories: [],
|
|
3851
|
+
notes: [],
|
|
3852
|
+
...error ? { error } : {}
|
|
3853
|
+
};
|
|
3854
|
+
}
|
|
3855
|
+
|
|
3856
|
+
// src/mcp/target.ts
|
|
3857
|
+
function targetProperties(verb, opts = {}) {
|
|
3858
|
+
const props = {
|
|
3859
|
+
target: {
|
|
3860
|
+
type: "string",
|
|
3861
|
+
description: `${verb} the element whose text, description, or placeholder matches (case-insensitive; exact matches win, else substring). Covered elements are skipped.`
|
|
3862
|
+
},
|
|
3863
|
+
targetId: { type: "string", description: "Match the element by its id or key." },
|
|
3864
|
+
targetType: { type: "string", description: "Restrict the match to a component type (e.g. Button, TextInput)." },
|
|
3865
|
+
selector: {
|
|
3866
|
+
type: "string",
|
|
3867
|
+
description: 'Instead of target*: an exact selector. Attributes as comma-separated key<op>value clauses (keys text, desc, hint, id, key, type, enabled, focused, checked, selected, visible, clickable, scrollable; ops = *= ^= $= /=regex) with an optional [n] index, e.g. "type=Button,text*=Sign[1]"; or "xpath=//Button[@text=\\"OK\\"]".'
|
|
3868
|
+
},
|
|
3869
|
+
window: windowProperty
|
|
3870
|
+
};
|
|
3871
|
+
if (opts.index !== false) {
|
|
3872
|
+
props.targetIndex = { type: "integer", minimum: 0, description: "Pick the n-th (0-based) of several matching elements." };
|
|
3873
|
+
}
|
|
3874
|
+
return props;
|
|
3875
|
+
}
|
|
3876
|
+
var windowProperty = {
|
|
3877
|
+
type: "integer",
|
|
3878
|
+
minimum: 0,
|
|
3879
|
+
description: "Only this window (an id from `dump windows`, or the [window <id> ...] header of a flat dump)."
|
|
3880
|
+
};
|
|
3881
|
+
function selectorFromArgs(args) {
|
|
3882
|
+
return selectorFrom({
|
|
3883
|
+
target: args.target,
|
|
3884
|
+
targetId: args.targetId,
|
|
3885
|
+
targetType: args.targetType,
|
|
3886
|
+
targetIndex: args.targetIndex === void 0 ? void 0 : String(args.targetIndex),
|
|
3887
|
+
selector: args.selector
|
|
3888
|
+
});
|
|
3889
|
+
}
|
|
3890
|
+
function targetIndexFromArgs(args) {
|
|
3891
|
+
return args.targetIndex;
|
|
3892
|
+
}
|
|
3893
|
+
function windowFromArgs(args) {
|
|
3894
|
+
return windowIdFrom({ window: args.window === void 0 ? void 0 : String(args.window) });
|
|
3895
|
+
}
|
|
3896
|
+
|
|
3897
|
+
// src/mcp/tools.ts
|
|
3898
|
+
var str = (description, extra = {}) => ({ type: "string", description, ...extra });
|
|
3899
|
+
var bool = (description, dflt) => ({
|
|
3900
|
+
type: "boolean",
|
|
3901
|
+
description,
|
|
3902
|
+
...dflt !== void 0 ? { default: dflt } : {}
|
|
3903
|
+
});
|
|
3904
|
+
var num2 = (description, extra = {}) => ({ type: "number", description, ...extra });
|
|
3905
|
+
var int = (description, extra = {}) => ({ type: "integer", description, ...extra });
|
|
3906
|
+
var PROJECT = {
|
|
3907
|
+
project: str("Project directory; a relative path resolves against the server's --project (default: the server's --project).")
|
|
3908
|
+
};
|
|
3909
|
+
var DEVICE = {
|
|
3910
|
+
device: str("Target device serial (default: the server's --device, else the only connected device).")
|
|
3911
|
+
};
|
|
3912
|
+
function object(properties, required = []) {
|
|
3913
|
+
return { type: "object", properties, ...required.length ? { required } : {}, additionalProperties: false };
|
|
3914
|
+
}
|
|
3915
|
+
function projectOf(a, ctx) {
|
|
3916
|
+
return typeof a.project === "string" ? path18.resolve(ctx.projectDir, a.project) : ctx.projectDir;
|
|
3917
|
+
}
|
|
3918
|
+
function deviceOf(a, ctx) {
|
|
3919
|
+
return a.device ?? ctx.deviceSerial;
|
|
3920
|
+
}
|
|
3921
|
+
function pathArg(a, key, ctx) {
|
|
3922
|
+
const v = a[key];
|
|
3923
|
+
return typeof v === "string" ? path18.resolve(projectOf(a, ctx), v) : void 0;
|
|
3924
|
+
}
|
|
3925
|
+
function jsonResult(json, isError = false, extra = []) {
|
|
3926
|
+
return {
|
|
3927
|
+
content: [{ type: "text", text: JSON.stringify(json) }, ...extra],
|
|
3928
|
+
structuredContent: json,
|
|
3929
|
+
...isError ? { isError: true } : {}
|
|
3930
|
+
};
|
|
3931
|
+
}
|
|
3932
|
+
function message(err) {
|
|
3933
|
+
return err instanceof Error ? err.message : String(err);
|
|
3934
|
+
}
|
|
3935
|
+
function jpeg(buf) {
|
|
3936
|
+
return { type: "image", data: buf.toString("base64"), mimeType: "image/jpeg" };
|
|
3937
|
+
}
|
|
3938
|
+
function rt() {
|
|
3939
|
+
const { config, logger } = getRuntime();
|
|
3940
|
+
return { config, logger };
|
|
3941
|
+
}
|
|
3942
|
+
var SMOKE = {
|
|
3943
|
+
smoke: bool(
|
|
3944
|
+
"After launch, wait for the process, take a screenshot after smokeDelay, and fail (isError) when the process died or the screen is blank; a crash is reported from the faultlog.",
|
|
3945
|
+
false
|
|
3946
|
+
),
|
|
3947
|
+
smokeDelay: int("With smoke: wait between the process appearing and the screenshot, ms.", {
|
|
3948
|
+
minimum: 0,
|
|
3949
|
+
default: DEFAULT_SMOKE_DELAY_MS2
|
|
3950
|
+
}),
|
|
3951
|
+
smokeScreenshot: str("With smoke: where to write the screenshot (default: a file in the system temp dir).")
|
|
3952
|
+
};
|
|
3953
|
+
var INPUT_TYPES2 = [
|
|
3954
|
+
"click",
|
|
3955
|
+
"doubleClick",
|
|
3956
|
+
"longClick",
|
|
3957
|
+
"swipe",
|
|
3958
|
+
"drag",
|
|
3959
|
+
"fling",
|
|
3960
|
+
"keyEvent",
|
|
3961
|
+
"inputText",
|
|
3962
|
+
"clearText",
|
|
3963
|
+
"hideKeyboard"
|
|
3964
|
+
];
|
|
3965
|
+
var build = {
|
|
3966
|
+
name: "build",
|
|
3967
|
+
title: "Build the app",
|
|
3968
|
+
description: "Build an OpenHarmony or HarmonyOS app via hvigorw (`oniro-app build --json`). Returns ok, exitCode, durationMs, failedTask, diagnostics (severity, source, code, rule, message, file, line, column, task), summary, discoveredHaps, warnings, error and rawTail. An OpenHarmony project fails early, naming the release to install, when the SDK for its compileSdkVersion is missing. isError when the build fails.",
|
|
3969
|
+
inputSchema: object({
|
|
3970
|
+
...PROJECT,
|
|
3971
|
+
product: str("hvigor product name.", { default: "default" }),
|
|
3972
|
+
module: str("Restrict the build to a specific module."),
|
|
3973
|
+
mode: str("hvigor build mode (e.g. release, debug)."),
|
|
3974
|
+
task: str("hvigor task to run.", { default: "assembleHap" }),
|
|
3975
|
+
deps: bool("Run `ohpm install --all` when oh_modules/ is missing (false = --no-deps).", true),
|
|
3976
|
+
parallel: bool("Build modules in parallel (false = --no-parallel).", true),
|
|
3977
|
+
installSdk: bool("Install the OpenHarmony SDK the project compiles against when it is missing, then build.", false)
|
|
3978
|
+
}),
|
|
3979
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
3980
|
+
changesDevice: false,
|
|
3981
|
+
async call(a, ctx) {
|
|
3982
|
+
try {
|
|
3983
|
+
const report = await runBuild(projectOf(a, ctx), {
|
|
3984
|
+
product: a.product,
|
|
3985
|
+
module: a.module,
|
|
3986
|
+
mode: a.mode,
|
|
3987
|
+
task: a.task,
|
|
3988
|
+
deps: a.deps,
|
|
3989
|
+
parallel: a.parallel,
|
|
3990
|
+
installSdk: a.installSdk
|
|
3991
|
+
});
|
|
3992
|
+
return jsonResult(report, !report.ok);
|
|
3993
|
+
} catch (err) {
|
|
3994
|
+
return jsonResult({ ok: false, exitCode: 1, error: message(err), diagnostics: [] }, true);
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
};
|
|
3998
|
+
var checkArktsTool = {
|
|
3999
|
+
name: "check_arkts",
|
|
4000
|
+
title: "Check ArkTS",
|
|
4001
|
+
description: "Type-check and lint ArkTS with the SDK's own ArkTS checker (what the build's CompileArkTS runs), plus the ArkUI component-model and project-config rules the build enforces later (V1/V2 decorators, @Entry root, route maps, resources, permissions), in seconds and without a build (`oniro-app check arkts --json`). Returns ok, completed, diagnostics (severity, source, rule, message, file, line, column), fixed, alsoModified, summary, sdk, durationMs, error. isError when it found an error or could not run (completed: false means nothing is known).",
|
|
4002
|
+
inputSchema: object({
|
|
4003
|
+
...PROJECT,
|
|
4004
|
+
files: {
|
|
4005
|
+
type: "array",
|
|
4006
|
+
items: { type: "string" },
|
|
4007
|
+
minItems: 1,
|
|
4008
|
+
description: "Check only these .ets files (project-wide rules such as main_pages.json and module.json5 are then skipped)."
|
|
4009
|
+
},
|
|
4010
|
+
fix: bool(
|
|
4011
|
+
"Apply the fixes the checker can verify (the compiler's `Did you mean` suggestions, import paths, a missing `export`, the @Component/@ComponentV2 decorator), re-check, and revert any fix that adds an error.",
|
|
4012
|
+
false
|
|
4013
|
+
),
|
|
4014
|
+
sdk: str("SDK directory to check against (holding ets/), instead of the one compileSdkVersion names.")
|
|
4015
|
+
}),
|
|
4016
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
4017
|
+
changesDevice: false,
|
|
4018
|
+
async call(a, ctx) {
|
|
4019
|
+
const report = await arktsCheckOrError(projectOf(a, ctx), {
|
|
4020
|
+
files: a.files,
|
|
4021
|
+
fix: a.fix,
|
|
4022
|
+
sdk: pathArg(a, "sdk", ctx)
|
|
4023
|
+
});
|
|
4024
|
+
return jsonResult(report, arktsCheckExitCode(report) !== 0);
|
|
4025
|
+
}
|
|
4026
|
+
};
|
|
4027
|
+
var test = {
|
|
4028
|
+
name: "test",
|
|
4029
|
+
title: "Run the ohosTest suite",
|
|
4030
|
+
description: "Build a module's main and ohosTest HAPs, install both on the device, run the suite with `aa test` (Hypium) and report each case (`oniro-app test --json`). Returns ok, outcome, tests (suite, name, status, statusCode, durationMs, message, stack), summary, resultCode, haps, log, error. isError when a case failed or the run did not complete. Installs on the device; the test module is left installed.",
|
|
4031
|
+
inputSchema: object({
|
|
4032
|
+
...PROJECT,
|
|
4033
|
+
module: str("Module whose src/ohosTest suite runs.", { default: "entry" }),
|
|
4034
|
+
product: str("hvigor product name.", { default: "default" }),
|
|
4035
|
+
class: str("Run only these cases: `Suite`, `Suite#case`, or several joined by commas."),
|
|
4036
|
+
timeout: int("Per-case timeout passed to the runner, ms.", { minimum: 1, default: 15e3 }),
|
|
4037
|
+
runTimeout: int("Wall-clock limit for the whole run, ms; aa test can hang when the app dies.", { minimum: 1, default: 6e5 }),
|
|
4038
|
+
build: bool("Build the HAPs first (false = --no-build: use the HAPs already built).", true),
|
|
4039
|
+
wake: bool(
|
|
4040
|
+
"Turn the screen on, swipe a credential-less lock screen away and keep it on during the run (false = --no-wake).",
|
|
4041
|
+
true
|
|
4042
|
+
),
|
|
4043
|
+
mode: str("hvigor build mode (e.g. debug)."),
|
|
4044
|
+
deps: bool("Run `ohpm install --all` when oh_modules/ is missing (false = --no-deps).", true),
|
|
4045
|
+
...DEVICE
|
|
4046
|
+
}),
|
|
4047
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
4048
|
+
changesDevice: true,
|
|
4049
|
+
async call(a, ctx) {
|
|
4050
|
+
const { config, logger } = rt();
|
|
4051
|
+
const report = await runTests2({
|
|
4052
|
+
config,
|
|
4053
|
+
projectDir: projectOf(a, ctx),
|
|
4054
|
+
module: a.module,
|
|
4055
|
+
product: a.product,
|
|
4056
|
+
testClass: a.class,
|
|
4057
|
+
caseTimeoutMs: a.timeout,
|
|
4058
|
+
runTimeoutMs: a.runTimeout,
|
|
4059
|
+
build: a.build,
|
|
4060
|
+
wakeScreen: a.wake,
|
|
4061
|
+
mode: a.mode,
|
|
4062
|
+
autoInstallDeps: a.deps,
|
|
4063
|
+
deviceSerial: deviceOf(a, ctx),
|
|
4064
|
+
logger
|
|
4065
|
+
});
|
|
4066
|
+
return jsonResult(report, testExitCode(report) !== 0);
|
|
4067
|
+
}
|
|
4068
|
+
};
|
|
4069
|
+
var apply = {
|
|
4070
|
+
name: "apply",
|
|
4071
|
+
title: "Install and apply a HAP",
|
|
4072
|
+
description: "Install the HAP of the project (or `hap`) and verify the running process took the change (`oniro-app app apply --json`). Refuses a HAP of another bundle. Handles sign-info mismatch, asset-cache invalidation, and persistent-bundle restart. Returns method, preInstallPid, postInstallPid, replaced, cacheCleared, and with smoke a `smoke` verdict (passed, failures, pid, alive, blankScreen, blankScore, crash, screenshot). isError when the smoke check fails.",
|
|
4073
|
+
inputSchema: object({
|
|
4074
|
+
...PROJECT,
|
|
4075
|
+
hap: str(
|
|
4076
|
+
"An explicit .hap file (relative to the project); its own bundleName is the one applied unless `bundle` is given."
|
|
4077
|
+
),
|
|
4078
|
+
bundle: str("Bundle name to apply changes to. Defaults to the bundleName in the .hap file, else in the project AppScope/app.json5."),
|
|
4079
|
+
module: str("Module to resolve the HAP from (multi-module projects)."),
|
|
4080
|
+
installedHap: str("Local path to the currently-installed HAP, to enable the asset-cache reboot."),
|
|
4081
|
+
system: bool("Treat as a persistent/system bundle (refuses uninstall on sign-info mismatch).", false),
|
|
4082
|
+
allowUninstall: bool("Permit uninstalling a system bundle on sign-info mismatch (dangerous).", false),
|
|
4083
|
+
...SMOKE,
|
|
4084
|
+
...DEVICE
|
|
4085
|
+
}),
|
|
4086
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
4087
|
+
changesDevice: true,
|
|
4088
|
+
async call(a, ctx) {
|
|
4089
|
+
const hap = pathArg(a, "hap", ctx);
|
|
4090
|
+
const report = await applyWithSmoke(rt(), {
|
|
4091
|
+
// A HAP goes in as the command's positional .hap, so its own bundle name is the one applied.
|
|
4092
|
+
target: hap ?? projectOf(a, ctx),
|
|
4093
|
+
bundle: a.bundle,
|
|
4094
|
+
module: a.module,
|
|
4095
|
+
installedHap: pathArg(a, "installedHap", ctx),
|
|
4096
|
+
system: a.system,
|
|
4097
|
+
allowUninstall: a.allowUninstall,
|
|
4098
|
+
smoke: a.smoke,
|
|
4099
|
+
smokeDelay: a.smokeDelay,
|
|
4100
|
+
smokeScreenshot: pathArg(a, "smokeScreenshot", ctx),
|
|
4101
|
+
device: deviceOf(a, ctx)
|
|
4102
|
+
});
|
|
4103
|
+
return jsonResult(report, report.smoke ? !report.smoke.passed : false);
|
|
4104
|
+
}
|
|
4105
|
+
};
|
|
4106
|
+
var launch = {
|
|
4107
|
+
name: "launch",
|
|
4108
|
+
title: "Launch the app",
|
|
4109
|
+
description: "Launch an app on the device (`oniro-app app launch --json`). Project mode (default) resolves the bundle and main ability from the project; bundle mode (`bundle`) launches an already-installed app. With smoke, waits for the process, takes a screenshot and reports whether it died, crashed (from the faultlog) or shows a blank screen. Returns bundle and, with smoke, `smoke` (passed, failures, pid, alive, blankScreen, blankScore, crash, screenshot). isError when the smoke check fails.",
|
|
4110
|
+
inputSchema: object({
|
|
4111
|
+
...PROJECT,
|
|
4112
|
+
bundle: str("Launch an already-installed app by bundle name (no project needed)."),
|
|
4113
|
+
module: str("Module folder name (project mode).", { default: "entry" }),
|
|
4114
|
+
ability: str(
|
|
4115
|
+
"Explicit ability to launch (defaults to the module mainElement in project mode, or the installed bundle main ability in bundle mode)."
|
|
4116
|
+
),
|
|
4117
|
+
...SMOKE,
|
|
4118
|
+
...DEVICE
|
|
4119
|
+
}),
|
|
4120
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
4121
|
+
changesDevice: true,
|
|
4122
|
+
async call(a, ctx) {
|
|
4123
|
+
const report = await launchWithSmoke(rt(), {
|
|
4124
|
+
projectDir: projectOf(a, ctx),
|
|
4125
|
+
bundle: a.bundle,
|
|
4126
|
+
module: a.module,
|
|
4127
|
+
ability: a.ability,
|
|
4128
|
+
smoke: a.smoke,
|
|
4129
|
+
smokeDelay: a.smokeDelay,
|
|
4130
|
+
smokeScreenshot: pathArg(a, "smokeScreenshot", ctx),
|
|
4131
|
+
device: deviceOf(a, ctx)
|
|
4132
|
+
});
|
|
4133
|
+
return jsonResult(report, report.smoke ? !report.smoke.passed : false);
|
|
4134
|
+
}
|
|
4135
|
+
};
|
|
4136
|
+
function readLayoutBaseline(file) {
|
|
4137
|
+
let text;
|
|
4138
|
+
try {
|
|
4139
|
+
text = fs9.readFileSync(file, "utf8");
|
|
4140
|
+
} catch (err) {
|
|
4141
|
+
throw new Error(`Cannot read the layout baseline ${file}: ${message(err)}`);
|
|
4142
|
+
}
|
|
4143
|
+
try {
|
|
4144
|
+
return parseLayoutBaseline2(JSON.parse(text));
|
|
4145
|
+
} catch (err) {
|
|
4146
|
+
throw new Error(`${file} is not a saved \`dump layout\` JSON: ${message(err)}`);
|
|
4147
|
+
}
|
|
4148
|
+
}
|
|
4149
|
+
var dumpLayoutTool = {
|
|
4150
|
+
name: "dump_layout",
|
|
4151
|
+
title: "Dump the UI layout",
|
|
4152
|
+
description: "Dump the on-screen UI layout (`oniro-app dump layout`). Default: the pruned JSON tree (large; prefer flat or summary). flat: one line per labelled or interactive element (type, label, center as 0-1 fractions, flags), as text. summary: per window element, content, clickable and text-node counts. diff: compare with a layout saved earlier (via `output`): matched elements with IoU, missing, extra, and a 0-1 similarity.",
|
|
4153
|
+
inputSchema: object({
|
|
4154
|
+
bundle: str("Filter the layout to a single window/bundle."),
|
|
4155
|
+
raw: bool("Return the unpruned layout tree.", false),
|
|
4156
|
+
flat: bool("One line per element (type, label, center as 0-1 fractions, flags) instead of a JSON tree.", false),
|
|
4157
|
+
summary: bool("Per window: element, content, clickable and text-node counts (JSON).", false),
|
|
4158
|
+
diff: str(
|
|
4159
|
+
"Compare with a saved `dump layout` JSON (relative to the project): matched elements with IoU, missing, extra, and a 0-1 similarity (JSON)."
|
|
4160
|
+
),
|
|
4161
|
+
output: str("Also write what is returned (the JSON, or the flat text) to this file, e.g. as a later diff baseline."),
|
|
4162
|
+
selector: str(
|
|
4163
|
+
"Return only the elements matching this selector (attribute clauses or xpath=..., as in input), numbered for its [n] suffix: { display, selector, matches }."
|
|
4164
|
+
),
|
|
4165
|
+
window: windowProperty,
|
|
4166
|
+
...PROJECT,
|
|
4167
|
+
...DEVICE
|
|
4168
|
+
}),
|
|
4169
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
4170
|
+
changesDevice: false,
|
|
4171
|
+
async call(a, ctx) {
|
|
4172
|
+
const { config, logger } = rt();
|
|
4173
|
+
if (a.raw && a.flat) throw new Error("raw and flat cannot be combined.");
|
|
4174
|
+
const modes = [a.flat && "flat", a.summary && "summary", a.diff !== void 0 && "diff"].filter(Boolean);
|
|
4175
|
+
if (modes.length > 1) throw new Error(`${modes.join(" and ")} cannot be combined.`);
|
|
4176
|
+
if (a.raw && modes.length > 0) throw new Error(`raw cannot be combined with ${modes[0]}.`);
|
|
4177
|
+
const selector = a.selector === void 0 ? void 0 : parseSelector2(a.selector);
|
|
4178
|
+
if (selector && (a.raw || a.summary || a.diff !== void 0)) {
|
|
4179
|
+
throw new Error("selector cannot be combined with raw, summary or diff.");
|
|
4180
|
+
}
|
|
4181
|
+
const windowId = windowFromArgs(a);
|
|
4182
|
+
const diffPath = pathArg(a, "diff", ctx);
|
|
4183
|
+
const baseline = diffPath !== void 0 ? readLayoutBaseline(diffPath) : void 0;
|
|
4184
|
+
const result = await dumpLayout3({
|
|
4185
|
+
config,
|
|
4186
|
+
bundleName: a.bundle,
|
|
4187
|
+
windowId,
|
|
4188
|
+
prune: !a.raw,
|
|
4189
|
+
detectKeyboard: !a.raw,
|
|
4190
|
+
deviceSerial: deviceOf(a, ctx),
|
|
4191
|
+
logger
|
|
4192
|
+
});
|
|
4193
|
+
const output = pathArg(a, "output", ctx);
|
|
4194
|
+
if (selector) {
|
|
4195
|
+
const matches = findLayoutNodes2(selectableTree2(result, windowId), selector);
|
|
4196
|
+
const json2 = { display: result.display, selector: selector.raw, matches };
|
|
4197
|
+
if (output) fs9.writeFileSync(output, `${JSON.stringify(json2, null, 2)}
|
|
4198
|
+
`);
|
|
4199
|
+
return jsonResult(json2);
|
|
4200
|
+
}
|
|
4201
|
+
if (a.flat) {
|
|
4202
|
+
const text = formatFlatLayout(result);
|
|
4203
|
+
if (output) fs9.writeFileSync(output, text);
|
|
4204
|
+
return { content: [{ type: "text", text }] };
|
|
4205
|
+
}
|
|
4206
|
+
let json;
|
|
4207
|
+
if (a.summary) json = summarizeLayout2(result);
|
|
4208
|
+
else if (baseline) json = diffLayouts2(baseline, result);
|
|
4209
|
+
else json = result;
|
|
4210
|
+
if (output) fs9.writeFileSync(output, `${JSON.stringify(json, null, 2)}
|
|
4211
|
+
`);
|
|
4212
|
+
return jsonResult(json);
|
|
4213
|
+
}
|
|
4214
|
+
};
|
|
4215
|
+
var input = {
|
|
4216
|
+
name: "input",
|
|
4217
|
+
title: "Send a UI input",
|
|
4218
|
+
description: "Inject a UI input event via uitest uiInput (`oniro-app input`). Position it with target/targetId/targetType (an on-screen element), fx/fy (0-1 fractions of the display, as in dump_layout flat and the screenshot grid), or x/y (pixels). Returns x, y (the resolved start point in pixels), target (the element it resolved to) and keyboardHidden.",
|
|
4219
|
+
inputSchema: object(
|
|
4220
|
+
{
|
|
4221
|
+
type: { type: "string", enum: INPUT_TYPES2, description: "Input type." },
|
|
4222
|
+
...targetProperties("Act on"),
|
|
4223
|
+
fx: num2("Start X as a 0-1 fraction of the display width.", { minimum: 0, maximum: 1 }),
|
|
4224
|
+
fy: num2("Start Y as a 0-1 fraction of the display height.", { minimum: 0, maximum: 1 }),
|
|
4225
|
+
fx2: num2("End X fraction (swipe/drag/fling).", { minimum: 0, maximum: 1 }),
|
|
4226
|
+
fy2: num2("End Y fraction (swipe/drag/fling).", { minimum: 0, maximum: 1 }),
|
|
4227
|
+
x: num2("X pixel (click/swipe start)."),
|
|
4228
|
+
y: num2("Y pixel (click/swipe start)."),
|
|
4229
|
+
x2: num2("End X pixel (swipe/drag/fling)."),
|
|
4230
|
+
y2: num2("End Y pixel (swipe/drag/fling)."),
|
|
4231
|
+
speed: num2("Velocity for swipe/drag/fling, px/s."),
|
|
4232
|
+
key: str(
|
|
4233
|
+
"Key for keyEvent: Back/Home/Power, a name (Delete, Enter, Tab, Escape, MoveHome, MoveEnd, \u2026), a letter, a KeyCode id, or a combo like Ctrl+A."
|
|
4234
|
+
),
|
|
4235
|
+
text: str("Text for inputText."),
|
|
4236
|
+
clear: bool("inputText: clear the field (Ctrl+A, Delete) before typing.", false),
|
|
4237
|
+
hideKeyboard: bool("Dismiss the soft keyboard afterwards, if it is up.", false),
|
|
4238
|
+
...DEVICE
|
|
4239
|
+
},
|
|
4240
|
+
["type"]
|
|
4241
|
+
),
|
|
4242
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
4243
|
+
changesDevice: true,
|
|
4244
|
+
async call(a, ctx) {
|
|
4245
|
+
const { config, logger } = rt();
|
|
4246
|
+
const result = await performInput2({
|
|
4247
|
+
config,
|
|
4248
|
+
type: a.type,
|
|
4249
|
+
target: selectorFromArgs(a),
|
|
4250
|
+
targetIndex: targetIndexFromArgs(a),
|
|
4251
|
+
windowId: windowFromArgs(a),
|
|
4252
|
+
fx: a.fx,
|
|
4253
|
+
fy: a.fy,
|
|
4254
|
+
fx2: a.fx2,
|
|
4255
|
+
fy2: a.fy2,
|
|
4256
|
+
pxX: a.x,
|
|
4257
|
+
pxY: a.y,
|
|
4258
|
+
pxX2: a.x2,
|
|
4259
|
+
pxY2: a.y2,
|
|
4260
|
+
speed: a.speed,
|
|
4261
|
+
key: a.key,
|
|
4262
|
+
text: a.text,
|
|
4263
|
+
clear: a.clear,
|
|
4264
|
+
hideKeyboard: a.hideKeyboard,
|
|
4265
|
+
deviceSerial: deviceOf(a, ctx),
|
|
4266
|
+
logger
|
|
4267
|
+
});
|
|
4268
|
+
return jsonResult(result);
|
|
4269
|
+
}
|
|
4270
|
+
};
|
|
4271
|
+
var DEFAULT_CONTACT_SHEET_FRAMES2 = 8;
|
|
4272
|
+
var TARGET_CROP_PAD2 = 0.02;
|
|
4273
|
+
var screenshot = {
|
|
4274
|
+
name: "screenshot",
|
|
4275
|
+
title: "Take a screenshot",
|
|
4276
|
+
description: "Capture the screen and return it as an image, downscaled so its longest side is at most maxDim (`oniro-app screenshot --max-dim`). grid: overlay a 10x10 grid (axes 0.0-1.0) for picking fx/fy tap coordinates. crop / target*: a full-resolution crop of one region or element, for checking detail cheaply. contactSheet: capture a burst and tile it into one image with per-frame change diffs. diff: compare the screen with a baseline JPEG (pHash/dHash distance, pixel-change ratio, mean change). output: also write the image to a file (with diff: the full-resolution capture, usable as a later baseline).",
|
|
4277
|
+
inputSchema: object({
|
|
4278
|
+
output: str("Also write the image to this file (relative to the project)."),
|
|
4279
|
+
grid: bool("Overlay a 10x10 coordinate grid.", false),
|
|
4280
|
+
maxDim: int("Longest-side cap of the returned image, px.", { minimum: 256, maximum: 4096, default: 1024 }),
|
|
4281
|
+
crop: str('Crop to this region, given as 0-1 fractions of the screen: "x1,y1,x2,y2".'),
|
|
4282
|
+
...targetProperties("Crop to"),
|
|
4283
|
+
contactSheet: bool("Capture a burst and composite it into one tiled contact sheet.", false),
|
|
4284
|
+
burst: int("With contactSheet: the number of frames.", { minimum: 1, default: DEFAULT_CONTACT_SHEET_FRAMES2 }),
|
|
4285
|
+
interval: int("With contactSheet: delay between frames, ms.", { minimum: 0, default: 50 }),
|
|
4286
|
+
diff: str(
|
|
4287
|
+
"Compare the screen with this baseline JPEG (relative to the project): pHash and dHash distance, pixel-change ratio, mean change."
|
|
4288
|
+
),
|
|
4289
|
+
...PROJECT,
|
|
4290
|
+
...DEVICE
|
|
4291
|
+
}),
|
|
4292
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
4293
|
+
changesDevice: false,
|
|
4294
|
+
async call(a, ctx) {
|
|
4295
|
+
const { config, logger } = rt();
|
|
4296
|
+
const deviceSerial = deviceOf(a, ctx);
|
|
4297
|
+
const maxDim = a.maxDim;
|
|
4298
|
+
const output = pathArg(a, "output", ctx);
|
|
4299
|
+
const selector = selectorFromArgs(a);
|
|
4300
|
+
const windowId = windowFromArgs(a);
|
|
4301
|
+
if (a.diff !== void 0) {
|
|
4302
|
+
if (a.crop !== void 0 || selector || windowId !== void 0 || a.grid || a.contactSheet) {
|
|
4303
|
+
throw new Error("diff cannot be combined with crop, target* / selector / window, grid, or contactSheet.");
|
|
4304
|
+
}
|
|
4305
|
+
const baselinePath = pathArg(a, "diff", ctx);
|
|
4306
|
+
let baseline;
|
|
4307
|
+
try {
|
|
4308
|
+
baseline = fs9.readFileSync(baselinePath);
|
|
4309
|
+
} catch (err) {
|
|
4310
|
+
throw new Error(`Cannot read the baseline ${baselinePath}: ${message(err)}`);
|
|
4311
|
+
}
|
|
4312
|
+
const shot2 = await takeScreenshot2({ config, deviceSerial, logger });
|
|
4313
|
+
if (output) fs9.writeFileSync(output, shot2.pixels);
|
|
4314
|
+
const diff = await diffScreenshots(baseline, shot2.pixels);
|
|
4315
|
+
const shown = await renderWithGrid(shot2.pixels, { maxDim });
|
|
4316
|
+
return jsonResult({ baseline: baselinePath, current: output ?? null, ...diff }, false, [jpeg(shown.buf)]);
|
|
4317
|
+
}
|
|
4318
|
+
if (a.crop !== void 0 || selector || windowId !== void 0) {
|
|
4319
|
+
if (a.crop !== void 0 && (selector || windowId !== void 0)) {
|
|
4320
|
+
throw new Error("Use either crop or target* / selector / window, not both.");
|
|
4321
|
+
}
|
|
4322
|
+
if (a.grid || a.contactSheet) throw new Error("crop / target cannot be combined with grid or contactSheet.");
|
|
4323
|
+
let rect;
|
|
4324
|
+
let label;
|
|
4325
|
+
if (selector) {
|
|
4326
|
+
const located = await locateTarget2({
|
|
4327
|
+
config,
|
|
4328
|
+
selector,
|
|
4329
|
+
index: targetIndexFromArgs(a),
|
|
4330
|
+
windowId,
|
|
4331
|
+
hideKeyboard: false,
|
|
4332
|
+
deviceSerial,
|
|
4333
|
+
logger
|
|
4334
|
+
});
|
|
4335
|
+
if (!located.node.b) throw new Error(`${describeLayoutNode4(located.node)} has no bounds to crop to.`);
|
|
4336
|
+
rect = padCropRect(located.node.b, TARGET_CROP_PAD2);
|
|
4337
|
+
label = describeLayoutNode4(located.node);
|
|
4338
|
+
} else if (windowId !== void 0) {
|
|
4339
|
+
const { tree } = await dumpLayout3({ config, windowId, deviceSerial, logger });
|
|
4340
|
+
const win = listLayoutWindows2(tree).find((w) => w.windowId === windowId);
|
|
4341
|
+
if (!win?.b) throw new Error(`Window ${windowId} has no bounds to crop to.`);
|
|
4342
|
+
rect = win.b;
|
|
4343
|
+
label = `window ${windowId}${win.bundle ? ` (${win.bundle})` : ""}`;
|
|
4344
|
+
} else {
|
|
4345
|
+
rect = parseCropRect(a.crop);
|
|
4346
|
+
label = rect.map((n) => n.toFixed(3)).join(",");
|
|
4347
|
+
}
|
|
4348
|
+
const shot2 = await takeScreenshot2({ config, deviceSerial, logger });
|
|
4349
|
+
const cropped = await cropImage(shot2.pixels, rect, { maxDim });
|
|
4350
|
+
if (output) fs9.writeFileSync(output, cropped.buf);
|
|
4351
|
+
const { buf: buf2, ...dims2 } = cropped;
|
|
4352
|
+
return jsonResult({ output: output ?? null, region: label, ...dims2 }, false, [jpeg(buf2)]);
|
|
4353
|
+
}
|
|
4354
|
+
if (a.contactSheet) {
|
|
4355
|
+
const intervalMs = a.interval;
|
|
4356
|
+
const frames = await captureBurst2({ config, count: a.burst, intervalMs, deviceSerial, logger });
|
|
4357
|
+
const diffs = await frameDiffs(frames);
|
|
4358
|
+
const sheet = await buildContactSheet(frames, { maxDim, diffs });
|
|
4359
|
+
if (output) fs9.writeFileSync(output, sheet.buf);
|
|
4360
|
+
const json = {
|
|
4361
|
+
sheet: output ?? null,
|
|
4362
|
+
frameCount: frames.length,
|
|
4363
|
+
cols: sheet.cols,
|
|
4364
|
+
rows: sheet.rows,
|
|
4365
|
+
intervalMs,
|
|
4366
|
+
note: "t_ms is nominal (idx*interval); diffVsPrev is 0..1 mean greyscale diff vs the previous frame.",
|
|
4367
|
+
frames: frames.map((_, i) => ({ idx: i, t_ms: i * intervalMs, diffVsPrev: diffs[i] ?? 0 }))
|
|
4368
|
+
};
|
|
4369
|
+
return jsonResult(json, false, [jpeg(sheet.buf)]);
|
|
4370
|
+
}
|
|
4371
|
+
const shot = await takeScreenshot2({ config, deviceSerial, logger });
|
|
4372
|
+
const rendered = await renderWithGrid(shot.pixels, { maxDim, grid: !!a.grid });
|
|
4373
|
+
if (output) fs9.writeFileSync(output, rendered.buf);
|
|
4374
|
+
const { buf, ...dims } = rendered;
|
|
4375
|
+
return jsonResult({ output: output ?? null, grid: !!a.grid, ...dims }, false, [jpeg(buf)]);
|
|
4376
|
+
}
|
|
4377
|
+
};
|
|
4378
|
+
var wait = {
|
|
4379
|
+
name: "wait",
|
|
4380
|
+
title: "Wait for a device condition",
|
|
4381
|
+
description: "Wait for a device condition (`oniro-app wait --json`): a hilog line (log), boot (boot), a bundle to start (bundle), or an on-screen element (target / targetId / targetType or selector, optionally in one window, or its absence with gone). Returns the matched log entry or element, or {booted}, {gone}, {bundle, running}. isError on timeout.",
|
|
4382
|
+
inputSchema: object({
|
|
4383
|
+
log: str("Resolve when a hilog line (tag: message) logged after the call starts matches this regex."),
|
|
4384
|
+
since: str(
|
|
4385
|
+
'With log: also match lines logged up to N seconds before the call started, measured on the device clock; "all" matches everything still in the device log buffer.'
|
|
4386
|
+
),
|
|
4387
|
+
boot: bool("Resolve when the device is reachable (optionally also pidOf).", false),
|
|
4388
|
+
bundle: str("With log: filter to this bundle. Alone: wait until this bundle is running."),
|
|
4389
|
+
pidOf: str("With boot: also require this process to have a pid."),
|
|
4390
|
+
domain: str("With log: hilog domain filter (e.g. 0xD003900)."),
|
|
4391
|
+
...targetProperties("Resolve when", { index: false }),
|
|
4392
|
+
gone: bool("With target*: resolve when no uncovered element matches any more.", false),
|
|
4393
|
+
interval: int("With target*: poll interval, ms (each poll is a layout dump).", { minimum: 1, default: 500 }),
|
|
4394
|
+
timeout: int("Max wait, ms.", { minimum: 0, default: 3e4 }),
|
|
4395
|
+
...DEVICE
|
|
4396
|
+
}),
|
|
4397
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
4398
|
+
changesDevice: false,
|
|
4399
|
+
async call(a, ctx) {
|
|
4400
|
+
const { config, logger } = rt();
|
|
4401
|
+
const deviceSerial = deviceOf(a, ctx);
|
|
4402
|
+
const timeoutMs = a.timeout;
|
|
4403
|
+
const selector = selectorFromArgs(a);
|
|
4404
|
+
const windowId = windowFromArgs(a);
|
|
4405
|
+
if (windowId !== void 0 && !selector) throw new Error("window applies only to target* and selector.");
|
|
4406
|
+
if (a.since !== void 0 && !a.log) throw new Error("since applies only to log.");
|
|
4407
|
+
if (a.log) {
|
|
4408
|
+
const entry = await waitForLog2({
|
|
4409
|
+
config,
|
|
4410
|
+
pattern: new RegExp(a.log),
|
|
4411
|
+
timeoutMs,
|
|
4412
|
+
bundle: a.bundle,
|
|
4413
|
+
domain: a.domain,
|
|
4414
|
+
deviceSerial,
|
|
4415
|
+
lookbackMs: parseSince(a.since),
|
|
4416
|
+
logger
|
|
4417
|
+
});
|
|
4418
|
+
return jsonResult(entry);
|
|
4419
|
+
}
|
|
4420
|
+
if (a.boot) {
|
|
4421
|
+
await waitForBoot2({ config, untilPidOf: a.pidOf, timeoutMs, deviceSerial, logger });
|
|
4422
|
+
return jsonResult({ booted: true });
|
|
4423
|
+
}
|
|
4424
|
+
if (selector) {
|
|
4425
|
+
const node = await waitForTarget2({
|
|
4426
|
+
config,
|
|
4427
|
+
selector,
|
|
4428
|
+
gone: a.gone,
|
|
4429
|
+
windowId,
|
|
4430
|
+
timeoutMs,
|
|
4431
|
+
pollMs: a.interval,
|
|
4432
|
+
deviceSerial,
|
|
4433
|
+
logger
|
|
4434
|
+
});
|
|
4435
|
+
return jsonResult(node ? node : { gone: true });
|
|
4436
|
+
}
|
|
4437
|
+
if (a.bundle) {
|
|
4438
|
+
await waitForBundle2({ config, bundle: a.bundle, timeoutMs, deviceSerial, logger });
|
|
4439
|
+
return jsonResult({ bundle: a.bundle, running: true });
|
|
4440
|
+
}
|
|
4441
|
+
throw new Error("Specify one of log, boot, bundle, target (or targetId / targetType), or selector.");
|
|
4442
|
+
}
|
|
4443
|
+
};
|
|
4444
|
+
var logsCrash = {
|
|
4445
|
+
name: "logs_crash",
|
|
4446
|
+
title: "Explain the last crash",
|
|
4447
|
+
description: "Fetch the newest faultlog of an app from the device and say what happened (`oniro-app logs crash --json`): a native crash (cpp_crash), an uncaught ArkTS/JS error (js_crash), a freeze (app_freeze), or none. Returns crashed, kind, subtype, confidence, bundle, pid, time, reason, topFrame, projectFrame, file, line, column, jsError, stack, nativeFrames, jsFrames, binderChain, deadlocks, notes, path, rawPath; `{crashed: false, kind: null}` when there is no fault. Not an error when there was no crash: check `crashed`.",
|
|
4448
|
+
inputSchema: object({
|
|
4449
|
+
bundle: str("App to look for (default: the bundleName of the project, else any process)."),
|
|
4450
|
+
since: str("Only faults from the last N seconds (device clock), or since an ISO 8601 time."),
|
|
4451
|
+
wait: int(
|
|
4452
|
+
"Keep looking this long when there is no fault yet, ms; hiview writes the faultlog a few seconds after the process dies.",
|
|
4453
|
+
{ minimum: 0, default: 0 }
|
|
4454
|
+
),
|
|
4455
|
+
path: str("Analyse this local faultlog (or faultloggerd .json crash record) instead of fetching one."),
|
|
4456
|
+
output: str("Where to save the fetched faultlog.", { default: path18.join(os4.homedir(), ".oniro", "faultlogs") }),
|
|
4457
|
+
...PROJECT,
|
|
4458
|
+
...DEVICE
|
|
4459
|
+
}),
|
|
4460
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
4461
|
+
changesDevice: false,
|
|
4462
|
+
async call(a, ctx) {
|
|
4463
|
+
const since = parseCrashSince(a.since);
|
|
4464
|
+
const local = pathArg(a, "path", ctx);
|
|
4465
|
+
const bundle = a.bundle ?? (local ? void 0 : projectBundle(projectOf(a, ctx)));
|
|
4466
|
+
let verdict;
|
|
4467
|
+
if (local) {
|
|
4468
|
+
verdict = analyzeFaultLog2(fs9.readFileSync(local, "utf8"), { name: path18.basename(local), path: local, rawPath: local });
|
|
4469
|
+
} else {
|
|
4470
|
+
const { config, logger } = rt();
|
|
4471
|
+
verdict = await latestCrash3({
|
|
4472
|
+
config,
|
|
4473
|
+
bundle,
|
|
4474
|
+
since,
|
|
4475
|
+
waitMs: a.wait,
|
|
4476
|
+
deviceSerial: deviceOf(a, ctx),
|
|
4477
|
+
saveDir: pathArg(a, "output", ctx),
|
|
4478
|
+
logger
|
|
4479
|
+
});
|
|
4480
|
+
}
|
|
4481
|
+
const none = { crashed: false, kind: null, bundle: bundle ?? null, since: since instanceof Date ? since.toISOString() : since ?? null };
|
|
4482
|
+
return jsonResult(verdict ?? none);
|
|
4483
|
+
}
|
|
4484
|
+
};
|
|
4485
|
+
var perfTraceTool = {
|
|
4486
|
+
name: "perf_trace",
|
|
4487
|
+
title: "Measure frames, jank and startup",
|
|
4488
|
+
description: "Capture a hitrace trace while the app cold-launches (`launch`), a flow file is replayed (`flow`), or for a fixed time (`for`), and summarise it (`oniro-app perf trace --json`). Returns ok, bundle, durationMs, trace (local path), refreshRateHz, frames (total, janky, jankRate, p50Ms, p95Ms, maxMs, missedVsyncs, longest), startup (coldMs, firstFrameMs, processMs, from, to) or null, flow (the replay report) or null, pid, mainThread, scenes (RenderService's own jank reports), hiperf, notes, gate (passed, failures), error. isError when the capture failed or a threshold (maxJankRate, maxStartupMs) or the flow failed. Launching force-stops the app first.",
|
|
4489
|
+
inputSchema: object({
|
|
4490
|
+
bundle: str("The app to measure (default: the flow's bundle, else the bundleName of the project)."),
|
|
4491
|
+
launch: bool("Force-stop the app, start tracing, then cold-launch it and report its startup time.", false),
|
|
4492
|
+
flow: str("A flow file (as for `replay`) to replay while tracing; relative to the project."),
|
|
4493
|
+
for: int("Without flow: how long to capture (after the launch with launch), ms. With flow: how long to keep capturing after it.", {
|
|
4494
|
+
minimum: 1
|
|
4495
|
+
}),
|
|
4496
|
+
ability: str("Ability `launch` starts (default: the main ability)."),
|
|
4497
|
+
out: str("Where to save the trace (default: a file in the system temp dir)."),
|
|
4498
|
+
categories: { type: "array", items: { type: "string" }, description: `hitrace categories (default ${DEFAULT_TRACE_CATEGORIES2.join(", ")}).` },
|
|
4499
|
+
bufferKb: int("hitrace buffer size, KB.", { minimum: 1, default: DEFAULT_TRACE_BUFFER_KB2 }),
|
|
4500
|
+
hiperf: bool("Also sample the app's process with hiperf and list its top functions.", false),
|
|
4501
|
+
maxJankRate: num2("Fail (isError) when the share of janky frames is above this.", { minimum: 0, maximum: 1 }),
|
|
4502
|
+
maxStartupMs: int("Fail (isError) when the cold start takes longer than this, ms (needs launch).", { minimum: 1 }),
|
|
4503
|
+
stepTimeout: int("With flow: how long to wait for a step's selector and expect, ms.", { minimum: 1 }),
|
|
4504
|
+
fallback: bool("With flow: act on a step's fallback point when its selector is not found.", false),
|
|
4505
|
+
wake: bool("With launch or flow: wake the screen and swipe a credential-less lock screen away (false = --no-wake).", true),
|
|
4506
|
+
...PROJECT,
|
|
4507
|
+
...DEVICE
|
|
4508
|
+
}),
|
|
4509
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
4510
|
+
// It force-stops and launches the app, and a flow taps and swipes.
|
|
4511
|
+
changesDevice: true,
|
|
4512
|
+
async call(a, ctx) {
|
|
4513
|
+
const { config, logger } = rt();
|
|
4514
|
+
const flowFile = pathArg(a, "flow", ctx);
|
|
4515
|
+
const flow = flowFile ? readFlowFile2(flowFile) : void 0;
|
|
4516
|
+
const report = await perfTrace2({
|
|
4517
|
+
config,
|
|
4518
|
+
logger,
|
|
4519
|
+
deviceSerial: deviceOf(a, ctx),
|
|
4520
|
+
bundle: a.bundle ?? flow?.bundle ?? projectBundle(projectOf(a, ctx)),
|
|
4521
|
+
ability: a.ability,
|
|
4522
|
+
launch: a.launch,
|
|
4523
|
+
flow,
|
|
4524
|
+
replay: { stepTimeoutMs: a.stepTimeout, fallback: a.fallback },
|
|
4525
|
+
forMs: a.for,
|
|
4526
|
+
out: pathArg(a, "out", ctx),
|
|
4527
|
+
categories: a.categories,
|
|
4528
|
+
bufferKb: a.bufferKb,
|
|
4529
|
+
hiperf: a.hiperf,
|
|
4530
|
+
wake: a.wake
|
|
4531
|
+
});
|
|
4532
|
+
const gate = evaluatePerfGate2(report, { maxJankRate: a.maxJankRate, maxStartupMs: a.maxStartupMs });
|
|
4533
|
+
return jsonResult({ ...report, gate }, perfExitCode(report, gate) !== 0);
|
|
4534
|
+
}
|
|
4535
|
+
};
|
|
4536
|
+
var screens = {
|
|
4537
|
+
name: "screens",
|
|
4538
|
+
title: "Inventory pages and routes",
|
|
4539
|
+
description: "Static inventory of the pages and routes of a project, from its sources, without a build or a device (`oniro-app screens --json`): @Entry and NavDestination pages, the router/NavPathStack call sites between them, the content nodes each page renders, and placeholder markers (TODO/FIXME, placeholder-like text, empty build()). Returns pages (name, module, file, line, component, entry, kind, registered, contentNodes, routesOut), routes (from, to, file, line, via, dynamic), placeholders (file, line, text, kind), warnings, modules, projectDir.",
|
|
4540
|
+
inputSchema: object({ ...PROJECT }),
|
|
4541
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
4542
|
+
changesDevice: false,
|
|
4543
|
+
async call(a, ctx) {
|
|
4544
|
+
try {
|
|
4545
|
+
return jsonResult(inventoryScreens2({ projectDir: projectOf(a, ctx) }));
|
|
4546
|
+
} catch (err) {
|
|
4547
|
+
return jsonResult({ error: message(err) }, true);
|
|
4548
|
+
}
|
|
4549
|
+
}
|
|
4550
|
+
};
|
|
4551
|
+
var SDK_PROPS = {
|
|
4552
|
+
api: int(
|
|
4553
|
+
"The SDK API level to read. Default: the project's compileSdkVersion, else the newest installed SDK.",
|
|
4554
|
+
{ minimum: 1 }
|
|
4555
|
+
),
|
|
4556
|
+
sdk: str("An SDK API directory (holding ets/) to read instead."),
|
|
4557
|
+
project: str("The project whose compileSdkVersion picks the SDK (default: the server's --project)."),
|
|
4558
|
+
rebuild: bool("Rebuild the index even if the cached one is current.", false)
|
|
4559
|
+
};
|
|
4560
|
+
var API_KINDS = [
|
|
4561
|
+
"namespace",
|
|
4562
|
+
"class",
|
|
4563
|
+
"interface",
|
|
4564
|
+
"enum",
|
|
4565
|
+
"enumMember",
|
|
4566
|
+
"type",
|
|
4567
|
+
"function",
|
|
4568
|
+
"method",
|
|
4569
|
+
"constructor",
|
|
4570
|
+
"property",
|
|
4571
|
+
"variable",
|
|
4572
|
+
"component",
|
|
4573
|
+
"decorator",
|
|
4574
|
+
"macro",
|
|
4575
|
+
"typedef",
|
|
4576
|
+
"struct"
|
|
4577
|
+
];
|
|
4578
|
+
function sdkOpts(a, ctx) {
|
|
4579
|
+
return {
|
|
4580
|
+
api: a.api !== void 0 ? String(a.api) : void 0,
|
|
4581
|
+
sdk: pathArg(a, "sdk", ctx),
|
|
4582
|
+
project: projectOf(a, ctx),
|
|
4583
|
+
rebuild: a.rebuild
|
|
4584
|
+
};
|
|
4585
|
+
}
|
|
4586
|
+
var apiSearch = {
|
|
4587
|
+
name: "api_search",
|
|
4588
|
+
title: "Search the SDK API",
|
|
4589
|
+
description: "Ranked search of the SDK's declarations at one API level (`oniro-app api search --json`): the ArkTS API, the ArkUI components and decorators, and the Node-API C headers. Every word must match a symbol name (exact > prefix > camelCase part > substring) or its doc line; a dotted word matches qualified names (`router.pushUrl`). Returns sdk, query, total, hits (name, qualifiedName, kind, kit, import, module, since, deprecated, useInstead, syscap, systemApi, file, path, line, signature, doc, overloads, score). isError when nothing matches.",
|
|
4590
|
+
inputSchema: object(
|
|
4591
|
+
{
|
|
4592
|
+
query: str('The search words, e.g. "navigation push" or "router.pushUrl".'),
|
|
4593
|
+
kit: str("Only symbols this kit exports (`ArkUI` or `@kit.ArkUI`)."),
|
|
4594
|
+
kind: { type: "array", items: { type: "string", enum: API_KINDS }, description: "Only these kinds." },
|
|
4595
|
+
lang: { type: "string", enum: ["arkts", "c"], description: "Only `arkts` declarations or `c` (Node-API headers)." },
|
|
4596
|
+
limit: int("At most n hits.", { minimum: 1, default: 20 }),
|
|
4597
|
+
deprecated: bool("Include @deprecated symbols (false = --no-deprecated).", true),
|
|
4598
|
+
systemapi: bool("Include @systemapi symbols, for system apps only (false = --no-systemapi).", true),
|
|
4599
|
+
...SDK_PROPS
|
|
4600
|
+
},
|
|
4601
|
+
["query"]
|
|
4602
|
+
),
|
|
4603
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
4604
|
+
changesDevice: false,
|
|
4605
|
+
async call(a, ctx) {
|
|
4606
|
+
const run = runApiSearch(a.query, {
|
|
4607
|
+
...sdkOpts(a, ctx),
|
|
4608
|
+
kit: a.kit,
|
|
4609
|
+
kind: a.kind,
|
|
4610
|
+
lang: a.lang,
|
|
4611
|
+
limit: String(a.limit),
|
|
4612
|
+
deprecated: a.deprecated,
|
|
4613
|
+
systemapi: a.systemapi
|
|
4614
|
+
});
|
|
4615
|
+
return jsonResult(run.json, run.total === 0);
|
|
4616
|
+
}
|
|
4617
|
+
};
|
|
4618
|
+
var apiShow = {
|
|
4619
|
+
name: "api_show",
|
|
4620
|
+
title: "Look up one SDK symbol",
|
|
4621
|
+
description: "Exact lookup (`oniro-app api show --json`): is `symbol` declared at this API level? Takes a qualified name (`NavPathStack.pushPathByName`, `router.pushUrl`, `Column`, `@Local`, `napi_create_threadsafe_function`); when none matches, names that end with it. Returns sdk, symbol, found, entries (the api_search hit fields, every overload), and on a miss alsoIn ([{api, since}], the other installed SDKs that declare it) and suggestions. isError when it is not declared.",
|
|
4622
|
+
inputSchema: object(
|
|
4623
|
+
{
|
|
4624
|
+
symbol: str("The symbol, e.g. `NavPathStack.pushPathByName`."),
|
|
4625
|
+
kit: str("Only declarations this kit exports."),
|
|
4626
|
+
otherSdks: bool("On a miss, look in the other installed SDKs (false = --no-other-sdks).", true),
|
|
4627
|
+
...SDK_PROPS
|
|
4628
|
+
},
|
|
4629
|
+
["symbol"]
|
|
4630
|
+
),
|
|
4631
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
4632
|
+
changesDevice: false,
|
|
4633
|
+
async call(a, ctx) {
|
|
4634
|
+
const run = runApiShow(a.symbol, {
|
|
4635
|
+
...sdkOpts(a, ctx),
|
|
4636
|
+
kit: a.kit,
|
|
4637
|
+
otherSdks: a.otherSdks
|
|
4638
|
+
});
|
|
4639
|
+
return jsonResult(run.json, !run.found);
|
|
4640
|
+
}
|
|
4641
|
+
};
|
|
4642
|
+
var DOCS_PROPS = {
|
|
4643
|
+
api: int("The SDK API level whose release picks the docs (default: the project's compileSdkVersion, else the newest installed SDK)."),
|
|
4644
|
+
sdk: str("An SDK API directory (holding ets/) whose version picks the docs."),
|
|
4645
|
+
ref: str("A docs branch or tag (`OpenHarmony-6.1-Release`, `master`) instead of the SDK-chosen one."),
|
|
4646
|
+
...PROJECT
|
|
4647
|
+
};
|
|
4648
|
+
function docsOpts(a, ctx) {
|
|
4649
|
+
return {
|
|
4650
|
+
api: a.api !== void 0 ? String(a.api) : void 0,
|
|
4651
|
+
sdk: pathArg(a, "sdk", ctx),
|
|
4652
|
+
ref: a.ref,
|
|
4653
|
+
project: projectOf(a, ctx)
|
|
4654
|
+
};
|
|
4655
|
+
}
|
|
4656
|
+
var docsSearch = {
|
|
4657
|
+
name: "docs_search",
|
|
4658
|
+
title: "Search the OpenHarmony docs",
|
|
4659
|
+
description: "Ranked search of the OpenHarmony application-development documentation of the release matching the SDK (`oniro-app docs search --json`): usage guides (ArkUI components, navigation, state management, data storage, abilities), kit API prose, business error codes and platform behaviour, which the SDK typings behind api_search do not say. Documents containing every word rank by where the words occur (title, headings, path, body); each hit names the matching sections, to read with docs_show. Use domain words (a component, kit or decorator name, an error code), not sentences. Returns docs (ref, dir, tree, sdk), query, terms, total, hits (path, title, score, headings [{line, text}], snippet). isError when nothing matches, or when the docs are not synced (`oniro-app docs sync`).",
|
|
4660
|
+
inputSchema: object(
|
|
4661
|
+
{
|
|
4662
|
+
query: str('The search words, e.g. "LazyForEach reuse", "relationalStore insert", "14800011".'),
|
|
4663
|
+
limit: int("At most n hits.", { minimum: 1, maximum: 30, default: 8 }),
|
|
4664
|
+
section: str("Only documents under this subtree: `ui`, `database`, `reference/errorcodes`, `application-models`."),
|
|
4665
|
+
...DOCS_PROPS
|
|
4666
|
+
},
|
|
4667
|
+
["query"]
|
|
4668
|
+
),
|
|
4669
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
4670
|
+
changesDevice: false,
|
|
4671
|
+
async call(a, ctx) {
|
|
4672
|
+
try {
|
|
4673
|
+
const run = runDocsSearch(a.query, {
|
|
4674
|
+
...docsOpts(a, ctx),
|
|
4675
|
+
limit: a.limit !== void 0 ? String(a.limit) : void 0,
|
|
4676
|
+
section: a.section
|
|
4677
|
+
});
|
|
4678
|
+
return jsonResult(run.json, run.result.total === 0);
|
|
4679
|
+
} catch (err) {
|
|
4680
|
+
return errorResult(message(err));
|
|
4681
|
+
}
|
|
4682
|
+
}
|
|
4683
|
+
};
|
|
4684
|
+
var docsShow = {
|
|
4685
|
+
name: "docs_show",
|
|
4686
|
+
title: "Read one OpenHarmony doc",
|
|
4687
|
+
description: "One documentation file (a path docs_search returned, relative to en/application-dev), or one section of it by heading (`oniro-app docs show --json`). Images and inline HTML are dropped; a long document is cut at maxLines with the list of its sections, so ask for a heading rather than the whole file. Returns docs, found, path, text, truncated, lines, headings; isError with suggestions (close paths, or the headings when the heading did not match).",
|
|
4688
|
+
inputSchema: object(
|
|
4689
|
+
{
|
|
4690
|
+
path: str("The document path, e.g. `ui/arkts-navigation-jump.md`."),
|
|
4691
|
+
heading: str("A heading (or a distinctive part of it, case-insensitive): only that section."),
|
|
4692
|
+
maxLines: int("At most n lines.", { minimum: 20, maximum: 1200, default: 250 }),
|
|
4693
|
+
...DOCS_PROPS
|
|
4694
|
+
},
|
|
4695
|
+
["path"]
|
|
4696
|
+
),
|
|
4697
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
4698
|
+
changesDevice: false,
|
|
4699
|
+
async call(a, ctx) {
|
|
4700
|
+
try {
|
|
4701
|
+
const run = runDocsShow(a.path, {
|
|
4702
|
+
...docsOpts(a, ctx),
|
|
4703
|
+
heading: a.heading,
|
|
4704
|
+
lines: a.maxLines !== void 0 ? String(a.maxLines) : void 0
|
|
4705
|
+
});
|
|
4706
|
+
return jsonResult(run.json, !run.result.found);
|
|
4707
|
+
} catch (err) {
|
|
4708
|
+
return errorResult(message(err));
|
|
4709
|
+
}
|
|
4710
|
+
}
|
|
4711
|
+
};
|
|
4712
|
+
var doctor = {
|
|
4713
|
+
name: "doctor",
|
|
4714
|
+
title: "Check the toolchain",
|
|
4715
|
+
description: "Check that this machine can build, check, sign, install and test (`oniro-app doctor --json`): node, java, the SDK the project compiles with, the command-line tools, a `check arkts` self-test, hdc and a reachable device at the project's API, signing material, free disk space. Returns ok, checks (id, status ok/warn/fail/skip, detail, fix, fixed), project, versions. isError when a check fails.",
|
|
4716
|
+
inputSchema: object({
|
|
4717
|
+
project: str("Project directory (default: the server's --project when it holds build-profile.json5)."),
|
|
4718
|
+
fix: bool("Install the project's missing OpenHarmony SDK.", false),
|
|
4719
|
+
device: bool("Check the device (false = --no-device, for a machine that only builds).", true),
|
|
4720
|
+
serial: str("The device to check, when several are connected (default: the server's --device)."),
|
|
4721
|
+
arktsCheck: bool("Run the `check arkts` self-test (false = --no-arkts-check).", true)
|
|
4722
|
+
}),
|
|
4723
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
4724
|
+
changesDevice: false,
|
|
4725
|
+
async call(a, ctx) {
|
|
4726
|
+
const { config, logger, progress } = getRuntime();
|
|
4727
|
+
let dir;
|
|
4728
|
+
if (typeof a.project === "string") dir = projectOf(a, ctx);
|
|
4729
|
+
else if (fs9.existsSync(path18.join(ctx.projectDir, "build-profile.json5"))) dir = ctx.projectDir;
|
|
4730
|
+
const report = await runDoctor2({
|
|
4731
|
+
config,
|
|
4732
|
+
projectDir: dir,
|
|
4733
|
+
fix: a.fix,
|
|
4734
|
+
device: a.device,
|
|
4735
|
+
deviceSerial: a.serial ?? ctx.deviceSerial,
|
|
4736
|
+
arktsSelfTest: a.arktsCheck,
|
|
4737
|
+
logger,
|
|
4738
|
+
progress
|
|
4739
|
+
});
|
|
4740
|
+
return jsonResult(report, doctorExitCode(report) !== 0);
|
|
4741
|
+
}
|
|
4742
|
+
};
|
|
4743
|
+
var devices = {
|
|
4744
|
+
name: "devices",
|
|
4745
|
+
title: "List devices",
|
|
4746
|
+
description: "List the connected hdc devices (`oniro-app devices --json`), as `{devices: [{serial, connection, status}]}`. long: also ask each connected device for its OS family, version, API level, CPU ABI, device type and model (`info`).",
|
|
4747
|
+
inputSchema: object({
|
|
4748
|
+
long: bool(
|
|
4749
|
+
"Also ask each connected device for its OS family (HarmonyOS / OpenHarmony), version, API level, CPU ABI, device type and model.",
|
|
4750
|
+
false
|
|
4751
|
+
)
|
|
4752
|
+
}),
|
|
4753
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
4754
|
+
changesDevice: false,
|
|
4755
|
+
async call(a) {
|
|
4756
|
+
const long = a.long;
|
|
4757
|
+
const { devices: list2, infos } = await queryDevices(long);
|
|
4758
|
+
return jsonResult({ devices: devicesJson(list2, infos, long) });
|
|
4759
|
+
}
|
|
4760
|
+
};
|
|
4761
|
+
var ALL_TOOLS = [
|
|
4762
|
+
build,
|
|
4763
|
+
checkArktsTool,
|
|
4764
|
+
test,
|
|
4765
|
+
apply,
|
|
4766
|
+
launch,
|
|
4767
|
+
dumpLayoutTool,
|
|
4768
|
+
input,
|
|
4769
|
+
screenshot,
|
|
4770
|
+
wait,
|
|
4771
|
+
logsCrash,
|
|
4772
|
+
perfTraceTool,
|
|
4773
|
+
screens,
|
|
4774
|
+
apiSearch,
|
|
4775
|
+
apiShow,
|
|
4776
|
+
docsSearch,
|
|
4777
|
+
docsShow,
|
|
4778
|
+
doctor,
|
|
4779
|
+
devices
|
|
4780
|
+
];
|
|
4781
|
+
var TOOL_NAMES = ALL_TOOLS.map((t) => t.name);
|
|
4782
|
+
function selectTools(names, readOnly) {
|
|
4783
|
+
if (names) {
|
|
4784
|
+
const unknown = names.filter((n) => !TOOL_NAMES.includes(n));
|
|
4785
|
+
if (unknown.length) throw new Error(`Unknown tool${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")}. Tools: ${TOOL_NAMES.join(", ")}.`);
|
|
4786
|
+
}
|
|
4787
|
+
return ALL_TOOLS.filter((t) => (!names || names.includes(t.name)) && !(readOnly && t.changesDevice));
|
|
4788
|
+
}
|
|
4789
|
+
|
|
4790
|
+
// src/commands/serve.ts
|
|
4791
|
+
function parseToolList(raw) {
|
|
4792
|
+
if (raw === void 0) return void 0;
|
|
4793
|
+
const names = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
4794
|
+
if (!names.length) throw new OniroError6("--tools needs at least one tool name.");
|
|
4795
|
+
return names;
|
|
4796
|
+
}
|
|
4797
|
+
function instructions(ctx, toolCount) {
|
|
4798
|
+
return `oniro-app: build, check, test, deploy and inspect the OpenHarmony/HarmonyOS app in ${ctx.projectDir}${ctx.deviceSerial ? ` on device ${ctx.deviceSerial}` : ""} (${toolCount} tools). Each tool runs the matching \`oniro-app\` command and returns its \`--json\` object, as structured content and as JSON text; isError is true when the command would exit non-zero (a failed build, check, test or smoke check, a missing symbol) or could not run. Relative paths resolve against the project. A typical round: check_arkts on the changed files, build, apply with smoke, then screenshot and dump_layout (flat) to see the result, input to drive it, logs_crash when it died.`;
|
|
4799
|
+
}
|
|
4800
|
+
function registerServeCommand(program) {
|
|
4801
|
+
const serve = program.command("serve").description("Serve oniro-app to other programs.");
|
|
4802
|
+
serve.command("mcp").description(
|
|
4803
|
+
`A Model Context Protocol server on stdio (newline-delimited JSON-RPC 2.0) exposing the gate commands as typed tools for agents: ${TOOL_NAMES.join(", ")}. Each tool returns the JSON the command's --json prints. stdout carries only the protocol; logs go to stderr. Register it with Claude Code: \`claude mcp add oniro-app -- oniro-app serve mcp --project <dir>\`.`
|
|
4804
|
+
).allowExcessArguments(false).option("--project <dir>", "Project the tools work on unless a call names another (default: the current directory).").option("--device <serial>", "Device the tools target unless a call names another (default: the only connected one).").option("--tools <list>", `Expose only these tools, comma-separated (of ${TOOL_NAMES.join(", ")}).`).option("--read-only", "Leave out the tools that change the device: apply, launch, input, test.").action(async (opts) => {
|
|
4805
|
+
const { logger } = getRuntime();
|
|
4806
|
+
const projectDir = path19.resolve(opts.project ?? process.cwd());
|
|
4807
|
+
if (!fs10.existsSync(projectDir)) throw new OniroError6(`--project ${projectDir} does not exist.`);
|
|
4808
|
+
const tools = selectTools(parseToolList(opts.tools), !!opts.readOnly);
|
|
4809
|
+
if (!tools.length) throw new OniroError6("No tool left to serve: --read-only removed every tool --tools named.");
|
|
4810
|
+
const context = { projectDir, deviceSerial: opts.device };
|
|
4811
|
+
const server = new McpServer({
|
|
4812
|
+
name: "oniro-app",
|
|
4813
|
+
version: program.version() ?? "0.0.0",
|
|
4814
|
+
tools,
|
|
4815
|
+
context,
|
|
4816
|
+
instructions: instructions(context, tools.length),
|
|
4817
|
+
logger
|
|
4818
|
+
});
|
|
4819
|
+
const protocolWrite = process.stdout.write.bind(process.stdout);
|
|
4820
|
+
process.stdout.write = ((chunk, ...rest) => process.stderr.write(chunk, ...rest));
|
|
4821
|
+
process.stdout.on("error", () => process.exit(0));
|
|
4822
|
+
logger.info(`oniro-app MCP server on stdio: project ${projectDir}, tools ${tools.map((t) => t.name).join(", ")}`);
|
|
4823
|
+
await serveStdio(server, { write: (line) => void protocolWrite(line) });
|
|
4824
|
+
logger.info("stdin closed; MCP server exiting.");
|
|
4825
|
+
protocolWrite("", () => process.exit(0));
|
|
4826
|
+
});
|
|
4827
|
+
}
|
|
4828
|
+
|
|
4829
|
+
// src/commands/record.ts
|
|
4830
|
+
import { InvalidArgumentError as InvalidArgumentError6 } from "commander";
|
|
4831
|
+
import * as fs11 from "fs";
|
|
4832
|
+
import * as path20 from "path";
|
|
4833
|
+
import { formatFlow, recordFlow } from "@oniroproject/core";
|
|
4834
|
+
function positiveMs3(raw) {
|
|
4835
|
+
const n = Number(raw);
|
|
4836
|
+
if (!Number.isFinite(n) || n <= 0) throw new InvalidArgumentError6("Expected a positive number of milliseconds.");
|
|
4837
|
+
return Math.floor(n);
|
|
4838
|
+
}
|
|
4839
|
+
function registerRecordCommand(program) {
|
|
4840
|
+
program.command("record").description(
|
|
4841
|
+
"Record what is done on the device into a flow file for `replay`. Runs uitest's recorder (`uitest uiRecord record -l`), which logs every tap, swipe and key with the widget under the finger and dumps the layout after each one; each event becomes a step with the most specific stable selector for that widget in the layout on screen when it happened, a fallback point, and an `expect` element that appeared after it. Typing collapses into one inputText step per field. Stops on Ctrl+C or after --for."
|
|
4842
|
+
).requiredOption("--out <file>", "Where to write the flow (JSON).").option("--for <ms>", "Stop after this long instead of waiting for Ctrl+C.", positiveMs3).option("--bundle <bundle>", "The app being recorded (default: the foreground app when recording starts).").option("--no-wake", "Leave the screen as it is (by default it is turned on, a lock screen without a credential is swiped away, and it is kept on while recording).").option("--raw <dir>", "Also keep uitest's raw recording (events, layouts, touch times) in this directory, for bug reports.").option("--device <serial>", "Target device serial.").option("--json", "Print {out, steps, events, warnings} as JSON.").action(async (opts) => {
|
|
4843
|
+
const { config, logger } = getRuntime();
|
|
4844
|
+
await withInterrupt(logger, "stopping the recorder", async (abortSignal) => {
|
|
4845
|
+
const result = await recordFlow({
|
|
4846
|
+
config,
|
|
4847
|
+
durationMs: opts.for,
|
|
4848
|
+
abortSignal,
|
|
4849
|
+
bundle: opts.bundle,
|
|
4850
|
+
wake: opts.wake,
|
|
4851
|
+
rawDir: opts.raw,
|
|
4852
|
+
deviceSerial: opts.device,
|
|
4853
|
+
logger,
|
|
4854
|
+
onEvents: (n) => logger.info(`${n} event line(s) recorded`)
|
|
4855
|
+
});
|
|
4856
|
+
const out = path20.resolve(opts.out);
|
|
4857
|
+
fs11.mkdirSync(path20.dirname(out), { recursive: true });
|
|
4858
|
+
fs11.writeFileSync(out, formatFlow(result.flow));
|
|
4859
|
+
for (const w of result.warnings) logger.warn(w);
|
|
4860
|
+
if (opts.json) {
|
|
4861
|
+
process.stdout.write(
|
|
4862
|
+
`${JSON.stringify({ out, steps: result.flow.steps.length, events: result.events, warnings: result.warnings }, null, 2)}
|
|
4863
|
+
`
|
|
4864
|
+
);
|
|
4865
|
+
} else {
|
|
4866
|
+
process.stdout.write(`Wrote ${result.flow.steps.length} step(s) to ${out}.
|
|
4867
|
+
`);
|
|
4868
|
+
}
|
|
4869
|
+
});
|
|
4870
|
+
});
|
|
4871
|
+
}
|
|
4872
|
+
|
|
4873
|
+
// src/commands/replay.ts
|
|
4874
|
+
import { InvalidArgumentError as InvalidArgumentError7 } from "commander";
|
|
4875
|
+
import * as path21 from "path";
|
|
4876
|
+
import { readFlowFile as readFlowFile3, replayFlow } from "@oniroproject/core";
|
|
4877
|
+
function stepIndex(raw) {
|
|
4878
|
+
const n = Number(raw);
|
|
4879
|
+
if (!Number.isInteger(n) || n < 0) throw new InvalidArgumentError7("Expected a step index (0-based).");
|
|
4880
|
+
return n;
|
|
4881
|
+
}
|
|
4882
|
+
function positiveMs4(raw) {
|
|
4883
|
+
const n = Number(raw);
|
|
4884
|
+
if (!Number.isFinite(n) || n <= 0) throw new InvalidArgumentError7("Expected a positive number of milliseconds.");
|
|
4885
|
+
return Math.floor(n);
|
|
4886
|
+
}
|
|
4887
|
+
function replayExitCode(report) {
|
|
4888
|
+
return report.outcome === "passed" ? 0 : report.outcome === "diverged" ? 1 : 2;
|
|
4889
|
+
}
|
|
4890
|
+
function formatReplayStep(s) {
|
|
4891
|
+
const what = s.selector ? ` ${s.selector}` : "";
|
|
4892
|
+
const how = s.usedFallback ? " (at the recorded point)" : "";
|
|
4893
|
+
return `[${s.index}] ${s.action}${what}${how} ${s.ok ? "ok" : "FAILED"} ${(s.durationMs / 1e3).toFixed(1)} s`;
|
|
4894
|
+
}
|
|
4895
|
+
function formatDivergence(r) {
|
|
4896
|
+
const lines = [`Diverged at step ${r.failedStep} (${r.reason}): ${r.message}`];
|
|
4897
|
+
if (r.screenshot) lines.push(` screenshot: ${r.screenshot}`);
|
|
4898
|
+
for (const n of r.nearMatches ?? []) lines.push(` near match: ${n.element} (differs in ${n.differs})`);
|
|
4899
|
+
if (r.layout) {
|
|
4900
|
+
const where = r.layout.appOnScreen ? `on screen in ${r.bundle}` : "on screen";
|
|
4901
|
+
lines.push(` ${where} (${r.layout.elementCount} element(s)${r.layout.keyboard ? ", keyboard up" : ""}):`);
|
|
4902
|
+
for (const e of r.layout.elements) lines.push(` ${e}`);
|
|
4903
|
+
if (r.layout.elementCount > r.layout.elements.length) lines.push(` \u2026 ${r.layout.elementCount - r.layout.elements.length} more`);
|
|
4904
|
+
}
|
|
4905
|
+
return lines.join("\n");
|
|
4906
|
+
}
|
|
4907
|
+
function registerReplayCommand(program) {
|
|
4908
|
+
program.command("replay <flow>").description(
|
|
4909
|
+
"Replay a flow file (written by `record` or by hand) on the device: for each step, wait for its selector, act on it the way `input --selector` does, then wait for its `expect`. Stops at the first step that fails and reports the step, why (not-found, expect-not-found, wrong-screen, ambiguous, covered, action-failed), a screenshot and what was on screen. Exits 0 when every step ran, 1 when the flow diverged, 2 when it could not run (invalid flow file, no device)."
|
|
4910
|
+
).option("--from <i>", "First step to run (0-based).", stepIndex).option("--to <i>", "Last step to run (0-based, inclusive).", stepIndex).option("--step-timeout <ms>", "How long to wait for a step's selector and its expect (a step's timeoutMs overrides it).", positiveMs4, 1e4).option("--screenshots <dir>", "Save a screenshot after every step (step-<i>.jpeg), and the divergence screenshot, here.").option("--fallback", "When a step's selector is not found, act on its recorded fallback point instead of stopping.").option("--launch", "Force-stop and start the flow's app (its bundle and ability) before the first step.").option("--no-wake", "Leave the screen as it is (by default it is turned on, a lock screen without a credential is swiped away, and it is kept on during the run).").option("--device <serial>", "Target device serial.").option(
|
|
4911
|
+
"--json",
|
|
4912
|
+
"Print the report as JSON: ok, outcome, steps, failedStep, reason, message, selector, screenshot, layout, nearMatches, error, warnings."
|
|
4913
|
+
).action(async (flowPath, opts) => {
|
|
4914
|
+
const { config, logger } = getRuntime();
|
|
4915
|
+
let report;
|
|
4916
|
+
try {
|
|
4917
|
+
const flow = readFlowFile3(path21.resolve(flowPath));
|
|
4918
|
+
report = await withInterrupt(logger, "stopping the replay", (abortSignal) => replayFlow({
|
|
4919
|
+
config,
|
|
4920
|
+
flow,
|
|
4921
|
+
abortSignal,
|
|
4922
|
+
from: opts.from,
|
|
4923
|
+
to: opts.to,
|
|
4924
|
+
stepTimeoutMs: opts.stepTimeout,
|
|
4925
|
+
screenshotsDir: opts.screenshots,
|
|
4926
|
+
fallback: opts.fallback,
|
|
4927
|
+
launch: opts.launch,
|
|
4928
|
+
wake: opts.wake,
|
|
4929
|
+
deviceSerial: opts.device,
|
|
4930
|
+
logger,
|
|
4931
|
+
onStep: opts.json ? void 0 : (s) => process.stdout.write(`${formatReplayStep(s)}
|
|
4932
|
+
`)
|
|
4933
|
+
}));
|
|
4934
|
+
} catch (err) {
|
|
4935
|
+
report = { ok: false, outcome: "error", range: [0, 0], steps: [], error: err.message, warnings: [] };
|
|
4936
|
+
}
|
|
4937
|
+
if (opts.json) {
|
|
4938
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}
|
|
4939
|
+
`);
|
|
4940
|
+
} else if (report.outcome === "passed") {
|
|
4941
|
+
process.stdout.write(`Flow passed: ${report.steps.length} step(s).
|
|
4942
|
+
`);
|
|
4943
|
+
} else if (report.outcome === "diverged") {
|
|
4944
|
+
process.stdout.write(`${formatDivergence(report)}
|
|
4945
|
+
`);
|
|
4946
|
+
}
|
|
4947
|
+
for (const w of report.warnings) logger.warn(w);
|
|
4948
|
+
if (report.error) logger.error(report.error);
|
|
4949
|
+
process.exitCode = replayExitCode(report);
|
|
4950
|
+
});
|
|
4951
|
+
}
|
|
4952
|
+
|
|
4953
|
+
// src/bin/oniro-app.ts
|
|
4954
|
+
var CLI_VERSION = "0.19.0";
|
|
4955
|
+
function buildProgram() {
|
|
4956
|
+
const program = new Command11().name("oniro-app").description(
|
|
4957
|
+
"Cross-platform CLI for Oniro/OpenHarmony app development.\n\nDesigned for non-interactive use (CI, scripts, agents): every command\ntakes explicit flags, results go to stdout, progress/logs go to stderr,\nand a non-zero exit code indicates failure."
|
|
4958
|
+
).version(CLI_VERSION).showHelpAfterError().enablePositionalOptions().exitOverride();
|
|
4959
|
+
registerSdkCommand(program);
|
|
4960
|
+
registerCmdToolsCommand(program);
|
|
4961
|
+
registerEmulatorCommand(program);
|
|
4962
|
+
registerBuildCommand(program);
|
|
4963
|
+
registerSignCommand(program);
|
|
4964
|
+
registerAuthCommand(program);
|
|
4965
|
+
registerAppCommand(program);
|
|
4966
|
+
registerCreateCommand(program);
|
|
4967
|
+
registerTemplatesCommand(program);
|
|
4968
|
+
registerRebootCommand(program);
|
|
4969
|
+
registerWaitCommand(program);
|
|
4970
|
+
registerWatchCommand(program);
|
|
4971
|
+
registerScreenshotCommand(program);
|
|
4972
|
+
registerDumpCommand(program);
|
|
4973
|
+
registerDevicesCommand(program);
|
|
4974
|
+
registerFileCommand(program);
|
|
4975
|
+
registerLintCommand(program);
|
|
4976
|
+
registerCheckCommand(program);
|
|
4977
|
+
registerDoctorCommand(program);
|
|
4978
|
+
registerScreensCommand(program);
|
|
4979
|
+
registerApiCommand(program);
|
|
4980
|
+
registerDocsCommand(program);
|
|
4981
|
+
registerTestCommand(program);
|
|
4982
|
+
registerInputCommand(program);
|
|
4983
|
+
registerRecordCommand(program);
|
|
4984
|
+
registerReplayCommand(program);
|
|
4985
|
+
registerPerfCommand(program);
|
|
4986
|
+
registerLogsCommand(program);
|
|
4987
|
+
registerHdcCommand(program);
|
|
4988
|
+
registerServeCommand(program);
|
|
4989
|
+
return program;
|
|
4990
|
+
}
|
|
4991
|
+
async function main() {
|
|
4992
|
+
const program = buildProgram();
|
|
4993
|
+
try {
|
|
4994
|
+
await program.parseAsync(process.argv);
|
|
4995
|
+
} catch (err) {
|
|
4996
|
+
if (err instanceof CommanderError) {
|
|
4997
|
+
process.exitCode = err.exitCode;
|
|
4998
|
+
return;
|
|
2112
4999
|
}
|
|
2113
|
-
|
|
5000
|
+
const message2 = err instanceof OniroError7 ? err.message : err instanceof Error ? err.message : String(err);
|
|
5001
|
+
const stack = process.env.ONIRO_DEBUG === "1" && err instanceof Error && err.stack ? `${err.stack}
|
|
5002
|
+
` : "";
|
|
5003
|
+
process.stderr.write(`[error] ${message2}
|
|
5004
|
+
${stack}`, () => process.exit(1));
|
|
2114
5005
|
}
|
|
2115
5006
|
}
|
|
2116
5007
|
void main();
|