@oniroproject/oniro-app 0.16.1 → 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 +711 -31
- package/dist/oniro-app.js +3273 -550
- 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,14 +535,26 @@ 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,
|
|
364
542
|
harmonyOsAutoSign,
|
|
543
|
+
harmonyOsSignHap,
|
|
544
|
+
signOpenHarmonyHap,
|
|
365
545
|
prepareSigning,
|
|
366
|
-
getOhosBaseSdkHome
|
|
546
|
+
getOhosBaseSdkHome,
|
|
547
|
+
describeProfileValidity,
|
|
548
|
+
describeSigningMaterial
|
|
367
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
|
+
}
|
|
368
558
|
function parseAclsList(value) {
|
|
369
559
|
return value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
370
560
|
}
|
|
@@ -376,7 +566,9 @@ function registerSignCommand(program) {
|
|
|
376
566
|
"cert (issuer=pki_internal) and its bundled validity window \u2014 dev/local builds only.",
|
|
377
567
|
"Overwrites the `signingConfigs` block in build-profile.json5 (other keys preserved).",
|
|
378
568
|
"HarmonyOS projects need --harmonyos instead, which has AppGallery Connect issue the",
|
|
379
|
-
"certificate and profile for a signed-in Huawei account (`oniro-app auth login`)."
|
|
569
|
+
"certificate and profile for a signed-in Huawei account (`oniro-app auth login`).",
|
|
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."
|
|
380
572
|
].join(" ")
|
|
381
573
|
).addOption(
|
|
382
574
|
new Option(
|
|
@@ -401,10 +593,64 @@ function registerSignCommand(program) {
|
|
|
401
593
|
).option(
|
|
402
594
|
"--harmonyos",
|
|
403
595
|
"Sign a HarmonyOS project through AppGallery Connect. Needs `oniro-app auth login`, and a device registered to the team: a connected device is registered if it reports a UDID (`hdc shell bm get -u`)."
|
|
596
|
+
).option(
|
|
597
|
+
"--hap <file>",
|
|
598
|
+
"Sign this prebuilt .hap (unsigned, or signed with other material) instead of a project, and write a signed copy (see --out). The bundle name is read from the HAP. For OpenHarmony, --apl, --app-feature, --acls and --app-privilege-capabilities apply as for a project; with --harmonyos, the ACL permissions are read from the HAP too."
|
|
599
|
+
).option(
|
|
600
|
+
"--out <file>",
|
|
601
|
+
"With --hap: where to write the signed HAP. Default: next to the input, `<name>-unsigned.hap` \u2192 `<name>-signed.hap`, `<name>.hap` \u2192 `<name>-signed.hap`, `<name>-signed.hap` \u2192 `<name>-resigned.hap`. Name the input itself to re-sign it in place."
|
|
404
602
|
).option("--team-id <id>", "With --harmonyos: the AGC team to sign under. Default: the account's own (`oniro-app auth team list`).").option("--product <name>", "With --harmonyos: the product to sign. Default `default`.").option("--force", "With --harmonyos: regenerate the certificate and profile even when they are still valid.").option("--bootstrap", "No-op if signing material is already present; otherwise generate it.").option("--store-password <pwd>", "Keystore store password (default 123456 \u2014 the SDK keystore password).").option("--key-password <pwd>", "Keystore key password (default 123456 \u2014 the SDK keystore password).").action(
|
|
405
603
|
async (projectDir, opts) => {
|
|
406
604
|
const { config, logger } = getRuntime();
|
|
407
605
|
const dir = path3.resolve(projectDir ?? process.cwd());
|
|
606
|
+
const passwords = opts.storePassword || opts.keyPassword ? { store: opts.storePassword ?? "123456", key: opts.keyPassword ?? "123456" } : void 0;
|
|
607
|
+
if (opts.out && !opts.hap) throw new OniroError2("--out names where `--hap` writes the signed HAP; pass --hap too.");
|
|
608
|
+
if (opts.hap) {
|
|
609
|
+
if (projectDir) throw new OniroError2("Pass either a project directory or --hap, not both.");
|
|
610
|
+
if (opts.product || opts.bootstrap) {
|
|
611
|
+
throw new OniroError2(`--${opts.product ? "product" : "bootstrap"} applies to a project, not to --hap.`);
|
|
612
|
+
}
|
|
613
|
+
const ignored = opts.harmonyos ? [
|
|
614
|
+
opts.apl !== "normal" && "--apl",
|
|
615
|
+
opts.appFeature && "--app-feature",
|
|
616
|
+
opts.acls && "--acls (the ACLs are read from the HAP)",
|
|
617
|
+
opts.appPrivilegeCapabilities && "--app-privilege-capabilities",
|
|
618
|
+
(opts.storePassword || opts.keyPassword) && "--store-password/--key-password"
|
|
619
|
+
] : [opts.teamId && "--team-id", opts.force && "--force"];
|
|
620
|
+
const unsupported = ignored.filter((flag) => typeof flag === "string");
|
|
621
|
+
if (unsupported.length > 0) {
|
|
622
|
+
throw new OniroError2(
|
|
623
|
+
`${unsupported.join(", ")} ${unsupported.length > 1 ? "do" : "does"} not apply to ${opts.harmonyos ? "--harmonyos --hap" : "--hap without --harmonyos"}.`
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
if (!opts.harmonyos) {
|
|
627
|
+
const result2 = await signOpenHarmonyHap({
|
|
628
|
+
hapPath: opts.hap,
|
|
629
|
+
outPath: opts.out,
|
|
630
|
+
sdkHome: getOhosBaseSdkHome(config),
|
|
631
|
+
apl: opts.apl,
|
|
632
|
+
appFeature: opts.appFeature,
|
|
633
|
+
acls: opts.acls,
|
|
634
|
+
appPrivilegeCapabilities: opts.appPrivilegeCapabilities,
|
|
635
|
+
passwords,
|
|
636
|
+
applicationCertPath: config.get("applicationCertPath", "") || void 0,
|
|
637
|
+
logger
|
|
638
|
+
});
|
|
639
|
+
logger.info(`Signed ${result2.bundleName} into ${result2.outPath}${materialSuffix(result2.material)}.`);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const result = await harmonyOsSignHap({
|
|
643
|
+
config,
|
|
644
|
+
session: createHarmonyOsSession({ config, logger }),
|
|
645
|
+
hapPath: opts.hap,
|
|
646
|
+
outPath: opts.out,
|
|
647
|
+
teamId: opts.teamId,
|
|
648
|
+
force: opts.force,
|
|
649
|
+
logger
|
|
650
|
+
});
|
|
651
|
+
logger.info(`Signed ${result.bundleName} into ${result.outPath} (${profileSummary(result)}).`);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
408
654
|
if (opts.harmonyos) {
|
|
409
655
|
const result = await harmonyOsAutoSign({
|
|
410
656
|
config,
|
|
@@ -415,15 +661,14 @@ function registerSignCommand(program) {
|
|
|
415
661
|
force: opts.force,
|
|
416
662
|
logger
|
|
417
663
|
});
|
|
418
|
-
logger.info(`Signing configs ready for ${result.bundleName} (
|
|
664
|
+
logger.info(`Signing configs ready for ${result.bundleName} (${profileSummary(result)}).`);
|
|
419
665
|
return;
|
|
420
666
|
}
|
|
421
667
|
if (detectRuntimeOs(dir, opts.product) === "HarmonyOS") {
|
|
422
|
-
throw new
|
|
668
|
+
throw new OniroError2(
|
|
423
669
|
`${dir} targets HarmonyOS, which needs a certificate from AppGallery Connect: run \`oniro-app auth login\` once, then \`oniro-app sign --harmonyos\`.`
|
|
424
670
|
);
|
|
425
671
|
}
|
|
426
|
-
const passwords = opts.storePassword || opts.keyPassword ? { store: opts.storePassword ?? "123456", key: opts.keyPassword ?? "123456" } : void 0;
|
|
427
672
|
if (opts.bootstrap) {
|
|
428
673
|
const result = prepareSigning({
|
|
429
674
|
config,
|
|
@@ -440,7 +685,7 @@ function registerSignCommand(program) {
|
|
|
440
685
|
}
|
|
441
686
|
const sdkHome = getOhosBaseSdkHome(config);
|
|
442
687
|
logger.info(`Generating signing configs in ${dir} using SDK at ${sdkHome}...`);
|
|
443
|
-
generateSigningConfigs({
|
|
688
|
+
const generated = generateSigningConfigs({
|
|
444
689
|
projectDir: dir,
|
|
445
690
|
sdkHome,
|
|
446
691
|
apl: opts.apl,
|
|
@@ -450,32 +695,96 @@ function registerSignCommand(program) {
|
|
|
450
695
|
passwords,
|
|
451
696
|
logger
|
|
452
697
|
});
|
|
453
|
-
logger.info(
|
|
698
|
+
logger.info(`Signing configs generated${materialSuffix(generated)}.`);
|
|
454
699
|
}
|
|
455
700
|
);
|
|
456
701
|
}
|
|
457
702
|
|
|
458
703
|
// src/commands/auth.ts
|
|
704
|
+
import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
|
|
705
|
+
import * as readline from "readline";
|
|
459
706
|
import { HUAWEI_SITES, createHarmonyOsSession as createHarmonyOsSession2 } from "@oniroproject/core";
|
|
460
707
|
function session() {
|
|
461
708
|
const { config, logger } = getRuntime();
|
|
462
|
-
return createHarmonyOsSession2({
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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:
|
|
466
720
|
${url}
|
|
467
|
-
|
|
468
|
-
|
|
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");
|
|
469
732
|
}
|
|
470
733
|
function registerAuthCommand(program) {
|
|
471
734
|
const auth = program.command("auth").description(
|
|
472
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)."
|
|
473
736
|
);
|
|
474
|
-
auth.command("login").description(
|
|
475
|
-
|
|
476
|
-
|
|
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 })}
|
|
477
759
|
`);
|
|
478
|
-
|
|
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
|
+
);
|
|
479
788
|
auth.command("logout").description("Sign out and delete the stored token.").action(async () => {
|
|
480
789
|
const signedOut = await session().logout();
|
|
481
790
|
getRuntime().logger.info(signedOut ? "Signed out." : "Not signed in; nothing to do.");
|
|
@@ -518,6 +827,7 @@ function registerAuthCommand(program) {
|
|
|
518
827
|
}
|
|
519
828
|
|
|
520
829
|
// src/commands/app.ts
|
|
830
|
+
import * as os2 from "os";
|
|
521
831
|
import * as path4 from "path";
|
|
522
832
|
import {
|
|
523
833
|
installApp,
|
|
@@ -528,134 +838,414 @@ import {
|
|
|
528
838
|
existingHapOrUnsigned,
|
|
529
839
|
getBundleName,
|
|
530
840
|
readHapBundleName,
|
|
531
|
-
|
|
841
|
+
smokeCheck,
|
|
842
|
+
latestCrash,
|
|
843
|
+
wakeAndUnlock,
|
|
844
|
+
DEFAULT_SMOKE_DELAY_MS,
|
|
845
|
+
OniroError as OniroError3
|
|
532
846
|
} from "@oniroproject/core";
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
await launchApp({ config, bundleName: opts.bundle, abilityName: opts.ability, deviceSerial: opts.device, logger });
|
|
553
|
-
} else {
|
|
554
|
-
const dir = path4.resolve(projectDir ?? process.cwd());
|
|
555
|
-
logger.info(`Launching app from ${dir}...`);
|
|
556
|
-
await launchApp({ config, projectDir: dir, moduleName: opts.module, abilityName: opts.ability, deviceSerial: opts.device, logger });
|
|
557
|
-
}
|
|
558
|
-
logger.info("App launched.");
|
|
559
|
-
});
|
|
560
|
-
app.command("apply [project-dir-or-hap]").description(
|
|
561
|
-
"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."
|
|
562
|
-
).option(
|
|
563
|
-
"--bundle <bundle>",
|
|
564
|
-
"Bundle name to apply changes to. Defaults to the bundleName in the .hap file, else in the project AppScope/app.json5."
|
|
565
|
-
).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) => {
|
|
566
|
-
const { config, logger } = getRuntime();
|
|
567
|
-
const isHap = target !== void 0 && /\.hap$/i.test(target);
|
|
568
|
-
if (isHap && opts.hap) throw new OniroError2("Pass the HAP either as the argument or with --hap, not both.");
|
|
569
|
-
const dir = isHap ? process.cwd() : path4.resolve(target ?? process.cwd());
|
|
570
|
-
const hapPath = isHap ? path4.resolve(target) : opts.hap;
|
|
571
|
-
let bundle = opts.bundle;
|
|
572
|
-
if (!bundle && isHap) {
|
|
573
|
-
const hap = existingHapOrUnsigned(hapPath);
|
|
574
|
-
if (!hap) throw new OniroError2(`HAP not found at ${hapPath}.`);
|
|
575
|
-
bundle = await readHapBundleName(hap.path).catch(() => null) ?? void 0;
|
|
576
|
-
if (!bundle) throw new OniroError2(`Could not read the bundle name from ${hap.path}. Pass --bundle.`);
|
|
577
|
-
}
|
|
578
|
-
bundle ??= getBundleName(dir);
|
|
579
|
-
const result = await applyChanges({
|
|
580
|
-
config,
|
|
581
|
-
bundle,
|
|
582
|
-
hapPath,
|
|
583
|
-
projectDir: dir,
|
|
584
|
-
module: opts.module,
|
|
585
|
-
installedHapPath: opts.installedHap,
|
|
586
|
-
isSystemBundle: opts.system,
|
|
587
|
-
allowUninstall: opts.allowUninstall,
|
|
588
|
-
deviceSerial: opts.device,
|
|
589
|
-
logger
|
|
590
|
-
});
|
|
591
|
-
if (opts.json) {
|
|
592
|
-
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
593
|
-
`);
|
|
594
|
-
} else {
|
|
595
|
-
logger.info(
|
|
596
|
-
`apply: method=${result.method} pid ${result.preInstallPid ?? "-"} -> ${result.postInstallPid ?? "-"}${result.cacheCleared ? " (cache cleared)" : ""}`
|
|
597
|
-
);
|
|
847
|
+
|
|
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"/>`);
|
|
598
866
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
}
|
|
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
|
+
);
|
|
877
|
+
}
|
|
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>`;
|
|
610
885
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
import * as fs2 from "fs";
|
|
623
|
-
import * as path5 from "path";
|
|
624
|
-
import { fileURLToPath } from "url";
|
|
625
|
-
function getBundledTemplateRoot() {
|
|
626
|
-
const here = path5.dirname(fileURLToPath(import.meta.url));
|
|
627
|
-
const candidates = [
|
|
628
|
-
path5.resolve(here, "../templates"),
|
|
629
|
-
// dist/oniro-app.js → ../templates
|
|
630
|
-
path5.resolve(here, "../../templates")
|
|
631
|
-
// src/bin/oniro-app.ts → ../../templates
|
|
632
|
-
];
|
|
633
|
-
for (const c of candidates) {
|
|
634
|
-
if (fs2.existsSync(c)) return c;
|
|
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 };
|
|
635
897
|
}
|
|
636
|
-
|
|
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 };
|
|
637
901
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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,
|
|
659
1249
|
location: path6.resolve(opts.location),
|
|
660
1250
|
sdkApi,
|
|
661
1251
|
moduleName: opts.module,
|
|
@@ -675,17 +1265,17 @@ function registerTemplatesCommand(program) {
|
|
|
675
1265
|
const templates = program.command("templates").description("Inspect the templates bundled with this CLI.");
|
|
676
1266
|
templates.command("list").description("List available project templates.").option("--json", "Emit machine-readable JSON.").action((opts) => {
|
|
677
1267
|
const root = getBundledTemplateRoot();
|
|
678
|
-
const
|
|
1268
|
+
const list2 = listTemplates(root);
|
|
679
1269
|
if (opts.json) {
|
|
680
|
-
process.stdout.write(`${JSON.stringify(
|
|
1270
|
+
process.stdout.write(`${JSON.stringify(list2, null, 2)}
|
|
681
1271
|
`);
|
|
682
1272
|
return;
|
|
683
1273
|
}
|
|
684
|
-
if (
|
|
1274
|
+
if (list2.length === 0) {
|
|
685
1275
|
process.stdout.write("No templates available.\n");
|
|
686
1276
|
return;
|
|
687
1277
|
}
|
|
688
|
-
const rows =
|
|
1278
|
+
const rows = list2.map((t) => `${t.id.padEnd(20)} ${t.label.padEnd(24)} ${t.description}`);
|
|
689
1279
|
process.stdout.write(`${rows.join("\n")}
|
|
690
1280
|
`);
|
|
691
1281
|
});
|
|
@@ -718,6 +1308,9 @@ function registerRebootCommand(program) {
|
|
|
718
1308
|
import { waitForLog, waitForBoot, waitForBundle, waitForTarget, describeLayoutNode } from "@oniroproject/core";
|
|
719
1309
|
|
|
720
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`.";
|
|
721
1314
|
function addTargetOptions(cmd, verb, opts = {}) {
|
|
722
1315
|
cmd.option(
|
|
723
1316
|
"--target <text>",
|
|
@@ -726,10 +1319,20 @@ function addTargetOptions(cmd, verb, opts = {}) {
|
|
|
726
1319
|
if (opts.index !== false) {
|
|
727
1320
|
cmd.option("--target-index <n>", "Pick the n-th (0-based) of several matching elements.");
|
|
728
1321
|
}
|
|
1322
|
+
cmd.option("--selector <expr>", `Instead of --target*: ${SELECTOR_HELP}`).option("--window <id>", WINDOW_HELP);
|
|
729
1323
|
return cmd;
|
|
730
1324
|
}
|
|
1325
|
+
function parseSelectorOption(raw) {
|
|
1326
|
+
return raw === void 0 ? void 0 : parseSelector(raw);
|
|
1327
|
+
}
|
|
731
1328
|
function selectorFrom(opts) {
|
|
732
|
-
|
|
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;
|
|
733
1336
|
return { text: opts.target, id: opts.targetId, type: opts.targetType };
|
|
734
1337
|
}
|
|
735
1338
|
function targetIndexFrom(opts) {
|
|
@@ -738,6 +1341,12 @@ function targetIndexFrom(opts) {
|
|
|
738
1341
|
if (!Number.isInteger(n) || n < 0) throw new Error(`--target-index must be a non-negative integer; got ${opts.targetIndex}.`);
|
|
739
1342
|
return n;
|
|
740
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
|
+
}
|
|
741
1350
|
|
|
742
1351
|
// src/lib/logSince.ts
|
|
743
1352
|
function parseSince(value) {
|
|
@@ -752,18 +1361,20 @@ function parseSince(value) {
|
|
|
752
1361
|
|
|
753
1362
|
// src/commands/wait.ts
|
|
754
1363
|
function registerWaitCommand(program) {
|
|
755
|
-
const
|
|
756
|
-
"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)."
|
|
757
1366
|
).option("--log <pattern>", "Resolve when a hilog line (tag: message) logged after the command starts matches this regex.").option(
|
|
758
1367
|
"--since <seconds>",
|
|
759
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.'
|
|
760
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).");
|
|
761
|
-
addTargetOptions(
|
|
762
|
-
|
|
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) => {
|
|
763
1372
|
const { config, logger } = getRuntime();
|
|
764
1373
|
const timeoutMs = Number(opts.timeout);
|
|
765
1374
|
const selector = selectorFrom(opts);
|
|
1375
|
+
const windowId = windowIdFrom(opts);
|
|
766
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.");
|
|
767
1378
|
if (opts.log) {
|
|
768
1379
|
const entry = await waitForLog({
|
|
769
1380
|
config,
|
|
@@ -790,6 +1401,7 @@ function registerWaitCommand(program) {
|
|
|
790
1401
|
config,
|
|
791
1402
|
selector,
|
|
792
1403
|
gone: opts.gone,
|
|
1404
|
+
windowId,
|
|
793
1405
|
timeoutMs,
|
|
794
1406
|
pollMs: Number(opts.interval) || 500,
|
|
795
1407
|
deviceSerial: opts.device,
|
|
@@ -809,7 +1421,9 @@ function registerWaitCommand(program) {
|
|
|
809
1421
|
logger.info(`Bundle ${opts.bundle} is running.`);
|
|
810
1422
|
return;
|
|
811
1423
|
}
|
|
812
|
-
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
|
+
);
|
|
813
1427
|
});
|
|
814
1428
|
}
|
|
815
1429
|
|
|
@@ -846,171 +1460,62 @@ function registerWatchCommand(program) {
|
|
|
846
1460
|
// src/commands/screenshot.ts
|
|
847
1461
|
import * as fs3 from "fs";
|
|
848
1462
|
import * as path7 from "path";
|
|
849
|
-
import { takeScreenshot, captureBurst, locateTarget, describeLayoutNode as describeLayoutNode2 } from "@oniroproject/core";
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
const labelFg = "#fff";
|
|
857
|
-
const fontSize = Math.max(10, Math.round(Math.min(width, height) / 60));
|
|
858
|
-
const padding = Math.round(fontSize * 0.4);
|
|
859
|
-
const labels = [];
|
|
860
|
-
const lines = [];
|
|
861
|
-
for (let i = 0; i <= 10; i++) {
|
|
862
|
-
const f = i / 10;
|
|
863
|
-
const x = Math.round(f * (width - 1));
|
|
864
|
-
const y = Math.round(f * (height - 1));
|
|
865
|
-
if (i > 0 && i < 10) {
|
|
866
|
-
lines.push(`<line x1="${x}" y1="0" x2="${x}" y2="${height}" stroke="${lineColor}" stroke-width="1"/>`);
|
|
867
|
-
lines.push(`<line x1="0" y1="${y}" x2="${width}" y2="${y}" stroke="${lineColor}" stroke-width="1"/>`);
|
|
868
|
-
}
|
|
869
|
-
const tag = f.toFixed(1);
|
|
870
|
-
const tx = i === 0 ? padding : i === 10 ? width - padding : x;
|
|
871
|
-
const anchorX = i === 0 ? "start" : i === 10 ? "end" : "middle";
|
|
872
|
-
labels.push(
|
|
873
|
-
`<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>`
|
|
874
|
-
);
|
|
875
|
-
const ty = i === 0 ? fontSize + padding : i === 10 ? height - padding : y + fontSize / 3;
|
|
876
|
-
labels.push(
|
|
877
|
-
`<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>`
|
|
878
|
-
);
|
|
879
|
-
}
|
|
880
|
-
labels.push(
|
|
881
|
-
`<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>`
|
|
882
|
-
);
|
|
883
|
-
labels.push(
|
|
884
|
-
`<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>`
|
|
885
|
-
);
|
|
886
|
-
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${lines.join("")}${labels.join("")}</svg>`;
|
|
887
|
-
}
|
|
888
|
-
async function renderWithGrid(jpeg, opts) {
|
|
889
|
-
const meta = await sharp(jpeg).metadata();
|
|
890
|
-
const originalWidth = meta.width ?? 0;
|
|
891
|
-
const originalHeight = meta.height ?? 0;
|
|
892
|
-
const longest = Math.max(originalWidth, originalHeight) || 1;
|
|
893
|
-
const scale = Math.min(1, opts.maxDim / longest);
|
|
894
|
-
const width = Math.max(1, Math.round(originalWidth * scale));
|
|
895
|
-
const height = Math.max(1, Math.round(originalHeight * scale));
|
|
896
|
-
const resized = await sharp(jpeg).resize(width, height).jpeg({ quality: 80 }).toBuffer();
|
|
897
|
-
if (!opts.grid) {
|
|
898
|
-
return { buf: resized, width, height, originalWidth, originalHeight };
|
|
899
|
-
}
|
|
900
|
-
const svg = buildGridSvg(width, height);
|
|
901
|
-
const buf = await sharp(resized).composite([{ input: Buffer.from(svg) }]).jpeg({ quality: 80 }).toBuffer();
|
|
902
|
-
return { buf, width, height, originalWidth, originalHeight };
|
|
903
|
-
}
|
|
904
|
-
function parseCropRect(spec) {
|
|
905
|
-
const parts = spec.split(",").map((s) => Number(s.trim()));
|
|
906
|
-
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 1)) {
|
|
907
|
-
throw new Error(`--crop expects four 0-1 fractions "x1,y1,x2,y2"; got "${spec}".`);
|
|
908
|
-
}
|
|
909
|
-
const [x1, y1, x2, y2] = parts;
|
|
910
|
-
if (!(x2 > x1 && y2 > y1)) throw new Error(`--crop needs x1 < x2 and y1 < y2; got "${spec}".`);
|
|
911
|
-
return [x1, y1, x2, y2];
|
|
912
|
-
}
|
|
913
|
-
function padCropRect(r, pad) {
|
|
914
|
-
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)];
|
|
915
|
-
}
|
|
916
|
-
async function cropImage(jpeg, rect, opts) {
|
|
917
|
-
const meta = await sharp(jpeg).metadata();
|
|
918
|
-
const w = meta.width ?? 1;
|
|
919
|
-
const h = meta.height ?? 1;
|
|
920
|
-
const left = Math.min(w - 1, Math.floor(rect[0] * w));
|
|
921
|
-
const top = Math.min(h - 1, Math.floor(rect[1] * h));
|
|
922
|
-
const cropWidth = Math.max(1, Math.min(w, Math.ceil(rect[2] * w)) - left);
|
|
923
|
-
const cropHeight = Math.max(1, Math.min(h, Math.ceil(rect[3] * h)) - top);
|
|
924
|
-
const scale = Math.min(1, opts.maxDim / Math.max(cropWidth, cropHeight));
|
|
925
|
-
const width = Math.max(1, Math.round(cropWidth * scale));
|
|
926
|
-
const height = Math.max(1, Math.round(cropHeight * scale));
|
|
927
|
-
const buf = await sharp(jpeg).extract({ left, top, width: cropWidth, height: cropHeight }).resize(width, height).jpeg({ quality: 85 }).toBuffer();
|
|
928
|
-
return { buf, width, height, left, top, cropWidth, cropHeight };
|
|
929
|
-
}
|
|
930
|
-
function buildTileLabelSvg(width, height, idx, diff) {
|
|
931
|
-
const fontSize = Math.max(12, Math.round(Math.min(width, height) / 16));
|
|
932
|
-
const pad = Math.round(fontSize * 0.4);
|
|
933
|
-
let label = `#${idx}`;
|
|
934
|
-
if (typeof diff === "number") label += ` \u0394${diff.toFixed(2)}`;
|
|
935
|
-
const boxW = Math.round(label.length * fontSize * 0.62) + pad * 2;
|
|
936
|
-
const boxH = fontSize + pad * 2;
|
|
937
|
-
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>`;
|
|
938
|
-
}
|
|
939
|
-
async function buildContactSheet(frames, opts) {
|
|
940
|
-
if (frames.length === 0) throw new Error("buildContactSheet: no frames");
|
|
941
|
-
const count = frames.length;
|
|
942
|
-
const cols = Math.max(1, opts.cols ?? Math.ceil(Math.sqrt(count)));
|
|
943
|
-
const rows = Math.ceil(count / cols);
|
|
944
|
-
const gap = opts.gap ?? 8;
|
|
945
|
-
const meta0 = await sharp(frames[0]).metadata();
|
|
946
|
-
const fw = meta0.width ?? 1;
|
|
947
|
-
const fh = meta0.height ?? 1;
|
|
948
|
-
const longest = Math.max(fw, fh) || 1;
|
|
949
|
-
const tileScale = Math.min(1, opts.maxDim / cols / longest);
|
|
950
|
-
const tileWidth = Math.max(1, Math.round(fw * tileScale));
|
|
951
|
-
const tileHeight = Math.max(1, Math.round(fh * tileScale));
|
|
952
|
-
const tiles = await Promise.all(
|
|
953
|
-
frames.map((frame, i) => {
|
|
954
|
-
const label = Buffer.from(buildTileLabelSvg(tileWidth, tileHeight, i, opts.diffs?.[i]));
|
|
955
|
-
return sharp(frame).resize(tileWidth, tileHeight, { fit: "fill" }).composite([{ input: label, top: 0, left: 0 }]).png().toBuffer();
|
|
956
|
-
})
|
|
957
|
-
);
|
|
958
|
-
const width = cols * tileWidth + (cols + 1) * gap;
|
|
959
|
-
const height = rows * tileHeight + (rows + 1) * gap;
|
|
960
|
-
const placements = tiles.map((input, i) => {
|
|
961
|
-
const r = Math.floor(i / cols);
|
|
962
|
-
const c = i % cols;
|
|
963
|
-
return { input, left: gap + c * (tileWidth + gap), top: gap + r * (tileHeight + gap) };
|
|
964
|
-
});
|
|
965
|
-
const buf = await sharp({
|
|
966
|
-
create: { width, height, channels: 3, background: { r: 20, g: 20, b: 20 } }
|
|
967
|
-
}).composite(placements).jpeg({ quality: 80 }).toBuffer();
|
|
968
|
-
return { buf, width, height, cols, rows, tileWidth, tileHeight, count };
|
|
969
|
-
}
|
|
970
|
-
async function frameDiffs(frames, opts = {}) {
|
|
971
|
-
if (frames.length === 0) return [];
|
|
972
|
-
const sample = opts.sample ?? 32;
|
|
973
|
-
const thumbs = await Promise.all(
|
|
974
|
-
frames.map((f) => sharp(f).greyscale().resize(sample, sample, { fit: "fill" }).raw().toBuffer())
|
|
975
|
-
);
|
|
976
|
-
const out = [0];
|
|
977
|
-
for (let i = 1; i < thumbs.length; i++) {
|
|
978
|
-
const a = thumbs[i - 1];
|
|
979
|
-
const b = thumbs[i];
|
|
980
|
-
if (!a || !b) {
|
|
981
|
-
out.push(0);
|
|
982
|
-
continue;
|
|
983
|
-
}
|
|
984
|
-
const n = Math.min(a.length, b.length) || 1;
|
|
985
|
-
let s = 0;
|
|
986
|
-
for (let k = 0; k < n; k++) s += Math.abs((a[k] ?? 0) - (b[k] ?? 0));
|
|
987
|
-
out.push(+(s / n / 255).toFixed(4));
|
|
988
|
-
}
|
|
989
|
-
return out;
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
// src/commands/screenshot.ts
|
|
993
|
-
var DEFAULT_CONTACT_SHEET_FRAMES = 8;
|
|
994
|
-
var TARGET_CROP_PAD = 0.02;
|
|
995
|
-
function clampMaxDim(raw) {
|
|
996
|
-
const n = Number(raw);
|
|
997
|
-
if (!Number.isFinite(n)) return 1024;
|
|
998
|
-
return Math.max(256, Math.min(4096, Math.round(n)));
|
|
1463
|
+
import { takeScreenshot, captureBurst, locateTarget, describeLayoutNode as describeLayoutNode2, dumpLayout, listLayoutWindows } from "@oniroproject/core";
|
|
1464
|
+
var DEFAULT_CONTACT_SHEET_FRAMES = 8;
|
|
1465
|
+
var TARGET_CROP_PAD = 0.02;
|
|
1466
|
+
function clampMaxDim(raw) {
|
|
1467
|
+
const n = Number(raw);
|
|
1468
|
+
if (!Number.isFinite(n)) return 1024;
|
|
1469
|
+
return Math.max(256, Math.min(4096, Math.round(n)));
|
|
999
1470
|
}
|
|
1000
1471
|
function registerScreenshotCommand(program) {
|
|
1001
|
-
const
|
|
1002
|
-
"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)."
|
|
1003
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.");
|
|
1004
|
-
addTargetOptions(
|
|
1005
|
-
|
|
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) => {
|
|
1006
1480
|
const { config, logger } = getRuntime();
|
|
1007
1481
|
const maxDim = clampMaxDim(opts.maxDim);
|
|
1008
1482
|
const intervalMs = Number(opts.interval) || 50;
|
|
1009
1483
|
const selector = selectorFrom(opts);
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
+
}
|
|
1012
1517
|
if (opts.grid || opts.contactSheet || opts.burst) {
|
|
1013
|
-
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.");
|
|
1014
1519
|
}
|
|
1015
1520
|
let rect;
|
|
1016
1521
|
let label;
|
|
@@ -1019,6 +1524,7 @@ function registerScreenshotCommand(program) {
|
|
|
1019
1524
|
config,
|
|
1020
1525
|
selector,
|
|
1021
1526
|
index: targetIndexFrom(opts),
|
|
1527
|
+
windowId,
|
|
1022
1528
|
hideKeyboard: false,
|
|
1023
1529
|
deviceSerial: opts.device,
|
|
1024
1530
|
logger
|
|
@@ -1026,6 +1532,12 @@ function registerScreenshotCommand(program) {
|
|
|
1026
1532
|
if (!located.node.b) throw new Error(`${describeLayoutNode2(located.node)} has no bounds to crop to.`);
|
|
1027
1533
|
rect = padCropRect(located.node.b, TARGET_CROP_PAD);
|
|
1028
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})` : ""}`;
|
|
1029
1541
|
} else {
|
|
1030
1542
|
rect = parseCropRect(opts.crop);
|
|
1031
1543
|
label = rect.map((n) => n.toFixed(3)).join(",");
|
|
@@ -1108,7 +1620,16 @@ function registerScreenshotCommand(program) {
|
|
|
1108
1620
|
}
|
|
1109
1621
|
|
|
1110
1622
|
// src/commands/dump.ts
|
|
1111
|
-
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";
|
|
1112
1633
|
|
|
1113
1634
|
// src/lib/layoutText.ts
|
|
1114
1635
|
import { flattenLayout } from "@oniroproject/core";
|
|
@@ -1126,6 +1647,7 @@ function flags(n) {
|
|
|
1126
1647
|
if (n.sel) out.push("selected");
|
|
1127
1648
|
if (n.focus) out.push("focus");
|
|
1128
1649
|
if (n.disabled) out.push("disabled");
|
|
1650
|
+
if (n.hidden) out.push("hidden");
|
|
1129
1651
|
if (n.covered) out.push("covered");
|
|
1130
1652
|
return out;
|
|
1131
1653
|
}
|
|
@@ -1144,6 +1666,9 @@ function isListed(n) {
|
|
|
1144
1666
|
const labelled = n.text !== void 0 || n.desc !== void 0 || n.hint !== void 0;
|
|
1145
1667
|
return labelled || !!(n.click || n.longClick || n.scroll || n.check || n.focus);
|
|
1146
1668
|
}
|
|
1669
|
+
function windowLabel(windowId, bundle) {
|
|
1670
|
+
return [windowId, bundle].filter((x) => x !== void 0).join(" ");
|
|
1671
|
+
}
|
|
1147
1672
|
function formatFlatLayout(result) {
|
|
1148
1673
|
const lines = [];
|
|
1149
1674
|
const d = result.display;
|
|
@@ -1152,13 +1677,15 @@ function formatFlatLayout(result) {
|
|
|
1152
1677
|
);
|
|
1153
1678
|
const keyboardBundle = result.keyboard?.shown ? result.keyboard.bundle : void 0;
|
|
1154
1679
|
let window;
|
|
1680
|
+
let windowId;
|
|
1155
1681
|
for (const n of flattenLayout(result.tree)) {
|
|
1156
|
-
if (n.window !== window) {
|
|
1682
|
+
if (n.window !== window || n.windowId !== windowId) {
|
|
1157
1683
|
window = n.window;
|
|
1684
|
+
windowId = n.windowId;
|
|
1158
1685
|
if (window !== void 0 && window === keyboardBundle) {
|
|
1159
|
-
lines.push(`[keyboard ${window}] up \u2014 dismiss with input --type hideKeyboard`);
|
|
1160
|
-
} else if (window !== void 0) {
|
|
1161
|
-
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)}]`);
|
|
1162
1689
|
}
|
|
1163
1690
|
}
|
|
1164
1691
|
if (window !== void 0 && window === keyboardBundle) continue;
|
|
@@ -1168,26 +1695,98 @@ function formatFlatLayout(result) {
|
|
|
1168
1695
|
return `${lines.join("\n")}
|
|
1169
1696
|
`;
|
|
1170
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
|
+
}
|
|
1171
1709
|
|
|
1172
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
|
+
}
|
|
1173
1733
|
function registerDumpCommand(program) {
|
|
1174
1734
|
program.command("dump [target]").description(
|
|
1175
|
-
"Dump device state.
|
|
1176
|
-
).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) => {
|
|
1177
1740
|
const { config, logger } = getRuntime();
|
|
1178
1741
|
const what = target ?? "layout";
|
|
1179
|
-
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.`);
|
|
1180
1749
|
if (opts.raw && opts.flat) throw new Error("--raw and --flat cannot be combined.");
|
|
1181
|
-
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({
|
|
1182
1760
|
config,
|
|
1183
1761
|
bundleName: opts.bundle,
|
|
1762
|
+
windowId: windowIdFrom(opts),
|
|
1184
1763
|
prune: !opts.raw,
|
|
1185
1764
|
detectKeyboard: !opts.raw,
|
|
1186
1765
|
deviceSerial: opts.device,
|
|
1187
1766
|
logger
|
|
1188
1767
|
});
|
|
1189
|
-
|
|
1190
|
-
|
|
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);
|
|
1191
1790
|
});
|
|
1192
1791
|
}
|
|
1193
1792
|
|
|
@@ -1196,9 +1795,9 @@ import { getDeviceInfo, listDevices } from "@oniroproject/core";
|
|
|
1196
1795
|
function formatLongDevice(d, info) {
|
|
1197
1796
|
const head = `${d.serial} ${d.connection ?? ""} ${d.status}`;
|
|
1198
1797
|
if (!info) return head;
|
|
1199
|
-
const
|
|
1798
|
+
const os5 = [info.osFamily, info.osFamily === "HarmonyOS" ? info.osDistVersion : void 0].filter(Boolean).join(" ");
|
|
1200
1799
|
const fields = [
|
|
1201
|
-
["os",
|
|
1800
|
+
["os", os5 || void 0],
|
|
1202
1801
|
["api", info.sdkApiVersion],
|
|
1203
1802
|
["abi", info.abi],
|
|
1204
1803
|
["type", info.deviceType],
|
|
@@ -1208,34 +1807,40 @@ function formatLongDevice(d, info) {
|
|
|
1208
1807
|
const rest = fields.filter(([, v]) => v).map(([k, v]) => `${k}=${/\s/.test(v) ? JSON.stringify(v) : v}`);
|
|
1209
1808
|
return [head, ...rest].join(" ");
|
|
1210
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
|
+
}
|
|
1211
1828
|
function registerDevicesCommand(program) {
|
|
1212
1829
|
program.command("devices").description("List connected hdc devices.").option(
|
|
1213
1830
|
"--long",
|
|
1214
1831
|
"Also ask each connected device for its OS family (HarmonyOS / OpenHarmony), version, API level, CPU ABI, device type and model."
|
|
1215
1832
|
).option("--json", "Emit machine-readable JSON.").action(async (opts) => {
|
|
1216
|
-
const {
|
|
1217
|
-
const devices = await listDevices(config);
|
|
1218
|
-
const infos = /* @__PURE__ */ new Map();
|
|
1219
|
-
if (opts.long) {
|
|
1220
|
-
for (const d of devices.filter((x) => x.status === "Connected")) {
|
|
1221
|
-
try {
|
|
1222
|
-
infos.set(d.serial, await getDeviceInfo({ config, deviceSerial: d.serial, logger }));
|
|
1223
|
-
} catch (err) {
|
|
1224
|
-
logger.warn(`Could not query ${d.serial}: ${err.message}`);
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1833
|
+
const { devices: devices2, infos } = await queryDevices(!!opts.long);
|
|
1228
1834
|
if (opts.json) {
|
|
1229
|
-
|
|
1230
|
-
process.stdout.write(`${JSON.stringify(out, null, 2)}
|
|
1835
|
+
process.stdout.write(`${JSON.stringify(devicesJson(devices2, infos, !!opts.long), null, 2)}
|
|
1231
1836
|
`);
|
|
1232
1837
|
return;
|
|
1233
1838
|
}
|
|
1234
|
-
if (
|
|
1839
|
+
if (devices2.length === 0) {
|
|
1235
1840
|
process.stdout.write("(no devices)\n");
|
|
1236
1841
|
return;
|
|
1237
1842
|
}
|
|
1238
|
-
for (const d of
|
|
1843
|
+
for (const d of devices2) {
|
|
1239
1844
|
process.stdout.write(`${opts.long ? formatLongDevice(d, infos.get(d.serial)) : `${d.serial} ${d.connection ?? ""} ${d.status}`}
|
|
1240
1845
|
`);
|
|
1241
1846
|
}
|
|
@@ -1263,7 +1868,8 @@ function registerFileCommand(program) {
|
|
|
1263
1868
|
}
|
|
1264
1869
|
|
|
1265
1870
|
// src/commands/lint.ts
|
|
1266
|
-
import
|
|
1871
|
+
import { InvalidArgumentError as InvalidArgumentError3 } from "commander";
|
|
1872
|
+
import * as path9 from "path";
|
|
1267
1873
|
import {
|
|
1268
1874
|
detectCmdToolsFlavour,
|
|
1269
1875
|
detectRuntimeOs as detectRuntimeOs2,
|
|
@@ -1271,6 +1877,76 @@ import {
|
|
|
1271
1877
|
missingProjectRootFiles,
|
|
1272
1878
|
runCodelinter
|
|
1273
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
|
|
1274
1950
|
function lintTotals(result) {
|
|
1275
1951
|
if (result.summary) return result.summary;
|
|
1276
1952
|
const count = (s) => result.findings.filter((f) => f.severity === s).length;
|
|
@@ -1281,19 +1957,34 @@ function lintTotals(result) {
|
|
|
1281
1957
|
suggestions: count("suggestion")
|
|
1282
1958
|
};
|
|
1283
1959
|
}
|
|
1284
|
-
function lintExitCode(result) {
|
|
1960
|
+
function lintExitCode(result, allowIncomplete = false) {
|
|
1285
1961
|
if (lintTotals(result).errors > 0) return 1;
|
|
1286
|
-
|
|
1962
|
+
if (lintFailed(result)) return result.code;
|
|
1963
|
+
return result.incomplete && !allowIncomplete ? 2 : 0;
|
|
1287
1964
|
}
|
|
1288
1965
|
function lintFailed(result) {
|
|
1289
1966
|
return result.summary === null && result.findings.length === 0 && result.code !== 0;
|
|
1290
1967
|
}
|
|
1291
1968
|
function registerLintCommand(program) {
|
|
1292
1969
|
program.command("lint [project-dir]").description(
|
|
1293
|
-
"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."
|
|
1294
|
-
).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
|
+
}
|
|
1295
1986
|
const { config, logger } = getRuntime();
|
|
1296
|
-
const dir =
|
|
1987
|
+
const dir = path9.resolve(projectDir ?? process.cwd());
|
|
1297
1988
|
const result = await runCodelinter({ config, projectDir: dir, files: opts.files, logger });
|
|
1298
1989
|
const totals = lintTotals(result);
|
|
1299
1990
|
const failed = lintFailed(result);
|
|
@@ -1330,68 +2021,275 @@ function registerLintCommand(program) {
|
|
|
1330
2021
|
);
|
|
1331
2022
|
}
|
|
1332
2023
|
}
|
|
1333
|
-
|
|
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);
|
|
1334
2030
|
});
|
|
1335
2031
|
}
|
|
1336
2032
|
|
|
1337
|
-
// src/commands/
|
|
1338
|
-
import {
|
|
1339
|
-
import
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
"
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
"
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
const
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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) => {
|
|
1367
2071
|
const { config, logger } = getRuntime();
|
|
1368
|
-
const
|
|
2072
|
+
const dir = path10.resolve(projectDir ?? process.cwd());
|
|
2073
|
+
const report = await runTests({
|
|
1369
2074
|
config,
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
pxY2: num(opts.y2),
|
|
1381
|
-
speed: num(opts.speed),
|
|
1382
|
-
key: opts.key,
|
|
1383
|
-
text: opts.text,
|
|
1384
|
-
clear: opts.clear,
|
|
1385
|
-
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,
|
|
1386
2085
|
deviceSerial: opts.device,
|
|
1387
2086
|
logger
|
|
1388
2087
|
});
|
|
1389
|
-
if (opts.
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
|
|
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;
|
|
2290
|
+
}
|
|
2291
|
+
const at = result.x !== void 0 ? ` at ${result.x},${result.y}` : "";
|
|
2292
|
+
const on = result.target ? ` on ${describeLayoutNode3(result.target)}` : "";
|
|
1395
2293
|
const kb = result.keyboardHidden ? " (dismissed the keyboard)" : "";
|
|
1396
2294
|
logger.info(`Sent ${opts.type}${at}${on}${kb}.`);
|
|
1397
2295
|
});
|
|
@@ -1419,13 +2317,14 @@ function registerInputCommand(program) {
|
|
|
1419
2317
|
}
|
|
1420
2318
|
|
|
1421
2319
|
// src/commands/logs.ts
|
|
1422
|
-
import * as
|
|
1423
|
-
import * as
|
|
1424
|
-
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";
|
|
1425
2324
|
|
|
1426
2325
|
// src/lib/logBundle.ts
|
|
1427
|
-
import * as
|
|
1428
|
-
import * as
|
|
2326
|
+
import * as fs6 from "fs/promises";
|
|
2327
|
+
import * as path13 from "path";
|
|
1429
2328
|
import { deflateRawSync } from "zlib";
|
|
1430
2329
|
import {
|
|
1431
2330
|
VERSION,
|
|
@@ -1458,7 +2357,7 @@ function validateDate(date, time) {
|
|
|
1458
2357
|
const value = new Date(Date.UTC(2e3, Number(monthText) - 1, Number(dayText), Number(hourText), Number(minuteText), Number(secondText), Number(millisecondText)));
|
|
1459
2358
|
return value.getUTCMonth() === Number(monthText) - 1 && value.getUTCDate() === Number(dayText) && Number(hourText) < 24 && Number(minuteText) < 60 && Number(secondText) < 60;
|
|
1460
2359
|
}
|
|
1461
|
-
function filterHilog(
|
|
2360
|
+
function filterHilog(input2, filters) {
|
|
1462
2361
|
const criteria = {};
|
|
1463
2362
|
if (filters.pid) criteria.pid = filters.pid.replace(/^0+(?=\d)/, "");
|
|
1464
2363
|
if (filters.tag) criteria.tag = filters.tag;
|
|
@@ -1467,7 +2366,7 @@ function filterHilog(input, filters) {
|
|
|
1467
2366
|
let sawRecord = false;
|
|
1468
2367
|
let keep = false;
|
|
1469
2368
|
let output = "";
|
|
1470
|
-
for (const line of
|
|
2369
|
+
for (const line of input2.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
|
|
1471
2370
|
const content = line.replace(/[\r\n]+$/, "");
|
|
1472
2371
|
const match = HILOG_RECORD.exec(content);
|
|
1473
2372
|
if (match?.groups) {
|
|
@@ -1520,34 +2419,34 @@ function decodeText(data, relativePath) {
|
|
|
1520
2419
|
return text;
|
|
1521
2420
|
}
|
|
1522
2421
|
async function redactTree(source, destination, serials, boundedHilog) {
|
|
1523
|
-
await
|
|
2422
|
+
await fs6.mkdir(destination, { recursive: false, mode: 448 });
|
|
1524
2423
|
try {
|
|
1525
2424
|
const visit = async (directory) => {
|
|
1526
|
-
for (const entry of await
|
|
1527
|
-
const sourcePath =
|
|
1528
|
-
const relative2 =
|
|
1529
|
-
const portableRelative = relative2.split(
|
|
1530
|
-
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);
|
|
1531
2430
|
if (entry.isDirectory()) {
|
|
1532
|
-
await
|
|
2431
|
+
await fs6.mkdir(destinationPath, { recursive: true, mode: 448 });
|
|
1533
2432
|
await visit(sourcePath);
|
|
1534
2433
|
} else if (entry.isFile()) {
|
|
1535
2434
|
if (portableRelative === "logs/hilog.txt") {
|
|
1536
2435
|
const text2 = boundedHilog ?? "HiLog omitted from this archive because the requested duration could not be reliably applied.\n";
|
|
1537
|
-
await
|
|
1538
|
-
await
|
|
2436
|
+
await fs6.mkdir(path13.dirname(destinationPath), { recursive: true, mode: 448 });
|
|
2437
|
+
await fs6.writeFile(destinationPath, redactLogText(text2, serials), { encoding: "utf8", mode: 384 });
|
|
1539
2438
|
continue;
|
|
1540
2439
|
}
|
|
1541
2440
|
if (portableRelative === "logs/hilog-filtered.txt" && boundedHilog === null) continue;
|
|
1542
|
-
const text = decodeText(await
|
|
1543
|
-
await
|
|
1544
|
-
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 });
|
|
1545
2444
|
}
|
|
1546
2445
|
}
|
|
1547
2446
|
};
|
|
1548
2447
|
await visit(source);
|
|
1549
2448
|
} catch (error) {
|
|
1550
|
-
await
|
|
2449
|
+
await fs6.rm(destination, { recursive: true, force: true });
|
|
1551
2450
|
throw error;
|
|
1552
2451
|
}
|
|
1553
2452
|
}
|
|
@@ -1572,8 +2471,8 @@ function dosDateTime(date) {
|
|
|
1572
2471
|
async function writeZip(source, archivePath) {
|
|
1573
2472
|
const files = [];
|
|
1574
2473
|
const visit = async (directory) => {
|
|
1575
|
-
for (const entry of await
|
|
1576
|
-
const filePath =
|
|
2474
|
+
for (const entry of await fs6.readdir(directory, { withFileTypes: true })) {
|
|
2475
|
+
const filePath = path13.join(directory, entry.name);
|
|
1577
2476
|
if (entry.isDirectory()) await visit(filePath);
|
|
1578
2477
|
else if (entry.isFile()) files.push(filePath);
|
|
1579
2478
|
}
|
|
@@ -1584,8 +2483,8 @@ async function writeZip(source, archivePath) {
|
|
|
1584
2483
|
let offset = 0;
|
|
1585
2484
|
const stamp = dosDateTime(/* @__PURE__ */ new Date());
|
|
1586
2485
|
for (const filePath of files) {
|
|
1587
|
-
const name = Buffer.from(
|
|
1588
|
-
const data = await
|
|
2486
|
+
const name = Buffer.from(path13.relative(source, filePath).split(path13.sep).join("/"), "utf8");
|
|
2487
|
+
const data = await fs6.readFile(filePath);
|
|
1589
2488
|
const compressed = deflateRawSync(data);
|
|
1590
2489
|
const checksum = crc32(data);
|
|
1591
2490
|
const local = Buffer.alloc(30);
|
|
@@ -1632,7 +2531,7 @@ async function writeZip(source, archivePath) {
|
|
|
1632
2531
|
end.writeUInt32LE(centralDirectory.length, 12);
|
|
1633
2532
|
end.writeUInt32LE(offset, 16);
|
|
1634
2533
|
end.writeUInt16LE(0, 20);
|
|
1635
|
-
await
|
|
2534
|
+
await fs6.writeFile(archivePath, Buffer.concat([...localParts, centralDirectory, end]), { flag: "wx", mode: 384 });
|
|
1636
2535
|
}
|
|
1637
2536
|
function commandFailure(result) {
|
|
1638
2537
|
if (result.code !== 0) return `hdc exited with code ${result.code}`;
|
|
@@ -1644,12 +2543,12 @@ function errorMessage(error) {
|
|
|
1644
2543
|
return error instanceof Error ? error.message : String(error);
|
|
1645
2544
|
}
|
|
1646
2545
|
async function uniqueBundleDirectory(outputDirectory, stamp) {
|
|
1647
|
-
await
|
|
2546
|
+
await fs6.mkdir(outputDirectory, { recursive: true, mode: 448 });
|
|
1648
2547
|
for (let suffix = 0; ; suffix++) {
|
|
1649
2548
|
const name = `incident-${stamp}${suffix ? `-${suffix}` : ""}`;
|
|
1650
|
-
const directory =
|
|
2549
|
+
const directory = path13.join(outputDirectory, name);
|
|
1651
2550
|
try {
|
|
1652
|
-
await
|
|
2551
|
+
await fs6.mkdir(directory, { mode: 448 });
|
|
1653
2552
|
return directory;
|
|
1654
2553
|
} catch (error) {
|
|
1655
2554
|
if (error.code !== "EEXIST") throw error;
|
|
@@ -1669,7 +2568,7 @@ async function collectLogBundle(options) {
|
|
|
1669
2568
|
}
|
|
1670
2569
|
const started = /* @__PURE__ */ new Date();
|
|
1671
2570
|
const stamp = started.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
1672
|
-
const bundleDirectory = await uniqueBundleDirectory(
|
|
2571
|
+
const bundleDirectory = await uniqueBundleDirectory(path13.resolve(options.outputDirectory), stamp);
|
|
1673
2572
|
const records = [];
|
|
1674
2573
|
const serials = [];
|
|
1675
2574
|
let replacementWarning = false;
|
|
@@ -1710,14 +2609,14 @@ async function collectLogBundle(options) {
|
|
|
1710
2609
|
]
|
|
1711
2610
|
};
|
|
1712
2611
|
const run = async (name, relativeOutput, args, deviceSerial) => {
|
|
1713
|
-
const outputPath =
|
|
1714
|
-
await
|
|
2612
|
+
const outputPath = path13.join(bundleDirectory, relativeOutput);
|
|
2613
|
+
await fs6.mkdir(path13.dirname(outputPath), { recursive: true, mode: 448 });
|
|
1715
2614
|
let result = null;
|
|
1716
2615
|
let failure = null;
|
|
1717
2616
|
try {
|
|
1718
2617
|
result = await execute(args, deviceSerial);
|
|
1719
|
-
await
|
|
1720
|
-
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 });
|
|
1721
2620
|
if (result.stdout.includes("\uFFFD") || result.stderr.includes("\uFFFD")) {
|
|
1722
2621
|
replacementWarning = true;
|
|
1723
2622
|
manifest.notes.push(`${name} output contains Unicode replacement characters; review the redacted evidence before sharing.`);
|
|
@@ -1725,7 +2624,7 @@ async function collectLogBundle(options) {
|
|
|
1725
2624
|
failure = commandFailure(result);
|
|
1726
2625
|
} catch (error) {
|
|
1727
2626
|
failure = errorMessage(error);
|
|
1728
|
-
await
|
|
2627
|
+
await fs6.writeFile(outputPath, "", { encoding: "utf8", mode: 384 });
|
|
1729
2628
|
}
|
|
1730
2629
|
const record = {
|
|
1731
2630
|
name,
|
|
@@ -1739,7 +2638,7 @@ async function collectLogBundle(options) {
|
|
|
1739
2638
|
return record;
|
|
1740
2639
|
};
|
|
1741
2640
|
const targetRecord = await run("list-targets", "device/targets.txt", ["list", "targets", "-v"]);
|
|
1742
|
-
const targetOutput = await
|
|
2641
|
+
const targetOutput = await fs6.readFile(path13.join(bundleDirectory, "device/targets.txt"), "utf8").catch(() => "");
|
|
1743
2642
|
const targets = parseDeviceList(targetOutput).filter((target) => target.status === "Connected" || target.status === "Unknown");
|
|
1744
2643
|
serials.push(...parseDeviceList(targetOutput).map((target) => target.serial));
|
|
1745
2644
|
const requestedDevice = resolveDeviceSerial(options.deviceSerial);
|
|
@@ -1781,9 +2680,9 @@ async function collectLogBundle(options) {
|
|
|
1781
2680
|
const hostElapsedMs = Date.now() - captureStartedHostMs;
|
|
1782
2681
|
if (deviceTimeBefore.status === "success" && hilog.status === "success" && deviceTimeAfter.status === "success") {
|
|
1783
2682
|
const [beforeText, afterText, fullHilog] = await Promise.all([
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
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")
|
|
1787
2686
|
]);
|
|
1788
2687
|
const window = filterHilogWindow(fullHilog, beforeText, afterText, durationSeconds, hostElapsedMs);
|
|
1789
2688
|
if (window.text === null) durationFailureReason = window.reason;
|
|
@@ -1811,7 +2710,7 @@ async function collectLogBundle(options) {
|
|
|
1811
2710
|
} else if (boundedHilog !== null && (options.pid || options.tag || options.level)) {
|
|
1812
2711
|
const filtered = filterHilog(boundedHilog, { pid: options.pid, tag: options.tag, level: options.level });
|
|
1813
2712
|
if (filtered) {
|
|
1814
|
-
await
|
|
2713
|
+
await fs6.writeFile(path13.join(bundleDirectory, "logs/hilog-filtered.txt"), filtered.text, { encoding: "utf8", mode: 384 });
|
|
1815
2714
|
const status = manifest.filterStatus;
|
|
1816
2715
|
for (const key of filtered.applied) status[key] = "applied";
|
|
1817
2716
|
} else {
|
|
@@ -1827,11 +2726,11 @@ async function collectLogBundle(options) {
|
|
|
1827
2726
|
manifest.collectionEndUtc = ended.toISOString();
|
|
1828
2727
|
const files = records.flatMap((record) => [record.output, record.stderr].filter((file) => Boolean(file)));
|
|
1829
2728
|
files.push("manifest.json", "notes.txt");
|
|
1830
|
-
if (await
|
|
2729
|
+
if (await fs6.access(path13.join(bundleDirectory, "logs/hilog-filtered.txt")).then(() => true, () => false)) {
|
|
1831
2730
|
files.push("logs/hilog-filtered.txt");
|
|
1832
2731
|
}
|
|
1833
2732
|
manifest.files = files;
|
|
1834
|
-
await
|
|
2733
|
+
await fs6.writeFile(path13.join(bundleDirectory, "notes.txt"), "Add reproduction steps and precise event timestamps here.\n", { encoding: "utf8", mode: 384 });
|
|
1835
2734
|
const durationFailure = !durationApplied;
|
|
1836
2735
|
const criticalFailure = Boolean(targetError) || records.some((record) => record.name === "hilog-snapshot" && record.status !== "success");
|
|
1837
2736
|
const anyCommandFailure = records.some((record) => record.status !== "success");
|
|
@@ -1842,16 +2741,16 @@ async function collectLogBundle(options) {
|
|
|
1842
2741
|
if (options.redact) {
|
|
1843
2742
|
redactedDirectory = `${bundleDirectory}-redacted`;
|
|
1844
2743
|
manifest.redactionStatus = replacementWarning ? "review-required" : "success";
|
|
1845
|
-
manifest.archiveStatus = { status: "success", path: `${
|
|
1846
|
-
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)}
|
|
1847
2746
|
`, { encoding: "utf8", mode: 384 });
|
|
1848
2747
|
await redactTree(bundleDirectory, redactedDirectory, serials, boundedHilog);
|
|
1849
2748
|
archivePath = `${redactedDirectory}.zip`;
|
|
1850
2749
|
await writeZip(redactedDirectory, archivePath);
|
|
1851
2750
|
} else {
|
|
1852
2751
|
archivePath = `${bundleDirectory}.zip`;
|
|
1853
|
-
manifest.archiveStatus = { status: "success", path:
|
|
1854
|
-
await
|
|
2752
|
+
manifest.archiveStatus = { status: "success", path: path13.basename(archivePath) };
|
|
2753
|
+
await fs6.writeFile(path13.join(bundleDirectory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
|
|
1855
2754
|
`, { encoding: "utf8", mode: 384 });
|
|
1856
2755
|
await writeZip(bundleDirectory, archivePath);
|
|
1857
2756
|
}
|
|
@@ -1861,9 +2760,9 @@ async function collectLogBundle(options) {
|
|
|
1861
2760
|
manifest.redactionStatus = options.redact ? "failed" : "not-requested";
|
|
1862
2761
|
manifest.archiveStatus = { status: "failed", path: null };
|
|
1863
2762
|
manifest.notes.push(bundleError);
|
|
1864
|
-
await
|
|
1865
|
-
if (redactedDirectory) await
|
|
1866
|
-
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)}
|
|
1867
2766
|
`, { encoding: "utf8", mode: 384 });
|
|
1868
2767
|
redactedDirectory = null;
|
|
1869
2768
|
}
|
|
@@ -1895,7 +2794,7 @@ function parseHilogTimestamp(datePart, timePart, referenceMs) {
|
|
|
1895
2794
|
const closest = candidates[0];
|
|
1896
2795
|
return closest !== void 0 && closest <= referenceMs + 2e3 ? closest : null;
|
|
1897
2796
|
}
|
|
1898
|
-
function filterHilogWindow(
|
|
2797
|
+
function filterHilogWindow(input2, deviceClockBefore, deviceClockAfter, durationSeconds, hostElapsedMs) {
|
|
1899
2798
|
if (!Number.isSafeInteger(durationSeconds) || durationSeconds < 1 || durationSeconds > 31536e3) {
|
|
1900
2799
|
return { text: null, reason: "requested duration is invalid" };
|
|
1901
2800
|
}
|
|
@@ -1911,7 +2810,7 @@ function filterHilogWindow(input, deviceClockBefore, deviceClockAfter, durationS
|
|
|
1911
2810
|
let sawRecord = false;
|
|
1912
2811
|
let keep = false;
|
|
1913
2812
|
let output = "";
|
|
1914
|
-
for (const line of
|
|
2813
|
+
for (const line of input2.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
|
|
1915
2814
|
const content = line.replace(/[\r\n]+$/, "");
|
|
1916
2815
|
const match = HILOG_RECORD.exec(content);
|
|
1917
2816
|
if (match?.groups) {
|
|
@@ -1936,6 +2835,53 @@ function filterHilogWindow(input, deviceClockBefore, deviceClockAfter, durationS
|
|
|
1936
2835
|
return sawRecord ? { text: output } : { text: null, reason: "no recognized timestamped HiLog records were found" };
|
|
1937
2836
|
}
|
|
1938
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
|
+
|
|
1939
2885
|
// src/commands/logs.ts
|
|
1940
2886
|
function registerLogsCommand(program) {
|
|
1941
2887
|
const logs = program.command("logs").description(
|
|
@@ -1943,7 +2889,7 @@ function registerLogsCommand(program) {
|
|
|
1943
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) => {
|
|
1944
2890
|
if (!opts.output) logs.error("error: required option '-o, --output <file>' not specified");
|
|
1945
2891
|
const { config, logger } = getRuntime();
|
|
1946
|
-
const file =
|
|
2892
|
+
const file = path15.resolve(opts.output);
|
|
1947
2893
|
const abort = new AbortController();
|
|
1948
2894
|
const stop = () => abort.abort();
|
|
1949
2895
|
process.once("SIGINT", stop);
|
|
@@ -1964,7 +2910,7 @@ function registerLogsCommand(program) {
|
|
|
1964
2910
|
process.stdout.write(`${result.file}
|
|
1965
2911
|
`);
|
|
1966
2912
|
});
|
|
1967
|
-
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) => {
|
|
1968
2914
|
const { config } = getRuntime();
|
|
1969
2915
|
const result = await collectLogBundle({
|
|
1970
2916
|
config,
|
|
@@ -1985,6 +2931,34 @@ function registerLogsCommand(program) {
|
|
|
1985
2931
|
throw new Error(`Bundle is partial; inspect manifest.json. ${result.error ?? "One or more collectors failed."}`);
|
|
1986
2932
|
}
|
|
1987
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
|
+
});
|
|
1988
2962
|
}
|
|
1989
2963
|
|
|
1990
2964
|
// src/commands/hdc.ts
|
|
@@ -2003,58 +2977,1807 @@ function registerHdcCommand(program) {
|
|
|
2003
2977
|
`);
|
|
2004
2978
|
return;
|
|
2005
2979
|
}
|
|
2006
|
-
await new Promise((
|
|
2980
|
+
await new Promise((resolve20, reject) => {
|
|
2007
2981
|
const child = spawnCompat(hdc, hdcPassthroughArgv(args, opts.device), { stdio: "inherit" });
|
|
2008
2982
|
child.on("error", (err) => reject(new Error(`Failed to run ${hdc}: ${err.message}`)));
|
|
2009
2983
|
child.on("close", (code) => {
|
|
2010
2984
|
process.exitCode = code ?? 1;
|
|
2011
|
-
|
|
2985
|
+
resolve20();
|
|
2012
2986
|
});
|
|
2013
2987
|
});
|
|
2014
2988
|
});
|
|
2015
2989
|
}
|
|
2016
2990
|
|
|
2017
|
-
// src/
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
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;
|
|
2044
3026
|
}
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
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)}.
|
|
2052
3187
|
`);
|
|
2053
|
-
|
|
2054
|
-
|
|
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})` : ""}
|
|
2055
3190
|
`);
|
|
3191
|
+
}
|
|
3192
|
+
if (suggestions.length) process.stdout.write(` close matches: ${suggestions.join(", ")}
|
|
3193
|
+
`);
|
|
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)}
|
|
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;
|
|
2056
4775
|
}
|
|
2057
|
-
|
|
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));
|
|
2058
4781
|
}
|
|
2059
4782
|
}
|
|
2060
4783
|
void main();
|