From bd1b0c305992bad7335e63b4b8b2edca4533c4ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E6=B3=BD=E6=A5=B7?= Date: Wed, 4 Nov 2026 11:53:10 +0800 Subject: [PATCH] fix(dev): scope hot updates to registered editor installations Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/DEVELOPMENT.md | 17 ++++ docs/STATUS.md | 11 ++- esbuild.mjs | 4 +- package.json | 5 +- scripts/syncGlobalExtensions.d.mts | 6 ++ scripts/syncGlobalExtensions.mjs | 99 ++++++++++++++------ src/integration/syncGlobalExtensions.test.ts | 60 ++++++++++++ 7 files changed, 166 insertions(+), 36 deletions(-) create mode 100644 scripts/syncGlobalExtensions.d.mts create mode 100644 src/integration/syncGlobalExtensions.test.ts diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c02a29af..77581807 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -282,6 +282,7 @@ pnpm run test:integration | --- | --- | | `pnpm run build:chat-ui` | 公共 UI 包及类型声明 | | `pnpm run build:webview` | 独立前端资源,输出 `dist/webview-v2/` | +| `pnpm run build:extension` | 只构建 Extension Host 与 Worker,不重建页面 | | `pnpm run build` | 扩展、Worker 与六个生产 Webview 入口,页面输出 `dist/webview/` | | `pnpm run package:vsix` | 串行执行打包前检查与构建,生成统一 VSIX | | `npm --prefix docs run build` | 文档静态站与站内文件链接检查 | @@ -295,6 +296,22 @@ pnpm run test:integration 保留无关改动,不提交密钥、日志、依赖目录、`dist/` 或 VSIX。 产品事实写入 [STATUS](STATUS.md),架构和视觉约束分别更新 [ARCHITECTURE](ARCHITECTURE.md) 与 [DESIGN](DESIGN.md)。 +### 日常热更新,不重装 VSIX + +已安装同版本扩展时,按改动选择一个命令,同时更新 Microsoft VS Code 和 Cursor: + +| 命令 | 更新范围 | +| --- | --- | +| `pnpm run sync:webview` | 公共 UI 与生产页面,正确构建到 `dist/webview/`,不复制后端 | +| `pnpm run sync:extension` | Extension Host 与 Worker,不构建或复制页面 | +| `pnpm run sync:all` | 前后端都改变时,统一构建后同步 | + +同步仅更新各编辑器安装登记中的当前同版本扩展,不覆盖旧版本目录、安装清单或用户数据; +内容相同的文件不重复写入。可追加 `--dry-run` 构建并检查目标,不修改安装目录。 +编辑器中现有监听会刷新页面;后端变化会重启扩展宿主,可能短暂影响其他扩展, +应在没有进行中的操作时执行。这是构建后自动刷新/宿主重启,不是保存源码即无感替换。 +扩展版本、清单注册项或依赖安装布局变化仍使用 VSIX,不用热更新代替正式发布。 + ## 构建、打包并安装最新源码 diff --git a/docs/STATUS.md b/docs/STATUS.md index d9b99a5b..e5b398cb 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -7,7 +7,7 @@ ## 版本与交付 - 当前源码版本为 `0.8.20`,在 `0.8.19` 基础上加入繁星/三体/关闭三种深色聊天背景,并修复 AskUser 多选识别、勾选状态与单选填空互斥切换。 -- 正式版 `0.8.20` 以 [GitHub Release](https://github.com/fireman-droid/droid-for-vscode/releases/tag/v0.8.20) 与 [Open VSX](https://open-vsx.org/extension/droidvisx/droidvisx/0.8.20) 为准。SDK 0.10.0 与分页已通过 PR #22 合入 main(`663cbffc`)。本机 Automations 详情迭代位于 `codex/local-automations`,本地试用 VSIX(版本仍为 `0.8.20`)已校验并安装到 Microsoft VS Code,待用户 Reload Window 体验;尚未推送、合并或正式发布。 +- 正式版 `0.8.20` 以 [GitHub Release](https://github.com/fireman-droid/droid-for-vscode/releases/tag/v0.8.20) 与 [Open VSX](https://open-vsx.org/extension/droidvisx/droidvisx/0.8.20) 为准。SDK 0.10.0 与分页已通过 PR #22 合入 main(`663cbffc`);Automations、滚动修复和验证流程调整已通过 PR #23 合入 main(`5c3aaebf`),必要 CI 全部通过,同一试用 VSIX 已安装到 Microsoft VS Code 和 Cursor,尚未正式发布。热更新脚本修复位于 `codex/fix-hot-reload`。 - 恢复文件等 composer 回执、附件/引用提示及临时诊断继续保留在消息列表底部状态区域;「Droid 正在工作/回答」、停止中(`Stopping…`)、压缩与等待子代理等运行状态统一移入底部 Composer 输入框的「继续提问」占位位置,未输入草稿时旋转显示、输入时隐藏、结束后恢复占位文字。 - Microsoft Marketplace 管理页在 2026-10-18 仍返回 429/Azure DevOps VSID 并发限流,此前还存在 `The extension 'droidvisx' already exists in the Marketplace`、Owner 列表为空的问题;未改名或覆盖既有产物,微软自动发布保持关闭,未配置 `VSCE_PAT`。 - 本机实测 daemon 自报版本为 `1.2.0`;文档站为 https://fireman-droid.github.io/droid-for-vscode/ 。 @@ -17,7 +17,7 @@ | 范围 | 当前实现与边界 | | --- | --- | | SDK 0.10.0 | 对象 transport、Script 批量权限与 RecommendMission 已适配;普通压缩保留原身份和租约,`/handoff` 显式创建后继,Spec 使用正式交接事件。文件浏览与自定义模型 revision 接口同步新版;默认设置保留已有开放字段,daemon 分页及 IDE 私有兼容补丁保留 | -| 本机 Automations(任务分支) | 已接通独立详情、三步创建、每天/每周与 UTC cron、固定会话或每次新建会话、运行与投递历史、会话查看/显式继续、隔离静态结果看板。创建暂停并跳过首次运行;配置写后回读,无法确认时阻止运行并请求暂停。已移除“立即运行”二次确认,保留执行校验与启用/删除确认;包含此调整的同一已校验试用 VSIX 已安装到 Microsoft VS Code 和 Cursor,尚未正式发布。云端、事件触发与 Workstream 配置不在范围内;未改现有每日问候任务 | +| 本机 Automations(已合入 main) | 已接通独立详情、三步创建、每天/每周与 UTC cron、固定会话或每次新建会话、运行与投递历史、会话查看/显式继续、隔离静态结果看板。创建暂停并跳过首次运行;配置写后回读,无法确认时阻止运行并请求暂停。已移除“立即运行”二次确认,保留执行校验与启用/删除确认;包含此调整的同一已校验试用 VSIX 已安装到 Microsoft VS Code 和 Cursor,尚未正式发布。云端、事件触发与 Workstream 配置不在范围内;未改现有每日问候任务 | | AskUser 问答卡片 | Runtime 与 Webview 共用多选提示识别,支持题目/主题中的括号说明和单选/多选混合表述;单选预设选项与自定义填空互斥,再次点击已选项可取消,多选保留全部已选项并合并补充填空 | | 历史分页(已合并,未发布) | daemon 默认只读最近一页,向上滚动自动续页;三页阅读窗口与实时尾部分离,卸载页可重读,保留跨页工具结果与压缩前驱;查看器复用分页,子任务按调用消息 ID 隔离,导出显式遍历全部页。移除聊天 JSONL 优先读取、自动 Process 回退及专用历史 Worker;保留独立 Process 模式和操作证据的本地读取 | | 聊天 | 新提问直接切换置顶,底部动态留白跑道承载首屏静止生长的回复,超出一屏后自动向下跟随,短回复结束时保持留白不回弹;最新活动独立成行,更早活动归入“执行过程”;正文按时间顺序穿插,历史数量紧随标题。单条工具与 thinking 独立折叠,Edit 默认显示精简 Diff,可在原卡片展开全部,没有 thinking 中间分组;长 thinking 原文分块合并更新并限高滚动。正文与折叠箭头左边缘对齐,失败操作收起时仍可查看。队列预览最多三行;常规设置提供等待/引导,默认等待;daemon 引导不停止当前任务,消费确认后移入聊天,投递不明时暂停保留内容。其余流式、附件、权限/问答、引用和 BTW 保留 | @@ -37,10 +37,11 @@ - 22 个公共 UI 测试及两份 DOM/输入辅助文件归入 `packages/chat-ui/test/`;应用业务测试仍就近存放。 默认 Vitest 收集范围包含公共包,文件预算覆盖测试目录;两份无消费者的旧组件转导出已移除。 - 根命令统一提供 `test:extension`、`test:runtime`、`test:webview`、`test:chat-ui`、`test:quality` 与性能入口,详见 DEVELOPMENT。 - 本地 PR 配置已将 `test:quality` 拆为按补全相关改动触发的独立任务,与 Node 24 构建并行; - 同名 `PR checks` 汇总所选任务,保留失败门禁,支持仅重试失败任务。尚未推送或在远端运行新配置。 + PR 配置已将 `test:quality` 拆为按补全相关改动触发的独立任务,与 Node 24 构建并行; + 同名 `PR checks` 汇总所选任务,保留失败门禁,支持仅重试失败任务;已在 PR #23 远端运行通过。 常规测试不调用模型,真实模型评估与 smoke 保持显式运行。 - 编辑器集成测试按必需激活事件逐项断言,允许增加其他合法入口,不再固定旧的三项完整清单。 +- 热更新脚本已分开前端/后端/全部入口,修正前端构建与同步目录不一致,并仅同步 VS Code / Cursor 登记的当前同版本安装。3 项脚本检查、语法、类型、文件预算及前后端构建与双编辑器目标预演通过;后端单独构建未改写页面产物。复用已有页面刷新与扩展宿主重启监听,不重装 VSIX;本轮未写入安装目录,实际打开编辑器的自动刷新效果仍待确认。 ## 最近验证 @@ -48,7 +49,7 @@ - SDK 升级:`typecheck`、`lint:budgets` 与 14 个定向测试文件/570 项通过,覆盖权限、压缩/交接、历史证据、Bridge 及 daemon 连接/恢复。最终 VSIX 171 项内容/入口校验与 PR #22 的必需 CI 已通过。未运行真实模型调用、重启 daemon 或追加浏览器验收。 -- 历史分页:任务分支已固定顶部加载提示的最小高度,避免出现/消失改变列表布局;导航动画期间不自动抢占分页,顶部上滑手势仍能加载历史。分页优先按已挂载消息一次对齐,加载期间继续滚动会更新阅读锚点,稳定回调依赖避免补偿被无关渲染取消。本轮 3 个文件/8 项定向检查、`typecheck`、`lint:budgets` 通过;本地试用 VSIX 已构建、通过 171 个归档条目及入口校验,并在用户退出 VS Code 后安装成功。尚未在真实页面确认闪屏消失,未正式发布。此前分页的真实 daemon 1.2.0 读取覆盖 129 MiB、121 页/12,090 条,原始 ID 和顺序一致,新增消息后旧游标续读亦一致;该历史证据不作为本轮界面验收。 +- 历史分页:main 已固定顶部加载提示的最小高度,避免出现/消失改变列表布局;导航动画期间不自动抢占分页,顶部上滑手势仍能加载历史。分页优先按已挂载消息一次对齐,加载期间继续滚动会更新阅读锚点,稳定回调依赖避免补偿被无关渲染取消。本轮 3 个文件/8 项定向检查、`typecheck`、`lint:budgets` 通过;本地试用 VSIX 已构建、通过 171 个归档条目及入口校验,已安装到 VS Code 和 Cursor。尚未在真实页面确认闪屏消失,未正式发布。此前分页的真实 daemon 1.2.0 读取覆盖 129 MiB、121 页/12,090 条,原始 ID 和顺序一致,新增消息后旧游标续读亦一致;该历史证据不作为本轮界面验收。 - `0.8.20` AskUser 修复:2 个定向测试文件共 28 项、`typecheck` 与 `lint:budgets` 已通过;真实页面点击验收留给用户,未将单元测试视为编辑器验收。发布构建与渠道结果以该版本 Actions 记录为准。 diff --git a/esbuild.mjs b/esbuild.mjs index 106c5284..1a8474f6 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -87,7 +87,9 @@ appendFileSync('dist/extension/THIRD_PARTY_LICENSES.txt', '\nKilo Code (vendored appendFileSync('dist/extension/THIRD_PARTY_LICENSES.txt', '\nContinue (Apache-2.0; autocomplete code via Kilo)\n' + readFileSync('third-party/CONTINUE-LICENSE.txt', 'utf8')); appendFileSync('dist/extension/THIRD_PARTY_LICENSES.txt', '\n' + readFileSync('third-party/TREE-SITTER-GRAMMARS-LICENSES.txt', 'utf8')); -execFileSync(process.execPath, ['scripts/buildWebviewV2.mjs', '--production'], { stdio: 'inherit' }); +if (!process.argv.includes('--extension-only')) { + execFileSync(process.execPath, ['scripts/buildWebviewV2.mjs', '--production'], { stdio: 'inherit' }); +} assertExpectedExternals(extensionResult.metafile, { required: new Set(['vscode']), diff --git a/package.json b/package.json index dd729cfb..1b983189 100644 --- a/package.json +++ b/package.json @@ -417,6 +417,7 @@ "package:chat-ui": "pnpm run build:chat-ui && pnpm --dir packages/chat-ui pack --pack-destination ../../dist", "typecheck:chat-ui": "pnpm --filter @droidvisx/chat-ui typecheck", "build": "node esbuild.mjs", + "build:extension": "node esbuild.mjs --extension-only", "dev:webview": "vite --config vite.webview-v2.config.ts", "test:extension": "vitest run src/shared src/extension", "test:webview": "vitest run src/webview-v2", @@ -438,7 +439,9 @@ "release:assets": "node scripts/checkRelease.mjs --prepare-assets", "vscode:prepublish": "pnpm run package:prepare", "build:webview": "node scripts/buildWebviewV2.mjs", - "sync:webview": "pnpm run build:chat-ui && pnpm run build:webview && node scripts/syncGlobalExtensions.mjs", + "sync:webview": "pnpm run build:chat-ui && pnpm run build:webview --production && node scripts/syncGlobalExtensions.mjs --webview-only", + "sync:extension": "pnpm run build:extension && node scripts/syncGlobalExtensions.mjs --extension-only", + "sync:all": "pnpm run build:chat-ui && pnpm run build && node scripts/syncGlobalExtensions.mjs", "benchmark:markdown": "node scripts/benchmarkMarkdown.mjs", "benchmark:review": "node scripts/benchmarkReview.mjs", "dev:performance": "node scripts/servePerformance.mjs" diff --git a/scripts/syncGlobalExtensions.d.mts b/scripts/syncGlobalExtensions.d.mts new file mode 100644 index 00000000..9fcd73b4 --- /dev/null +++ b/scripts/syncGlobalExtensions.d.mts @@ -0,0 +1,6 @@ +export function syncGlobalExtensions(options?: { + repoRoot?: string; + home?: string; + mode?: 'all' | 'webview' | 'extension'; + dryRun?: boolean; +}): Array<{ editor: string; directory: string; changedFiles: number }>; diff --git a/scripts/syncGlobalExtensions.mjs b/scripts/syncGlobalExtensions.mjs index 81540dd6..4e9dc194 100644 --- a/scripts/syncGlobalExtensions.mjs +++ b/scripts/syncGlobalExtensions.mjs @@ -1,42 +1,83 @@ -import { cpSync, existsSync, readdirSync } from 'node:fs'; +import { cpSync, existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; -const includeExtension = !process.argv.includes('--webview-only'); -const repoRoot = process.cwd(); -const webviewSrc = path.join(repoRoot, 'dist', 'webview'); -const extensionSrc = path.join(repoRoot, 'dist', 'extension'); -const manifestSrc = path.join(repoRoot, 'package.json'); +const repository = fileURLToPath(new URL('../', import.meta.url)); +const requiredAssets = { + webview: ['webview.js', 'webview.css', 'management.js', 'models.js', 'review.js', 'mission-control.js', 'session-viewer.js'], + extension: ['extension.cjs', 'sessionCatalogWorker.cjs', 'persistentIdeRelayWorker.cjs', 'btwAnalysisWorker.cjs'], +}; +const json = file => JSON.parse(readFileSync(file, 'utf8')); -if (!existsSync(webviewSrc)) { - throw new Error(`Missing dist/webview directory at ${webviewSrc}. Run build:webview first.`); +function changedFiles(source, destination) { + return readdirSync(source, { withFileTypes: true }).flatMap(entry => { + const from = path.join(source, entry.name), to = path.join(destination, entry.name); + if (entry.isDirectory()) return changedFiles(from, to); + if (existsSync(to) && readFileSync(from).equals(readFileSync(to))) return []; + return [{ from, to }]; + }); } -const roots = [ - path.join(homedir(), '.vscode', 'extensions'), - path.join(homedir(), '.cursor', 'extensions'), -]; - -let syncedCount = 0; -for (const root of roots) { - if (!existsSync(root)) continue; - const entries = readdirSync(root, { withFileTypes: true }) - .filter(entry => entry.isDirectory() && entry.name.startsWith('droidvisx.droidvisx-')) - .map(entry => path.join(root, entry.name)); +/** Sync only registered installations; hot updates never rewrite extension manifests. */ +export function syncGlobalExtensions({ repoRoot = repository, home = homedir(), mode = 'all', dryRun = false } = {}) { + if (!['all', 'webview', 'extension'].includes(mode)) throw new Error(`Unknown sync mode: ${mode}`); + const groups = mode === 'all' ? ['webview', 'extension'] : [mode]; + const manifest = json(path.join(repoRoot, 'package.json')); + const id = `${manifest.publisher}.${manifest.name}`; + for (const group of groups) { + for (const asset of requiredAssets[group]) { + const file = path.join(repoRoot, 'dist', group, asset); + if (!existsSync(file) || !statSync(file).isFile() || statSync(file).size === 0) { + throw new Error(`Missing ${file}. Build the selected ${group} assets before syncing.`); + } + } + } - for (const targetDir of entries) { - cpSync(webviewSrc, path.join(targetDir, 'dist', 'webview'), { recursive: true, force: true }); - if (includeExtension && existsSync(extensionSrc)) { - cpSync(extensionSrc, path.join(targetDir, 'dist', 'extension'), { recursive: true, force: true }); - if (existsSync(manifestSrc)) { - cpSync(manifestSrc, path.join(targetDir, 'package.json'), { force: true }); + const targets = []; + for (const [editor, folder] of [['VS Code', '.vscode'], ['Cursor', '.cursor']]) { + const root = path.join(home, folder, 'extensions'); + const registry = path.join(root, 'extensions.json'); + if (!existsSync(registry)) continue; + const obsoletePath = path.join(root, '.obsolete'); + const obsolete = existsSync(obsoletePath) ? json(obsoletePath) : {}; + const locations = new Set(json(registry).filter(entry => entry.identifier?.id === id) + .map(entry => entry.relativeLocation)); + for (const location of locations) { + if (typeof location !== 'string' || !location.startsWith(`${id}-`) || /[/\\]/.test(location)) { + throw new Error(`Invalid ${editor} extension location.`); } + if (obsolete[location]) continue; + const directory = path.join(root, location); + const installed = json(path.join(directory, 'package.json')); + if (installed.version !== manifest.version || installed.publisher !== manifest.publisher || installed.name !== manifest.name) { + throw new Error(`${editor}: installed extension differs from this version. Install a VSIX before using hot updates.`); + } + const files = groups.flatMap(group => changedFiles( + path.join(repoRoot, 'dist', group), path.join(directory, 'dist', group))); + targets.push({ editor, directory, files }); } - syncedCount += 1; - console.log(`Synced webview bundle -> ${targetDir}`); } + if (!targets.length) throw new Error('No registered Droid installation found in VS Code or Cursor. Install a VSIX first.'); + // Finish preflight for both editors before writing to either installation. + if (!dryRun) for (const target of targets) { + for (const file of target.files) cpSync(file.from, file.to, { force: true }); + } + return targets.map(({ editor, directory, files }) => ({ editor, directory, changedFiles: files.length })); } -if (syncedCount === 0) { - console.warn('No installed droidvisx.droidvisx-* extension directories found under ~/.vscode or ~/.cursor.'); +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + try { + const args = process.argv.slice(2); + if (args.some(arg => !['--webview-only', '--extension-only', '--dry-run'].includes(arg)) || + args.includes('--webview-only') && args.includes('--extension-only')) throw new Error('Choose one sync mode: --webview-only or --extension-only.'); + const mode = args.includes('--webview-only') ? 'webview' : args.includes('--extension-only') ? 'extension' : 'all'; + const dryRun = args.includes('--dry-run'); + for (const result of syncGlobalExtensions({ mode, dryRun })) { + console.log(`${dryRun ? 'Would sync' : 'Synced'} ${mode}: ${result.editor}, ${result.changedFiles} changed files -> ${result.directory}`); + } + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } } diff --git a/src/integration/syncGlobalExtensions.test.ts b/src/integration/syncGlobalExtensions.test.ts new file mode 100644 index 00000000..d50c6e5e --- /dev/null +++ b/src/integration/syncGlobalExtensions.test.ts @@ -0,0 +1,60 @@ +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, expect, it } from 'vitest'; +import { syncGlobalExtensions } from '../../scripts/syncGlobalExtensions.mjs'; + +const temporary: string[] = []; +afterEach(() => { for (const dir of temporary.splice(0)) rmSync(dir, { recursive: true, force: true }); }); +function fixture() { + const root = mkdtempSync(path.join(tmpdir(), 'droid-sync-')); temporary.push(root); + const repoRoot = path.join(root, 'repo'), home = path.join(root, 'home'); + const manifest = { publisher: 'droidvisx', name: 'droidvisx', version: '0.8.20' }; + const write = (file: string, value: string) => { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, value); }; + write(path.join(repoRoot, 'package.json'), JSON.stringify(manifest)); + for (const asset of ['webview.js', 'webview.css', 'management.js', 'models.js', 'review.js', 'mission-control.js', 'session-viewer.js']) + write(path.join(repoRoot, 'dist/webview', asset), 'new page'); + for (const asset of ['extension.cjs', 'sessionCatalogWorker.cjs', 'persistentIdeRelayWorker.cjs', 'btwAnalysisWorker.cjs']) + write(path.join(repoRoot, 'dist/extension', asset), 'new host'); + const targets = ['.vscode', '.cursor'].map(editor => { + const extensions = path.join(home, editor, 'extensions'); + const location = 'droidvisx.droidvisx-0.8.20', directory = path.join(extensions, location); + write(path.join(extensions, 'extensions.json'), JSON.stringify([{ identifier: { id: 'droidvisx.droidvisx' }, relativeLocation: location }])); + write(path.join(directory, 'package.json'), JSON.stringify({ ...manifest, __metadata: { keep: true } })); + write(path.join(directory, 'dist/webview/webview.js'), 'old page'); + write(path.join(directory, 'dist/extension/extension.cjs'), 'old host'); + const old = path.join(extensions, 'droidvisx.droidvisx-0.8.19/dist/webview/webview.js'); + write(old, 'retained old install'); + return { directory, old }; + }); + return { repoRoot, home, targets, write, manifest }; +} + +it.each(['webview', 'extension'] as const)('syncs only %s assets to both registered editor installations', mode => { + const f = fixture(); + const result = syncGlobalExtensions({ ...f, mode }); + expect(result.map(item => item.editor)).toEqual(['VS Code', 'Cursor']); + for (const target of f.targets) { + expect(readFileSync(path.join(target.directory, 'dist/webview/webview.js'), 'utf8')).toBe(mode === 'webview' ? 'new page' : 'old page'); + expect(readFileSync(path.join(target.directory, 'dist/extension/extension.cjs'), 'utf8')).toBe(mode === 'extension' ? 'new host' : 'old host'); + expect(JSON.parse(readFileSync(path.join(target.directory, 'package.json'), 'utf8')).__metadata).toEqual({ keep: true }); + expect(readFileSync(target.old, 'utf8')).toBe('retained old install'); + } + expect(syncGlobalExtensions({ ...f, mode }).every(item => item.changedFiles === 0)).toBe(true); +}); + +it('preflights both editors and leaves files unchanged on dry runs, missing builds and version mismatches', () => { + const f = fixture(); + expect(syncGlobalExtensions({ ...f, dryRun: true }).every(item => item.changedFiles > 0)).toBe(true); + const unchanged = () => f.targets.forEach(target => + expect(readFileSync(path.join(target.directory, 'dist/webview/webview.js'), 'utf8')).toBe('old page')); + unchanged(); + const asset = path.join(f.repoRoot, 'dist/extension/extension.cjs'); + rmSync(asset); + expect(() => syncGlobalExtensions(f)).toThrow('Missing'); + unchanged(); + f.write(asset, 'new host'); + f.write(path.join(f.targets[1].directory, 'package.json'), JSON.stringify({ ...f.manifest, version: '0.8.19' })); + expect(() => syncGlobalExtensions(f)).toThrow('Install a VSIX'); + unchanged(); +});