feat: bundle private Windows runtimes with settings switches

This commit is contained in:
Codex committed 2026-10-21 16:31:24 +09:00
1 parent 759df7060d
commit bef87520c2
28 files changed
+902 -10

No files matched your search

+4
View File
@@ -62,3 +62,7 @@ T01–T03 正式入口约定:模式标签通过 Node 构建夹具,再启动
3. 必要断言不能为变绿删除;失败区分产品缺陷与环境缺失。
4. 删除前检查 npm 入口、import/require、runner 动态文件列表、夹具、恢复链及当前文档;仅历史记录引用不等于运行依赖,但须给恢复路径。
5. 提交测试证据时写明受测代码 SHA、实际命令和未验证范围。默认基线通过不自动关闭需求验收。
### Windows 私有运行时
相关变更使用 `node scripts/private-runtimes-regression.cjs`、`node --test scripts/runtime-bundle.test.mjs` 和配置 `YCODE_TEST_DOM_MODULE` 后的 `node scripts/test-private-runtimes-dom.mjs`。`runtimes:prepare/verify` 校验实际包;`runtimes:smoke` 仅在 Windows x64 执行真实工具,在其他平台明确 SKIP。见 [Windows 内置工具链](../docs/Windows内置工具链.md),不计入默认离线核心测试的 Windows 覆盖。
+54
View File
@@ -0,0 +1,54 @@
// Downloads official pinned archives only. Never executes downloaded Windows files.
import fs from 'node:fs/promises'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import sevenZip from '7zip-bin'
import { transformRuntime } from './runtime-transform.mjs'
import { projectRoot, runtimeRoot, manifest, digest, inventory, verifyRuntimeBundle } from './runtime-bundle.mjs'
const cache = path.join(projectRoot, '.tmp/runtime-downloads')
await fs.mkdir(cache, { recursive: true })
const stage = await fs.mkdtemp(path.join(runtimeRoot, '.prepare-'))
try {
if (process.platform !== 'win32') await fs.chmod(sevenZip.path7za, 0o755)
for (const runtime of manifest.runtimes) {
const archive = path.join(cache, runtime.archive)
let bytes = await fs.readFile(archive).catch(() => null)
if (!bytes) {
console.log(`Downloading ${runtime.id} ${runtime.version} from ${runtime.url}`)
const response = await fetch(runtime.url, { signal: AbortSignal.timeout(300_000) })
if (!response.ok) throw new Error(`Download failed: ${response.status} ${runtime.url}`)
bytes = Buffer.from(await response.arrayBuffer())
}
if (digest(bytes, runtime.algorithm, runtime.digestEncoding) !== runtime.digest) throw new Error(`Archive integrity failure: ${runtime.id}; remove ${archive} to redownload`)
await fs.writeFile(archive, bytes)
const unpack = path.join(stage, `unpack-${runtime.id}`)
await fs.mkdir(unpack)
const result = spawnSync(sevenZip.path7za, ['x', archive, `-o${unpack}`, '-y'], { encoding: 'utf8', windowsHide: true })
if (result.error || result.status !== 0) throw new Error(`Archive extraction failed: ${runtime.id}: ${result.error || result.stderr || result.stdout}`)
const target = path.join(stage, runtime.id)
await fs.rename(path.join(unpack, runtime.prefix), target)
await transformRuntime(runtime.id, target)
// No loose mutation of the extraction lock. A source update requires reviewed tree digest changes.
const tree = digest(JSON.stringify(await inventory(target)))
if (runtime.treeSha256 && tree !== runtime.treeSha256) throw new Error(`Extracted ${runtime.id} tree mismatch: ${tree}`)
if (!runtime.treeSha256) throw new Error(`Unpinned ${runtime.id} extracted tree: ${tree}`)
}
await verifyRuntimeBundle(stage)
const destination = path.join(runtimeRoot, 'win32-x64')
const previous = `${destination}.previous`
await fs.rm(previous, { recursive: true, force: true })
const exists = await fs.stat(destination).then(() => true, () => false)
if (exists) await fs.rename(destination, previous)
try {
await fs.mkdir(destination)
for (const runtime of manifest.runtimes) await fs.rename(path.join(stage, runtime.id), path.join(destination, runtime.id))
} catch (error) {
await fs.rm(destination, { recursive: true, force: true })
if (exists) await fs.rename(previous, destination)
throw error
}
await fs.rm(previous, { recursive: true, force: true })
console.log('Windows x64 runtimes prepared; native Windows smoke tests still required.')
} finally {
await fs.rm(stage, { recursive: true, force: true })
}
+135
View File
@@ -0,0 +1,135 @@
const assert = require('node:assert/strict')
const fs = require('node:fs/promises')
const path = require('node:path')
const os = require('node:os')
const { build } = require('esbuild')
;(async () => {
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'ycode-private-runtimes-'))
let checks = 0
const check = (name, run) => { run(); checks++; console.log('PASS', name) }
try {
const pureFile = path.join(temp, 'pure.cjs')
await build({ entryPoints: ['src/main/private-runtime-environment.ts'], outfile: pureFile, bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' })
const { normalizePrivateRuntimePreferences: normalize, validatePrivateRuntimePatch: validate, privateRuntimeView: view, applyPrivateRuntimeEnvironment: apply } = require(pureFile)
const root = 'C:\\YCode Private\\resources\\runtimes\\win32-x64'
const defaults = normalize(undefined)
const all = view('win32', root, defaults, () => true)
const base = Object.freeze({ Path: 'C:\\System Tools;C:\\Windows', SystemRoot: 'C:\\Windows', TERM: 'xterm' })
check('defaults and complete bundle activate all tools', () => assert.ok(all.tools.every(tool => tool.active)))
check('private tools precede original system paths, including spaces', () => {
const result = apply(base, all)
assert.ok(result.PATH.startsWith(root + '\\node;'))
assert.ok(result.PATH.endsWith(base.Path))
assert.ok(result.PATH.includes(root + '\\python\\Scripts'))
assert.equal(result.CLAUDE_CODE_GIT_BASH_PATH, root + '\\git\\bin\\bash.exe')
assert.equal(result.Path, undefined)
assert.equal(base.Path, 'C:\\System Tools;C:\\Windows')
})
check('disabled global preference preserves original environment exactly', () => assert.deepEqual(apply(base, view('win32', root, normalize({ enabled: false }), () => true)), base))
check('per-tool disabled does not inject its paths', () => {
const result = apply(base, view('win32', root, normalize({ tools: { node: false, git: false } }), () => true))
assert.ok(!result.PATH.includes('\\node'))
assert.ok(!result.PATH.includes('\\git'))
assert.equal(result.CLAUDE_CODE_GIT_BASH_PATH, undefined)
assert.ok(result.PATH.startsWith(root + '\\python;'))
})
check('missing and incomplete bundle never reports active', () => {
for (const missing of ['node.exe', 'npm.cmd', 'npx.cmd', 'python.exe', 'pip.cmd', 'git.exe', 'bash.exe']) {
const state = view('win32', root, defaults, file => !file.endsWith(missing))
assert.equal(state.tools.filter(tool => !tool.available).length, 1)
assert.ok(state.tools.filter(tool => !tool.available).every(tool => !tool.active && tool.path === null))
}
assert.deepEqual(apply(base, view('win32', root, defaults, () => false)), base)
})
check('Linux and macOS preserve original environment', () => {
for (const platform of ['linux', 'darwin']) {
const state = view(platform, root, defaults, () => true)
assert.equal(state.supported, false)
assert.deepEqual(apply(base, state), base)
}
})
check('case-insensitive PATH becomes one canonical key and survives MCP SDK merge', () => {
const result = apply({ PATH: 'C:\\System', Path: 'C:\\Explicit', path: 'c:\\system', claude_code_git_bash_path: 'old' }, all)
assert.deepEqual(Object.keys(result).filter(key => key.toLowerCase() === 'path'), ['PATH'])
assert.equal(result.PATH.split(';').filter(item => item.toLowerCase() === 'c:\\system').length, 1)
assert.ok(({ PATH: 'C:\\System', ...result }).PATH.startsWith(root))
assert.equal(result.claude_code_git_bash_path, undefined)
})
check('overlay introduces no credentials or unrelated variables', () => {
const result = apply({ PATH: 'C:\\System' }, all)
assert.deepEqual(Object.keys(result).sort(), ['CLAUDE_CODE_GIT_BASH_PATH', 'PATH'])
})
check('writable private caches and prefixes are scoped to active tools', () => {
const dataRoot = 'C:\\Users\\test\\AppData\\YCode\\runtime-data'
const result = apply({ ...base, npm_config_prefix: 'system', pythonuserbase: 'system' }, all, dataRoot)
assert.equal(result.NPM_CONFIG_PREFIX, dataRoot + '\\node\\global')
assert.equal(result.NPM_CONFIG_CACHE, dataRoot + '\\node\\cache')
assert.equal(result.PYTHONUSERBASE, dataRoot + '\\python\\user')
assert.equal(result.PIP_CACHE_DIR, dataRoot + '\\python\\cache')
assert.ok(result.PATH.includes(dataRoot + '\\python\\user\\Python314\\Scripts'))
assert.equal(result.PYTHONHOME, undefined)
assert.equal(result.PIP_USER, undefined)
assert.equal(result.PIP_TARGET, undefined)
assert.equal(result.npm_config_prefix, undefined)
assert.equal(result.pythonuserbase, undefined)
assert.deepEqual(apply(base, view('win32', root, normalize({ enabled: false }), () => true), dataRoot), base)
const gitOnly = apply(base, view('win32', root, normalize({ tools: { node: false, python: false } }), () => true), dataRoot)
assert.equal(gitOnly.NPM_CONFIG_PREFIX, undefined)
assert.equal(gitOnly.PYTHONUSERBASE, undefined)
})
check('overlay idempotence', () => assert.deepEqual(apply(apply(base, all), all), apply(base, all)))
check('IPC patch validation rejects malformed and unknown values', () => {
for (const bad of [null, [], 'yes', { enabled: 'true' }, { tools: null }, { tools: [] }, { tools: { ruby: true } }, { tools: { node: 1 } }, { root: 'C:\\evil' }]) assert.throws(() => validate(bad))
assert.deepEqual(validate({ enabled: false, tools: { node: true } }), { enabled: false, tools: { node: true } })
})
const userData = path.join(temp, 'user-data')
await fs.mkdir(userData)
await fs.writeFile(path.join(userData, 'config.json'), JSON.stringify({ apiKeyPlain: 'persistence-only-test', unrelated: 'preserved' }))
async function service(name) {
const outfile = path.join(temp, name + '.cjs')
await build({ entryPoints: ['src/main/private-runtimes.ts'], outfile, bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent', plugins: [{ name: 'isolated-electron', setup(build) {
build.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'mock' }))
build.onLoad({ filter: /.*/, namespace: 'mock' }, () => ({ contents: `export const app = { isPackaged:false,getPath:()=>${JSON.stringify(userData)},getAppPath:()=>${JSON.stringify(temp)} };export const safeStorage={};`, loader: 'js' }))
} }] })
return require(outfile)
}
const api = await service('service')
await Promise.all([api.updatePrivateRuntimes({ tools: { node: false } }), api.updatePrivateRuntimes({ tools: { git: false } }), api.updatePrivateRuntimes({ enabled: false })])
const saved = JSON.parse(await fs.readFile(path.join(userData, 'private-runtimes.json'), 'utf8'))
const unrelated = JSON.parse(await fs.readFile(path.join(userData, 'config.json'), 'utf8'))
check('serialized updates merge toggles and preserve unrelated config', () => {
assert.deepEqual(saved, { enabled: false, tools: { node: false, python: true, git: false } })
assert.equal(unrelated.unrelated, 'preserved')
assert.equal(unrelated.apiKeyPlain, 'persistence-only-test')
})
const fresh = await service('reloaded')
check('settings survive module restart without exposing config credentials', () => {
assert.ok(!JSON.stringify(all).includes('persistence-only-test'))
})
assert.deepEqual((await fresh.getPrivateRuntimes()).preferences, saved)
assert.ok(!JSON.stringify(await fresh.getPrivateRuntimes()).includes('persistence-only-test'))
// Force temporary-file creation failure; the committed preferences remain readable.
await fs.mkdir(path.join(userData, 'private-runtimes.json.tmp'))
await assert.rejects(api.updatePrivateRuntimes({ enabled: true }))
assert.equal((await api.getPrivateRuntimes()).preferences.enabled, false)
check('failed atomic save leaves the committed enable policy unchanged', () => {})
await fs.rm(path.join(userData, 'private-runtimes.json.tmp'), { recursive: true })
await api.updatePrivateRuntimes({ enabled: true })
assert.equal((await api.getPrivateRuntimes()).preferences.enabled, true)
const settingsPath = path.join(userData, 'private-runtimes.json')
const validSettings = await fs.readFile(settingsPath, 'utf8')
for (const corrupt of ['{broken', '{}', 'null', '{"enabled":"true","tools":{}}']) {
await fs.writeFile(settingsPath, corrupt)
await assert.rejects(api.getPrivateRuntimes())
await assert.rejects(api.updatePrivateRuntimes({ enabled: true }))
}
await fs.rm(settingsPath)
await fs.mkdir(settingsPath)
await assert.rejects(api.getPrivateRuntimes())
await fs.rm(settingsPath, { recursive: true })
await fs.writeFile(settingsPath, validSettings)
check('corrupt, incomplete, and unreadable stored policy never silently enables tools', () => {})
console.log(`Private runtimes: ${checks} regression groups passed; no Windows executables run.`)
} finally { await fs.rm(temp, { recursive: true, force: true }) }
})().catch(error => { console.error(error); process.exitCode = 1 })
+7
View File
@@ -0,0 +1,7 @@
module.exports = async function beforePack(context) {
if (context.electronPlatformName !== 'win32' || context.arch !== 1) {
throw new Error('YCode bundled runtimes support Windows x64 only; do not ship x64 runtimes for another target.')
}
const { verifyRuntimeBundle } = await import('./runtime-bundle.mjs')
await verifyRuntimeBundle()
}
+43
View File
@@ -0,0 +1,43 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
export const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
export const runtimeRoot = path.join(projectRoot, 'resources/runtimes')
export const manifest = JSON.parse(await fs.readFile(path.join(runtimeRoot, 'manifest.json'), 'utf8'))
export const digest = (bytes, algorithm = 'sha256', encoding = 'hex') => crypto.createHash(algorithm).update(bytes).digest(encoding)
export async function inventory(root) {
const files = {}
async function visit(dir) {
for (const entry of (await fs.readdir(dir, { withFileTypes: true })).sort((a,b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
const absolute = path.join(dir, entry.name)
if (entry.isSymbolicLink()) throw new Error(`Runtime symlink is not allowed: ${absolute}`)
if (entry.isDirectory()) await visit(absolute)
else if (entry.isFile()) files[path.relative(root, absolute).split(path.sep).join('/')] = digest(await fs.readFile(absolute))
else throw new Error(`Unsupported runtime entry: ${absolute}`)
}
}
await visit(root)
return files
}
export async function verifyRuntimeBundle(root = path.join(runtimeRoot, 'win32-x64')) {
for (const runtime of manifest.runtimes) {
const dir = path.join(root, runtime.id)
for (const required of runtime.required) {
const file = path.join(dir, required)
const bytes = await fs.readFile(file).catch(() => { throw new Error(`Missing bundled ${runtime.id}: ${required}. Run npm run runtimes:prepare.`) })
if (!bytes.length) throw new Error(`Empty runtime asset: ${file}`)
if (required.endsWith('.exe')) {
if (bytes.length < 64 || bytes.subarray(0, 2).toString() !== 'MZ') throw new Error(`Invalid Windows executable: ${file}`)
const pe = bytes.readUInt32LE(0x3c)
if (pe > bytes.length - 6 || bytes.subarray(pe, pe + 4).toString('hex') !== '50450000' || bytes.readUInt16LE(pe + 4) !== 0x8664) {
throw new Error(`Runtime executable is not Windows x64 PE: ${file}`)
}
}
}
const files = await inventory(dir)
const actual = digest(JSON.stringify(files))
if (!runtime.treeSha256 || actual !== runtime.treeSha256) throw new Error(`Bundled ${runtime.id} tree checksum mismatch: ${actual}; expected ${runtime.treeSha256}. Run npm run runtimes:prepare.`)
console.log(`${runtime.id} ${runtime.version}: verified ${Object.keys(files).length} files (${actual})`)
}
}
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { createRequire } from 'node:module'
import { digest, inventory, manifest, verifyRuntimeBundle } from './runtime-bundle.mjs'
import { transformRuntime } from './runtime-transform.mjs'
const require = createRequire(import.meta.url)
test('source lock uses reviewed official HTTPS endpoints and fixed digests', () => {
for (const r of manifest.runtimes) {
assert.ok(['nodejs.org','api.nuget.org','github.com'].includes(new URL(r.url).hostname))
assert.equal(new URL(r.url).protocol, 'https:')
assert.match(r.treeSha256, /^[a-f0-9]{64}$/)
assert.equal(Buffer.from(r.digest, r.digestEncoding).length, r.algorithm === 'sha512' ? 64 : 32)
}
})
test('inventory is deterministic and notices content changes', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ycode-inventory-'))
try {
await fs.writeFile(path.join(root, 'a'), 'one')
const first = digest(JSON.stringify(await inventory(root)))
assert.equal(first, digest(JSON.stringify(await inventory(root))))
await fs.writeFile(path.join(root, 'a'), 'two')
assert.notEqual(first, digest(JSON.stringify(await inventory(root))))
await assert.rejects(verifyRuntimeBundle(root), /Missing bundled/)
} finally { await fs.rm(root, { recursive:true, force:true }) }
})
test('Python convenience launcher is relocatable and uses module pip', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ycode-python-'))
try {
await transformRuntime('python', root)
assert.equal(await fs.readFile(path.join(root,'Scripts/pip.cmd'),'utf8'), '@echo off\r\n"%~dp0..\\python.exe" -m pip %*\r\n')
} finally { await fs.rm(root,{recursive:true,force:true}) }
})
test('packaging rejects wrong platform and wrong arch', async () => {
const beforePack = require('./runtime-before-pack.cjs')
await assert.rejects(beforePack({electronPlatformName:'win32',arch:3}), /Windows x64 only/)
await assert.rejects(beforePack({electronPlatformName:'linux',arch:1}), /Windows x64 only/)
})
+33
View File
@@ -0,0 +1,33 @@
import fs from 'node:fs/promises'
import path from 'node:path'
// Deterministic extraction transforms. No native Windows programs run here.
export async function transformRuntime(id, target) {
if (id === 'node') {
for (const name of ['npm.ps1', 'npx.ps1']) await fs.rm(path.join(target, name), { force: true })
}
if (id === 'python') {
await fs.mkdir(path.join(target, 'Scripts'), { recursive: true })
const wrapper = '@echo off\r\n"%~dp0..\\python.exe" -m pip %*\r\n'
for (const name of ['pip.cmd', 'pip3.cmd']) await fs.writeFile(path.join(target, 'Scripts', name), wrapper)
}
if (id === 'git') {
// PortableGit's 13-copy-dlls.post normally hardlinks these after extraction.
// Copies are relocation-safe and survive installer/archive packaging.
const bin = path.join(target, 'ucrt64/bin')
const exec = path.join(target, 'ucrt64/libexec/git-core')
for (const name of (await fs.readdir(bin)).filter(name => name.endsWith('.dll')).sort()) {
await fs.copyFile(path.join(bin, name), path.join(exec, name))
}
await fs.copyFile(path.join(bin, 'git.exe'), path.join(exec, 'dlls-copied'))
for (const name of ['shm', 'mqueue']) {
await fs.mkdir(path.join(target, 'dev', name), { recursive: true })
await fs.writeFile(path.join(target, 'dev', name, 'YCODE-KEEP.txt'), '')
}
const audit = path.join(target, 'ycode-upstream-post-install')
await fs.mkdir(audit, { recursive: true })
await fs.rename(path.join(target, 'etc/post-install'), path.join(audit, 'scripts'))
await fs.rename(path.join(target, 'post-install.bat'), path.join(audit, 'post-install.bat'))
// Do not snapshot build-host /etc or synthesize Windows MSYS symlinks on Linux.
// Upstream scripts are moved out of etc/post-install so login Bash cannot mutate resources.
}
}
+39
View File
@@ -0,0 +1,39 @@
// Offline, isolated native smoke. Never call this a Windows pass on another OS.
import fs from 'node:fs/promises'
import path from 'node:path'
import os from 'node:os'
import { spawnSync } from 'node:child_process'
import { runtimeRoot, verifyRuntimeBundle } from './runtime-bundle.mjs'
if (process.platform !== 'win32' || process.arch !== 'x64') {
console.log('SKIP: native toolchain smoke requires Windows x64; no Windows binaries executed.')
process.exit(0)
}
const root = path.resolve(process.argv[2] || path.join(runtimeRoot, 'win32-x64'))
await verifyRuntimeBundle(root)
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'YCode runtime 中文 '))
try {
const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(?:path|home|userprofile|temp|tmp|python.*|pip_.*|npm_config_.*|node_options|git_.*)$/i.test(key)))
const env = { ...inherited, HOME: temp, USERPROFILE: temp, TEMP: temp, TMP: temp,
npm_config_userconfig: path.join(temp, 'npmrc'), npm_config_globalconfig: path.join(temp, 'npm-globalrc'),
npm_config_cache: path.join(temp, 'npm-cache'), npm_config_prefix: path.join(temp, 'npm-prefix'),
PYTHONUSERBASE: path.join(temp, 'python-user'), PIP_CACHE_DIR: path.join(temp, 'pip-cache'),
PYTHONNOUSERSITE: '1', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: path.join(temp, 'gitconfig'),
PATH: [path.join(root, 'node'), path.join(root, 'python'), path.join(root, 'git/cmd'), path.join(root, 'git/bin'), path.join(process.env.SystemRoot || 'C:\\Windows', 'System32')].join(';') }
const run = (exe, args) => {
const r = spawnSync(exe, args, { cwd: temp, env, encoding: 'utf8', timeout: 60_000, windowsHide: true })
if (r.error || r.status !== 0) throw new Error(`${exe} ${args.join(' ')} failed: ${r.error || r.stderr || r.stdout}`)
console.log(r.stdout.trim())
}
const node = path.join(root, 'node/node.exe'), python = path.join(root, 'python/python.exe')
run(node, ['--version'])
// Explicit cmd.exe, fixed arguments only; downloaded npm/npx scripts never use shell interpolation from user input.
for (const command of ['npm', 'npx']) run(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `""${path.join(root, `node/${command}.cmd`)}" --version"`])
run(python, ['--version']); run(python, ['-m', 'pip', '--version'])
run(python, ['-m', 'venv', path.join(temp, 'venv')])
run(path.join(temp, 'venv/Scripts/python.exe'), ['-m', 'pip', '--version'])
const git = path.join(root, 'git/cmd/git.exe'), bash = path.join(root, 'git/bin/bash.exe')
run(git, ['--version']); run(git, ['init', '--quiet', path.join(temp, 'repo')])
run(bash, ['--version']); run(bash, ['--noprofile', '--norc', '-c', 'printf "ycode-bash-ok\\n"'])
await verifyRuntimeBundle(root)
console.log('PASS: offline Windows x64 runtime smoke; resources unchanged. Network package installs and HTTPS Git remain untested.')
} finally { await fs.rm(temp, { recursive: true, force: true }) }
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { JSDOM } = require(process.env.YCODE_TEST_DOM_MODULE || 'jsdom')
const output = path.resolve('.tmp/private-runtimes-dom')
fs.mkdirSync(output, { recursive: true })
fs.writeFileSync(path.join(output, 'fixture.tsx'), `import React from 'react';import {createRoot} from 'react-dom/client';import Panel from ${JSON.stringify(path.resolve('src/renderer/src/components/PrivateRuntimesSettingsPanel.tsx'))};let root;window.mount=()=>{root=createRoot(document.getElementById('root'));root.render(<React.StrictMode><Panel/></React.StrictMode>)};window.unmount=()=>root.unmount();window.mount();`)
await require('esbuild').build({entryPoints:[path.join(output,'fixture.tsx')],outfile:path.join(output,'fixture.js'),bundle:true,platform:'browser',format:'iife',jsx:'automatic',logLevel:'error'})
const dom = new JSDOM('<html><body><div id="root"></div></body></html>',{url:'https://fixture.invalid',runScripts:'outside-only',pretendToBeVisual:true})
const w=dom.window,d=w.document,checks=[],errors=[],calls=[]
const ids=['node','python','git']
let preferences={enabled:true,tools:{node:true,python:true,git:true}},supported=true,missing=[],failGet=false,failUpdate=false,hold=null,delay=false
const snapshot=()=>({platform:supported?'win32':'linux',supported,preferences:structuredClone(preferences),tools:ids.map(id=>({id,available:!missing.includes(id),enabled:preferences.tools[id],active:supported&&!missing.includes(id)&&preferences.enabled&&preferences.tools[id],path:missing.includes(id)?null:`C:\\runtimes\\${id}`}))})
w.ycode={privateRuntimes:{get:async()=>{if(failGet)throw Error('read failure');return snapshot()},update:async patch=>{calls.push(patch);if(delay)await new Promise(resolve=>hold=resolve);if(failUpdate)throw Error('write failure');preferences={...preferences,...patch,tools:{...preferences.tools,...patch.tools}};return snapshot()}}}
w.addEventListener('error',event=>errors.push(event.message))
const pause=()=>new Promise(resolve=>setTimeout(resolve,15))
const wait=async fn=>{for(let i=0;i<150;i++){if(fn())return;await pause()}throw Error('Timed out: '+fn)}
const check=(name,value)=>{assert.ok(value,name);checks.push(name)}
const control=name=>d.querySelector(`[aria-label="${name}"]`)
const master=()=>control('启用内置运行时'),node=()=>control('启用 Node.js'),python=()=>control('启用 Python')
const remount=async()=>{w.unmount();await pause();w.mount();await wait(()=>master()&&!d.body.textContent.includes('正在读取内置运行时…'));await pause()}
try {
w.eval(fs.readFileSync(path.join(output,'fixture.js'),'utf8'))
await wait(()=>master()&&!master().disabled)
check('exactly four accessible switches',d.querySelectorAll('[role=switch]').length===4)
check('ready status derives from backend',d.querySelector('[data-runtime=node] small').textContent.includes('已启用'))
delay=true;node().click();node().click();await wait(()=>hold)
check('duplicate clicks produce one patch',calls.length===1)
check('saving retains acknowledged value and disables all controls',node().checked&&[...d.querySelectorAll('input')].every(el=>el.disabled)&&d.body.textContent.includes('正在保存'))
hold();await wait(()=>!node().checked&&!node().disabled);delay=false
check('tool patch is narrow',JSON.stringify(calls[0])===JSON.stringify({tools:{node:false}}))
master().click();await wait(()=>!master().checked&&!master().disabled)
check('master off preserves individual choices',!node().checked&&python().checked&&node().disabled&&python().disabled)
master().click();await wait(()=>master().checked&&!node().disabled)
check('master on restores choices',!node().checked&&python().checked)
failUpdate=true;python().click();await wait(()=>d.querySelector('[role=alert]'))
check('failed save does not change persisted checkmark',python().checked&&preferences.tools.python&&d.body.textContent.includes('write failure'))
failUpdate=false;python().click();await wait(()=>!python().checked)
check('retry succeeds and clears error',!d.querySelector('[role=alert]'))
await remount();check('reopen reloads saved choices',!node().checked&&!python().checked&&master().checked)
missing=['git'];await remount()
check('missing bundle is disabled with honest status',control('启用 Git Bash').disabled&&d.querySelector('[data-runtime=git]').textContent.includes('未随包提供'))
supported=false;await remount()
check('non-Windows disables controls and explains support',[...d.querySelectorAll('input')].every(el=>el.disabled)&&d.body.textContent.includes('仅支持 Windows x64'))
failGet=true;await remount()
check('read failure offers recovery without enabling controls',d.body.textContent.includes('read failure')&&[...d.querySelectorAll('input')].every(el=>el.disabled))
failGet=false;supported=true;missing=[];d.querySelector('button').click();await wait(()=>!master().disabled)
check('read retry recovers actual state',!d.querySelector('[role=alert]')&&!node().checked)
delay=true;node().click();await wait(()=>hold);w.unmount();hold();await pause();delay=false;w.mount();await wait(()=>master()&&!master().disabled)
check('save acknowledged after navigation is read on reopen',node().checked)
check('no unrelated security controls',!d.body.textContent.includes('删除保护')&&!d.body.textContent.includes('审计中心'))
check('future process and system fallback warning present',d.body.textContent.includes('重新打开终端或启动新会话')&&d.body.textContent.includes('系统已安装的工具仍可能可用'))
check('no uncaught renderer errors',errors.length===0)
console.log(JSON.stringify({result:'PASS',checks,limits:'Controlled DOM/API fixture, not Windows execution or screenshot pixel parity.'},null,2))
}finally{w.unmount();dom.window.close()}
+2
View File
@@ -0,0 +1,2 @@
import { verifyRuntimeBundle } from './runtime-bundle.mjs'
await verifyRuntimeBundle(process.argv[2])