| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093 |
- #!/usr/bin/env node
- /**
- * smoke.js — 产物离线自检
- *
- * 作用:在 PC 上把 dist/tiktok-app.js 真正加载并跑一遍,验证
- * ① 模块依赖顺序正确(少一个依赖会在加载期就抛 ReferenceError)
- * ② 界面树能构建完整(每个 View 构造是否用到不存在的 API)
- * ③ 状态读写闭环(点一下勾选框,配置是否真的落盘)
- * ④ **流程闭环**:登录 → 主界面 → 权限拦截 → 开始运行 → 计时 → 停止
- * ⑤ 主题工具与业务纯函数的计算是否正确
- *
- * 为什么必须有它:AutoX 设备端没有测试框架,产物发到手机上后
- * 唯一的反馈是"界面出来了/没出来"。把能在 PC 上判定的问题留在 PC 上。
- *
- * 用法:node tools/smoke.js
- */
- 'use strict';
- const path = require('path');
- const stub = require('./android-stub');
- const env = stub.install();
- globalThis.__FEIZHOU_TEST__ = true;
- /* 加载产物 = 执行 App.main(),界面树会被建出来但挂在桩上,不碰真实设备 */
- require(path.join(__dirname, '..', 'dist', 'tiktok-app.js'));
- const api = globalThis.__FEIZHOU__;
- if (!api) {
- console.error('✗ 产物没有暴露 __FEIZHOU__,构建可能被改坏了');
- process.exit(1);
- }
- let passed = 0;
- const failures = [];
- function check(name, condition, detail) {
- if (condition) {
- passed += 1;
- return;
- }
- failures.push(name + (detail ? ' → ' + detail : ''));
- }
- function eq(name, actual, expected) {
- check(name, actual === expected, `期望 ${expected},实际 ${actual}`);
- }
- /**
- * 「运行计时器」的数量。
- *
- * 悬浮球的呼吸环也是一个 setInterval(球在说"正在运行",环就该在呼吸),
- * 而球在第一次渲染时就已经建好了,于是 interval 总数会多出它那一个。
- * 断言运行计时器时必须把它减掉 —— 否则会把"球的视觉动效"当成"计时器没停"。
- */
- /** 只数"运行时长计时器",把基础设施计时器排除掉:
- * · 悬浮球的呼吸灯动画
- * · 界面生命周期监听(界面关了要带走悬浮球,见 main.js 的 bindLifecycleCleanup) */
- function tickIntervals() {
- return env.intervalCount()
- - (api.FB.breathing() ? 1 : 0)
- - (api.App.lifecycleTick ? 1 : 0);
- }
- /** 递归收集一棵视图树里所有非空文字,用于断言"界面上到底显示了什么"。 */
- function textsOf(view, out) {
- const acc = out || [];
- if (!view) { return acc; }
- if (typeof view.text === 'string' && view.text !== '') { acc.push(view.text); }
- (view.children || []).forEach((child) => textsOf(child, acc));
- return acc;
- }
- /**
- * 按显示文字点击控件。
- * 按钮的点击监听挂在容器 FrameLayout 上、文字在子 TextView 上,
- * 所以要找"带这段文字的节点",再点它的父节点。
- */
- function clickByText(root, label) { const hits = [];
- (function walk(view) {
- if (!view) { return; }
- if (view.text === label) { hits.push(view); }
- (view.children || []).forEach(walk);
- })(root);
- for (let i = 0; i < hits.length; i += 1) {
- const node = hits[i];
- if (node.parent && node.parent.listeners && node.parent.listeners.click) {
- node.parent.performClick();
- return true;
- }
- if (node.listeners && node.listeners.click) {
- node.performClick();
- return true;
- }
- }
- return false;
- }
- /* ------------------------------------------------------- 1. 加载与挂载 */
- check('产物加载完成并暴露内部对象', !!api);
- check('根视图已挂载', !!env.mounted.root, 'ui.setContentView 未被调用');
- check('根视图有子节点(光晕层 + 主体 + 弹窗层)',
- env.mounted.root && env.mounted.root.children.length >= 3,
- env.mounted.root ? `实际 ${env.mounted.root.children.length}` : '无');
- /* ------------------------------------------------------- 2. 启动屏路由 */
- eq('卡密未激活时启动进登录页', api.App.screen, 'login');
- const loginTexts = textsOf(api.App.body);
- check('登录页显示应用名', loginTexts.indexOf('飞舟脚本') >= 0, loginTexts.join(' | '));
- check('登录页有登录激活按钮', loginTexts.indexOf('登录激活') >= 0);
- check('登录页说明是"请登录后使用"',
- loginTexts.indexOf('TikTok 自动化脚本 · 请登录后使用') >= 0);
- check('登录页有退出应用入口', loginTexts.indexOf('退出应用') >= 0);
- check('登录页没有页签栏(未激活不给主界面)', api.App.tabHost === undefined || !api.App.tabHost);
- /* ------------------------------------------------------- 3. 主题计算 */
- /* 0xFF2EA6FF 作为 32 位有符号 int 就是 -13719809(Java 的 setColor 接受这种负数) */
- eq('argb(#2EA6FF) 得到正确的 ARGB int', api.Theme.util.argb('#2EA6FF'), -13719809);
- eq('argb 六位与八位输入等价', api.Theme.util.argb('#2EA6FF'), api.Theme.util.argb('#FF2EA6FF'));
- eq('alphaOf(#FFFFFF, 0.06) 的 alpha 通道正确',
- (api.Theme.util.alphaOf('#FFFFFF', 0.06) >>> 24) & 0xFF, 15);
- const solid = api.Theme.util.solidBg(-1, 16);
- check('solidBg 返回可用背景对象', !!solid && solid.radius > 0);
- const gradient = api.Theme.util.gradientBg('#2EA6FF', '#9482FF', 99);
- check('gradientBg 写入两个色停',
- gradient && gradient.colors && gradient.colors.length === 2,
- gradient && gradient.colors ? `${gradient.colors.length}` : 'null');
- const stroke = api.Theme.util.strokeBg(0.04, 0.08, '#FFFFFF', 16);
- check('strokeBg 写入描边', !!stroke.stroke && stroke.stroke.width > 0);
- /* 颜色令牌完整性。
- * 拼错一个键不会报错:argb(undefined) 以前会算出 0(全透明),
- * 界面上的表现只是「某块东西不见了」—— 整排非活动页签就这么消失过一次。 */
- const hexKeys = Object.keys(api.Theme.hex);
- const badHex = hexKeys.filter((k) => !/^#[0-9A-Fa-f]{6}$/.test(api.Theme.hex[k]));
- check('Theme.hex 全部是合法的 6 位色值', badHex.length === 0, badHex.join(', '));
- const zeroColors = hexKeys.filter((k) => api.Theme.util.argb(api.Theme.hex[k]) === 0);
- check('没有色值会被解析成全透明', zeroColors.length === 0, zeroColors.join(', '));
- check('非法色值不会静默返回透明(返回洋红)',
- api.Theme.util.argb(undefined) === -65281 && api.Theme.util.argb('#ZZZ') === -65281,
- `${api.Theme.util.argb(undefined)} / ${api.Theme.util.argb('#ZZZ')}`);
- /* 界面代码真正引用到的键必须都在 —— 上面那条只保证"已有的都合法",
- * 这条保证"用到的都存在"(漏删/改名就是这里暴露) */
- const REQUIRED_HEX = ['bg', 'tabBar', 'accent', 'accentText', 'accentDeep', 'textPrimary',
- 'textSecondary', 'textMuted', 'textFaint', 'tabIdle', 'warn', 'danger', 'ok'];
- const missingHex = REQUIRED_HEX.filter((k) => api.Theme.hex[k] === undefined);
- check('界面用到的颜色键都存在', missingHex.length === 0, missingHex.join(', '));
- /* ------------------------------------------------------- 4. 数据层 */
- eq('默认配置版本号', api.Store.defaults().version, api.Store.SCHEMA_VERSION);
- eq('默认 tab 是「任务」', api.Store.get('activeTab'), 'tasks');
- /* —— v8:ID提取不再有"脚本",四张卡的 23 个字段全部从字段表移除 ——
- * 光从页面里删不够:字段还留在 FIELDS 里的话,它会继续出现在导出配置与审计里,
- * 变成一个"没有任何控件、却一直有值"的幽灵。 */
- const legacyIdFields = api.Store.FIELDS.filter((f) => /^tabs\.id\.scripts\./.test(f.path));
- check('四张脚本卡的字段已全部从字段表移除', legacyIdFields.length === 0,
- legacyIdFields.map((f) => f.path).join(', '));
- /* 取材清单是**另一个储物柜**(不定长列表进不了那张标量表)。空清单是合法初始状态,
- * 等价于 v7 的"一个脚本都没选":能进页面,但点运行会被拦。 */
- eq('取材清单默认是空的', api.Sources.count(), 0);
- check('空清单 = 未配置(再也找不到"哪个脚本被选中"这种概念)',
- api.Pages.checkedScript('id') === null);
- /* v7 那次清语义的成果一个都没丢:功能设置 19 个字段还在 */
- /* 23 = v7 的 19 个 + 去重两开关 + 单次最长运行 + 同时解析数字 uid */
- eq('ID提取功能设置是 23 个字段',
- api.Store.FIELDS.filter((f) => /^tabs\.id\.features\./.test(f.path)).length, 23);
- /* —— 界面 → 引擎的字段映射:一个都不许丢 ——
- * 这个项目踩过"路径拼错 = 静默丢值"(字段在界面上有控件、能改、能存,
- * 但引擎配置里永远是默认值,谁也看不出来)。所以逐项验证"改了就一定进引擎配置"。
- * 做法:把每一项都改成**非默认值**,再看 buildIdConfig 的结果里是不是都有。 */
- const idBase = 'tabs.id.features.';
- api.Store.set(idBase + 'filterPrivate', true);
- api.Store.set(idBase + 'filterNoAvatar', true);
- api.Store.set(idBase + 'langRegion', 'zh');
- api.Store.set(idBase + 'nicknameCheck', 'sign');
- api.Store.set(idBase + 'followMin', 11);
- api.Store.set(idBase + 'followMax', 22);
- api.Store.set(idBase + 'fansMin', 33);
- api.Store.set(idBase + 'fansMax', 44);
- api.Store.set(idBase + 'likesMin', 55);
- api.Store.set(idBase + 'likesMax', 66);
- api.Store.set(idBase + 'videosMin', 77);
- api.Store.set(idBase + 'videosMax', 88);
- api.Store.set(idBase + 'extractLimit', 99);
- api.Store.set(idBase + 'dedupeHistory', true);
- api.Store.set(idBase + 'cloudDedupe', true);
- api.Store.set(idBase + 'regionCodes', 'JP#TW');
- api.Sources.clear();
- api.Sources.add('anchor');
- const mapped = api.Engine.buildIdConfig();
- const mf = mapped.config.id.filter;
- const mo = mapped.config.id.output;
- eq('映射:私密过滤', mf.private, true);
- eq('映射:无头像过滤', mf.noAvatar, true);
- eq('映射:语言区域', mf.langRegion, 'zh');
- eq('映射:取样范围', mf.nicknameCheck, 'sign');
- eq('映射:关注量下限', mf.follow.min, 11);
- eq('映射:关注量上限', mf.follow.max, 22);
- eq('映射:粉丝量下限', mf.fans.min, 33);
- eq('映射:粉丝量上限', mf.fans.max, 44);
- eq('映射:获赞量下限', mf.likes.min, 55);
- eq('映射:获赞量上限', mf.likes.max, 66);
- eq('映射:视频量下限', mf.videos.min, 77);
- eq('映射:视频量上限', mf.videos.max, 88);
- eq('映射:提取上限', mo.limit, 99);
- eq('映射:跨轮去重', mo.dedupeHistory, true);
- check('映射:结果目录与历史文件在同一处',
- mo.dir && mo.historyPath.indexOf(mo.dir) === 0, mo.dir + ' / ' + mo.historyPath);
- /* 单次最长运行(分钟 → 毫秒)。这个值实际决定「一轮最多能抓多少个」:
- * 映射丢了的话用户改时间完全不起作用,而且界面上看不出来(改完还在按 30 分钟收尾)。 */
- api.Store.set(idBase + 'maxRuntimeMin', 90);
- const mappedRuntime = api.Engine.buildIdConfig();
- eq('映射:单次最长运行(分钟 → 毫秒)',
- mappedRuntime.config.id.guard.maxRuntimeMs, 90 * 60 * 1000);
- api.Store.set(idBase + 'maxRuntimeMin', api.Store.defaultOf(idBase + 'maxRuntimeMin'));
- /* 同时解析数字 uid:默认开,关了必须真的传到引擎。
- * 这个开关决定"抓完要不要再多花 2.5 秒/个去换 uid" —— 映射丢了的话
- * 用户关掉它仍然会白等,而界面上完全看不出来。 */
- eq('映射:同时解析数字 uid 默认开', api.Engine.buildIdConfig().config.id.output.resolveUid, true);
- api.Store.set(idBase + 'resolveUid', false);
- eq('映射:关掉后引擎不再解析 uid', api.Engine.buildIdConfig().config.id.output.resolveUid, false);
- api.Store.set(idBase + 'resolveUid', api.Store.defaultOf(idBase + 'resolveUid'));
- /* 「做不到的项」必须逐条进 notes —— 用户勾了却什么都发生,比明确告知糟得多 */
- const noteText = mapped.notes.join(' | ');
- check('云端去重开了 → 如实告知只用本地', noteText.indexOf('云端去重') >= 0, noteText);
- check('视频量收窄了 → 如实告知用户页不显示该数字',
- noteText.indexOf('视频量') >= 0, noteText);
- /* 「提取区域」刻意**不报**:它在界面上是灰行 + 「暂不支持」胶囊,用户已经看得到;
- * 每次运行再报一次只会变成噪音。这条断言守住这个决定,防止以后又加回来。 */
- check('提取区域不重复报(界面上已标注暂不支持)',
- noteText.indexOf('提取区域') < 0, noteText);
- /* 复原,别把非默认值留给后面的用例 */
- /* 复原:**逐项回到默认值**而不是硬编码 0 —— 这几项里 followMax/likesMax/videosMax
- * 的默认值是 500000 而不是 0,硬写 0 会把后面的断言带歪(第一版就是这么错的)。
- * 用 Store.defaultOf() 才能跟着默认值变。 */
- ['filterPrivate', 'filterNoAvatar', 'langRegion', 'nicknameCheck',
- 'followMin', 'followMax', 'fansMin', 'fansMax', 'likesMin', 'likesMax',
- 'videosMin', 'videosMax', 'extractLimit', 'dedupeHistory', 'cloudDedupe',
- 'regionCodes'].forEach((name) => {
- api.Store.set(idBase + name, api.Store.defaultOf(idBase + name));
- });
- api.Sources.clear();
- /* 全默认值时不该冒出一堆"未生效"的噪音(会把真正需要注意的那条淹没) */
- api.Sources.add('anchor');
- const quiet = api.Engine.buildIdConfig();
- /* 默认配置下**只有一条**该报:无头像过滤默认是开的(规格书要求),
- * 而它确实有黑白照片会被误判这个局限 —— 用户第一次跑就该知道。
- * 其余各项(云端去重 / 视频量 / AI 三项 / 提取区域)都不该出声。 */
- check('默认配置下只报真正需要注意的那一条(无头像的局限)',
- quiet.notes.length === 1 && quiet.notes[0].indexOf('无头像') >= 0,
- quiet.notes.join(' | '));
- api.Sources.clear();
- /* —— v7 补的两个去重开关(v8 保留) —— */
- eq('本地去重默认开', api.Store.get('tabs.id.features.dedupeLocal'), true);
- eq('跨轮去重默认关(首次运行没有历史可比)', api.Store.get('tabs.id.features.dedupeHistory'), false);
- /* 养号参数:设计稿 16:2 —— 两个区间 + 三个档位 */
- eq('养号浏览时长默认 3 ~ 8',
- api.Store.get('tabs.nurture.browseMin') + ' ~ ' + api.Store.get('tabs.nurture.browseMax'), '3 ~ 8');
- eq('养号点赞概率默认 30%', api.Store.label('likeRate', api.Store.get('tabs.nurture.likeRate')), '30%');
- eq('养号评论概率默认 10%', api.Store.label('commentRate', api.Store.get('tabs.nurture.commentRate')), '10%');
- eq('养号关注概率默认 15%', api.Store.label('followRate', api.Store.get('tabs.nurture.followRate')), '15%');
- eq('养号单次操作默认 10 ~ 30',
- api.Store.get('tabs.nurture.actionMin') + ' ~ ' + api.Store.get('tabs.nurture.actionMax'), '10 ~ 30');
- /* UID私信 / 视频功能的区间默认值(设计稿 3:579 / 3:667) */
- eq('关注数量默认 100 ~ 200',
- api.Store.get('tabs.dm.followMin') + ' ~ ' + api.Store.get('tabs.dm.followMax'), '100 ~ 200');
- eq('私信数量默认 20 ~ 30',
- api.Store.get('tabs.dm.dmMin') + ' ~ ' + api.Store.get('tabs.dm.dmMax'), '20 ~ 30');
- /* v9:休息时间默认从 100~500 秒改为 10~40 秒。
- * 它是「每处理完一个 UID 后」的休息,而关注 100~200 + 私信 20~30 是两批不同的人,
- * 合计 120~230 个 UID —— 按旧默认一轮要跑 5~24 小时,实际跑不完。 */
- eq('休息时间默认 10 ~ 40(v9 起)',
- api.Store.get('tabs.dm.restMin') + ' ~ ' + api.Store.get('tabs.dm.restMax'), '10 ~ 40');
- eq('停止个数默认 1 ~ 5',
- api.Store.get('tabs.video.stopMin') + ' ~ ' + api.Store.get('tabs.video.stopMax'), '1 ~ 5');
- eq('间隔时间默认 20 ~ 20',
- api.Store.get('tabs.video.intervalMin') + ' ~ ' + api.Store.get('tabs.video.intervalMax'), '20 ~ 20');
- /* 每个区间都必须有对应的跨字段约束,否则会出现「最小值 500 / 最大值 100」 */
- const rangePairs = api.Store.CROSS_RULES.map((r) => r.low + '|' + r.high);
- const EXPECT_PAIRS = [
- 'tabs.dm.followMin|tabs.dm.followMax',
- 'tabs.dm.dmMin|tabs.dm.dmMax',
- 'tabs.dm.restMin|tabs.dm.restMax',
- 'tabs.nurture.browseMin|tabs.nurture.browseMax',
- 'tabs.nurture.actionMin|tabs.nurture.actionMax',
- 'tabs.video.stopMin|tabs.video.stopMax',
- 'tabs.video.intervalMin|tabs.video.intervalMax'
- ];
- const missingPair = EXPECT_PAIRS.filter((p) => rangePairs.indexOf(p) < 0);
- check('每个界面区间都有对应的跨字段约束', missingPair.length === 0, missingPair.join(', '));
- /* ID提取页功能设置:四个门槛都成对且有默认值 */
- eq('关注量下限默认 0', api.Store.get('tabs.id.features.followMin'), 0);
- eq('关注量上限默认 500000', api.Store.get('tabs.id.features.followMax'), 500000);
- eq('提取区域默认国家简码', api.Store.get('tabs.id.features.regionCodes'), 'JP#TW#US');
- /* 字段表完整性:每个字段的默认值都必须与声明类型相符 */
- const typeMismatch = api.Store.FIELDS.filter((f) => {
- if (f.type === 'bool') { return typeof f.def !== 'boolean'; }
- if (f.type === 'int') { return typeof f.def !== 'number'; }
- return typeof f.def !== 'string';
- });
- check('字段表里每个默认值都与声明类型相符', typeMismatch.length === 0,
- typeMismatch.map((f) => `${f.path}=${typeof f.def}`).join(', '));
- /* set → 重新读取 → 落盘(storages 里应该有 JSON) */
- api.Store.set('tabs.video.keyword', 'fashion');
- eq('写入后立即读到新值', api.Store.get('tabs.video.keyword'), 'fashion');
- const persisted = env.memoryStorage[api.Store.STORAGE_NAME][api.Store.STORAGE_KEY];
- check('配置已持久化到 storages', typeof persisted === 'string' && persisted.indexOf('fashion') >= 0);
- const reloaded = JSON.parse(persisted);
- eq('持久化内容可直接解析回同一字段', reloaded.tabs.video.keyword, 'fashion');
- /* 深合并:旧数据缺新字段时应保留默认值 */
- const merged = api.Store.merge({ a: 1, b: { c: 2, d: 3 } }, { b: { c: 9 } });
- eq('merge 覆盖同名键', merged.b.c, 9);
- eq('merge 保留未提及的嵌套键', merged.b.d, 3);
- eq('merge 保留顶层未提及键', merged.a, 1);
- /* 校验:非法值必须被收敛而不是穿透到界面 */
- const bad = api.Store.validate({
- version: 99,
- activeTab: 'not-a-tab',
- tabs: {
- dm: { followMax: 999999, aiGender: 'unknown', restMin: 500, restMax: 100 },
- video: { stopMin: -3, stopMax: -9, actions: { follow: 'yes' } },
- nurture: { browseMin: 60, browseMax: 5, actionMax: 9999 },
- id: { features: { followMin: 900, followMax: 100 } }
- }
- });
- eq('非法 tab 回落到 tasks', bad.config.activeTab, 'tasks');
- eq('超范围的上限被夹到 max', bad.config.tabs.dm.followMax, 5000);
- eq('非法枚举回落到默认', bad.config.tabs.dm.aiGender, 'male');
- eq('min > max 时把 max 抬到 min', bad.config.tabs.dm.restMax, bad.config.tabs.dm.restMin);
- eq('负的停止个数被夹到 min', bad.config.tabs.video.stopMin, 1);
- eq('区间的另一端也各自被夹到 min', bad.config.tabs.video.stopMax, 1);
- eq('浏览时长 min>max 被抬齐', bad.config.tabs.nurture.browseMax, bad.config.tabs.nurture.browseMin);
- eq('单次操作 max 超范围被夹', bad.config.tabs.nurture.actionMax, 500);
- eq('字符串真值被转成布尔', bad.config.tabs.video.actions.follow, true);
- eq('ID页门槛的 min > max 也被抬齐',
- bad.config.tabs.id.features.followMax, bad.config.tabs.id.features.followMin);
- check('版本不一致会被记录为问题', bad.problems.length >= 1, `problems=${bad.problems.length}`);
- /* 枚举映射:显示文案 ↔ 存储 key 必须可逆 */
- eq('label 反向解析 dedupe', api.Store.label('dedupe', 'id'), 'ID 模式');
- eq('keyAt 由位置取回 key', api.Store.keyAt('dedupe', 1), 'text');
- eq('indexOfLabel 与 keyAt 互逆', api.Store.keyAt('aiGender', api.Store.indexOfLabel('aiGender', 'female')), 'female');
- eq('概率档位 keyAt 反解', api.Store.keyAt('likeRate', 0), 'r10');
- /* v4 → v5 迁移:五处「一个值」变成「一个区间」,旧值必须沿用且行为不变 */
- const v4 = api.Store.validate({
- version: 4,
- tabs: {
- dm: { followLimit: 300, dmLimit: 40 },
- nurture: { browseDuration: 'd5_10', actionLimit: 'n50' },
- video: { stopCount: 7 }
- }
- });
- eq('v4 关注上限 → 关注数量 0 ~ 300',
- v4.config.tabs.dm.followMin + ' ~ ' + v4.config.tabs.dm.followMax, '0 ~ 300');
- eq('v4 私信上限 → 私信数量 0 ~ 40',
- v4.config.tabs.dm.dmMin + ' ~ ' + v4.config.tabs.dm.dmMax, '0 ~ 40');
- eq('v4 浏览时长档位 d5_10 → 5 ~ 10',
- v4.config.tabs.nurture.browseMin + ' ~ ' + v4.config.tabs.nurture.browseMax, '5 ~ 10');
- eq('v4 单次操作上限 50 次 → 0 ~ 50',
- v4.config.tabs.nurture.actionMin + ' ~ ' + v4.config.tabs.nurture.actionMax, '0 ~ 50');
- eq('v4 停止个数 7(精确量)→ 7 ~ 7',
- v4.config.tabs.video.stopMin + ' ~ ' + v4.config.tabs.video.stopMax, '7 ~ 7');
- check('迁移会记录做了什么', v4.problems.some((p) => p.indexOf('关注上限') >= 0 || p.indexOf('followLimit') >= 0),
- v4.problems.join(' | '));
- check('旧字段不会残留在配置里', v4.config.tabs.dm.followLimit === undefined &&
- v4.config.tabs.video.stopCount === undefined);
- eq('未迁移时用的是设计稿默认区间', api.Store.defaults().tabs.dm.followMin, 100);
- /* ------------------------------------------------------- 5. 服务层 */
- eq('卡密规范化去空格与连字符', api.Service.normalizeCode(' abcd-1234 efgh-5678 '), 'ABCD1234EFGH5678');
- check('16 位合法卡密通过格式校验', api.Service.isWellFormed('ABCD1234EFGH5678'));
- check('短卡密被拒', !api.Service.isWellFormed('ABCD1234'));
- check('含非法字符被拒', !api.Service.isWellFormed('ABCD1234EFGH567!'));
- eq('卡密分组展示', api.Service.formatCode('ABCD1234EFGH5678'), 'ABCD-1234-EFGH-5678');
- eq('本地模式登录成功', api.Service.login('ABCD1234EFGH5678').ok, true);
- check('格式错误的卡密登录失败', api.Service.login('bad').ok === false);
- eq('今日日期格式为 yyyy-MM-dd', /^\d{4}-\d{2}-\d{2}$/.test(api.Service.today()), true);
- check('未来日期不算过期', !api.Service.isExpired(api.Service.datePlusDays(5)));
- check('过去日期算过期', api.Service.isExpired('2000-01-01'));
- check('检查更新在无端点时给出本地结论', api.Service.checkUpdate().source === 'local');
- /* licenseDisplay 必须带上 status:页面靠它决定显示「解绑」还是「去登录」。
- * 少了它,已登录状态下会出现「已登录」旁边挂着「去登录」这种自相矛盾的组合,
- * 而且不会报任何错。 */
- api.Store.set('license.status', 'active');
- const activeDisplay = api.Service.licenseDisplay();
- check('licenseDisplay 在已登录时返回 status', activeDisplay.status === 'active',
- String(activeDisplay.status));
- check('licenseDisplay 文案与色调随状态变化',
- activeDisplay.text === '已登录' && activeDisplay.tone === 'ok');
- api.Store.set('license.status', 'none');
- eq('licenseDisplay 在未登录时返回 none', api.Service.licenseDisplay().status, 'none');
- api.Store.set('license.status', 'expired');
- eq('licenseDisplay 在已过期时返回 expired 与 warn 色',
- api.Service.licenseDisplay().status + '/' + api.Service.licenseDisplay().tone, 'expired/warn');
- api.Store.set('license.status', 'none');
- /* ------------------------------------------------------- 6. 设备层 */
- const tiktok = api.Device.tiktok();
- check('TikTok 探测到已安装', tiktok.installed);
- eq('TikTok 版本号取自系统', tiktok.versionName, '39.5.4');
- eq('版本号格式化为 v 前缀', api.Device.formatVersion('39.5.4'), 'v39.5.4');
- eq('已是 v 前缀则不重复添加', api.Device.formatVersion('v39.5.4'), 'v39.5.4');
- eq('权限行共 3 条', api.Device.permissionRows().length, 3);
- /* 权限行的顺序与「可选」标记是本轮的重点:
- * 通知必须排最后且标可选,否则用户会以为它和悬浮窗一样是硬依赖。 */
- const permRows = api.Device.permissionRows();
- eq('权限顺序:无障碍 → 悬浮窗 → 通知', permRows.map((r) => r.name).join(','),
- '无障碍服务,悬浮窗权限,通知权限');
- check('通知权限被标记为可选', permRows[2].optional === true);
- check('无障碍与悬浮窗不是可选', !permRows[0].optional && !permRows[1].optional);
- check('无障碍未连接时上报 false(桩里 auto.service=null)', api.Device.a11yEnabled() === false);
- check('权限不齐时 allPermissionsGranted 为 false', api.Device.allPermissionsGranted() === false);
- check('阻断权限判定只看无障碍与悬浮窗', api.Device.blockingPermissionsGranted() === false);
- check('状态栏高度取到非零值(桩返回 66px)', api.Device.statusBarHeight() > 0);
- check('TikTok 包名列表覆盖主包', api.Device.TIKTOK_PACKAGES.indexOf('com.zhiliaoapp.musically') >= 0);
- /* ------------------------------------------------------- 7. 界面构建 */
- /* 底栏只剩三个 tab(规格书 §1)。这条断言守着整个改版的入口层级:
- * 一旦有人把四个任务页签加回底栏,它就会响。 */
- eq('底栏 tab 数量为 3', api.W.TABS.length, 3);
- eq('底栏 tab 顺序与设计稿一致', api.W.TABS.map((t) => t.label).join(','), '任务,记录,我的');
- /* 一级页(3 个 tab)+ 二级页(4 个任务详情)都要能独立构建成功——
- * 某个页面漏了依赖会在这里暴露。 */
- let pageErrors = [];
- Object.keys(api.App.TAB_PAGES).forEach((key) => {
- try {
- const view = api.App.TAB_PAGES[key]();
- if (!view) { pageErrors.push(key + ': 返回空'); }
- else if (view.getChildCount() === 0) { pageErrors.push(key + ': 内容为空'); }
- } catch (error) {
- pageErrors.push(key + ': ' + error.message);
- }
- });
- Object.keys(api.App.DETAIL_PAGES).forEach((key) => {
- try {
- const view = api.App.DETAIL_PAGES[key](() => { });
- if (!view) { pageErrors.push(key + ': 返回空'); }
- else if (view.getChildCount() === 0) { pageErrors.push(key + ': 内容为空'); }
- } catch (error) {
- pageErrors.push(key + ': ' + error.message);
- }
- });
- check('3 个一级页 + 4 个任务详情页全部可构建', pageErrors.length === 0, pageErrors.join(' | '));
- /* 四个任务与两张表(Pages.TASKS / Pages.SCRIPTS_OF)必须对得上:
- * 任务卡要显示「当前:xxx」,靠的就是这个对应关系。 */
- eq('任务卡数量为 4', api.Pages.TASKS.length, 4);
- eq('四个任务的 key 与旧配置路径一致',
- api.Pages.TASKS.map((t) => t.key).join(','), 'id,dm,nurture,video');
- check('每个任务都有脚本清单', api.Pages.TASKS.every((t) => (api.Pages.SCRIPTS_OF[t.key] || []).length > 0));
- /* 新做的四个弹窗都要能构建 —— 它们的排版比普通弹窗复杂得多 */
- const dialogCases = [
- ['权限弹窗', () => api.Dialog.showPermissions()],
- ['权限拦截弹窗', () => api.Dialog.permissionBlocked()],
- ['未选脚本拦截弹窗', () => api.Dialog.runBlocked('请先选择要运行的脚本')],
- ['解绑确认弹窗', () => api.Dialog.confirmUnbind(() => { })],
- ['卡密到期弹窗', () => api.Dialog.licenseExpired(() => { })]
- ];
- dialogCases.forEach(([name, build]) => {
- try {
- const dialog = build();
- check(name + '可构建', !!dialog && !!dialog.root && dialog.root.getChildCount() >= 2);
- } catch (error) {
- check(name + '可构建', false, error.message);
- }
- });
- /* 权限拦截弹窗的内容必须把失败原因说清楚 */
- const blocked = api.Dialog.permissionBlocked();
- const blockedTexts = textsOf(blocked.root);
- check('权限拦截弹窗标题为「无法开始运行」', blockedTexts.indexOf('无法开始运行') >= 0);
- check('权限拦截弹窗说明点名缺哪两个权限',
- blockedTexts.indexOf('无障碍与悬浮窗权限未开启,脚本无法执行') >= 0);
- check('权限拦截弹窗里通知仍标「未开启(可选)」',
- blockedTexts.indexOf('未开启(可选)') >= 0, blockedTexts.join(' | '));
- check('权限拦截弹窗有去授权与稍后再说',
- blockedTexts.indexOf('去授权') >= 0 && blockedTexts.indexOf('稍后再说') >= 0);
- blocked.close();
- const unbind = api.Dialog.confirmUnbind(() => { });
- const unbindTexts = textsOf(unbind.root);
- check('解绑弹窗说清后果(需重新输入卡密)',
- unbindTexts.indexOf('解绑后需重新输入卡密才能使用') >= 0);
- check('解绑弹窗说清连带影响(运行中会停)',
- unbindTexts.indexOf('正在运行的脚本将立即停止') >= 0);
- check('解绑弹窗有两个按钮', unbindTexts.indexOf('取消') >= 0 && unbindTexts.indexOf('确认解绑') >= 0);
- unbind.close();
- /* ------------------------------------------------------- 8. 登录流程 */
- /* 空卡密不提交,留在登录页 */
- api.App.submitLicense(' ');
- eq('空卡密不切换屏', api.App.screen, 'login');
- /* 合法卡密 → 主界面 */
- api.App.submitLicense('ABCD1234EFGH5678');
- eq('登录成功后切到主界面', api.App.screen, 'app');
- eq('登录状态已落盘', api.Store.get('license.status'), 'active');
- eq('登录页三态已复位', api.Pages.LICENSE_VIEW.state, 'idle');
- /* 我的页的卡密卡要按状态分叉:已登录给「解绑」,未登录给「去登录」。
- * 这两条断言抓的是「display 少返回 status」那类静默错配。 */
- api.App.switchTab('mine');
- const activeMine = textsOf(api.App.contentHost).join(' | ');
- check('已登录时我的页给「解绑」入口', activeMine.indexOf('解绑') >= 0, activeMine);
- check('已登录时我的页不出现「去登录」', activeMine.indexOf('去登录') < 0);
- api.Store.set('license.status', 'none');
- api.App.renderScreen();
- const anonMine = textsOf(api.App.contentHost).join(' | ');
- check('未登录时我的页给「去登录」', anonMine.indexOf('去登录') >= 0, anonMine);
- check('未登录时我的页不给「解绑」', anonMine.indexOf('解绑') < 0);
- api.Store.set('license.status', 'active');
- api.App.renderScreen();
- /* ------------------------------------------------------- 9. 主界面与交互 */
- /* 权限桩先挂上:本节测的是**导航与校验**,权限在 §2.5 里排在"已选脚本"之前,
- * 不先给齐权限的话,后面每条断言都会先撞上权限窗。
- * 权限缺失这一条单独在第 10 节测(那里会把它复位成缺失)。 */
- globalThis.auto.service = {};
- globalThis.floaty.checkPermission = () => true;
- /* 运行目标 = **当前打开的那个任务详情页**里选中的脚本(v6 改版后的规则)。
- * 一级页(任务列表)没有"当前任务"这回事:四张卡各有各的「运行」按钮。 */
- api.App.openDetail('id');
- check('ID提取页默认没有可运行目标(取材清单是空的)', api.App.currentTask() === null);
- eq('取材清单默认为空', api.Sources.count(), 0);
- api.App.openDetail('dm');
- eq('进 UID私信详情后运行目标是该页脚本一', api.App.currentTask().key, 's1');
- eq('运行目标带上了界面显示名', api.App.currentTask().name, '脚本一 · 已提 UID 私信');
- eq('运行目标带上了它属于哪个任务', api.App.currentTask().task, 'dm');
- /* —— v8:ID提取的"已配置" = 取材清单非空,不再有"单选一个脚本"这回事 ——
- * 而且运行目标的 key 固定是 "id":它不再用脚本 key 区分,
- * 因为清单里可以有任意多条取材,没有"当前是哪一条"这种概念。 */
- api.App.openDetail('id');
- api.Sources.add('anchor');
- check('加了一条取材后就有了运行目标', api.App.currentTask() !== null);
- eq('运行目标的 key 是 id(不再是 s1/s2)', api.App.currentTask().key, 'id');
- check('运行目标的显示名不再出现"脚本"字样',
- api.App.currentTask().name.indexOf('脚本') < 0, api.App.currentTask().name);
- /* 清单可以有多条,运行目标仍是同一个(摘要取第一条) */
- api.Sources.add('text');
- eq('两条取材后清单长度是 2', api.Sources.count(), 2);
- eq('运行目标不随条数变化', api.App.currentTask().key, 'id');
- check('任务卡的「当前:」显示取材摘要而不是"未选择脚本"',
- api.Pages.taskScriptLine('id').indexOf('未选择') < 0, api.Pages.taskScriptLine('id'));
- /* 空清单 → 点运行被拦(与 v7 的"未选脚本"同一关,但文案不同) */
- api.Sources.clear();
- check('清空取材后没有运行目标', api.App.currentTask() === null);
- api.App.openDetail('id');
- api.App.toggleRun();
- eq('空清单时不进入运行态', api.Session.running, false);
- check('空清单时弹出拦截弹窗', !!api.Dialog.active);
- check('拦截弹窗标题是「无法开始运行」',
- api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('无法开始运行') >= 0);
- check('拦截文案说的是「添加取材」,不是让人去找不存在的脚本',
- api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('请先添加一条取材') >= 0);
- api.Dialog.active && api.Dialog.active.close();
- /* 加回一条,供后面的用例使用 */
- api.Sources.add('anchor');
- /* tab 切换:切到「我的」时不应该有开始运行按钮 */
- api.App.switchTab('mine');
- eq('切到我的后 activeTab 正确', api.App.activeTab, 'mine');
- eq('我的页不渲染开始运行按钮', api.App.runHost.getChildCount(), 0);
- eq('tab 状态已落盘', api.Store.get('activeTab'), 'mine');
- /* 任务列表页也没有 CTA —— 四张任务卡上的「运行」按钮才是它的启停入口 */
- api.App.switchTab('tasks');
- eq('任务列表页不渲染开始运行按钮', api.App.runHost.getChildCount(), 0);
- check('任务列表显示四张任务卡的状态徽标',
- ['空闲', '未配置'].some((w) => textsOf(api.App.contentHost).indexOf(w) >= 0),
- textsOf(api.App.contentHost).join(' | '));
- check('任务列表显示「当前:xxx」一行',
- textsOf(api.App.contentHost).join(' | ').indexOf('当前:') >= 0);
- /* —— 任务卡的三个可点区域都要能进详情 ——
- *
- * ★ 这条守着一个真机踩过的坑(2026-10-13 用户反馈「点脚本进不去参数设置」):
- * 早先卡片里写过一句 `line.setOnClickListener(null)`,本意是"行内不挂监听",
- * 但 AutoX 的桥对 `null` 不保证是"清掉监听"—— 它可能装上一个吃掉点击、
- * 什么都不做的空监听。结果是「点运行按钮有反应、点卡片其它地方毫无反应」。
- * 现在卡片根 / 图标框 / 文本组各挂一次同一个回调,这条断言逐个点它们。 */
- let openedBy = '';
- const cardProbe = api.W.taskCard({
- name: 'X', iconChar: '▤', colorHex: '#2EA6FF', badge: '空闲', badgeTone: 'mute',
- scriptLine: '当前:X', lastLine: '还未运行过', running: false,
- onOpen: () => { openedBy = 'open'; },
- onRun: () => { openedBy = 'run'; }
- });
- const cardLine = cardProbe.root.getChildAt(0);
- cardProbe.root.performClick();
- check('点卡片根 → 进详情', openedBy === 'open', openedBy);
- openedBy = '';
- cardProbe.iconBox.performClick();
- check('点图标框 → 进详情', openedBy === 'open', openedBy);
- openedBy = '';
- cardProbe.textGroup.performClick();
- check('点文本组(脚本名那一块)→ 进详情', openedBy === 'open', openedBy);
- openedBy = '';
- cardProbe.button.performClick();
- check('点「运行」按钮 → 起跑(不是进详情)', openedBy === 'run', openedBy);
- check('任务卡里没有"空监听"占位(会吃掉点击)',
- cardLine.listeners.click === undefined || cardLine.listeners.click !== null,
- '中间的 row 上不该有 click 监听');
- /* 进二级页后 CTA 回来 */
- api.App.openDetail('id');
- eq('进任务详情后有开始运行按钮', api.App.runHost.getChildCount(), 1);
- check('二级页顶部是返回头(标题 + 返回箭头)',
- textsOf(api.App.contentHost).indexOf('ID提取') >= 0);
- /* 内容区应该有「返回头 + 间距 + 滚动页」 */
- check('内容区构建出返回头与页面', api.App.contentHost.getChildCount() >= 3,
- `实际 ${api.App.contentHost.getChildCount()}`);
- /* v8:三段的标题都要在 —— 这是"结构换了"最直接的可读证据。
- * (原来的「单选:同一时间仅可运行一个脚本」说明随单选一起消失了。) */
- const idPageTexts = textsOf(api.App.contentHost);
- check('ID提取页有「取材」「只要这样的账号」「输出」三段',
- idPageTexts.indexOf('取材') >= 0
- && idPageTexts.indexOf('只要这样的账号') >= 0
- && idPageTexts.indexOf('输出') >= 0,
- idPageTexts.slice(0, 20).join(' | '));
- check('不再有「单选:同一时间仅可运行一个脚本」这条约束',
- idPageTexts.indexOf('单选:同一时间仅可运行一个脚本') < 0);
- check('有取材时渲染出取材卡片与「添加一条」',
- idPageTexts.indexOf('第 1 条') >= 0 && idPageTexts.indexOf('+ 添加一条取材') >= 0,
- idPageTexts.slice(0, 20).join(' | '));
- /* 运行配置落盘 */
- const exported = api.App.exportRunConfig([{ tab: 'id', script: 's1' }]);
- check('运行配置可写出', exported === true);
- const runConfig = env.memoryFiles[api.App.RUN_CONFIG_PATH];
- check('运行配置内容含 tasks', typeof runConfig === 'string' && runConfig.indexOf('"tasks"') >= 0);
- check('运行配置是合法 JSON', (() => {
- try { JSON.parse(runConfig); return true; } catch (e) { return false; }
- })());
- /* ------------------------------------------------------- 10. 运行态闭环 */
- /* 运行态只在「已接入执行引擎」的脚本上才可能发生。
- * 视频功能是当前唯一接了的(其余任务点开始运行会被 Dialog.engineMissing 拦下,
- * 那也是下面单独测的一条)。 */
- api.Store.set('license.status', 'active');
- api.App.openDetail('video');
- /* 视频页的脚本卡默认不勾选(用户得自己确认要跑哪一个),这里先勾上 */
- api.Store.set('tabs.video.scripts.s1.checked', true);
- api.App.renderContent();
- check('视频功能页有勾选中的脚本', !!api.App.currentTask());
- /* ① 缺阻断权限 → 弹权限拦截窗,不进运行态。
- * 刻意把上面挂的桩**收回**:缺权限必须是真实可复现的状态,
- * 而不是"桩没配"这种偶然。 */
- globalThis.auto.service = null; /* 桩:无障碍未连接 */
- globalThis.floaty.checkPermission = () => false; /* 桩:悬浮窗未授权 */
- api.App.openDetail('video');
- api.App.toggleRun();
- eq('缺权限时不进入运行态', api.Session.running, false);
- check('缺权限时弹出的是权限拦截窗', !!api.Dialog.active);
- api.Dialog.active && api.Dialog.active.close();
- /* ② 补齐权限后开始运行 */
- globalThis.auto.service = {}; /* 桩:无障碍已连接 */
- globalThis.floaty.checkPermission = () => true; /* 桩:悬浮窗已授权 */
- check('补齐后阻断权限判定为真', api.Device.blockingPermissionsGranted() === true);
- /* —— 引擎产物定位:路径必须是从入口脚本同级推出来的那个 —— */
- const plan = api.Engine.prepare();
- check('能定位到引擎产物', plan.ok, plan.error || '');
- eq('定位到的是项目目录下的引擎产物', api.Engine.scriptPath, env.ENGINE_SCRIPT);
- api.App.toggleRun();
- eq('权限齐全后进入运行态', api.Session.running, true);
- check('运行时出现全局运行条', !!api.App.runBar);
- check('运行条先显示「启动中」——此时引擎一个字都还没回',
- textsOf(api.App.runBar.root).indexOf('视频功能 · 启动中') >= 0,
- textsOf(api.App.runBar.root).join(' | '));
- eq('启动中时 awaitingAck 为真', api.Session.awaitingAck, true);
- /* 引擎报到(第一次 run-state)= 启动确认。 */
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- v: 2, runId: api.Session.runId, status: 'starting', phase: '',
- done: 0, total: 0,
- engine: { task: 'video', capabilities: { heartbeat: true } }
- });
- env.flushIntervals();
- eq('收到本轮 run-state 后握手完成', api.Session.awaitingAck, false);
- check('运行条转回「运行中」',
- textsOf(api.App.runBar.root).indexOf('视频功能 · 运行中') >= 0,
- textsOf(api.App.runBar.root).join(' | '));
- check('运行按钮文案切为「停止运行」',
- textsOf(api.App.runHost).indexOf('停止运行') >= 0,
- textsOf(api.App.runHost).join(' | '));
- eq('运行计时器已启动', tickIntervals(), 1);
- check('初始时长为 00:00:00', textsOf(api.App.runBar.root).indexOf('00:00:00') >= 0);
- /* —— 引擎真的被唤起了,且参数齐全 —— */
- eq('唤起了 1 个引擎', env.engineState.spawned.length, 1);
- const spawn = env.engineState.spawned[0] || { config: {} };
- eq('唤起的是定位到的产物', spawn.path, env.ENGINE_SCRIPT);
- const argv = (spawn.config && spawn.config.arguments) || [];
- check('引擎参数里有 --config / --state / --stop-flag / --run-id',
- argv.indexOf('--config') >= 0 && argv.indexOf('--state') >= 0 &&
- argv.indexOf('--stop-flag') >= 0 && argv.indexOf('--run-id') >= 0,
- JSON.stringify(argv));
- check('传给引擎的 runId 与本次会话一致',
- argv[argv.indexOf('--run-id') + 1] === api.Session.runId,
- `${argv[argv.indexOf('--run-id') + 1]} vs ${api.Session.runId}`);
- /* —— 引擎配置:界面字段被正确映射 —— */
- const engineConfig = JSON.parse(env.memoryFiles[api.Engine.CONFIG_PATH]);
- eq('关键词映射到 target.keyword', engineConfig.target.keyword,
- api.Store.getText('tabs.video.keyword'));
- check('停止个数区间映射为 maxVideos(落在区间内)',
- engineConfig.target.maxVideos >= api.Store.getNumber('tabs.video.stopMin') &&
- engineConfig.target.maxVideos <= api.Store.getNumber('tabs.video.stopMax'),
- `实际 ${engineConfig.target.maxVideos}`);
- eq('间隔时间(秒)映射为观看时长(毫秒)',
- engineConfig.watch.minMs, api.Store.getNumber('tabs.video.intervalMin') * 1000);
- eq('没勾评论时引擎不启用评论动作', engineConfig.comments.enabled, false);
- /* 点赞 / 关注与评论同级的两个开关。这条断言防的是"界面勾了但引擎没收到"——
- * 那种情况下运行看起来正常,只是点赞一个都没发生。 */
- eq('点赞开关映射到引擎 likes.enabled',
- engineConfig.likes.enabled, api.Store.getBool('tabs.video.actions.like'));
- eq('关注开关映射到引擎 follows.enabled',
- engineConfig.follows.enabled, api.Store.getBool('tabs.video.actions.follow'));
- /* 反过来也验一次:把点赞钩上,引擎配置要跟着变 true */
- api.Store.set('tabs.video.actions.like', true);
- const likedOn = api.Engine.buildConfig({ task: 'video' });
- eq('勾上点赞后引擎收到 enabled=true', likedOn.config.likes.enabled, true);
- api.Store.set('tabs.video.actions.like', false);
- eq('引擎配置写清了 configPath', engineConfig.runtime.configPath, api.Engine.CONFIG_PATH);
- /* 运行配置里带引擎信息与"本次不会生效的项",事后只看这一个文件就能复盘 */
- const runConfigNow = JSON.parse(env.memoryFiles[api.App.RUN_CONFIG_PATH]);
- check('运行配置记录了引擎产物路径',
- runConfigNow.engine && runConfigNow.engine.scriptPath === env.ENGINE_SCRIPT);
- check('运行配置记录了本次 runId',
- runConfigNow.engine && runConfigNow.engine.runId === api.Session.runId);
- /* 运行中不允许切换脚本:验证组件在 disabled 下真的不回调 */
- let disabledFired = false;
- const disabledHead = api.W.scriptHeader({
- checked: false, expanded: false, name: 'X', disabled: true,
- onToggleCheck: () => { disabledFired = true; },
- onToggleExpand: () => { }
- });
- disabledHead.checkbox.root.performClick();
- check('禁用状态下点脚本头不触发勾选', disabledFired === false);
- let enabledNext = null;
- const enabledHead = api.W.scriptHeader({
- checked: false, expanded: false, name: 'X',
- onToggleCheck: (v) => { enabledNext = v; },
- onToggleExpand: () => { }
- });
- enabledHead.checkbox.root.performClick();
- eq('启用状态下点脚本头回传新状态', enabledNext, true);
- let radioNext = 'unchanged';
- const radioHead = api.W.scriptHeader({
- checked: true, expanded: false, name: 'X', mode: 'radio',
- onToggleCheck: (v) => { radioNext = v; },
- onToggleExpand: () => { }
- });
- radioHead.checkbox.root.performClick();
- eq('单选组里点已选中项可取消(否则「未选择脚本」那一屏永远回不去)', radioNext, false);
- /* —— 点运行条 = 跳回**正在运行的那个任务**的详情页(规格书 §4 第 7 条)——
- * 用户在「记录」tab 上看到运行条、点一下,最想问的是"谁在跑"。 */
- api.App.switchTab('records');
- check('切到记录 tab 后运行条仍然可见(它是全局组件)', !!api.App.runBar);
- api.App.runBar.root.performClick();
- eq('点运行条跳回运行中的任务详情页', api.App.detail, 'video');
- eq('点运行条同时切回任务 tab', api.App.activeTab, 'tasks');
- check('详情页标题是正在跑的那个任务',
- textsOf(api.App.contentHost).indexOf('视频功能') >= 0);
- api.App.backgroundWarned = false;
- env.emitter.emit('resume');
- eq('界面回到前台时提醒了一次', api.App.backgroundWarned, true);
- check('resume 事件确实被注册了', typeof env.uiEvents.resume === 'function');
- api.App.backgroundWarned = false; /* 复位,后续断言不受影响 */
- /* ③ 计时推进(与引擎进度互不干扰:计时归计时,进度归引擎上报) */
- api.Session.startedAt = Date.now() - 3725000; /* 人为推到 1 小时 2 分 5 秒前 */
- env.flushIntervals();
- check('计时按 01:02:05 刷新', textsOf(api.App.runBar.root).indexOf('01:02:05') >= 0,
- textsOf(api.App.runBar.root).join(' | '));
- eq('时长格式化补零', api.Session.formatElapsed(65000), '00:01:05');
- /* ④ 引擎上报进度 → 状态条跟着变(界面不重建,只改文字) */
- const beforeBarRoot = api.App.runBar.root;
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- v: 2, runId: api.Session.runId, status: 'running', phase: '已完成 2/5', done: 2, total: 5,
- engine: { task: 'video', capabilities: { heartbeat: true } }
- });
- env.flushIntervals();
- check('状态条显示引擎上报的阶段',
- textsOf(api.App.runBar.root).indexOf('视频功能 · 已完成 2/5') >= 0,
- textsOf(api.App.runBar.root).join(' | '));
- eq('进度刷新不重建状态条(只改文字)', api.App.runBar.root === beforeBarRoot, true);
- /* ④ 点停止是**两步**的:先发请求(「正在通知引擎」)、引擎承认收到(「正在收尾」),
- * 最后等它写终态才真正结束。三步分别对应三种真实处境:
- * ① 我点了但它还没听见 → 说"正在通知"是诚实的;
- * ② 它听见了、正在退出 → "正在收尾";
- * ③ 它写完了 → 收工。 */
- api.App.toggleRun();
- eq('发出停止请求后仍处于运行态', api.Session.running, true);
- eq('界面进入停止态', api.Session.stopping, true);
- check('还没收到引擎确认时说「正在通知引擎」',
- textsOf(api.App.runBar.root).indexOf('视频功能 · 正在通知引擎') >= 0,
- textsOf(api.App.runBar.root).join(' | '));
- eq('停止确认时间戳还没置', api.Session.stopAckedAt, 0);
- eq('停止标志写的内容是本次 runId',
- env.memoryFiles[api.Engine.STOP_FLAG_PATH], api.Session.runId);
- /* 引擎的心跳线程先看见停止请求(它是独立线程,不受主流程长等待影响) */
- env.memoryFiles[api.Engine.BEAT_PATH] = JSON.stringify({
- v: 2, runId: api.Session.runId, seq: 5, beatAt: Date.now(), stopSeenAt: Date.now()
- });
- env.flushIntervals();
- check('心跳承认收到停止后转「正在收尾」', api.Session.stopAckedAt > 0);
- check('运行条显示「正在收尾」',
- textsOf(api.App.runBar.root).indexOf('视频功能 · 正在收尾') >= 0,
- textsOf(api.App.runBar.root).join(' | '));
- /* 引擎确认停止 */
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- runId: api.Session.runId, status: 'stopped', phase: '', message: '已停止'
- });
- env.flushIntervals();
- eq('引擎确认后退出运行态', api.Session.running, false);
- eq('运行计时器已停止', tickIntervals(), 0);
- check('运行时状态条已移除', api.App.runBar === null);
- check('按钮回到「开始运行」',
- textsOf(api.App.runHost).indexOf('开始运行') >= 0,
- textsOf(api.App.runHost).join(' | '));
- check('留下了一行「上次运行」结论',
- textsOf(api.App.body).join(' | ').indexOf('上次运行:') >= 0);
- /* 跑完要把界面拉回前台:引擎在前台操作 TikTok,用户自己翻最近任务很别扭。
- * 必须是「当前这个脚本界面」的类名 + REORDER_TO_FRONT,不能用 launchPackage ——
- * 后者会打开 AutoX 主界面,用户看到的是一个不相干的页面。 */
- const frontIntent = globalThis.app.lastIntent;
- check('运行结束后把界面拉回前台', !!frontIntent &&
- frontIntent.className === 'org.autojs.autojs.ui.script.ScriptExecuteActivity',
- frontIntent ? frontIntent.className : 'null');
- eq('用 REORDER_TO_FRONT 而不是新建界面',
- frontIntent && frontIntent.flags, 0x20000000);
- /* ⑤ 引擎正常跑完 → 终态同样被接住 */
- api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
- eq('再次进入运行态', api.Session.running, true);
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- runId: api.Session.runId, status: 'finished', phase: '',
- message: '完成:处理 2 个视频 / 发出 1 条评论'
- });
- env.flushIntervals();
- eq('finished 终态结束运行', api.Session.running, false);
- check('结论文案来自引擎上报',
- api.App.lastRunResult && api.App.lastRunResult.message.indexOf('完成:处理 2 个视频') === 0,
- api.App.lastRunResult ? api.App.lastRunResult.message : 'null');
- /* ⑥ 引擎起了但一个字都不回 → 握手超时。
- * ★ 这和"引擎跑了以后被杀"是两件事:前者是压根没起来(要补写停止标志,
- * 防止迟到的引擎醒来后照着本轮配置自己跑),后者是跑着跑着没了。 */
- api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
- const stalledRunId = api.Session.runId;
- env.flushIntervals();
- eq('握手期仍在运行', api.Session.running, true);
- eq('握手期一直在等确认', api.Session.awaitingAck, true);
- check('握手期状态条说「启动中」',
- textsOf(api.App.runBar.root).indexOf('视频功能 · 启动中') >= 0,
- textsOf(api.App.runBar.root).join(' | '));
- /* 慢但没死:越过软阈值只改文案 */
- api.Session.ackStartedAt = Date.now() - (api.Engine.T.startAckSoftMs + 500);
- env.flushIntervals();
- eq('超过软阈值仍在等(不判死)', api.Session.running, true);
- eq('软阈值只改文案', api.Session.ackSlow, true);
- api.Session.ackStartedAt = Date.now() - (api.Engine.T.startAckMaxMs + 1000);
- env.flushIntervals();
- eq('越过硬阈值后结束运行', api.Session.running, false);
- check('结论是「启动超时」而不是「引擎已退出」',
- api.App.lastRunResult && api.App.lastRunResult.message.indexOf('启动超时') >= 0,
- api.App.lastRunResult ? api.App.lastRunResult.message : 'null');
- eq('握手超时后补写了停止标志(挡住迟到的引擎)',
- env.memoryFiles[api.Engine.STOP_FLAG_PATH], stalledRunId);
- /* ⑦ 握手成功之后引擎被系统杀掉(没写终态)→ 心跳过期判死 */
- api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- v: 2, runId: api.Session.runId, status: 'running', phase: '搜索 fashion', done: 0, total: 5,
- engine: { task: 'video', capabilities: { heartbeat: true } }
- });
- env.memoryFiles[api.Engine.BEAT_PATH] = JSON.stringify({
- v: 2, runId: api.Session.runId, seq: 3, beatAt: Date.now(), stopSeenAt: 0
- });
- env.flushIntervals();
- eq('收到 run-state 与心跳后握手完成', api.Session.awaitingAck, false);
- eq('兜底判定前仍在运行', api.Session.running, true);
- /* ★ S5:心跳新鲜时,`engines.all()` 说没有子引擎也**不能**判死。
- * 这正是要防的误判 —— 引擎在做 25 秒的 watch,列表里看不见它很正常。 */
- env.engineState.children = [];
- env.flushIntervals();
- eq('心跳新鲜时不因 engines.all() 说没有就判死', api.Session.running, true);
- /* 心跳停在同一时刻 → 过期 */
- env.memoryFiles[api.Engine.BEAT_PATH] = JSON.stringify({
- v: 2, runId: api.Session.runId, seq: 3, beatAt: Date.now() - (api.Engine.T.beatDeadMs + 5000),
- stopSeenAt: 0
- });
- env.flushIntervals();
- eq('心跳过期后结束运行', api.Session.running, false);
- check('兜底结论说明是引擎退出',
- api.App.lastRunResult && api.App.lastRunResult.message.indexOf('引擎已退出') >= 0,
- api.App.lastRunResult ? api.App.lastRunResult.message : 'null');
- env.engineState.children = [{ id: 'child-1' }];
- /* ⑧ 协议 v2 的边角:每一条都是"不报错、只是结论错"的路口 */
- const P = api.Protocol;
- /* S3 `failed` 快速通道:看到就收,不等任何超时 */
- api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- v: 2, runId: api.Session.runId, status: 'failed', message: '配置无效:关键词为空'
- });
- env.flushIntervals();
- eq('failed 立刻结束运行', api.Session.running, false);
- check('failed 的结论文案原样带回',
- api.App.lastRunResult && api.App.lastRunResult.message.indexOf('配置无效') >= 0,
- api.App.lastRunResult ? api.App.lastRunResult.message : 'null');
- /* S7' 上一轮的心跳不是本轮的证据:不降档,但也不能拿它判活 */
- api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- v: 2, runId: api.Session.runId, status: 'running', phase: '',
- engine: { task: 'video', capabilities: { heartbeat: true } }
- });
- env.memoryFiles[api.Engine.BEAT_PATH] = JSON.stringify({
- v: 2, runId: 'run-old-previous', seq: 9, beatAt: Date.now(), stopSeenAt: 0
- });
- env.flushIntervals();
- eq('上一轮的心跳不算本轮心跳', api.Session.beatAt, 0);
- eq('有 run-state 就算握手完成(心跳缺失不代表没起来)', api.Session.awaitingAck, false);
- eq('不因"还没有本轮心跳"立刻判死', api.Session.running, true);
- api.Session.ackedAt = Date.now() - (P.SPEC.thresholds.beatDeadMs + 1000);
- env.flushIntervals();
- eq('本轮始终没有心跳 → 到点才判死', api.Session.running, false);
- /* S8 未知状态 + 更高版本号:只读降级,绝不能据此结束运行 */
- api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- v: 99, runId: api.Session.runId, status: 'some-future-status', phase: ''
- });
- api.Session.ackedAt = Date.now();
- env.flushIntervals();
- eq('未知状态不结束运行', api.Session.running, true);
- eq('未知状态不当成终态', api.Engine.isTerminalStatus('some-future-status'), false);
- eq('未知状态也不冒充活跃态', api.Engine.isActiveStatus('some-future-status'), false);
- /* S9 半截 JSON:读成 null,且**任何状态都不改变**(尤其不得据此判死) */
- env.memoryFiles[api.Engine.STATE_PATH] = '{ "v": 2, "runId": "run-', /* 正好写到一半 */
- eq('半截 run-state 读成 null', api.Engine.readState(), null);
- env.memoryFiles[api.Engine.BEAT_PATH] = '{"v":2,"runId":';
- eq('半截心跳读成 null', api.Engine.readBeat(), null);
- env.flushIntervals();
- eq('半截状态不改变运行态', api.Session.running, true);
- /* S12 已确认收到停止,但一直不写终态 */
- api.App.stopRun();
- eq('停止请求已发出', api.Session.stopping, true);
- api.Session.stopAckedAt = Date.now() - (P.SPEC.thresholds.stopFinishMs + 1000);
- env.flushIntervals();
- eq('确认后迟迟不收尾也会被收口', api.Session.running, false);
- check('结论如实说明是"未确认收尾"',
- api.App.lastRunResult && api.App.lastRunResult.message.indexOf('未确认收尾') >= 0,
- api.App.lastRunResult ? api.App.lastRunResult.message : 'null');
- /* S13 v1 回退:老引擎(run-state 里没有 v)仍然要能跑完、能被判死、能停 */
- api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- runId: api.Session.runId, status: 'running', phase: '搜索 fashion', done: 1, total: 5
- });
- env.flushIntervals();
- eq('无 v 的 run-state 判为 v1', api.App.engineTier(api.Engine.readState(), null), 'v1');
- eq('v1 也能完成握手', api.Session.awaitingAck, false);
- env.engineState.children = [];
- api.Session.startedAt = Date.now() - (api.App.CHILD_GRACE_MS + 1000);
- env.flushIntervals();
- eq('v1 口径下子引擎消失就判死(沿用旧的 4 秒)', api.Session.running, false);
- env.engineState.children = [{ id: 'child-1' }];
- /* v1 的停止仍是单段 8 秒 */
- api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- runId: api.Session.runId, status: 'running', phase: ''
- });
- env.flushIntervals();
- api.App.stopRun();
- api.Session.stopRequestedAt = Date.now() - (api.App.STOP_TIMEOUT_MS + 1000);
- env.flushIntervals();
- eq('v1 停止超时后收口', api.Session.running, false);
- check('v1 的结论沿用旧文案',
- api.App.lastRunResult && api.App.lastRunResult.message.indexOf('未确认') >= 0,
- api.App.lastRunResult ? api.App.lastRunResult.message : 'null');
- /* ⑦ 运行前置校验顺序 —— **顺序本身就是规格**(§2.5):
- * 卡密 → 权限 → 已选脚本 → 引擎接入 → 是否已在跑
- * 所以这里逐条把"前一关"卡住,验证它确实**先**被拦下来。 */
- api.App.lastRunResult = null;
- api.App.openDetail('id');
- api.Sources.clear(); /* 先保证"取材清单为空" */
- const spawnsBefore = env.engineState.spawned.length;
- /* ③ 已选脚本?否 → F1 */
- api.App.toggleRun();
- eq('未选脚本时不进运行态', api.Session.running, false);
- check('拦下的是「请先添加一条取材」(ID提取没有脚本可选了)',
- api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('请先添加一条取材') >= 0);
- api.Dialog.active && api.Dialog.active.close();
- /* ④ ID提取**已接入**执行引擎 —— 加了取材就该真的起引擎。
- * 这一条是本轮(执行引擎开发)的落点:在此之前它一直被「引擎未接入」拦下。 */
- api.Sources.add('anchor');
- const spawnsBeforeId = env.engineState.spawned.length;
- api.App.toggleRun();
- eq('ID提取已接入引擎:点运行会真的起引擎', env.engineState.spawned.length, spawnsBeforeId + 1);
- eq('ID提取进入运行态', api.Session.running, true);
- check('没有弹任何拦截窗', api.Dialog.active === null);
- /* 起引擎时写下去的配置必须是 task=id,且**带上完整的取材清单** ——
- * 这里最容易出的问题是"取景清单只写了第一条"或"字段名对不上引擎契约",
- * 那种错在界面上完全看不出来,只有当引擎跑到一半才发现什么都没抓到。 */
- let idEngineConfig = null;
- try { idEngineConfig = JSON.parse(env.memoryFiles[api.Engine.CONFIG_PATH]); } catch (e) { idEngineConfig = null; }
- eq('引擎配置的 task 是 id', idEngineConfig && idEngineConfig.task, 'id');
- check('引擎配置带上了取材清单',
- idEngineConfig && idEngineConfig.id && idEngineConfig.id.sources.length === 1,
- idEngineConfig && idEngineConfig.id ? JSON.stringify(idEngineConfig.id.sources) : 'null');
- check('取材项带上了起点与深度(引擎靠这两个字段决定怎么走)',
- idEngineConfig && idEngineConfig.id.sources[0].kind === 'anchor' &&
- idEngineConfig.id.sources[0].expand === '',
- idEngineConfig ? JSON.stringify(idEngineConfig.id.sources[0]) : 'null');
- check('引擎配置里带上了过滤与输出两段',
- idEngineConfig && !!idEngineConfig.id.filter && !!idEngineConfig.id.output,
- idEngineConfig ? Object.keys(idEngineConfig.id).join(',') : 'null');
- /* 收尾这次运行,让后面的用例回到"未运行"状态 */
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- runId: api.Session.runId, status: 'stopped', phase: '', message: '已停止:本次已收 0 个 ID'
- });
- env.flushIntervals();
- eq('ID提取的终止态被接住', api.Session.running, false);
- /* ------------------------------------- 11.5 养号(配置映射 / 页面 / 不可用项) */
- console.log('[smoke] 养号 · 配置映射 · 评论不可用的如实告知');
- /* 界面只有 7 个字段,但两个区间 + 三个概率档位都要能正确落到引擎配置上。
- * 映射错一个,用户看到的是"填了 30%",引擎执行的是别的数 —— 而且不会报错。 */
- api.Store.set('tabs.nurture.actionMin', 5);
- api.Store.set('tabs.nurture.actionMax', 9);
- api.Store.set('tabs.nurture.browseMin', 4);
- api.Store.set('tabs.nurture.browseMax', 7);
- api.Store.set('tabs.nurture.likeRate', 'r50');
- api.Store.set('tabs.nurture.followRate', 'r15');
- const nur = api.Engine.buildConfig({ task: 'nurture' });
- const nurtureTask = nur.config.task;
- eq('养号映射:task = nurture', nurtureTask, 'nurture');
- eq('养号映射:轮数下限', nur.config.nurture.rounds.min, 5);
- eq('养号映射:轮数上限', nur.config.nurture.rounds.max, 9);
- eq('养号映射:浏览时长下限(4 分钟 → 毫秒)', nur.config.nurture.browse.minMs, 4 * 60 * 1000);
- eq('养号映射:浏览时长上限(7 分钟 → 毫秒)', nur.config.nurture.browse.maxMs, 7 * 60 * 1000);
- eq('养号映射:点赞概率 50% → 0.5', nur.config.nurture.likeRate, 0.5);
- eq('养号映射:关注概率 15% → 0.15', nur.config.nurture.followRate, 0.15);
- /* ★ 评论一律传 0,并且**必须在 notes 里说出来**。
- * 静默降级是这个项目最忌讳的事:用户看到"10%",跑完却发现一条评论都没发,
- * 而界面上没有任何解释。 */
- eq('养号映射:评论概率强制 0(当前版本发不出去)', nur.config.nurture.commentRate, 0);
- eq('养号映射:评论在引擎侧关闭', nur.config.comments.enabled, false);
- api.Store.set('tabs.nurture.commentRate', 'r10');
- const nurWithComment = api.Engine.buildConfig({ task: 'nurture' });
- check('养号:选了评论概率 → notes 里如实说明不支持',
- nurWithComment.notes.length > 0 && nurWithComment.notes[0].indexOf('评论') >= 0,
- JSON.stringify(nurWithComment.notes));
- eq('养号:说明之后评论仍然是 0', nurWithComment.config.nurture.commentRate, 0);
- /* 存了非法档位时:`Store.getEnum` 会把它兜回该枚举的默认项(likeRate 默认 r30),
- * 所以引擎拿到的是 0.3 而不是 0 —— 这是**有意的**:下拉永远显示一个合法档位,
- * 不会出现"框里是空的、引擎却没做任何动作"这种沉默的不一致。
- * (engine.js 里 rateOf 对非 rN 格式返回 0 是第二层兜底,正常路径不会走到。) */
- api.Store.set('tabs.nurture.likeRate', 'garbage');
- eq('养号映射:非法档位被兜回默认档位(r30 → 0.3)',
- api.Engine.buildConfig({ task: 'nurture' }).config.nurture.likeRate, 0.3);
- api.Store.set('tabs.nurture.likeRate', api.Store.defaultOf('tabs.nurture.likeRate'));
- api.Store.set('tabs.nurture.commentRate', api.Store.defaultOf('tabs.nurture.commentRate'));
- /* 脚本必须声明 engine,否则点运行会被 Dialog.engineMissing 拦下 ——
- * 这是"引擎已接入"的唯一标记,漏了它养号永远是灰的。 */
- const nurtureScripts = api.Pages.NURTURE_SCRIPTS;
- eq('养号脚本声明了引擎', nurtureScripts.length, 1);
- eq('养号脚本的 engine 标记', nurtureScripts[0].engine, 'tiktok-auto');
- /* ★ prepare 必须按任务分支放行。
- *
- * 养号的配置里**没有 `target`**(它没有搜索目标),而 `prepare` 末尾那句
- * 「关键词为空」的校验会去读 `config.target.keyword` —— 真机上直接
- * `TypeError: Cannot read property "keyword" from undefined`,而且**毫无提示**:
- * AutoX 里脚本直接退出、界面回桌面,只能靠 logcat 才看得到。
- *
- * 这条断言在 PC 上跑会得到同一个异常,所以是这类"新增任务漏改校验分派"的防火墙。 */
- let nurturePlan = null;
- try {
- nurturePlan = api.Engine.prepare({ task: 'nurture' });
- } catch (error) {
- nurturePlan = { ok: false, error: 'prepare 抛异常:' + error.message };
- }
- check('养号 prepare 不抛异常(配置里没有 target)',
- nurturePlan && nurturePlan.ok === true, JSON.stringify(nurturePlan && nurturePlan.error));
- /* 页面上必须显示"当前版本不支持",而不是给一个点不动的下拉 */
- api.App.openDetail('nurture');
- api.App.renderScreen();
- const nurTexts = textsOf(api.App.contentHost).join(' | ');
- check('养号页显示评论不可用', nurTexts.indexOf('当前版本不支持') >= 0, nurTexts);
- check('养号页说明了「单次操作」是轮数', nurTexts.indexOf('轮数') >= 0, nurTexts);
- check('养号页仍保留点赞与关注概率', nurTexts.indexOf('点赞概率') >= 0 && nurTexts.indexOf('关注概率') >= 0, nurTexts);
- api.Store.set('activeDetail', '');
- /* ------------------------------------- 11.6 UID私信(配置映射 / 两个脚本的区别) */
- console.log('[smoke] UID私信 · 配置映射 · 脚本二的拦截');
- /* 两个脚本都接入了引擎,逐个校验 —— 漏一个标记就会被 `Dialog.engineMissing`
- * 拦在运行前(那道闸是"绝不静默跑不相关的东西")。 */
- eq('私信脚本一有引擎标记', api.Pages.DM_SCRIPTS[0].engine, 'tiktok-auto');
- eq('私信脚本二有引擎标记(自己粉丝取材已实现)', api.Pages.DM_SCRIPTS[1].engine, 'tiktok-auto');
- /* ★ 单选互斥(这条是真机上发现的 bug):两个都勾着时 `buildDmConfig` 只能挑一个跑,
- * 用户以为跑「自己粉丝私信」、实际跑「已提 UID 私信」,界面上完全看不出异常。 */
- api.Pages.toggleDmScript('s1', true);
- eq('私信脚本单选:勾脚本一 → 脚本二被取消', api.Store.getBool('tabs.dm.scripts.s2.checked'), false);
- api.Pages.toggleDmScript('s2', true);
- eq('私信脚本单选:勾脚本二 → 脚本一被取消', api.Store.getBool('tabs.dm.scripts.s1.checked'), false);
- eq('私信脚本单选:脚本二现在是勾上的', api.Store.getBool('tabs.dm.scripts.s2.checked'), true);
- api.Pages.toggleDmScript('s2', false);
- eq('私信脚本单选:再点一次可取消(回到未选脚本)', api.Store.getBool('tabs.dm.scripts.s2.checked'), false);
- api.Pages.toggleDmScript('s1', true);
- api.Store.set('tabs.dm.scripts.s1.checked', true);
- api.Store.set('tabs.dm.scripts.s2.checked', false);
- api.Store.set('tabs.dm.doFollow', true);
- api.Store.set('tabs.dm.doDm', true);
- api.Store.set('tabs.dm.followMin', 7);
- api.Store.set('tabs.dm.followMax', 9);
- api.Store.set('tabs.dm.dmMin', 3);
- api.Store.set('tabs.dm.dmMax', 4);
- api.Store.set('tabs.dm.restMin', 12);
- api.Store.set('tabs.dm.restMax', 30);
- const dmCfg = api.Engine.buildConfig({ task: 'dm' });
- eq('私信映射:task = dm', dmCfg.config.task, 'dm');
- /* 两个数量是**区间随机取一个数**:固定不变会让每次运行发出去的量完全一样 */
- check('私信映射:关注数量落在区间内',
- dmCfg.config.dm.follow.max >= 7 && dmCfg.config.dm.follow.max <= 9,
- dmCfg.config.dm.follow.max);
- check('私信映射:私信数量落在区间内',
- dmCfg.config.dm.send.max >= 3 && dmCfg.config.dm.send.max <= 4,
- dmCfg.config.dm.send.max);
- eq('私信映射:休息下限(12 秒 → 毫秒)', dmCfg.config.dm.rest.minMs, 12 * 1000);
- eq('私信映射:休息上限(30 秒 → 毫秒)', dmCfg.config.dm.rest.maxMs, 30 * 1000);
- eq('私信映射:UID 文本路径',
- dmCfg.config.dm.uidPath, api.Store.getText('tabs.dm.scripts.s1.uidTextPath'));
- /* 关掉「进行关注」时不能还带着数量 —— 否则引擎会去关注一批不该关注的人 */
- api.Store.set('tabs.dm.doFollow', false);
- eq('私信映射:关掉关注后数量归零', api.Engine.buildConfig({ task: 'dm' }).config.dm.follow.max, 0);
- api.Store.set('tabs.dm.doFollow', true);
- /* 兜底:设备上可能还留着最初的旧默认 100~500 秒(迁移在某些历史版本上没生效)。
- * 那个值会让一轮跑 5~24 小时 —— 读取时必须改按 10~40 执行,并给出说明。 */
- api.Store.set('tabs.dm.restMin', 100);
- api.Store.set('tabs.dm.restMax', 500);
- const dmLegacyRest = api.Engine.buildConfig({ task: 'dm' });
- eq('私信:旧默认休息 100~500 → 按 10 秒起算',
- dmLegacyRest.config.dm.rest.minMs, 10 * 1000);
- eq('私信:旧默认休息 100~500 → 按 40 秒起算',
- dmLegacyRest.config.dm.rest.maxMs, 40 * 1000);
- check('私信:旧默认休息会给出说明',
- dmLegacyRest.notes.some((n) => String(n).indexOf('休息时间') >= 0),
- JSON.stringify(dmLegacyRest.notes));
- /* ★ 迁移补执行(v11)。这条守的是一个**真发生过**的坑:
- * 9 号迁移用 `restMin === 100` 判断,设备上存的可能是字符串 "100" →
- * 匹配不上 → 迁移"跑了但没改",版本却被抬到 10 → 从此永久卡在 100~500。
- * 症状是「界面写 100~500、引擎按 10~40 跑」两边不一致。 */
- const legacyV10 = JSON.parse(JSON.stringify(api.Store.current()));
- legacyV10.version = 10;
- legacyV10.tabs.dm.restMin = 100;
- legacyV10.tabs.dm.restMax = 500;
- eq('v10 存档的旧休息时间被补执行迁移(下限)',
- api.Store.validate(legacyV10).config.tabs.dm.restMin, 10);
- eq('v10 存档的旧休息时间被补执行迁移(上限)',
- api.Store.validate(legacyV10).config.tabs.dm.restMax, 40);
- const legacyStrRest = JSON.parse(JSON.stringify(api.Store.current()));
- legacyStrRest.version = 9;
- legacyStrRest.tabs.dm.restMin = '100';
- legacyStrRest.tabs.dm.restMax = '500';
- eq('字符串型旧休息时间也能迁移(严格相等曾静默漏掉)',
- api.Store.validate(legacyStrRest).config.tabs.dm.restMax, 40);
- /* 反过来:用户自己调过的值**绝不能**被迁移覆盖 */
- const customRest = JSON.parse(JSON.stringify(api.Store.current()));
- customRest.version = 10;
- customRest.tabs.dm.restMin = 60;
- customRest.tabs.dm.restMax = 90;
- eq('用户自定义的休息时间不被迁移覆盖(下限)',
- api.Store.validate(customRest).config.tabs.dm.restMin, 60);
- eq('用户自定义的休息时间不被迁移覆盖(上限)',
- api.Store.validate(customRest).config.tabs.dm.restMax, 90);
- /* prepare 也要按任务分支早返回:私信配置里同样没有 `target`,
- * 不早返回就会崩在 `built.config.target.keyword`(真机上是脚本退出、界面回桌面、零提示) */
- let dmPlan = null;
- try {
- dmPlan = api.Engine.prepare({ task: 'dm' });
- } catch (error) {
- dmPlan = { ok: false, error: 'prepare 抛异常:' + error.message };
- }
- check('私信 prepare 不抛异常(配置里没有 target)',
- dmPlan && dmPlan.ok === true, JSON.stringify(dmPlan && dmPlan.error));
- /* 勾了脚本二:取材改成"自己的粉丝列表",所以 uidPath 必须留空 ——
- * 留旧路径会让日志里出现一个"看起来在用的文件",排障时误导。 */
- api.Store.set('tabs.dm.scripts.s1.checked', false);
- api.Store.set('tabs.dm.scripts.s2.checked', true);
- const dmS2 = api.Engine.buildConfig({ task: 'dm' });
- eq('私信:勾了脚本二 → 配置里 script = s2', dmS2.config.dm.script, 's2');
- eq('私信:勾了脚本二 → 不带 UID 文本路径', dmS2.config.dm.uidPath, '');
- check('私信:勾了脚本二 → notes 说清从自己粉丝列表取材',
- dmS2.notes.some((n) => String(n).indexOf('粉丝列表') >= 0),
- JSON.stringify(dmS2.notes));
- api.Store.set('tabs.dm.scripts.s2.checked', false);
- api.Store.set('tabs.dm.scripts.s1.checked', true);
- /* 脚本二**没有自己的控件**(取材规则由共用参数决定),所以展开后那段说明是
- * 用户唯一能知道"人从哪儿来、要多少才够"的地方 —— 它必须真的渲染出来。 */
- api.App.openDetail('dm');
- api.Store.set('tabs.dm.scripts.s2.expanded', true);
- api.App.renderScreen();
- const s2ExpandTexts = textsOf(api.App.contentHost).join(' | ');
- check('脚本二展开后有取材说明(自己的粉丝列表)',
- s2ExpandTexts.indexOf('粉丝列表') >= 0, s2ExpandTexts);
- check('脚本二说明里点出「关注数量」「私信数量」两个额度',
- s2ExpandTexts.indexOf('关注数量') >= 0 && s2ExpandTexts.indexOf('私信数量') >= 0,
- s2ExpandTexts);
- api.Store.set('tabs.dm.scripts.s2.expanded', false);
- /* 还原默认值 */
- api.Store.set('tabs.dm.followMin', api.Store.defaultOf('tabs.dm.followMin'));
- api.Store.set('tabs.dm.followMax', api.Store.defaultOf('tabs.dm.followMax'));
- api.Store.set('tabs.dm.dmMin', api.Store.defaultOf('tabs.dm.dmMin'));
- api.Store.set('tabs.dm.dmMax', api.Store.defaultOf('tabs.dm.dmMax'));
- api.Store.set('tabs.dm.restMin', api.Store.defaultOf('tabs.dm.restMin'));
- api.Store.set('tabs.dm.restMax', api.Store.defaultOf('tabs.dm.restMax'));
- api.Store.set('activeDetail', '');
- /* ------------------------------------- 12. 断点续跑(提示条 / 记录页「继续」) */
- console.log('[smoke] 断点续跑 · 提示条 · 记录页「继续」');
- const ID_RUNFILE = api.Engine.ID_DIR + '/uid_20261028_2000.txt';
- /** 造一份断点。指纹必须与"此刻的配置"一致,否则会被判成"配置改过了" */
- function seedCheckpoint(patch) {
- const cp = Object.assign({
- v: 1, state: 'stopped', runId: 'run-break-1', beganAt: 1, updatedAt: 1,
- sig: api.Fingerprint.ofIdSection(api.Engine.buildIdConfig().config.id),
- runFile: ID_RUNFILE, mapFile: '', limitBase: 37,
- counts: { extracted: 37, skipped: 0, duplicates: 0, openedProfiles: 0 },
- sources: [
- { key: 'src-a', kind: 'anchor', label: '账号 A 的 fans',
- status: 'done', screens: 3, handled: 40, added: 37 },
- { key: 'src-b', kind: 'search', label: '用户「小猫」',
- status: 'pending', screens: 0, handled: 0, added: 0 }
- ],
- expand: { queue: [], done: [] }
- }, patch || {});
- env.memoryFiles[api.Engine.ID_PROGRESS_PATH] = JSON.stringify(cp);
- env.memoryFiles[ID_RUNFILE] = 'u1\nu2\n';
- return cp;
- }
- function endRun() {
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- runId: api.Session.runId, status: 'stopped', phase: '', message: '已停止'
- });
- env.flushIntervals();
- }
- api.App.openDetail('id');
- if (api.Sources.count() === 0) { api.Sources.add('anchor'); }
- const idSource = api.Sources.list()[0];
- /* ① 先验回归:**没有断点**时普通「开始运行」绝不带 --resume */
- delete env.memoryFiles[api.Engine.ID_PROGRESS_PATH];
- const spawn0 = env.engineState.spawned.length;
- api.App.requestRun('id');
- eq('普通运行唤起了引擎', env.engineState.spawned.length, spawn0 + 1);
- const argv0 = env.engineState.spawned[env.engineState.spawned.length - 1].config.arguments || [];
- check('普通「开始运行」不带 --resume', argv0.indexOf('--resume') < 0, JSON.stringify(argv0));
- eq('普通运行时配置里 resume.enabled = false',
- JSON.parse(env.memoryFiles[api.Engine.CONFIG_PATH]).id.resume.enabled, false);
- endRun();
- /* ② 有断点 → ID页顶部出现提示条 */
- seedCheckpoint();
- api.App.renderScreen();
- let cpTexts = textsOf(api.App.contentHost).join(' | ');
- check('有未跑完的断点 → ID页顶部出现提示条',
- cpTexts.indexOf('上次运行没跑完') >= 0, cpTexts);
- check('提示条写清已收个数', cpTexts.indexOf('已收 37 个') >= 0, cpTexts);
- check('提示条给出「继续」与「清空」两个动作',
- cpTexts.indexOf('继续') >= 0 && cpTexts.indexOf('清空') >= 0, cpTexts);
- /* ③ 点「继续」→ 起引擎、带 --resume、配置里 resume.enabled = true */
- const spawn1 = env.engineState.spawned.length;
- api.App.resumeRun();
- eq('「继续」唤起了引擎', env.engineState.spawned.length, spawn1 + 1);
- const argv1 = env.engineState.spawned[env.engineState.spawned.length - 1].config.arguments || [];
- check('续跑的引擎参数带 --resume', argv1.indexOf('--resume') >= 0, JSON.stringify(argv1));
- check('续跑指明了断点文件',
- argv1[argv1.indexOf('--checkpoint') + 1] === api.Engine.ID_PROGRESS_PATH,
- JSON.stringify(argv1));
- const resumedConfig = JSON.parse(env.memoryFiles[api.Engine.CONFIG_PATH]);
- eq('续跑时配置里 resume.enabled = true', resumedConfig.id.resume.enabled, true);
- eq('续跑时取材带上了界面的稳定 key', resumedConfig.id.sources[0].key, idSource.id);
- endRun();
- /* ④ 配置改过 → 提示条改口,且不再给「继续」 */
- seedCheckpoint({ sig: { v: 1, sources: 'x', filter: 'y', output: 'z', all: 'w' } });
- api.App.renderScreen();
- cpTexts = textsOf(api.App.contentHost).join(' | ');
- check('配置改过 → 提示条说「无法继续」', cpTexts.indexOf('上次运行无法继续') >= 0, cpTexts);
- check('配置改过 → 指出是哪一段改了', cpTexts.indexOf('取材') >= 0 || cpTexts.indexOf('改过') >= 0, cpTexts);
- /* 注意不能只查「继续」二字:失效态的标题本身就是「上次运行无法继续」,
- * 里面有「继续」但意思是"不能续"。判据要落在**可续态专属文案**是否消失。 */
- check('配置改过 → 不再出现可续文案',
- cpTexts.indexOf('上次运行没跑完') < 0 && cpTexts.indexOf('已收 37 个') < 0, cpTexts);
- check('断点被判定为失效', api.Resume.snapshot().valid === false);
- /* ⑤ 清空断点 → 覆盖成 state:'none'(不删文件) */
- api.App.clearCheckpoint();
- const cleared = JSON.parse(env.memoryFiles[api.Engine.ID_PROGRESS_PATH]);
- eq('清空断点后 state = none', cleared.state, 'none');
- check('清空断点是覆盖写而不是删文件',
- env.memoryFiles[api.Engine.ID_PROGRESS_PATH] !== undefined);
- /* ⑥ 已跑完的断点 → 不显示提示条 */
- seedCheckpoint({ state: 'done' });
- api.App.renderScreen();
- cpTexts = textsOf(api.App.contentHost).join(' | ');
- check('上次已跑完 → 不显示提示条', cpTexts.indexOf('上次运行没跑完') < 0, cpTexts);
- /* ⑦ 记录页:只有"就是那个断点"的记录才有「继续」 */
- seedCheckpoint();
- const recA = api.Records.add({
- taskKey: 'id', taskName: 'ID提取', scriptKey: 'id', scriptName: 'ID提取',
- result: 'stopped', startedAt: 1, endedAt: 2, resultText: '已停止:累计 37 个 ID',
- runId: 'run-break-1'
- });
- const recB = api.Records.add({
- taskKey: 'id', taskName: 'ID提取', scriptKey: 'id', scriptName: 'ID提取',
- result: 'stopped', startedAt: 1, endedAt: 2, resultText: '已停止:累计 5 个 ID',
- runId: 'run-other-9'
- });
- check('记录保留了 runId', recA.runId === 'run-break-1', recA.runId);
- check('runId 对得上的记录可续', api.Resume.isRecordResumable(recA) === true);
- check('runId 对不上的记录不可续', api.Resume.isRecordResumable(recB) === false);
- api.App.switchTab('records');
- const recTexts = textsOf(api.App.contentHost).join(' | ');
- check('记录列表出现「继续」', recTexts.indexOf('继续') >= 0, recTexts);
- /* ⑧ D3 详情页也给一个入口 */
- api.App.openRecord(recA.id);
- const d3Texts = textsOf(api.App.contentHost).join(' | ');
- check('记录详情页显示「没跑完」并给继续',
- d3Texts.indexOf('没跑完') >= 0 && d3Texts.indexOf('继续') >= 0, d3Texts);
- api.App.closeRecord();
- /* 还原现场:清掉断点与这两条假记录,别影响后面的用例 */
- api.App.clearCheckpoint();
- api.Records.save(api.Records.list().filter((r) => r.id !== recA.id && r.id !== recB.id));
- api.App.switchTab('tasks');
- /* ⑤ 「引擎未接入」那道闸必须继续有效。
- *
- * ★ 四个任务现在**全都接了引擎**,已经没有"天然缺引擎"的样本可用了。
- * 所以这条用例改成**临时摘掉**脚本二的 engine 标记来验闸门本身,验完立刻装回去 ——
- * 直接删掉这条就等于放弃守"绝不静默跑不相关的东西"(以后新增任务漏填 engine 时,
- * 会静默按别的链路的逻辑跑,症状是"配置对、结果完全不对")。 */
- api.App.openDetail('dm');
- const savedS2Engine = api.Pages.DM_SCRIPTS[1].engine;
- delete api.Pages.DM_SCRIPTS[1].engine; /* 注册表存的是同一个数组引用,改对象即可 */
- api.Store.set('tabs.dm.scripts.s1.checked', false);
- api.Store.set('tabs.dm.scripts.s2.checked', true);
- api.App.toggleRun();
- eq('未接入引擎的脚本不进运行态', api.Session.running, false);
- check('弹出的是「引擎未接入」拦截窗', !!api.Dialog.active);
- check('拦截窗说明了哪个脚本没接入',
- api.Dialog.active && textsOf(api.Dialog.active.root).join(' ').indexOf('执行引擎尚未接入') >= 0,
- api.Dialog.active ? textsOf(api.Dialog.active.root).join(' | ') : 'null');
- api.Dialog.active && api.Dialog.active.close();
- /* 装回引擎标记并还原勾选 —— 后面所有用例都依赖"脚本二有引擎" */
- api.Pages.DM_SCRIPTS[1].engine = savedS2Engine;
- api.Store.set('tabs.dm.scripts.s2.checked', false);
- api.Store.set('tabs.dm.scripts.s1.checked', true);
- /* 回到 ID提取 详情页,继续后面的校验顺序用例 */
- api.App.openDetail('id');
- api.Sources.clear();
- api.Sources.add('anchor');
- /* ① 卡密优先于其它一切:失效时**直接回登录页**,连弹窗都不弹 */
- api.Store.set('license.status', 'none');
- api.App.toggleRun();
- eq('卡密失效时优先回登录页(而不是弹脚本拦截)', api.App.screen, 'login');
- eq('回登录页时不弹任何弹窗', api.Dialog.active, null);
- api.Store.set('license.status', 'active');
- api.App.screen = 'app';
- /* ② 权限先于「有取材」:故意清空清单 + 缺权限,拦截的必须是权限窗 */
- api.Sources.clear();
- globalThis.floaty.checkPermission = () => false; /* 桩:悬浮窗未授权 */
- api.App.openDetail('id');
- api.App.toggleRun();
- check('缺权限时弹的是权限拦截窗(先于"未选脚本")',
- api.Dialog.active && textsOf(api.Dialog.active.root).join(' | ').indexOf('无障碍与悬浮窗权限未开启') >= 0,
- api.Dialog.active ? textsOf(api.Dialog.active.root).join(' | ') : 'null');
- api.Dialog.active && api.Dialog.active.close();
- globalThis.floaty.checkPermission = () => true; /* 恢复:后续用例需要权限齐全 */
- api.Sources.add('anchor');
- /* ⑧ 运行中卡密失效 → 停跑 + 弹到期窗 + 回登录页(设计稿 16:1360) */
- api.Store.set('license.status', 'active');
- api.App.openDetail('video');
- api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
- const lastRunId = api.Session.runId;
- check('本次运行有 runId', lastRunId !== '');
- eq('重新进入运行态', api.Session.running, true);
- eq('运行计时器再次启动', tickIntervals(), 1);
- api.Store.set('license.status', 'none');
- env.flushIntervals();
- eq('卡密失效时自动停止运行', api.Session.running, false);
- eq('卡密失效时运行计时器一并停止', tickIntervals(), 0);
- check('卡密失效时弹出到期弹窗', !!api.Dialog.active);
- check('到期弹窗说明「正在运行的脚本已停止」',
- api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('正在运行的脚本已停止') >= 0);
- /* 卡密失效时也要让引擎停手,否则它会继续操作 TikTok。
- * 注意这里比的是**本次的 runId**:Session 已被复位,从它身上读不到了。 */
- eq('卡密失效时向引擎发出了停止请求',
- env.memoryFiles[api.Engine.STOP_FLAG_PATH], lastRunId);
- /* 弹窗不自己切屏:要先让用户看清「脚本已停止」再点确认(设计稿 16:1490 的后续说明) */
- eq('弹窗未确认前仍停主界面', api.App.screen, 'app');
- clickByText(api.Dialog.active.root, '确认');
- eq('点确认后回到登录页', api.App.screen, 'login');
- eq('弹窗已关闭', api.Dialog.active, null);
- /* ------------------------------------- 11. 任务卡直跑 / F3 / 记录 */
- console.log('');
- console.log('[smoke] 任务卡 · 全局单跑 · 运行记录');
- /* —— 任务卡上的「运行」= 不必先切页签、不必先勾选(本次改版的核心诉求)—— */
- api.Store.set('license.status', 'active');
- api.App.screen = 'app';
- api.App.switchTab('tasks');
- api.Store.set('tabs.video.scripts.s1.checked', true);
- api.App.renderScreen();
- /* 任务卡的「运行」回调走的是 App.requestRun(taskKey):
- * 直接从列表起跑,验证它真的起来了(并且自动把运行条带到那个任务页)。 */
- const spawnsBeforeCard = env.engineState.spawned.length;
- api.App.requestRun('video');
- eq('从任务卡直接起跑会进入运行态', api.Session.running, true);
- eq('运行的任务是卡片对应的那个', api.Session.tab, 'video');
- eq('起跑时唤起了引擎', env.engineState.spawned.length, spawnsBeforeCard + 1);
- eq('起跑后自动停在运行中的任务详情页', api.App.detail, 'video');
- /* —— F3 全局单跑:运行中点**别的**任务的「运行」不禁用按钮,而是弹确认 ——
- *
- * 目前只有「视频功能」接了引擎,所以这里**临时**给 ID提取 挂一个引擎声明:
- * 验的是流程逻辑(弹确认 / 不误起引擎 / 停止并开始),
- * 而不是"现在到底哪个任务接了引擎"(那是数据,随时会变)。 */
- const idScripts = api.Pages.SCRIPTS_OF['id'];
- const originalIdEngine = idScripts[0].engine;
- idScripts[0].engine = 'tiktok-auto';
- /* v8:ID提取的"已配置"是取材清单非空,不再有脚本勾选 */
- api.Sources.add('anchor');
- const beforeSwitch = env.engineState.spawned.length;
- const runningRunId = api.Session.runId;
- api.App.requestRun('id');
- check('运行中点别的任务会弹 F3 确认', !!api.Dialog.active);
- check('F3 标题是「{任务名}正在运行」',
- api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('视频功能正在运行') >= 0,
- api.Dialog.active ? textsOf(api.Dialog.active.root).join(' | ') : 'null');
- check('F3 说明写明了「全局单跑」',
- api.Dialog.active && textsOf(api.Dialog.active.root).join(' | ').indexOf('全局单跑') >= 0);
- check('F3 的主按钮是「停止并开始」',
- api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('停止并开始') >= 0);
- eq('未确认前不会起新引擎', env.engineState.spawned.length, beforeSwitch);
- eq('未确认前旧任务仍在跑', api.Session.tab, 'video');
- /* 取消 → 什么都不发生(停止标志里还是上一轮的值,本轮 runId 没被写进去) */
- clickByText(api.Dialog.active.root, '取消');
- eq('点取消后仍在跑原来的任务', api.Session.tab, 'video');
- eq('点取消后没有起新引擎', env.engineState.spawned.length, beforeSwitch);
- check('点取消后没有向引擎写停止请求',
- env.memoryFiles[api.Engine.STOP_FLAG_PATH] !== runningRunId);
- /* 再点一次 → 「停止并开始」:先停旧的(写停止标志),再起新的 */
- api.App.requestRun('id');
- clickByText(api.Dialog.active.root, '停止并开始');
- eq('「停止并开始」向旧引擎写了停止请求', env.memoryFiles[api.Engine.STOP_FLAG_PATH], runningRunId);
- eq('「停止并开始」后跑的是新任务', api.Session.tab, 'id');
- eq('「停止并开始」确实起了新引擎', env.engineState.spawned.length, beforeSwitch + 1);
- check('新一轮的 runId 与上一轮不同', api.Session.runId !== runningRunId);
- /* 收尾:停掉这次运行并把临时挂的引擎声明恢复(否则后面的用例会以为 ID提取 能跑) */
- api.App.stopRun();
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- runId: api.Session.runId, status: 'stopped', phase: '', message: '已停止'
- });
- env.flushIntervals();
- eq('收尾后回到未运行', api.Session.running, false);
- idScripts[0].engine = originalIdEngine;
- /* —— 运行记录:每次运行结束都要落一条(规格书 §4 第 15 条)—— */
- api.Records.clear();
- eq('初始时没有记录', api.Records.list().length, 0);
- api.App.switchTab('records');
- check('没有记录时走空态那一屏',
- textsOf(api.App.contentHost).indexOf('还没有运行记录') >= 0,
- textsOf(api.App.contentHost).join(' | '));
- check('空态给了去哪能产生记录的指引',
- textsOf(api.App.contentHost).indexOf('去「任务」标签选一个脚本跑起来') >= 0);
- /* 跑完一次(引擎上报 finished)→ 必须写一条记录 */
- api.App.openDetail('video');
- api.Store.set('tabs.video.scripts.s1.checked', true);
- api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- runId: api.Session.runId, status: 'finished', phase: '',
- message: '完成:处理 2 个视频 / 发出 1 条评论'
- });
- env.flushIntervals();
- eq('运行正常结束后退出运行态', api.Session.running, false);
- const afterFinish = api.Records.list();
- eq('结束后写下了 1 条运行记录', afterFinish.length, 1);
- eq('记录挂在跑的那个任务下', afterFinish[0].taskKey, 'video');
- eq('记录的结果分类是「已完成」', afterFinish[0].result, 'success');
- eq('记录的结果文案来自引擎上报', afterFinish[0].resultText,
- '完成:处理 2 个视频 / 发出 1 条评论');
- check('记录带了耗时', typeof afterFinish[0].durationMs === 'number' && afterFinish[0].durationMs >= 0);
- api.App.switchTab('records');
- check('记录页显示条数与指引',
- textsOf(api.App.contentHost).indexOf('共 1 条 · 点任一条查看执行日志') >= 0,
- textsOf(api.App.contentHost).join(' | '));
- check('记录条目显示「任务 · 脚本」',
- textsOf(api.App.contentHost).indexOf('视频功能 · 关键词视频功能') >= 0,
- textsOf(api.App.contentHost).join(' | '));
- check('记录条目显示结果标签', textsOf(api.App.contentHost).indexOf('已完成') >= 0);
- /* —— uid 映射文件:引擎写了,界面就必须拿得到(2026-10-28 修)——
- * 引擎把 mapFile 放在 summary 里(idflow.js),而界面组装记录时曾经只存 resultFile,
- * 于是记录页只有一句「(已生成 uid 映射)」—— 路径、打开、分享全都没有,
- * 用户只能自己去 /sdcard 里翻。这条断言盯的就是"引擎给了、界面丢了"。 */
- api.App.openDetail('id');
- if (api.Sources.count() === 0) { api.Sources.add('anchor'); }
- /* 先拍一张记录快照:这条场景只是为了验"映射文件不丢",验完必须还原,
- * 否则后面「共 1 条」「返回后回到记录列表」这些断言全会被多出来的记录带偏。 */
- const recordsBeforeMap = api.Records.list();
- api.App.startRun(api.App.currentTask());
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- runId: api.Session.runId, status: 'finished', phase: '',
- summary: {
- ok: true,
- resultFile: '/sdcard/Download/tiktok_app/uid_20261028_1200.txt',
- mapFile: '/sdcard/Download/tiktok_app/uid_map_20261028_1200.txt'
- }
- });
- env.flushIntervals();
- const idRecord = api.Records.list()[0];
- eq('ID 记录带着 uid 映射文件路径', idRecord.mapFile,
- '/sdcard/Download/tiktok_app/uid_map_20261028_1200.txt');
- api.App.openRecord(idRecord.id);
- const idTexts = textsOf(api.App.contentHost).join(' | ');
- check('记录详情能显示映射文件路径', idTexts.indexOf('uid_map_20261028_1200') >= 0, idTexts);
- check('结果文件与映射文件各自占一行',
- idTexts.indexOf('uid_20261028_1200.txt') >= 0 && idTexts.indexOf('uid 映射') >= 0, idTexts);
- /* 还原现场:关掉详情、把记录列表恢复成这条场景之前的模样。
- * 不还原的话,后面「共 1 条」「返回后回到记录列表」都会被这条假记录带偏。 */
- api.App.closeRecord();
- api.Records.save(recordsBeforeMap);
- /* —— D3 运行详情:点条目进去 —— */
- api.App.openRecord(afterFinish[0].id);
- check('运行详情顶部是返回头', textsOf(api.App.contentHost).indexOf('运行详情') >= 0);
- check('运行详情有日志卡', textsOf(api.App.contentHost).indexOf('执行日志') >= 0);
- check('没有日志时如实说明(不画一张空卡片)',
- textsOf(api.App.contentHost).indexOf('本次运行没有回传日志') >= 0,
- textsOf(api.App.contentHost).join(' | '));
- eq('运行详情没有开始运行按钮(它只是看,不驱动)', api.App.runHost.getChildCount(), 0);
- /* 返回:回到记录列表 */
- api.App.closeRecord();
- check('返回后回到记录列表',
- textsOf(api.App.contentHost).join(' | ').indexOf('共 1 条') >= 0,
- textsOf(api.App.contentHost).join(' | '));
- /* —— 引擎真的写了日志时:记录要带上真实日志行 —— */
- env.memoryFiles[api.Engine.LOG_PATH] = [
- JSON.stringify({ t: Date.now(), level: 'INFO', step: '启动脚本', text: '[启动脚本] 启动 · 粉丝页提 ID' }),
- JSON.stringify({ t: Date.now(), level: 'WARN', step: '跳过', text: '[跳过] 第 7 个账号为私密账户' }),
- '{"t":1,"level":"INFO","text":"半截行' /* 故意坏一行:只能丢这一行 */
- ].join('\n');
- const readBack = api.Engine.readLog();
- eq('日志逐行解析,坏行只丢自己', readBack.length, 2);
- eq('日志带上了级别', readBack[0].level, 'INFO');
- api.Session.start('video', 's1', '关键词视频功能', 'run-logtest');
- const loggedRecord = api.App.writeRecord({ status: 'failed', message: '无障碍服务被关闭' });
- api.Session.stop();
- eq('新记录带上了日志行', (loggedRecord.logs || []).length, 2);
- eq('失败终态被归为「报错中断」', loggedRecord.result, 'failed');
- /* —— F5 脚本报错:失败终态要弹窗 + 运行条变红 —— */
- api.App.failBar = null;
- api.Store.set('tabs.video.scripts.s1.checked', true);
- api.App.startRun({ task: 'video', key: 's1', name: '关键词视频功能', engine: 'tiktok-auto' });
- eq('再次进入运行态(F5 用例)', api.Session.running, true);
- env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
- runId: api.Session.runId, status: 'failed', phase: '',
- message: '无障碍服务被关闭,脚本已停止运行'
- });
- env.flushIntervals();
- eq('失败终态结束运行', api.Session.running, false);
- check('失败后弹出 F5 报错窗', !!api.Dialog.active);
- check('F5 标题是「脚本执行出错」',
- api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('脚本执行出错') >= 0,
- api.Dialog.active ? textsOf(api.Dialog.active.root).join(' | ') : 'null');
- check('F5 有两个出口:查看日志 / 知道了',
- api.Dialog.active && textsOf(api.Dialog.active.root).indexOf('查看日志') >= 0 &&
- textsOf(api.Dialog.active.root).indexOf('知道了') >= 0);
- check('失败时运行条变红并写明「执行出错已停止」', !!api.App.failBar &&
- api.App.failBar.text.indexOf('执行出错已停止') >= 0,
- api.App.failBar ? api.App.failBar.text : 'null');
- check('报错条右侧写「已终止」', api.App.failBar && api.App.failBar.time === '已终止');
- check('报错条挂在刚写下的那条记录上',
- api.App.failBar && !!api.Records.find(api.App.failBar.recordId));
- /* 「查看日志」直接进 D3,并且报错条随之消失 */
- clickByText(api.Dialog.active.root, '查看日志');
- check('点「查看日志」进了运行详情', api.App.recordId !== '');
- check('进详情后报错条已清掉', api.App.failBar === null);
- check('详情页显示的是失败那条记录',
- textsOf(api.App.contentHost).indexOf('报错中断') >= 0,
- textsOf(api.App.contentHost).join(' | '));
- api.App.closeRecord();
- /* 「知道了」也能收掉报错条;点运行条同样能收掉 */
- api.App.failBar = { text: 'X · 执行出错已停止', time: '已终止', recordId: '' };
- api.App.renderScreen();
- check('报错态会渲染出红色运行条', !!api.App.runBar);
- api.App.runBar.root.performClick();
- check('点报错条后它消失', api.App.failBar === null);
- /* —— Records 的纯函数(文案规则,最容易写错的一层)—— */
- eq('时长 < 1 分钟说秒', api.Records.formatDuration(45000), '45 秒');
- eq('整分钟说「N 分钟」', api.Records.formatDuration(720000), '12 分钟');
- eq('带零头说「N 分 M 秒」', api.Records.formatDuration(750000), '12 分 30 秒');
- eq('超过 1 小时说「H 小时 M 分」', api.Records.formatDuration(3900000), '1 小时 5 分');
- const now = new Date(2026, 9, 13, 20, 0, 0).getTime();
- eq('今天的时间戳显示「今天 HH:MM」',
- api.Records.formatDay(new Date(2026, 9, 13, 10, 24).getTime(), now), '今天 10:24');
- eq('昨天的时间戳显示「昨天 HH:MM」',
- api.Records.formatDay(new Date(2026, 9, 12, 9, 58).getTime(), now), '昨天 09:58');
- eq('更早的时间戳显示「MM-DD HH:MM」',
- api.Records.formatDay(new Date(2026, 9, 5, 9, 58).getTime(), now), '10-05 09:58');
- /* —— 任务卡读取真实记录:「上次…」不再是假数据(规格书 §5)—— */
- api.Records.clear();
- eq('没有记录时任务卡说「还未运行过」', api.Records.lastRunLine('video'), '还未运行过');
- api.Records.add({ taskKey: 'video', taskName: '视频功能', scriptName: '关键词视频功能',
- result: 'success', startedAt: Date.now(), endedAt: Date.now(),
- durationMs: 1000, resultText: '处理 2 个视频' });
- check('有记录后任务卡显示「上次 …」',
- api.Records.lastRunLine('video').indexOf('上次 ') === 0,
- api.Records.lastRunLine('video'));
- check('任务卡的「上次」带上了结果',
- api.Records.lastRunLine('video').indexOf('处理 2 个视频') >= 0);
- /* 记录上限:超出必须从尾部丢弃,否则 storages 会被越写越大 */
- api.Records.clear();
- for (let i = 0; i < api.Records.MAX + 5; i++) {
- api.Records.add({ taskKey: 'video', taskName: '视频功能', result: 'success',
- startedAt: Date.now(), endedAt: Date.now(), durationMs: 0 });
- }
- eq('记录最多保留 MAX 条', api.Records.list().length, api.Records.MAX);
- api.Records.clear();
- /* ------------------------------------- 12. 输入框:直接可改,不点开弹窗 */
- console.log('');
- console.log('[smoke] 直接输入');
- /* 上一段把卡密打成失效并退回了登录页,这里先恢复成「已激活 + 主界面」 */
- api.Store.set('license.status', 'active');
- api.Store.set('license.expireAt', '2027-12-31');
- api.App.screen = 'app';
- api.App.openDetail('video');
- const kwHandle = api.W.pendingFields['tabs.video.keyword'];
- check('渲染页面时把本页输入框登记为「待提交」', !!kwHandle);
- if (kwHandle) {
- kwHandle.root.performClick();
- eq('点输入框不弹出任何弹窗', api.Dialog.active, null);
- /* 在栏位里清空重填(不是直接 Store.set —— 那改的是数据、不是栏位) */
- kwHandle.root.eraseAll();
- kwHandle.root.type('summer');
- eq('打字过程中就已写入配置', api.Store.getText('tabs.video.keyword'), 'summer');
- }
- /* 切页签会先提交本页输入框、再清空登记表 */
- api.App.switchTab('mine');
- check('切页签后不再留着上一页的输入框登记',
- !api.W.pendingFields['tabs.video.keyword']);
- eq('切页签后配置仍是用户输入的值', api.Store.getText('tabs.video.keyword'), 'summer');
- /* activeTab 的候选值必须包含每一个页签 —— 曾经少了 "mine",
- * 结果是「切到我的页后重启会落回 ID提取」。写入端的归一化会把这种漏配直接打回来。 */
- api.Store.set('activeTab', 'mine');
- eq('activeTab 认「我的」这个页签', api.Store.get('activeTab'), 'mine');
- api.Store.set('activeTab', 'video');
- /* ========================================================= 13. 悬浮球 */
- console.log('');
- console.log('[smoke] 悬浮球(规格书 §10)');
- /* 桩里的 density(android-stub 的 getDisplayMetrics 给的就是 2.75) */
- const FB_DENSITY = 2.75;
- /* 这一段存在的理由:悬浮球是**唯一"界面完全正确也可能点了没反应"**的模块 ——
- * 它的动作回调挂在窗口的视图上,而 UI 回调里抛错在 AutoX 上不报错、不弹窗、
- * 也不结束脚本。真机漏过的那次 bug 就是这样:扇形第 1 个按钮(运行/停止)的回调
- * 是个空函数,其余三个按钮正常。所以这里把它逐条钉住,别再靠肉眼守。
- *
- * 2026-10-15 起它是**两个 overlay 窗口**(球一个、扇形一个,几何恒定)。
- * 为什么不能是一个窗口:改窗口尺寸/位置由系统立刻生效、视图重排要等下一帧,
- * 中间那几帧合成器会把旧画面摆到新窗口位置 —— 用户看到的就是"点击后先跳一下"。
- * 下面的断言里,**"展开/收起不改任何窗口几何"** 那几条就是这件事的回归防线。 */
- api.Store.set('license.status', 'active');
- api.Store.set('license.expireAt', '2027-12-31');
- api.Store.set('ball.hidden', false);
- api.App.screen = 'app';
- api.FB.destroy();
- /* —— 显示前置:卡密有效 + 悬浮窗权限 + 用户没主动隐藏(三者缺一不建球) —— */
- check('三项前置满足时可以显示球', api.FB.canShow() === true);
- api.Store.set('ball.hidden', true);
- check('用户隐藏后不建球', api.FB.canShow() === false);
- api.Store.set('ball.hidden', false);
- api.Store.set('license.status', 'expired');
- check('卡密失效后不建球', api.FB.canShow() === false);
- api.Store.set('license.status', 'active');
- globalThis.floaty.checkPermission = () => false;
- check('没有悬浮窗权限时不建球', api.FB.canShow() === false);
- globalThis.floaty.checkPermission = () => true;
- /* —— 建球:**两个**窗口 —— */
- api.FB.ensure();
- const fbWins = env.wmWindows();
- eq('建出两个窗口(球 + 扇形)', fbWins.length, 2);
- const fbBallLp = api.FB.ballLp;
- const fbFanLp = api.FB.fanLp;
- check('球窗口的 LayoutParams 已记下', !!fbBallLp);
- check('扇形窗口的 LayoutParams 已记下', !!fbFanLp);
- const NOT_FOCUSABLE = 0x8, NOT_TOUCHABLE = 0x10;
- /* 尺寸按 §10.1:球窗口 = 热区 64 + 两侧内边距 12×2;扇形窗口 = fanSizeDp() */
- eq('球窗口边长 = (64 热区 + 2×12 内边距) × density',
- fbBallLp.width, Math.round((64 + 12 * 2) * FB_DENSITY));
- eq('扇形窗口宽 = (12+64+96+24+4+96+12) × density',
- fbFanLp.width, Math.round((12 + 64 + 96 + 24 + 4 + 96 + 12) * FB_DENSITY));
- eq('扇形窗口高 = 2×(96+24+12) × density',
- fbFanLp.height, Math.round(2 * (96 + 24 + 12) * FB_DENSITY));
- check('两个窗口都不抢输入焦点(§10.9④)',
- (fbBallLp.flags & NOT_FOCUSABLE) !== 0 && (fbFanLp.flags & NOT_FOCUSABLE) !== 0);
- check('球窗口永远可触摸', (fbBallLp.flags & NOT_TOUCHABLE) === 0);
- check('折叠时扇形窗口不可触摸(否则整块透明区成死区)',
- (fbFanLp.flags & NOT_TOUCHABLE) !== 0);
- check('两窗口左边缘对齐(x 相同)', fbBallLp.x === fbFanLp.x);
- eq('扇形窗口比球窗口高 88dp(球心两态的根内偏移差)',
- fbBallLp.y - fbFanLp.y, Math.round(88 * FB_DENSITY));
- /* 球窗口里就三件套;扇形窗口里是 4 按钮 + 4 标签 */
- const fbChildCount = api.FB.root.getChildCount();
- eq('球窗口里是「环 + 球 + 热区」', fbChildCount, 3);
- const fbFanViews = api.FB.views.fan.slice();
- eq('扇形里 4 个按钮 + 4 个标签', fbFanViews.length, 4);
- const fbFanChildCount = api.FB.fanRoot.getChildCount();
- eq('扇形窗口里 8 个子视图', fbFanChildCount, 8);
- const fbBallBefore = api.FB.views.ball;
- const fbBrandBefore = api.FB.brandBitmap;
- check('品牌位图已缓存(不每次解码)', !!fbBrandBefore);
- eq('折叠态下扇形按钮是 GONE',
- fbFanViews[0].btn.visibility, globalThis.android.view.View.GONE);
- /* 球位要如实发布给引擎:引擎的坐标手势点到球位就点到球上(实测踩过) */
- const fbRect = JSON.parse(env.memoryFiles['/sdcard/tiktok_app/ball-rect.json'] || '{}');
- check('球位已写盘给引擎避让', fbRect.hidden !== true && fbRect.w > 0);
- eq('折叠态只发布球窗口(扇形那时不可触摸,引擎不必绕)', fbRect.w, fbBallLp.width);
- eq('折叠态的避让矩形就是球窗口的矩形', fbRect.h, fbBallLp.height);
- api.FB.ensure();
- eq('ensure 幂等,不会建出第三个窗口', env.wmWindows().length, 2);
- check('运行前悬浮球不呼吸(空闲态没有外环)', api.FB.breathing() === false);
- /* ★★ 核心:展开 / 收起**不改任何窗口几何** —— 这是"不再跳动"的全部依据。
- * 只要两个窗口的 x/y/w/h 在切换前后完全一致,合成器就不可能把旧画面摆到新位置。 */
- const geoBefore = [fbBallLp.x, fbBallLp.y, fbBallLp.width, fbBallLp.height,
- fbFanLp.x, fbFanLp.y, fbFanLp.width, fbFanLp.height].join(',');
- check('初始是折叠态', api.FB.open === false);
- api.FB.expand();
- check('展开后 open=true', api.FB.open === true);
- eq('展开后扇形按钮是 VISIBLE',
- fbFanViews[0].btn.visibility, globalThis.android.view.View.VISIBLE);
- eq('展开后标签是 VISIBLE',
- fbFanViews[0].pill.visibility, globalThis.android.view.View.VISIBLE);
- check('展开后扇形窗口变成可触摸', (fbFanLp.flags & NOT_TOUCHABLE) === 0);
- eq('展开没有增删窗口', env.wmWindows().length, 2);
- eq('★ 展开没有改变任何窗口几何(x/y/w/h 全等)',
- [fbBallLp.x, fbBallLp.y, fbBallLp.width, fbBallLp.height,
- fbFanLp.x, fbFanLp.y, fbFanLp.width, fbFanLp.height].join(','), geoBefore);
- check('展开复用同一批视图,不是重建', api.FB.views.ball === fbBallBefore);
- eq('展开没有增删球窗口的子视图', api.FB.root.getChildCount(), fbChildCount);
- eq('展开没有增删扇形窗口的子视图', api.FB.fanRoot.getChildCount(), fbFanChildCount);
- check('展开没有重新解码品牌位图', api.FB.brandBitmap === fbBrandBefore);
- eq('展开后第 1 个按钮仍是同一个视图(就地更新)', api.FB.firstBtn.view, fbFanViews[0].btn);
- const fbRectOpen = JSON.parse(env.memoryFiles['/sdcard/tiktok_app/ball-rect.json'] || '{}');
- check('展开态发布的避让矩形是两窗口的并集(比球窗口宽)',
- fbRectOpen.w > fbBallLp.width, 'w=' + fbRectOpen.w);
- api.FB.collapse();
- check('收起后 open=false', api.FB.open === false);
- eq('收起后按钮回到 GONE',
- fbFanViews[0].btn.visibility, globalThis.android.view.View.GONE);
- check('收起后扇形窗口又不可触摸', (fbFanLp.flags & NOT_TOUCHABLE) !== 0);
- eq('收起也没增删窗口', env.wmWindows().length, 2);
- eq('★ 收起同样没有改变任何窗口几何',
- [fbBallLp.x, fbBallLp.y, fbBallLp.width, fbBallLp.height,
- fbFanLp.x, fbFanLp.y, fbFanLp.width, fbFanLp.height].join(','), geoBefore);
- check('收起后仍是同一批视图', api.FB.views.ball === fbBallBefore);
- /* —— 手势:单击展开 / 拖动吸附 / CANCEL 不算点击 —— */
- const fbEv = env.MotionEvent;
- function fbTap(x, y) {
- api.FB.views.hot.dispatchTouch(env.touchEvent(fbEv.ACTION_DOWN, x, y));
- api.FB.views.hot.dispatchTouch(env.touchEvent(fbEv.ACTION_UP, x, y));
- }
- function fbTouchX() { return api.FB.anchorX + Math.round(48 * FB_DENSITY / 2); }
- fbTap(fbTouchX(), api.FB.anchorCy);
- check('单击球 → 展开扇形', api.FB.open === true);
- eq('单击展开同样不改窗口几何',
- [fbBallLp.x, fbBallLp.y, fbBallLp.width, fbBallLp.height].join(','),
- geoBefore.split(',').slice(0, 4).join(','));
- fbTap(fbTouchX(), api.FB.anchorCy);
- check('再单击 → 收起', api.FB.open === false);
- /* ★ CANCEL 必须**只复位状态**:改窗口尺寸会中断手势并发 CANCEL,
- * 早期把它当 UP 处理 → 每次展开立刻又收起(用户看到的是"点了完全没反应") */
- api.FB.views.hot.dispatchTouch(env.touchEvent(fbEv.ACTION_DOWN, fbTouchX(), api.FB.anchorCy));
- api.FB.views.hot.dispatchTouch(env.touchEvent(fbEv.ACTION_CANCEL, fbTouchX(), api.FB.anchorCy));
- check('ACTION_CANCEL 不当作单击(不展开)', api.FB.open === false);
- /* 拖动超过 8px 判定为拖动 → 松手吸附边缘、记忆位置、扇形跟着镜像 */
- api.FB.side = 'left';
- api.FB.anchorX = Math.round(12 * FB_DENSITY);
- api.FB.savePosition();
- const dragFromX = fbTouchX();
- const fanBtnXBefore = fbFanViews[0].btn.getLeft();
- api.FB.views.hot.dispatchTouch(env.touchEvent(fbEv.ACTION_DOWN, dragFromX, api.FB.anchorCy));
- api.FB.views.hot.dispatchTouch(env.touchEvent(fbEv.ACTION_MOVE, dragFromX + 500, api.FB.anchorCy));
- api.FB.views.hot.dispatchTouch(env.touchEvent(fbEv.ACTION_UP, dragFromX + 500, api.FB.anchorCy));
- eq('拖到右半边后吸附右侧', api.FB.side, 'right');
- eq('吸附位置已落盘记忆', api.Store.get('ball.side'), 'right');
- check('拖动不会被误判成单击', api.FB.open === false);
- check('换边后球窗口跟着移动(x 变了)',
- api.FB.ballLp.x !== fbBallLp.x || api.FB.ballLp.x > 0, 'x=' + api.FB.ballLp.x);
- check('换边后扇形整体镜像(按钮 x 变了)',
- fbFanViews[0].btn.getLeft() !== fanBtnXBefore,
- fanBtnXBefore + ' → ' + fbFanViews[0].btn.getLeft());
- /* 复原到左侧,后面的按钮动作断言要在已知状态下做 */
- api.FB.side = 'left';
- api.FB.anchorX = Math.round(12 * FB_DENSITY);
- api.FB.restoreAnchor = api.FB.restoreAnchor; /* noop,保持引用可读 */
- api.FB.savePosition();
- api.FB.ensure();
- /* —— ★ 扇形四个按钮都必须真的触发动作 —— */
- api.FB.expand();
- let fbRunCalls = 0;
- let fbRunArg = null;
- const fbRealRequestRun = api.App.requestRun;
- api.App.requestRun = function (key) { fbRunCalls += 1; fbRunArg = key; };
- api.App.openDetail('video'); /* askRun 需要"当前任务",否则它选择回 App 让用户挑 */
- /* ★★ 动作必须在**点击时现算**:曾经把动作存在可变 holder 里、只在"三态变化"时接线,
- * 而展开这一步是 idle→idle → 回调永远是空函数:界面全对、事件也到了、就是没反应。 */
- api.FB.firstBtn.view.performClick();
- eq('空闲态点扇形第 1 个按钮 → 真的发起运行', fbRunCalls, 1);
- eq('发起运行用的是当前任务', fbRunArg, 'video');
- check('选完按钮后扇形自动收起(别挡着正在跑的 TikTok)', api.FB.open === false);
- eq('点完按钮扇形窗口也回到不可触摸', (fbFanLp.flags & NOT_TOUCHABLE) !== 0 ? 1 : 0, 1);
- /* 运行中:重新摊开时第 1 个按钮必须**一建出来就是**「停止」 */
- api.Session.start('video', 'video1', '关键词视频功能', 'run-fb-1');
- api.FB.sync();
- check('运行中球开始呼吸(§10.4 三态环)', api.FB.breathing() === true);
- api.FB.expand();
- eq('运行中按钮图标是方块(停止)', String(api.FB.firstBtn.view.getText()), '■');
- eq('运行中标签是「停止」', String(api.FB.firstBtn.pill.getText()), '停止');
- /* 扇形开着时状态变化 → **就地**改那一个按钮(不重建:重建会吃掉用户那一刻的点击) */
- api.Session.stop();
- api.FB.sync();
- eq('扇形开着时空闲下来 → 按钮就地变回「▶」', String(api.FB.firstBtn.view.getText()), '▶');
- check('空闲下来后停止呼吸', api.FB.breathing() === false);
- api.Session.start('video', 'video1', '关键词视频功能', 'run-fb-1');
- api.FB.sync();
- eq('扇形开着时又跑起来 → 按钮就地变回「■」', String(api.FB.firstBtn.view.getText()), '■');
- api.Dialog.active = null;
- api.FB.firstBtn.view.performClick();
- check('运行中点它 → 先收起扇形', api.FB.open === false);
- check('运行中点它 → 拉起 H5 停止确认', !!api.Dialog.active);
- check('确认弹窗的标题正确',
- textsOf(api.Dialog.active && api.Dialog.active.root).indexOf('停止当前脚本?') >= 0);
- check('确认前先记下"要停哪个脚本"',
- textsOf(api.Dialog.active && api.Dialog.active.root).some((t) => t.indexOf('关键词视频功能') >= 0));
- if (api.Dialog.active) { api.Dialog.active.close(); }
- api.Session.stop();
- api.FB.sync();
- /* 「回到 App」→ 按 REORDER_TO_FRONT 发 Intent(回到的是同一个界面,不是 AutoX 主界面) */
- api.FB.expand();
- globalThis.app.lastIntent = null;
- const backBtn = (function () {
- for (let i = 0; i < api.FB.fanRoot.getChildCount(); i++) {
- const v = api.FB.fanRoot.getChildAt(i);
- if (String(v.text || '') === '\u25A6') { return v; }
- }
- return null;
- })();
- check('扇形里有「回到 App」按钮', !!backBtn);
- if (backBtn) {
- backBtn.performClick();
- check('点「回到 App」→ 发出一条拉回前台的 Intent', !!globalThis.app.lastIntent);
- check('拉回前台用的是 REORDER_TO_FRONT',
- globalThis.app.lastIntent.flags === undefined ||
- globalThis.app.lastIntent.flags === globalThis.android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
- }
- /* 「退出应用」→ H6 确认(不真的退:确认弹窗出现即算通) */
- api.Dialog.active = null;
- api.FB.expand();
- const exitBtn = (function () {
- for (let i = 0; i < api.FB.fanRoot.getChildCount(); i++) {
- const v = api.FB.fanRoot.getChildAt(i);
- if (String(v.text || '') === '\u2715') { return v; }
- }
- return null;
- })();
- check('扇形里有「退出应用」按钮', !!exitBtn);
- if (exitBtn) {
- exitBtn.performClick();
- check('点「退出应用」→ 拉起 H6 确认', !!api.Dialog.active);
- check('H6 标题正确',
- textsOf(api.Dialog.active && api.Dialog.active.root).indexOf('退出飞舟脚本?') >= 0);
- if (api.Dialog.active) { api.Dialog.active.close(); }
- }
- /* —— 隐藏 / 销毁:两个窗口都要被摘掉,否则会在屏幕上留下孤儿浮窗 —— */
- api.FB.expand();
- api.FB.hide();
- eq('隐藏后落盘 ball.hidden', api.Store.getBool('ball.hidden'), true);
- eq('隐藏后两个窗口都被移除', env.wmWindows().length, 0);
- check('隐藏后释放窗口引用', api.FB.window === null && api.FB.fanLp === null);
- const fbRectHidden = JSON.parse(env.memoryFiles['/sdcard/tiktok_app/ball-rect.json'] || '{}');
- check('隐藏时告诉引擎"球没了"(否则引擎会一直绕开一个不存在的球)', fbRectHidden.hidden === true);
- /* 隐藏是可逆的:Store.set 打开后必须能再建出来 */
- api.FB.setHidden(false);
- eq('再打开开关后又建出两个窗口', env.wmWindows().length, 2);
- check('重建后窗口引用已恢复', api.FB.window !== null && api.FB.fanLp !== null);
- api.FB.destroy();
- check('destroy 之后不再持有窗口', api.FB.window === null);
- eq('destroy 之后 WindowManager 上没有残留窗口', env.wmWindows().length, 0);
- api.App.requestRun = fbRealRequestRun;
- /* ------------------------------------------------------- 14. 启动结论 */
- check('启动结论已落盘', typeof env.memoryFiles['/sdcard/tiktok_app_ui_result.txt'] === 'string');
- check('启动结论标记 OK_UI_STARTED',
- String(env.memoryFiles['/sdcard/tiktok_app_ui_result.txt']).indexOf('OK_UI_STARTED') === 0);
- check('启动结论记录当前屏与卡密状态',
- String(env.memoryFiles['/sdcard/tiktok_app_ui_result.txt']).indexOf('screen=') >= 0);
- /* ------------------------------------------------------------ 汇总 */
- console.log('');
- console.log(`通过 ${passed} 项`);
- if (failures.length > 0) {
- console.log(`失败 ${failures.length} 项:`);
- failures.forEach((f) => console.log(' ✗ ' + f));
- console.log('');
- console.log('SMOKE_FAILED');
- process.exit(1);
- }
- console.log('SMOKE_ALL_PASS');
|