From d2530cf3b5cd160c8403b342a4b5f9d039456dde Mon Sep 17 00:00:00 2001 From: "xiaohan.yuan" Date: Wed, 21 Oct 2026 12:49:52 +0800 Subject: [PATCH] docs(B02): specify installed recommendation styling and footer removal --- docs/协作/B02-需求.md | 83 ++++ .../B02-01/installed-recommendation-list.png | Bin 0 -> 39838 bytes .../协作/evidence/B02-01/installed-state.json | 16 + .../B02-01/rea-extract-limitation.json | 1 + .../evidence/B02-01/rea-summary-record.json | 419 ++++++++++++++++++ .../B02-01/skills-installed-probe.mjs | 386 ++++++++++++++++ docs/协作/开发与验收协作流程.md | 2 + 7 files changed, 907 insertions(+) create mode 100644 docs/协作/B02-需求.md create mode 100644 docs/协作/evidence/B02-01/installed-recommendation-list.png create mode 100644 docs/协作/evidence/B02-01/installed-state.json create mode 100644 docs/协作/evidence/B02-01/rea-extract-limitation.json create mode 100644 docs/协作/evidence/B02-01/rea-summary-record.json create mode 100644 docs/协作/evidence/B02-01/skills-installed-probe.mjs diff --git a/docs/协作/B02-需求.md b/docs/协作/B02-需求.md new file mode 100644 index 0000000..f11b72c --- /dev/null +++ b/docs/协作/B02-需求.md @@ -0,0 +1,83 @@ +# B02:推荐技能的已安装样式与重复入口 + +日期:2026-10-21(Asia/Shanghai)。状态:**待开发**。用户新增需求,不改变 B01 的范围、交付或验收结论。 + +用户要求:“专家技能连接器这块的技能,已经安装的在推荐中的样式也不对,还保留有添加的按钮,参考一下 WorkBuddy。还有下方也不应该有查看已安装技能,这个要移除掉。” + +## 1. 已确认问题与复现 + +接收时 `gitea/master` 为 `ddd677bae3c43a5514eb40f7a1d9749fdf389115`。独立窗口使用既有固定代码 `51531dcae8d8ade85db9e203bc8c0f712805b2ae`,对应推荐技能组件、样式、CardFrame 与测试从该提交到接收版本无改动。 + +Windows Electron 加载真实 `CapabilityCenter`、推荐技能组件和 CSS,使用受控 IPC、临时 userData 与模拟安装结果,不下载网络包或改用户技能。安装后关闭详情,推荐列表实测: + +```text +data-recommendation-state = current +卡片 class = sm-card,没有 sm-card--installed +添加按钮仍存在,disabled=true,按钮中仍有 Plus SVG +原动作槽位没有 sm-use-btn +卡片下方另有“去使用”文字动作 +列表底部存在“查看已安装技能”按钮 +renderer errors = [] +``` + +所以不是“完全没有识别到已安装”,而是**识别成功后,卡片仍渲染添加动作与未安装壳**。不能把仅禁用加号当作完成样式修复。 + +源码定位: + +- `src/renderer/src/capabilities/RecommendedSkills.tsx:259`:`Card` 没有传 installed 身份;actions 无条件创建 `.sm-add-btn` 和 `PlusIcon`,current 状态只影响 disabled。 +- `RecommendedSkills.tsx:263`:独立 footer 按钮“查看已安装技能”。 +- `RecommendedSkills.tsx:247`:目录不可用分支也有同名入口,需与正常列表的移除要求一并检查,不要留下另一处无依据的底部重复入口。 +- `src/renderer/src/capabilities/SkillsPage.tsx:103`:推荐页当前的 installed 导航 callback;顶部已有“已安装(n)”入口,应保留这一条正常导航。 +- `scripts/test-recommended-skills-ui.mjs:232`:旧测试要求 `.sm-add-btn.disabled === true`;`:241` 附近依赖 footer 按钮进入已安装。这两处把当前错误设计写进了测试,需同步修正。 + +证据:[实测状态](evidence/B02-01/installed-state.json)、[推荐列表截图](evidence/B02-01/installed-recommendation-list.png)、[只读窗口探针](evidence/B02-01/skills-installed-probe.mjs)。 + +## 2. WorkBuddy 依据与未验证边界 + +可复用的已有 REA 恢复记录: + +- `src/renderer/src/capabilities/skills-source-manifest.json` 标注 WorkBuddy **5.8.2**,原包 SHA-256 `035254bc76310ea46317433e4840602094543ef323d2d5c5aff7c131729de918`。 +- Evidence:JavaScript `ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb`,技能市场相关 `ev_e8faf60b3d22b99ede302f2d0efdfb3145f53f59789d4a345f5892dc13a589a4`,CSS `ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94`。 +- 该恢复链在项目中已有 `CardFrame.installed`/`sm-card--installed`、`UseSkillIcon` 与 `.sm-use-btn`。已恢复 CSS 的使用动作是 24×24px、默认透明背景、16px SVG;与未安装 `.sm-add-btn` 的 24×24px 灰底、12px SVG 不同。 +- [技能详情安装与使用链路修正](../技能详情安装与使用链路修正-2026-10-20.md) 第原版边界部分记录:历史原版 ActionRow 随安装状态切换安装/使用,成功提示带使用动作。不要把“+保留但禁用”当作原版已安装设计。 + +**本次当前版本并未完整核验**: + +- 本轮仅通过 REA 静态读取当前安装包,没有用直接 CDP、手写提取器、computer-use 或其他通道绕过。 +- 当前 ASAR SHA-256 为 `026a1d0805a2366d06390cef52e157a7277543cdd27dd308e6b1acb47ffffdc1`,版本号未读取确认,不能继续写成 5.8.2/5.8.3。 +- REA 父 Evidence `ev_ef70f1417c069be6bf352efcb858244efaae444f71f1f9893ed2fb579ed88849`,摘要 Evidence `ev_4d76ad1b9f386f97343307067ee2f5b5b91d2189366ab1cbe3890954db4634a6`。本次选择的源文件是 `renderer/assets/src-DbE1_mgM.js`,不是当前完整技能市场组件;存在完整性矛盾、未选文件和语义覆盖上限,**不据此对当前技能市场样式作结论**。 +- REA 整包提取又因缺失声明的 Darwin companion `cli/node_modules/koffi/build/koffi/darwin_arm64/koffi.node` 失败。没有绕过改用其他提取器,也没有启动 WorkBuddy 去安装测试技能。 +- [完整摘要记录](evidence/B02-01/rea-summary-record.json)、[REA 提取限制](evidence/B02-01/rea-extract-limitation.json) 已提交。父 Evidence 的大型 bundle 仅保留于验收端临时证据,不把其路径当作跨机交接的唯一依据。 + +开发可依据已有恢复组件和用户明确要求先修确认问题;当前原版推荐卡是否另有状态图标、精确操作位置及复杂安装态细节,须后续通过 REA 补同状态证据,未拿到不能宣称完整 1:1。 + +## 3. 开发任务与验收标准 + +### YC-005 / P2:推荐卡片切换真实已安装状态 + +允许边界:`RecommendedSkills.tsx`、`SkillsPage.tsx`、推荐局部 CSS/既有卡片适配、相关 UI 回归。原版恢复组件本体、共享卡片与技能身份模型如需变更,由开发主对话统一审查;不重写安装下载后端或扩展市场服务。 + +- **AC5.1**:同推荐身份、同来源版本的 current 安装项,卡片不再渲染添加 Plus 按钮,不仅是禁用或透明隐藏;使用与已安装语义一致的卡片状态和动作。辅助技术标签也不能继续叫“安装某技能”。 +- **AC5.2**:按既有 REA 恢复记录实现原版的已安装/使用展示,核对布局、图标槽位、字体、默认/hover/focus 与浅深色;不要把文字“已安装”加在角落就认定完成。 +- **AC5.3**:未安装项仍可添加并显示真实安装进度;安装成功后自动切换,关闭/重开页面及外部安装/卸载通知后状态同步,不要求重启。 +- **AC5.4**:禁用、不可调用、损坏、同名本地、版本不同、本地修改、工作区技能等状态仍明确区分。不要只按同名或当前标签把不同来源/scope 误认为同一个已安装技能;保留已有保护工作区和本地修改的规则。 +- **AC5.5**:版本不同/本地修改等确有替换需求时,用清楚的更新/替换动作及既有确认流程,不伪装成首次添加,也不直接覆盖。正常 current 状态不发重复安装请求。 +- **AC5.6**:使用动作保留既有进入新任务草稿、不自动发送的语义;禁用技能先启用及失败反馈保持。原动作槽位与卡片下方不要同时留下无依据的重复使用按钮;按原版证据确定位置。 + +### YC-006 / P2:移除底部“查看已安装技能” + +- **AC6.1**:正常推荐列表底部不再渲染 `.yc-skill-recommendation-footer` 及“查看已安装技能”按钮,清理只服务于该入口的空白、样式和 props;不能只是 CSS 隐藏。 +- **AC6.2**:保留顶部“已安装(n)”导航及真实计数,进入已安装、返回推荐、搜索与目录刷新仍正常。 +- **AC6.3**:复核目录不可用/空状态中的重复同名入口;保持清楚的错误与恢复方式,使用现有顶部导航,不为删除正常 footer 而留下另一个底部重复入口。未使用的 callback 清理要核对所有分支与调用方。 +- **AC6.4**:测试通过顶部现有入口进入已安装,新增断言 footer 不存在;不允许为旧测试便利保留用户明确要求移除的按钮。 + +## 4. 验证与交付 + +- 必跑 `npm run test:recommended-skills:ui`、相关能力输入/技能管理回归,以及 `npm run build`。源码/class 检查不能替代实际已安装推荐卡的计算样式、截图与交互。 +- 受控 Electron fixture 覆盖未安装→安装→current、禁用/损坏、不同来源与版本、本地修改、卸载/重装及目录错误;不得使用用户真实技能或为验样式安装 WorkBuddy 技能。 +- 对已有 UI 脚本中的“禁用 Plus 即通过”和 footer 导航断言改成真实目标,保留安装确认、进度、取消、重复请求、外部通知、使用草稿等有效回归。 +- Windows 浅/深、窄窗下记录相同状态的 DOM、计算样式和截图;同版本 WorkBuddy 对照尚缺则明确“未验证”,不将本地恢复样式称为像素通过。 +- 本次复现探针在固定受测检出运行:独立安装依赖后,将 Git 中的 `skills-installed-probe.mjs` 复制为 `.tmp/b01-acceptance/skills-installed-probe.mjs` 再执行。该路径用于其自身 Electron 子进程启动;脚本只用受控宿主,不真实安装。 +- 创建 `docs/协作/B02-交付-01.md` 接单、记录完整代码 SHA、逐项 AC、命令及证据。不要预填验收通过;不把此项混入 B01/YC-001 的修复完成声明。 + +本次只发布新需求和证据,**未修改产品代码**。文档推送成功后窗口交回开发端,用户可将 B02 与 B01 的后续安排一并交给开发对话。 diff --git a/docs/协作/evidence/B02-01/installed-recommendation-list.png b/docs/协作/evidence/B02-01/installed-recommendation-list.png new file mode 100644 index 0000000000000000000000000000000000000000..a3f0f3288fc809cc26bab3072e44d246d4ea5185 GIT binary patch literal 39838 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 t)xZn|W+XHtp&1FyNN7evGZOx9B;53vQawN child.kill(), 120000) + process.exitCode = await new Promise(resolve => child.on('exit', code => { clearTimeout(timer); resolve(code ?? 1) })) +} else { + void (async () => { + const { app, BrowserWindow, ipcMain } = electron + const output = path.resolve(process.argv.at(-1)), relative = path.relative(base, output) + assert.ok(relative.startsWith('recommended-skills-ui-') && !relative.includes('..') && !path.isAbsolute(relative)) + const userData = path.join(output, 'data') + fs.mkdirSync(userData, { recursive: true }) + app.setPath('userData', userData) + const checks = [], errors = [], calls = [], screenshots = [] + + const recommendation = { + id: 'archify', + name: 'Archify', + description: '生成可交互的架构图、流程图和一页式说明', + author: 'tt-a1i', + license: 'MIT', + repository: 'tt-a1i/archify', + version: '3.2.0', + commit: '25cf37080fc8b1dbe758a4efc3935b55f3327083', + packagePath: 'archify.zip', + sha256: '70e210265c59f24dcbdf2a502ff671104b5454e7ee7d3554aecd86bad32384f5', + expectedBytes: 5271959, + containsScripts: true + } + const localSkill = { + id: 'user:local', name: 'Archify', description: '本地导入的同名技能', version: '1.0.0', + path: 'C:/local/archify', scope: 'user', enabled: true, userInvocable: true, + disableModelInvocation: false, source: 'imported', userModified: true + } + const installedRecommendation = { + id: 'user:rec', name: 'Archify', description: recommendation.description, version: '3.2.0', + path: 'C:/managed/archify', scope: 'user', enabled: true, userInvocable: true, + disableModelInvocation: false, source: 'imported', userModified: false, + origin: { + recommendationId: 'archify', repository: recommendation.repository, commit: recommendation.commit, + packagePath: recommendation.packagePath, archiveSha256: recommendation.sha256, version: '3.2.0', installedAt: 1 + } + } + + let installed = [], progress = null, failNext = false, cancelRequested = false, delay = 0, recommendationOverride = null, recommendationFails = false + let clock = 0 + const listeners = new Set(), changedListeners = new Set() + const emit = state => { + // The host stamps a monotonic time; a strict comparison rejects a stale frame. + const stamped = { ...state, updatedAt: ++clock } + progress = stamped + for (const listener of listeners) listener(stamped) + for (const win of BrowserWindow.getAllWindows()) if (!win.isDestroyed()) win.webContents.send('skills:install-progress', stamped) + } + const changed = () => { + for (const listener of changedListeners) listener() + for (const win of BrowserWindow.getAllWindows()) if (!win.isDestroyed()) win.webContents.send('skills:changed') + } + + ipcMain.handle('skills-qa', async (_event, method, args) => { + calls.push([method, ...args]) + if (method === 'recommendations') { + if (recommendationFails) throw Error('推荐目录服务不可用') + return recommendationOverride ?? [recommendation] + } + if (method === 'list') return installed + if (method === 'setEnabled') { installed = installed.map(skill => skill.id === args[0] ? { ...skill, enabled: args[1] } : skill); changed(); return } + if (method === 'installState') return progress + // The host only accepts the cancel request; the terminal frame still comes from the install flow. + if (method === 'cancelInstall') { cancelRequested = true; return { cancelled: true } } + if (method === 'installRecommended') { + const [id, options] = args + if (id !== recommendation.id) throw Error('未知推荐技能') + if (failNext) { + failNext = false + emit({ operationId: options.operationId, recommendationId: id, phase: 'failed', receivedBytes: 0, error: '下载失败:网络错误', updatedAt: Date.now() }) + throw Error('下载失败:网络错误') + } + emit({ operationId: options.operationId, recommendationId: id, phase: 'downloading', receivedBytes: 0, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() }) + await new Promise(resolve => setTimeout(resolve, delay)) + if (cancelRequested) { cancelRequested = false; emit({ operationId: options.operationId, recommendationId: id, phase: 'cancelled', receivedBytes: 0, updatedAt: Date.now() }); throw Object.assign(Error('推荐技能安装已取消'), { name: 'AbortError' }) } + emit({ operationId: options.operationId, recommendationId: id, phase: 'validating', receivedBytes: recommendation.expectedBytes, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() }) + await new Promise(resolve => setTimeout(resolve, delay)) + if (cancelRequested) { cancelRequested = false; emit({ operationId: options.operationId, recommendationId: id, phase: 'cancelled', receivedBytes: recommendation.expectedBytes, updatedAt: Date.now() }); throw Object.assign(Error('推荐技能安装已取消'), { name: 'AbortError' }) } + emit({ operationId: options.operationId, recommendationId: id, phase: 'installing', receivedBytes: recommendation.expectedBytes, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() }) + await new Promise(resolve => setTimeout(resolve, delay)) + installed = [installedRecommendation] + emit({ operationId: options.operationId, recommendationId: id, phase: 'installed', receivedBytes: recommendation.expectedBytes, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() }) + changed() + return installedRecommendation + } + if (method === 'qaSetInstalled') { installed = args[0]; changed(); return installed } + if (method === 'qaFailNext') { failNext = true; return true } + if (method === 'qaDelay') { delay = args[0]; return true } + if (method === 'qaSetRecommendations') { recommendationOverride = args[0]; return true } + if (method === 'qaFailRecommendations') { recommendationFails = args[0] === true; return true } + // Simulate an operation owned by another window. + if (method === 'qaEmitForeign') { + const [phase, operationId] = args + emit({ operationId, recommendationId: recommendation.id, phase, receivedBytes: 0, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() }) + return true + } + // Replay a frame with an older timestamp to prove the terminal state never regresses. + if (method === 'qaEmitStale') { + const [phase, operationId, updatedAt] = args + const stamped = { operationId, recommendationId: recommendation.id, phase, receivedBytes: 0, totalBytes: recommendation.expectedBytes, updatedAt } + progress = stamped + for (const listener of listeners) listener(stamped) + for (const win of BrowserWindow.getAllWindows()) if (!win.isDestroyed()) win.webContents.send('skills:install-progress', stamped) + return true + } + if (method === 'qaSubscribe') { listeners.add(args[0]); return true } + if (method === 'qaSubscribeChanged') { changedListeners.add(args[0]); return true } + throw Error('Unknown QA method ' + method) + }) + + fs.writeFileSync(path.join(output, 'preload.cjs'), `const{contextBridge,ipcRenderer}=require('electron'); +const invoke=(method,...args)=>ipcRenderer.invoke('skills-qa',method,args); +contextBridge.exposeInMainWorld('ycode',{skills:{ + list:(cwd)=>invoke('list',cwd), + recommendations:()=>invoke('recommendations'), + installRecommended:(id,options)=>invoke('installRecommended',id,options), + cancelInstall:(operationId)=>invoke('cancelInstall',operationId), + installState:()=>invoke('installState'), + onInstallProgress:(listener)=>{const wrapped=(_event,state)=>listener(state);ipcRenderer.on('skills:install-progress',wrapped);return()=>ipcRenderer.removeListener('skills:install-progress',wrapped)}, + onChanged:(listener)=>{const wrapped=()=>listener();ipcRenderer.on('skills:changed',wrapped);return()=>ipcRenderer.removeListener('skills:changed',wrapped)}, + read:async()=>({skill:{},content:'',files:[]}), + setEnabled:(...args)=>invoke('setEnabled',...args), remove:async()=>{}, export:async()=>null, importPath:async()=>null + ,qaSetInstalled:(v)=>invoke('qaSetInstalled',v), + qaFailNext:()=>invoke('qaFailNext'), + qaDelay:(v)=>invoke('qaDelay',v), + qaSetRecommendations:(v)=>invoke('qaSetRecommendations',v), + qaFailRecommendations:(v)=>invoke('qaFailRecommendations',v), + qaEmitForeign:(phase,operationId)=>invoke('qaEmitForeign',phase,operationId), + qaEmitStale:(phase,operationId,updatedAt)=>invoke('qaEmitStale',phase,operationId,updatedAt) +},resources:{openExternal:async()=>undefined}});`) + + const location = file => JSON.stringify(path.join(repository, file).replaceAll('\\', '/')) + fs.writeFileSync(path.join(output, 'fixture.tsx'), `import React from 'react'; +import{createRoot}from'react-dom/client'; +import Center from ${location('src/renderer/src/capabilities/CapabilityCenter')}; +import${location('src/renderer/src/styles/tokens.css')}; +import${location('src/renderer/src/styles/app.css')}; +window.__qa={uses:[],created:0,find:0}; +createRoot(document.getElementById('root')).render( +
+
{}} onUseSkill={skill=>window.__qa.uses.push(skill)} onCreateSkill={()=>window.__qa.created++} onFindSkill={()=>window.__qa.find++}/> +
);`) + await require('esbuild').build({ + entryPoints: [path.join(output, 'fixture.tsx')], bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic', + outfile: path.join(output, 'fixture.js'), loader: { '.svg': 'file', '.png': 'file', '.jpg': 'file', '.woff': 'file', '.woff2': 'file', '.ttf': 'file' }, logLevel: 'warning' + }) + fs.writeFileSync(path.join(output, 'index.html'), '
') + + const win = new BrowserWindow({ + width: 1052, height: 901, useContentSize: true, frame: false, show: false, + webPreferences: { preload: path.join(output, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } + }) + win.webContents.on('console-message', (_event, level, message) => { if (level === 3) errors.push(message) }) + const read = code => win.webContents.executeJavaScript(code) + const check = (label, actual, expected) => { + const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected) + assert.ok(pass, `${label}: ${JSON.stringify(actual)}`) + checks.push(label) + } + const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) + const wait = async code => { for (let index = 0; index < 200; index++) { if (await read(code)) return; await pause(50) } throw Error('Timeout ' + code) } + const click = async selector => { + await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing '+${JSON.stringify(selector)});e.click();return true})()`) + await pause(140) + } + const capture = async name => { + win.webContents.invalidate() + await pause(120) + // A hidden window can capture the previous frame; warm the compositor first. + await win.webContents.capturePage() + await pause(200) + const image = await win.webContents.capturePage() + fs.writeFileSync(path.join(output, name + '.png'), image.toPNG()) + screenshots.push(name + '.png') + } + + try { + await win.loadFile(path.join(output, 'index.html')) + await wait('!!document.querySelector(".yc-skills-page")') + await wait('!!document.querySelector("[data-recommendation-id=archify]")') + + check('the recommendation card renders from the host catalog', await read('document.querySelector("[data-recommendation-id=archify] .sm-card-name")?.textContent'), 'Archify') + check('the card exposes its install state for the host test', await read('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState'), 'none') + check('the card shows the author supplied description', await read('document.querySelector("[data-recommendation-id=archify] .sm-card-desc")?.textContent'), recommendation.description) + check('no maintenance placeholder is shown', await read('!!document.querySelector("[data-capability-maintenance]")'), false) + check('the remote catalog tabs still report no connection', await read('!!document.querySelector(".yc-skill-market-unavailable")'), false) + await capture('recommendation-card') + + // First install asks for an explicit confirmation. + await click('[aria-label="安装 Archify"]') + await wait('!!document.querySelector(".yc-skill-detail-shell")') + check('the confirm dialog names the source repository', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("tt-a1i/archify")'), true) + check('the confirm dialog states that no install script runs', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("不会执行仓库中的任何安装命令")'), true) + check('the confirm dialog warns that the skill contains scripts', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("包含脚本") || document.querySelector(".yc-skill-detail-shell").textContent.includes("脚本")'), true) + await capture('install-confirm') + await read('(()=>{const first=document.querySelector("[role=dialog] .sm-skill-detail-close");first.focus();first.dispatchEvent(new KeyboardEvent("keydown",{key:"Tab",shiftKey:true,bubbles:true,cancelable:true}))})()') + check('reverse Tab reaches the collapsible source information', await read('document.activeElement.tagName'), 'SUMMARY') + await read('document.activeElement.dispatchEvent(new KeyboardEvent("keydown",{key:"Tab",bubbles:true,cancelable:true}))') + check('Tab wraps inside the modal rather than entering the sidebar', await read('document.activeElement.getAttribute("aria-label")'), '关闭技能详情') + await read('window.__detailBeforeInstall=document.querySelector("[role=dialog]")') + await click('[aria-label="确认安装推荐技能"]') + await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installed"') + check('the install progress is reported by the host', await read('document.querySelector(".yc-skill-inline-install").dataset.installPhase'), 'installed') + check('the host received the recommendation id', await read(`JSON.stringify(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').map(c=>c[1]))`), '["archify"]') + check('the host received a fresh operation id', await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').every(c=>typeof c[2]?.operationId==='string'&&c[2].operationId.length>8))`), true) + check('a first install is not an overwrite', await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').every(c=>c[2]?.overwrite!==true))`), true) + await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="current"') + check('the card reports the recommendation as installed', await read('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState'), 'current') + check('the installed skill is not offered for download again', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), true) + check('the detail remains the same DOM instance throughout installation', await read('window.__detailBeforeInstall===document.querySelector("[role=dialog]")'), true) + check('the detail offers use after installation', await read('document.querySelector("[role=dialog] .sm-skill-detail-action-row")?.textContent.includes("去使用")'), true) + check('the success notification also offers use', await read('document.querySelector(".yc-skill-install-success")?.textContent.includes("去使用")'), true) + check('there is no permanent list footer progress panel', await read('!!document.querySelector(".yc-skills-scroll > .yc-skill-inline-install")'), false) + await capture('installed-detail') + await click('[role=dialog] .sm-skill-detail-close') + + // The installed list stays reachable and the skill can be used. + await capture('installed-recommendation-list'); + const installedState=await read('(()=>{const root=document.querySelector("[data-recommendation-id=archify]");const card=root.querySelector(".sm-card");const add=root.querySelector(".sm-add-btn");return {state:root.dataset.recommendationState,cardClasses:card.className,installedClass:card.classList.contains("sm-card--installed"),addButtonPresent:!!add,addButtonDisabled:add?.disabled,addSvgPresent:!!add?.querySelector("svg"),useSlotPresent:!!root.querySelector(".sm-use-btn"),footerPresent:!!document.querySelector(".yc-skill-recommendation-footer"),footerText:document.querySelector(".yc-skill-recommendation-footer")?.textContent}})()'); + fs.writeFileSync(path.join(output,'installed-state.json'),JSON.stringify({codeSha:'51531dcae8d8ade85db9e203bc8c0f712805b2ae',installedState,errors,scope:'Actual recommended-skill component and CSS in Electron with controlled host; no network or actual skill installation'},null,2)); + console.log(JSON.stringify({result:'REPRODUCED',output,installedState}));win.destroy();app.exit(0);return; + await click('.yc-skill-recommendation-footer button') + await wait('document.querySelectorAll(".sm-card").length===1') + check('the installed skill appears in the installed list', await read('document.querySelector(".sm-card-name")?.textContent'), 'Archify') + await click('[aria-label="返回专家中心"]') + await wait('!!document.querySelector("[data-recommendation-id=archify]")') + + // A local skill with the same name requires an explicit overwrite. + await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([localSkill])})`) + await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="conflict"') + await click('[aria-label="安装 Archify"]') + await wait('!!document.querySelector(".yc-skill-detail-shell")') + check('a same-name local skill requires an explicit replacement', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("替换")'), true) + check('the replacement notice mentions the local modification', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("本地内容已被修改")'), true) + await click('[aria-label="确认安装推荐技能"]') + await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="current"') + check('an explicit replacement is sent to the host as an overwrite', await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').at(-1)[2]?.overwrite)===true`), true) + await click('[role=dialog] .sm-skill-detail-close') + + // A failing download reports the real host error and allows a retry. + await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([])})`) + await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="none"') + await read(`window.ycode.skills.qaFailNext()`) + await click('[aria-label="安装 Archify"]') + await click('[aria-label="确认安装推荐技能"]') + await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="failed"') + check('a failed install shows the host error', await read('document.querySelector(".yc-skill-inline-install").textContent.includes("网络错误")'), true) + check('a failed install can be retried from the same card', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), false) + await click('[role=dialog] .sm-skill-detail-close') + + // Cancellation is allowed before the atomic commit phase. + await read(`window.ycode.skills.qaDelay(400)`) + await click('[aria-label="安装 Archify"]') + await click('[aria-label="确认安装推荐技能"]') + await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="downloading"') + check('the download phase can be cancelled', await read('[...document.querySelectorAll("[role=dialog] button")].some(e=>e.textContent==="取消安装"&&!e.disabled)'), true) + await read('document.querySelector("[role=dialog]").querySelectorAll("button").forEach(e=>{if(e.textContent==="取消安装")e.click()})') + await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="cancelled"') + check('the cancellation is sent to the host', await read(`${JSON.stringify(calls)}.some(c=>c[0]==='cancelInstall')`), true) + await click('[role=dialog] .sm-skill-detail-close') + await read(`window.ycode.skills.qaDelay(0)`) + + // Closing the detail never aborts an accepted install; reopening resumes it. + await read('window.ycode.skills.qaDelay(250)') + await click('[aria-label="安装 Archify"]'); await click('[aria-label="确认安装推荐技能"]') + await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="downloading"') + const continuingId = await read('document.querySelector(".yc-skill-inline-install").dataset.operationId') + await click('[role=dialog] .sm-skill-detail-close') + check('closing a running detail restores the background', await read('document.getElementById("root").inert'), false) + await click('[data-recommendation-id=archify] .sm-card') + check('reopening detail keeps the same install operation', await read('document.querySelector(".yc-skill-inline-install").dataset.operationId'), continuingId) + await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installing"') + check('the commit phase disables cancellation in place', await read('[...document.querySelectorAll("[role=dialog] button")].find(e=>e.textContent==="取消安装")?.disabled'), true) + await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installed"') + await click('[role=dialog] .sm-skill-detail-close') + await click('.yc-skill-install-success [aria-label="使用推荐技能 Archify"]') + check('the success notification invokes the same use callback', await read('window.__qa.uses.length'), 1) + await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([{ ...installedRecommendation, enabled: false }])})`) + await wait('document.querySelector(".yc-skill-recommendation-meta")?.textContent.includes("启用后使用")') + await click('.yc-skill-recommendation-meta [aria-label="使用推荐技能 Archify"]') + await wait('window.__qa.uses.length===2') + check('disabled recommendation is enabled before use', await read('window.__qa.uses.at(-1).enabled'), true) + await read('window.ycode.skills.qaDelay(0)'); await read('window.ycode.skills.qaSetInstalled([])') + await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="none"') + + // A failing catalog is reported as unavailable, not as a maintenance page. + // The page loads its catalog once, so the failure is proven on a fresh mount. + await read(`window.ycode.skills.qaFailRecommendations(true)`) + await read(`location.reload()`) + await wait('!!document.querySelector(".yc-skill-recommendation-unavailable")') + check('an empty catalog is reported as unavailable, not as a maintenance page', await read('!!document.querySelector("[data-capability-maintenance]")'), false) + check('the unavailable state still offers the installed list', await read('!!document.querySelector(".yc-skill-recommendation-unavailable button")'), true) + await read(`window.ycode.skills.qaFailRecommendations(false)`) + await read(`location.reload()`) + await wait('!!document.querySelector("[data-recommendation-id=archify]")') + + // An install owned by another window blocks a new install here. + const foreignOperation = '11111111-2222-4333-8444-555555555555' + await read(`window.ycode.skills.qaEmitForeign('downloading', ${JSON.stringify(foreignOperation)})`) + await wait('!!document.querySelector("[data-foreign-install=true]")') + check('an install from another window is reported', await read('document.querySelector("[data-foreign-install=true]").textContent.includes("其他窗口正在安装推荐技能")'), true) + check('a new install is blocked while another window is installing', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), true) + check('the blocked state is explained on the card', await read('!!document.querySelector(".yc-skill-recommendation-busy")'), true) + await capture('foreign-install') + await read(`window.ycode.skills.qaEmitForeign('installed', ${JSON.stringify(foreignOperation)})`) + await wait('!document.querySelector("[data-foreign-install=true]")') + check('the foreign block clears when that operation finishes', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), false) + + // A stale frame must not move a settled operation back into a running phase. + await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([])})`) + await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="none"') + await read(`window.ycode.skills.qaDelay(0)`) + await click('[aria-label="安装 Archify"]') + await click('[aria-label="确认安装推荐技能"]') + await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installed"') + const settledOperation = await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').at(-1)[2]?.operationId)`) + check('the settled install recorded its operation id', typeof settledOperation === 'string' && settledOperation.length > 8, true) + await read(`window.ycode.skills.qaEmitStale('downloading', ${JSON.stringify(settledOperation)}, 1)`) + await pause(250) + check('a stale frame does not regress the settled install', await read('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase'), 'installed') + await click('[role=dialog] .sm-skill-detail-close') + + // Dark and narrow captures of the real page. + // This fixture mounts only the capability center, so the theme attribute is set directly. + // The dark token switch is proven here only; the production app harness covers the light flow. + await read(`(()=>{localStorage.setItem('ycode:theme','dark');document.documentElement.dataset.theme='dark';return true})()`) + await wait('document.documentElement.dataset.theme==="dark"') + await wait('!!document.querySelector(".yc-capability-center .expert-center-dark")') + check('the dark theme reaches the real page', await read('!!document.querySelector(".yc-capability-center .expert-center-dark")'), true) + // The card transitions its background, so the dark surface only settles after the animation. + await wait(`(()=>{const card=document.querySelector('[data-recommendation-id=archify] .sm-card');if(!card)return false;const m=/rgba?\\((\\d+)/.exec(getComputedStyle(card).backgroundColor);return !!m&&Number(m[1])<90})()`) + const darkColors = await read(`(()=>{const card=document.querySelector('[data-recommendation-id=archify] .sm-card');const page=document.querySelector('.yc-capability-center .expert-center-page');return{card:card?getComputedStyle(card).backgroundColor:null,page:page?getComputedStyle(page).backgroundColor:null}})()`) + check('the dark card uses a dark surface', darkColors.card, value => typeof value === 'string' && /rgba?\((\d+), (\d+), (\d+)/.test(value) && Number(value.match(/rgba?\((\d+)/)[1]) < 90) + const overflow = await read(`(()=>{const wide=[...document.querySelectorAll('*')].filter(e=>e.getBoundingClientRect().right>innerWidth+1).map(e=>e.className||e.tagName).slice(0,4);return{scrollWidth:document.documentElement.scrollWidth,innerWidth,wide}})()`) + check('the dark layout keeps the recommendation card inside the viewport', await read(`(()=>{const c=document.querySelector('[data-recommendation-id=archify] .sm-card');if(!c)return false;const r=c.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1})()`), true) + check('the dark layout does not overflow the document', overflow.scrollWidth <= overflow.innerWidth + 1, true) + await capture('recommendation-dark') + win.setSize(720, 900) + await pause(400) + check('the recommendation card stays inside the narrow viewport', await read('(()=>{const c=document.querySelector("[data-recommendation-id=archify] .sm-card");if(!c)return false;const r=c.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1})()'), true) + const narrowOverflow = await read(`(()=>{const wide=[...document.querySelectorAll('*')].filter(e=>e.getBoundingClientRect().right>innerWidth+1).map(e=>String(e.className||e.tagName)).slice(0,5);return{scrollWidth:document.documentElement.scrollWidth,innerWidth,wide}})()`) + check('the narrow recommendation card causes no document overflow', narrowOverflow.scrollWidth <= narrowOverflow.innerWidth + 1, true) + await capture('recommendation-narrow') + win.setSize(1052, 901) + await pause(250) + + check('no renderer errors', errors, []) + fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({ + result: 'PASS', checks, calls, screenshots, + limits: 'Controlled host in isolated Electron; the shipped page and styles are used. No network, no real skill storage, no real install.' + }, null, 2)) + console.log(JSON.stringify({ result: 'PASS', checks: checks.length, output })) + win.destroy() + app.exit(0) + } catch (error) { + await capture('failure') + fs.writeFileSync(path.join(output, 'failure.json'), JSON.stringify({ error: String(error), stack: error?.stack, checks, errors, calls, html: await read('document.body.innerHTML') }, null, 2)) + console.error(error) + win.destroy() + app.exit(1) + } + })().catch(error => { console.error(error); electron.app.exit(1) }) +} diff --git a/docs/协作/开发与验收协作流程.md b/docs/协作/开发与验收协作流程.md index d89edcc..1b8ad11 100644 --- a/docs/协作/开发与验收协作流程.md +++ b/docs/协作/开发与验收协作流程.md @@ -186,3 +186,5 @@ npm run build - [Windows复测与后续开发](../对话输入对齐复测与后续开发-2026-10-21.md):针对 `37ddb2c` 的 A01~A09,包含已证缺陷、样式偏差与真实 SDK 验收缺口;该结果不自动适用于后续代码。 首批正式需求已整理为 [B01:对话与输入交互复测问题修复](B01-需求.md),覆盖 A01~A09,分为 YC-001~YC-004,并列明逐项 AC、测试及证据要求。状态为“待开发”,不代表已经接单、交付或验收通过。本批需求推送成功后推送窗口转交开发端;开发端创建 `B01-交付-01.md` 接单、实现并交付。 + +2026-10-21 新增技能页需求见 [B02:推荐技能的已安装样式与重复入口](B02-需求.md),包含 YC-005/006。它与 B01 分开交付:推荐中已安装卡不再保留添加 Plus,移除列表底部“查看已安装技能”,保留顶部导航,并核对既有 WorkBuddy REA 恢复记录及当前证据限制。