@hmharness/domain-harmony 0.6.5 → 0.6.7

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.
@@ -13,15 +13,40 @@ export interface UiRegressionResult {
13
13
  saw: string | null;
14
14
  description: string;
15
15
  screenshot: string | null;
16
+ /** true when NO provider actually looked at the image (all blind/failed).
17
+ * The verdict is then about the vision chain, NOT about the app: never
18
+ * render it as a product FAIL - a tool that reports FAIL when its own
19
+ * eyes are shut is worse than no tool. */
20
+ visionUnavailable?: boolean;
21
+ /** per-provider errors collected while walking the vision chain */
22
+ visionErrors?: string[];
16
23
  }
24
+ /** Injectable vision call (tests substitute it; production uses chatVision). */
25
+ export type VisionCall = (provider: ProviderConfig, prompt: string, imageDataUrl: string) => Promise<string>;
26
+ /**
27
+ * Walk the vision chain until a provider truly LOOKS at the image. A reply
28
+ * that is a refusal ("I can't view the image" - what a text-only provider
29
+ * returns with HTTP 200) is treated as a provider failure and the next
30
+ * candidate is tried. Returns the first real description, or the collected
31
+ * errors when every provider was blind/dead.
32
+ */
33
+ export declare function describeWithChain(chain: ProviderConfig[], prompt: string, imageDataUrl: string, call?: VisionCall): Promise<{
34
+ described: string | null;
35
+ errors: string[];
36
+ }>;
37
+ /** Keyword assertion on a genuine description (any hit wins). */
38
+ export declare function assertKeywords(described: string, expect: string[]): string | null;
17
39
  /** Capture the device screen via hdc; returns the local file path. */
18
40
  export declare function captureDeviceScreen(hdc: string, target: string | undefined, outDir: string): Promise<string>;
19
41
  /** One regression case through the real device + vision chain. */
20
42
  export declare function runUiRegression(opts: {
21
43
  hdc: string;
22
44
  target?: string;
23
- vision: ProviderConfig;
45
+ /** one provider, or the ordered chain (vision + visionFallbacks) */
46
+ vision: ProviderConfig | ProviderConfig[];
24
47
  cases: UiRegressionCase[];
25
48
  outDir: string;
49
+ /** test seam: substitute the multimodal call */
50
+ visionCall?: VisionCall;
26
51
  }): Promise<UiRegressionResult[]>;
27
52
  export declare const harmonyUiRegression: Tool;
package/dist/uiregress.js CHANGED
@@ -19,8 +19,36 @@ import { execFile } from 'node:child_process';
19
19
  import { mkdir, readFile, rm } from 'node:fs/promises';
20
20
  import { join } from 'node:path';
21
21
  import { promisify } from 'node:util';
22
- import { chatVision } from '@hmharness/kernel';
22
+ import { chatVision, foundNothing, isVisionRefusal } from '@hmharness/kernel';
23
23
  const execCb = promisify(execFile);
24
+ /**
25
+ * Walk the vision chain until a provider truly LOOKS at the image. A reply
26
+ * that is a refusal ("I can't view the image" - what a text-only provider
27
+ * returns with HTTP 200) is treated as a provider failure and the next
28
+ * candidate is tried. Returns the first real description, or the collected
29
+ * errors when every provider was blind/dead.
30
+ */
31
+ export async function describeWithChain(chain, prompt, imageDataUrl, call = chatVision) {
32
+ const errors = [];
33
+ for (const provider of chain) {
34
+ try {
35
+ const text = await call(provider, prompt, imageDataUrl);
36
+ if (isVisionRefusal(text)) {
37
+ errors.push(`${provider.model}: answered without seeing the image ("${text.trim().slice(0, 80)}")`);
38
+ continue;
39
+ }
40
+ return { described: text, errors };
41
+ }
42
+ catch (err) {
43
+ errors.push(`${provider.model}: ${String(err).slice(0, 120)}`);
44
+ }
45
+ }
46
+ return { described: null, errors };
47
+ }
48
+ /** Keyword assertion on a genuine description (any hit wins). */
49
+ export function assertKeywords(described, expect) {
50
+ return expect.find((k) => described.toLowerCase().includes(k.toLowerCase())) ?? null;
51
+ }
24
52
  /** Capture the device screen via hdc; returns the local file path. */
