From 4d8b7c096d9e66345683f5adcd515ed68499924c Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Mon, 12 Oct 2026 15:55:54 +0800 Subject: [PATCH 1/4] fix(ci): restore workspace installs and isolate Windows process tests --- ...ci-workspace-and-windows-test-isolation.md | 30 ++++++++++++++++++ package-lock.json | 4 +-- packages/adapter-dsh/src/session.ts | 3 +- .../test/watch-serialization.test.js | 24 ++++++++++++++ packages/beauticode-desktop/README.md | 4 +++ packages/beauticode-desktop/package.json | 3 +- .../src/quick-shortcuts.mjs | 10 ++++-- .../beauticode-desktop/test/contract.test.mjs | 4 +-- .../beauticode-desktop/test/pack.test.mjs | 1 + .../test/quick-shortcuts.test.mjs | 31 +++++++++++++++++++ scripts/pack-desktop-aggregate.mjs | 5 ++- 11 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 docs/adr/0008-ci-workspace-and-windows-test-isolation.md create mode 100644 packages/adapter-dsh/test/watch-serialization.test.js diff --git a/docs/adr/0008-ci-workspace-and-windows-test-isolation.md b/docs/adr/0008-ci-workspace-and-windows-test-isolation.md new file mode 100644 index 0000000..64a5497 --- /dev/null +++ b/docs/adr/0008-ci-workspace-and-windows-test-isolation.md @@ -0,0 +1,30 @@ +# 源码工作区的平台验证与 Windows 测试隔离 + +日期:2026-10-12。状态:实施。 + +## 问题与证据 + +主分支 [CI 38132448422](https://github.com/starsstreaming/beautiCode/actions/runs/38132448422) +在 Ubuntu 安装阶段因 `beauticode-desktop` 工作区的 `os: win32` 声明退出, +Windows 两个 Node 版本均在快捷方式测试中只报告被截断的 CLIXML 进度输出。 +此前同一提交的 PR 检查还出现 DSH 目录替换竞态和多项 Windows 子进程超时。 + +## 决策 + +- 源码工作区允许在 CI 的 Linux、Windows 上安装;聚合打包器负责写入正式产物的 + `os: ["win32"]`,保留用户安装契约。源码及产物契约分别验证,避免使用 `--force` + 绕过 npm 对平台及引擎的检查。 +- Desktop 测试采用现有长路径临时目录运行器,并串行执行测试文件,避免多个 + PowerShell、复制 Node 校验和快捷启动用例同时争用 hosted runner 的资源。 + 原有用例与超时边界保留,不通过跳过测试或无边界重试变绿。 +- 快捷方式进程禁用 PowerShell 进度输出,明确文本格式,保留有界输出缓冲; + 优先报告 ENOBUFS、ETIMEDOUT 等真实进程错误码,不让 CLIXML 掩盖失败原因。 +- DSH 写事务持有 busy 状态时,监视器不得读取活动清单。该读取会初始化 + `active` 目录,可能在目录原子替换的中间阶段重新创建目标目录。已有读任务 + 仍由事务入口等待完成,后续 tick 则在入口处退出。 + +## 验证与回滚 + +回归覆盖 busy 期间零读取、空闲后恢复读取、48 KiB 子进程诊断输出及错误码、 +源码与发布产物的不同平台契约,并保留真实 Windows 快捷方式端到端验证。 +合并前要求四个操作系统/Node 组合均通过 CI;变更没有数据迁移,可回退本提交。 diff --git a/package-lock.json b/package-lock.json index 9f5e6d7..185a031 100644 --- a/package-lock.json +++ b/package-lock.json @@ -207,10 +207,8 @@ }, "packages/beauticode-desktop": { "version": "0.1.0-test.12", + "hasInstallScript": true, "license": "MIT", - "os": [ - "win32" - ], "bin": { "beauticode-desktop": "bin/beauticode-desktop.mjs" }, diff --git a/packages/adapter-dsh/src/session.ts b/packages/adapter-dsh/src/session.ts index 5d5976a..3034040 100644 --- a/packages/adapter-dsh/src/session.ts +++ b/packages/adapter-dsh/src/session.ts @@ -599,7 +599,8 @@ export class DshSession implements HostSession { } private async watchOnce(): Promise { - if (this.closed || !this.host || this.watchTask) return; + // readActiveManifest can initialize activeDir. A tick during promotion must not recreate that rename target. + if (this.closed || !this.host || this.watchTask || this.userBusy) return; const task = (async () => { try { const [status, manifest] = await Promise.all([ diff --git a/packages/adapter-dsh/test/watch-serialization.test.js b/packages/adapter-dsh/test/watch-serialization.test.js new file mode 100644 index 0000000..efbc354 --- /dev/null +++ b/packages/adapter-dsh/test/watch-serialization.test.js @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { DshSession } from "../dist/index.js"; + +test("watch ticks never open active state while a user transaction owns it", async () => { + const session = new DshSession({ dataRoot: path.join(os.tmpdir(), "unused-dsh-watch-test"), bundledGallery: false }); + let statusReads = 0, manifestReads = 0; + // Hold the domain transaction's busy state deterministically, without relying on filesystem timing. + session.host = { status: async () => { + statusReads++; + return { connectedClients: 0, current: { generation: 0, media: "clear" } }; + } }; + session.store.readActiveManifest = async () => { manifestReads++; return { generation: 0, background: null }; }; + session.userBusy = true; + await session.watchOnce(); + assert.equal(statusReads, 0); + assert.equal(manifestReads, 0); + session.userBusy = false; + await session.watchOnce(); + assert.equal(statusReads, 1); + assert.equal(manifestReads, 1); +}); diff --git a/packages/beauticode-desktop/README.md b/packages/beauticode-desktop/README.md index 05ba943..c2a51e2 100644 --- a/packages/beauticode-desktop/README.md +++ b/packages/beauticode-desktop/README.md @@ -2,6 +2,10 @@ Windows-only aggregate package for the beautiCode desktop hosts. Requires Node.js 22 or newer. +The source workspace installs on Linux for CI validation. `npm run desktop:pack` +adds the Windows-only npm platform restriction to the distributable; running +Windows installation commands on another platform remains unsupported. + Codex MSIX safety notice: direct launching an executable inside WindowsApps loses its package identity and can prevent Codex from starting. This package uses package-aware activation and refuses the unsafe direct-launch route, but diff --git a/packages/beauticode-desktop/package.json b/packages/beauticode-desktop/package.json index 9174af5..ca01a34 100644 --- a/packages/beauticode-desktop/package.json +++ b/packages/beauticode-desktop/package.json @@ -4,7 +4,6 @@ "private": false, "description": "beautiCode desktop host aggregate for DSH, Codex, WorkBuddy, Cursor, and Doubao.", "type": "module", - "os": ["win32"], "bin": { "beauticode-desktop": "./bin/beauticode-desktop.mjs" }, @@ -22,7 +21,7 @@ ], "scripts": { "postinstall": "node ./bin/postinstall.mjs", - "test": "node --test test/*.test.mjs" + "test": "node ../../scripts/test-runner.mjs --test --test-concurrency=1 test/*.test.mjs" }, "engines": { "node": ">=22" diff --git a/packages/beauticode-desktop/src/quick-shortcuts.mjs b/packages/beauticode-desktop/src/quick-shortcuts.mjs index deceaf2..84081a1 100644 --- a/packages/beauticode-desktop/src/quick-shortcuts.mjs +++ b/packages/beauticode-desktop/src/quick-shortcuts.mjs @@ -30,6 +30,7 @@ export function installHostQuickShortcut(host, options = {}) { const description = `beautiCode quick launch for ${host}`; const script = [ "$ErrorActionPreference='Stop'", + "$ProgressPreference='SilentlyContinue'", "$shell=New-Object -ComObject WScript.Shell", `$shortcutPath=${psLiteral(shortcutPath)}`, `$launcher=${psLiteral(launcher)}`, @@ -51,10 +52,13 @@ export function installHostQuickShortcut(host, options = {}) { " [Console]::Out.WriteLine('created')", '}', ].join('\n'); - const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { - encoding: 'utf8', windowsHide: true, timeout: 8_000, maxBuffer: 16 * 1024, + const result = (options.spawnSync || spawnSync)('powershell.exe', ['-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { + encoding: 'utf8', windowsHide: true, timeout: 8_000, maxBuffer: 64 * 1024, }); - if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || 'shortcut-create-failed').trim().slice(0, 200)); + if (result.error || result.status !== 0) { + const code = result.error?.code || (/shortcut-conflict/.test(String(result.stderr)) ? 'shortcut-conflict' : `exit-${result.status ?? 'unknown'}`); + throw Object.assign(new Error(`shortcut-create-failed: ${code}`), { code }); + } const outcome = String(result.stdout).trim(); if (outcome !== 'created' && outcome !== 'present') throw new Error('shortcut-result-invalid'); return { outcome, path: shortcutPath }; diff --git a/packages/beauticode-desktop/test/contract.test.mjs b/packages/beauticode-desktop/test/contract.test.mjs index 3da55c9..53c90e2 100644 --- a/packages/beauticode-desktop/test/contract.test.mjs +++ b/packages/beauticode-desktop/test/contract.test.mjs @@ -8,12 +8,12 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const here = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(here, ".."); -test("desktop aggregate manifest is a public test package with one CLI", () => { +test("desktop source workspace can be installed for cross-platform validation", () => { const manifest = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); assert.equal(manifest.name, "beauticode-desktop"); assert.equal(manifest.version, "0.1.0-test.12"); assert.equal(manifest.private, false); - assert.deepEqual(manifest.os, ["win32"]); + assert.equal(manifest.os, undefined); assert.equal(manifest.bin["beauticode-desktop"], "./bin/beauticode-desktop.mjs"); assert.ok(manifest.files.includes("runtime")); }); diff --git a/packages/beauticode-desktop/test/pack.test.mjs b/packages/beauticode-desktop/test/pack.test.mjs index bf6378a..4ac648b 100644 --- a/packages/beauticode-desktop/test/pack.test.mjs +++ b/packages/beauticode-desktop/test/pack.test.mjs @@ -40,6 +40,7 @@ test("staging produces one self-contained runtime for all five hosts", async () assert.equal(pkg.name, "beauticode-desktop"); assert.equal(pkg.version, "0.1.0-test.12"); assert.equal(pkg.private, false); + assert.deepEqual(pkg.os, ["win32"]); assert.equal(pkg.bin["beauticode-desktop"], "./bin/beauticode-desktop.mjs"); for (const [host, config] of Object.entries(HOST_RUNTIME)) { for (const relative of config.required) { diff --git a/packages/beauticode-desktop/test/quick-shortcuts.test.mjs b/packages/beauticode-desktop/test/quick-shortcuts.test.mjs index b4e153a..b6f2184 100644 --- a/packages/beauticode-desktop/test/quick-shortcuts.test.mjs +++ b/packages/beauticode-desktop/test/quick-shortcuts.test.mjs @@ -3,6 +3,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import { spawnSync } from 'node:child_process'; +import { installHostQuickShortcut } from '../src/quick-shortcuts.mjs'; test('quick shortcut survives an npm package move by pointing at the stable runtime', { skip: process.platform !== 'win32' }, async (t) => { const { installHostQuickShortcut } = await import('../src/quick-shortcuts.mjs').catch(() => ({})); @@ -18,3 +20,32 @@ test('quick shortcut survives an npm package move by pointing at the stable runt assert.equal(fs.existsSync(first.path), true); assert.equal(installHostQuickShortcut('cursor', { launcher, programsDir }).outcome, 'present'); }); + +test('shortcut process capture tolerates module progress without losing a successful outcome', { skip: process.platform !== 'win32' }, (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'beauticode-shortcut-output-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const launcher = path.join(root, 'launcher.ps1'); + fs.writeFileSync(launcher, 'exit 0\n'); + let calls = 0; + const result = installHostQuickShortcut('cursor', { + launcher, programsDir: path.join(root, 'Programs'), + spawnSync(_program, _args, options) { + calls++; + // Reproduce the size of PowerShell module-loading progress on a fresh hosted runner. + return spawnSync(process.execPath, ['--eval', "process.stderr.write('x'.repeat(48 * 1024)); process.stdout.write('created');"], options); + }, + }); + assert.equal(calls, 1); + assert.equal(result.outcome, 'created'); +}); + +test('shortcut process errors report the actual failure code instead of CLIXML progress', { skip: process.platform !== 'win32' }, (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'beauticode-shortcut-error-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const launcher = path.join(root, 'launcher.ps1'); + fs.writeFileSync(launcher, 'exit 0\n'); + assert.throws(() => installHostQuickShortcut('cursor', { + launcher, programsDir: path.join(root, 'Programs'), + spawnSync: () => ({ status: null, error: Object.assign(new Error('capture failed'), { code: 'ENOBUFS' }), stderr: '#< CLIXML progress' }), + }), (error) => error.code === 'ENOBUFS' && error.message.includes('ENOBUFS') && !error.message.includes('CLIXML')); +}); diff --git a/scripts/pack-desktop-aggregate.mjs b/scripts/pack-desktop-aggregate.mjs index bec5d6e..7df4ca4 100644 --- a/scripts/pack-desktop-aggregate.mjs +++ b/scripts/pack-desktop-aggregate.mjs @@ -166,7 +166,10 @@ export async function stageDesktopAggregate(destRoot = defaultStageDir(), opts = if (opts.build !== false) runBuild(); await fsp.rm(destRoot, { recursive: true, force: true }); await fsp.mkdir(destRoot, { recursive: true }); - await fsp.copyFile(path.join(sourceRoot, "package.json"), path.join(destRoot, "package.json")); + const manifest = JSON.parse(await fsp.readFile(path.join(sourceRoot, "package.json"), "utf8")); + // Source workspaces must install on Linux CI; only the distributable has a Windows-only runtime contract. + manifest.os = ["win32"]; + await fsp.writeFile(path.join(destRoot, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); for (const name of ["README.md", "LICENSE"]) { const source = name === "LICENSE" ? path.join(repoRoot, name) : path.join(sourceRoot, name); if (fs.existsSync(source)) await fsp.copyFile(source, path.join(destRoot, name)); From 09a32fe66e1a82d5e58b1ce4275d4b57157e5576 Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Mon, 12 Oct 2026 16:02:10 +0800 Subject: [PATCH 2/4] test(ci): use native lifecycle fixtures and expose shortcut failure diagnostics --- packages/adapter-codex/test/codex-lifecycle.test.js | 2 +- .../beauticode-desktop/test/quick-shortcuts.test.mjs | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/adapter-codex/test/codex-lifecycle.test.js b/packages/adapter-codex/test/codex-lifecycle.test.js index 10453a8..403ee00 100644 --- a/packages/adapter-codex/test/codex-lifecycle.test.js +++ b/packages/adapter-codex/test/codex-lifecycle.test.js @@ -13,7 +13,7 @@ import { import { acquireCodexGuardianLease, startCodexHelperWatchdog } from "../../../integrations/codex-desktop/codex-watchdog.mjs"; import { classifyCodexStartupProcess } from "../dist/launch.js"; -const HELPER_HOME = "C:\\Users\\me\\AppData\\Local\\beautiCode\\codex-plugin"; +const HELPER_HOME = path.resolve(os.tmpdir(), "beautiCode", "codex-plugin"); test("second outer guardian cannot acquire a live lease", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "bc-guardian-")); diff --git a/packages/beauticode-desktop/test/quick-shortcuts.test.mjs b/packages/beauticode-desktop/test/quick-shortcuts.test.mjs index b6f2184..be51db2 100644 --- a/packages/beauticode-desktop/test/quick-shortcuts.test.mjs +++ b/packages/beauticode-desktop/test/quick-shortcuts.test.mjs @@ -15,7 +15,14 @@ test('quick shortcut survives an npm package move by pointing at the stable runt fs.mkdirSync(path.dirname(launcher), { recursive: true }); fs.writeFileSync(launcher, 'exit 0\n'); const programsDir = path.join(root, 'Programs'); - const first = installHostQuickShortcut('cursor', { launcher, programsDir }); + const first = installHostQuickShortcut('cursor', { launcher, programsDir, + spawnSync(...args) { + const result = spawnSync(...args); + // Keep native diagnostics in the isolated fixture so CI can distinguish COM failures from process capture failures. + assert.equal(result.status, 0, result.error?.message || result.stderr); + return result; + }, + }); assert.equal(first.outcome, 'created'); assert.equal(fs.existsSync(first.path), true); assert.equal(installHostQuickShortcut('cursor', { launcher, programsDir }).outcome, 'present'); From 7fead3bf9664c3d10fd158e6a529f5585a4d3cd6 Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Mon, 12 Oct 2026 16:12:22 +0800 Subject: [PATCH 3/4] fix(ci): persist Unicode shortcuts through an owned ASCII alias --- ...ci-workspace-and-windows-test-isolation.md | 7 +++ .../test/wb-runner-log.test.js | 2 +- .../src/quick-shortcuts.mjs | 28 +++++++++-- .../test/quick-shortcuts.test.mjs | 46 +++++++++++++++++++ 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/docs/adr/0008-ci-workspace-and-windows-test-isolation.md b/docs/adr/0008-ci-workspace-and-windows-test-isolation.md index 64a5497..cd33acc 100644 --- a/docs/adr/0008-ci-workspace-and-windows-test-isolation.md +++ b/docs/adr/0008-ci-workspace-and-windows-test-isolation.md @@ -19,6 +19,10 @@ Windows 两个 Node 版本均在快捷方式测试中只报告被截断的 CLIXM 原有用例与超时边界保留,不通过跳过测试或无边界重试变绿。 - 快捷方式进程禁用 PowerShell 进度输出,明确文本格式,保留有界输出缓冲; 优先报告 ENOBUFS、ETIMEDOUT 等真实进程错误码,不让 CLIXML 掩盖失败原因。 + 对正常启动后的非零退出保留有界、移除路径与 URL 的诊断文本。 + 完整诊断进一步定位到 Windows 英文镜像中 WScript 无法保存中文 `.lnk` 名称; + COM 只操作私有 ASCII 临时别名,再由 .NET 文件 API 以不覆盖方式发布原中文名称。 + 读取既有快捷方式也通过该别名进行,始终保留原有归属校验及 finally 清理。 - DSH 写事务持有 busy 状态时,监视器不得读取活动清单。该读取会初始化 `active` 目录,可能在目录原子替换的中间阶段重新创建目标目录。已有读任务 仍由事务入口等待完成,后续 tick 则在入口处退出。 @@ -27,4 +31,7 @@ Windows 两个 Node 版本均在快捷方式测试中只报告被截断的 CLIXM 回归覆盖 busy 期间零读取、空闲后恢复读取、48 KiB 子进程诊断输出及错误码、 源码与发布产物的不同平台契约,并保留真实 Windows 快捷方式端到端验证。 +Linux 夹具统一采用本机绝对路径;日志失败夹具使用真实空字符,不把 Windows +非法文件名误当成跨平台非法文件名。Unicode 持久化回归以受限 COM 夹具执行真实 +PowerShell 和 .NET 文件发布,核验最终中文名称及临时文件清理。 合并前要求四个操作系统/Node 组合均通过 CI;变更没有数据迁移,可回退本提交。 diff --git a/packages/adapter-workbuddy/test/wb-runner-log.test.js b/packages/adapter-workbuddy/test/wb-runner-log.test.js index 4cc560d..2e534ff 100644 --- a/packages/adapter-workbuddy/test/wb-runner-log.test.js +++ b/packages/adapter-workbuddy/test/wb-runner-log.test.js @@ -24,7 +24,7 @@ test("runner logs without inherited stdio and rotates within a bound", async () test("log failure is nonfatal and reports once to foreground stderr", () => { const errors = []; - const logger = createWorkBuddyLogger({ file: "\\0invalid", stderr: (message) => errors.push(message) }); + const logger = createWorkBuddyLogger({ file: "\0invalid", stderr: (message) => errors.push(message) }); logger.info("one"); logger.info("two"); assert.equal(errors.length, 1); }); diff --git a/packages/beauticode-desktop/src/quick-shortcuts.mjs b/packages/beauticode-desktop/src/quick-shortcuts.mjs index 84081a1..ebe0924 100644 --- a/packages/beauticode-desktop/src/quick-shortcuts.mjs +++ b/packages/beauticode-desktop/src/quick-shortcuts.mjs @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { randomUUID } from 'node:crypto'; const NAMES = Object.freeze({ codex: 'Codex', workbuddy: 'WorkBuddy', cursor: 'Cursor', doubao: 'Doubao' }); @@ -27,37 +28,56 @@ export function installHostQuickShortcut(host, options = {}) { const programsDir = options.programsDir || path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'beautiCode'); fs.mkdirSync(programsDir, { recursive: true }); const shortcutPath = path.join(programsDir, `beautiCode ${NAMES[host]} (背景).lnk`); + // WScript shortcut persistence can reject Unicode filenames on an English Windows image. + // Use a private ASCII alias for COM reads/writes; the filesystem publishes the unchanged display name without overwrite. + const temporaryPath = path.join(programsDir, `.beauticode-${randomUUID()}.lnk`); const description = `beautiCode quick launch for ${host}`; const script = [ "$ErrorActionPreference='Stop'", "$ProgressPreference='SilentlyContinue'", - "$shell=New-Object -ComObject WScript.Shell", + "[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false)", `$shortcutPath=${psLiteral(shortcutPath)}`, + `$temporaryPath=${psLiteral(temporaryPath)}`, `$launcher=${psLiteral(launcher)}`, `$description=${psLiteral(description)}`, "$target=(Join-Path $env:WINDIR 'System32\\WindowsPowerShell\\v1.0\\powershell.exe')", " $arguments='-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File " + '"' + "' + $launcher + '" + '"' + " --quick-launch'", + 'try {', + "$shell=New-Object -ComObject WScript.Shell", "if(Test-Path -LiteralPath $shortcutPath){", - " $existing=$shell.CreateShortcut($shortcutPath)", + " [System.IO.File]::Copy($shortcutPath,$temporaryPath)", + " $existing=$shell.CreateShortcut($temporaryPath)", " if($existing.Description -ne $description -or $existing.Arguments -ne $arguments -or $existing.TargetPath -ine $target){throw 'shortcut-conflict'}", " [Console]::Out.WriteLine('present')", '}else{', - " $link=$shell.CreateShortcut($shortcutPath)", + " $link=$shell.CreateShortcut($temporaryPath)", " $link.TargetPath=$target", " $link.Arguments=$arguments", " $link.WorkingDirectory=(Split-Path -Parent $launcher)", " $link.WindowStyle=7", " $link.Description=$description", " $link.Save()", + " [System.IO.File]::Move($temporaryPath,$shortcutPath)", " [Console]::Out.WriteLine('created')", '}', + '} catch {', + " [Console]::Error.WriteLine($_.Exception.Message)", + ' exit 1', + '} finally {', + " [System.IO.File]::Delete($temporaryPath)", + '}', ].join('\n'); const result = (options.spawnSync || spawnSync)('powershell.exe', ['-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { encoding: 'utf8', windowsHide: true, timeout: 8_000, maxBuffer: 64 * 1024, }); if (result.error || result.status !== 0) { const code = result.error?.code || (/shortcut-conflict/.test(String(result.stderr)) ? 'shortcut-conflict' : `exit-${result.status ?? 'unknown'}`); - throw Object.assign(new Error(`shortcut-create-failed: ${code}`), { code }); + const detail = result.error ? '' : String(result.stderr || result.stdout || '') + .replaceAll(launcher, '[path]').replaceAll(shortcutPath, '[path]').replaceAll(temporaryPath, '[path]').replaceAll(programsDir, '[path]') + .replace(/(?:https?|file):\/\/[^\s"'<>]+/gi, '[url]') + .replace(/(?:[A-Za-z]:[\\/]|\\\\)[^"'<>\r\n]+/g, '[path]') + .replace(/[\x00-\x1f]+/g, ' ').trim().slice(0, 500); + throw Object.assign(new Error(`shortcut-create-failed: ${code}${detail ? `: ${detail}` : ''}`), { code }); } const outcome = String(result.stdout).trim(); if (outcome !== 'created' && outcome !== 'present') throw new Error('shortcut-result-invalid'); diff --git a/packages/beauticode-desktop/test/quick-shortcuts.test.mjs b/packages/beauticode-desktop/test/quick-shortcuts.test.mjs index be51db2..76a09b4 100644 --- a/packages/beauticode-desktop/test/quick-shortcuts.test.mjs +++ b/packages/beauticode-desktop/test/quick-shortcuts.test.mjs @@ -56,3 +56,49 @@ test('shortcut process errors report the actual failure code instead of CLIXML p spawnSync: () => ({ status: null, error: Object.assign(new Error('capture failed'), { code: 'ENOBUFS' }), stderr: '#< CLIXML progress' }), }), (error) => error.code === 'ENOBUFS' && error.message.includes('ENOBUFS') && !error.message.includes('CLIXML')); }); + +test('shortcut nonzero exits retain bounded diagnostics without local paths or URLs', { skip: process.platform !== 'win32' }, (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'beauticode-shortcut-detail-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const launcher = path.join(root, 'launcher.ps1'); + fs.writeFileSync(launcher, 'exit 0\n'); + assert.throws(() => installHostQuickShortcut('cursor', { + launcher, programsDir: path.join(root, 'Programs'), + spawnSync: () => ({ status: 1, stderr: `COM Save permission denied: '${launcher}' https://example.test/?token=secret ` + 'x'.repeat(4000) }), + }), (error) => error.message.includes('COM Save permission denied') && error.message.length < 600 && + !error.message.includes(root) && !error.message.includes('token=secret')); +}); + +test('shortcut publication preserves its Unicode name when native COM accepts only ASCII filenames', { skip: process.platform !== 'win32' }, (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'beauticode-shortcut-unicode-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const launcher = path.join(root, 'launcher.ps1'); + fs.writeFileSync(launcher, 'exit 0\n'); + const programsDir = path.join(root, 'Programs'); + const nativeFixture = ` +function New-Object { + param([string]$ComObject) + if ($ComObject -ne 'WScript.Shell') { throw 'unexpected COM fixture' } + $shell = [pscustomobject]@{} + $shell | Add-Member -MemberType ScriptMethod -Name CreateShortcut -Value { + param($file) + $link = [pscustomobject]@{ FullName=$file; TargetPath=''; Arguments=''; WorkingDirectory=''; WindowStyle=1; Description='' } + $link | Add-Member -MemberType ScriptMethod -Name Save -Value { + if ($this.FullName -match '[^\\x00-\\x7f]') { throw 'native-unicode-save-failed' } + [System.IO.File]::WriteAllText($this.FullName, 'fixture shortcut') + } + return $link + } + return $shell +} +`; + const result = installHostQuickShortcut('cursor', { + launcher, programsDir, + spawnSync(program, args, options) { + const script = Buffer.from(args.at(-1), 'base64').toString('utf16le'); + return spawnSync(program, [...args.slice(0, -1), Buffer.from(nativeFixture + script, 'utf16le').toString('base64')], options); + }, + }); + assert.equal(result.outcome, 'created'); + assert.deepEqual(fs.readdirSync(programsDir), ['beautiCode Cursor (背景).lnk']); +}); From 44352dbb2ec9ee3ce316e5bfefd72a8b187aa7e4 Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Mon, 12 Oct 2026 16:20:44 +0800 Subject: [PATCH 4/4] fix(ci): normalize platform fixtures and clean terminated shortcut processes --- ...ci-workspace-and-windows-test-isolation.md | 3 ++ .../src/quick-shortcuts.mjs | 41 ++++++++++++------- packages/beauticode-desktop/test/all.test.mjs | 5 ++- .../test/quick-shortcuts.test.mjs | 18 ++++++++ .../beauticode-desktop/test/route.test.mjs | 6 ++- 5 files changed, 55 insertions(+), 18 deletions(-) diff --git a/docs/adr/0008-ci-workspace-and-windows-test-isolation.md b/docs/adr/0008-ci-workspace-and-windows-test-isolation.md index cd33acc..cf7ddaa 100644 --- a/docs/adr/0008-ci-workspace-and-windows-test-isolation.md +++ b/docs/adr/0008-ci-workspace-and-windows-test-isolation.md @@ -23,6 +23,8 @@ Windows 两个 Node 版本均在快捷方式测试中只报告被截断的 CLIXM 完整诊断进一步定位到 Windows 英文镜像中 WScript 无法保存中文 `.lnk` 名称; COM 只操作私有 ASCII 临时别名,再由 .NET 文件 API 以不覆盖方式发布原中文名称。 读取既有快捷方式也通过该别名进行,始终保留原有归属校验及 finally 清理。 + 父进程另行清理本次 UUID 别名,覆盖超时或输出超限终止 PowerShell 后其 finally + 无法执行的情形;清理失败保留原操作与清理错误码,避免静默遗留开始菜单项。 - DSH 写事务持有 busy 状态时,监视器不得读取活动清单。该读取会初始化 `active` 目录,可能在目录原子替换的中间阶段重新创建目标目录。已有读任务 仍由事务入口等待完成,后续 tick 则在入口处退出。 @@ -34,4 +36,5 @@ Windows 两个 Node 版本均在快捷方式测试中只报告被截断的 CLIXM Linux 夹具统一采用本机绝对路径;日志失败夹具使用真实空字符,不把 Windows 非法文件名误当成跨平台非法文件名。Unicode 持久化回归以受限 COM 夹具执行真实 PowerShell 和 .NET 文件发布,核验最终中文名称及临时文件清理。 +Windows 注册表夹具显式使用 `path.win32` 归一化,避免 Linux 把反斜杠当作文件名字符。 合并前要求四个操作系统/Node 组合均通过 CI;变更没有数据迁移,可回退本提交。 diff --git a/packages/beauticode-desktop/src/quick-shortcuts.mjs b/packages/beauticode-desktop/src/quick-shortcuts.mjs index ebe0924..36518ef 100644 --- a/packages/beauticode-desktop/src/quick-shortcuts.mjs +++ b/packages/beauticode-desktop/src/quick-shortcuts.mjs @@ -67,19 +67,32 @@ export function installHostQuickShortcut(host, options = {}) { " [System.IO.File]::Delete($temporaryPath)", '}', ].join('\n'); - const result = (options.spawnSync || spawnSync)('powershell.exe', ['-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { - encoding: 'utf8', windowsHide: true, timeout: 8_000, maxBuffer: 64 * 1024, - }); - if (result.error || result.status !== 0) { - const code = result.error?.code || (/shortcut-conflict/.test(String(result.stderr)) ? 'shortcut-conflict' : `exit-${result.status ?? 'unknown'}`); - const detail = result.error ? '' : String(result.stderr || result.stdout || '') - .replaceAll(launcher, '[path]').replaceAll(shortcutPath, '[path]').replaceAll(temporaryPath, '[path]').replaceAll(programsDir, '[path]') - .replace(/(?:https?|file):\/\/[^\s"'<>]+/gi, '[url]') - .replace(/(?:[A-Za-z]:[\\/]|\\\\)[^"'<>\r\n]+/g, '[path]') - .replace(/[\x00-\x1f]+/g, ' ').trim().slice(0, 500); - throw Object.assign(new Error(`shortcut-create-failed: ${code}${detail ? `: ${detail}` : ''}`), { code }); + let operationError; + try { + const result = (options.spawnSync || spawnSync)('powershell.exe', ['-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { + encoding: 'utf8', windowsHide: true, timeout: 8_000, maxBuffer: 64 * 1024, + }); + if (result.error || result.status !== 0) { + const code = result.error?.code || (/shortcut-conflict/.test(String(result.stderr)) ? 'shortcut-conflict' : `exit-${result.status ?? 'unknown'}`); + const detail = result.error ? '' : String(result.stderr || result.stdout || '') + .replaceAll(launcher, '[path]').replaceAll(shortcutPath, '[path]').replaceAll(temporaryPath, '[path]').replaceAll(programsDir, '[path]') + .replace(/(?:https?|file):\/\/[^\s"'<>]+/gi, '[url]') + .replace(/(?:[A-Za-z]:[\\/]|\\\\)[^"'<>\r\n]+/g, '[path]') + .replace(/[\x00-\x1f]+/g, ' ').trim().slice(0, 500); + throw Object.assign(new Error(`shortcut-create-failed: ${code}${detail ? `: ${detail}` : ''}`), { code }); + } + const outcome = String(result.stdout).trim(); + if (outcome !== 'created' && outcome !== 'present') throw new Error('shortcut-result-invalid'); + return { outcome, path: shortcutPath }; + } catch (error) { + operationError = error; + throw error; + } finally { + try { fs.rmSync(temporaryPath, { force: true }); } + catch (error) { + const code = error.code || "unknown"; + const detail = operationError ? `${operationError.message}; ` : ""; + throw Object.assign(new Error(`${detail}shortcut-temp-cleanup-failed: ${code}`), { code }); + } } - const outcome = String(result.stdout).trim(); - if (outcome !== 'created' && outcome !== 'present') throw new Error('shortcut-result-invalid'); - return { outcome, path: shortcutPath }; } diff --git a/packages/beauticode-desktop/test/all.test.mjs b/packages/beauticode-desktop/test/all.test.mjs index 7d93e26..ccc5d2c 100644 --- a/packages/beauticode-desktop/test/all.test.mjs +++ b/packages/beauticode-desktop/test/all.test.mjs @@ -17,13 +17,14 @@ const env = { }; function fakeOptions(hostFiles, spawnSync) { - const normalized = new Set(hostFiles.map((file) => path.normalize(file))); + // This fixture models Windows install records even when the test runner itself is Linux. + const normalized = new Set(hostFiles.map((file) => path.win32.normalize(file))); return { home, runtimeRoot, env, platform: "win32", - exists: (file) => path.normalize(file).startsWith(path.normalize(runtimeRoot)) || normalized.has(path.normalize(file)), + exists: (file) => path.win32.normalize(file).startsWith(path.win32.normalize(runtimeRoot)) || normalized.has(path.win32.normalize(file)), probeAppxCodex: () => false, queryRegistered: () => [], verifyHostInstall: () => ({ ready: true, state: "guardian-ready" }), diff --git a/packages/beauticode-desktop/test/quick-shortcuts.test.mjs b/packages/beauticode-desktop/test/quick-shortcuts.test.mjs index 76a09b4..49ae1c0 100644 --- a/packages/beauticode-desktop/test/quick-shortcuts.test.mjs +++ b/packages/beauticode-desktop/test/quick-shortcuts.test.mjs @@ -102,3 +102,21 @@ function New-Object { assert.equal(result.outcome, 'created'); assert.deepEqual(fs.readdirSync(programsDir), ['beautiCode Cursor (背景).lnk']); }); + +test('parent cleans its private shortcut alias when the native process is terminated', { skip: process.platform !== 'win32' }, (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'beauticode-shortcut-timeout-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const launcher = path.join(root, 'launcher.ps1'); + fs.writeFileSync(launcher, 'exit 0\n'); + const programsDir = path.join(root, 'Programs'); + assert.throws(() => installHostQuickShortcut('cursor', { + launcher, programsDir, + spawnSync(_program, args) { + const script = Buffer.from(args.at(-1), 'base64').toString('utf16le'); + const temporary = script.match(/\$temporaryPath='([^']+)'/)[1]; + fs.writeFileSync(temporary, 'unfinished owned shortcut'); + return { status: null, error: Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }) }; + }, + }), (error) => error.code === 'ETIMEDOUT'); + assert.deepEqual(fs.readdirSync(programsDir), []); +}); diff --git a/packages/beauticode-desktop/test/route.test.mjs b/packages/beauticode-desktop/test/route.test.mjs index 93c58cc..bfd4143 100644 --- a/packages/beauticode-desktop/test/route.test.mjs +++ b/packages/beauticode-desktop/test/route.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import path from "node:path"; +import os from "node:os"; import test from "node:test"; import { HOSTS, parseCommand, resolveRoute, runHostCommand } from "../src/index.mjs"; @@ -10,12 +11,13 @@ test("per-host health commands are read-only", () => { }); test("each host resolves install and uninstall to a package-local script", () => { + const runtimeRoot = path.resolve(os.tmpdir(), "bc-package", "runtime"); for (const host of HOSTS) { for (const command of ["install", "uninstall"]) { - const route = resolveRoute(host, command, { runtimeRoot: "C:/package/runtime" }); + const route = resolveRoute(host, command, { runtimeRoot }); assert.equal(route.host, host); assert.equal(route.command, command); - assert.equal(route.script.replaceAll("\\", "/").startsWith("C:/package/runtime/"), true); + assert.equal(route.script.startsWith(runtimeRoot + path.sep), true); assert.equal(route.script.includes("desktop"), host === "cursor" || host === "doubao"); assert.ok(Array.isArray(route.args)); assert.equal(path.isAbsolute(route.script), true);