diff --git a/client/electron/test/harmony-deviceprobe.test.mjs b/client/electron/test/harmony-deviceprobe.test.mjs new file mode 100644 index 0000000..98c3bf1 --- /dev/null +++ b/client/electron/test/harmony-deviceprobe.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/* +设备读回的**前置闸**(pi 邮件 `971c58fa` §4 / `bf583b0f` §2)。 + +危险形状不是"读不到",而是**读到别人的界面却当成我们的**: +断言可能通过、也可能红,**两者都不是在讲我们的界面** —— 前者是假绿。 +所以这条判据喂**四类样本**,其中最容易被漏的是「② 别人的合法 dump,且含与我们要判的控件同名/同文案的项」。 +*/ + +const HERE = dirname(fileURLToPath(import.meta.url)); +const MODEL = join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'model'); +const C = await import(pathToFileURL(join(MODEL, 'DeviceProbe.ts')).href); + +const OURS = 'com.jianf.agentmail'; +const THEIRS = 'com.example.homeagent'; + +// ① 我们的 dump(含我们要判的那个控件) +const dumpOurs = [ + 'MissionList:', + ' mission #1', + ' appType: APP', + ' bundleName: com.jianf.agentmail', + ' windowTitle: 邮件', + ' abilityName: EntryAbility', +].join('\n'); + +// ② 别人的**合法** dump —— 而且**故意**带一项与我们要判的控件同名/同文案的东西 +const dumpTheirs = [ + 'MissionList:', + ' mission #1', + ' appType: APP', + ' bundleName: com.example.homeagent', + ' windowTitle: 邮件', // 文案一样 + ' abilityName: EntryAbility', // ability 名也可能一样 + ' component: MailList', // 控件名一样 +].join('\n'); + +test('★ 闸①:我们自己的 dump ⇒ ours(且能取出标题)', () => { + assert.equal(C.foregroundVerdict(dumpOurs, OURS), C.VERDICT_OURS); + assert.equal(C.mayAssertOn(dumpOurs, OURS), true); + assert.equal(C.parseForeground(dumpOurs).windowTitle, '邮件'); +}); + +test('★ 闸②:别人的**合法** dump(含同名控件/同文案)⇒ other,绝不算我们的', () => { + assert.equal(C.foregroundVerdict(dumpTheirs, OURS), C.VERDICT_OTHER, + '这一条是最容易漏的输入:格式完全合法、控件名与文案都与我们相同 —— 按文案判就会静默放行'); + assert.equal(C.mayAssertOn(dumpTheirs, OURS), false); + assert.equal(C.parseForeground(dumpTheirs).windowTitle, '邮件', '文案确实一样(所以判据只能看 bundle)'); + assert.ok(C.unverifiedReason(C.VERDICT_OTHER).includes('未验'), '拿不到独占 ⇒ 状态词必须是"未验"'); +}); + +test('★ 闸③:截断/畸形 dump ⇒ unverified(缺证据 ≠ 没有那个现象)', () => { + assert.equal(C.foregroundVerdict('MissionList:\n mission #1\n appType: APP', OURS), C.VERDICT_UNVERIFIED); + assert.equal(C.foregroundVerdict('{不是 dump', OURS), C.VERDICT_UNVERIFIED); + assert.equal(C.mayAssertOn('', OURS), false); +}); + +test('★ 闸④:空 dump ⇒ unverified(窗口还没起来时最容易出现,也最容易被当成"坏现象不存在")', () => { + assert.equal(C.foregroundVerdict('', OURS), C.VERDICT_UNVERIFIED); + assert.ok(C.unverifiedReason(C.VERDICT_UNVERIFIED).includes('缺证据'), '状态词必须点明"缺证据≠没有现象"'); +}); + +test('★ 闸只按 bundle 判:把别人的 dump 改成"标题像我们"也不许变成 ours', () => { + const tricky = dumpTheirs.replace('邮件', '收件箱 · AgentMail'); + assert.equal(C.foregroundVerdict(tricky, OURS), C.VERDICT_OTHER, + '标题/文案/控件名都是可以撞的,只有 bundle 是身份'); + const swapped = dumpOurs.replace(OURS, THEIRS); + assert.equal(C.foregroundVerdict(swapped, OURS), C.VERDICT_OTHER, '反过来:bundle 换成别人的就不是我们'); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 8aaf842..c7ef1a3 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -79,6 +79,7 @@ const SUITE = [ ['test/build-stamp.test.mjs', [], 7], ['test/packaging.test.mjs', [], 5], ['test/align-refs.test.mjs', [], 3], + ['test/harmony-deviceprobe.test.mjs', ['--experimental-strip-types', '--no-warnings'], 5], ['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 13], ['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 10], ['test/debt-visibility.test.mjs', [], 1], @@ -272,6 +273,27 @@ for (const [file, flags, expected] of SUITE) { const CRASH_SIGNS = /(SyntaxError|ReferenceError|TypeError|Cannot find module|ERR_MODULE_NOT_FOUND|is not defined|is not a function|CompileError|build failed|Unexpected identifier|missing ',' in argument list)/; const crashed = r.status !== 0 && (onlyFileLevel || (notOkNames.length === 0 && CRASH_SIGNS.test(out))); const empty = out.trim().length === 0; + /* + * ── 条数累计:**必须在红/绿分支之外** ── + * + * 原来它写在下面 `else { … }`(只在**绿**时进入)里,于是**红文件一条都不计入**, + * `checks` **静默少算**。实测:`checks=379` 而逐文件手算 387 —— 差的那些正是红文件里的。 + * **"红"只说明判据说"不成立",不说明它没跑**;跑过的条数一样要算进来。 + * + * 单位是自报的条数本身(`RESULT pass=N fail=M` 的 N+M,或 `# tests N`), + * 两种文件的"条"不是同一种条,所以汇总里那一格叫 `checks` 而不叫 `tests`。 + */ + const marker = /RESULT pass=(\d+) fail=(\d+)/.exec(out); + const nodeTest = /^# pass (\d+)/m.exec(out); + const nodeFail = /^# fail (\d+)/m.exec(out); + const ran = marker ? Number(marker[1]) : (nodeTest ? Number(nodeTest[1]) : null); + if (ran !== null) { + totalTests += ran; + ranFilesCount += 1; + totalFail += marker ? Number(marker[2]) : (nodeFail ? Number(nodeFail[1]) : 0); + } else { + unreportedFiles += 1; + } // 不 break:后面每条都要跑出来,否则"红了几条"这个信息本身是假的 if (crashed || empty) { const firstErr = (out.match(/^.*(Error|error:).*$/m) || [''])[0].trim().slice(0, 160); diff --git a/client/harmony/entry/src/main/ets/model/DeviceProbe.ts b/client/harmony/entry/src/main/ets/model/DeviceProbe.ts new file mode 100644 index 0000000..9a3a068 --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/DeviceProbe.ts @@ -0,0 +1,95 @@ +/** + * 设备读回的**前置闸**(纯逻辑,无 `@ohos`)—— 判据能用 node 直接跑。 + * + * 为什么需要它(pi 邮件 `971c58fa` §4):别的 agent 在同一个模拟器上跑它的 app, + * 它的 `aa start` 会把前台抢走。如果我在那之后读控件树,**读到的是它的窗口**, + * 而我的断言就会拿**别的 app 的布局**去判 —— 可能"通过",也可能红,**两者都不是在讲我们的界面**。 + * 后者是**假绿**:比"被打断"严重得多。 + * + * 处置规则(pi 邮件 `82ae145b` 认可,`bf583b0f` 补样本): + * 前台不是我们 ⇒ **不下断言**;而且**不许静默跳过**(跳过会在下一轮被读成"验过了")。 + * 正确状态是 **`未验`**,余额里那笔到期欠账**继续开着**。 + * 一句话:**"没验"和"验过了"之间不许有斜坡。** + */ + +/** 我们从 dump 里真正要用到的两样东西 */ +export interface ForegroundInfo { + bundle: string; + windowTitle: string; +} + +/** 闸的判决:**只有三种**,没有第四种"大概是我们" */ +export const VERDICT_OURS: string = 'ours'; +export const VERDICT_OTHER: string = 'other'; +export const VERDICT_UNVERIFIED: string = 'unverified'; + +/** + * 从 dump 文本里取"谁在前台"。 + * + * **取不到就返回 undefined**(空 dump / 截断 / 格式变了)—— 不猜、不兜底。 + * 空 dump 是最容易被误读的一种:**窗口还没起来**时它最常出现, + * 而"没读到"极容易被当成"那个坏现象没有出现"(⇒ 假绿)。 + */ +export function parseForeground(dump: string): ForegroundInfo | undefined { + if (dump.length === 0) { + return undefined; + } + const bundle: string = firstMatch(dump, 'bundleName'); + const altBundle: string = bundle.length > 0 ? bundle : firstMatch(dump, 'bundle'); + if (altBundle.length === 0) { + return undefined; + } + const info: ForegroundInfo = { bundle: altBundle, windowTitle: firstMatch(dump, 'windowTitle') }; + return info; +} + +/** + * 取某个键的值(**限定在同一行内**)。 + * + * ⚠️ 这里踩过一次坑,写下来免得再犯:第一版写的是 `key + '[^A-Za-z0-9_]*([A-Za-z0-9_.]+)'`, + * 那个否定类**既接受非 ASCII 也接受换行** ⇒ 对 `windowTitle: 邮件\n abilityName: EntryAbility` + * 它会一路吞掉中文标题和换行,然后**捕获下一行的键名** `abilityName`。 + * 于是"标题"读到的是别人的键名 —— 一个**看起来有值、其实指错地方**的结果(判据当场把它抓出来了)。 + * ⇒ 分隔符不许跨行:按行取,值里允许中文。**空值返回空串,绝不回退到别处找**。 + */ +function firstMatch(text: string, key: string): string { + const re = new RegExp(key + '\\s*[:=]\\s*([^\\n\\r]*)'); + const m = re.exec(text); + if (m === null) { + return ''; + } + return m[1].trim(); +} + +/** + * 判决"这份 dump 是不是我们的" —— **只许看 bundle,不许看标题/控件名/文案**。 + * + * 理由(pi 邮件 `bf583b0f` §2):别人的合法 dump 里**完全可能**有与我们要判的控件同名、 + * 文案相同的项("邮件"这种通用词尤其容易)。按文案判 ⇒ 别人的 dump 会被当成我们的 ⇒ 静默放行。 + */ +export function foregroundVerdict(dump: string, ourBundle: string): string { + const info: ForegroundInfo | undefined = parseForeground(dump); + if (info === undefined) { + return VERDICT_UNVERIFIED; + } + return info.bundle === ourBundle ? VERDICT_OURS : VERDICT_OTHER; +} + +/** + * 能不能在这份 dump 上做断言。 + * `false` ⇒ 调用侧**必须**把这条判据记成 `未验`(欠账继续开着),**不许**记成通过、也不许静默跳过。 + */ +export function mayAssertOn(dump: string, ourBundle: string): boolean { + return foregroundVerdict(dump, ourBundle) === VERDICT_OURS; +} + +/** 拿不到独占时的状态词 —— 统一到这里,免得各处自己编(编出来的词会互相读不懂) */ +export function unverifiedReason(verdict: string): string { + if (verdict === VERDICT_OURS) { + return ''; + } + if (verdict === VERDICT_OTHER) { + return '未验:前台是别的 app(拿不到独占窗口)—— 读到的界面不是我们的,断言会讲错对象'; + } + return '未验:没读到前台是谁(空 dump / 截断 / 格式变了)—— **缺证据不等于没有那个现象**'; +}