diff --git a/docs/engineering/agent-workflow.md b/docs/engineering/agent-workflow.md index a990bef0f..c86e1f981 100644 --- a/docs/engineering/agent-workflow.md +++ b/docs/engineering/agent-workflow.md @@ -6,7 +6,7 @@ ## 事实与复现 -先读取 Issue 最新评论及其截图,把用户操作变成可观察的验收条件。记录框架、平台、命令、依赖实际解析路径和编译器版本。明确区分已观察事实、待证假设和环境阻塞。 +先读取 Issue 最新评论及其截图,把用户操作变成可观察的验收条件。记录框架、平台、命令、依赖实际解析路径和编译器版本。明确区分已观察事实、待证假设和环境阻塞。每个独立问题记录最后成功阶段、第一失败阶段、原始错误及证据位置;内部卡点无法确定时写“未知”。回答“最后卡在哪里”时先给具体命令/阶段,再说明下一步;后续缺少设备或交互桌面的阻塞不能代替此前故障定位。 测试预期必须来自项目配置和产品语义,不照搬默认 theme 数值。组件属性必须有真实消费方。首次加载先建立基线,再验证增量。Web 验证须确认服务根目录身份、端口和当前构建,不能仅凭标题或 localhost URL 判断实例。 @@ -14,6 +14,10 @@ 沿输入、转换、产物、运行时逐层找首次偏离。先固化失败用例,再修改负责该行为的模块;用相同用例验证修复前失败和修复后通过。未复现时不制造产品补丁,也不凭 mock 单测宣称用户环境已修复。 +连续两次相同失败且没有新增证据时,下一步必须增加阶段日志、做最小对照或更换验证路径;不盲目重试同一操作。诊断改进、一次运行通过和根因修复分别报告;只有对照与回归能够支持因果关系时,才把假设升级为已确认根因。 + +协议、参数、路径或环境变量发生变化时,搜索全部生产消费方、mock、fixture、说明文档和 change intent,逐项确认是否需要同步。可模拟的平台分支在本地全部执行,防止仅运行宿主分支漏掉 Windows 等平台;模拟测试不能替代真实进程或桌面验收。 + HMR 在同一服务进程连续修改,覆盖新增、替换、删除和最终浏览器刷新。保存标识与被测属性尽量在一次写入提交;等待本轮 DOM、CSS 和计算样式一致,不用旧 marker 或固定等待证明成功。保留服务端错误、请求失败、页面日志和失败截图。 ## 分层验收 @@ -23,21 +27,61 @@ HMR 在同一服务进程连续修改,覆盖新增、替换、删除和最终 3. 对应 demo 的 static 基线和真实运行链路;设备流程见 [多端手册](../../e2e/LOCAL-MULTI-PLATFORM-E2E.md)。 4. 检查 diff 与规则:`git diff --check`、`pnpm agents:check`。 +验证按依赖顺序执行:工具安装先于工具检查,构建完成先于读取 dist 的测试;共享源码或输出目录的验证串行执行。工作流变更除了语法校验,还验证实际命令的成功、失败和前置条件,保留语义断言与退出码。 + 正常测试设置 `CI=1`;Vitest 5 显式使用 `--update=none`,禁止使用会被解释为更新参数的 `--update=false`。E2E 配置同时默认 `update: 'none'`,显式 `-u` 才更新基线。更新基线必须单独执行、限定项目、说明语义差异,再运行不更新的验证。聚合构建的缓存命中、跳过目标、测试 skip 均单独报告。 ## 沉淀与自我修正 根因修复、错误结论纠正或可复用的流程失败,需要在 `docs/engineering/lessons/` 留一份中文记录,链接持久回归和证据。原始大日志放到忽略的 artifacts;记录保留关键错误、命令、结果和适用版本,不能只留本机路径。 -记录使用 YAML frontmatter:`status` 为 `verified`、`partial` 或 `superseded`;`issue` 为来源 URL;`baseline` 为验证提交 SHA;`regressions` 为仓库相对测试路径数组。正文包含“症状”“根因与纠正”“验证”“适用边界”“规则评估”。 +记录使用 YAML frontmatter:`status` 为 `verified`、`partial` 或 `superseded`;`issue` 为来源 URL;`baseline` 为原排查基线的完整提交 SHA;`regressions` 为仓库相对测试路径数组。正文包含“症状”“根因与纠正”“验证”“适用边界”“规则评估”。 + +新建或更新复盘时,使用可选的 `verification` 数组记录本次结论对应的证据;未采用该字段的历史记录仍兼容。提供时必须为非空数组,每条只允许下列字段: + +| 字段 | 约定 | +| --- | --- | +| `claim` | 非空字符串,说明本条验证支持的具体结论 | +| `kind` | `unit`、`integration`、`ci` 或 `native` | +| `status` | `passed`、`failed` 或 `pending`;跳过的验收记录为 `pending`,在原因中说明 | +| `sha` | 被测或待测的完整 40 位十六进制提交 SHA | +| `environment` | 非空字符串,记录实际或目标系统及 Node/IDE 等版本 | +| `command` / `url` | 已执行记录至少提供一个;命令为非空字符串,URL 为完整 HTTPS 地址;提供的字段均须有效 | +| `reason` | 待验收记录必须说明原因;若提供则必须是非空字符串 | + +`baseline` 保留原排查基线,各条 `verification.sha` 记录实际验证提交。单项通过不改变整篇 `partial` 状态,也不证明所有环境或根因已解决。CI 证据优先链接不可混淆的运行/任务 URL;本地命令同时说明源码是否含未提交改动,原始日志位置和限制放在正文。记录的命令仅供审阅,不会被校验器执行。 每次交付评估规则变化:优先修正已有条目或补可执行测试;只有跨任务可复用且有证据的约束才进入 AGENTS。允许说明“不新增规则”。推翻旧结论时保留原因和替代记录;过时经验标记 superseded,不静默覆盖历史证据。 +本次流程纠正的实例见 [PR #1172 证据与交付复盘](lessons/ai-evidence-delivery.md)。 + 根规则只放跨仓库硬约束,领域规则只放局部差异,操作步骤只有一个权威文档。新增规则须给出触发场景、验证入口和复查条件;不以重复或更长的规则代表更高质量。 +## 交付与自动跟进收尾 + +交付前核对最新 head SHA、CI 运行/任务 ID 与验收范围,分别报告通过、失败、跳过和待验收。PR 标题、正文、相关复盘和 change intent 按最终实现同步,保留首次失败与纠正过程;历史通过记录标明提交,不用旧结果描述当前状态。正文写成面向审阅者的最终变更说明,避免不断追加过期进展。 + +CI 通过、审核批准、无冲突可合并与已经合并是不同状态。更新 PR 元数据或取消草稿后重新读取状态;若触发新一轮检查,报告“此前一轮通过,新一轮进行中”。未经合并授权不得执行合并。 + +只在已有授权范围内跟进自动化;观察状态不变时安静等待,只报告有意义的变化。已授权任务达到约定停止条件、PR 合并/关闭或用户要求停止时,调用所在平台的管理工具停用,再核对返回状态;不能只把停止条件写进提示词。停用失败则如实说明,不声称已停止,也不擅自重建定时任务。原生待验收按独立范围记录,不能无限延长已经完成的自动 CI 跟进。 + +## 交接模板 + +中断、移交或长任务结束时,更新已有任务记录。临时状态放在忽略目录,持久结论进入复盘,不将本机路径复制为仓库规则。可直接使用以下模板: + +```text +目标与授权范围:本轮成功标准;允许的外部操作;明确不做的事。 +工作区:worktree、分支、基线 SHA、被测 SHA;是否含未提交改动。 +当前归属:本轮文件;测试临时变更与恢复记录;自有/外部 PID 和服务。 +证据:每项结论的环境、命令或运行 ID/URL;通过、失败、跳过、待验收。 +准确阻塞:最后成功阶段 → 第一失败阶段;原始错误;已知事实与未证假设。 +下一步:具体操作及预期新增证据;缺少的设备、输入或外部条件。 +自动化:任务 ID、授权范围、停止条件、已核对的启用/停用状态。 +``` + ## 自动检查与边界 -`pnpm agents:check` 是只读检查,验证规则索引、相对文件链接、明确 pnpm 命令和复盘引用。它不会执行文档里的命令,不会改规则、更新快照或对语义正确性做保证。新增语法或规则变更先补检查器测试。 +`pnpm agents:check` 是只读检查,验证规则索引、相对文件链接、明确 pnpm 命令、复盘引用及可选证据字段的结构。错误包含文件名和从 1 开始的 `verification[n]` 记录位置。它保持离线,不联网核对 CI,不执行文档里的命令,不会改规则、更新快照或对证据真实性及语义正确性做保证。尤其不能把 CI 通过换算成原生验收通过。新增语法或规则变更先补检查器测试。 纯 Markdown PR 同样执行独立规则检查。AI 可以在授权任务中提出和实现有证据的规则修订,但不得自行放宽安全边界、操作用户会话、发布、关闭 Issue 或创建后台任务。 diff --git a/docs/engineering/lessons/ai-evidence-delivery.md b/docs/engineering/lessons/ai-evidence-delivery.md new file mode 100644 index 000000000..d494e52bc --- /dev/null +++ b/docs/engineering/lessons/ai-evidence-delivery.md @@ -0,0 +1,74 @@ +--- +status: partial +issue: https://github.com/sonofmagic/weapp-tailwindcss/pull/1172 +baseline: 8940eb551660176ab5f69f2086ac3a13acd161af +regressions: + - scripts/agents/verification.test.mjs + - packages/hbuilderx-runner/test/host-connection.test.ts + - e2e/react-native-ci.test.ts +verification: + - claim: "证据结构校验和旧记录兼容回归通过" + kind: "unit" + status: "passed" + sha: "960bc940d13cc8e47b0036f85714a2489e52d130" + environment: "macOS / Node 24.18.0 / pnpm 11.25.0" + command: "CI=1 pnpm agents:test --update=none" + - claim: "Windows/macOS/Linux × Node 22/24 六组 portable 回归通过;不替代原生桌面验收" + kind: "ci" + status: "passed" + sha: "7623470bddfb3e3ada51cefbdbd6527015867220" + environment: "GitHub hosted Windows、macOS、Linux / Node 22、24" + url: "https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34309551585" + - claim: "React Native Web、Android、iOS 自动验收通过" + kind: "ci" + status: "passed" + sha: "7623470bddfb3e3ada51cefbdbd6527015867220" + environment: "GitHub hosted Linux、macOS / Node 24 / Expo 54 / Android 11、iOS 18.5" + url: "https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34309551591" + - claim: "通过 IDE 点击运行后的 Windows 两轮连接验收" + kind: "native" + status: "pending" + sha: "7623470bddfb3e3ada51cefbdbd6527015867220" + environment: "Windows 11 交互桌面 / HBuilderX stable、alpha" + reason: "没有可用交互桌面完成该模式的逐场景两轮验收;portable 和历史 CLI 结果不能替代" + - claim: "macOS stable 两轮与 alpha 完整第二轮连接验收" + kind: "native" + status: "pending" + sha: "7623470bddfb3e3ada51cefbdbd6527015867220" + environment: "macOS / HBuilderX stable 5.24、alpha 5.25" + reason: "alpha 首轮四场景完成;剩余轮次未完成,不能由 CI 通过或 PR 合并补足" +--- + +# 从 PR #1172 修正 AI 排障与交付流程 + +## 症状 + +本轮暴露了五类流程问题:Windows 输出协议更新后遗漏另一份 mock;Android 加速检查放在工具安装前;CI 完成后仓库复盘仍写待验证、change intent 仍描述旧 JSON;回答“最后卡在哪里”时使用泛化的启停问题或后续缺少交互桌面代替准确命令;自动跟进提示词虽有停止条件,实际仍需用户提醒停用。 + +## 根因与纠正 + +协议修改没有完整检查生产消费方、mock、fixture 和说明,宿主平台测试又掩盖 Windows 分支。已有 host-connection 回归现已显式执行三平台,旧 mock 在 macOS 模拟 win32 时能够失败,修正后通过。流程要求每次边界变更检查完整消费链;原生能力仍由真实环境验证。 + +工作流语法检查不能证明工具已经安装。Android 回归现已检查生命周期顺序,并实际执行带空格、中文与 & 路径的启动钩子,验证成功与非零失败退出。构建与消费 dist 的测试同样按依赖顺序执行。 + +原记录与 PR 正文各自追加进展却没有最终同步。本轮以明确 SHA、运行 URL 和验收范围更新对应复盘,保留原始失败与反例;change intent 按最终 Base64 协议纠正。verification 为可选结构,历史文档继续兼容;新记录不允许通过未知字段、空来源或缺失待验收原因掩盖信息遗漏。 + +准确卡点应表述为:最后一轮继承 stdin 对照已渲染页面,随后 project close 空日志、20 秒超时,其他只读 CLI 请求仍正常。内部等待位置未知;缺少 Windows 桌面导致新的连接模式待验收是独立问题。后续排障记录最后成功阶段、第一失败阶段和原始错误,两次相同失败没有新证据时必须改变诊断动作。 + +已授权自动化的停止必须落实为管理工具操作并核对状态;本次任务最终已停用为 PAUSED,不能把提示词中的停止条件当作执行结果。新流程要求按约定终止跟进,并用交接模板保留目标、授权、源码与进程归属、证据、精确阻塞和下一步。它不自动创建、恢复或管理任何定时任务。 + +## 验证 + +证据校验先运行旧实现:36 个负例未被拒绝,测试失败;补充字段校验后全部通过,并覆盖 SHA 尾部换行与真正的嵌套数组输入。最终 50 项测试通过。代码验证提交见 verification,流程和本复盘为后续说明文档,不冒充旧 SHA 已包含这些文档。 + +仓库级 fixture 检查错误包含文件和第二条记录位置;不可达 HTTPS 地址不会触发网络请求,带写文件语句的 command 不会执行,文档字节和 Git 状态保持不变。另运行 `pnpm agents:check`(45 份规则、19 份工程文档、238 条命令,0 错误)、脚本定向 ESLint 和 `git diff --check`;Markdown 被现有 ESLint 配置忽略,使用 agents 校验与人工差异审查,不冒充已通过 Markdown lint。校验结果只支持结构、兼容性与只读行为,不能证明任意 claim 的真实性。 + +#1172 的六组 portable 及 React Native 三端 CI 已通过,详见结构化来源;首次失败不删除。原生阶段详情见[Windows 进程边界](hbuilderx-windows-process-boundary.md)、[连接验收](hbuilderx-attached-acceptance.md)及 [Android 前置条件](android-ci-kvm-access.md)。 + +## 适用边界 + +原生 CLI 间歇挂起的内部根因仍未确认;Windows 交互桌面两轮、macOS stable 两轮及 alpha 完整第二轮仍待验收。不能以自动 CI 通过或 PR 合并覆盖这些限制。新校验器只检查结构,不核实远端状态、命令执行结果、因果关系或原生覆盖,也不保证 AI 从此不会误判。 + +## 规则评估 + +改进集中在[现有工程流程](../agent-workflow.md)与 agents 校验器,根 AGENTS 继续路由到该文档,不添加重复规则。本轮不涉及产品行为、demo/static 基线或 CI 触发范围,无需重建 demo;仍需人工审查证据与结论是否相符、授权边界及真正的停止条件。 diff --git a/docs/engineering/lessons/android-ci-kvm-access.md b/docs/engineering/lessons/android-ci-kvm-access.md index 4bf7fd78a..008497ef8 100644 --- a/docs/engineering/lessons/android-ci-kvm-access.md +++ b/docs/engineering/lessons/android-ci-kvm-access.md @@ -5,6 +5,19 @@ baseline: 7b50dcb582174221fc2f85ae0e9132e0a4c16316 regressions: - e2e/react-native-android-window.test.ts - e2e/react-native-ci.test.ts +verification: + - claim: "安装模拟器之前执行加速检查会退出 127" + kind: "ci" + status: "failed" + sha: "5ce6db93e80baf1bbaacb6a6fc080ac223c52c15" + environment: "GitHub ubuntu-latest / Node 24" + url: "https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34306912064/job/102325400077" + - claim: "React Native Web、Android、iOS 自动验收通过" + kind: "ci" + status: "passed" + sha: "7623470bddfb3e3ada51cefbdbd6527015867220" + environment: "GitHub hosted Linux、macOS / Node 24 / Expo 54 / Android 11、iOS 18.5" + url: "https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34309551591" --- # Android CI 的 KVM 访问前置条件 @@ -17,11 +30,11 @@ Expo Android 11/API 30 自动检查 34304902514 的任务 102319452874 在调用 工作流日志明确报告当前用户没有 /dev/kvm 权限,自动将硬件加速关闭,使用 `-accel off` 启动。模拟器警告 x86_64 软件仿真可能无法工作,TCG 不支持 AVX/F16C;启动耗时 356 秒。logcat 中 Android ART 的 BootImageLoader::LoadImage 在 app_process 启动阶段触发 SIGTRAP,尚未进入 uiautomator 业务逻辑。 -CI 只给当前临时 runner 用户增加 KVM 读写 ACL,在 action 之前检查权限,并禁止 action 静默退回软件仿真。首轮补丁 5ce6db93e 把 emulator -accel-check 也放在 action 前,实际运行 34306912064 因模拟器尚未安装而退出 127;该执行顺序有误。本次将二进制检查移入 action 的 pre-emulator-launch-script,位于 installAndroidSdk/createAvd 之后、launchEmulator 之前,避免假定 runner 预装模拟器。原有 UI、运行时、截图与样式断言保持不变,没有添加重试。日志能证明旧运行缺少 KVM 前置条件;具体 ART 指令故障与软件仿真的因果仍需后续真实 CI 证据,不据此修改产品代码。 +CI 只给当前临时 runner 用户增加 KVM 读写 ACL,在 action 之前检查权限,并禁止 action 静默退回软件仿真。首轮补丁 5ce6db93e 把 emulator -accel-check 也放在 action 前,实际运行 34306912064 因模拟器尚未安装而退出 127;该执行顺序有误。本次将二进制检查移入 action 的 pre-emulator-launch-script,位于 installAndroidSdk/createAvd 之后、launchEmulator 之前,避免假定 runner 预装模拟器。原有 UI、运行时、截图与样式断言保持不变,没有添加重试。日志能证明旧运行缺少 KVM 前置条件;后续真实 CI 已确认加速模式下验收通过,但具体 ART 指令故障与软件仿真的因果仍未独立证实,不据此修改产品代码。 ## 验证 -新增 e2e/react-native-ci.test.ts:禁止在 SDK 安装前调用模拟器;实际执行启动钩子验证含中文、空格及 & 的 SDK 路径,并验证加速检查非零退出会原样传播。同一回归在旧工作流失败,修正后 2 个回归通过,actionlint 校验工作流通过;Linux hosted runner 的实际加速和现有 Android 验收由新提交自动检查验证。本机为 macOS,未执行 /dev/kvm 配置,不以本机命令冒充 Linux 结果。原失败 logcat、截图、UI XML、Metro 和构建日志保留在原工作流 artifact。 +新增 e2e/react-native-ci.test.ts:禁止在 SDK 安装前调用模拟器;实际执行启动钩子验证含中文、空格及 & 的 SDK 路径,并验证加速检查非零退出会原样传播。同一回归在旧工作流失败,修正后 2 个回归通过,actionlint 校验工作流通过;随后 `cc6b30f96` 的真实 Linux 任务确认 KVM version 12 可用、模拟器约 26 秒启动,UI、TSX 保存标识和 CSS 颜色变化通过,三张截图及 UI XML 已上传;最新被测提交 `7623470bd` 的 [React Native 完整工作流](https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34309551591) 再次通过 Web、Android 和 iOS 验收。本机为 macOS,未执行 /dev/kvm 配置,不以本机命令冒充 Linux 结果。原失败 logcat、截图、UI XML、Metro 和构建日志保留在原工作流 artifact。 ## 适用边界 diff --git a/docs/engineering/lessons/demo-watch-patch-hydration.md b/docs/engineering/lessons/demo-watch-patch-hydration.md new file mode 100644 index 000000000..671509f5c --- /dev/null +++ b/docs/engineering/lessons/demo-watch-patch-hydration.md @@ -0,0 +1,74 @@ +--- +status: partial +issue: https://github.com/sonofmagic/weapp-tailwindcss/pull/1173 +baseline: d17489eca1b038d0fd9b5fbfde69b8d187e4a51a +regressions: + - scripts/ci/demo-matrix/rollup-invalidation.test.mjs + - scripts/ci/demo-matrix/rollup-watch.test.mjs + - scripts/ci/demo-matrix/browser-hydration.test.mjs +verification: + - claim: uview Linux 小程序保存到 add 阶段后不再触发编译 + kind: ci + status: failed + sha: d17489eca1b038d0fd9b5fbfde69b8d187e4a51a + environment: GitHub ubuntu-latest,Node 24,pnpm 12.3.4 + url: https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34339329577/job/102427989877 + - claim: macOS uni-app SSR 初始探针通过但 replace 阶段仍读取旧探针 + kind: ci + status: failed + sha: d17489eca1b038d0fd9b5fbfde69b8d187e4a51a + environment: GitHub macos-latest,Node 24,pnpm 12.3.4 + url: https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34339329577/job/102427986893 + - claim: 两个实际 Rollup 消费链与延迟 Vue hydration 回归通过 + kind: integration + status: passed + sha: d17489eca1b038d0fd9b5fbfde69b8d187e4a51a + environment: macOS,Node 24.18.0,pnpm 12.3.4,基线加本次未提交补丁 + command: CI=1 pnpm test:demo:matrix +--- + +# demo watcher 补丁覆盖与 SSR 验收起点 + +## 症状 + +修复 pnpm 12 启动兼容后,PR Gate 的 187 项检查通过,但两组 portable demo 失败。 +uview 在 Linux 的 mp-weixin/mp-alipay 均完成 initial 和 replace,随后 add 不再出现新编译日志。 +macOS uni-app SSR 已建立 Vite WebSocket 连接、能够看到服务端 HTML 和计算样式, +但 replace 之后页面仍是旧探针,浏览器只记录了 CSS 热更新。 + +## 根因与纠正 + +uview 通过 Vite 7 消费 Rollup 4.63.1,普通 uni-app 通过 Vite 5 消费带补丁的 4.63.0。 +原有回归只解析后者,因而没有覆盖新版本中仍存在的变更通知丢失及 transform watcher 问题。 +将相同 watcher 修复按 4.63.1 的源码上下文单独生成补丁,并让回归解析两个 demo 的真实依赖, +覆盖 CJS/ESM、文件/目录 transform 依赖、连续原子保存、删除及重建。 +不将补丁泛化到未经测试的未来版本,也不新增全局 Rollup override。 + +SSR 验收存在独立的时序缺口:服务端探针和开发连接先于客户端异步页面模块就绪。 +新增真实 Vue 延迟 hydration 对照,旧 runner 在 mounted 之前返回,新 runner 等待探针所属组件挂载后再保存。 +该等待仅用于已注册的 uni-app SSR 场景;保留原有每轮文本、类消费、计算样式和刷新断言。 +对照证明验收起点存在缺陷,不能单凭一次 macOS 通过断言已排除全部远端 SSR 故障。 + +## 验证 + +- 未打补丁的 4.63.1 通知去重回归失败,补齐后通过;两个版本的 CJS/ESM 实际 watcher 回归通过。 +- 真实延迟 Vue hydration 回归在旧 runner 下失败,新 runner 下通过。 +- `CI=1 pnpm test:demo:matrix`:59 项通过。 +- `pnpm install --frozen-lockfile`:通过。锁文件语义比对确认只增加补丁标识与 Rollup 直接依赖引用;包版本、peer 选择、integrity、catalog 和其他元数据保持不变。 +- uview mp-weixin 的 production、initial、replace、add、restore,以及 uni-app SSR 的完整保存和刷新流程通过既有基线。 + +原始日志在忽略目录 `e2e/.artifacts/pr-1173-ci/`。一次本地 uview mp-alipay 首次编译停在 +Tailwind 初始化后、未输出编译完成,内部卡点未知;该现象不同于远端已经完成 replace 后不再编译, +保留日志并增加 Vite 阶段日志检查,随后 mp-alipay 的 production 与四轮保存通过。该次通过没有定位此前首次编译超时的内部原因,不用前置超时替代原始故障定位。 +首次尝试复用 4.63.0 补丁文件时,pnpm 严格应用因源码行位置变化失败,现已使用独立 4.63.1 补丁。 +重新解析锁文件还带入无关 peer 选择变化,因此仅保留本次所需的 pnpm 生成补丁标识和直接引用,并冻结复验。 + +## 适用边界 + +本轮未修改 demo、样式 fixture 或 static 基线;运行既有断言,不降低验收范围。 +Vue 挂载状态是开发版运行时能力,此内部验收模式不适用于生产站点。 +Windows/Linux 真实进程结论需以最新提交的对应 CI 为准;原生 HBuilderX CLI 与交互桌面验收状态不变。 + +## 规则评估 + +不新增 AGENTS 规则。把依赖升级消费链检查落实到实际 demo 解析的 watcher,并用真实 Vue hydration 测试约束验收起点。 diff --git a/docs/engineering/lessons/hbuilderx-attached-acceptance.md b/docs/engineering/lessons/hbuilderx-attached-acceptance.md index 356419938..4d0da0a65 100644 --- a/docs/engineering/lessons/hbuilderx-attached-acceptance.md +++ b/docs/engineering/lessons/hbuilderx-attached-acceptance.md @@ -5,6 +5,25 @@ baseline: 2f7318b022c5ea2ff00750ff6121e44b6a275e1d regressions: - e2e/hbuilderx-attach.test.ts - e2e/issue-hbuilderx-attach.test.ts +verification: + - claim: "Windows/macOS/Linux × Node 22/24 六组 portable 回归通过;不替代原生桌面验收" + kind: "ci" + status: "passed" + sha: "7623470bddfb3e3ada51cefbdbd6527015867220" + environment: "GitHub hosted Windows、macOS、Linux / Node 22、24" + url: "https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34309551585" + - claim: "通过 IDE 点击运行后的 Windows 两轮连接验收" + kind: "native" + status: "pending" + sha: "7623470bddfb3e3ada51cefbdbd6527015867220" + environment: "Windows 11 交互桌面 / HBuilderX stable、alpha" + reason: "没有可用交互桌面完成该模式的逐场景两轮验收;portable 和历史 CLI 结果不能替代" + - claim: "macOS stable 两轮与 alpha 完整第二轮连接验收" + kind: "native" + status: "pending" + sha: "7623470bddfb3e3ada51cefbdbd6527015867220" + environment: "macOS / HBuilderX stable 5.24、alpha 5.25" + reason: "alpha 首轮四场景完成;剩余轮次未完成,不能由 CI 通过或 PR 合并补足" --- # 原生 IDE 启动与 Web HMR 验收分离 @@ -43,7 +62,7 @@ macOS Alpha 首轮最终结果: 每项原始证据位于 `e2e/.artifacts/hbuilderx-attach/--<标识>/`,日志来自原生控制台的可访问文本原样保存,记录了 `HX_Version`、编译器、VDOM 和起止标识。LF 在 `6f2d368f5`,其余三项在 `7b50dcb58` 执行,二者被测产品和连接代码相同,后者仅新增 watcher 夹具与报告变更。第二轮 LF 因 GUI 未启动新服务而等待超时、源码恢复;stable 两轮、alpha 完整第二轮及 Windows 交互验收尚未完成,不能声称计划矩阵已全通过。 -该提交的 portable 六个系统/Node 组合全部成功,Windows 原生 job 为 skipped,self-hosted 原生工作流未由 PR 触发。完整 PR 其它自动任务仍继续跟进。 +最终被测提交 `7623470bd` 的 [portable 六组回归](https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34309551585)及 [React Native 三端 CI](https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34309551591)均成功;Windows 原生 job 为 skipped,self-hosted 原生工作流未由 PR 触发。PR #1172 已合并;已授权跟进任务随后应用户要求停用并核对为 PAUSED。这些状态不补足原生验收矩阵。 ## 适用边界 diff --git a/docs/engineering/lessons/hbuilderx-windows-process-boundary.md b/docs/engineering/lessons/hbuilderx-windows-process-boundary.md index 0eb4f27f5..48d1a2ba1 100644 --- a/docs/engineering/lessons/hbuilderx-windows-process-boundary.md +++ b/docs/engineering/lessons/hbuilderx-windows-process-boundary.md @@ -11,6 +11,25 @@ regressions: - e2e/hbuilderx-project-alias.test.ts - e2e/issue-1144-stable.test.ts - packages/weapp-tailwindcss/test/uni-app-x/style-reference-paths.test.ts +verification: + - claim: "继承 stdin 对照在页面渲染成功后仍出现 project close 空日志超时" + kind: "native" + status: "failed" + sha: "d87fea520ac37af46246ba7d212e6807cd626910" + environment: "GitHub Windows / HBuilderX stable 5.24、alpha 5.25" + url: "https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34294835402" + - claim: "Windows/macOS/Linux × Node 22/24 六组 portable 回归通过;不替代原生桌面验收" + kind: "ci" + status: "passed" + sha: "7623470bddfb3e3ada51cefbdbd6527015867220" + environment: "GitHub hosted Windows、macOS、Linux / Node 22、24" + url: "https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34309551585" + - claim: "通过 IDE 点击运行后的 Windows 两轮连接验收" + kind: "native" + status: "pending" + sha: "7623470bddfb3e3ada51cefbdbd6527015867220" + environment: "Windows 11 交互桌面 / HBuilderX stable、alpha" + reason: "没有可用交互桌面完成该模式的逐场景两轮验收;portable 和历史 CLI 结果不能替代" --- # Windows HBuilderX 进程参数边界 @@ -78,7 +97,7 @@ Windows IDE 每个版本重复两轮完整场景,失败保留现场且不自 ## Windows 实例发现与连接边界 现代 Windows 不一定安装 WMIC。旧实现查询失败直接返回空列表,导致 runner 对已有 IDE 再次执行 `open`。 -现改为 Windows 自带 PowerShell 与进程 API,使用 JSON 传递路径,避免 CSV 丢失逗号、中文或 UNC 路径; +该阶段改为 Windows 自带 PowerShell 与进程 API,使用 JSON 传递路径,避免 CSV 丢失逗号、中文或 UNC 路径; 查询错误与有效空列表分开处理。实际 Windows 回归创建位于中文、空格、逗号和 `&` 目录的临时 `HBuilderX.exe`,确认无 WMIC 时仍能找到同目录 CLI,并只清理测试自身进程。 @@ -237,6 +256,10 @@ alpha 两次真实目录渲染成功后,第二次关闭也空日志、20 秒 该模式只用于诊断,没有改动产品 runner。后续应依据原失败客户端与宿主的实际等待位置继续定位, 不要继续重试已否定的 shell、输出文件或输入句柄包装。 +## 最终状态补充 + +最终 `7623470bd` 的[六组 portable 回归](https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34309551585)全部通过。进程路径传输后续已改用带完整性标识的 Base64 列表,并取消 PowerShell 模块自动加载;上文 JSON 为历史阶段,最终实现及超时证据见[进程探测记录](windows-process-discovery-cold-start.md)。CLI 最后一次 stdin 对照的第一失败阶段仍是成功渲染后的 `project close`:空日志、20 秒超时,其他只读请求正常;具体客户端或 RPC 内部等待位置未知。新的 Windows IDE 连接模式缺少交互桌面验收属于另一条阻塞,不能替代该故障描述。 + ## 规则评估 不新增 AGENTS 规则。现有跨平台参数、真实运行和失败证据要求足够,以可执行回归补齐遗漏。 diff --git a/docs/engineering/lessons/pnpm12-ci-entry-contract.md b/docs/engineering/lessons/pnpm12-ci-entry-contract.md new file mode 100644 index 000000000..f3f4d6be0 --- /dev/null +++ b/docs/engineering/lessons/pnpm12-ci-entry-contract.md @@ -0,0 +1,84 @@ +--- +status: partial +issue: https://github.com/sonofmagic/weapp-tailwindcss/pull/1173 +baseline: 97c8671753fb73e062ff20c2661137c5e45bde01 +regressions: + - packages/weapp-tailwindcss/test/ci/pnpm-command.test.ts + - packages/weapp-tailwindcss/test/ci/lockfile.test.ts + - packages/weapp-tailwindcss/test/ci/workflows.test.ts + - scripts/ci/demo-matrix/pnpm-version.test.mjs + - scripts/ci/demo-matrix/matrix.test.mjs + - e2e/dev-startup-matrix.test.ts +verification: + - claim: pnpm 升级后 Linux 启动器在执行原生入口时失败 + kind: ci + status: failed + sha: 97c8671753fb73e062ff20c2661137c5e45bde01 + environment: GitHub ubuntu-latest,Node 22.23.2,pnpm 12.3.4 + url: https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34337080724/job/102420435474 + - claim: portable demo 在版本前置检查退出,尚未开始构建 + kind: ci + status: failed + sha: 97c8671753fb73e062ff20c2661137c5e45bde01 + environment: GitHub ubuntu-latest,Node 24,pnpm 12.3.4 + url: https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34337080724/job/102420819671 + - claim: 三平台入口选择和真实原生进程的参数与退出码回归通过 + kind: unit + status: passed + sha: 97c8671753fb73e062ff20c2661137c5e45bde01 + environment: macOS,Node 24.18.0,pnpm 12.3.4,基线加本次未提交修复;其他平台入口选择为模拟测试 + command: CI=1 pnpm --filter weapp-tailwindcss exec vitest run test/ci --update=none + - claim: weapp-vite 实际开发进程完成首次启动且保持存活 + kind: integration + status: passed + sha: 97c8671753fb73e062ff20c2661137c5e45bde01 + environment: macOS,Node 24.18.0,pnpm 12.3.4,基线加本次未提交修复 + command: CI=1 E2E_DEV_STARTUP_RUN=1 E2E_DEV_STARTUP_CASE=weapp-vite-tailwindcss-v4 pnpm exec vitest run -c e2e/vitest.e2e.config.ts e2e/dev-startup-matrix.test.ts --update=none + - claim: Taro Vite 小程序生产产物和四轮开发保存通过既有基线 + kind: integration + status: passed + sha: 97c8671753fb73e062ff20c2661137c5e45bde01 + environment: macOS,Node 24.18.0,pnpm 12.3.4,基线加本次未提交修复 + command: CI=1 pnpm e2e:demo:matrix taro-vite-react-tailwindcss-v4:weapp +--- + +# pnpm 12 升级后的 CI 消费链 + +## 症状 + +PR #1173 合入 main 的工具升级后,冻结安装和包构建成功,但 demo 启动检查失败。 +portable runner 在开始构建前比较 `12.3.4` 与硬编码 `11.25.0`;后续无 artifact 是该退出的结果。 +Linux dev startup 最后成功阶段是启动 watch wrapper,第一失败阶段是 Node 读取 pnpm ELF 文件, +报 `SyntaxError: Invalid or unexpected token`。这与原生 HBuilderX CLI 挂起没有因果证据。 + +单测分片还暴露了两个独立问题:锁文件出现多个 YAML 文档导致 `YAML.parse` 失败, +以及 repoctl 已升至 `^5.4.9`,测试仍要求 `^5.4.7`。 + +## 根因与纠正 + +- 版本来源:runner、报告 gate 与报告 fixture 统一读取根 manifest;独立用户复现仍读取自身 manifest,保留其 pnpm 11 版本,不跟随仓库升级。 +- 进程入口:`npm_execpath` 不保证是 JavaScript。共享命令工具区分 JS、原生程序和 Windows 命令脚本;两个 watch wrapper 复用它,原生程序直接执行,保留独立参数与退出码。 +- 锁文件边界:pnpm 12 将包管理器依赖与项目依赖分段保存。测试通过 YAML 文档解析器选出唯一带安装设置和 importers 的项目文档,拒绝解析错误、缺失和重复文档,不按固定段号截取。 +- 生命周期契约:repoctl 回归约束所需能力的最低版本 5.4.7,并保留全部发布命令断言,允许兼容升级。 + +## 验证 + +原生入口回归修复前 5 项失败,修复后通过。覆盖 Windows/macOS/Linux 的 JS 与原生入口、 +空格和中文路径、Windows shim,以及真实原生进程的参数和非零退出码。 +锁文件回归覆盖旧单段、新多段、顺序变化、LF/CRLF、缺失、重复和错误 YAML。 +版本回归覆盖 manifest 路径与 URL、版本升级、预发布、完整性后缀及非法声明;gate 继续拒绝错误版本和不完整证据。 + +本地完成冻结安装、`pnpm build:ci`、CI 目录 132 项、demo matrix 53 项、agents 50 项测试。 +实际 weapp-vite startup 2 项通过,Taro 生产与 initial/replace/add/restore 全部通过,未更新基线。 +原始日志在忽略目录 `e2e/.artifacts/pr-1173-ci/`;通过记录明确包含未提交补丁,不能用于宣称原始 SHA 已通过。 +最终提交和远端运行状态以 PR 的最新验收记录为准。 + +## 适用边界 + +不改变产品公共 API、demo 源码、static 基线、CI 触发条件或原生 IDE 验收范围。 +本地原生进程证据来自 macOS,三系统远端矩阵需对应最新 SHA 的 CI 结果确认。 +HBuilderX CLI 间歇挂起根因仍未知,Windows 交互桌面及 macOS 剩余验收轮次仍未完成。 + +## 规则评估 + +不新增 AGENTS 规则。沿用已有升级消费链检查,以版本读取、原生入口和锁文件解析回归替代重复规则。 diff --git a/docs/engineering/lessons/windows-process-discovery-cold-start.md b/docs/engineering/lessons/windows-process-discovery-cold-start.md index 58452db0a..912b706a1 100644 --- a/docs/engineering/lessons/windows-process-discovery-cold-start.md +++ b/docs/engineering/lessons/windows-process-discovery-cold-start.md @@ -6,6 +6,19 @@ regressions: - packages/hbuilderx-runner/test/discovery.test.ts - packages/hbuilderx-runner/test/discovery-windows.test.ts - packages/hbuilderx-runner/test/host-connection.test.ts +verification: + - claim: "Windows Node 22 的进程探测超过 10 秒期限" + kind: "ci" + status: "failed" + sha: "5ce6db93e80baf1bbaacb6a6fc080ac223c52c15" + environment: "GitHub windows-latest / Node 22" + url: "https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34306912103/job/102325400331" + - claim: "Windows/macOS/Linux × Node 22/24 六组 portable 回归通过;不替代原生桌面验收" + kind: "ci" + status: "passed" + sha: "7623470bddfb3e3ada51cefbdbd6527015867220" + environment: "GitHub hosted Windows、macOS、Linux / Node 22、24" + url: "https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34309551585" --- # Windows 进程探测的冷启动依赖与失败证据 @@ -24,9 +37,11 @@ regressions: 本地执行包内 Vitest、构建和定向 ESLint;真实 Windows 进程用例仍由 Node 22/24 自动矩阵执行。新增截断列表、路径损坏、盘符根目录、相对盘符路径、UNC、BOM、阶段日志与信号回归。macOS 本地通过不替代 Windows 实测。cc6b30f96 的 Windows Node 22/24 真实进程用例分别在 5065/2755 ms 通过,但 host-connection 的另一份 mock 仍返回旧 JSON 协议,导致该 job 失败。本次同步该 fixture,并把信息查询测试改成显式三平台分支;在 macOS 上旧 fixture 的 win32 分支已复现失败,修正后本地完整包测试 58 项通过,2 项平台用例跳过。该遗漏说明依赖 process.platform 的模拟用例不能只运行当前宿主分支。 +最终 `7623470bd` 的[六组 portable 回归](https://github.com/sonofmagic/weapp-tailwindcss/actions/runs/34309551585)全部通过,包括真实 Windows 进程与 API 协议验证。这补齐最终执行结果,不将首次超时的内部阶段升级为已确认根因。 + ## 适用边界 -这是 runner 的操作系统进程查询,不调用 HBuilderX CLI,不证明 CLI 间歇挂起已修复。后续以当前提交的 Windows 自动检查记录实际结果;如再超时,根据阶段证据继续定位。 +这是 runner 的操作系统进程查询,不调用 HBuilderX CLI,不证明 CLI 间歇挂起已修复。本记录的通过结论仅对应上述提交与运行;若再出现超时,仍需根据阶段证据继续定位。 ## 规则评估 diff --git a/packages/weapp-tailwindcss/test/ci/lockfile.test.ts b/packages/weapp-tailwindcss/test/ci/lockfile.test.ts new file mode 100644 index 000000000..524ed4ae9 --- /dev/null +++ b/packages/weapp-tailwindcss/test/ci/lockfile.test.ts @@ -0,0 +1,19 @@ +import { parseWorkspaceLockfile } from './lockfile' + +const workspace = 'lockfileVersion: "9.0"\nsettings: { autoInstallPeers: true }\nimporters: { ".": { devDependencies: { repoctl: { version: 5.4.9 } } } }\n' +const tools = 'lockfileVersion: "9.0"\nimporters: { ".": { packageManagerDependencies: { pnpm: { version: 12.3.4 } } } }\n' + +describe('workspace lockfile documents', () => { + it.each(['\n', '\r\n'])('supports single and multiple documents with %j newlines', (newline) => { + const expected = { repoctl: { version: '5.4.9' } } + for (const source of [workspace, `---\n${tools}---\n${workspace}`, `---\n${workspace}---\n${tools}`]) { + expect(parseWorkspaceLockfile(source.replaceAll('\n', newline)).importers['.'].devDependencies).toEqual(expected) + } + }) + + it('rejects missing, duplicate and malformed documents', () => { + expect(() => parseWorkspaceLockfile(tools)).toThrow('received 0') + expect(() => parseWorkspaceLockfile(`${workspace}---\n${workspace}`)).toThrow('received 2') + expect(() => parseWorkspaceLockfile(`bad: [\n---\n${workspace}`)).toThrow() + }) +}) diff --git a/packages/weapp-tailwindcss/test/ci/lockfile.ts b/packages/weapp-tailwindcss/test/ci/lockfile.ts new file mode 100644 index 000000000..9fb84386e --- /dev/null +++ b/packages/weapp-tailwindcss/test/ci/lockfile.ts @@ -0,0 +1,16 @@ +import YAML from 'yaml' + +export function parseWorkspaceLockfile(source: string): Record { + const documents = YAML.parseAllDocuments(source).map((document) => { + if (document.errors.length) { + throw document.errors[0] + } + return document.toJS() + }) + // pnpm 12 将工具自身依赖与项目依赖分段保存;只读取带项目安装设置的文档。 + const workspaces = documents.filter(document => document?.settings && document?.importers) + if (workspaces.length !== 1) { + throw new Error(`Expected one workspace lockfile document, received ${workspaces.length}`) + } + return workspaces[0] +} diff --git a/packages/weapp-tailwindcss/test/ci/workflows.test.ts b/packages/weapp-tailwindcss/test/ci/workflows.test.ts index f2ad2c8e6..6259f42b4 100644 --- a/packages/weapp-tailwindcss/test/ci/workflows.test.ts +++ b/packages/weapp-tailwindcss/test/ci/workflows.test.ts @@ -1,8 +1,10 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { subset } from 'semver' import { describe, expect, it } from 'vitest' import YAML from 'yaml' +import { parseWorkspaceLockfile } from './lockfile' const dirname = path.dirname(fileURLToPath(import.meta.url)) const repoRoot = path.resolve(dirname, '../../../..') @@ -474,7 +476,7 @@ describe('ci workflows', () => { catalogs?: Record> overrides?: Record } - const lockfile = YAML.parseAllDocuments(readText('pnpm-lock.yaml'))[1]?.toJS() as { + const lockfile = parseWorkspaceLockfile(readText('pnpm-lock.yaml')) as { catalogs?: Record> importers?: Record @@ -804,7 +806,8 @@ describe('ci workflows', () => { expect(packageJson.scripts['pr:rc']).toBe('repo release pre enter rc') expect(packageJson.scripts['pr:next']).toBe('repo release pre enter next') expect(packageJson.scripts['pr:exit']).toBe('repo release pre exit') - expect(packageJson.devDependencies.repoctl).toBe('^5.4.9') + // 5.4.7 提供此处依赖的发布闭环;允许兼容升级,禁止回退到缺少该能力的版本。 + expect(subset(packageJson.devDependencies.repoctl, '>=5.4.7')).toBe(true) expect(packageJson.devDependencies['@changesets/cli']).toBeUndefined() expect(packageJson.devDependencies['@changesets/changelog-github']).toBeUndefined() expect(packageJson.devDependencies['@icebreakers/changelog-github']).toBeUndefined() diff --git a/scripts/agents/check.mjs b/scripts/agents/check.mjs index 6d8f696f9..da39a81e4 100644 --- a/scripts/agents/check.mjs +++ b/scripts/agents/check.mjs @@ -4,6 +4,7 @@ import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' import { parse } from 'yaml' +import { validateVerification } from './verification.mjs' const root = fileURLToPath(new URL('../../', import.meta.url)) const read = file => readFileSync(file, 'utf8') @@ -67,6 +68,9 @@ export function validateLesson(markdown, repoRoot) { if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { return ['复盘 frontmatter 必须是对象'] } + if (Object.hasOwn(metadata, 'verification')) { + errors.push(...validateVerification(metadata.verification)) + } if (!['verified', 'partial', 'superseded'].includes(metadata.status)) { errors.push('复盘 status 无效') } diff --git a/scripts/agents/verification.mjs b/scripts/agents/verification.mjs new file mode 100644 index 000000000..386022218 --- /dev/null +++ b/scripts/agents/verification.mjs @@ -0,0 +1,65 @@ +const fields = new Set(['claim', 'kind', 'status', 'sha', 'environment', 'command', 'url', 'reason']) +const isText = value => typeof value === 'string' && value.trim().length > 0 + +function isHttpsUrl(value) { + if (!isText(value) || !/^https:\/\//i.test(value) || /\s/.test(value)) { + return false + } + try { + const url = new URL(value) + return url.protocol === 'https:' && Boolean(url.hostname) + } + catch { + return false + } +} + +/** 只检查证据记录结构,不执行命令、不查询远端,也不推断结论成立。 */ +export function validateVerification(records) { + if (!Array.isArray(records) || records.length === 0) { + return ['verification 必须是非空记录数组'] + } + const errors = [] + records.forEach((record, index) => { + // 面向文档作者使用从 1 开始的位置,仓库入口再补上文件名。 + const at = `verification[${index + 1}]` + if (!record || typeof record !== 'object' || Array.isArray(record)) { + errors.push(`${at} 必须是对象`) + return + } + for (const key of Object.keys(record)) { + if (!fields.has(key)) { + errors.push(`${at}.${key} 是未知字段`) + } + } + for (const key of ['claim', 'environment']) { + if (!isText(record[key])) { + errors.push(`${at}.${key} 必须是非空字符串`) + } + } + if (!['unit', 'integration', 'ci', 'native'].includes(record.kind)) { + errors.push(`${at}.kind 必须是 unit、integration、ci 或 native`) + } + if (!['passed', 'failed', 'pending'].includes(record.status)) { + errors.push(`${at}.status 必须是 passed、failed 或 pending`) + } + if (typeof record.sha !== 'string' || record.sha.length !== 40 || !/^[a-f\d]{40}$/i.test(record.sha)) { + errors.push(`${at}.sha 必须是完整 SHA`) + } + for (const key of ['command', 'reason']) { + if (Object.hasOwn(record, key) && !isText(record[key])) { + errors.push(`${at}.${key} 必须是非空字符串`) + } + } + if (Object.hasOwn(record, 'url') && !isHttpsUrl(record.url)) { + errors.push(`${at}.url 必须是完整 HTTPS URL`) + } + if (['passed', 'failed'].includes(record.status) && !isText(record.command) && !isHttpsUrl(record.url)) { + errors.push(`${at} 已执行记录必须提供 command 或 url`) + } + if (record.status === 'pending' && !isText(record.reason)) { + errors.push(`${at}.reason 待验收记录必须说明原因`) + } + }) + return errors +} diff --git a/scripts/agents/verification.test.mjs b/scripts/agents/verification.test.mjs new file mode 100644 index 000000000..c8540fbb5 --- /dev/null +++ b/scripts/agents/verification.test.mjs @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { it } from 'vitest' +import { stringify } from 'yaml' +import { checkRepository, validateLesson } from './check.mjs' + +const root = path.resolve(import.meta.dirname, '../..') +const evidence = { + claim: '已有服务连接回归通过', + kind: 'integration', + status: 'passed', + sha: '7623470bddfb3e3ada51cefbdbd6527015867220', + environment: 'macOS / Node 24', + command: 'CI=1 pnpm agents:test --update=none', +} + +function lesson(verification, include = true) { + const metadata = { + status: 'partial', + issue: 'https://github.com/sonofmagic/weapp-tailwindcss/pull/1172', + baseline: evidence.sha, + regressions: ['scripts/agents/check.test.mjs'], + ...(include ? { verification } : {}), + } + return `---\n${stringify(metadata)}---\n${['症状', '根因与纠正', '验证', '适用边界', '规则评估'].map(title => `## ${title}\n记录\n`).join('\n')}` +} + +it('兼容未采用 verification 的历史复盘', () => { + assert.deepEqual(validateLesson(lesson(undefined, false), root), []) +}) + +it.each(['unit', 'integration', 'ci', 'native'])('接受 %s 的已执行证据与失败记录', (kind) => { + const passed = { ...evidence, kind } + const failed = { ...passed, status: 'failed', url: 'https://example.com/actions/runs/123' } + delete failed.command + assert.deepEqual(validateLesson(lesson([passed, failed]), root), []) +}) + +it.each(['\n', '\r\n'])('保留单项通过、失败与原生待验收的独立语义,换行 %j', (newline) => { + const pending = { ...evidence, kind: 'native', status: 'pending', environment: 'Windows 11 / HBuilderX stable', reason: '没有可用交互桌面' } + delete pending.command + const markdown = lesson([evidence, { ...evidence, status: 'failed' }, pending]).replace(/\n/g, newline) + assert.deepEqual(validateLesson(markdown, root), []) +}) + +it.each([null, {}, 'passed', [], [null], [[]], ['evidence']].map(value => [value]))('拒绝非记录数组或空证据:%j', (value) => { + assert.ok(validateLesson(lesson(value), root).some(error => error.includes('verification'))) +}) + +it.each([ + ['claim', undefined], + ['claim', ' '], + ['claim', 1], + ['environment', []], + ['environment', null], + ['environment', ''], + ['kind', 'browser'], + ['kind', null], + ['status', 'skipped'], + ['status', true], + ['sha', 'abc123'], + ['sha', `${'a'.repeat(40)}\n`], + ['sha', 'g'.repeat(40)], + ['sha', 123], + ['sha', ['a'.repeat(40)]], + ['command', 42], + ['command', ' '], + ['url', []], + ['url', 'https://'], + ['url', 'http://example.com'], + ['url', 'file:///evidence'], + ['url', 'https:example.com'], + ['reason', false], + ['reason', ''], + ['unexpected', 'typo'], +])('报告字段 %s 的非法值 %j', (field, value) => { + const record = { ...evidence, [field]: value } + if (value === undefined) { + delete record[field] + } + assert.ok(validateLesson(lesson([record]), root).some(error => error.includes(`verification[1].${field}`))) +}) + +it.each(['passed', 'failed'])('%s 必须提供命令或 HTTPS 来源', (status) => { + const record = { ...evidence, status } + delete record.command + assert.ok(validateLesson(lesson([record]), root).some(error => error.includes('verification[1]') && error.includes('command 或 url'))) +}) + +it('有命令或 URL 也不能省略待验收原因', () => { + assert.ok(validateLesson(lesson([{ ...evidence, status: 'pending' }]), root).some(error => error.includes('verification[1].reason'))) +}) + +it('支持多个来源,同时定位第二条记录的字段错误', () => { + assert.deepEqual(validateLesson(lesson([{ ...evidence, url: 'https://example.com/run' }]), root), []) + const errors = validateLesson(lesson([evidence, { ...evidence, environment: 123 }]), root) + assert.deepEqual(errors, ['verification[2].environment 必须是非空字符串']) +}) + +it('仓库检查给出文件与记录位置,不执行命令、不联网、不改写证据', () => { + const directory = mkdtempSync(path.join(tmpdir(), '证据 & agents-')) + try { + const put = (file, content) => { + const absolute = path.resolve(directory, file) + mkdirSync(path.dirname(absolute), { recursive: true }) + writeFileSync(absolute, content) + } + execFileSync('git', ['init', '--quiet', directory]) + put('package.json', JSON.stringify({ name: 'root', scripts: {} })) + put('pnpm-workspace.yaml', 'packages: []\n') + put('AGENTS.md', '# 根规则\n') + put('docs/engineering/agent-index.md', '[root](../../AGENTS.md)\n') + put('scripts/agents/check.test.mjs', '') + const file = 'docs/engineering/lessons/evidence.md' + const marker = path.join(directory, 'must-not-execute') + const command = `node -e 'require("node:fs").writeFileSync(${JSON.stringify(marker)}, "wrong")'` + const markdown = lesson([{ ...evidence, command, url: 'https://127.0.0.1:1/unreachable' }, { ...evidence, sha: 'bad' }]) + put(file, markdown) + const before = execFileSync('git', ['status', '--porcelain=v1', '-z'], { cwd: directory }) + assert.deepEqual(checkRepository(directory).errors, [`${file}:verification[2].sha 必须是完整 SHA`]) + assert.equal(readFileSync(path.join(directory, file), 'utf8'), markdown) + assert.equal(existsSync(marker), false) + assert.deepEqual(execFileSync('git', ['status', '--porcelain=v1', '-z'], { cwd: directory }), before) + } + finally { + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/scripts/ci/demo-matrix/README.md b/scripts/ci/demo-matrix/README.md index 48398e71c..1c318146c 100644 --- a/scripts/ci/demo-matrix/README.md +++ b/scripts/ci/demo-matrix/README.md @@ -51,4 +51,4 @@ pnpm e2e:demo:matrix issue-uview-plus-cssentries:mp-alipay ## PR Gate -[demo-matrix.yml](../../../.github/workflows/demo-matrix.yml) 在各操作系统冻结安装 pnpm 11.25.0 锁文件并构建当前包。所有目标必须执行成功;最终 gate 对照清单检查每个 OS/Node/目标和全部阶段、提交 SHA、pnpm 版本,不接受缺失、重复、过期或跳过的报告。PR Gate 对启用的矩阵要求 success。CI 证据只对报告中的具体提交有效,本机通过不能替代 Windows/Linux 验收。 +[demo-matrix.yml](../../../.github/workflows/demo-matrix.yml) 在各操作系统使用根 `package.json#packageManager` 指定的 pnpm 版本冻结安装锁文件并构建当前包。所有目标必须执行成功;最终 gate 对照清单检查每个 OS/Node/目标和全部阶段、提交 SHA、pnpm 版本,不接受缺失、重复、过期或跳过的报告。PR Gate 对启用的矩阵要求 success。CI 证据只对报告中的具体提交有效,本机通过不能替代 Windows/Linux 验收。 diff --git a/scripts/ci/demo-matrix/browser-hydration.test.mjs b/scripts/ci/demo-matrix/browser-hydration.test.mjs new file mode 100644 index 000000000..542f3c349 --- /dev/null +++ b/scripts/ci/demo-matrix/browser-hydration.test.mjs @@ -0,0 +1,55 @@ +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { createServer } from 'vite' +import { expect, it } from 'vitest' +import { openBrowser } from './browser.mjs' +import { repo } from './catalog.mjs' + +it('waits for Vue hydration when SSR markup and the update transport are already present', async () => { + const root = await realpath(await mkdtemp(path.join(tmpdir(), 'demo-hydration-'))) + const requireDemo = createRequire(path.join(repo, 'demo/uni-app-vite-tailwindcss-v4/package.json')) + await writeFile(path.join(root, 'entry.js'), ` + import { createSSRApp, h, onMounted } from 'vue' + createSSRApp({ setup() { + onMounted(() => console.log('hydration-complete')) + return () => h('div', { id: 'tw-matrix-height' }, 'ready') + } }).mount('#app') + `) + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + resolve: { alias: { vue: requireDemo.resolve('vue/dist/vue.esm-bundler.js') } }, + server: { host: '127.0.0.1', port: 0, fs: { allow: [root, repo] } }, + plugins: [{ + name: 'delayed-hydration', + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url !== '/') { + return next() + } + res.setHeader('Content-Type', 'text/html') + res.end(` + +
ready
`) + }) + }, + }], + }) + let browser + const deadline = Date.now() + 20_000 + try { + await server.listen() + browser = await openBrowser(server.resolvedUrls.local[0], { + ensureRunning() { expect(Date.now()).toBeLessThan(deadline) }, + }, root, { vueHydration: true }) + expect(browser.events).toContain('log: hydration-complete') + } + finally { + await browser?.close() + await server.close() + await rm(root, { recursive: true, force: true }) + } +}, 30_000) diff --git a/scripts/ci/demo-matrix/browser.mjs b/scripts/ci/demo-matrix/browser.mjs index c593672f0..4d2a89e1f 100644 --- a/scripts/ci/demo-matrix/browser.mjs +++ b/scripts/ci/demo-matrix/browser.mjs @@ -7,7 +7,7 @@ import { inspectStyles } from './output.mjs' import { probeClasses } from './probe.mjs' import { until } from './process.mjs' -export async function openBrowser(url, session, artifactDir) { +export async function openBrowser(url, session, artifactDir, { vueHydration = false } = {}) { const browser = await chromium.launch() const page = await browser.newPage({ viewport: { width: 1200, height: 900 } }) const events = [] @@ -92,6 +92,11 @@ export async function openBrowser(url, session, artifactDir) { } await page.locator('#tw-matrix-height').waitFor({ timeout: 5000 }) assert.equal(pendingModules.size, 0, `Local modules still loading: ${[...pendingModules].map(request => request.url()).join(', ')}`) + if (vueHydration) { + // SSR HTML 可能先于异步页面模块出现;等待探针所属组件挂载,才开始保存源码。 + const mounted = await page.locator('#tw-matrix-height').evaluate(element => element.__vueParentComponent?.isMounted === true) + assert.ok(mounted, 'SSR probe component has not completed Vue hydration') + } }, session) await until(() => assert.ok(transportReady, 'Development update transport is not ready'), session) return { diff --git a/scripts/ci/demo-matrix/gate.mjs b/scripts/ci/demo-matrix/gate.mjs index c6e62587a..2ba57f832 100644 --- a/scripts/ci/demo-matrix/gate.mjs +++ b/scripts/ci/demo-matrix/gate.mjs @@ -4,6 +4,7 @@ import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' import fg from 'fast-glob' +import { readPnpmVersion } from '../../pnpm-version.mjs' import { cases, coverage, matrix, requiredPhases } from './catalog.mjs' const platforms = { 'ubuntu-latest': 'linux', 'windows-latest': 'win32', 'macos-latest': 'darwin' } @@ -13,7 +14,7 @@ export function verifyReports(reports, expectedMatrix, sha) { const actual = new Set() for (const report of reports) { assert.equal(report.sha, sha, 'Report belongs to a different commit') - assert.equal(report.pnpm, '12.3.4') + assert.equal(report.pnpm, readPnpmVersion(), 'pnpm version must match root packageManager') const node = Number(report.node.match(/^v(\d+)/)?.[1]) assert.deepEqual(report.results.map(result => result.id).sort(), [...report.expected].sort()) for (const result of report.results) { diff --git a/scripts/ci/demo-matrix/matrix.test.mjs b/scripts/ci/demo-matrix/matrix.test.mjs index 1e336b663..91713990d 100644 --- a/scripts/ci/demo-matrix/matrix.test.mjs +++ b/scripts/ci/demo-matrix/matrix.test.mjs @@ -5,6 +5,7 @@ import path from 'node:path' import process from 'node:process' import { execa } from 'execa' import { describe, expect, it } from 'vitest' +import { readPnpmVersion } from '../../pnpm-version.mjs' import { authoredClasses, authoredCss } from './authored.mjs' import { cases, checkCatalog, coverage, demos, matrix, repo, requiredPhases } from './catalog.mjs' import { consumedClasses } from './consumption.mjs' @@ -80,7 +81,7 @@ describe('portable demo matrix', () => { for (const [index, job] of matrix().include.entries()) { const report = { sha: head, - pnpm: '12.3.4', + pnpm: readPnpmVersion(), node: `v${job.node}.0.0`, os: platforms[job.os], expected: job.cases, @@ -113,8 +114,9 @@ describe('portable demo matrix', () => { it('fails closed for absent, skipped, duplicate, stale or incomplete evidence', () => { const id = cases[0].id const expected = { include: [{ os: 'windows-latest', node: 24, cases: [id] }] } - const passed = { sha: 'head', pnpm: '12.3.4', node: 'v24.19.0', os: 'win32', expected: [id], results: [{ id, coverage: 'utilities', status: 'passed', rounds: Object.fromEntries(['production', 'initial', 'replace', 'add', 'restore'].map(round => [round, {}])) }] } + const passed = { sha: 'head', pnpm: readPnpmVersion(), node: 'v24.19.0', os: 'win32', expected: [id], results: [{ id, coverage: 'utilities', status: 'passed', rounds: Object.fromEntries(['production', 'initial', 'replace', 'add', 'restore'].map(round => [round, {}])) }] } expect(verifyReports([passed], expected, 'head')).toBe(1) + expect(() => verifyReports([{ ...passed, pnpm: '0.0.0' }], expected, 'head')).toThrow('pnpm version must match root packageManager') expect(() => verifyReports([], expected, 'head')).toThrow() expect(() => verifyReports([passed, passed], expected, 'head')).toThrow('Duplicate') expect(() => verifyReports([passed], expected, 'new-head')).toThrow('different commit') @@ -130,7 +132,7 @@ describe('portable demo matrix', () => { expect(coverage(hybrid)).toBe('webview-build') expect(requiredPhases(hybrid)).toEqual(['production']) expect(requiredPhases(cases.find(item => item.target === 'h5'))).toContain('refresh') - const report = { sha: 'head', pnpm: '12.3.4', node: 'v24.19.0', os: 'win32', expected: [native.id], results: [{ id: native.id, coverage: 'native-build', status: 'passed', rounds: { production: { javascript: true } } }] } + const report = { sha: 'head', pnpm: readPnpmVersion(), node: 'v24.19.0', os: 'win32', expected: [native.id], results: [{ id: native.id, coverage: 'native-build', status: 'passed', rounds: { production: { javascript: true } } }] } const expected = { include: [{ os: 'windows-latest', node: 24, cases: [native.id] }] } expect(verifyReports([report], expected, 'head')).toBe(1) expect(() => verifyReports([{ ...report, results: [{ ...report.results[0], coverage: 'utilities' }] }], expected, 'head')).toThrow('Incorrect coverage') diff --git a/scripts/ci/demo-matrix/pnpm-version.test.mjs b/scripts/ci/demo-matrix/pnpm-version.test.mjs new file mode 100644 index 000000000..d58779b33 --- /dev/null +++ b/scripts/ci/demo-matrix/pnpm-version.test.mjs @@ -0,0 +1,30 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' +import { readPnpmVersion } from '../../pnpm-version.mjs' + +describe('pnpm manifest contract', () => { + it.each(['\n', '\r\n'])('reads the supplied manifest independently of cwd with %j newlines', (newline) => { + const dir = mkdtempSync(path.join(tmpdir(), 'pnpm 版本 & space-')) + const file = path.join(dir, 'package.json') + try { + for (const version of ['11.25.0', '12.3.4', '13.0.0-rc.1']) { + for (const suffix of ['', '+sha512.aabbcc']) { + writeFileSync(file, JSON.stringify({ packageManager: `pnpm@${version}${suffix}` }, null, 2).replaceAll('\n', newline)) + expect(readPnpmVersion(file)).toBe(version) + expect(readPnpmVersion(pathToFileURL(file))).toBe(version) + } + } + for (const packageManager of [undefined, null, 12, 'npm@12.3.4', 'pnpm@latest', 'pnpm@^12.3.4', 'pnpm@12', 'pnpm@12.3.4+sha512.invalid']) { + writeFileSync(file, JSON.stringify({ packageManager })) + expect(() => readPnpmVersion(file)).toThrow(`${file}: packageManager must pin an exact pnpm version`) + } + writeFileSync(file, '{invalid') + expect(() => readPnpmVersion(file)).toThrow() + expect(() => readPnpmVersion(path.join(dir, 'missing.json'))).toThrow() + } + finally { rmSync(dir, { recursive: true, force: true }) } + }) +}) diff --git a/scripts/ci/demo-matrix/rollup-invalidation.test.mjs b/scripts/ci/demo-matrix/rollup-invalidation.test.mjs index 06cb1fb28..cac4043e9 100644 --- a/scripts/ci/demo-matrix/rollup-invalidation.test.mjs +++ b/scripts/ci/demo-matrix/rollup-invalidation.test.mjs @@ -3,104 +3,99 @@ import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import path from 'node:path' import { pathToFileURL } from 'node:url' -import { describe, expect, it } from 'vitest' +import { expect, it } from 'vitest' import { repo } from './catalog.mjs' import { replaceSourceFile } from './source-file.mjs' -describe.each(['uni-app-vite-tailwindcss-v4', 'issue-uview-plus-cssentries'])('%s Rollup', (demo) => { - const demoRequire = createRequire(path.join(repo, 'demo', demo, 'package.json')) - const viteRequire = createRequire(demoRequire.resolve('vite/package.json')) - const rollupDist = path.dirname(viteRequire.resolve('rollup')) +const demoRequire = createRequire(path.join(repo, 'demo/uni-app-vite-tailwindcss-v4/package.json')) +const viteRequire = createRequire(demoRequire.resolve('vite/package.json')) +const rollupDist = path.dirname(viteRequire.resolve('rollup')) - it('deduplicates identical notifications without dropping new file states', async () => { - const { chokidar } = viteRequire(path.join(rollupDist, 'shared/index.js')) - const watcher = chokidar.watch([], { ignoreInitial: true }) - const file = path.join(tmpdir(), 'rollup-change-probe.json') - const events = [] - watcher.on('change', (_, stats) => events.push(stats)) - const initial = { dev: 1, ino: 1, size: 10, mtimeMs: 1, ctimeMs: 1 } - try { - await watcher._emit('change', file, initial) - await watcher._emit('change', file, { ...initial }) - expect(events).toEqual([initial]) - for (const key of ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs']) { - const changed = { ...events.at(-1), [key]: events.at(-1)[key] + 1 } - await watcher._emit('change', file, changed) - expect(events.at(-1)).toEqual(changed) - const count = events.length - await watcher._emit('change', file, { ...changed }) - expect(events).toHaveLength(count) - } - expect(events).toHaveLength(6) +it.each(['uni-app-vite-tailwindcss-v4', 'issue-uview-plus-cssentries'])('deduplicates identical notifications without dropping new file states (%s)', async (demo) => { + const requireDemo = createRequire(path.join(repo, 'demo', demo, 'package.json')) + const requireVite = createRequire(requireDemo.resolve('vite/package.json')) + const { chokidar } = requireVite(path.join(path.dirname(requireVite.resolve('rollup')), 'shared/index.js')) + const watcher = chokidar.watch([], { ignoreInitial: true }) + const file = path.join(tmpdir(), 'rollup-change-probe.json') + const events = [] + watcher.on('change', (_, stats) => events.push(stats)) + const initial = { dev: 1, ino: 1, size: 10, mtimeMs: 1, ctimeMs: 1 } + try { + await watcher._emit('change', file, initial) + await watcher._emit('change', file, { ...initial }) + expect(events).toEqual([initial]) + for (const key of ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs']) { + const changed = { ...events.at(-1), [key]: events.at(-1)[key] + 1 } + await watcher._emit('change', file, changed) + expect(events.at(-1)).toEqual(changed) + const count = events.length + await watcher._emit('change', file, { ...changed }) + expect(events).toHaveLength(count) } - finally { await watcher.close() } - }) + expect(events).toHaveLength(6) + } + finally { await watcher.close() } +}) - it.each(['cjs', 'esm'])('preserves transform invalidation received during an unfinished build (%s)', async (format) => { - const rollup = format === 'cjs' - ? viteRequire('rollup') - : await import(pathToFileURL(path.join(rollupDist, 'es/rollup.js')).href) - const dir = await realpath(await mkdtemp(path.join(tmpdir(), 'rollup-inflight-'))) - const entry = path.join(dir, 'entry.js') - const dataDir = path.join(dir, 'data') - const data = path.join(dataDir, 'value.json') - const output = path.join(dir, 'bundle.mjs') - let reached = false - const release = Promise.withResolvers() - const invalidated = [] - let watcher - let inspection = 0 - try { - await mkdir(dataDir) - await replaceSourceFile(entry, 'export { default } from "virtual:derived"') - await replaceSourceFile(data, '{"value":0}') - watcher = rollup.watch({ - input: entry, - output: { file: output, format: 'es' }, - watch: { - // 该用例验证构建期间的缓存失效;polling 保证两轮状态都能跨过原生事件去重窗口。 - // 原子替换后的原生监听连续性由 rollup-watch.test.mjs 独立覆盖。 - chokidar: { usePolling: true, interval: 10 }, - onInvalidate(id) { invalidated.push(id) }, +it.each(['cjs', 'esm'])('preserves transform invalidation received during an unfinished build (%s)', async (format) => { + const rollup = format === 'cjs' + ? viteRequire('rollup') + : await import(pathToFileURL(path.join(rollupDist, 'es/rollup.js')).href) + const dir = await realpath(await mkdtemp(path.join(tmpdir(), 'rollup-inflight-'))) + const entry = path.join(dir, 'entry.js') + const dataDir = path.join(dir, 'data') + const data = path.join(dataDir, 'value.json') + const output = path.join(dir, 'bundle.mjs') + let reached = false + const release = Promise.withResolvers() + const invalidated = [] + let watcher + let inspection = 0 + try { + await mkdir(dataDir) + await replaceSourceFile(entry, 'export { default } from "virtual:derived"') + await replaceSourceFile(data, '{"value":0}') + watcher = rollup.watch({ + input: entry, + output: { file: output, format: 'es' }, + watch: { onInvalidate(id) { invalidated.push(id) } }, + plugins: [{ + name: 'inflight-transform-dependency', + resolveId(id) { return id === 'virtual:derived' ? id : null }, + load(id) { return id === 'virtual:derived' ? 'export default null' : null }, + async transform(_, id) { + if (id !== 'virtual:derived') { + return + } + this.addWatchFile(data) + const { value } = JSON.parse(await readFile(data, 'utf8')) + if (value === 1) { + reached = true + await release.promise + } + return `export default ${value * 2}` }, - plugins: [{ - name: 'inflight-transform-dependency', - resolveId(id) { return id === 'virtual:derived' ? id : null }, - load(id) { return id === 'virtual:derived' ? 'export default null' : null }, - async transform(_, id) { - if (id !== 'virtual:derived') { - return - } - this.addWatchFile(data) - const { value } = JSON.parse(await readFile(data, 'utf8')) - if (value === 1) { - reached = true - await release.promise - } - return `export default ${value * 2}` - }, - }], - }) - watcher.on('event', event => event.result?.close()) - const inspect = value => expect.poll(async () => { - const module = await import(`${pathToFileURL(output).href}?inspection=${++inspection}`) - return module.default - }, { timeout: 5000, interval: 1 }).toBe(value) - await inspect(0) - await replaceSourceFile(data, '{"value":1}') - await expect.poll(() => reached, { timeout: 5000, interval: 1 }).toBe(true) - const previous = invalidated.length - await replaceSourceFile(data, '{"value":2}') - await expect.poll(() => invalidated.slice(previous), { timeout: 5000, interval: 1 }).toContain(data) - release.resolve() - await inspect(4) - await replaceSourceFile(data, '{"value":3}') - await inspect(6) - } - finally { - release.resolve() - await watcher?.close() - await rm(dir, { recursive: true, force: true }) - } - }, 15_000) -}) + }], + }) + watcher.on('event', event => event.result?.close()) + const inspect = value => expect.poll(async () => { + const module = await import(`${pathToFileURL(output).href}?inspection=${++inspection}`) + return module.default + }, { timeout: 5000, interval: 1 }).toBe(value) + await inspect(0) + await replaceSourceFile(data, '{"value":1}') + await expect.poll(() => reached, { timeout: 5000, interval: 1 }).toBe(true) + const previous = invalidated.length + await replaceSourceFile(data, '{"value":2}') + await expect.poll(() => invalidated.slice(previous), { timeout: 5000, interval: 1 }).toContain(data) + release.resolve() + await inspect(4) + await replaceSourceFile(data, '{"value":3}') + await inspect(6) + } + finally { + release.resolve() + await watcher?.close() + await rm(dir, { recursive: true, force: true }) + } +}, 15_000) diff --git a/scripts/ci/demo-matrix/rollup-watch.test.mjs b/scripts/ci/demo-matrix/rollup-watch.test.mjs index c378d5c67..97ecc3f63 100644 --- a/scripts/ci/demo-matrix/rollup-watch.test.mjs +++ b/scripts/ci/demo-matrix/rollup-watch.test.mjs @@ -7,11 +7,7 @@ import { expect, it } from 'vitest' import { repo } from './catalog.mjs' import { replaceSourceFile } from './source-file.mjs' -const scenarios = ['uni-app-vite-tailwindcss-v4', 'issue-uview-plus-cssentries'].flatMap(demo => - ['cjs', 'esm'].flatMap(format => ['file', 'directory'].map(dependency => ({ demo, format, dependency }))), -) - -it.each(scenarios)('keeps module and transform dependencies live after atomic replacement ($demo, $format, $dependency)', async ({ demo, format, dependency }) => { +it.each(['uni-app-vite-tailwindcss-v4', 'issue-uview-plus-cssentries'].flatMap(demo => ['cjs', 'esm'].flatMap(format => ['file', 'directory'].map(dependency => ({ demo, format, dependency })))))('keeps module and transform dependencies live after atomic replacement ($demo, $format, $dependency)', async ({ demo, format, dependency }) => { const demoRequire = createRequire(path.join(repo, 'demo', demo, 'package.json')) const viteRequire = createRequire(demoRequire.resolve('vite/package.json')) const rollup = format === 'cjs' diff --git a/scripts/ci/demo-matrix/run.mjs b/scripts/ci/demo-matrix/run.mjs index e167ef7fd..588f53555 100644 --- a/scripts/ci/demo-matrix/run.mjs +++ b/scripts/ci/demo-matrix/run.mjs @@ -3,6 +3,7 @@ import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import path from 'node:path' import process from 'node:process' import { execa } from 'execa' +import { readPnpmVersion } from '../../pnpm-version.mjs' import { authoredCss } from './authored.mjs' import { openBrowser } from './browser.mjs' import { cases, checkCatalog, commands, coverage, isWeb, matrix, repo } from './catalog.mjs' @@ -42,7 +43,7 @@ const report = { expected: selected.map(item => item.id), results: [], } -assert.equal(report.pnpm, '12.3.4') +assert.equal(report.pnpm, readPnpmVersion(), 'pnpm version must match root packageManager') await mkdir(artifactRoot, { recursive: true }) let interrupted = false let activeSession @@ -116,7 +117,7 @@ async function runCase(item) { session = start(command.dev, dir, developmentEnvironment(command.env), path.join(artifactDir, 'dev-live.log')) activeSession = session if (isWeb(item) || item.name.startsWith('web/')) { - browser = await openBrowser(`http://127.0.0.1:${port}${item.route ?? '/'}`, session, artifactDir) + browser = await openBrowser(`http://127.0.0.1:${port}${item.route ?? '/'}`, session, artifactDir, { vueHydration: item.family === 'uni' && item.target === 'h5:ssr' }) } for (const round of ['initial', 'replace', 'add', 'restore']) { assert.ok(!interrupted, 'Matrix interrupted') diff --git a/scripts/ci/windows-utilities.mjs b/scripts/ci/windows-utilities.mjs index 392433255..387757d3c 100644 --- a/scripts/ci/windows-utilities.mjs +++ b/scripts/ci/windows-utilities.mjs @@ -7,6 +7,7 @@ import process from 'node:process' import { fileURLToPath } from 'node:url' import { execa } from 'execa' import { parse, stringify } from 'yaml' +import { readPnpmVersion } from '../pnpm-version.mjs' import { packRuntimeDependencies } from './pack-runtime-dependencies.mjs' import { inspectOutput, verifyOutput } from './windows-utilities-output.mjs' @@ -116,7 +117,7 @@ async function verify(label, expectRegression) { try { await cp(fixture, project, { recursive: true }) - assert.equal((await runPnpm(['--version'])).trim(), '11.25.0') + assert.equal((await runPnpm(['--version'])).trim(), readPnpmVersion(path.join(project, 'package.json')), 'pnpm version must match the reproduction fixture') await writeFile(path.join(reportDir, 'published-install.log'), await runPnpm(['install', '--frozen-lockfile'])) await verify('published-5.5.1', process.platform === 'win32') diff --git a/scripts/pnpm-version.mjs b/scripts/pnpm-version.mjs new file mode 100644 index 000000000..08f0f5e55 --- /dev/null +++ b/scripts/pnpm-version.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +export function readPnpmVersion(manifestFile = new URL('../package.json', import.meta.url)) { + const { packageManager } = JSON.parse(readFileSync(manifestFile, 'utf8')) + const version = typeof packageManager === 'string' + ? packageManager.match(/^pnpm@(\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?)(?:\+sha(?:224|256|384|512)\.[a-f\d]+)?$/i)?.[1] + : undefined + assert.ok(version, `${manifestFile}: packageManager must pin an exact pnpm version`) + return version +} diff --git a/scripts/uni-e2e-watch.mjs b/scripts/uni-e2e-watch.mjs index 60b8520ee..c109675d0 100644 --- a/scripts/uni-e2e-watch.mjs +++ b/scripts/uni-e2e-watch.mjs @@ -14,12 +14,12 @@ const uniPlatform = process.env.UNI_E2E_WATCH_PLATFORM || 'mp-weixin' function spawnPnpm(args, options = {}) { const { command, args: commandArgs, shell } = createPnpmCommand(args) return spawn(command, commandArgs, { + shell, cwd: process.cwd(), env: { ...process.env, ...options.env, }, - shell, stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'], }) } diff --git a/scripts/weapp-vite-e2e-watch.mjs b/scripts/weapp-vite-e2e-watch.mjs index 77cdec01a..a774f75a9 100644 --- a/scripts/weapp-vite-e2e-watch.mjs +++ b/scripts/weapp-vite-e2e-watch.mjs @@ -26,9 +26,9 @@ export function resolveWatchPlatform(env = process.env) { function spawnPnpm(args, options = {}) { const { command, args: commandArgs, shell } = createPnpmCommand(args) return spawn(command, commandArgs, { + shell, cwd: process.cwd(), env: process.env, - shell, stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'], }) }