25
53
  export async function captureDeviceScreen(hdc, target, outDir) {
26
54
  await mkdir(outDir, { recursive: true });
@@ -45,6 +73,7 @@ export async function captureDeviceScreen(hdc, target, outDir) {
45
73
  }
46
74
  /** One regression case through the real device + vision chain. */
47
75
  export async function runUiRegression(opts) {
76
+ const chain = Array.isArray(opts.vision) ? opts.vision : [opts.vision];
48
77
  const results = [];
49
78
  for (const c of opts.cases) {
50
79
  const pre = opts.target ? ['-t', opts.target] : [];
@@ -66,17 +95,38 @@ export async function runUiRegression(opts) {
66
95
  results.push({ case: c.name, pass: false, saw: null, description: 'screenshot failed: ' + String(err).slice(0, 120), screenshot: null });
67
96
  continue;
68
97
  }
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 });
98
+ // vision describe (through the chain; refusals do not count as seeing)
99
+ const b64 = (await readFile(shot)).toString('base64');
100
+ const { described, errors } = await describeWithChain(chain, '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}`, opts.visionCall);
101
+ if (described === null) {
102
+ // EVERY provider failed to look: this says nothing about the app.
103
+ results.push({
104
+ case: c.name,
105
+ pass: false,
106
+ saw: null,
107
+ visionUnavailable: true,
108
+ visionErrors: errors,
109
+ description: `VISION PROVIDER FAILURE (no UI verdict): none of the ${chain.length} configured vision provider(s) could see the image - ${errors.join('; ')}`,
110
+ screenshot: shot,
111
+ });
112
+ continue;
76
113
  }
77
- catch (err) {
78
- results.push({ case: c.name, pass: false, saw: null, description: 'vision failed: ' + String(err).slice(0, 150), screenshot: shot });
114
+ const text = described.trim();
115
+ const saw = assertKeywords(text, c.expect);
116
+ if (!saw && foundNothing(text)) {
117
+ // the model itself says it read no text: no evidence either way
118
+ results.push({
119
+ case: c.name,
120
+ pass: false,
121
+ saw: null,
122
+ visionUnavailable: true,
123
+ visionErrors: [...errors, `${chain[0].model}: reported no readable text on screen ("${text.slice(0, 120)}")`],
124
+ description: `NO VERDICT - the vision model reported no readable text on screen (blank screen, or a provider that cannot see): ${text.slice(0, 240)}`,
125
+ screenshot: shot,
126
+ });
127
+ continue;
79
128
  }
129
+ results.push({ case: c.name, pass: Boolean(saw), saw, description: text.slice(0, 400), screenshot: shot, visionErrors: errors });
80
130
  }
81
131
  return results;
82
132
  }
@@ -99,18 +149,20 @@ export const harmonyUiRegression = {
99
149
  const expect = Array.isArray(args.expect) ? args.expect.map(String).filter(Boolean) : [];
100
150
  if (!bundle || expect.length === 0)
101
151
  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');
152
+ // vision provider from config (kernel routing): the full chain, so a
153
+ // blind or dead provider is retried rather than graded as a UI verdict
154
+ const { loadConfig, visionProviderChain } = await import('@hmharness/kernel');
104
155
  const cfg = await loadConfig();
105
156
  let vision;
106
157
  try {
107
- vision = resolveProvider(cfg, 'vision');
108
- if (!vision.apiKey)
158
+ vision = visionProviderChain(cfg);
159
+ if (vision.length === 0 || !vision[0].apiKey)
109
160
  throw new Error('no key');
110
161
  }
111
162
  catch {
112
163
  return { output: 'No vision provider configured (vision block or providers+routing.vision) - harmony_ui_regression needs one.', isError: true };
113
164
  }
165
+ const chainNames = vision.map((p) => `${p.model}${p.supportsVision === false ? ' (marked text-only!)' : ''}`);
114
166
  // hdc
115
167
  const deveco = process.env.HM_DEVECO_HOME ?? 'C:\\DevEco-Studio';
116
168
  let hdc = 'hdc';
@@ -136,12 +188,31 @@ export const harmonyUiRegression = {
136
188
  outDir,
137
189
  });
138
190
  const r = results[0];
191
+ if (r.visionUnavailable) {
192
+ // The machine could not look at the screen. Report that loudly and do
193
+ // NOT dress it up as a product verdict.
194
+ return {
195
+ output: [
196
+ `UI regression: ${r.case}`,
197
+ `NO VERDICT - vision provider failure, not a UI result`,
198
+ `vision chain tried (${chainNames.join(' -> ')}):`,
199
+ ...(r.visionErrors ?? []).map((e) => ` - ${e}`),
200
+ ...(r.screenshot ? [`screenshot: ${r.screenshot}`] : []),
201
+ 'Fix: point routing.vision at a model that accepts images, or mark the text-only one with "supportsVision": false so it is skipped.',
202
+ 'Ground truth without vision: hdc shell uitest dumpLayout -p /data/local/tmp/layout.json (the view tree carries every node text).',
203
+ ].join('\n'),
204
+ isError: true,
205
+ };
206
+ }
139
207
  const lines = [
140
208
  `UI regression: ${r.case}`,
141
209
  r.saw ? `PASS - saw "${r.saw}" on screen` : `FAIL - none of [${expect.join(', ')}] visible`,
142
210
  ...(r.screenshot ? [`screenshot: ${r.screenshot}`] : []),
143
211
  `vision said: ${r.description}`,
144
212
  ];
213
+ if (r.visionErrors?.length) {
214
+ lines.push(`degraded chain (earlier provider(s) did not see the image): ${r.visionErrors.join('; ')}`);
215
+ }
145
216
  return { output: lines.join('\n'), isError: !r.pass };
146
217
  },
147
218
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/domain-harmony",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
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
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "build": "tsc -p tsconfig.build.json"
16
16
  },
17
17
  "dependencies": {
18
- "@hmharness/kernel": "0.6.5"
18
+ "@hmharness/kernel": "0.6.7"
19
19
  },
20
20
  "files": [
21
21
  "dist"