smoke.js 74 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393
  1. #!/usr/bin/env node
  2. /**
  3. * smoke.js — 产物离线自检
  4. *
  5. * 作用:在 PC 上把 dist/tiktok-app.js 真正加载并跑一遍,验证
  6. * ① 模块依赖顺序正确(少一个依赖会在加载期就抛 ReferenceError)
  7. * ② 界面树能构建完整(每个 View 构造是否用到不存在的 API)
  8. * ③ 状态读写闭环(点一下勾选框,配置是否真的落盘)
  9. * ④ **流程闭环**:登录 → 主界面 → 权限拦截 → 开始运行 → 计时 → 停止
  10. * ⑤ 主题工具与业务纯函数的计算是否正确
  11. *
  12. * 为什么必须有它:AutoX 设备端没有测试框架,产物发到手机上后
  13. * 唯一的反馈是"界面出来了/没出来"。把能在 PC 上判定的问题留在 PC 上。
  14. *
  15. * 用法:node tools/smoke.js
  16. */
  17. 'use strict';
  18. const path = require('path');
  19. const stub = require('./android-stub');
  20. const env = stub.install();
  21. globalThis.__FEIZHOU_TEST__ = true;
  22. /* 加载产物 = 执行 App.main(),界面树会被建出来但挂在桩上,不碰真实设备 */
  23. require(path.join(__dirname, '..', 'dist', 'tiktok-app.js'));
  24. const api = globalThis.__FEIZHOU__;
  25. if (!api) {
  26. console.error('✗ 产物没有暴露 __FEIZHOU__,构建可能被改坏了');
  27. process.exit(1);
  28. }
  29. let passed = 0;
  30. const failures = [];
  31. function check(name, condition, detail) {
  32. if (condition) {
  33. passed += 1;
  34. return;
  35. }
  36. failures.push(name + (detail ? ' → ' + detail : ''));
  37. }
  38. function eq(name, actual, expected) {
  39. check(name, actual === expected, `期望 ${expected},实际 ${actual}`);
  40. }
  41. /**
  42. * 「运行计时器」的数量。
  43. *
  44. * 悬浮球的呼吸环也是一个 setInterval(球在说"正在运行",环就该在呼吸),
  45. * 而球在第一次渲染时就已经建好了,于是 interval 总数会多出它那一个。
  46. * 断言运行计时器时必须把它减掉 —— 否则会把"球的视觉动效"当成"计时器没停"。
  47. */
  48. function tickIntervals() {
  49. return env.intervalCount() - (api.FB.breathing() ? 1 : 0);
  50. }
  51. /** 递归收集一棵视图树里所有非空文字,用于断言"界面上到底显示了什么"。 */
  52. function textsOf(view, out) {
  53. const acc = out || [];
  54. if (!view) { return acc; }
  55. if (typeof view.text === 'string' && view.text !== '') { acc.push(view.text); }
  56. (view.children || []).forEach((child) => textsOf(child, acc));
  57. return acc;
  58. }
  59. /**
  60. * 按显示文字点击控件。
  61. * 按钮的点击监听挂在容器 FrameLayout 上、文字在子 TextView 上,
  62. * 所以要找"带这段文字的节点",再点它的父节点。
  63. */
  64. function clickByText(root, label) { const hits = [];
  65. (function walk(view) {
  66. if (!view) { return; }
  67. if (view.text === label) { hits.push(view); }
  68. (view.children || []).forEach(walk);
  69. })(root);
  70. for (let i = 0; i < hits.length; i += 1) {
  71. const node = hits[i];
  72. if (node.parent && node.parent.listeners && node.parent.listeners.click) {
  73. node.parent.performClick();
  74. return true;
  75. }
  76. if (node.listeners && node.listeners.click) {
  77. node.performClick();
  78. return true;
  79. }
  80. }
  81. return false;
  82. }
  83. /* ------------------------------------------------------- 1. 加载与挂载 */
  84. check('产物加载完成并暴露内部对象', !!api);
  85. check('根视图已挂载', !!env.mounted.root, 'ui.setContentView 未被调用');
  86. check('根视图有子节点(光晕层 + 主体 + 弹窗层)',
  87. env.mounted.root && env.mounted.root.children.length >= 3,
  88. env.mounted.root ? `实际 ${env.mounted.root.children.length}` : '无');
  89. /* ------------------------------------------------------- 2. 启动屏路由 */
  90. eq('卡密未激活时启动进登录页', api.App.screen, 'login');
  91. const loginTexts = textsOf(api.App.body);
  92. check('登录页显示应用名', loginTexts.indexOf('飞舟脚本') >= 0, loginTexts.join(' | '));
  93. check('登录页有登录激活按钮', loginTexts.indexOf('登录激活') >= 0);
  94. check('登录页说明是"请登录后使用"',
  95. loginTexts.indexOf('TikTok 自动化脚本 · 请登录后使用') >= 0);
  96. check('登录页有退出应用入口', loginTexts.indexOf('退出应用') >= 0);
  97. check('登录页没有页签栏(未激活不给主界面)', api.App.tabHost === undefined || !api.App.tabHost);
  98. /* ------------------------------------------------------- 3. 主题计算 */
  99. /* 0xFF2EA6FF 作为 32 位有符号 int 就是 -13719809(Java 的 setColor 接受这种负数) */
  100. eq('argb(#2EA6FF) 得到正确的 ARGB int', api.Theme.util.argb('#2EA6FF'), -13719809);
  101. eq('argb 六位与八位输入等价', api.Theme.util.argb('#2EA6FF'), api.Theme.util.argb('#FF2EA6FF'));
  102. eq('alphaOf(#FFFFFF, 0.06) 的 alpha 通道正确',
  103. (api.Theme.util.alphaOf('#FFFFFF', 0.06) >>> 24) & 0xFF, 15);
  104. const solid = api.Theme.util.solidBg(-1, 16);
  105. check('solidBg 返回可用背景对象', !!solid && solid.radius > 0);
  106. const gradient = api.Theme.util.gradientBg('#2EA6FF', '#9482FF', 99);
  107. check('gradientBg 写入两个色停',
  108. gradient && gradient.colors && gradient.colors.length === 2,
  109. gradient && gradient.colors ? `${gradient.colors.length}` : 'null');
  110. const stroke = api.Theme.util.strokeBg(0.04, 0.08, '#FFFFFF', 16);
  111. check('strokeBg 写入描边', !!stroke.stroke && stroke.stroke.width > 0);
  112. /* 颜色令牌完整性。
  113. * 拼错一个键不会报错:argb(undefined) 以前会算出 0(全透明),
  114. * 界面上的表现只是「某块东西不见了」—— 整排非活动页签就这么消失过一次。 */
  115. const hexKeys = Object.keys(api.Theme.hex);
  116. const badHex = hexKeys.filter((k) => !/^#[0-9A-Fa-f]{6}$/.test(api.Theme.hex[k]));
  117. check('Theme.hex 全部是合法的 6 位色值', badHex.length === 0, badHex.join(', '));
  118. const zeroColors = hexKeys.filter((k) => api.Theme.util.argb(api.Theme.hex[k]) === 0);
  119. check('没有色值会被解析成全透明', zeroColors.length === 0, zeroColors.join(', '));
  120. check('非法色值不会静默返回透明(返回洋红)',
  121. api.Theme.util.argb(undefined) === -65281 && api.Theme.util.argb('#ZZZ') === -65281,
  122. `${api.Theme.util.argb(undefined)} / ${api.Theme.util.argb('#ZZZ')}`);
  123. /* 界面代码真正引用到的键必须都在 —— 上面那条只保证"已有的都合法",
  124. * 这条保证"用到的都存在"(漏删/改名就是这里暴露) */
  125. const REQUIRED_HEX = ['bg', 'tabBar', 'accent', 'accentText', 'accentDeep', 'textPrimary',
  126. 'textSecondary', 'textMuted', 'textFaint', 'tabIdle', 'warn', 'danger', 'ok'];
  127. const missingHex = REQUIRED_HEX.filter((k) => api.Theme.hex[k] === undefined);
  128. check('界面用到的颜色键都存在', missingHex.length === 0, missingHex.join(', '));
  129. /* ------------------------------------------------------- 4. 数据层 */
  130. eq('默认配置版本号', api.Store.defaults().version, api.Store.SCHEMA_VERSION);
  131. eq('默认 tab 是「任务」', api.Store.get('activeTab'), 'tasks');
  132. /* —— v8:ID提取不再有"脚本",四张卡的 23 个字段全部从字段表移除 ——
  133. * 光从页面里删不够:字段还留在 FIELDS 里的话,它会继续出现在导出配置与审计里,
  134. * 变成一个"没有任何控件、却一直有值"的幽灵。 */
  135. const legacyIdFields = api.Store.FIELDS.filter((f) => /^tabs\.id\.scripts\./.test(f.path));
  136. check('四张脚本卡的字段已全部从字段表移除', legacyIdFields.length === 0,
  137. legacyIdFields.map((f) => f.path).join(', '));
  138. /* 取材清单是**另一个储物柜**(不定长列表进不了那张标量表)。空清单是合法初始状态,
  139. * 等价于 v7 的"一个脚本都没选":能进页面,但点运行会被拦。 */
  140. eq('取材清单默认是空的', api.Sources.count(), 0);
  141. check('空清单 = 未配置(再也找不到"哪个脚本被选中"这种概念)',
  142. api.Pages.checkedScript('id') === null);
  143. /* v7 那次清语义的成果一个都没丢:功能设置 19 个字段还在 */
  144. eq('ID提取功能设置仍是 21 个字段(v7 的 19 个 + 去重两开关)',
  145. api.Store.FIELDS.filter((f) => /^tabs\.id\.features\./.test(f.path)).length, 21);
  146. /* —— v7 补的两个去重开关(v8 保留) —— */
  147. eq('本地去重默认开', api.Store.get('tabs.id.features.dedupeLocal'), true);
  148. eq('跨轮去重默认关(首次运行没有历史可比)', api.Store.get('tabs.id.features.dedupeHistory'), false);
  149. /* 养号参数:设计稿 16:2 —— 两个区间 + 三个档位 */
  150. eq('养号浏览时长默认 3 ~ 8',
  151. api.Store.get('tabs.nurture.browseMin') + ' ~ ' + api.Store.get('tabs.nurture.browseMax'), '3 ~ 8');
  152. eq('养号点赞概率默认 30%', api.Store.label('likeRate', api.Store.get('tabs.nurture.likeRate')), '30%');
  153. eq('养号评论概率默认 10%', api.Store.label('commentRate', api.Store.get('tabs.nurture.commentRate')), '10%');
  154. eq('养号关注概率默认 15%', api.Store.label('followRate', api.Store.get('tabs.nurture.followRate')), '15%');
  155. eq('养号单次操作默认 10 ~ 30',
  156. api.Store.get('tabs.nurture.actionMin') + ' ~ ' + api.Store.get('tabs.nurture.actionMax'), '10 ~ 30');
  157. /* UID私信 / 视频功能的区间默认值(设计稿 3:579 / 3:667) */
  158. eq('关注数量默认 100 ~ 200',
  159. api.Store.get('tabs.dm.followMin') + ' ~ ' + api.Store.get('tabs.dm.followMax'), '100 ~ 200');
  160. eq('私信数量默认 20 ~ 30',
  161. api.Store.get('tabs.dm.dmMin') + ' ~ ' + api.Store.get('tabs.dm.dmMax'), '20 ~ 30');
  162. eq('休息时间默认 100 ~ 500',
  163. api.Store.get('tabs.dm.restMin') + ' ~ ' + api.Store.get('tabs.dm.restMax'), '100 ~ 500');
  164. eq('停止个数默认 1 ~ 5',
  165. api.Store.get('tabs.video.stopMin') + ' ~ ' + api.Store.get('tabs.video.stopMax'), '1 ~ 5');
  166. eq('间隔时间默认 20 ~ 20',
  167. api.Store.get('tabs.video.intervalMin') + ' ~ ' + api.Store.get('tabs.video.intervalMax'), '20 ~ 20');
  168. /* 每个区间都必须有对应的跨字段约束,否则会出现「最小值 500 / 最大值 100」 */
  169. const rangePairs = api.Store.CROSS_RULES.map((r) => r.low + '|' + r.high);
  170. const EXPECT_PAIRS = [
  171. 'tabs.dm.followMin|tabs.dm.followMax',
  172. 'tabs.dm.dmMin|tabs.dm.dmMax',
  173. 'tabs.dm.restMin|tabs.dm.restMax',
  174. 'tabs.nurture.browseMin|tabs.nurture.browseMax',
  175. 'tabs.nurture.actionMin|tabs.nurture.actionMax',
  176. 'tabs.video.stopMin|tabs.video.stopMax',
  177. 'tabs.video.intervalMin|tabs.video.intervalMax'
  178. ];
  179. const missingPair = EXPECT_PAIRS.filter((p) => rangePairs.indexOf(p) < 0);
  180. check('每个界面区间都有对应的跨字段约束', missingPair.length === 0, missingPair.join(', '));
  181. /* ID提取页功能设置:四个门槛都成对且有默认值 */
  182. eq('关注量下限默认 0', api.Store.get('tabs.id.features.followMin'), 0);
  183. eq('关注量上限默认 500000', api.Store.get('tabs.id.features.followMax'), 500000);
  184. eq('提取区域默认国家简码', api.Store.get('tabs.id.features.regionCodes'), 'JP#TW#US');
  185. /* 字段表完整性:每个字段的默认值都必须与声明类型相符 */
  186. const typeMismatch = api.Store.FIELDS.filter((f) => {
  187. if (f.type === 'bool') { return typeof f.def !== 'boolean'; }
  188. if (f.type === 'int') { return typeof f.def !== 'number'; }
  189. return typeof f.def !== 'string';
  190. });
  191. check('字段表里每个默认值都与声明类型相符', typeMismatch.length === 0,
  192. typeMismatch.map((f) => `${f.path}=${typeof f.def}`).join(', '));
  193. /* set → 重新读取 → 落盘(storages 里应该有 JSON) */
  194. api.Store.set('tabs.video.keyword', 'fashion');
  195. eq('写入后立即读到新值', api.Store.get('tabs.video.keyword'), 'fashion');
  196. const persisted = env.memoryStorage[api.Store.STORAGE_NAME][api.Store.STORAGE_KEY];
  197. check('配置已持久化到 storages', typeof persisted === 'string' && persisted.indexOf('fashion') >= 0);
  198. const reloaded = JSON.parse(persisted);
  199. eq('持久化内容可直接解析回同一字段', reloaded.tabs.video.keyword, 'fashion');
  200. /* 深合并:旧数据缺新字段时应保留默认值 */
  201. const merged = api.Store.merge({ a: 1, b: { c: 2, d: 3 } }, { b: { c: 9 } });
  202. eq('merge 覆盖同名键', merged.b.c, 9);
  203. eq('merge 保留未提及的嵌套键', merged.b.d, 3);
  204. eq('merge 保留顶层未提及键', merged.a, 1);
  205. /* 校验:非法值必须被收敛而不是穿透到界面 */
  206. const bad = api.Store.validate({
  207. version: 99,
  208. activeTab: 'not-a-tab',
  209. tabs: {
  210. dm: { followMax: 999999, aiGender: 'unknown', restMin: 500, restMax: 100 },
  211. video: { stopMin: -3, stopMax: -9, actions: { follow: 'yes' } },
  212. nurture: { browseMin: 60, browseMax: 5, actionMax: 9999 },
  213. id: { features: { followMin: 900, followMax: 100 } }
  214. }
  215. });
  216. eq('非法 tab 回落到 tasks', bad.config.activeTab, 'tasks');
  217. eq('超范围的上限被夹到 max', bad.config.tabs.dm.followMax, 5000);
  218. eq('非法枚举回落到默认', bad.config.tabs.dm.aiGender, 'male');
  219. eq('min > max 时把 max 抬到 min', bad.config.tabs.dm.restMax, bad.config.tabs.dm.restMin);
  220. eq('负的停止个数被夹到 min', bad.config.tabs.video.stopMin, 1);
  221. eq('区间的另一端也各自被夹到 min', bad.config.tabs.video.stopMax, 1);
  222. eq('浏览时长 min>max 被抬齐', bad.config.tabs.nurture.browseMax, bad.config.tabs.nurture.browseMin);
  223. eq('单次操作 max 超范围被夹', bad.config.tabs.nurture.actionMax, 500);
  224. eq('字符串真值被转成布尔', bad.config.tabs.video.actions.follow, true);
  225. eq('ID页门槛的 min > max 也被抬齐',
  226. bad.config.tabs.id.features.followMax, bad.config.tabs.id.features.followMin);
  227. check('版本不一致会被记录为问题', bad.problems.length >= 1, `problems=${bad.problems.length}`);
  228. /* 枚举映射:显示文案 ↔ 存储 key 必须可逆 */
  229. eq('label 反向解析 dedupe', api.Store.label('dedupe', 'id'), 'ID 模式');
  230. eq('keyAt 由位置取回 key', api.Store.keyAt('dedupe', 1), 'text');
  231. eq('indexOfLabel 与 keyAt 互逆', api.Store.keyAt('aiGender', api.Store.indexOfLabel('aiGender', 'female')), 'female');
  232. eq('概率档位 keyAt 反解', api.Store.keyAt('likeRate', 0), 'r10');
  233. /* v4 → v5 迁移:五处「一个值」变成「一个区间」,旧值必须沿用且行为不变 */
  234. const v4 = api.Store.validate({
  235. version: 4,
  236. tabs: {
  237. dm: { followLimit: 300, dmLimit: 40 },
  238. nurture: { browseDuration: 'd5_10', actionLimit: 'n50' },
  239. video: { stopCount: 7 }
  240. }
  241. });
  242. eq('v4 关注上限 → 关注数量 0 ~ 300',
  243. v4.config.tabs.dm.followMin + ' ~ ' + v4.config.tabs.dm.followMax, '0 ~ 300');
  244. eq('v4 私信上限 → 私信数量 0 ~ 40',
  245. v4.config.tabs.dm.dmMin + ' ~ ' + v4.config.tabs.dm.dmMax, '0 ~ 40');
  246. eq('v4 浏览时长档位 d5_10 → 5 ~ 10',
  247. v4.config.tabs.nurture.browseMin + ' ~ ' + v4.config.tabs.nurture.browseMax, '5 ~ 10');
  248. eq('v4 单次操作上限 50 次 → 0 ~ 50',
  249. v4.config.tabs.nurture.actionMin + ' ~ ' + v4.config.tabs.nurture.actionMax, '0 ~ 50');
  250. eq('v4 停止个数 7(精确量)→ 7 ~ 7',
  251. v4.config.tabs.video.stopMin + ' ~ ' + v4.config.tabs.video.stopMax, '7 ~ 7');
  252. check('迁移会记录做了什么', v4.problems.some((p) => p.indexOf('关注上限') >= 0 || p.indexOf('followLimit') >= 0),
  253. v4.problems.join(' | '));
  254. check('旧字段不会残留在配置里', v4.config.tabs.dm.followLimit === undefined &&
  255. v4.config.tabs.video.stopCount === undefined);
  256. eq('未迁移时用的是设计稿默认区间', api.Store.defaults().tabs.dm.followMin, 100);
  257. /* ------------------------------------------------------- 5. 服务层 */
  258. eq('卡密规范化去空格与连字符', api.Service.normalizeCode(' abcd-1234 efgh-5678 '), 'ABCD1234EFGH5678');
  259. check('16 位合法卡密通过格式校验', api.Service.isWellFormed('ABCD1234EFGH5678'));
  260. check('短卡密被拒', !api.Service.isWellFormed('ABCD1234'));
  261. check('含非法字符被拒', !api.Service.isWellFormed('ABCD1234EFGH567!'));
  262. eq('卡密分组展示', api.Service.formatCode('ABCD1234EFGH5678'), 'ABCD-1234-EFGH-5678');
  263. eq('本地模式登录成功', api.Service.login('ABCD1234EFGH5678').ok, true);
  264. check('格式错误的卡密登录失败', api.Service.login('bad').ok === false);
  265. eq('今日日期格式为 yyyy-MM-dd', /^\d{4}-\d{2}-\d{2}$/.test(api.Service.today()), true);
  266. check('未来日期不算过期', !api.Service.isExpired(api.Service.datePlusDays(5)));
  267. check('过去日期算过期', api.Service.isExpired('2000-01-01'));
  268. check('检查更新在无端点时给出本地结论', api.Service.checkUpdate().source === 'local');
  269. /* licenseDisplay 必须带上 status:页面靠它决定显示「解绑」还是「去登录」。
  270. * 少了它,已登录状态下会出现「已登录」旁边挂着「去登录」这种自相矛盾的组合,
  271. * 而且不会报任何错。 */
  272. api.Store.set('license.status', 'active');
  273. const activeDisplay = api.Service.licenseDisplay();
  274. check('licenseDisplay 在已登录时返回 status', activeDisplay.status === 'active',
  275. String(activeDisplay.status));
  276. check('licenseDisplay 文案与色调随状态变化',
  277. activeDisplay.text === '已登录' && activeDisplay.tone === 'ok');
  278. api.Store.set('license.status', 'none');
  279. eq('licenseDisplay 在未登录时返回 none', api.Service.licenseDisplay().status, 'none');
  280. api.Store.set('license.status', 'expired');
  281. eq('licenseDisplay 在已过期时返回 expired 与 warn 色',
  282. api.Service.licenseDisplay().status + '/' + api.Service.licenseDisplay().tone, 'expired/warn');
  283. api.Store.set('license.status', 'none');
  284. /* ------------------------------------------------------- 6. 设备层 */
  285. const tiktok = api.Device.tiktok();
  286. check('TikTok 探测到已安装', tiktok.installed);
  287. eq('TikTok 版本号取自系统', tiktok.versionName, '39.5.4');
  288. eq('版本号格式化为 v 前缀', api.Device.formatVersion('39.5.4'), 'v39.5.4');
  289. eq('已是 v 前缀则不重复添加', api.Device.formatVersion('v39.5.4'), 'v39.5.4');
  290. eq('权限行共 3 条', api.Device.permissionRows().length, 3);
  291. /* 权限行的顺序与「可选」标记是本轮的重点:
  292. * 通知必须排最后且标可选,否则用户会以为它和悬浮窗一样是硬依赖。 */
  293. const permRows = api.Device.permissionRows();
  294. eq('权限顺序:无障碍 → 悬浮窗 → 通知', permRows.map((r) => r.name).join(','),
  295. '无障碍服务,悬浮窗权限,通知权限');
  296. check('通知权限被标记为可选', permRows[2].optional === true);
  297. check('无障碍与悬浮窗不是可选', !permRows[0].optional && !permRows[1].optional);
  298. check('无障碍未连接时上报 false(桩里 auto.service=null)', api.Device.a11yEnabled() === false);
  299. check('权限不齐时 allPermissionsGranted 为 false', api.Device.allPermissionsGranted() === false);
  300. check('阻断权限判定只看无障碍与悬浮窗', api.Device.blockingPermissionsGranted() === false);
  301. check('状态栏高度取到非零值(桩返回 66px)', api.Device.statusBarHeight() > 0);
  302. check('TikTok 包名列表覆盖主包', api.Device.TIKTOK_PACKAGES.indexOf('com.zhiliaoapp.musically') >= 0);
  303. /* ------------------------------------------------------- 7. 界面构建 */
  304. /* 底栏只剩三个 tab(规格书 §1)。这条断言守着整个改版的入口层级:
  305. * 一旦有人把四个任务页签加回底栏,它就会响。 */
  306. eq('底栏 tab 数量为 3', api.W.TABS.length, 3);
  307. eq('底栏 tab 顺序与设计稿一致', api.W.TABS.map((t) => t.label).join(','), '任务,记录,我的');
  308. /* 一级页(3 个 tab)+ 二级页(4 个任务详情)都要能独立构建成功——
  309. * 某个页面漏了依赖会在这里暴露。 */
  310. let pageErrors = [];
  311. Object.keys(api.App.TAB_PAGES).forEach((key) => {
  312. try {
  313. const view = api.App.TAB_PAGES[key]();
  314. if (!view) { pageErrors.push(key + ': 返回空'); }
  315. else if (view.getChildCount() === 0) { pageErrors.push(key + ': 内容为空'); }
  316. } catch (error) {
  317. pageErrors.push(key + ': ' + error.message);
  318. }
  319. });
  320. Object.keys(api.App.DETAIL_PAGES).forEach((key) => {
  321. try {
  322. const view = api.App.DETAIL_PAGES[key](() => { });
  323. if (!view) { pageErrors.push(key + ': 返回空'); }
  324. else if (view.getChildCount() === 0) { pageErrors.push(key + ': 内容为空'); }
  325. } catch (error) {
  326. pageErrors.push(key + ': ' + error.message);
  327. }
  328. });
  329. check('3 个一级页 + 4 个任务详情页全部可构建', pageErrors.length === 0, pageErrors.join(' | '));
  330. /* 四个任务与两张表(Pages.TASKS / Pages.SCRIPTS_OF)必须对得上:
  331. * 任务卡要显示「当前:xxx」,靠的就是这个对应关系。 */
  332. eq('任务卡数量为 4', api.Pages.TASKS.length, 4);
  333. eq('四个任务的 key 与旧配置路径一致',
  334. api.Pages.TASKS.map((t) => t.key).join(','), 'id,dm,nurture,video');
  335. check('每个任务都有脚本清单', api.Pages.TASKS.every((t) => (api.Pages.SCRIPTS_OF[t.key] || []).length > 0));
  336. /* 新做的四个弹窗都要能构建 —— 它们的排版比普通弹窗复杂得多 */
  337. const dialogCases = [
  338. ['权限弹窗', () => api.Dialog.showPermissions()],
  339. ['权限拦截弹窗', () => api.Dialog.permissionBlocked()],
  340. ['未选脚本拦截弹窗', () => api.Dialog.runBlocked('请先选择要运行的脚本')],
  341. ['解绑确认弹窗', () => api.Dialog.confirmUnbind(() => { })],
  342. ['卡密到期弹窗', () => api.Dialog.licenseExpired(() => { })]
  343. ];
  344. dialogCases.forEach(([name, build]) => {
  345. try {
  346. const dialog = build();
  347. check(name + '可构建', !!dialog && !!dialog.root && dialog.root.getChildCount() >= 2);
  348. } catch (error) {
  349. check(name + '可构建', false, error.message);
  350. }
  351. });
  352. /* 权限拦截弹窗的内容必须把失败原因说清楚 */
  353. const blocked = api.Dialog.permissionBlocked();
  354. const blockedTexts = textsOf(blocked.root);
  355. check('权限拦截弹窗标题为「无法开始运行」', blockedTexts.indexOf('无法开始运行') >= 0);
  356. check('权限拦截弹窗说明点名缺哪两个权限',
  357. blockedTexts.indexOf('无障碍与悬浮窗权限未开启,脚本无法执行') >= 0);
  358. check('权限拦截弹窗里通知仍标「未开启(可选)」',
  359. blockedTexts.indexOf('未开启(可选)') >= 0, blockedTexts.join(' | '));
  360. check('权限拦截弹窗有去授权与稍后再说',
  361. blockedTexts.indexOf('去授权') >= 0 && blockedTexts.indexOf('稍后再说') >= 0);
  362. blocked.close();
  363. const unbind = api.Dialog.confirmUnbind(() => { });
  364. const unbindTexts = textsOf(unbind.root);
  365. check('解绑弹窗说清后果(需重新输入卡密)',
  366. unbindTexts.indexOf('解绑后需重新输入卡密才能使用') >= 0);
  367. check('解绑弹窗说清连带影响(运行中会停)',
  368. unbindTexts.indexOf('正在运行的脚本将立即停止') >= 0);
  369. check('解绑弹窗有两个按钮', unbindTexts.indexOf('取消') >= 0 && unbindTexts.indexOf('确认解绑') >= 0);
  370. unbind.close();
  371. /* ------------------------------------------------------- 8. 登录流程 */
  372. /* 空卡密不提交,留在登录页 */
  373. api.App.submitLicense(' ');
  374. eq('空卡密不切换屏', api.App.screen, 'login');
  375. /* 合法卡密 → 主界面 */
  376. api.App.submitLicense('ABCD1234EFGH5678');
  377. eq('登录成功后切到主界面', api.App.screen, 'app');
  378. eq('登录状态已落盘', api.Store.get('license.status'), 'active');
  379. eq('登录页三态已复位', api.Pages.LICENSE_VIEW.state, 'idle');
  380. /* 我的页的卡密卡要按状态分叉:已登录给「解绑」,未登录给「去登录」。
  381. * 这两条断言抓的是「display 少返回 status」那类静默错配。 */
  382. api.App.switchTab('mine');
  383. const activeMine = textsOf(api.App.contentHost).join(' | ');
  384. check('已登录时我的页给「解绑」入口', activeMine.indexOf('解绑') >= 0, activeMine);
  385. check('已登录时我的页不出现「去登录」', activeMine.indexOf('去登录') < 0);
  386. api.Store.set('license.status', 'none');
  387. api.App.renderScreen();
  388. const anonMine = textsOf(api.App.contentHost).join(' | ');
  389. check('未登录时我的页给「去登录」', anonMine.indexOf('去登录') >= 0, anonMine);
  390. check('未登录时我的页不给「解绑」', anonMine.indexOf('解绑') < 0);
  391. api.Store.set('license.status', 'active');
  392. api.App.renderScreen();
  393. /* ------------------------------------------------------- 9. 主界面与交互 */
  394. /* 权限桩先挂上:本节测的是**导航与校验**,权限在 §2.5 里排在"已选脚本"之前,
  395. * 不先给齐权限的话,后面每条断言都会先撞上权限窗。
  396. * 权限缺失这一条单独在第 10 节测(那里会把它复位成缺失)。 */
  397. globalThis.auto.service = {};
  398. globalThis.floaty.checkPermission = () => true;
  399. /* 运行目标 = **当前打开的那个任务详情页**里选中的脚本(v6 改版后的规则)。
  400. * 一级页(任务列表)没有"当前任务"这回事:四张卡各有各的「运行」按钮。 */
  401. api.App.openDetail('id');
  402. check('ID提取页默认没有可运行目标(取材清单是空的)', api.App.currentTask() === null);
  403. eq('取材清单默认为空', api.Sources.count(), 0);
  404. api.App.openDetail('dm');
  405. eq('进 UID私信详情后运行目标是该页脚本一', api.App.currentTask().key, 's1');
  406. eq('运行目标带上了界面显示名', api.App.currentTask().name, '脚本一 · 已提 UID 私信');
  407. eq('运行目标带上了它属于哪个任务', api.App.currentTask().task, 'dm');
  408. /* —— v8:ID提取的"已配置" = 取材清单非空,不再有"单选一个脚本"这回事 ——
  409. * 而且运行目标的 key 固定是 "id":它不再用脚本 key 区分,
  410. * 因为清单里可以有任意多条取材,没有"当前是哪一条"这种概念。 */
  411. api.App.openDetail('id');
  412. api.Sources.add('anchor');
  413. check('加了一条取材后就有了运行目标', api.App.currentTask() !== null);
  414. eq('运行目标的 key 是 id(不再是 s1/s2)', api.App.currentTask().key, 'id');
  415. check('运行目标的显示名不再出现"脚本"字样',
  416. api.App.currentTask().name.indexOf('脚本') < 0, api.App.currentTask().name);
  417. /* 清单可以有多条,运行目标仍是同一个(摘要取第一条) */
  418. api.Sources.add('text');
  419. eq('两条取材后清单长度是 2', api.Sources.count(), 2);
  420. eq('运行目标不随条数变化', api.App.currentTask().key, 'id');
  421. check('任务卡的「当前:」显示取材摘要而不是"未选择脚本"',
  422. api.Pages.taskScriptLine('id').indexOf('未选择') < 0, api.Pages.taskScriptLine('id'));
  423. /* 空清单 → 点运行被拦(与 v7 的"未选脚本"同一关,但文案不同) */
  424. api.Sources.clear();
  425. check('清空取材后没有运行目标', api.App.currentTask() === null);
  426. api.App.openDetail('id');
  427. api.App.toggleRun();
  428. eq('空清单时不进入运行态', api.Session.running, false);
  429. check('空清单时弹出拦截弹窗', !!api.Dialog.active);
  430. check('拦截弹窗标题是「无法开始运行」',
  431. api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('无法开始运行') >= 0);
  432. check('拦截文案说的是「添加取材」,不是让人去找不存在的脚本',
  433. api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('请先添加一条取材') >= 0);
  434. api.Dialog.active && api.Dialog.active.close();
  435. /* 加回一条,供后面的用例使用 */
  436. api.Sources.add('anchor');
  437. /* tab 切换:切到「我的」时不应该有开始运行按钮 */
  438. api.App.switchTab('mine');
  439. eq('切到我的后 activeTab 正确', api.App.activeTab, 'mine');
  440. eq('我的页不渲染开始运行按钮', api.App.runHost.getChildCount(), 0);
  441. eq('tab 状态已落盘', api.Store.get('activeTab'), 'mine');
  442. /* 任务列表页也没有 CTA —— 四张任务卡上的「运行」按钮才是它的启停入口 */
  443. api.App.switchTab('tasks');
  444. eq('任务列表页不渲染开始运行按钮', api.App.runHost.getChildCount(), 0);
  445. check('任务列表显示四张任务卡的状态徽标',
  446. ['空闲', '未配置'].some((w) => textsOf(api.App.contentHost).indexOf(w) >= 0),
  447. textsOf(api.App.contentHost).join(' | '));
  448. check('任务列表显示「当前:xxx」一行',
  449. textsOf(api.App.contentHost).join(' | ').indexOf('当前:') >= 0);
  450. /* —— 任务卡的三个可点区域都要能进详情 ——
  451. *
  452. * ★ 这条守着一个真机踩过的坑(2026-10-13 用户反馈「点脚本进不去参数设置」):
  453. * 早先卡片里写过一句 `line.setOnClickListener(null)`,本意是"行内不挂监听",
  454. * 但 AutoX 的桥对 `null` 不保证是"清掉监听"—— 它可能装上一个吃掉点击、
  455. * 什么都不做的空监听。结果是「点运行按钮有反应、点卡片其它地方毫无反应」。
  456. * 现在卡片根 / 图标框 / 文本组各挂一次同一个回调,这条断言逐个点它们。 */
  457. let openedBy = '';
  458. const cardProbe = api.W.taskCard({
  459. name: 'X', iconChar: '▤', colorHex: '#2EA6FF', badge: '空闲', badgeTone: 'mute',
  460. scriptLine: '当前:X', lastLine: '还未运行过', running: false,
  461. onOpen: () => { openedBy = 'open'; },
  462. onRun: () => { openedBy = 'run'; }
  463. });
  464. const cardLine = cardProbe.root.getChildAt(0);
  465. cardProbe.root.performClick();
  466. check('点卡片根 → 进详情', openedBy === 'open', openedBy);
  467. openedBy = '';
  468. cardProbe.iconBox.performClick();
  469. check('点图标框 → 进详情', openedBy === 'open', openedBy);
  470. openedBy = '';
  471. cardProbe.textGroup.performClick();
  472. check('点文本组(脚本名那一块)→ 进详情', openedBy === 'open', openedBy);
  473. openedBy = '';
  474. cardProbe.button.performClick();
  475. check('点「运行」按钮 → 起跑(不是进详情)', openedBy === 'run', openedBy);
  476. check('任务卡里没有"空监听"占位(会吃掉点击)',
  477. cardLine.listeners.click === undefined || cardLine.listeners.click !== null,
  478. '中间的 row 上不该有 click 监听');
  479. /* 进二级页后 CTA 回来 */
  480. api.App.openDetail('id');
  481. eq('进任务详情后有开始运行按钮', api.App.runHost.getChildCount(), 1);
  482. check('二级页顶部是返回头(标题 + 返回箭头)',
  483. textsOf(api.App.contentHost).indexOf('ID提取') >= 0);
  484. /* 内容区应该有「返回头 + 间距 + 滚动页」 */
  485. check('内容区构建出返回头与页面', api.App.contentHost.getChildCount() >= 3,
  486. `实际 ${api.App.contentHost.getChildCount()}`);
  487. /* v8:三段的标题都要在 —— 这是"结构换了"最直接的可读证据。
  488. * (原来的「单选:同一时间仅可运行一个脚本」说明随单选一起消失了。) */
  489. const idPageTexts = textsOf(api.App.contentHost);
  490. check('ID提取页有「取材」「只要这样的账号」「输出」三段',
  491. idPageTexts.indexOf('取材') >= 0
  492. && idPageTexts.indexOf('只要这样的账号') >= 0
  493. && idPageTexts.indexOf('输出') >= 0,
  494. idPageTexts.slice(0, 20).join(' | '));
  495. check('不再有「单选:同一时间仅可运行一个脚本」这条约束',
  496. idPageTexts.indexOf('单选:同一时间仅可运行一个脚本') < 0);
  497. check('有取材时渲染出取材卡片与「添加一条」',
  498. idPageTexts.indexOf('第 1 条') >= 0 && idPageTexts.indexOf('+ 添加一条取材') >= 0,
  499. idPageTexts.slice(0, 20).join(' | '));
  500. /* 运行配置落盘 */
  501. const exported = api.App.exportRunConfig([{ tab: 'id', script: 's1' }]);
  502. check('运行配置可写出', exported === true);
  503. const runConfig = env.memoryFiles[api.App.RUN_CONFIG_PATH];
  504. check('运行配置内容含 tasks', typeof runConfig === 'string' && runConfig.indexOf('"tasks"') >= 0);
  505. check('运行配置是合法 JSON', (() => {
  506. try { JSON.parse(runConfig); return true; } catch (e) { return false; }
  507. })());
  508. /* ------------------------------------------------------- 10. 运行态闭环 */
  509. /* 运行态只在「已接入执行引擎」的脚本上才可能发生。
  510. * 视频功能是当前唯一接了的(其余任务点开始运行会被 Dialog.engineMissing 拦下,
  511. * 那也是下面单独测的一条)。 */
  512. api.Store.set('license.status', 'active');
  513. api.App.openDetail('video');
  514. /* 视频页的脚本卡默认不勾选(用户得自己确认要跑哪一个),这里先勾上 */
  515. api.Store.set('tabs.video.scripts.s1.checked', true);
  516. api.App.renderContent();
  517. check('视频功能页有勾选中的脚本', !!api.App.currentTask());
  518. /* ① 缺阻断权限 → 弹权限拦截窗,不进运行态。
  519. * 刻意把上面挂的桩**收回**:缺权限必须是真实可复现的状态,
  520. * 而不是"桩没配"这种偶然。 */
  521. globalThis.auto.service = null; /* 桩:无障碍未连接 */
  522. globalThis.floaty.checkPermission = () => false; /* 桩:悬浮窗未授权 */
  523. api.App.openDetail('video');
  524. api.App.toggleRun();
  525. eq('缺权限时不进入运行态', api.Session.running, false);
  526. check('缺权限时弹出的是权限拦截窗', !!api.Dialog.active);
  527. api.Dialog.active && api.Dialog.active.close();
  528. /* ② 补齐权限后开始运行 */
  529. globalThis.auto.service = {}; /* 桩:无障碍已连接 */
  530. globalThis.floaty.checkPermission = () => true; /* 桩:悬浮窗已授权 */
  531. check('补齐后阻断权限判定为真', api.Device.blockingPermissionsGranted() === true);
  532. /* —— 引擎产物定位:路径必须是从入口脚本同级推出来的那个 —— */
  533. const plan = api.Engine.prepare();
  534. check('能定位到引擎产物', plan.ok, plan.error || '');
  535. eq('定位到的是项目目录下的引擎产物', api.Engine.scriptPath, env.ENGINE_SCRIPT);
  536. api.App.toggleRun();
  537. eq('权限齐全后进入运行态', api.Session.running, true);
  538. check('运行时出现全局运行条', !!api.App.runBar);
  539. check('运行条显示任务名 · 脚本名 · 运行中',
  540. textsOf(api.App.runBar.root).indexOf('视频功能 · 运行中') >= 0,
  541. textsOf(api.App.runBar.root).join(' | '));
  542. check('运行按钮文案切为「停止运行」',
  543. textsOf(api.App.runHost).indexOf('停止运行') >= 0,
  544. textsOf(api.App.runHost).join(' | '));
  545. eq('运行计时器已启动', tickIntervals(), 1);
  546. check('初始时长为 00:00:00', textsOf(api.App.runBar.root).indexOf('00:00:00') >= 0);
  547. /* —— 引擎真的被唤起了,且参数齐全 —— */
  548. eq('唤起了 1 个引擎', env.engineState.spawned.length, 1);
  549. const spawn = env.engineState.spawned[0] || { config: {} };
  550. eq('唤起的是定位到的产物', spawn.path, env.ENGINE_SCRIPT);
  551. const argv = (spawn.config && spawn.config.arguments) || [];
  552. check('引擎参数里有 --config / --state / --stop-flag / --run-id',
  553. argv.indexOf('--config') >= 0 && argv.indexOf('--state') >= 0 &&
  554. argv.indexOf('--stop-flag') >= 0 && argv.indexOf('--run-id') >= 0,
  555. JSON.stringify(argv));
  556. check('传给引擎的 runId 与本次会话一致',
  557. argv[argv.indexOf('--run-id') + 1] === api.Session.runId,
  558. `${argv[argv.indexOf('--run-id') + 1]} vs ${api.Session.runId}`);
  559. /* —— 引擎配置:界面字段被正确映射 —— */
  560. const engineConfig = JSON.parse(env.memoryFiles[api.Engine.CONFIG_PATH]);
  561. eq('关键词映射到 target.keyword', engineConfig.target.keyword,
  562. api.Store.getText('tabs.video.keyword'));
  563. check('停止个数区间映射为 maxVideos(落在区间内)',
  564. engineConfig.target.maxVideos >= api.Store.getNumber('tabs.video.stopMin') &&
  565. engineConfig.target.maxVideos <= api.Store.getNumber('tabs.video.stopMax'),
  566. `实际 ${engineConfig.target.maxVideos}`);
  567. eq('间隔时间(秒)映射为观看时长(毫秒)',
  568. engineConfig.watch.minMs, api.Store.getNumber('tabs.video.intervalMin') * 1000);
  569. eq('没勾评论时引擎不启用评论动作', engineConfig.comments.enabled, false);
  570. eq('引擎配置写清了 configPath', engineConfig.runtime.configPath, api.Engine.CONFIG_PATH);
  571. /* 运行配置里带引擎信息与"本次不会生效的项",事后只看这一个文件就能复盘 */
  572. const runConfigNow = JSON.parse(env.memoryFiles[api.App.RUN_CONFIG_PATH]);
  573. check('运行配置记录了引擎产物路径',
  574. runConfigNow.engine && runConfigNow.engine.scriptPath === env.ENGINE_SCRIPT);
  575. check('运行配置记录了本次 runId',
  576. runConfigNow.engine && runConfigNow.engine.runId === api.Session.runId);
  577. /* 运行中不允许切换脚本:验证组件在 disabled 下真的不回调 */
  578. let disabledFired = false;
  579. const disabledHead = api.W.scriptHeader({
  580. checked: false, expanded: false, name: 'X', disabled: true,
  581. onToggleCheck: () => { disabledFired = true; },
  582. onToggleExpand: () => { }
  583. });
  584. disabledHead.checkbox.root.performClick();
  585. check('禁用状态下点脚本头不触发勾选', disabledFired === false);
  586. let enabledNext = null;
  587. const enabledHead = api.W.scriptHeader({
  588. checked: false, expanded: false, name: 'X',
  589. onToggleCheck: (v) => { enabledNext = v; },
  590. onToggleExpand: () => { }
  591. });
  592. enabledHead.checkbox.root.performClick();
  593. eq('启用状态下点脚本头回传新状态', enabledNext, true);
  594. let radioNext = 'unchanged';
  595. const radioHead = api.W.scriptHeader({
  596. checked: true, expanded: false, name: 'X', mode: 'radio',
  597. onToggleCheck: (v) => { radioNext = v; },
  598. onToggleExpand: () => { }
  599. });
  600. radioHead.checkbox.root.performClick();
  601. eq('单选组里点已选中项可取消(否则「未选择脚本」那一屏永远回不去)', radioNext, false);
  602. /* —— 点运行条 = 跳回**正在运行的那个任务**的详情页(规格书 §4 第 7 条)——
  603. * 用户在「记录」tab 上看到运行条、点一下,最想问的是"谁在跑"。 */
  604. api.App.switchTab('records');
  605. check('切到记录 tab 后运行条仍然可见(它是全局组件)', !!api.App.runBar);
  606. api.App.runBar.root.performClick();
  607. eq('点运行条跳回运行中的任务详情页', api.App.detail, 'video');
  608. eq('点运行条同时切回任务 tab', api.App.activeTab, 'tasks');
  609. check('详情页标题是正在跑的那个任务',
  610. textsOf(api.App.contentHost).indexOf('视频功能') >= 0);
  611. api.App.backgroundWarned = false;
  612. env.emitter.emit('resume');
  613. eq('界面回到前台时提醒了一次', api.App.backgroundWarned, true);
  614. check('resume 事件确实被注册了', typeof env.uiEvents.resume === 'function');
  615. api.App.backgroundWarned = false; /* 复位,后续断言不受影响 */
  616. /* ③ 计时推进(与引擎进度互不干扰:计时归计时,进度归引擎上报) */
  617. api.Session.startedAt = Date.now() - 3725000; /* 人为推到 1 小时 2 分 5 秒前 */
  618. env.flushIntervals();
  619. check('计时按 01:02:05 刷新', textsOf(api.App.runBar.root).indexOf('01:02:05') >= 0,
  620. textsOf(api.App.runBar.root).join(' | '));
  621. eq('时长格式化补零', api.Session.formatElapsed(65000), '00:01:05');
  622. /* ④ 引擎上报进度 → 状态条跟着变(界面不重建,只改文字) */
  623. const beforeBarRoot = api.App.runBar.root;
  624. env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
  625. runId: api.Session.runId, status: 'running', phase: '已完成 2/5', done: 2, total: 5
  626. });
  627. env.flushIntervals();
  628. check('状态条显示引擎上报的阶段',
  629. textsOf(api.App.runBar.root).indexOf('视频功能 · 已完成 2/5') >= 0,
  630. textsOf(api.App.runBar.root).join(' | '));
  631. eq('进度刷新不重建状态条(只改文字)', api.App.runBar.root === beforeBarRoot, true);
  632. /* ④ 点停止是**两步**的:先发请求、`正在收尾`,等引擎确认才真正结束 */
  633. api.App.toggleRun();
  634. eq('发出停止请求后仍处于运行态', api.Session.running, true);
  635. eq('界面进入「正在收尾」', api.Session.stopping, true);
  636. check('状态条显示「正在收尾」',
  637. textsOf(api.App.runBar.root).indexOf('视频功能 · 正在收尾') >= 0,
  638. textsOf(api.App.runBar.root).join(' | '));
  639. eq('停止标志写的内容是本次 runId',
  640. env.memoryFiles[api.Engine.STOP_FLAG_PATH], api.Session.runId);
  641. /* 引擎确认停止 */
  642. env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
  643. runId: api.Session.runId, status: 'stopped', phase: '', message: '已停止'
  644. });
  645. env.flushIntervals();
  646. eq('引擎确认后退出运行态', api.Session.running, false);
  647. eq('运行计时器已停止', tickIntervals(), 0);
  648. check('运行时状态条已移除', api.App.runBar === null);
  649. check('按钮回到「开始运行」',
  650. textsOf(api.App.runHost).indexOf('开始运行') >= 0,
  651. textsOf(api.App.runHost).join(' | '));
  652. check('留下了一行「上次运行」结论',
  653. textsOf(api.App.body).join(' | ').indexOf('上次运行:') >= 0);
  654. /* 跑完要把界面拉回前台:引擎在前台操作 TikTok,用户自己翻最近任务很别扭。
  655. * 必须是「当前这个脚本界面」的类名 + REORDER_TO_FRONT,不能用 launchPackage ——
  656. * 后者会打开 AutoX 主界面,用户看到的是一个不相干的页面。 */
  657. const frontIntent = globalThis.app.lastIntent;
  658. check('运行结束后把界面拉回前台', !!frontIntent &&
  659. frontIntent.className === 'org.autojs.autojs.ui.script.ScriptExecuteActivity',
  660. frontIntent ? frontIntent.className : 'null');
  661. eq('用 REORDER_TO_FRONT 而不是新建界面',
  662. frontIntent && frontIntent.flags, 0x20000000);
  663. /* ⑤ 引擎正常跑完 → 终态同样被接住 */
  664. api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
  665. eq('再次进入运行态', api.Session.running, true);
  666. env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
  667. runId: api.Session.runId, status: 'finished', phase: '',
  668. message: '完成:处理 2 个视频 / 发出 1 条评论'
  669. });
  670. env.flushIntervals();
  671. eq('finished 终态结束运行', api.Session.running, false);
  672. check('结论文案来自引擎上报',
  673. api.App.lastRunResult && api.App.lastRunResult.message.indexOf('完成:处理 2 个视频') === 0,
  674. api.App.lastRunResult ? api.App.lastRunResult.message : 'null');
  675. /* ⑥ 引擎没写终态就没了 → 兜底判定(否则界面会永远卡在「运行中」) */
  676. api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
  677. env.flushIntervals(); /* 先让它看到过子引擎 */
  678. eq('兜底判定前仍在运行', api.Session.running, true);
  679. env.engineState.children = []; /* 子引擎消失 */
  680. api.Session.startedAt = Date.now() - 10000; /* 越过宽限期 */
  681. env.flushIntervals();
  682. eq('引擎退出且无终态时结束运行', api.Session.running, false);
  683. check('兜底结论说明是引擎退出',
  684. api.App.lastRunResult && api.App.lastRunResult.message.indexOf('引擎已退出') >= 0,
  685. api.App.lastRunResult ? api.App.lastRunResult.message : 'null');
  686. env.engineState.children = [{ id: 'child-1' }];
  687. /* ⑦ 运行前置校验顺序 —— **顺序本身就是规格**(§2.5):
  688. * 卡密 → 权限 → 已选脚本 → 引擎接入 → 是否已在跑
  689. * 所以这里逐条把"前一关"卡住,验证它确实**先**被拦下来。 */
  690. api.App.lastRunResult = null;
  691. api.App.openDetail('id');
  692. api.Sources.clear(); /* 先保证"取材清单为空" */
  693. const spawnsBefore = env.engineState.spawned.length;
  694. /* ③ 已选脚本?否 → F1 */
  695. api.App.toggleRun();
  696. eq('未选脚本时不进运行态', api.Session.running, false);
  697. check('拦下的是「请先添加一条取材」(ID提取没有脚本可选了)',
  698. api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('请先添加一条取材') >= 0);
  699. api.Dialog.active && api.Dialog.active.close();
  700. /* ④ 引擎未接入 → 加了取材也要拦,且**不会**误起一个引擎 */
  701. api.Sources.add('anchor');
  702. api.App.toggleRun();
  703. eq('引擎未接入的脚本不进运行态', api.Session.running, false);
  704. check('弹出的是「引擎未接入」拦截窗', !!api.Dialog.active);
  705. check('拦截窗说明了哪个脚本没接入',
  706. api.Dialog.active && textsOf(api.Dialog.active.root).join(' ').indexOf('执行引擎尚未接入') >= 0,
  707. api.Dialog.active ? textsOf(api.Dialog.active.root).join(' | ') : 'null');
  708. eq('被拦下时没有起任何引擎', env.engineState.spawned.length, spawnsBefore);
  709. api.Dialog.active && api.Dialog.active.close();
  710. /* ① 卡密优先于其它一切:失效时**直接回登录页**,连弹窗都不弹 */
  711. api.Store.set('license.status', 'none');
  712. api.App.toggleRun();
  713. eq('卡密失效时优先回登录页(而不是弹脚本拦截)', api.App.screen, 'login');
  714. eq('回登录页时不弹任何弹窗', api.Dialog.active, null);
  715. api.Store.set('license.status', 'active');
  716. api.App.screen = 'app';
  717. /* ② 权限先于「有取材」:故意清空清单 + 缺权限,拦截的必须是权限窗 */
  718. api.Sources.clear();
  719. globalThis.floaty.checkPermission = () => false; /* 桩:悬浮窗未授权 */
  720. api.App.openDetail('id');
  721. api.App.toggleRun();
  722. check('缺权限时弹的是权限拦截窗(先于"未选脚本")',
  723. api.Dialog.active && textsOf(api.Dialog.active.root).join(' | ').indexOf('无障碍与悬浮窗权限未开启') >= 0,
  724. api.Dialog.active ? textsOf(api.Dialog.active.root).join(' | ') : 'null');
  725. api.Dialog.active && api.Dialog.active.close();
  726. globalThis.floaty.checkPermission = () => true; /* 恢复:后续用例需要权限齐全 */
  727. api.Sources.add('anchor');
  728. /* ⑧ 运行中卡密失效 → 停跑 + 弹到期窗 + 回登录页(设计稿 16:1360) */
  729. api.Store.set('license.status', 'active');
  730. api.App.openDetail('video');
  731. api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
  732. const lastRunId = api.Session.runId;
  733. check('本次运行有 runId', lastRunId !== '');
  734. eq('重新进入运行态', api.Session.running, true);
  735. eq('运行计时器再次启动', tickIntervals(), 1);
  736. api.Store.set('license.status', 'none');
  737. env.flushIntervals();
  738. eq('卡密失效时自动停止运行', api.Session.running, false);
  739. eq('卡密失效时运行计时器一并停止', tickIntervals(), 0);
  740. check('卡密失效时弹出到期弹窗', !!api.Dialog.active);
  741. check('到期弹窗说明「正在运行的脚本已停止」',
  742. api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('正在运行的脚本已停止') >= 0);
  743. /* 卡密失效时也要让引擎停手,否则它会继续操作 TikTok。
  744. * 注意这里比的是**本次的 runId**:Session 已被复位,从它身上读不到了。 */
  745. eq('卡密失效时向引擎发出了停止请求',
  746. env.memoryFiles[api.Engine.STOP_FLAG_PATH], lastRunId);
  747. /* 弹窗不自己切屏:要先让用户看清「脚本已停止」再点确认(设计稿 16:1490 的后续说明) */
  748. eq('弹窗未确认前仍停主界面', api.App.screen, 'app');
  749. clickByText(api.Dialog.active.root, '确认');
  750. eq('点确认后回到登录页', api.App.screen, 'login');
  751. eq('弹窗已关闭', api.Dialog.active, null);
  752. /* ------------------------------------- 11. 任务卡直跑 / F3 / 记录 */
  753. console.log('');
  754. console.log('[smoke] 任务卡 · 全局单跑 · 运行记录');
  755. /* —— 任务卡上的「运行」= 不必先切页签、不必先勾选(本次改版的核心诉求)—— */
  756. api.Store.set('license.status', 'active');
  757. api.App.screen = 'app';
  758. api.App.switchTab('tasks');
  759. api.Store.set('tabs.video.scripts.s1.checked', true);
  760. api.App.renderScreen();
  761. /* 任务卡的「运行」回调走的是 App.requestRun(taskKey):
  762. * 直接从列表起跑,验证它真的起来了(并且自动把运行条带到那个任务页)。 */
  763. const spawnsBeforeCard = env.engineState.spawned.length;
  764. api.App.requestRun('video');
  765. eq('从任务卡直接起跑会进入运行态', api.Session.running, true);
  766. eq('运行的任务是卡片对应的那个', api.Session.tab, 'video');
  767. eq('起跑时唤起了引擎', env.engineState.spawned.length, spawnsBeforeCard + 1);
  768. eq('起跑后自动停在运行中的任务详情页', api.App.detail, 'video');
  769. /* —— F3 全局单跑:运行中点**别的**任务的「运行」不禁用按钮,而是弹确认 ——
  770. *
  771. * 目前只有「视频功能」接了引擎,所以这里**临时**给 ID提取 挂一个引擎声明:
  772. * 验的是流程逻辑(弹确认 / 不误起引擎 / 停止并开始),
  773. * 而不是"现在到底哪个任务接了引擎"(那是数据,随时会变)。 */
  774. const idScripts = api.Pages.SCRIPTS_OF['id'];
  775. const originalIdEngine = idScripts[0].engine;
  776. idScripts[0].engine = 'tiktok-auto';
  777. /* v8:ID提取的"已配置"是取材清单非空,不再有脚本勾选 */
  778. api.Sources.add('anchor');
  779. const beforeSwitch = env.engineState.spawned.length;
  780. const runningRunId = api.Session.runId;
  781. api.App.requestRun('id');
  782. check('运行中点别的任务会弹 F3 确认', !!api.Dialog.active);
  783. check('F3 标题是「{任务名}正在运行」',
  784. api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('视频功能正在运行') >= 0,
  785. api.Dialog.active ? textsOf(api.Dialog.active.root).join(' | ') : 'null');
  786. check('F3 说明写明了「全局单跑」',
  787. api.Dialog.active && textsOf(api.Dialog.active.root).join(' | ').indexOf('全局单跑') >= 0);
  788. check('F3 的主按钮是「停止并开始」',
  789. api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('停止并开始') >= 0);
  790. eq('未确认前不会起新引擎', env.engineState.spawned.length, beforeSwitch);
  791. eq('未确认前旧任务仍在跑', api.Session.tab, 'video');
  792. /* 取消 → 什么都不发生(停止标志里还是上一轮的值,本轮 runId 没被写进去) */
  793. clickByText(api.Dialog.active.root, '取消');
  794. eq('点取消后仍在跑原来的任务', api.Session.tab, 'video');
  795. eq('点取消后没有起新引擎', env.engineState.spawned.length, beforeSwitch);
  796. check('点取消后没有向引擎写停止请求',
  797. env.memoryFiles[api.Engine.STOP_FLAG_PATH] !== runningRunId);
  798. /* 再点一次 → 「停止并开始」:先停旧的(写停止标志),再起新的 */
  799. api.App.requestRun('id');
  800. clickByText(api.Dialog.active.root, '停止并开始');
  801. eq('「停止并开始」向旧引擎写了停止请求', env.memoryFiles[api.Engine.STOP_FLAG_PATH], runningRunId);
  802. eq('「停止并开始」后跑的是新任务', api.Session.tab, 'id');
  803. eq('「停止并开始」确实起了新引擎', env.engineState.spawned.length, beforeSwitch + 1);
  804. check('新一轮的 runId 与上一轮不同', api.Session.runId !== runningRunId);
  805. /* 收尾:停掉这次运行并把临时挂的引擎声明恢复(否则后面的用例会以为 ID提取 能跑) */
  806. api.App.stopRun();
  807. env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
  808. runId: api.Session.runId, status: 'stopped', phase: '', message: '已停止'
  809. });
  810. env.flushIntervals();
  811. eq('收尾后回到未运行', api.Session.running, false);
  812. idScripts[0].engine = originalIdEngine;
  813. /* —— 运行记录:每次运行结束都要落一条(规格书 §4 第 15 条)—— */
  814. api.Records.clear();
  815. eq('初始时没有记录', api.Records.list().length, 0);
  816. api.App.switchTab('records');
  817. check('没有记录时走空态那一屏',
  818. textsOf(api.App.contentHost).indexOf('还没有运行记录') >= 0,
  819. textsOf(api.App.contentHost).join(' | '));
  820. check('空态给了去哪能产生记录的指引',
  821. textsOf(api.App.contentHost).indexOf('去「任务」标签选一个脚本跑起来') >= 0);
  822. /* 跑完一次(引擎上报 finished)→ 必须写一条记录 */
  823. api.App.openDetail('video');
  824. api.Store.set('tabs.video.scripts.s1.checked', true);
  825. api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
  826. env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
  827. runId: api.Session.runId, status: 'finished', phase: '',
  828. message: '完成:处理 2 个视频 / 发出 1 条评论'
  829. });
  830. env.flushIntervals();
  831. eq('运行正常结束后退出运行态', api.Session.running, false);
  832. const afterFinish = api.Records.list();
  833. eq('结束后写下了 1 条运行记录', afterFinish.length, 1);
  834. eq('记录挂在跑的那个任务下', afterFinish[0].taskKey, 'video');
  835. eq('记录的结果分类是「已完成」', afterFinish[0].result, 'success');
  836. eq('记录的结果文案来自引擎上报', afterFinish[0].resultText,
  837. '完成:处理 2 个视频 / 发出 1 条评论');
  838. check('记录带了耗时', typeof afterFinish[0].durationMs === 'number' && afterFinish[0].durationMs >= 0);
  839. api.App.switchTab('records');
  840. check('记录页显示条数与指引',
  841. textsOf(api.App.contentHost).indexOf('共 1 条 · 点任一条查看执行日志') >= 0,
  842. textsOf(api.App.contentHost).join(' | '));
  843. check('记录条目显示「任务 · 脚本」',
  844. textsOf(api.App.contentHost).indexOf('视频功能 · 关键词视频功能') >= 0,
  845. textsOf(api.App.contentHost).join(' | '));
  846. check('记录条目显示结果标签', textsOf(api.App.contentHost).indexOf('已完成') >= 0);
  847. /* —— D3 运行详情:点条目进去 —— */
  848. api.App.openRecord(afterFinish[0].id);
  849. check('运行详情顶部是返回头', textsOf(api.App.contentHost).indexOf('运行详情') >= 0);
  850. check('运行详情有日志卡', textsOf(api.App.contentHost).indexOf('执行日志') >= 0);
  851. check('没有日志时如实说明(不画一张空卡片)',
  852. textsOf(api.App.contentHost).indexOf('本次运行没有回传日志') >= 0,
  853. textsOf(api.App.contentHost).join(' | '));
  854. eq('运行详情没有开始运行按钮(它只是看,不驱动)', api.App.runHost.getChildCount(), 0);
  855. /* 返回:回到记录列表 */
  856. api.App.closeRecord();
  857. check('返回后回到记录列表',
  858. textsOf(api.App.contentHost).join(' | ').indexOf('共 1 条') >= 0,
  859. textsOf(api.App.contentHost).join(' | '));
  860. /* —— 引擎真的写了日志时:记录要带上真实日志行 —— */
  861. env.memoryFiles[api.Engine.LOG_PATH] = [
  862. JSON.stringify({ t: Date.now(), level: 'INFO', step: '启动脚本', text: '[启动脚本] 启动 · 粉丝页提 ID' }),
  863. JSON.stringify({ t: Date.now(), level: 'WARN', step: '跳过', text: '[跳过] 第 7 个账号为私密账户' }),
  864. '{"t":1,"level":"INFO","text":"半截行' /* 故意坏一行:只能丢这一行 */
  865. ].join('\n');
  866. const readBack = api.Engine.readLog();
  867. eq('日志逐行解析,坏行只丢自己', readBack.length, 2);
  868. eq('日志带上了级别', readBack[0].level, 'INFO');
  869. api.Session.start('video', 's1', '关键词视频功能', 'run-logtest');
  870. const loggedRecord = api.App.writeRecord({ status: 'failed', message: '无障碍服务被关闭' });
  871. api.Session.stop();
  872. eq('新记录带上了日志行', (loggedRecord.logs || []).length, 2);
  873. eq('失败终态被归为「报错中断」', loggedRecord.result, 'failed');
  874. /* —— F5 脚本报错:失败终态要弹窗 + 运行条变红 —— */
  875. api.App.failBar = null;
  876. api.Store.set('tabs.video.scripts.s1.checked', true);
  877. api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
  878. eq('再次进入运行态(F5 用例)', api.Session.running, true);
  879. env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
  880. runId: api.Session.runId, status: 'failed', phase: '',
  881. message: '无障碍服务被关闭,脚本已停止运行'
  882. });
  883. env.flushIntervals();
  884. eq('失败终态结束运行', api.Session.running, false);
  885. check('失败后弹出 F5 报错窗', !!api.Dialog.active);
  886. check('F5 标题是「脚本执行出错」',
  887. api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('脚本执行出错') >= 0,
  888. api.Dialog.active ? textsOf(api.Dialog.active.root).join(' | ') : 'null');
  889. check('F5 有两个出口:查看日志 / 知道了',
  890. api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('查看日志') >= 0 &&
  891. textsOf(api.Dialog.active.root).indexOf('知道了') >= 0);
  892. check('失败时运行条变红并写明「执行出错已停止」', !!api.App.failBar &&
  893. api.App.failBar.text.indexOf('执行出错已停止') >= 0,
  894. api.App.failBar ? api.App.failBar.text : 'null');
  895. check('报错条右侧写「已终止」', api.App.failBar && api.App.failBar.time === '已终止');
  896. check('报错条挂在刚写下的那条记录上',
  897. api.App.failBar && !!api.Records.find(api.App.failBar.recordId));
  898. /* 「查看日志」直接进 D3,并且报错条随之消失 */
  899. clickByText(api.Dialog.active.root, '查看日志');
  900. check('点「查看日志」进了运行详情', api.App.recordId !== '');
  901. check('进详情后报错条已清掉', api.App.failBar === null);
  902. check('详情页显示的是失败那条记录',
  903. textsOf(api.App.contentHost).indexOf('报错中断') >= 0,
  904. textsOf(api.App.contentHost).join(' | '));
  905. api.App.closeRecord();
  906. /* 「知道了」也能收掉报错条;点运行条同样能收掉 */
  907. api.App.failBar = { text: 'X · 执行出错已停止', time: '已终止', recordId: '' };
  908. api.App.renderScreen();
  909. check('报错态会渲染出红色运行条', !!api.App.runBar);
  910. api.App.runBar.root.performClick();
  911. check('点报错条后它消失', api.App.failBar === null);
  912. /* —— Records 的纯函数(文案规则,最容易写错的一层)—— */
  913. eq('时长 < 1 分钟说秒', api.Records.formatDuration(45000), '45 秒');
  914. eq('整分钟说「N 分钟」', api.Records.formatDuration(720000), '12 分钟');
  915. eq('带零头说「N 分 M 秒」', api.Records.formatDuration(750000), '12 分 30 秒');
  916. eq('超过 1 小时说「H 小时 M 分」', api.Records.formatDuration(3900000), '1 小时 5 分');
  917. const now = new Date(2026, 9, 13, 20, 0, 0).getTime();
  918. eq('今天的时间戳显示「今天 HH:MM」',
  919. api.Records.formatDay(new Date(2026, 9, 13, 10, 24).getTime(), now), '今天 10:24');
  920. eq('昨天的时间戳显示「昨天 HH:MM」',
  921. api.Records.formatDay(new Date(2026, 9, 12, 9, 58).getTime(), now), '昨天 09:58');
  922. eq('更早的时间戳显示「MM-DD HH:MM」',
  923. api.Records.formatDay(new Date(2026, 9, 5, 9, 58).getTime(), now), '10-05 09:58');
  924. /* —— 任务卡读取真实记录:「上次…」不再是假数据(规格书 §5)—— */
  925. api.Records.clear();
  926. eq('没有记录时任务卡说「还未运行过」', api.Records.lastRunLine('video'), '还未运行过');
  927. api.Records.add({ taskKey: 'video', taskName: '视频功能', scriptName: '关键词视频功能',
  928. result: 'success', startedAt: Date.now(), endedAt: Date.now(),
  929. durationMs: 1000, resultText: '处理 2 个视频' });
  930. check('有记录后任务卡显示「上次 …」',
  931. api.Records.lastRunLine('video').indexOf('上次 ') === 0,
  932. api.Records.lastRunLine('video'));
  933. check('任务卡的「上次」带上了结果',
  934. api.Records.lastRunLine('video').indexOf('处理 2 个视频') >= 0);
  935. /* 记录上限:超出必须从尾部丢弃,否则 storages 会被越写越大 */
  936. api.Records.clear();
  937. for (let i = 0; i < api.Records.MAX + 5; i++) {
  938. api.Records.add({ taskKey: 'video', taskName: '视频功能', result: 'success',
  939. startedAt: Date.now(), endedAt: Date.now(), durationMs: 0 });
  940. }
  941. eq('记录最多保留 MAX 条', api.Records.list().length, api.Records.MAX);
  942. api.Records.clear();
  943. /* ------------------------------------- 12. 输入框:直接可改,不点开弹窗 */
  944. console.log('');
  945. console.log('[smoke] 直接输入');
  946. /* 上一段把卡密打成失效并退回了登录页,这里先恢复成「已激活 + 主界面」 */
  947. api.Store.set('license.status', 'active');
  948. api.Store.set('license.expireAt', '2027-12-31');
  949. api.App.screen = 'app';
  950. api.App.openDetail('video');
  951. const kwHandle = api.W.pendingFields['tabs.video.keyword'];
  952. check('渲染页面时把本页输入框登记为「待提交」', !!kwHandle);
  953. if (kwHandle) {
  954. kwHandle.root.performClick();
  955. eq('点输入框不弹出任何弹窗', api.Dialog.active, null);
  956. /* 在栏位里清空重填(不是直接 Store.set —— 那改的是数据、不是栏位) */
  957. kwHandle.root.eraseAll();
  958. kwHandle.root.type('summer');
  959. eq('打字过程中就已写入配置', api.Store.getText('tabs.video.keyword'), 'summer');
  960. }
  961. /* 切页签会先提交本页输入框、再清空登记表 */
  962. api.App.switchTab('mine');
  963. check('切页签后不再留着上一页的输入框登记',
  964. !api.W.pendingFields['tabs.video.keyword']);
  965. eq('切页签后配置仍是用户输入的值', api.Store.getText('tabs.video.keyword'), 'summer');
  966. /* activeTab 的候选值必须包含每一个页签 —— 曾经少了 "mine",
  967. * 结果是「切到我的页后重启会落回 ID提取」。写入端的归一化会把这种漏配直接打回来。 */
  968. api.Store.set('activeTab', 'mine');
  969. eq('activeTab 认「我的」这个页签', api.Store.get('activeTab'), 'mine');
  970. api.Store.set('activeTab', 'video');
  971. /* ========================================================= 13. 悬浮球 */
  972. console.log('');
  973. console.log('[smoke] 悬浮球(规格书 §10)');
  974. /* 桩里的 density(android-stub 的 getDisplayMetrics 给的就是 2.75) */
  975. const FB_DENSITY = 2.75;
  976. /* 这一段存在的理由:悬浮球是**唯一"界面完全正确也可能点了没反应"**的模块 ——
  977. * 它的动作回调挂在窗口的视图上,而 UI 回调里抛错在 AutoX 上不报错、不弹窗、
  978. * 也不结束脚本。真机漏过的那次 bug 就是这样:扇形第 1 个按钮(运行/停止)的回调
  979. * 是个空函数,其余三个按钮正常。所以这里把它逐条钉住,别再靠肉眼守。
  980. *
  981. * 2026-10-15 起它是**两个 overlay 窗口**(球一个、扇形一个,几何恒定)。
  982. * 为什么不能是一个窗口:改窗口尺寸/位置由系统立刻生效、视图重排要等下一帧,
  983. * 中间那几帧合成器会把旧画面摆到新窗口位置 —— 用户看到的就是"点击后先跳一下"。
  984. * 下面的断言里,**"展开/收起不改任何窗口几何"** 那几条就是这件事的回归防线。 */
  985. api.Store.set('license.status', 'active');
  986. api.Store.set('license.expireAt', '2027-12-31');
  987. api.Store.set('ball.hidden', false);
  988. api.App.screen = 'app';
  989. api.FB.destroy();
  990. /* —— 显示前置:卡密有效 + 悬浮窗权限 + 用户没主动隐藏(三者缺一不建球) —— */
  991. check('三项前置满足时可以显示球', api.FB.canShow() === true);
  992. api.Store.set('ball.hidden', true);
  993. check('用户隐藏后不建球', api.FB.canShow() === false);
  994. api.Store.set('ball.hidden', false);
  995. api.Store.set('license.status', 'expired');
  996. check('卡密失效后不建球', api.FB.canShow() === false);
  997. api.Store.set('license.status', 'active');
  998. globalThis.floaty.checkPermission = () => false;
  999. check('没有悬浮窗权限时不建球', api.FB.canShow() === false);
  1000. globalThis.floaty.checkPermission = () => true;
  1001. /* —— 建球:**两个**窗口 —— */
  1002. api.FB.ensure();
  1003. const fbWins = env.wmWindows();
  1004. eq('建出两个窗口(球 + 扇形)', fbWins.length, 2);
  1005. const fbBallLp = api.FB.ballLp;
  1006. const fbFanLp = api.FB.fanLp;
  1007. check('球窗口的 LayoutParams 已记下', !!fbBallLp);
  1008. check('扇形窗口的 LayoutParams 已记下', !!fbFanLp);
  1009. const NOT_FOCUSABLE = 0x8, NOT_TOUCHABLE = 0x10;
  1010. /* 尺寸按 §10.1:球窗口 = 热区 64 + 两侧内边距 12×2;扇形窗口 = fanSizeDp() */
  1011. eq('球窗口边长 = (64 热区 + 2×12 内边距) × density',
  1012. fbBallLp.width, Math.round((64 + 12 * 2) * FB_DENSITY));
  1013. eq('扇形窗口宽 = (12+64+96+24+4+96+12) × density',
  1014. fbFanLp.width, Math.round((12 + 64 + 96 + 24 + 4 + 96 + 12) * FB_DENSITY));
  1015. eq('扇形窗口高 = 2×(96+24+12) × density',
  1016. fbFanLp.height, Math.round(2 * (96 + 24 + 12) * FB_DENSITY));
  1017. check('两个窗口都不抢输入焦点(§10.9④)',
  1018. (fbBallLp.flags & NOT_FOCUSABLE) !== 0 && (fbFanLp.flags & NOT_FOCUSABLE) !== 0);
  1019. check('球窗口永远可触摸', (fbBallLp.flags & NOT_TOUCHABLE) === 0);
  1020. check('折叠时扇形窗口不可触摸(否则整块透明区成死区)',
  1021. (fbFanLp.flags & NOT_TOUCHABLE) !== 0);
  1022. check('两窗口左边缘对齐(x 相同)', fbBallLp.x === fbFanLp.x);
  1023. eq('扇形窗口比球窗口高 88dp(球心两态的根内偏移差)',
  1024. fbBallLp.y - fbFanLp.y, Math.round(88 * FB_DENSITY));
  1025. /* 球窗口里就三件套;扇形窗口里是 4 按钮 + 4 标签 */
  1026. const fbChildCount = api.FB.root.getChildCount();
  1027. eq('球窗口里是「环 + 球 + 热区」', fbChildCount, 3);
  1028. const fbFanViews = api.FB.views.fan.slice();
  1029. eq('扇形里 4 个按钮 + 4 个标签', fbFanViews.length, 4);
  1030. const fbFanChildCount = api.FB.fanRoot.getChildCount();
  1031. eq('扇形窗口里 8 个子视图', fbFanChildCount, 8);
  1032. const fbBallBefore = api.FB.views.ball;
  1033. const fbBrandBefore = api.FB.brandBitmap;
  1034. check('品牌位图已缓存(不每次解码)', !!fbBrandBefore);
  1035. eq('折叠态下扇形按钮是 GONE',
  1036. fbFanViews[0].btn.visibility, globalThis.android.view.View.GONE);
  1037. /* 球位要如实发布给引擎:引擎的坐标手势点到球位就点到球上(实测踩过) */
  1038. const fbRect = JSON.parse(env.memoryFiles['/sdcard/tiktok_app/ball-rect.json'] || '{}');
  1039. check('球位已写盘给引擎避让', fbRect.hidden !== true && fbRect.w > 0);
  1040. eq('折叠态只发布球窗口(扇形那时不可触摸,引擎不必绕)', fbRect.w, fbBallLp.width);
  1041. eq('折叠态的避让矩形就是球窗口的矩形', fbRect.h, fbBallLp.height);
  1042. api.FB.ensure();
  1043. eq('ensure 幂等,不会建出第三个窗口', env.wmWindows().length, 2);
  1044. check('运行前悬浮球不呼吸(空闲态没有外环)', api.FB.breathing() === false);
  1045. /* ★★ 核心:展开 / 收起**不改任何窗口几何** —— 这是"不再跳动"的全部依据。
  1046. * 只要两个窗口的 x/y/w/h 在切换前后完全一致,合成器就不可能把旧画面摆到新位置。 */
  1047. const geoBefore = [fbBallLp.x, fbBallLp.y, fbBallLp.width, fbBallLp.height,
  1048. fbFanLp.x, fbFanLp.y, fbFanLp.width, fbFanLp.height].join(',');
  1049. check('初始是折叠态', api.FB.open === false);
  1050. api.FB.expand();
  1051. check('展开后 open=true', api.FB.open === true);
  1052. eq('展开后扇形按钮是 VISIBLE',
  1053. fbFanViews[0].btn.visibility, globalThis.android.view.View.VISIBLE);
  1054. eq('展开后标签是 VISIBLE',
  1055. fbFanViews[0].pill.visibility, globalThis.android.view.View.VISIBLE);
  1056. check('展开后扇形窗口变成可触摸', (fbFanLp.flags & NOT_TOUCHABLE) === 0);
  1057. eq('展开没有增删窗口', env.wmWindows().length, 2);
  1058. eq('★ 展开没有改变任何窗口几何(x/y/w/h 全等)',
  1059. [fbBallLp.x, fbBallLp.y, fbBallLp.width, fbBallLp.height,
  1060. fbFanLp.x, fbFanLp.y, fbFanLp.width, fbFanLp.height].join(','), geoBefore);
  1061. check('展开复用同一批视图,不是重建', api.FB.views.ball === fbBallBefore);
  1062. eq('展开没有增删球窗口的子视图', api.FB.root.getChildCount(), fbChildCount);
  1063. eq('展开没有增删扇形窗口的子视图', api.FB.fanRoot.getChildCount(), fbFanChildCount);
  1064. check('展开没有重新解码品牌位图', api.FB.brandBitmap === fbBrandBefore);
  1065. eq('展开后第 1 个按钮仍是同一个视图(就地更新)', api.FB.firstBtn.view, fbFanViews[0].btn);
  1066. const fbRectOpen = JSON.parse(env.memoryFiles['/sdcard/tiktok_app/ball-rect.json'] || '{}');
  1067. check('展开态发布的避让矩形是两窗口的并集(比球窗口宽)',
  1068. fbRectOpen.w > fbBallLp.width, 'w=' + fbRectOpen.w);
  1069. api.FB.collapse();
  1070. check('收起后 open=false', api.FB.open === false);
  1071. eq('收起后按钮回到 GONE',
  1072. fbFanViews[0].btn.visibility, globalThis.android.view.View.GONE);
  1073. check('收起后扇形窗口又不可触摸', (fbFanLp.flags & NOT_TOUCHABLE) !== 0);
  1074. eq('收起也没增删窗口', env.wmWindows().length, 2);
  1075. eq('★ 收起同样没有改变任何窗口几何',
  1076. [fbBallLp.x, fbBallLp.y, fbBallLp.width, fbBallLp.height,
  1077. fbFanLp.x, fbFanLp.y, fbFanLp.width, fbFanLp.height].join(','), geoBefore);
  1078. check('收起后仍是同一批视图', api.FB.views.ball === fbBallBefore);
  1079. /* —— 手势:单击展开 / 拖动吸附 / CANCEL 不算点击 —— */
  1080. const fbEv = env.MotionEvent;
  1081. function fbTap(x, y) {
  1082. api.FB.views.hot.dispatchTouch(env.touchEvent(fbEv.ACTION_DOWN, x, y));
  1083. api.FB.views.hot.dispatchTouch(env.touchEvent(fbEv.ACTION_UP, x, y));
  1084. }
  1085. function fbTouchX() { return api.FB.anchorX + Math.round(48 * FB_DENSITY / 2); }
  1086. fbTap(fbTouchX(), api.FB.anchorCy);
  1087. check('单击球 → 展开扇形', api.FB.open === true);
  1088. eq('单击展开同样不改窗口几何',
  1089. [fbBallLp.x, fbBallLp.y, fbBallLp.width, fbBallLp.height].join(','),
  1090. geoBefore.split(',').slice(0, 4).join(','));
  1091. fbTap(fbTouchX(), api.FB.anchorCy);
  1092. check('再单击 → 收起', api.FB.open === false);
  1093. /* ★ CANCEL 必须**只复位状态**:改窗口尺寸会中断手势并发 CANCEL,
  1094. * 早期把它当 UP 处理 → 每次展开立刻又收起(用户看到的是"点了完全没反应") */
  1095. api.FB.views.hot.dispatchTouch(env.touchEvent(fbEv.ACTION_DOWN, fbTouchX(), api.FB.anchorCy));
  1096. api.FB.views.hot.dispatchTouch(env.touchEvent(fbEv.ACTION_CANCEL, fbTouchX(), api.FB.anchorCy));
  1097. check('ACTION_CANCEL 不当作单击(不展开)', api.FB.open === false);
  1098. /* 拖动超过 8px 判定为拖动 → 松手吸附边缘、记忆位置、扇形跟着镜像 */
  1099. api.FB.side = 'left';
  1100. api.FB.anchorX = Math.round(12 * FB_DENSITY);
  1101. api.FB.savePosition();
  1102. const dragFromX = fbTouchX();
  1103. const fanBtnXBefore = fbFanViews[0].btn.getLeft();
  1104. api.FB.views.hot.dispatchTouch(env.touchEvent(fbEv.ACTION_DOWN, dragFromX, api.FB.anchorCy));
  1105. api.FB.views.hot.dispatchTouch(env.touchEvent(fbEv.ACTION_MOVE, dragFromX + 500, api.FB.anchorCy));
  1106. api.FB.views.hot.dispatchTouch(env.touchEvent(fbEv.ACTION_UP, dragFromX + 500, api.FB.anchorCy));
  1107. eq('拖到右半边后吸附右侧', api.FB.side, 'right');
  1108. eq('吸附位置已落盘记忆', api.Store.get('ball.side'), 'right');
  1109. check('拖动不会被误判成单击', api.FB.open === false);
  1110. check('换边后球窗口跟着移动(x 变了)',
  1111. api.FB.ballLp.x !== fbBallLp.x || api.FB.ballLp.x > 0, 'x=' + api.FB.ballLp.x);
  1112. check('换边后扇形整体镜像(按钮 x 变了)',
  1113. fbFanViews[0].btn.getLeft() !== fanBtnXBefore,
  1114. fanBtnXBefore + ' → ' + fbFanViews[0].btn.getLeft());
  1115. /* 复原到左侧,后面的按钮动作断言要在已知状态下做 */
  1116. api.FB.side = 'left';
  1117. api.FB.anchorX = Math.round(12 * FB_DENSITY);
  1118. api.FB.restoreAnchor = api.FB.restoreAnchor; /* noop,保持引用可读 */
  1119. api.FB.savePosition();
  1120. api.FB.ensure();
  1121. /* —— ★ 扇形四个按钮都必须真的触发动作 —— */
  1122. api.FB.expand();
  1123. let fbRunCalls = 0;
  1124. let fbRunArg = null;
  1125. const fbRealRequestRun = api.App.requestRun;
  1126. api.App.requestRun = function (key) { fbRunCalls += 1; fbRunArg = key; };
  1127. api.App.openDetail('video'); /* askRun 需要"当前任务",否则它选择回 App 让用户挑 */
  1128. /* ★★ 动作必须在**点击时现算**:曾经把动作存在可变 holder 里、只在"三态变化"时接线,
  1129. * 而展开这一步是 idle→idle → 回调永远是空函数:界面全对、事件也到了、就是没反应。 */
  1130. api.FB.firstBtn.view.performClick();
  1131. eq('空闲态点扇形第 1 个按钮 → 真的发起运行', fbRunCalls, 1);
  1132. eq('发起运行用的是当前任务', fbRunArg, 'video');
  1133. check('选完按钮后扇形自动收起(别挡着正在跑的 TikTok)', api.FB.open === false);
  1134. eq('点完按钮扇形窗口也回到不可触摸', (fbFanLp.flags & NOT_TOUCHABLE) !== 0 ? 1 : 0, 1);
  1135. /* 运行中:重新摊开时第 1 个按钮必须**一建出来就是**「停止」 */
  1136. api.Session.start('video', 'video1', '关键词视频功能', 'run-fb-1');
  1137. api.FB.sync();
  1138. check('运行中球开始呼吸(§10.4 三态环)', api.FB.breathing() === true);
  1139. api.FB.expand();
  1140. eq('运行中按钮图标是方块(停止)', String(api.FB.firstBtn.view.getText()), '■');
  1141. eq('运行中标签是「停止」', String(api.FB.firstBtn.pill.getText()), '停止');
  1142. /* 扇形开着时状态变化 → **就地**改那一个按钮(不重建:重建会吃掉用户那一刻的点击) */
  1143. api.Session.stop();
  1144. api.FB.sync();
  1145. eq('扇形开着时空闲下来 → 按钮就地变回「▶」', String(api.FB.firstBtn.view.getText()), '▶');
  1146. check('空闲下来后停止呼吸', api.FB.breathing() === false);
  1147. api.Session.start('video', 'video1', '关键词视频功能', 'run-fb-1');
  1148. api.FB.sync();
  1149. eq('扇形开着时又跑起来 → 按钮就地变回「■」', String(api.FB.firstBtn.view.getText()), '■');
  1150. api.Dialog.active = null;
  1151. api.FB.firstBtn.view.performClick();
  1152. check('运行中点它 → 先收起扇形', api.FB.open === false);
  1153. check('运行中点它 → 拉起 H5 停止确认', !!api.Dialog.active);
  1154. check('确认弹窗的标题正确',
  1155. textsOf(api.Dialog.active && api.Dialog.active.root).indexOf('停止当前脚本?') >= 0);
  1156. check('确认前先记下"要停哪个脚本"',
  1157. textsOf(api.Dialog.active && api.Dialog.active.root).some((t) => t.indexOf('关键词视频功能') >= 0));
  1158. if (api.Dialog.active) { api.Dialog.active.close(); }
  1159. api.Session.stop();
  1160. api.FB.sync();
  1161. /* 「回到 App」→ 按 REORDER_TO_FRONT 发 Intent(回到的是同一个界面,不是 AutoX 主界面) */
  1162. api.FB.expand();
  1163. globalThis.app.lastIntent = null;
  1164. const backBtn = (function () {
  1165. for (let i = 0; i < api.FB.fanRoot.getChildCount(); i++) {
  1166. const v = api.FB.fanRoot.getChildAt(i);
  1167. if (String(v.text || '') === '\u25A6') { return v; }
  1168. }
  1169. return null;
  1170. })();
  1171. check('扇形里有「回到 App」按钮', !!backBtn);
  1172. if (backBtn) {
  1173. backBtn.performClick();
  1174. check('点「回到 App」→ 发出一条拉回前台的 Intent', !!globalThis.app.lastIntent);
  1175. check('拉回前台用的是 REORDER_TO_FRONT',
  1176. globalThis.app.lastIntent.flags === undefined ||
  1177. globalThis.app.lastIntent.flags === globalThis.android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
  1178. }
  1179. /* 「退出应用」→ H6 确认(不真的退:确认弹窗出现即算通) */
  1180. api.Dialog.active = null;
  1181. api.FB.expand();
  1182. const exitBtn = (function () {
  1183. for (let i = 0; i < api.FB.fanRoot.getChildCount(); i++) {
  1184. const v = api.FB.fanRoot.getChildAt(i);
  1185. if (String(v.text || '') === '\u2715') { return v; }
  1186. }
  1187. return null;
  1188. })();
  1189. check('扇形里有「退出应用」按钮', !!exitBtn);
  1190. if (exitBtn) {
  1191. exitBtn.performClick();
  1192. check('点「退出应用」→ 拉起 H6 确认', !!api.Dialog.active);
  1193. check('H6 标题正确',
  1194. textsOf(api.Dialog.active && api.Dialog.active.root).indexOf('退出飞舟脚本?') >= 0);
  1195. if (api.Dialog.active) { api.Dialog.active.close(); }
  1196. }
  1197. /* —— 隐藏 / 销毁:两个窗口都要被摘掉,否则会在屏幕上留下孤儿浮窗 —— */
  1198. api.FB.expand();
  1199. api.FB.hide();
  1200. eq('隐藏后落盘 ball.hidden', api.Store.getBool('ball.hidden'), true);
  1201. eq('隐藏后两个窗口都被移除', env.wmWindows().length, 0);
  1202. check('隐藏后释放窗口引用', api.FB.window === null && api.FB.fanLp === null);
  1203. const fbRectHidden = JSON.parse(env.memoryFiles['/sdcard/tiktok_app/ball-rect.json'] || '{}');
  1204. check('隐藏时告诉引擎"球没了"(否则引擎会一直绕开一个不存在的球)', fbRectHidden.hidden === true);
  1205. /* 隐藏是可逆的:Store.set 打开后必须能再建出来 */
  1206. api.FB.setHidden(false);
  1207. eq('再打开开关后又建出两个窗口', env.wmWindows().length, 2);
  1208. check('重建后窗口引用已恢复', api.FB.window !== null && api.FB.fanLp !== null);
  1209. api.FB.destroy();
  1210. check('destroy 之后不再持有窗口', api.FB.window === null);
  1211. eq('destroy 之后 WindowManager 上没有残留窗口', env.wmWindows().length, 0);
  1212. api.App.requestRun = fbRealRequestRun;
  1213. /* ------------------------------------------------------- 14. 启动结论 */
  1214. check('启动结论已落盘', typeof env.memoryFiles['/sdcard/tiktok_app_ui_result.txt'] === 'string');
  1215. check('启动结论标记 OK_UI_STARTED',
  1216. String(env.memoryFiles['/sdcard/tiktok_app_ui_result.txt']).indexOf('OK_UI_STARTED') === 0);
  1217. check('启动结论记录当前屏与卡密状态',
  1218. String(env.memoryFiles['/sdcard/tiktok_app_ui_result.txt']).indexOf('screen=') >= 0);
  1219. /* ------------------------------------------------------------ 汇总 */
  1220. console.log('');
  1221. console.log(`通过 ${passed} 项`);
  1222. if (failures.length > 0) {
  1223. console.log(`失败 ${failures.length} 项:`);
  1224. failures.forEach((f) => console.log(' ✗ ' + f));
  1225. console.log('');
  1226. console.log('SMOKE_FAILED');
  1227. process.exit(1);
  1228. }
  1229. console.log('SMOKE_ALL_PASS');