Front的pnpm build只更新MaiChartManager/wwwroot;LinuxDebug 运行时的 ContentRoot 是bin/LinuxDebug/net10.0/linux-x64。- 浏览器 QA 前还要执行
dotnet build MaiChartManager/MaiChartManager.csproj -c LinuxDebug,否则后端仍会提供旧的bin/.../wwwroot。 - 可用首页引用的
assets/index-*.js哈希确认运行中的服务是否已加载最新前端。
@munet/ui的 Radio 会在 click 中调用preventDefault();更新 Vue 状态后,浏览器可能在事件结束时回滚原生checked,造成内容已切换但圆点仍显示旧状态。- 关键模式选择应同时断言业务内容和
input.isChecked();需要受控状态时使用原生 radio 的checked+onChange,或先修复共享组件。 - Radio 有 250ms 颜色过渡,截图应在状态断言通过并等待过渡结束后采集。
- 限高滚动区不要让纵向 flex 子项默认收缩,否则 Select 等控件会被压成细条;使用普通块级流,或显式禁止子项收缩。
- 响应式弹窗应同时断言弹窗矩形、标题和操作按钮都位于视口内,不能只检查文档没有横向滚动。
- 只验证前端动效且后端未启动时,初始化错误弹窗会遮住真实页面;可以仅在 QA 浏览器会话中关闭弹窗和根节点的 modal 状态,再采集页面证据,不要修改产品错误处理。
- 动效证据矩阵送审前要逐项检查文件存在且非空,尤其是反向切换的 settled 帧;运行时 transform 数据不能替代缺失的视觉帧。
- 临时测试程序直接项目引用主项目时,默认配置会把主项目解析成 Windows TFM;先构建
LinuxDebugBackend,再引用其输出 DLL。 - 引用主 Web 程序集的临时测试项目还需声明
Microsoft.AspNetCore.AppFrameworkReference,否则运行时无法加载 MVC 程序集。
pnpm genClient要连http://localhost:5181/swagger/v1/swagger.json。这个端口来自MaiChartManager/Properties/launchSettings.json,只有dotnet run会读它; 直接跑编译出来的 exe 会落到 Kestrel 默认的 5000,脚本连不上。- 但就算
dotnet run起了 5181,走ServerManager的真实应用里/swagger/v1/swagger.json是 404(/也是 404)。要生成 client 请用专门的MaiChartManager.GenClient:dotnet run --project MaiChartManager.GenClient/MaiChartManager.GenClient.csproj -c LinuxDebugBackend, 它只挂 Swagger + Controllers,跑完自动退出。 GenClient原先用cmd.exe /c调 pnpm,Linux 上会直接抛 “No such file or directory”, 已改成按平台选cmd.exe/pnpm。- backend 目录里跑的进程不要用
pkill -f <模式>收尾:模式会匹配到自己所在的 shell 命令行, 把当前命令一起杀掉(表现为退出码 144)。用ss -ltnp拿 PID 再kill。
dotnet publish AquaMai.Config.Wasm/AquaMai.Config.Wasm.csproj -c Release在没装wasm-toolsworkload 的机器上也能跑通,只会警告 “Publishing without optimizations”。- 产物在
AquaMai.Config.Wasm/bin/Release/net9.0/publish/wwwroot/_framework/,编辑器的src/wasm/dotnet.ts直接按相对路径引用它,没发布过就构建不出来。
- 没有
skipLibCheck时全项目 18 条报错里有 9 条来自三方.d.ts(vueuse 引Bluetooth*、naive-ui 引没装的katex、vue-i18n 引 vue 里不存在的GenericComponentInstance、@f3ve/vue-markdown-it引markdown-it),真正的源码错只有 9 条。先开skipLibCheck把噪音降到 0 再看剩什么,否则会把三方问题误判成自己的工作。 - 生成文件
src/client/apiGen.ts/aquaMaiVersionConfigApiGen.ts头部的// @ts-nocheck来自 swagger-typescript-api 硬编码的FILE_PREFIX,generateApi没有开关能关掉。实测剥掉后这两份文件(约 3400 行)零报错,所以在genClient.ts里做了生成后剥离,并写成「找不到就抛错退出」,避免库升级后静默失效。 src/icons/*.svg当组件用时类型是错的:vite/client声明*.svg是string,而 vite-svg-loader 把它编译成组件。不要自己在env.d.ts里再声明一遍*.svg——两份同名 ambient module 会合并,产生重复的export default(tsc报 TS2300),而skipLibCheck: true恰好把这条报错也吞掉,表现为 JSX 属性莫名不认,极难定位。正解是用 loader 支持的?component后缀:vite/client没有这个 pattern,可以安全地单独声明。- 判断一个
@ts-ignore是不是承重不能靠读代码。临时把所有@ts-ignore一次失效再跑一次tsc最快;注意 TS 认的是「以@ts-ignore开头的行注释」,把@ts-ignore改成@ts-ignore-defused仍然是生效的指令,要让行号不变地把指令挪到注释中间(例如// DISABLED @ts-ignore)。 - 最大的一条:
skipLibCheck: true不只跳过 node_modules,它会跳过程序里所有.d.ts,包括我们自己写的env.d.ts/shims-vue.d.ts。实测在.d.ts里写declare const x: ThisTypeDoesNotExist;→tscexit 0 无任何输出;同一行放进.ts→ TS2304。这次真的被这个坑咬到过:第一版把*.svg声明进env.d.ts,与vite/client的同名 ambient module 合并产生重复export default(TS2300),恰好被 skipLibCheck 吞掉,表现为 JSX 属性莫名不认、改 tsconfig 都没用。解法是把环境声明从.d.ts改名成.ts(没有任何代码 import 它们,纯声明文件,改后缀不影响运行时),这样既保留 skipLibCheck 挡三方噪音,自家的声明又照常被检查。 Button这类组件补事件 props 有个坑:声明过的 prop 会从$attrs里被摘掉,不再走 fallthrough,所以补 props 时必须同时在组件内部的根元素上显式绑定,否则调用方的 handler 静默失效(不报错、也不生效)。用@munet/ui的 Button +onMouseleave做过正反对照:只声明不绑定 → 事件不触发;补上显式绑定 → 触发。- 验证「事件到底有没有绑上」,可以在
MaiChartManager/Front下临时放一个verify-btn/index.html+main.tsx,用项目自己的vite --port 5199 --strictPort起 dev server(能直接解析@munet/ui的 TSX 源码),再用chromium --headless --no-sandbox --disable-dev-shm-usage --virtual-time-budget=10000 --dump-dom <url>把页面内dispatchEvent的结果写进<pre>读出来;--dump-dom会等模块执行完,--virtual-time-budget足够。改完记得删临时目录并 kill 掉 dev server(pkill -f vite会连带杀掉自己的 shell,用 PID kill 更稳)。
- 只改
MuNET-UI子模块指针、不跑pnpm install,会让根pnpm-lock.yaml与MuNET-UI/package.json失同步。GitHub Actions 里CI=true会让 pnpm 的frozen-lockfile默认为 true,于是pnpm install直接ERR_PNPM_OUTDATED_LOCKFILE失败——现有的 Build Canary 也会一起挂,不只是新加的 CI。 - 只跑
pnpm install --no-frozen-lockfile还不够:pnpm 会保留旧 lockfile 里已存在的解析结果,于是 Front 的 vue 停在 3.5.32,而 MuNET-UI 的 vue 按自己的^3.5.38升到 3.5.43 —— workspace 里出现两份 vue。因为@munet/ui是被 Front 以源码形式消费的,tsc 会同时看到两份 Vue 类型,症状是报错里出现VNodeRef & VNodeRef这种「类型和自己交叉」的怪东西(实测AquaMaiConfigurator.tsx的ref={searchRef}报 TS2322),改代码是修不好的。看到X & X这种交叉,先怀疑依赖有重复副本,别去改业务代码。 - 正解是让 workspace 内共享包只保留一个版本:
pnpm update <pkg> --filter <成员>把 Front 的 specifier 抬到与 MuNET-UI 解析出同一个版本(注意不是"specifier 数值相等"——实际是让两者的 semver 范围相交、解析器收敛到同一版)。本次抬到 vue 3.5.43 / vite 8.3.4 / unocss 66.10.5 / sass 1.105.1 / typescript 6.0.3 / @vueuse/core 14.4.0 / @vitejs/plugin-vue 6.0.9 / @vitejs/plugin-vue-jsx 5.1.6。 pnpm update -r会连子模块里的MuNET-UI/package.json一起改,用--filter mcm-frontend可以只动 Front。- 光靠
pnpm update还不够:pnpm 会保留 lockfile 里已有的解析结果,所以像@vitejs/plugin-vue-jsx这种两边范围本来不相交的包会留下两份。收尾要跑一次pnpm dedupe,然后逐包断言packages:段里只剩一个版本(@babel/*之类被顺带清掉的重复也有 29 个)。 - 之后加 CI 时又发现两个覆盖盲区,都不是这次改动引入的,但要知道:①
pnpm typecheck只跑tsc,不跑vite build,而build.yml只在 push 到 main 触发、不在 PR 触发,所以 PR 可以类型全绿却把打包弄挂,要等合并后才由 Build Canary 暴露;②pnpm -r run typecheck只会跑定义了 typecheck 脚本的成员,新增 workspace 成员时漏写脚本会静默跳过(日志里的Scope: N of M workspace projects不反映谁有脚本),没有任何内置开关能让它报错。 .vue的类型检查盲区是整个 workspace 的:Front 1 个 + MuNET-UI 2 个,共 3 个.vue组件没人检查(plain tsc 不解析.vue,MuNET-UI tsconfig 里src/**/*.vue那条 include 是 no-op)。- 判断「是不是真修好了」不能只看 lockfile:要同时确认
install --frozen-lockfile通过、pnpm typecheck通过、以及pnpm build仍然通过。本次 vite 从 8.0.8 跳到 8.3.4(打包器换成 rolldown),实测 build 12.1s 正常完成、只有 chunk 体积等既有警告。
- 顺序必须是「先把子模块工作区同步到新指针,再跑
pnpm install」。反过来的话 pnpm 会按旧子模块的 manifest 写 lockfile;之后再把子模块 bump 上去,lockfile 里记的 specifier 就和 manifest 对不上, 而 CI(CI=true)与typecheck.yml跑的都是pnpm install --frozen-lockfile,会直接失败:- vue (lockfile: ^3.5.32, manifest: ^3.5.38)。补跑一次不带--frozen-lockfile的 install 即可, 正常情况下只会改 specifier、不改解析出的版本。 - 光
git submodule update把指针切过去还不够,工作区得真的检出到新提交;git submodule status里 带+前缀就说明记录与实际不一致,这时 lockfile 也可能是错的。
- 独立网页版(
AquaMai.Config.Editor/src/web)不需要 dotnet 后端:pnpm build后的dist/就是全部产物 (dist/已被 gitignore),python3 -m http.server起个静态服务就能测。WASM 运行时是构建时就打进去的, 页面首屏会拉十几个 MB 的_framework,本地很快,别被wait_for_selector的默认超时坑到,给到 120s。 - 联网拉 CI 版 AquaMai 的路径可以离线复现:
page.route把 COS 的**/aquamai.json和 Cloudflare 的**/api/config都 fulfill 成一份只指向本地 DLL 的版本数组,DLL 本身用route.fulfill(path=...)读Projects/MuNET/AquaMai/Output/AquaMai.dll。这样空状态能进到「内核已加载」, 而不是卡在下载失败的错误态。注意非宿主路径对签名只记录不拦截,随便一个 AquaMai.dll 都能用来跑界面。 - 想测
<input type=file>回退和下载分支,得在add_init_script里delete window.showOpenFilePicker; delete window.showSaveFilePicker;——Chromium 有这两个 API, 不删就走 picker,而 picker 是没法用setInputFiles驱动的。 - 拖放用
page.evaluate_handle造DataTransfer+new File(...),再page.dispatch_event("#app > div", "drop", {"dataTransfer": dt}),能完整走到 drop 逻辑(types里会有Files,getAsFile()拿得到名字)。File 内容要传 str 或 base64 在页面里解码:直接传 Pythonbytes会被序列化成数字数组,File 里就不是原字节——我因此误判过一次「拖真 DLL 没生效」,其实是测试脚本的锅。 - playwright-python 1.63 自带的 chromium 版本与
~/.cache/ms-playwright里缓存的(1161/1223/1234)对不上,launch()直接报chromium_headless_shell-1243不存在,又不想改用户 cache,就用p.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox"])借系统 chromium。 - 页面主题是写死的
DynamicLight(src/web/main.tsx无条件覆盖selectedThemeName),所以这条路径截不到 暗色主题。跨主题安全的颜色要么用主题给的变量(--link-color、--modal-backdrop-bg、--modal-button-bg), 要么用color-mix(in oklab, currentColor N%, transparent);别用裸oklch(... var(--hue)), 它在暗色主题下是浅色。另:bg-[var(--x)]/12的透明度修饰符对 CSS 变量无效(UnoCSS 直接丢掉透明度), 要透明就用bg-[color-mix(in_oklab,var(--x)_12%,transparent)]。 - UnoCSS 的裸数字类名是间距倍数:
lh-1.8编译成line-height:.45rem,多行文字会叠在一起, 必须写lh-1.8em。怀疑某个类没生效时,最快的验证是createGenerator(unoConfig).generate('...')(临时脚本放编辑器目录里才解析得到unocss), 或者直接在dist/assets/*.css里 grep 目标类。 - 走宿主模式(注入
window.aquaMaiEditorHost)时,loadDll给的 DLL 是硬校验签名的: 非宿主路径只把结果记进signature,宿主路径status !== 'valid'直接抛错、页面停在空状态。 所以要跑宿主模式的 E2E 得用真签名的 DLL(Projects/MuNET/AquaMai/Output/AquaMai.dll末尾就带AquaMaiSig块,keyId 2,能用),随手拿个别的文件会得到「宿主提供的 AquaMai DLL 签名无效:未找到签名」(签名状态现在是本地化文案,不再是not_found这样的机器码)。 - 宿主的
loadDll里别把字节从 Python 传进来:让页面自己fetch一个用page.routefulfill 的 URL, 在页面里转 base64 再返回,和拖放那次踩的是同一个序列化坑。 - 缩放顶栏这类布局开关,别只断言元素在不在。
hideToolbar: true时AquaMaiConfigurator的网格y应该等于根节点 padding(8px)、对照组是 89px;顺便确认异常情况下错误条还在(它不是顶栏的一部分)。 - 想读 MCM dev server 里合并后的 i18n messages,别走
$i18n:globalInjection 注入的那个对象上既没有t也没有getLocaleMessage(i18n.t is not a function)。用 dev server 的模块图直接await import('/src/locales/index.ts')拿默认导出的 i18n 实例,再i18n.global.getLocaleMessage(loc)。 - 改
AquaMai.Config.Editor/src/locales/*.yaml后一定要真的构建一次(pnpm build)。 英文文案里出现:(半角冒号加空格)时是非法裸标量,vite 的 yaml 插件会直接让构建挂掉,tsc完全查不出来。要么整条加引号,要么用全角冒号。 - 手动改前端代码后想知道 i18n 有没有漏键,用脚本抽
t('...')字面量和三份 yaml 的扁平键集比对 (mod.soundChannel.这种动态拼接的会误报,忽略即可),比在界面上翻一遍快得多。 - 测「dirty 时换配置内核」这条流程(
/tmp/mcm_e2e/kernel_switch.py)时踩到几处断言坑,都不是页面的问题:- 别用
get_by_text找状态栏里的文案。showTransactionalDialog是全局单例,弹窗关掉之后 DOM 还在(v-show), 里面那句「…未保存的修改…」会让get_by_text('未保存').count()永远大于 0。认未保存标记要用span.c-orange-6。 - 找内核版本也别用文本:
AquaMai 配置编辑器(标题)和AquaMai 1.9.23.0(徽章)只差一个\S+, 用^AquaMai \d这种带数字的正则,或者认徽章里的memory-alt图标。空状态里根本没有徽章,那行是 kernelText。 - 换内核的状态栏会先变「已加载 AquaMai x」(setDll)再变「配置版本 y」(重新解析写回),中间那一下不保证轮询得到 (Vue 批量更新 + WASM 解析很快)。别等瞬时态,轮询终态:徽章变了 且 状态栏是「配置版本」。
- 拿开关当「脏」的素材时,
input[type=checkbox]的第一个是分区的开关,改它会把它下面的条目整个收起来、 DOM 重排;而且CheckBox的 input 自带title="",closest('[title]')会命中它自己。要先向上找非空 title 拿到分区 / 条目路径,再用[title="<path>"] input[type=checkbox]把它钉住。 - 点「保存并切换」之后重新解析出来的应该是保存过的那份,所以用户改动还在;点「不保存」才会回到文件里的值。 这两个方向的断言别写反。
- 静态服务别固定用同一个端口:
TCPServer关掉后 8791 上留一堆 TIME-WAIT,下一轮bind直接 EADDRINUSE (设了allow_reuse_address也没救回来),换个端口最省事。另外pkill -f kernel_switch.py会连自己一起杀(退出码 144)。
- 别用
- 合成的
DragEvent偶尔会不带dataTransfer.types,这时页面的hasFiles()会(正确地)把这个拖放当成非文件拖放忽略掉, 表现为「拖了但什么都没发生、错误条也是空的」。这类失败重拖一次就好,别去页面里找 bug;反过来, 如果你看到「拖了没反应」,先确认是测试脚本的问题。 - 断言自定义面板(
sectionPanelOverride/overrides)时注意两点:- 分区根节点没有
title,带title的是里面那一行表头(分区名 + 开关 + 注释)。 要拿根节点得xpath=//*[@title="<分区 path>"]/..。 ENTRY_GROUP_PADDING(.p-l-15)不能用来判断「配置项被面板顶掉了没」:键位、音量路由 这些面板自己也用它排条目行。要挑一个面板不复用这个类的分区来验证 override。
- 分区根节点没有
GameSystem.MaimollerIO排版里P1/P2(整侧开关)那两个has()判断是给老内核留的: 现在这版 AquaMai 里根本没有这两个条目,只有 Touch1p / Button1p / Led1p 加 2P 那三件套。 写断言时别指望那两行一定渲染出来,分组行的标签是1P/2P(ENTRY_LABEL_CLASS 的 div)。