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