@hmharness/domain-harmony 0.1.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.
@@ -0,0 +1,26 @@
1
+ import type { Tool } from '@hmharness/kernel';
2
+ export interface SigningIdentity {
3
+ profile: string;
4
+ p12: string;
5
+ cer: string | null;
6
+ keyAlias: string;
7
+ storePassword: string;
8
+ origin: string;
9
+ }
10
+ export declare function hapsignToolPaths(devecoHome: string): {
11
+ jar: string;
12
+ p12: string;
13
+ pem: string;
14
+ template: string;
15
+ java: string;
16
+ };
17
+ export declare function resolveSigningIdentity(devecoHome: string, explicit?: Partial<SigningIdentity>): Promise<SigningIdentity | null>;
18
+ /** Clone the SDK debug profile template with a FRESH validity window
19
+ * (the shipped one is 2021-2023 = expired) and sign it into a p7b. */
20
+ export declare function ensureDebugProfile(devecoHome: string, tmpDir: string, log?: (l: string) => void): Promise<string>;
21
+ export interface SignResult {
22
+ signed: string;
23
+ }
24
+ /** Sign one unsigned .hap (SDK debug identity path). */
25
+ export declare function signHap(hap: string, id: SigningIdentity, devecoHome: string, tmpDir: string, log?: (l: string) => void): Promise<SignResult>;
26
+ export declare const harmonySign: Tool;
@@ -0,0 +1,209 @@
1
+ /**
2
+ * @hmharness/domain-harmony - signing (hapsigntool wrapper, device-proven)
3
+ * Signs a built .hap with the local debug identity so it installs on
4
+ * emulator/developer-mode devices. Production (release/AGC) signing stays
5
+ * in DevEco/AppGallery - this wraps the DEBUG flow.
6
+ *
7
+ * FULL DEVICE-PROVEN CHAIN (validated on emulator 2026-09-05, the signed
8
+ * hap installed+launched+logged "EntryAbility onCreate"):
9
+ * 1. the SDK debug profile TEMPLATE (UnsgnedDebugProfileTemplate.json)
10
+ * ships with a 2021-2023 validity window - EXPIRED. We clone it with
11
+ * a fresh now+30y window (new uuid) into HMH_HOME/tmp.
12
+ * 2. sign-profile: keyAlias "openharmony application profile debug",
13
+ * profileCertFile = OpenHarmonyProfileDebug.pem (a 3-cert chain),
14
+ * keystore OpenHarmony.p12 (pwd 123456) -> a fresh .p7b.
15
+ * 3. sign-app: keyAlias "openharmony application profile debug" (!),
16
+ * appCertFile = the same 3-cert pem, profileFile = the fresh p7b,
17
+ * mode localSign -> <name>-signed.hap next to the unsigned one.
18
+ * Gotchas learned the hard way:
19
+ * - keyAlias for sign-app is the PROFILE DEBUG alias, not "application
20
+ * release" (its self-signed cert fails the chain check).
21
+ * - -mode value is localSign (not debug/release).
22
+ * - hdc install wants backslash-normalized Windows paths.
23
+ * Identity override: explicit profile+p12 args skip the SDK flow.
24
+ */
25
+ import { execFile } from 'node:child_process';
26
+ import { access, mkdir, readdir, writeFile } from 'node:fs/promises';
27
+ import { join, resolve } from 'node:path';
28
+ import { promisify } from 'node:util';
29
+ const execCb = promisify(execFile);
30
+ async function exists(p) {
31
+ try {
32
+ await access(p);
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ export function hapsignToolPaths(devecoHome) {
40
+ const lib = join(devecoHome, 'sdk', 'default', 'openharmony', 'toolchains', 'lib');
41
+ return {
42
+ jar: join(lib, 'hap-sign-tool.jar'),
43
+ p12: join(lib, 'OpenHarmony.p12'),
44
+ pem: join(lib, 'OpenHarmonyProfileDebug.pem'),
45
+ template: join(lib, 'UnsgnedDebugProfileTemplate.json'),
46
+ java: join(devecoHome, 'jbr', 'bin', process.platform === 'win32' ? 'java.exe' : 'java'),
47
+ };
48
+ }
49
+ export async function resolveSigningIdentity(devecoHome, explicit = {}) {
50
+ if (explicit.profile && explicit.p12) {
51
+ if ((await exists(explicit.profile)) && (await exists(explicit.p12))) {
52
+ return { profile: explicit.profile, p12: explicit.p12, cer: explicit.cer ?? null, keyAlias: explicit.keyAlias ?? 'openharmony application profile debug', storePassword: explicit.storePassword ?? '123456', origin: 'explicit' };
53
+ }
54
+ return null;
55
+ }
56
+ // DevEco auto-sign materials (user-level) - the profile must be a real
57
+ // .p7b; ~/.ohos/config stores handles not files, so only adopt when the
58
+ // user explicitly passes them. The always-works path is the SDK flow.
59
+ const p = hapsignToolPaths(devecoHome);
60
+ if ((await exists(p.p12)) && (await exists(p.pem))) {
61
+ return { profile: '(generated)', p12: p.p12, cer: p.pem, keyAlias: 'openharmony application profile debug', storePassword: '123456', origin: 'sdk debug identity' };
62
+ }
63
+ return null;
64
+ }
65
+ /** Clone the SDK debug profile template with a FRESH validity window
66
+ * (the shipped one is 2021-2023 = expired) and sign it into a p7b. */
67
+ export async function ensureDebugProfile(devecoHome, tmpDir, log) {
68
+ const p = hapsignToolPaths(devecoHome);
69
+ const outP7b = join(tmpDir, 'ohos-debug-fresh.p7b');
70
+ if (await exists(outP7b))
71
+ return outP7b; // fresh enough (regenerated per HMH_HOME tmp lifecycle)
72
+ const { readFile } = await import('node:fs/promises');
73
+ const tpl = JSON.parse(await readFile(p.template, 'utf8'));
74
+ const now = Math.floor(Date.now() / 1000);
75
+ tpl.validity = { 'not-before': now - 86400, 'not-after': now + 30 * 365 * 86400 };
76
+ tpl.uuid = 'hmh-' + Math.random().toString(36).slice(2) + '-' + Math.random().toString(36).slice(2, 10);
77
+ const tplPath = join(tmpDir, 'debug-profile.json');
78
+ await writeFile(tplPath, JSON.stringify(tpl, null, 2), 'utf8');
79
+ await execCb(p.java, [
80
+ '-jar', p.jar, 'sign-profile',
81
+ '-mode', 'localSign',
82
+ '-keyAlias', 'openharmony application profile debug',
83
+ '-keyPwd', '123456', '-keystoreFile', p.p12, '-keystorePwd', '123456',
84
+ '-profileCertFile', p.pem,
85
+ '-inFile', tplPath, '-signAlg', 'SHA256withECDSA',
86
+ '-outFile', outP7b,
87
+ ], { timeout: 60_000, windowsHide: true });
88
+ log?.(`debug profile generated: ${outP7b}`);
89
+ return outP7b;
90
+ }
91
+ /** Sign one unsigned .hap (SDK debug identity path). */
92
+ export async function signHap(hap, id, devecoHome, tmpDir, log) {
93
+ const p = hapsignToolPaths(devecoHome);
94
+ const base = hap.replace(/-unsigned(\.hap)?$/i, '');
95
+ const out = (base.endsWith('.hap') ? base.replace(/\.hap$/i, '') : base) + '-signed.hap';
96
+ const profile = id.profile === '(generated)' ? await ensureDebugProfile(devecoHome, tmpDir, log) : id.profile;
97
+ const cmd = [
98
+ '-jar', p.jar, 'sign-app',
99
+ '-mode', 'localSign',
100
+ '-keyAlias', id.keyAlias,
101
+ '-keyPwd', id.storePassword, '-keystoreFile', id.p12, '-keystorePwd', id.storePassword,
102
+ '-appCertFile', id.cer ?? p.pem,
103
+ '-profileFile', profile,
104
+ '-inFile', hap, '-signAlg', 'SHA256withECDSA',
105
+ '-outFile', out,
106
+ ];
107
+ log?.(`java -jar hap-sign-tool.jar sign-app (profile: ${profile})`);
108
+ await execCb(p.java, cmd, { timeout: 120_000, windowsHide: true });
109
+ return { signed: out };
110
+ }
111
+ export const harmonySign = {
112
+ name: 'harmony_sign',
113
+ description: 'Sign a built .hap with the local debug identity (SDK OpenHarmony identity by default; explicit profile+p12 override) so it installs on emulator/developer-mode devices. Auto-generates a fresh debug profile (the SDK template 2021 validity is expired). Output lands beside the unsigned hap (-signed suffix). Release/AGC signing stays in DevEco.',
114
+ parameters: {
115
+ type: 'object',
116
+ properties: {
117
+ hap: { type: 'string', description: 'unsigned .hap path (default: newest under the project build output)' },
118
+ profile: { type: 'string', description: 'explicit provisioning profile (.p7b) path' },
119
+ p12: { type: 'string', description: 'explicit signing keystore path' },
120
+ cer: { type: 'string', description: 'explicit app cert chain path' },
121
+ key_alias: { type: 'string', description: 'key alias (default: the SDK debug alias)' },
122
+ store_password: { type: 'string', description: 'keystore password (default: 123456 debug)' },
123
+ },
124
+ required: [],
125
+ },
126
+ needsApproval: () => true,
127
+ async execute(args, ctx) {
128
+ const deveco = process.env.HM_DEVECO_HOME ?? 'C:\\DevEco-Studio';
129
+ const p = hapsignToolPaths(deveco);
130
+ if (!(await exists(p.jar)))
131
+ return { output: `hap-sign-tool.jar not found at ${p.jar}. Set HM_DEVECO_HOME.`, isError: true };
132
+ if (!(await exists(p.java)))
133
+ return { output: `DevEco bundled java not found at ${p.java}. Set HM_DEVECO_HOME.`, isError: true };
134
+ const id = await resolveSigningIdentity(deveco, {
135
+ profile: typeof args.profile === 'string' ? args.profile : undefined,
136
+ p12: typeof args.p12 === 'string' ? args.p12 : undefined,
137
+ cer: typeof args.cer === 'string' ? args.cer : undefined,
138
+ keyAlias: typeof args.key_alias === 'string' ? args.key_alias : undefined,
139
+ storePassword: typeof args.store_password === 'string' ? args.store_password : undefined,
140
+ });
141
+ if (!id)
142
+ return { output: 'No signing identity: SDK lib files missing (OpenHarmony.p12 / OpenHarmonyProfileDebug.pem), or pass profile+p12 explicitly.', isError: true };
143
+ // resolve the hap
144
+ let hap = typeof args.hap === 'string' && args.hap ? resolve(ctx.cwd, args.hap) : '';
145
+ if (!hap) {
146
+ let dir = ctx.cwd;
147
+ for (;;) {
148
+ if (await exists(join(dir, 'build-profile.json5')))
149
+ break;
150
+ const parent = resolve(dir, '..');
151
+ if (parent === dir)
152
+ return { output: 'No .hap given and no project found around cwd.', isError: true };
153
+ dir = parent;
154
+ }
155
+ const { stat } = await import('node:fs/promises');
156
+ let best = '';
157
+ let bestMtime = 0;
158
+ const stack = [dir];
159
+ while (stack.length) {
160
+ const d = stack.pop();
161
+ let entries;
162
+ try {
163
+ entries = await readdir(d, { withFileTypes: true });
164
+ }
165
+ catch {
166
+ continue;
167
+ }
168
+ for (const e of entries) {
169
+ const pp = join(d, e.name);
170
+ if (e.isDirectory()) {
171
+ if (!['node_modules', 'oh_modules', '.hvigor', '.preview'].includes(e.name))
172
+ stack.push(pp);
173
+ }
174
+ else if (e.name.endsWith('.hap') && !e.name.includes('-signed')) {
175
+ const m = (await stat(pp)).mtimeMs;
176
+ if (m > bestMtime) {
177
+ bestMtime = m;
178
+ best = pp;
179
+ }
180
+ }
181
+ }
182
+ }
183
+ hap = best;
184
+ if (!hap)
185
+ return { output: 'No unsigned .hap found. Build first with harmony_build.', isError: true };
186
+ }
187
+ if (!(await exists(hap)))
188
+ return { output: `No such file: ${hap}`, isError: true };
189
+ const tmpDir = join(ctx.home, 'tmp');
190
+ await mkdir(tmpDir, { recursive: true });
191
+ try {
192
+ const r = await signHap(hap, id, deveco, tmpDir);
193
+ const ok = await exists(r.signed);
194
+ return {
195
+ output: [
196
+ `identity: ${id.origin}`,
197
+ `signed: ${r.signed}`,
198
+ `source: ${hap}`,
199
+ ok ? 'OK - install the signed hap (harmony_install / harmony_device_test).' : 'WARNING: output missing after sign',
200
+ ].join('\n'),
201
+ isError: !ok,
202
+ };
203
+ }
204
+ catch (err) {
205
+ const e = err;
206
+ return { output: ['sign failed:', e.stdout || '', e.stderr || e.message || ''].filter(Boolean).join('\n').slice(0, 2000), isError: true };
207
+ }
208
+ },
209
+ };
@@ -0,0 +1,27 @@
1
+ import { type ProviderConfig, type Tool } from '@hmharness/kernel';
2
+ export interface UiRegressionCase {
3
+ name: string;
4
+ bundle: string;
5
+ ability: string;
6
+ /** keywords the vision model must find on screen (any = hit) */
7
+ expect: string[];
8
+ settleMs?: number;
9
+ }
10
+ export interface UiRegressionResult {
11
+ case: string;
12
+ pass: boolean;
13
+ saw: string | null;
14
+ description: string;
15
+ screenshot: string | null;
16
+ }
17
+ /** Capture the device screen via hdc; returns the local file path. */
18
+ export declare function captureDeviceScreen(hdc: string, target: string | undefined, outDir: string): Promise<string>;
19
+ /** One regression case through the real device + vision chain. */
20
+ export declare function runUiRegression(opts: {
21
+ hdc: string;
22
+ target?: string;
23
+ vision: ProviderConfig;
24
+ cases: UiRegressionCase[];
25
+ outDir: string;
26
+ }): Promise<UiRegressionResult[]>;
27
+ export declare const harmonyUiRegression: Tool;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @hmharness/domain-harmony - uiregress (visual UI regression, quality trio #2)
3
+ * Minimal honest version of the old line's visual-regression gap, built
4
+ * from parts that already exist and are device-proven:
5
+ * hdc shell snapshot_display / uitest UIRecord screenshots + see_image
6
+ * (multi-provider vision) + keyword assertion.
7
+ *
8
+ * A regression case = { name, launch bundle/ability, expect: keywords the
9
+ * vision model MUST see on the device screen }. Run = launch -> screenshot
10
+ * -> vision describe -> keyword assert -> PASS/FAIL with the description
11
+ * attached as evidence. Fuzzy by nature (vision), so:
12
+ * - every verdict quotes the model's actual description (auditable)
13
+ * - a case only passes on an exact keyword hit, never "looks fine"
14
+ * Deliberately NOT pixel-diff: pixel diffs break on emulator GPU font
15
+ * rendering; semantic presence of expected UI text/elements is the
16
+ * production-honest signal.
17
+ */
18
+ import { execFile } from 'node:child_process';
19
+ import { mkdir, readFile, rm } from 'node:fs/promises';
20
+ import { join } from 'node:path';
21
+ import { promisify } from 'node:util';
22
+ import { chatVision } from '@hmharness/kernel';
23
+ const execCb = promisify(execFile);
24
+ /** Capture the device screen via hdc; returns the local file path. */
25
+ export async function captureDeviceScreen(hdc, target, outDir) {
26
+ await mkdir(outDir, { recursive: true });
27
+ const pre = target ? ['-t', target] : [];
28
+ // openharmony uitest UIRecord is the stable screenshot route on device
29
+ const remote = '/data/local/tmp/hmh-shot.jpeg';
30
+ await execCb(hdc, [...pre, 'shell', 'snapshot_display', '-f', '/data/local/tmp/hmh-shot.jpeg'], { timeout: 30_000, windowsHide: true }).catch(() => undefined);
31
+ const local = join(outDir, `ui-${Date.now()}.jpeg`);
32
+ await execCb(hdc, [...pre, 'file', 'recv', remote, local], { timeout: 30_000, windowsHide: true });
33
+ // verify the recv produced a non-empty file; retry with uitest if not
34
+ try {
35
+ const st = await readFile(local).then((b) => b.length).catch(() => 0);
36
+ if (st > 1000)
37
+ return local;
38
+ }
39
+ catch { /* fall through to uitest */ }
40
+ await execCb(hdc, [...pre, 'shell', 'uitest', 'UIRecord', 'start'].flat(), { timeout: 20_000, windowsHide: true }).catch(() => undefined);
41
+ await rm(local, { force: true }).catch(() => undefined);
42
+ await execCb(hdc, [...pre, 'shell', 'uitest', 'UIRecord', 'lastOutput', remote], { timeout: 20_000, windowsHide: true }).catch(() => undefined);
43
+ await execCb(hdc, [...pre, 'file', 'recv', remote, local], { timeout: 30_000, windowsHide: true });
44
+ return local;
45
+ }
46
+ /** One regression case through the real device + vision chain. */
47
+ export async function runUiRegression(opts) {
48
+ const results = [];
49
+ for (const c of opts.cases) {
50
+ const pre = opts.target ? ['-t', opts.target] : [];
51
+ // launch
52
+ try {
53
+ await execCb(opts.hdc, [...pre, 'shell', 'aa', 'start', '-a', c.ability, '-b', c.bundle], { timeout: 30_000, windowsHide: true });
54
+ }
55
+ catch (err) {
56
+ results.push({ case: c.name, pass: false, saw: null, description: 'launch failed: ' + String(err).slice(0, 120), screenshot: null });
57
+ continue;
58
+ }
59
+ await new Promise((r) => setTimeout(r, c.settleMs ?? 3500));
60
+ // screenshot
61
+ let shot = null;
62
+ try {
63
+ shot = await captureDeviceScreen(opts.hdc, opts.target, opts.outDir);
64
+ }
65
+ catch (err) {
66
+ results.push({ case: c.name, pass: false, saw: null, description: 'screenshot failed: ' + String(err).slice(0, 120), screenshot: null });
67
+ continue;
68
+ }
69
+ // vision describe
70
+ try {
71
+ const b64 = (await readFile(shot)).toString('base64');
72
+ const text = await chatVision(opts.vision, 'Describe this device screen briefly. Then on the last line output exactly: FOUND: <the most prominent UI text you can read>.', `data:image/jpeg;base64,${b64}`);
73
+ const described = text.trim();
74
+ const saw = c.expect.find((k) => described.toLowerCase().includes(k.toLowerCase())) ?? null;
75
+ results.push({ case: c.name, pass: Boolean(saw), saw, description: described.slice(0, 400), screenshot: shot });
76
+ }
77
+ catch (err) {
78
+ results.push({ case: c.name, pass: false, saw: null, description: 'vision failed: ' + String(err).slice(0, 150), screenshot: shot });
79
+ }
80
+ }
81
+ return results;
82
+ }
83
+ export const harmonyUiRegression = {
84
+ name: 'harmony_ui_regression',
85
+ description: 'Visual UI regression on a connected device/emulator: launch the app, screenshot the real screen, describe it with the vision model, and assert expected keywords are visible. Each verdict quotes the model description (auditable, never a blind pass). Cases given inline (bundle/ability/expect keywords). Semantic presence check, not pixel diff - resilient to GPU font rendering differences.',
86
+ parameters: {
87
+ type: 'object',
88
+ properties: {
89
+ bundle: { type: 'string', description: 'bundle to launch' },
90
+ ability: { type: 'string', description: 'ability to launch (default EntryAbility)' },
91
+ expect: { type: 'array', items: { type: 'string' }, description: 'keywords that must be visible on screen (any hit = pass)' },
92
+ target: { type: 'string', description: 'device target id from harmony_devices' },
93
+ },
94
+ required: ['bundle', 'expect'],
95
+ },
96
+ needsApproval: () => true, // launches apps + writes screenshot files
97
+ async execute(args, ctx) {
98
+ const bundle = String(args.bundle ?? '').trim();
99
+ const expect = Array.isArray(args.expect) ? args.expect.map(String).filter(Boolean) : [];
100
+ if (!bundle || expect.length === 0)
101
+ return { output: 'bundle and non-empty expect[] required', isError: true };
102
+ // vision provider from config (kernel routing)
103
+ const { loadConfig, resolveProvider } = await import('@hmharness/kernel');
104
+ const cfg = await loadConfig();
105
+ let vision;
106
+ try {
107
+ vision = resolveProvider(cfg, 'vision');
108
+ if (!vision.apiKey)
109
+ throw new Error('no key');
110
+ }
111
+ catch {
112
+ return { output: 'No vision provider configured (vision block or providers+routing.vision) - harmony_ui_regression needs one.', isError: true };
113
+ }
114
+ // hdc
115
+ const deveco = process.env.HM_DEVECO_HOME ?? 'C:\\DevEco-Studio';
116
+ let hdc = 'hdc';
117
+ try {
118
+ await execCb(hdc, ['--version'], { timeout: 8000, windowsHide: true });
119
+ }
120
+ catch {
121
+ const cand = join(deveco, 'sdk', 'default', 'openharmony', 'toolchains', 'hdc.exe');
122
+ try {
123
+ await readFile(cand);
124
+ hdc = cand;
125
+ }
126
+ catch {
127
+ return { output: 'hdc not found.', isError: true };
128
+ }
129
+ }
130
+ const outDir = join(ctx.home, 'tmp', 'uiregress');
131
+ const results = await runUiRegression({
132
+ hdc,
133
+ target: typeof args.target === 'string' ? args.target : undefined,
134
+ vision,
135
+ cases: [{ name: `${bundle}/${String(args.ability ?? 'EntryAbility')}`, bundle, ability: String(args.ability ?? 'EntryAbility'), expect }],
136
+ outDir,
137
+ });
138
+ const r = results[0];
139
+ const lines = [
140
+ `UI regression: ${r.case}`,
141
+ r.saw ? `PASS - saw "${r.saw}" on screen` : `FAIL - none of [${expect.join(', ')}] visible`,
142
+ ...(r.screenshot ? [`screenshot: ${r.screenshot}`] : []),
143
+ `vision said: ${r.description}`,
144
+ ];
145
+ return { output: lines.join('\n'), isError: !r.pass };
146
+ },
147
+ };
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@hmharness/domain-harmony",
3
+ "version": "0.1.0",
4
+ "description": "hmharness HarmonyOS domain core: device, toolchain, build, and project lifecycle capabilities. HarmonyOS is the framework's native domain, not an add-on.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "types": "dist/index.d.ts",
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.build.json"
16
+ },
17
+ "dependencies": {
18
+ "@hmharness/kernel": "0.1.0"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "license": "Apache-2.0",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/swsgbl/hmharness.git"
27
+ },
28
+ "engines": {
29
+ "node": ">=22"
30
+ }
31
+ }