From 960bc940d13cc8e47b0036f85714a2489e52d130 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Wed, 9 Sep 2026 16:04:40 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(agents):=20=E6=A0=A1=E9=AA=8C=E5=A4=8D?= =?UTF-8?q?=E7=9B=98=E7=9A=84=E7=BB=93=E6=9E=84=E5=8C=96=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1172 --- scripts/agents/check.mjs | 4 + scripts/agents/verification.mjs | 65 +++++++++++++ scripts/agents/verification.test.mjs | 131 +++++++++++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 scripts/agents/verification.mjs create mode 100644 scripts/agents/verification.test.mjs 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 }) + } +}) From 3ac9b49cf269a19de21b6ff34106eb7a48ea8894 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Wed, 9 Sep 2026 16:16:39 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs(agents):=20=E6=B2=89=E6=B7=80=E6=8E=92?= =?UTF-8?q?=E9=9A=9C=E8=AF=81=E6=8D=AE=E4=B8=8E=E4=BA=A4=E4=BB=98=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1172 --- .changeset/twelve-aliens-fold.md | 2 +- docs/engineering/agent-workflow.md | 50 ++++++++++++- .../lessons/ai-evidence-delivery.md | 74 +++++++++++++++++++ .../lessons/android-ci-kvm-access.md | 17 ++++- .../lessons/hbuilderx-attached-acceptance.md | 21 +++++- .../hbuilderx-windows-process-boundary.md | 25 ++++++- .../windows-process-discovery-cold-start.md | 17 ++++- 7 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 docs/engineering/lessons/ai-evidence-delivery.md diff --git a/.changeset/twelve-aliens-fold.md b/.changeset/twelve-aliens-fold.md index e86f11fe0..c122795c7 100644 --- a/.changeset/twelve-aliens-fold.md +++ b/.changeset/twelve-aliens-fold.md @@ -4,7 +4,7 @@ 修复 Windows 原生进程参数被 shell 拆分的问题,正确处理带空格和中文的路径、cmd shim 以及可执行文件缺失错误。 -使用 Windows 内置 PowerShell 与进程 API 探测已有 HBuilderX 实例,避免 WMIC 缺失时误判为未启动并重复打开 IDE。通过 JSON 保留中文、逗号和共享路径,探测失败时明确报错。 +使用 Windows 内置 PowerShell 与进程 API 探测已有 HBuilderX 实例,避免 WMIC 缺失时误判为未启动并重复打开 IDE。通过带完整性标识的 Base64 路径列表保留中文、逗号和共享路径,探测失败时明确报错。 显式配置 CLI 时直接通过 listhost 与版本握手绑定实例,不再依赖操作系统进程枚举。只有确认没有 host 才启动一次 IDE;版本不匹配、歧义与命令超时保留为错误,连接过程共享启动截止时间。 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/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/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 间歇挂起已修复。本记录的通过结论仅对应上述提交与运行;若再出现超时,仍需根据阶段证据继续定位。 ## 规则评估 From d17489eca1b038d0fd9b5fbfde69b8d187e4a51a Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Wed, 9 Sep 2026 18:15:55 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(ci):=20=E5=85=BC=E5=AE=B9=20pnpm=2012?= =?UTF-8?q?=20=E5=8E=9F=E7=94=9F=E5=85=A5=E5=8F=A3=E4=B8=8E=E9=94=81?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lessons/pnpm12-ci-entry-contract.md | 84 +++++++++++++++++++ .../test/ci/lockfile.test.ts | 19 +++++ .../weapp-tailwindcss/test/ci/lockfile.ts | 16 ++++ .../test/ci/pnpm-command.test.ts | 71 ++++++++++++---- .../test/ci/workflows.test.ts | 7 +- scripts/ci/demo-matrix/README.md | 2 +- scripts/ci/demo-matrix/gate.mjs | 3 +- scripts/ci/demo-matrix/matrix.test.mjs | 8 +- scripts/ci/demo-matrix/pnpm-version.test.mjs | 30 +++++++ scripts/ci/demo-matrix/run.mjs | 3 +- scripts/ci/windows-utilities.mjs | 3 +- scripts/pnpm-command.mjs | 9 +- scripts/pnpm-version.mjs | 11 +++ scripts/uni-e2e-watch.mjs | 20 +---- scripts/weapp-vite-e2e-watch.mjs | 20 +---- 15 files changed, 245 insertions(+), 61 deletions(-) create mode 100644 docs/engineering/lessons/pnpm12-ci-entry-contract.md create mode 100644 packages/weapp-tailwindcss/test/ci/lockfile.test.ts create mode 100644 packages/weapp-tailwindcss/test/ci/lockfile.ts create mode 100644 scripts/ci/demo-matrix/pnpm-version.test.mjs create mode 100644 scripts/pnpm-version.mjs 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/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/pnpm-command.test.ts b/packages/weapp-tailwindcss/test/ci/pnpm-command.test.ts index 102f33269..aff440144 100644 --- a/packages/weapp-tailwindcss/test/ci/pnpm-command.test.ts +++ b/packages/weapp-tailwindcss/test/ci/pnpm-command.test.ts @@ -1,27 +1,64 @@ +import { spawnSync } from 'node:child_process' +import { copyFileSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' import { createPnpmCommand } from '../../../../scripts/pnpm-command.mjs' describe('pnpm command', () => { - it('reuses the active pnpm cli through node when npm_execpath is available', () => { - expect(createPnpmCommand(['build'], { - platform: 'win32', - execPath: 'C:\\node\\node.exe', - npmExecPath: 'C:\\pnpm\\pnpm.cjs', - })).toEqual({ - command: 'C:\\node\\node.exe', - args: ['C:\\pnpm\\pnpm.cjs', 'build'], - shell: false, + for (const platform of ['win32', 'darwin', 'linux']) { + const paths = platform === 'win32' ? path.win32 : path.posix + const root = platform === 'win32' ? 'C:\\工具 & space' : '/工具 & space' + const execPath = paths.join(root, platform === 'win32' ? 'node.exe' : 'node') + + it.each(['pnpm.cjs', 'pnpm.mjs', 'pnpm.js'])(`runs JavaScript entry %s through Node on ${platform}`, (entry) => { + const npmExecPath = paths.join(root, entry) + expect(createPnpmCommand(['build', '参数 & space'], { platform, execPath, npmExecPath })).toEqual({ + command: execPath, + args: [npmExecPath, 'build', '参数 & space'], + shell: false, + }) + }) + + it(`runs the native pnpm entry directly on ${platform}`, () => { + const npmExecPath = paths.join(root, platform === 'win32' ? 'pnpm.exe' : 'pnpm') + expect(createPnpmCommand(['build'], { platform, execPath, npmExecPath })).toEqual({ + command: npmExecPath, + args: ['build'], + shell: false, + }) }) - }) - it('uses a shell for the Windows cmd fallback', () => { - expect(createPnpmCommand(['build'], { - platform: 'win32', - execPath: 'C:\\node\\node.exe', - npmExecPath: undefined, - })).toEqual({ - command: 'pnpm.cmd', + it(`uses the platform fallback when no active entry exists on ${platform}`, () => { + expect(createPnpmCommand(['build'], { platform, execPath, npmExecPath: undefined })).toEqual({ + command: platform === 'win32' ? 'pnpm.cmd' : 'pnpm', + args: ['build'], + shell: platform === 'win32', + }) + }) + } + + it('uses a shell for an explicit Windows command shim', () => { + expect(createPnpmCommand(['build'], { platform: 'win32', npmExecPath: 'C:\\pnpm\\pnpm.cmd' })).toEqual({ + command: 'C:\\pnpm\\pnpm.cmd', args: ['build'], shell: true, }) }) + + it('executes a real native binary and preserves arguments and exit codes', () => { + const root = mkdtempSync(path.join(tmpdir(), 'pnpm 原生 & space-')) + try { + // Node 本身是原生程序,用同一入口验证二进制不会被当作 JavaScript 读取。 + const npmExecPath = path.join(root, process.platform === 'win32' ? 'pnpm.exe' : 'pnpm') + copyFileSync(process.execPath, npmExecPath) + const args = ['--eval', 'console.log(JSON.stringify(process.argv.slice(1))); process.exit(7)', '参数 & space', 'C:\\path\\file'] + const command = createPnpmCommand(args, { npmExecPath }) + const result = spawnSync(command.command, command.args, { shell: command.shell, encoding: 'utf8' }) + expect(result.error).toBeUndefined() + expect(result.status, result.stderr).toBe(7) + expect(JSON.parse(result.stdout)).toEqual(args.slice(2)) + } + finally { rmSync(root, { recursive: true, force: true }) } + }) }) diff --git a/packages/weapp-tailwindcss/test/ci/workflows.test.ts b/packages/weapp-tailwindcss/test/ci/workflows.test.ts index 1fa0123df..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.parse(readText('pnpm-lock.yaml')) 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.7') + // 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/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/gate.mjs b/scripts/ci/demo-matrix/gate.mjs index 0fea8f9f0..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, '11.25.0') + 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 715097dce..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: '11.25.0', + 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: '11.25.0', 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: '11.25.0', 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/run.mjs b/scripts/ci/demo-matrix/run.mjs index e0392de53..879d4ef7e 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, '11.25.0') +assert.equal(report.pnpm, readPnpmVersion(), 'pnpm version must match root packageManager') await mkdir(artifactRoot, { recursive: true }) let interrupted = false let activeSession 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-command.mjs b/scripts/pnpm-command.mjs index 6cbcc016d..7fd951e6b 100644 --- a/scripts/pnpm-command.mjs +++ b/scripts/pnpm-command.mjs @@ -1,3 +1,4 @@ +import path from 'node:path' import process from 'node:process' export function createPnpmCommand( @@ -11,10 +12,12 @@ export function createPnpmCommand( : process.env.npm_execpath if (npmExecPath) { + const extension = (platform === 'win32' ? path.win32 : path.posix).extname(npmExecPath).toLowerCase() + const javascript = ['.js', '.cjs', '.mjs'].includes(extension) return { - command: execPath, - args: [npmExecPath, ...args], - shell: false, + command: javascript ? execPath : npmExecPath, + args: javascript ? [npmExecPath, ...args] : args, + shell: platform === 'win32' && ['.cmd', '.bat'].includes(extension), } } 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 40174a6d8..e345f453d 100644 --- a/scripts/uni-e2e-watch.mjs +++ b/scripts/uni-e2e-watch.mjs @@ -3,31 +3,19 @@ import { readdir, stat } from 'node:fs/promises' import path from 'node:path' import process from 'node:process' +import { createPnpmCommand } from './pnpm-command.mjs' + const READY_RE = /Build complete|Watching for changes|ready in \d+/i -const pnpmExecPath = process.env.npm_execpath const sourceDirs = ['src'] const ignoredDirs = new Set(['dist', 'node_modules', '.git']) const ignoredFiles = new Set(['auto-imports.d.ts', 'components.d.ts', 'uni-pages.d.ts']) const useNativeWatch = process.env.UNI_E2E_WATCH_NATIVE === '1' const uniPlatform = process.env.UNI_E2E_WATCH_PLATFORM || 'mp-weixin' -function createPnpmCommand(args) { - if (pnpmExecPath) { - return { - command: process.execPath, - args: [pnpmExecPath, ...args], - } - } - - return { - command: process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', - args, - } -} - function spawnPnpm(args, options = {}) { - const { command, args: commandArgs } = createPnpmCommand(args) + const { command, args: commandArgs, shell } = createPnpmCommand(args) return spawn(command, commandArgs, { + shell, cwd: process.cwd(), env: { ...process.env, diff --git a/scripts/weapp-vite-e2e-watch.mjs b/scripts/weapp-vite-e2e-watch.mjs index 5d4c5e2bf..028005142 100644 --- a/scripts/weapp-vite-e2e-watch.mjs +++ b/scripts/weapp-vite-e2e-watch.mjs @@ -4,8 +4,9 @@ import path from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' +import { createPnpmCommand } from './pnpm-command.mjs' + const READY_RE = /开发服务已就绪|dev(?:elopment)? server ready|ready in \d+/i -const pnpmExecPath = process.env.npm_execpath const sourceDirs = ['miniprogram', 'pages', 'packageA', 'packageB', 'sub-normal', 'sub-independent'] const ignoredDirs = new Set(['dist', 'node_modules', '.git']) const rootSourceFileRe = /^(?:app|tailwind\.config(?:\.[\w-]+)?)\.[cm]?[jt]s$|^app\.(?:wxss|css|s[ac]ss|less|json)$/i @@ -23,23 +24,10 @@ export function resolveWatchPlatform(env = process.env) { return platform } -function createPnpmCommand(args) { - if (pnpmExecPath) { - return { - command: process.execPath, - args: [pnpmExecPath, ...args], - } - } - - return { - command: process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', - args, - } -} - function spawnPnpm(args, options = {}) { - const { command, args: commandArgs } = createPnpmCommand(args) + const { command, args: commandArgs, shell } = createPnpmCommand(args) return spawn(command, commandArgs, { + shell, cwd: process.cwd(), env: process.env, stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'], From 6ae6d1a4a016b1cac325458cb816eecfe061cbfe Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Thu, 10 Sep 2026 02:10:58 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(ci):=20=E8=A1=A5=E9=BD=90=20Rollup=20wa?= =?UTF-8?q?tcher=20=E8=A1=A5=E4=B8=81=E5=B9=B6=E7=AD=89=E5=BE=85=20SSR=20h?= =?UTF-8?q?ydration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lessons/demo-watch-patch-hydration.md | 74 ++++++++ patches/rollup@4.63.1.patch | 160 ++++++++++++++++++ pnpm-lock.yaml | 55 +++--- pnpm-workspace.yaml | 1 + .../ci/demo-matrix/browser-hydration.test.mjs | 55 ++++++ scripts/ci/demo-matrix/browser.mjs | 7 +- .../demo-matrix/rollup-invalidation.test.mjs | 6 +- scripts/ci/demo-matrix/rollup-watch.test.mjs | 4 +- scripts/ci/demo-matrix/run.mjs | 2 +- 9 files changed, 331 insertions(+), 33 deletions(-) create mode 100644 docs/engineering/lessons/demo-watch-patch-hydration.md create mode 100644 patches/rollup@4.63.1.patch create mode 100644 scripts/ci/demo-matrix/browser-hydration.test.mjs 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/patches/rollup@4.63.1.patch b/patches/rollup@4.63.1.patch new file mode 100644 index 000000000..d04c42f18 --- /dev/null +++ b/patches/rollup@4.63.1.patch @@ -0,0 +1,160 @@ +diff --git a/dist/es/shared/watch.js b/dist/es/shared/watch.js +--- a/dist/es/shared/watch.js ++++ b/dist/es/shared/watch.js +@@ -9254,2 +9254,9 @@ +- const isThrottled = !this._throttle(EV_CHANGE, path, 50); +- if (isThrottled) return this; ++ // 只合并同一文件状态的重复通知,不丢弃窗口内的新版本。 ++ const previous = this._throttled.get(EV_CHANGE)?.get(path); ++ const stats = val1; ++ if (previous?.stats && stats && ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs'].some(key => previous.stats[key] !== stats[key])) { ++ previous.clear(); ++ } ++ const throttle = this._throttle(EV_CHANGE, path, 50); ++ if (!throttle) return this; ++ throttle.stats = stats; +@@ -9620 +9627 @@ +- this.transformWatchers = new Map(); ++ this.transformDependencies = new Set(); +@@ -9623 +9630 @@ +- this.watcher = this.createWatcher(null); ++ this.watcher = this.createWatcher(); +@@ -9627,3 +9633,0 @@ +- for (const watcher of this.transformWatchers.values()) { +- watcher.close(); +- } +@@ -9633,5 +9637 @@ +- const transformWatcher = this.transformWatchers.get(id); +- if (transformWatcher) { +- this.transformWatchers.delete(id); +- transformWatcher.close(); +- } ++ this.transformDependencies.delete(id); +@@ -9641,3 +9641 @@ +- const watcher = this.transformWatchers.get(id) ?? this.createWatcher(id); +- watcher.add(id); +- this.transformWatchers.set(id, watcher); ++ this.transformDependencies.add(id); +@@ -9645,3 +9643 @@ +- else { +- this.watcher.add(id); +- } ++ this.watcher.add(id); +@@ -9649 +9645 @@ +- createWatcher(transformWatcherId) { ++ createWatcher() { +@@ -9653 +9648,0 @@ +- const isTransformDependency = transformWatcherId !== null; +@@ -9655 +9649,0 @@ +- const changedId = transformWatcherId || id; +@@ -9660,2 +9654,2 @@ +- watcher.unwatch(changedId); +- watcher.add(changedId); ++ watcher.unwatch(id); ++ watcher.add(id); +@@ -9663 +9657,7 @@ +- task.invalidate(changedId, { event, isTransformDependency }); ++ task.invalidate(id, { event, isTransformDependency: this.transformDependencies.has(id) }); ++ for (const dependency of this.transformDependencies) { ++ const relative = path.relative(dependency, id); ++ if (relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) { ++ task.invalidate(dependency, { event, isTransformDependency: true }); ++ } ++ } +@@ -9787,0 +9788 @@ ++ this.invalidatedTransformDependencies = new Set(); +@@ -9812,0 +9814 @@ ++ this.invalidatedTransformDependencies.add(id); +@@ -9826,0 +9829,2 @@ ++ // 新一轮消费现有缓存;本轮构建期间到达的失效必须保留给下一轮。 ++ this.invalidatedTransformDependencies.clear(); +@@ -9881,0 +9886,6 @@ ++ // 新缓存不能覆盖构建期间对旧缓存标记的失效。 ++ for (const module of this.cache.modules) { ++ if (module.transformDependencies.some(id => this.invalidatedTransformDependencies.has(id))) { ++ module.originalCode = null; ++ } ++ } +diff --git a/dist/shared/watch.js b/dist/shared/watch.js +--- a/dist/shared/watch.js ++++ b/dist/shared/watch.js +@@ -33 +33 @@ +- this.transformWatchers = new Map(); ++ this.transformDependencies = new Set(); +@@ -36 +36 @@ +- this.watcher = this.createWatcher(null); ++ this.watcher = this.createWatcher(); +@@ -40,3 +39,0 @@ +- for (const watcher of this.transformWatchers.values()) { +- watcher.close(); +- } +@@ -46,5 +43 @@ +- const transformWatcher = this.transformWatchers.get(id); +- if (transformWatcher) { +- this.transformWatchers.delete(id); +- transformWatcher.close(); +- } ++ this.transformDependencies.delete(id); +@@ -54,9 +47,5 @@ +- const watcher = this.transformWatchers.get(id) ?? this.createWatcher(id); +- watcher.add(id); +- this.transformWatchers.set(id, watcher); +- } +- else { +- this.watcher.add(id); +- } +- } +- createWatcher(transformWatcherId) { ++ this.transformDependencies.add(id); ++ } ++ this.watcher.add(id); ++ } ++ createWatcher() { +@@ -66 +54,0 @@ +- const isTransformDependency = transformWatcherId !== null; +@@ -68 +55,0 @@ +- const changedId = transformWatcherId || id; +@@ -73,4 +60,10 @@ +- watcher.unwatch(changedId); +- watcher.add(changedId); +- } +- task.invalidate(changedId, { event, isTransformDependency }); ++ watcher.unwatch(id); ++ watcher.add(id); ++ } ++ task.invalidate(id, { event, isTransformDependency: this.transformDependencies.has(id) }); ++ for (const dependency of this.transformDependencies) { ++ const relative = path.relative(dependency, id); ++ if (relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) { ++ task.invalidate(dependency, { event, isTransformDependency: true }); ++ } ++ } +@@ -200,0 +194 @@ ++ this.invalidatedTransformDependencies = new Set(); +@@ -225,0 +220 @@ ++ this.invalidatedTransformDependencies.add(id); +@@ -239,0 +235,2 @@ ++ // 新一轮消费现有缓存;本轮构建期间到达的失效必须保留给下一轮。 ++ this.invalidatedTransformDependencies.clear(); +@@ -294,0 +292,6 @@ ++ // 新缓存不能覆盖构建期间对旧缓存标记的失效。 ++ for (const module of this.cache.modules) { ++ if (module.transformDependencies.some(id => this.invalidatedTransformDependencies.has(id))) { ++ module.originalCode = null; ++ } ++ } +diff --git a/dist/shared/index.js b/dist/shared/index.js +--- a/dist/shared/index.js ++++ b/dist/shared/index.js +@@ -9250,2 +9250,9 @@ +- const isThrottled = !this._throttle(EV_CHANGE, path, 50); +- if (isThrottled) return this; ++ // 只合并同一文件状态的重复通知,不丢弃窗口内的新版本。 ++ const previous = this._throttled.get(EV_CHANGE)?.get(path); ++ const stats = val1; ++ if (previous?.stats && stats && ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs'].some(key => previous.stats[key] !== stats[key])) { ++ previous.clear(); ++ } ++ const throttle = this._throttle(EV_CHANGE, path, 50); ++ if (!throttle) return this; ++ throttle.stats = stats; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dac8d552a..d377b6c12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -727,6 +727,7 @@ pnpmfileChecksum: sha256-VIhHxkE3DlYGW4jpmh5YBZoirXpyRMuq7rJOkofCfW8= patchedDependencies: rollup@4.63.0: 4f732dcaad5986ffbe82caa0236c5ece50738873705cb399aa2f0a4dd15ed375 + rollup@4.63.1: 160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0 importers: @@ -1100,7 +1101,7 @@ importers: version: 6.1.3 rollup: specifier: ^4.63.0 - version: 4.63.1 + version: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) sass: specifier: catalog:sass195 version: 1.104.0 @@ -48967,7 +48968,7 @@ snapshots: '@rollup/plugin-alias@6.0.0(rollup@4.63.1)': optionalDependencies: - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7(supports-color@10.2.2))(@types/babel__core@7.20.5)(rollup@4.63.1)(supports-color@10.2.2)': dependencies: @@ -48976,7 +48977,7 @@ snapshots: '@rollup/pluginutils': 5.4.0(rollup@4.63.1) optionalDependencies: '@types/babel__core': 7.20.5 - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) transitivePeerDependencies: - supports-color @@ -48989,7 +48990,7 @@ snapshots: is-reference: 1.2.1 magic-string: 0.30.21 optionalDependencies: - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) '@rollup/plugin-commonjs@29.0.3(rollup@4.63.1)': dependencies: @@ -49001,7 +49002,7 @@ snapshots: magic-string: 0.30.21 picomatch: 4.0.7 optionalDependencies: - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) '@rollup/plugin-inject@5.0.5(rollup@4.63.1)': dependencies: @@ -49009,13 +49010,13 @@ snapshots: estree-walker: 2.0.2 magic-string: 0.30.21 optionalDependencies: - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) '@rollup/plugin-json@6.1.0(rollup@4.63.1)': dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.63.1) optionalDependencies: - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) '@rollup/plugin-node-resolve@15.3.1(rollup@4.63.1)': dependencies: @@ -49025,7 +49026,7 @@ snapshots: is-module: 1.0.0 resolve: 1.22.12 optionalDependencies: - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) '@rollup/plugin-node-resolve@16.0.3(rollup@4.63.1)': dependencies: @@ -49035,21 +49036,21 @@ snapshots: is-module: 1.0.0 resolve: 1.22.12 optionalDependencies: - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) '@rollup/plugin-replace@5.0.7(rollup@4.63.1)': dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.63.1) magic-string: 0.30.21 optionalDependencies: - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) '@rollup/plugin-replace@6.0.3(rollup@4.63.1)': dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.63.1) magic-string: 0.30.21 optionalDependencies: - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) '@rollup/plugin-terser@0.4.4(rollup@4.63.1)': dependencies: @@ -49057,7 +49058,7 @@ snapshots: smob: 1.5.0 terser: 5.51.2 optionalDependencies: - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) '@rollup/plugin-terser@1.0.0(rollup@4.63.1)': dependencies: @@ -49065,7 +49066,7 @@ snapshots: smob: 1.6.2 terser: 5.51.2 optionalDependencies: - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) '@rollup/pluginutils@5.1.0(rollup@4.14.3)': dependencies: @@ -49081,7 +49082,7 @@ snapshots: estree-walker: 2.0.2 picomatch: 2.3.2 optionalDependencies: - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) '@rollup/pluginutils@5.4.0(rollup@4.63.1)': dependencies: @@ -49089,7 +49090,7 @@ snapshots: estree-walker: 2.0.2 picomatch: 4.0.7 optionalDependencies: - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) '@rollup/rollup-android-arm-eabi@4.14.3': optional: true @@ -51885,7 +51886,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 html-webpack-plugin: 5.6.8(@rspack/core@1.7.11(@swc/helpers@0.5.23))(webpack@5.105.4) - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) vue: 3.5.42(typescript@6.0.3) webpack: 5.105.4(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.10(postcss@8.5.28))(csso@5.0.5)(esbuild@0.21.5)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(uglify-js@3.19.3)(webpack-cli@7.2.3) webpack-chain: 6.5.1 @@ -51903,7 +51904,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 html-webpack-plugin: 5.6.8(@rspack/core@2.2.2(@swc/helpers@0.5.23))(webpack@5.105.4) - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) vue: 3.5.42(typescript@6.0.3) webpack: 5.105.4(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.10(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(uglify-js@3.19.3)(webpack-cli@7.2.3) webpack-chain: 6.5.1 @@ -66900,7 +66901,7 @@ snapshots: pkg-types: 2.3.3 pretty-bytes: 7.1.2 radix3: 1.1.2 - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) rollup-plugin-visualizer: 7.1.1(rolldown@1.2.7)(rollup@4.63.1) scule: 1.3.0 semver: 7.8.5 @@ -71457,7 +71458,7 @@ snapshots: yargs: 18.1.0 optionalDependencies: rolldown: 1.2.7 - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) rollup-pluginutils@2.8.2: dependencies: @@ -71525,7 +71526,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.63.0 fsevents: 2.3.3 - rollup@4.63.1: + rollup@4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0): dependencies: '@types/estree': 1.0.9 optionalDependencies: @@ -74547,7 +74548,7 @@ snapshots: '@rspack/core': 2.2.2(@swc/helpers@0.5.23) esbuild: 0.28.2 rolldown: 1.2.7 - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) vite: 7.3.6(@types/node@26.5.0)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.33.0)(sass-embedded@1.104.0)(sass@1.104.0)(stylus@0.63.0(supports-color@10.2.2))(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) webpack: 5.105.4(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.10(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(uglify-js@3.19.3)(webpack-cli@7.2.3) optional: true @@ -74561,7 +74562,7 @@ snapshots: '@rspack/core': 2.2.3(@swc/helpers@0.5.23) esbuild: 0.28.2 rolldown: 1.2.7 - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) vite: 7.3.6(@types/node@26.5.0)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.33.0)(sass-embedded@1.104.0)(sass@1.104.0)(stylus@0.63.0(supports-color@10.2.2))(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) webpack: 5.105.4(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.10(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(uglify-js@3.19.3)(webpack-cli@7.2.3) @@ -74574,7 +74575,7 @@ snapshots: '@rspack/core': 2.2.3(@swc/helpers@0.5.23) esbuild: 0.28.2 rolldown: 1.2.7 - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) vite: 8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.104.0)(sass@1.104.0)(stylus@0.63.0(supports-color@10.2.2))(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) webpack: 5.105.4(@swc/core@1.16.2(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.10(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.28)(uglify-js@3.19.3)(webpack-cli@7.2.3) @@ -75112,7 +75113,7 @@ snapshots: dependencies: esbuild: 0.21.5 postcss: 8.5.28 - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) optionalDependencies: '@types/node': 26.5.0 fsevents: 2.3.3 @@ -75129,7 +75130,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 postcss: 8.5.28 - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.5.0 @@ -75171,7 +75172,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.28 - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) tinyglobby: 0.2.15 optionalDependencies: '@types/node': 26.5.0 @@ -75192,7 +75193,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.28 - rollup: 4.63.1 + rollup: 4.63.1(patch_hash=160d2781493503218c8f97a2da6b816fa2f8d96b6f5b10c07910753c0e7e02f0) tinyglobby: 0.2.15 optionalDependencies: '@types/node': 26.5.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8f8dbdce6..a73feb642 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -390,3 +390,4 @@ catalogs: patchedDependencies: rollup@4.63.0: patches/rollup@4.63.0.patch + rollup@4.63.1: patches/rollup@4.63.1.patch 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/rollup-invalidation.test.mjs b/scripts/ci/demo-matrix/rollup-invalidation.test.mjs index 9c51cc684..cac4043e9 100644 --- a/scripts/ci/demo-matrix/rollup-invalidation.test.mjs +++ b/scripts/ci/demo-matrix/rollup-invalidation.test.mjs @@ -11,8 +11,10 @@ const demoRequire = createRequire(path.join(repo, 'demo/uni-app-vite-tailwindcss 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')) +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 = [] diff --git a/scripts/ci/demo-matrix/rollup-watch.test.mjs b/scripts/ci/demo-matrix/rollup-watch.test.mjs index cee996fbf..97ecc3f63 100644 --- a/scripts/ci/demo-matrix/rollup-watch.test.mjs +++ b/scripts/ci/demo-matrix/rollup-watch.test.mjs @@ -7,8 +7,8 @@ import { expect, it } from 'vitest' import { repo } from './catalog.mjs' import { replaceSourceFile } from './source-file.mjs' -it.each(['cjs', 'esm'].flatMap(format => ['file', 'directory'].map(dependency => ({ format, dependency }))))('keeps module and transform dependencies live after atomic replacement ($format, $dependency)', async ({ format, dependency }) => { - const demoRequire = createRequire(path.join(repo, 'demo/uni-app-vite-tailwindcss-v4/package.json')) +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' ? viteRequire('rollup') diff --git a/scripts/ci/demo-matrix/run.mjs b/scripts/ci/demo-matrix/run.mjs index 879d4ef7e..588f53555 100644 --- a/scripts/ci/demo-matrix/run.mjs +++ b/scripts/ci/demo-matrix/run.mjs @@ -117,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')