feat: ship skill and MCP workflows with signed updater for v0.0.7

This commit is contained in:
xiaohan.yuan committed 2026-10-21 00:04:17 +08:00
1 parent 1b52df0603
commit a01085c656
136 files changed
+18646 -217

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
import { build } from 'esbuild'
await build({
entryPoints: ['scripts/claude-append-protocol-probe.ts'],
outfile: '.tmp/claude-append-protocol-probe.mjs',
bundle: true,
platform: 'node',
format: 'esm',
external: ['@anthropic-ai/claude-agent-sdk'],
logLevel: 'warning'
})
@@ -8,6 +8,9 @@ await build({
format: 'esm',
external: ['@anthropic-ai/claude-agent-sdk'],
logLevel: 'warning',
banner: {
js: `import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);`
},
plugins: [{
name: 'claude-kernel-electron-stub',
setup(plugin) {
@@ -19,6 +22,7 @@ await build({
getPath: (name) => name === 'userData' ? process.env.CLAUDE_TEST_USER_DATA : process.cwd(),
getAppPath: () => process.cwd()
};
export const safeStorage = { isEncryptionAvailable: () => false };
`
}))
}
@@ -0,0 +1,10 @@
import { build } from 'esbuild'
await build({
entryPoints: ['scripts/request-settings-regression.ts'],
outfile: '.tmp/request-settings-regression.mjs',
bundle: true,
platform: 'node',
format: 'esm',
logLevel: 'warning'
})
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict'
import { renderToStaticMarkup } from 'react-dom/server'
import {
CapabilityPicker,
capabilitySelectionKey,
detectCapabilitySlashTrigger,
filterCapabilityItems,
resolveCapabilityPickerKey,
type CapabilityPickerItem
} from '../src/renderer/src/workbuddy/composer/CapabilityPicker'
const items: CapabilityPickerItem[] = [
{ id: 'skill:review', name: '代码审查', description: '检查实现缺陷与风险', kind: 'skill' },
{ id: 'mcp-server', name: 'postgres', description: '查询本地数据库', kind: 'mcp' },
{ id: 'compact', name: 'compact', description: '压缩当前上下文', kind: 'command' }
]
const checks: string[] = []
const check = (name: string, actual: unknown, expected: unknown): void => {
assert.deepEqual(actual, expected, name)
checks.push(name)
}
check('empty query preserves registration order', filterCapabilityItems(items, ''), items)
check('filter matches names and IDs case-insensitively',
filterCapabilityItems(items, '/POST').map(item => item.id), ['mcp-server'])
check('filter matches descriptions', filterCapabilityItems(items, '上下文').map(item => item.id), ['compact'])
check('filter matches kind labels', filterCapabilityItems(items, '命令').map(item => item.id), ['compact'])
check('multiple terms must all match', filterCapabilityItems(items, '数据库 本地').map(item => item.id), ['mcp-server'])
check('selection identity includes kind', capabilitySelectionKey({ id: 'compact', kind: 'command' }), 'command:compact')
check('slash at input start is detected',
detectCapabilitySlashTrigger('/comp', 5, items), { start: 0, end: 5, query: 'comp' })
const independent = '请使用 /mcp'
check('independent slash token is detected',
detectCapabilitySlashTrigger(independent, independent.length, items), { start: 4, end: independent.length, query: 'mcp' })
check('plain slash opens the catalog when items exist',
detectCapabilitySlashTrigger('/', 1, items), { start: 0, end: 1, query: '' })
for (const value of ['https://example.com/a', 'src/renderer/file.ts', 'C:\\work\\file.ts', '/Users/me/file.txt', 'file:///tmp/a']) {
check('path or URL does not trigger: ' + value, detectCapabilitySlashTrigger(value, value.length, items), null)
}
check('unknown leading token is rejected when catalog is available',
detectCapabilitySlashTrigger('/Users', 6, items), null)
check('slash inside ordinary text is not independent',
detectCapabilitySlashTrigger('path/compact', 12, items), null)
check('IME composition suppresses Enter',
resolveCapabilityPickerKey({ key: 'Enter', isComposing: true, itemCount: 3, activeIndex: 1 }), { type: 'none' })
check('IME keyCode 229 suppresses selection',
resolveCapabilityPickerKey({ key: 'Enter', keyCode: 229, itemCount: 3, activeIndex: 1 }), { type: 'none' })
check('ArrowDown wraps', resolveCapabilityPickerKey({ key: 'ArrowDown', itemCount: 3, activeIndex: 2 }),
{ type: 'move', index: 0 })
check('ArrowUp wraps', resolveCapabilityPickerKey({ key: 'ArrowUp', itemCount: 3, activeIndex: 0 }),
{ type: 'move', index: 2 })
check('Home and End select boundaries',
[resolveCapabilityPickerKey({ key: 'Home', itemCount: 3, activeIndex: 2 }),
resolveCapabilityPickerKey({ key: 'End', itemCount: 3, activeIndex: 0 })],
[{ type: 'move', index: 0 }, { type: 'move', index: 2 }])
check('Enter selects the active item',
resolveCapabilityPickerKey({ key: 'Enter', itemCount: 3, activeIndex: 1 }), { type: 'select', index: 1 })
check('Escape closes', resolveCapabilityPickerKey({ key: 'Escape', itemCount: 3, activeIndex: 1 }), { type: 'close' })
check('Enter is ignored on an empty result', resolveCapabilityPickerKey({ key: 'Enter', itemCount: 0, activeIndex: 0 }),
{ type: 'none' })
const html = renderToStaticMarkup(
<CapabilityPicker items={items} selected={[{ id: 'skill:review', kind: 'skill' }]}
onSelect={() => undefined} onClose={() => undefined} />
)
check('SSR renders combobox and listbox', [html.includes('role="combobox"'), html.includes('role="listbox"')], [true, true])
check('SSR exposes type labels', ['技能', 'MCP', '命令'].every(label => html.includes(label)), true)
check('SSR marks selected identity', html.includes('aria-selected="true"'), true)
check('SSR renders descriptions', html.includes('检查实现缺陷与风险'), true)
check('SSR seeds external query',
renderToStaticMarkup(<CapabilityPicker items={items} selected={[]} onSelect={() => undefined}
onClose={() => undefined} query="compact" />).includes('data-kind="command"'), true)
check('SSR renders explicit empty state',
renderToStaticMarkup(<CapabilityPicker items={items} selected={[]} onSelect={() => undefined}
onClose={() => undefined} query="missing" />).includes('没有匹配的能力'), true)
console.log(JSON.stringify({ result: 'PASS', checks: checks.length }))
+2 -1
View File
@@ -124,7 +124,8 @@ async function main(): Promise<void> {
export const ipcMain = {
handle: (name, handler) => state.handlers.set(name, handler)
};
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }), showSaveDialog: async () => ({ canceled: true }) };
export const webContents = { getAllWebContents: () => [], fromId: () => undefined };
export const shell = { openPath: async () => '' };
export const powerSaveBlocker = { start: () => 1, stop: () => true, isStarted: () => false };
export class BrowserWindow {}
+47 -2
View File
@@ -92,7 +92,7 @@ function resetStore(): void {
})
}
function installFakeWindow(send: (payload: { sessionId: string; text: string }) => Promise<SendResult>): void {
function installFakeWindow(send: (payload: Parameters<typeof window.ycode.chat.send>[0]) => Promise<SendResult>): void {
const api = {
session: {
list: async () => [session],
@@ -718,7 +718,7 @@ async function testEarlyOrderedEventsAndDuplicateGuard(): Promise<void> {
const firstSend = useChatStore.getState().send('question')
assert.equal(useChatStore.getState().sending, true, 'sending should cover the pending IPC request')
assert.equal(useChatStore.getState().messages.length, 0, 'early events wait for the send result placeholder')
await useChatStore.getState().send('duplicate')
await assert.rejects(useChatStore.getState().send('duplicate'), /提交/)
assert.equal(sendCalls, 1, 'a second send must be ignored while the first send is pending')
response.resolve(result)
@@ -929,7 +929,32 @@ async function testParallelSessionsAndStaleLoads(): Promise<void> {
assert.equal(useChatStore.getState().messages[0].content, 'fast', 'stale session fetch cannot replace a newer selection')
}
async function testNativeSegmentBoundaryBeforeSendResponse(): Promise<void> {
resetStore()
const gate = deferred<SendResult>()
installFakeWindow(async () => gate.promise)
const sending = useChatStore.getState().send('root')
const runId = 'native-segment'
const root = sendResult(runId)
useChatStore.getState().applyEvent({ runId, sessionId: session.id, sequence: 1, type: 'text', delta: 'root answer' })
const guide: ChatMessage = { id: 'guide-user', role: 'user', content: 'guidance', createdAt: 12, status: 'done' }
const answer: ChatMessage = { id: 'guide-assistant', role: 'assistant', content: '', createdAt: 13, status: 'streaming', requestId: runId }
useChatStore.getState().observeRun({ ...root, sessionId: session.id, sequence: 2, segmentBoundary: true,
assistantMessageId: answer.id, userMessage: guide, messages: [root.userMessage,
{ id: root.assistantMessageId, role: 'assistant', content: 'root answer', createdAt: 11, status: 'done' }, guide, answer] })
useChatStore.getState().applyEvent({ runId, sessionId: session.id, sequence: 3, type: 'text', delta: 'guide answer' })
gate.resolve(root)
await sending
useChatStore.getState().applyEvent({ runId, sessionId: session.id, sequence: 4, type: 'done', content: 'guide answer', tools: [] })
const messages = useChatStore.getState().messages
assert.equal(messages.find(message => message.id === root.assistantMessageId)?.content, 'root answer')
assert.equal(messages.find(message => message.id === answer.id)?.content, 'guide answer')
assert.equal(messages.find(message => message.id === answer.id)?.status, 'done')
assert.equal(useChatStore.getState().running, null)
}
async function main(): Promise<void> {
await testNativeSegmentBoundaryBeforeSendResponse()
resetStore()
let inlinePayload: Parameters<typeof window.ycode.chat.send>[0] | undefined
installFakeWindow(async payload => { inlinePayload = payload; return sendResult('inline-layout') })
@@ -947,6 +972,26 @@ async function main(): Promise<void> {
installFakeWindow(async () => { referenceOnlySends++; return sendResult('skill-only') })
await useChatStore.getState().send('', { skillIds: ['skill'] })
assert.equal(referenceOnlySends, 2, 'a skill-only draft must reach IPC')
let capabilityPayload: Parameters<typeof window.ycode.chat.send>[0] | undefined
resetStore()
installFakeWindow(async payload => { capabilityPayload = payload; return sendResult('mcp-only') })
await useChatStore.getState().send('', { mcpServerIds: ['server'] })
assert.deepEqual(capabilityPayload?.mcpServerIds, ['server'], 'an MCP-only draft must reach IPC with its selected server')
resetStore()
installFakeWindow(async payload => { capabilityPayload = payload; return sendResult('compact-only') })
await useChatStore.getState().send('/compact', { command: 'compact' })
assert.equal(capabilityPayload?.command, 'compact', 'a command-only draft must reach IPC as a native command')
resetStore()
useChatStore.setState({ currentSessionId: null, messages: [], draftContext: {}, draftId: 7 })
const deferredResult = deferred<SendResult>()
installFakeWindow(async () => deferredResult.promise)
const optimisticSend = useChatStore.getState().send('', { skillIds: ['optimistic-skill'], mcpServerIds: ['optimistic-server'] })
const optimisticUser = useChatStore.getState().messages.find(message => message.role === 'user')
assert.deepEqual(optimisticUser?.skills?.map(item => item.id), ['optimistic-skill'], 'the optimistic user message must retain selected skill metadata')
assert.deepEqual(optimisticUser?.mcpServerIds, ['optimistic-server'], 'the optimistic user message must retain MCP selection metadata')
deferredResult.resolve(sendResult('optimistic-mcp'))
await optimisticSend
await testFailedCreatedSessionIsRemoved()
await testFailedCreatedSessionWithMessagesIsRetained()
await testUnsafeCleanupDoesNotMasqueradeAsHome()
+533
View File
@@ -0,0 +1,533 @@
import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createServer, type ServerResponse } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
interface CapturedRequest {
index: number
url: string
headers: Record<string, string | string[] | undefined>
body: Record<string, unknown>
userTexts: string[]
latestText: string
completed: boolean
aborted: boolean
responseText?: string
}
interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
}
class AsyncQueue<T> implements AsyncIterable<T> {
private readonly values: T[] = []
private readonly waiters: Array<(result: IteratorResult<T>) => void> = []
private closed = false
push(value: T): void {
if (this.closed) throw new Error('input queue is closed')
const waiter = this.waiters.shift()
if (waiter) waiter({ value, done: false })
else this.values.push(value)
}
close(): void {
if (this.closed) return
this.closed = true
for (const waiter of this.waiters.splice(0)) waiter({ value: undefined, done: true })
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: () => {
const value = this.values.shift()
if (value !== undefined) return Promise.resolve({ value, done: false })
if (this.closed) return Promise.resolve({ value: undefined, done: true })
return new Promise<IteratorResult<T>>((resolve) => this.waiters.push(resolve))
}
}
}
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise })
return { promise, resolve }
}
function sse(res: ServerResponse, event: string, data: unknown): void {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
}
function messageStart(res: ServerResponse, id: string): void {
sse(res, 'message_start', {
type: 'message_start',
message: {
id,
type: 'message',
role: 'assistant',
model: 'claude-append-probe',
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 8, output_tokens: 1 }
}
})
}
function textBlock(res: ServerResponse, text: string): void {
sse(res, 'content_block_start', {
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: '' }
})
sse(res, 'content_block_delta', {
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text }
})
sse(res, 'content_block_stop', { type: 'content_block_stop', index: 0 })
}
function messageEnd(res: ServerResponse, stopReason = 'end_turn'): void {
sse(res, 'message_delta', {
type: 'message_delta',
delta: { stop_reason: stopReason, stop_sequence: null },
usage: { output_tokens: 4 }
})
sse(res, 'message_stop', { type: 'message_stop' })
res.end()
}
function plainText(res: ServerResponse, id: string, text: string): void {
messageStart(res, id)
textBlock(res, text)
messageEnd(res)
}
function contentTexts(body: Record<string, unknown>): string[] {
const messages = Array.isArray(body.messages) ? body.messages : []
const texts: string[] = []
for (const rawMessage of messages) {
if (!rawMessage || typeof rawMessage !== 'object') continue
const message = rawMessage as Record<string, unknown>
if (message.role !== 'user') continue
const content = message.content
if (typeof content === 'string') {
texts.push(content)
continue
}
if (!Array.isArray(content)) continue
for (const block of content) {
if (!block || typeof block !== 'object') continue
const item = block as Record<string, unknown>
if (item.type === 'text' && typeof item.text === 'string') texts.push(item.text)
}
}
return texts
}
function requestTrace(body: Record<string, unknown>): Array<Record<string, unknown>> {
const messages = Array.isArray(body.messages) ? body.messages : []
return messages.flatMap((rawMessage) => {
if (!rawMessage || typeof rawMessage !== 'object') return []
const message = rawMessage as Record<string, unknown>
const content = message.content
const parts = typeof content === 'string'
? [{ type: 'text', text: content }]
: Array.isArray(content)
? content.flatMap((block) => {
if (!block || typeof block !== 'object') return []
const item = block as Record<string, unknown>
return item.type === 'text' && typeof item.text === 'string'
? [{ type: 'text', text: item.text }]
: [{ type: String(item.type) }]
})
: []
const text = parts.flatMap((part) => 'text' in part ? [part.text] : []).join('')
if (!text.includes('PROBE_') && !text.includes('answer-')) return []
return [{ role: message.role, content: parts }]
})
}
function compactFrame(message: SDKMessage): Record<string, unknown> {
const value = message as unknown as Record<string, unknown>
const inner = value.message && typeof value.message === 'object'
? value.message as Record<string, unknown>
: undefined
return {
type: value.type,
subtype: value.subtype,
uuid: value.uuid,
prompt_id: value.prompt_id,
user_message_uuid: value.user_message_uuid,
user_message_uuids: value.user_message_uuids,
result_index: value.result_index,
queued_turn_count: value.queued_turn_count,
is_error: value.is_error,
result: value.result,
parent_tool_use_id: value.parent_tool_use_id,
message_id: inner?.id,
message_role: inner?.role,
message_stop_reason: inner?.stop_reason,
content_types: Array.isArray(inner?.content)
? inner.content.map((block) => block && typeof block === 'object'
? (block as Record<string, unknown>).type
: typeof block)
: undefined
}
}
function inputMessage(sessionId: string, uuid: string, text: string): SDKUserMessage {
return {
type: 'user',
message: { role: 'user', content: [{ type: 'text', text }] },
parent_tool_use_id: null,
uuid: uuid as SDKUserMessage['uuid'],
session_id: sessionId
}
}
function executablePath(): string {
const executable = process.platform === 'win32' ? 'claude.exe' : 'claude'
const packageName = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`
const candidates = [
join(process.cwd(), 'node_modules', ...packageName.split('/'), executable),
join(process.cwd(), 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'vendor', executable)
]
const found = candidates.find((candidate) => existsSync(candidate))
if (!found) throw new Error(`Claude executable not found: ${candidates.join(', ')}`)
return found
}
function isolatedEnv(tempRoot: string, baseUrl: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
HOME: join(tempRoot, 'home'),
USERPROFILE: join(tempRoot, 'home'),
CLAUDE_CONFIG_DIR: join(tempRoot, 'claude-config'),
CLAUDE_SECURESTORAGE_CONFIG_DIR: join(tempRoot, 'claude-config'),
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_API_KEY: 'ycode-loopback-probe-key',
ANTHROPIC_MODEL: 'claude-append-probe',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-append-probe',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-append-probe',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-append-probe',
ANTHROPIC_SMALL_FAST_MODEL: 'claude-append-probe',
CLAUDE_CODE_MAX_CONTEXT_TOKENS: '200000',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
DISABLE_TELEMETRY: '1'
}
for (const name of [
'ANTHROPIC_AUTH_TOKEN',
'CLAUDE_CODE_OAUTH_TOKEN',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_USE_ANTHROPIC_AWS',
'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
'CLAUDE_CODE_USE_MANTLE',
'CLAUDE_CODE_USE_GATEWAY'
]) delete env[name]
return env
}
async function waitFor<T>(description: string, predicate: () => T | undefined | false, timeoutMs = 20_000): Promise<T> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const value = predicate()
if (value) return value
await new Promise((resolve) => setTimeout(resolve, 20))
}
throw new Error(`timed out waiting for ${description}`)
}
const sdkPackage = JSON.parse(await readFile(
join(process.cwd(), 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'package.json'),
'utf8'
)) as { version?: string; claudeCodeVersion?: string }
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-claude-append-probe-'))
const workspace = join(tempRoot, 'workspace')
const outputPath = join(process.cwd(), '.tmp', 'claude-append-protocol-probe.json')
await mkdir(workspace, { recursive: true })
await mkdir(join(tempRoot, 'home'), { recursive: true })
await mkdir(join(tempRoot, 'claude-config'), { recursive: true })
await mkdir(join(process.cwd(), '.tmp'), { recursive: true })
await writeFile(join(workspace, 'README.md'), 'append protocol probe\n', 'utf8')
const requests: CapturedRequest[] = []
const runtimeMessages: SDKMessage[] = []
const stderr: string[] = []
const firstResponseGate = deferred<void>()
const processAbort = new AbortController()
const server = createServer((req, res) => {
let raw = ''
req.on('data', (chunk) => { raw += chunk })
req.on('end', async () => {
let body: Record<string, unknown> = {}
try { body = JSON.parse(raw || '{}') as Record<string, unknown> } catch { body = { raw } }
if (!req.url?.includes('/messages')) {
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{}')
return
}
const userTexts = contentTexts(body)
const captured: CapturedRequest = {
index: requests.length + 1,
url: req.url ?? '',
headers: req.headers,
body,
userTexts,
latestText: userTexts.at(-1) ?? '',
completed: false,
aborted: false
}
requests.push(captured)
res.on('close', () => {
if (!captured.completed) captured.aborted = true
})
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive'
})
const responseText = `answer-${captured.index}:${captured.userTexts.join('|')}`
captured.responseText = responseText
if (captured.index === 1) {
await firstResponseGate.promise
}
if (!res.destroyed) {
captured.completed = true
plainText(res, `msg_append_${captured.index}`, responseText)
}
})
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('loopback model did not bind')
const baseUrl = `http://127.0.0.1:${address.port}`
const sessionId = randomUUID()
const uuids = {
initial: randomUUID(),
appendA: randomUUID(),
appendB: randomUUID()
}
const input = new AsyncQueue<SDKUserMessage>()
let instance: ReturnType<typeof query> | null = null
let pumpError: unknown
let pump = Promise.resolve()
let appendsQueuedBeforeFirstResponse = false
try {
instance = query({
prompt: input,
options: {
cwd: workspace,
model: 'claude-append-probe',
pathToClaudeCodeExecutable: executablePath(),
env: isolatedEnv(tempRoot, baseUrl),
includePartialMessages: true,
abortController: processAbort,
permissionMode: 'default',
settingSources: [],
strictMcpConfig: true,
tools: [],
allowedTools: [],
plugins: [],
agents: {},
skills: [],
systemPrompt: { type: 'preset', preset: 'claude_code' },
sessionId,
maxTurns: 4,
stderr: (data) => {
stderr.push(data)
if (process.env.DEBUG_CLAUDE_APPEND_PROBE === '1') process.stderr.write(`[claude-probe] ${data}`)
}
}
})
const currentInstance = instance
pump = (async () => {
try {
for await (const message of currentInstance) runtimeMessages.push(message)
} catch (error) {
pumpError = error
}
})()
input.push(inputMessage(sessionId, uuids.initial, 'PROBE_INITIAL'))
await waitFor('first model request', () => requests[0])
// Hold request 1 so both appends are submitted while the turn is active.
input.push(inputMessage(sessionId, uuids.appendA, 'PROBE_APPEND_A'))
input.push(inputMessage(sessionId, uuids.appendB, 'PROBE_APPEND_B'))
appendsQueuedBeforeFirstResponse = true
await new Promise((resolve) => setTimeout(resolve, 250))
firstResponseGate.resolve()
await waitFor('all queued turns to settle', () => {
const results = runtimeMessages.filter((message) => message.type === 'result')
const last = results.at(-1) as unknown as Record<string, unknown> | undefined
return results.length >= 2 && last?.queued_turn_count === 0
}, 30_000)
await new Promise((resolve) => setTimeout(resolve, 300))
const results = runtimeMessages
.filter((message) => message.type === 'result')
.map((message) => message as unknown as Record<string, unknown>)
const assistants = runtimeMessages
.filter((message) => message.type === 'assistant')
.map((message) => message as unknown as Record<string, unknown>)
const userFrames = runtimeMessages
.filter((message) => message.type === 'user')
.map((message) => message as unknown as Record<string, unknown>)
const firstResult = results[0]
const finalResult = results.at(-1)
const firstResultUuids = Array.isArray(firstResult?.user_message_uuids)
? firstResult.user_message_uuids as string[]
: []
const finalResultUuids = Array.isArray(finalResult?.user_message_uuids)
? finalResult.user_message_uuids as string[]
: []
assert.equal(requests[0]?.aborted, false, 'the first model request must complete without cancellation')
assert.ok(requests.length >= 2, 'queued appends must produce another model request')
assert.ok(firstResult, 'the initial turn must emit a result')
assert.ok(finalResult, 'the queued append turn must emit a result')
assert.ok(firstResultUuids.includes(uuids.initial), 'the first result must be attributed to the initial UUID')
assert.ok(!firstResultUuids.includes(uuids.appendA), 'the first result must not claim append A')
assert.ok(!firstResultUuids.includes(uuids.appendB), 'the first result must not claim append B')
assert.ok(
finalResultUuids.includes(uuids.appendA) && finalResultUuids.includes(uuids.appendB),
'a later result must cover both queued appends'
)
assert.ok(results.length >= 2, 'one result must not stand for every queued send')
assert.equal(userFrames.length, 0, 'text sends must not be replayed as SDK user frames')
assert.equal(assistants.length, 2, 'each logical turn must emit one complete assistant frame')
const assistantUuidSets = assistants.map((assistant) => Array.isArray(assistant.user_message_uuids)
? assistant.user_message_uuids as string[]
: [])
assert.ok(!assistantUuidSets[0]?.includes(uuids.appendA), 'the first assistant must not claim append A')
assert.ok(
assistantUuidSets[1]?.includes(uuids.appendA) && assistantUuidSets[1]?.includes(uuids.appendB),
'the second assistant must cover both coalesced appends'
)
for (const [index, frame] of [...assistants, ...results].entries()) {
const frameUuids = Array.isArray(frame.user_message_uuids) ? frame.user_message_uuids as string[] : []
assert.equal(frameUuids.at(-1), frame.user_message_uuid, `frame ${index} must use the last member as user_message_uuid`)
}
assert.equal(pumpError, undefined, 'the SDK iterator must not fail during the probe')
const report = {
result: 'PASS',
sdkVersion: sdkPackage.version,
claudeCodeVersion: sdkPackage.claudeCodeVersion,
loopback: baseUrl,
activeAppend: {
appendsQueuedBeforeFirstResponse,
interruptCalled: false,
inputClosedBeforeResults: false
},
uuids,
requests: requests.map((request) => ({
index: request.index,
url: request.url,
completed: request.completed,
aborted: request.aborted,
latestText: request.latestText,
userTexts: request.userTexts
.filter((text) => text.includes('PROBE_') || text.includes('answer-'))
.slice(-8),
messages: requestTrace(request.body)
})),
results: results.map((result) => ({
subtype: result.subtype,
is_error: result.is_error,
result_index: result.result_index,
queued_turn_count: result.queued_turn_count,
user_message_uuid: result.user_message_uuid,
user_message_uuids: result.user_message_uuids,
prompt_id: result.prompt_id,
result: result.result
})),
assistantKeys: assistants.map((assistant) => Object.keys(assistant).sort()),
resultKeys: results.map((result) => Object.keys(result).sort()),
assistants: assistants.map((assistant) => ({
uuid: assistant.uuid,
user_message_uuid: assistant.user_message_uuid,
user_message_uuids: assistant.user_message_uuids,
prompt_id: assistant.prompt_id,
parent_tool_use_id: assistant.parent_tool_use_id,
message_id: (assistant.message as Record<string, unknown> | undefined)?.id
})),
userFrameCount: userFrames.length,
userFrames: userFrames.map((frame) => ({
uuid: frame.uuid,
user_message_uuid: frame.user_message_uuid,
user_message_uuids: frame.user_message_uuids,
prompt_id: frame.prompt_id,
message: frame.message
})),
promptIdObserved: runtimeMessages.some((message) => {
const value = message as unknown as Record<string, unknown>
return typeof value.prompt_id === 'string'
}),
assistantPromptIdObserved: assistants.some((assistant) => typeof assistant.prompt_id === 'string'),
frames: runtimeMessages.map(compactFrame),
stderr: stderr.slice(-20)
}
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
console.log(JSON.stringify({
result: report.result,
outputPath,
uuids,
activeAppend: report.activeAppend,
requests: report.requests,
results: report.results,
assistants: report.assistants,
userFrameCount: report.userFrameCount,
userFrames: report.userFrames,
assistantKeys: report.assistantKeys,
resultKeys: report.resultKeys,
promptIdObserved: report.promptIdObserved,
assistantPromptIdObserved: report.assistantPromptIdObserved
}, null, 2))
} catch (error) {
const diagnostic = {
result: 'FAIL',
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
pumpError: pumpError instanceof Error ? `${pumpError.name}: ${pumpError.message}` : pumpError,
uuids,
requests: requests.map((request) => ({
index: request.index,
completed: request.completed,
aborted: request.aborted,
userTexts: request.userTexts
})),
frames: runtimeMessages.map(compactFrame),
stderr: stderr.slice(-40)
}
await writeFile(outputPath, `${JSON.stringify(diagnostic, null, 2)}\n`, 'utf8')
console.error(JSON.stringify(diagnostic, null, 2))
throw error
} finally {
input.close()
processAbort.abort()
instance?.close()
await Promise.race([
pump,
new Promise((resolve) => setTimeout(resolve, 2000))
])
await new Promise<void>((resolve) => server.close(() => resolve()))
await rm(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }).catch(() => undefined)
}
+199
View File
@@ -0,0 +1,199 @@
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
const testRoot = await fs.mkdtemp(path.resolve('.tmp/claude-capabilities-'))
const userData = path.join(testRoot, 'data')
const workspace = path.join(testRoot, 'workspace')
await Promise.all([userData, workspace].map(folder => fs.mkdir(folder, { recursive: true })))
const checks: string[] = []
const check = (name: string, actual: unknown, expected: unknown): void => {
assert.deepEqual(actual, expected, name)
checks.push(name)
}
function document(name: string, extra = ''): string {
return `---\nname: ${name}\ndescription: Controlled ${name}\n${extra}---\nInstructions for ${name}. Read references/guide.txt when needed.\n`
}
async function skill(scope: '.agents/skills' | '.claude/skills', folder: string, name: string, extra = ''): Promise<string> {
const root = path.join(workspace, scope, folder)
await fs.mkdir(path.join(root, 'references'), { recursive: true })
await fs.writeFile(path.join(root, 'SKILL.md'), document(name, extra))
await fs.writeFile(path.join(root, 'references', 'guide.txt'), `RESOURCE_${name}`)
return root
}
await skill('.agents/skills', 'automatic', 'automatic')
await skill('.agents/skills', 'manual', 'manual', 'disable-model-invocation: true\n')
await skill('.agents/skills', 'selected-only', 'selected-only', 'user-invocable: false\n')
await skill('.agents/skills', 'disabled', 'disabled')
await skill('.claude/skills', 'legacy', 'legacy')
const electronStub = {
name: 'isolated-electron-path',
setup(context: import('esbuild').PluginBuild) {
context.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'capability-test' }))
context.onLoad({ filter: /.*/, namespace: 'capability-test' }, () => ({
loader: 'js',
contents: `export const app={getPath:(name)=>{if(name!=='userData')throw Error('Unexpected app path');return ${JSON.stringify(userData)}}};export const safeStorage={isEncryptionAvailable:()=>false,encryptString:()=>{throw Error('unused')},decryptString:()=>{throw Error('unused')}};`
}))
}
}
async function compile(entry: string, file: string): Promise<string> {
const outfile = path.join(testRoot, file)
await build({ entryPoints: [path.resolve(entry)], outfile, bundle: true, platform: 'node', format: 'esm',
packages: 'external', logLevel: 'warning', plugins: [electronStub] })
return outfile
}
const adapterPath = await compile('src/main/capabilities/claude-capabilities.ts', 'adapter.mjs')
const skillsPath = await compile('src/main/capabilities/skills.ts', 'skills.mjs')
const mcpPath = await compile('src/main/capabilities/mcp.ts', 'mcp.mjs')
const adapter = await import(pathToFileURL(adapterPath).href)
const skills = await import(pathToFileURL(skillsPath).href)
const listed = await skills.listSkills(workspace)
const byName = (name: string) => listed.find((item: { name: string }) => item.name === name)
const automatic = byName('automatic')
const manual = byName('manual')
const selectedOnly = byName('selected-only')
const disabled = byName('disabled')
assert.ok(automatic && manual && selectedOnly && disabled)
check('only .agents/skills is discovered', listed.some((item: { name: string }) => item.name === 'legacy'), false)
await skills.setSkillEnabled(disabled.id, false, workspace)
let executeSignal: AbortSignal | undefined
let discoverOptions: { serverId?: string; query?: string } | undefined
const mcp = {
async discover(options?: { serverId?: string; query?: string }) {
discoverOptions = options
return [
{ id: 'ready', name: 'Ready server', status: 'connected', available: true,
tools: [
{ name: 'echo', description: 'Echo text', inputSchema: { type: 'object', properties: { text: { type: 'string' } } } },
{ name: 'search-only', description: 'Find needles', inputSchema: { type: 'object', properties: {} } }
] },
{ id: 'untrusted', name: 'Unapproved server', status: 'disconnected', available: false,
reason: '请先批准当前 MCP 配置', tools: [] }
]
},
async execute(serverId: string, toolName: string, args: Record<string, unknown>, signal: AbortSignal) {
executeSignal = signal
if (serverId === 'untrusted') return { ok: false, content: '请先批准当前 MCP 配置' }
if (serverId === 'disabled') return { ok: false, content: 'MCP 服务已禁用' }
if (toolName === 'wait') return new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(Object.assign(new Error('MCP 调用已取消'), { name: 'AbortError' })), { once: true })
})
return { ok: true, content: `${serverId}:${String(args.text ?? '')}` }
}
}
const runtime = adapter.createClaudeCapabilityRuntime({ cwd: workspace, explicitSkillIds: [manual.id], selectedMcpServerIds: ['ready'] }, { mcp })
const rootController = new AbortController()
const rootBoundRuntime = adapter.createClaudeCapabilityRuntime({ cwd: workspace, requestSignal: () => rootController.signal }, { mcp })
const rootBoundCall = rootBoundRuntime.callMcp('ready', 'wait', {}, new AbortController().signal)
await new Promise(resolve => setTimeout(resolve, 0))
rootController.abort()
await assert.rejects(rootBoundCall, /取消/)
check('root cancellation reaches MCP independently of SDK callback signal', executeSignal?.aborted, true)
const catalog = await runtime.list()
check('automatic skill appears without selection', catalog.skills.some((item: { id: string; selected: boolean }) => item.id === automatic.id && !item.selected), true)
check('explicit manual-only skill appears as selected', catalog.skills.some((item: { id: string; selected: boolean }) => item.id === manual.id && item.selected), true)
check('user-invocable=false remains model-discoverable but unselected',
catalog.skills.some((item: { id: string; selected: boolean }) => item.id === selectedOnly.id && !item.selected), true)
check('disabled skill is omitted', catalog.skills.some((item: { id: string }) => item.id === disabled.id), false)
check('MCP selection is message-scoped metadata', [catalog.mcp.find((item: { id: string }) => item.id === 'ready').selected,
catalog.mcp.find((item: { id: string }) => item.id === 'untrusted').selected], [true, false])
check('approval failure is reported truthfully', catalog.mcp.find((item: { id: string }) => item.id === 'untrusted').reason, '请先批准当前 MCP 配置')
check('default discovery omits tool schemas',
'inputSchema' in catalog.mcp.find((item: { id: string }) => item.id === 'ready').tools[0], false)
check('default discovery sends summary-only options', discoverOptions, {})
const toolSearch = await runtime.list('Echo')
check('tool-only search keeps its parent server', toolSearch.mcp.map((item: { id: string }) => item.id), ['ready'])
check('tool search returns only the matched tool with schema',
toolSearch.mcp[0].tools, [{ name: 'echo', description: 'Echo text',
inputSchema: { type: 'object', properties: { text: { type: 'string' } } } }])
check('tool search forwards query to MCP discovery', discoverOptions, { query: 'Echo' })
const drilled = await runtime.list(undefined, 'ready')
check('server drill-down returns all tool schemas',
drilled.mcp[0].tools.every((item: { inputSchema?: unknown }) => item.inputSchema !== undefined), true)
check('server drill-down forwards server identity', discoverOptions, { serverId: 'ready' })
check('automatic skill body loads on demand', (await runtime.loadSkill(automatic.id)).includes('Instructions for automatic.'), true)
check('explicit selected skill body loads', (await runtime.explicitSkillContext()).includes('Instructions for manual.'), true)
check('relative skill resource resolves from package root',
await runtime.skillResource(automatic.id, 'references/guide.txt'), 'RESOURCE_automatic')
await assert.rejects(runtime.skillResource(automatic.id, '../outside.txt'), /路径/)
checks.push('resource traversal is rejected')
check('automatic-only skill remains available to the model',
(await runtime.loadSkill(selectedOnly.id)).includes('Instructions for selected-only.'), true)
check('automatic-only resource remains model-readable',
await runtime.skillResource(selectedOnly.id, 'references/guide.txt'), 'RESOURCE_selected-only')
const automaticRuntime = adapter.createClaudeCapabilityRuntime({ cwd: workspace }, { mcp })
await assert.rejects(automaticRuntime.loadSkill(manual.id), /自动调用/)
checks.push('manual-only skill remains unavailable without an explicit reference')
await assert.rejects(automaticRuntime.loadSkill(disabled.id), /禁用/)
checks.push('disabled skill body cannot load')
const explicitOnly = adapter.createClaudeCapabilityRuntime({ cwd: workspace, explicitSkillIds: [selectedOnly.id] }, { mcp })
await assert.rejects(explicitOnly.loadSkill(selectedOnly.id), /手动调用/)
checks.push('explicit selection cannot bypass user-invocable=false')
runtime.setRequestSelection({ explicitSkillIds: [], selectedMcpServerIds: [] })
const nextCatalog = await runtime.list()
check('next request clears prior explicit skill and MCP selection',
[nextCatalog.skills.some((item: { id: string }) => item.id === manual.id),
nextCatalog.mcp.some((item: { id: string; selected: boolean }) => item.id === 'ready' && item.selected)], [false, false])
await assert.rejects(runtime.loadSkill(manual.id), /自动调用/)
checks.push('explicit skill permission does not leak into the next request')
check('real MCP call is passed through', await runtime.callMcp('ready', 'echo', { text: 'actual' }, new AbortController().signal),
{ ok: true, content: 'ready:actual' })
check('unapproved MCP call fails instead of pretending success',
await runtime.callMcp('untrusted', 'echo', {}, new AbortController().signal),
{ ok: false, content: '请先批准当前 MCP 配置' })
check('disabled MCP call fails',
await runtime.callMcp('disabled', 'echo', {}, new AbortController().signal),
{ ok: false, content: 'MCP 服务已禁用' })
const controller = new AbortController()
const pending = runtime.callMcp('ready', 'wait', {}, controller.signal)
await new Promise<void>(resolve => setImmediate(resolve))
controller.abort()
await assert.rejects(pending, (error: unknown) => (error as Error).name === 'AbortError')
check('cancellation signal reaches MCP execution', executeSignal?.aborted, true)
await skills.setSkillEnabled(disabled.id, false, workspace)
await assert.rejects(runtime.loadSkill(disabled.id), /禁用/)
checks.push('global disable takes effect on the next bridge call')
await assert.rejects(runtime.skillResource(disabled.id, 'references/guide.txt'), /禁用/)
checks.push('disabled skill resource cannot load')
const realMcp = await import(pathToFileURL(mcpPath).href)
const mcpEvents = path.join(testRoot, 'mcp-events.jsonl')
try {
let fixture = await realMcp.saveMcpServer({
name: 'Schema fixture',
transport: 'stdio',
command: process.execPath,
args: [path.resolve('scripts/test-mcp-server.mjs'), 'stdio', mcpEvents],
enabled: true
})
await realMcp.trustMcpServer(fixture.id, fixture.configHash)
fixture = await realMcp.connectMcpServer(fixture.id)
const summary = await realMcp.discoverMcpCapabilities()
check('real MCP default discovery omits schemas',
summary.find((item: { id: string }) => item.id === fixture.id).tools.every((item: object) => !('inputSchema' in item)), true)
const drilled = await realMcp.discoverMcpCapabilities({ serverId: fixture.id })
const echo = drilled[0].tools.find((item: { name: string }) => item.name === 'echo')
check('real MCP server drill-down includes schemas',
echo.inputSchema, { type: 'object', properties: { text: { type: 'string' } } })
const searched = await realMcp.discoverMcpCapabilities({ query: 'echo' })
check('real MCP tool search includes schema',
searched.find((item: { id: string }) => item.id === fixture.id).tools,
[{ name: 'echo', description: 'Controlled stdio echo',
inputSchema: { type: 'object', properties: { text: { type: 'string' } } } }])
} finally {
await realMcp.disposeMcpServers()
}
assert.ok(runtime.server, 'SDK MCP server should be constructed')
checks.push('Claude SDK capability server constructed')
await fs.writeFile(path.join(testRoot, 'report.json'), JSON.stringify({ result: 'PASS', checks }, null, 2))
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory: testRoot }))
+81 -1
View File
@@ -121,6 +121,26 @@ const server = createServer((req, res) => {
connection: 'keep-alive'
})
const id = `msg_kernel_${requests.length}`
if (latestContent.includes('APPEND_ACTIVE_SMOKE')) {
setTimeout(() => { if (!res.destroyed) plainText(res, id, 'Initial append turn.') }, 400)
return
}
if (userText.includes('APPEND_GUIDE_A') || userText.includes('APPEND_GUIDE_B')) {
plainText(res, id, 'Appended answer.')
return
}
if (latestContent.includes('HOLD_SMOKE')) {
setTimeout(() => { if (!res.destroyed) plainText(res, id, 'Held response.') }, 500)
return
}
if (latestContent.includes('AFTER_CANCEL_SMOKE')) {
plainText(res, id, 'After cancellation.')
return
}
if (userText.includes('AFTER_COMPACT_SMOKE')) {
plainText(res, id, 'After compaction.')
return
}
if (userText.includes('Write the title in the predominant language')) {
plainText(res, id, 'Plan Smoke')
return
@@ -300,11 +320,71 @@ try {
assert.ok(resumedRequest)
assert.ok(resumedRequest.userText.includes('ASK_SMOKE'))
assert.ok(resumedRequest.userText.includes('PLAN_SMOKE'))
const beforeSummary = requests.length
const summary = await resumed.getContextSummary()
assert.ok(summary && summary.categories.length > 0, 'native summary must return actual structured categories')
assert.equal(requests.length, beforeSummary, 'summary must not call the model or per-category count API')
const controller = new AbortController()
const held = resumed.runTurn(request('HOLD_SMOKE', randomUUID(), controller))
const rejection = assert.rejects(held, /取消/)
for (let index = 0; index < 500 && !requests.some(item => item.userText.includes('HOLD_SMOKE')); index++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
assert.ok(requests.some(item => item.userText.includes('HOLD_SMOKE')))
controller.abort()
await rejection
const afterCancel = await resumed.runTurn(request('AFTER_CANCEL_SMOKE', randomUUID(), new AbortController()))
assert.equal(afterCancel.content, 'After cancellation.', 'cancelled producer must not settle the next run')
const groups: string[][] = []
const segmentResults: string[] = []
const appendController = new AbortController()
const appendRun = resumed.runTurn({ ...request('APPEND_ACTIVE_SMOKE', randomUUID(), appendController),
onSegmentComplete: async result => { segmentResults.push(result.content) },
onSegmentStart: async inputs => { groups.push(inputs.map(input => input.userMessageId)) }
})
void appendRun.catch(() => {})
for (let index = 0; index < 500 && !requests.some(item => JSON.stringify(item.body.messages ?? []).includes('APPEND_ACTIVE_SMOKE')); index++) await new Promise(resolve => setTimeout(resolve, 10))
assert.ok(requests.some(item => JSON.stringify(item.body.messages ?? []).includes('APPEND_ACTIVE_SMOKE')))
const guideA = randomUUID(), guideB = randomUUID()
let acknowledged = false
const appended = Promise.all([
resumed.appendInput({ userMessageId: guideA, prompt: 'APPEND_GUIDE_A' }),
resumed.appendInput({ userMessageId: guideB, prompt: 'APPEND_GUIDE_B' })
]).then(() => { acknowledged = true })
assert.equal(acknowledged, false, 'pushing into the input stream is not delivery acknowledgement')
const appendResult = await appendRun
await appended
assert.equal(appendResult.content, 'Appended answer.')
assert.deepEqual(segmentResults, ['Initial append turn.'], 'first result must not settle appended inputs')
assert.deepEqual(groups.flat(), [guideA, guideB], 'native merged UUIDs must be associated exactly once')
assert.equal(appendController.signal.aborted, false, 'guidance must not cancel the active run')
await assert.rejects(resumed.appendInput({ userMessageId: randomUUID(), prompt: 'idle' }), error => (error as { submitted?: boolean }).submitted === false)
if (process.env.CLAUDE_PROTOCOL_PROBE === '1') {
const observed: Array<Record<string, unknown>> = []
const target = resumed as unknown as { handleMessage(message: unknown): Promise<void> }
const original = target.handleMessage.bind(target)
target.handleMessage = message => { observed.push(message as Record<string, unknown>); return original(message) }
const compact = await resumed.runTurn({ ...request('/compact', randomUUID(), new AbortController()), command: 'compact' })
console.log(JSON.stringify({ probe: 'compact', status: compact.status,
frames: observed.map(frame => ({ type: frame.type, subtype: frame.subtype, promptId: frame.prompt_id, ids: frame.user_message_uuids })),
boundary: observed.some(frame => frame.subtype === 'compact_boundary') }))
assert.ok(observed.some(frame => frame.subtype === 'compact_boundary'), 'native compaction must emit its boundary')
assert.equal(compact.content, '上下文压缩完成')
const postCompact = await resumed.runTurn(request('AFTER_COMPACT_SMOKE', randomUUID(), new AbortController()))
if (postCompact.content !== 'After compaction.') console.error(JSON.stringify({ postCompact, requests: requests.slice(-3).map(item => ({ url: item.url, userText: item.userText.slice(-1200) })), frames: observed.map(frame => ({ type: frame.type, subtype: frame.subtype, ids: frame.user_message_uuids })) }))
assert.equal(postCompact.content, 'After compaction.')
const postRequest = requests.findLast(item => item.userText.includes('AFTER_COMPACT_SMOKE'))
assert.ok(postRequest)
assert.equal(postRequest.userText.includes('ASK_SMOKE'), false, 'native compaction must replace old full inputs on the next upstream request')
}
console.log(JSON.stringify({
result: 'PASS',
requests: requests.length,
events: events.length,
turns: 3,
turns: process.env.CLAUDE_PROTOCOL_PROBE === '1' ? 9 : 7,
basePath: requests.find((item) => item.url.includes('/v1/messages'))?.url
}))
} finally {
+62 -1
View File
@@ -6,6 +6,8 @@ import { buildComposerSendPayload, MAX_ATTACHMENT_BYTES, oversizedAttachmentName
import { MessageActionToolbar } from '../src/renderer/src/workbuddy/message-actions/MessageActionToolbar'
import { UserMessageToolbar } from '../src/renderer/src/workbuddy/message-actions/UserMessageToolbar'
import { UserMessageEditor } from '../src/renderer/src/workbuddy/conversation/UserMessageEditor'
import { messageCapabilityRefs } from '../src/renderer/src/workbuddy/conversation/message-capabilities'
import { draftFingerprint, draftIsEmpty, draftPlainText, mergeFailedDraft } from '../src/renderer/src/workbuddy/composer/draft-fingerprint'
import { buildCopyMessagePayload, buildUserMessageClipboard, getSortedRequestMessages, projectSafeMessage, safeStringify, USER_CONTENT_BLOCKS_MIME } from '../src/renderer/src/workbuddy/message-actions/project-safe-message'
import type { ChatMessage } from '../src/shared/types'
@@ -41,6 +43,61 @@ const legacy: ChatMessage = {
createdAt: 1
}
const userMessage: ChatMessage = { id: 'user-copy', role: 'user', content: 'plain user text', createdAt: 1 }
assert.deepEqual(messageCapabilityRefs({ ...userMessage, skills: [{ id: 'skill-a', name: 'Skill A', uri: '/skill-a' }],
mcpServerIds: ['mcp-a'], command: 'compact' }), { skillIds: ['skill-a'], mcpServerIds: ['mcp-a'], command: 'compact' },
'editing and regenerating must preserve message-scoped capability references')
const compactDraft = { text: '', command: 'compact' as const }
assert.notEqual(draftFingerprint(compactDraft), draftFingerprint({ text: '/compact', command: 'compact' }),
'compact draft identity must use the real empty editor snapshot, not the synthetic wire text')
assert.equal(draftIsEmpty({ text: '', userContent: [{ type: 'resource', id: 'C:/work/a.ts' }] }), false,
'rich-only drafts are not empty')
assert.equal(draftIsEmpty({ text: '', userContent: [{ type: 'text', text: ' ' }] }), true,
'empty rich text does not keep a draft alive')
assert.equal(draftPlainText({ text: '', userContent: [{ type: 'text', text: 'rich-only text' }] }), 'rich-only text',
'rich-only textual content reaches the send payload')
const failedDraft = {
text: 'old request',
attachments: [{ id: 'attachment-a', name: 'a.txt', mimeType: 'text/plain', size: 1 }],
resources: ['C:/work/a.ts', 'C:/work/shared.ts'],
quotes: [{ id: 'quote-a', kind: 'table' as const, sourceId: 'source-a', markdown: 'a', preview: 'a' }],
skills: [{ id: 'skill-a', name: 'Skill A' }],
mcpServerIds: ['mcp-a']
}
const currentDraft = {
text: 'new request',
attachments: [
{ id: 'attachment-b', name: 'b.txt', mimeType: 'text/plain', size: 1 },
{ id: 'attachment-a', name: 'duplicate.txt', mimeType: 'text/plain', size: 1 }
],
resources: ['C:/work/b.ts', 'C:/work/shared.ts'],
quotes: [{ id: 'quote-b', kind: 'table' as const, sourceId: 'source-b', markdown: 'b', preview: 'b' }],
skills: [{ id: 'skill-b', name: 'Skill B' }, { id: 'skill-a', name: 'Duplicate' }],
mcpServerIds: ['mcp-b', 'mcp-a']
}
const mergedDraft = mergeFailedDraft(currentDraft, failedDraft)
assert.equal(mergedDraft.draft.text, 'old request\nnew request', 'failed content must stay before subsequent input')
assert.deepEqual(mergedDraft.draft.attachments?.map(item => item.id), ['attachment-a', 'attachment-b'])
assert.deepEqual(mergedDraft.draft.resources, ['C:/work/a.ts', 'C:/work/shared.ts', 'C:/work/b.ts'])
assert.deepEqual(mergedDraft.draft.quotes?.map(item => item.id), ['quote-a', 'quote-b'])
assert.deepEqual(mergedDraft.draft.skills?.map(item => item.id), ['skill-a', 'skill-b'])
assert.deepEqual(mergedDraft.draft.mcpServerIds, ['mcp-a', 'mcp-b'])
assert.equal(mergedDraft.commandConflict, false)
const orderedFailure = mergeFailedDraft({ text: 'later' }, {
text: 'beforeafter', resources: ['C:/work/inline.ts'],
userContent: [{ type: 'text', text: 'before' }, { type: 'resource', id: 'C:/work/inline.ts' }, { type: 'text', text: 'after' }]
})
assert.deepEqual(orderedFailure.draft.userContent, [
{ type: 'text', text: 'before' }, { type: 'resource', id: 'C:/work/inline.ts' }, { type: 'text', text: 'after\nlater' }
], 'failed draft keeps inline reference order before subsequent input')
const commandFailure = mergeFailedDraft({ text: 'next ordinary draft' }, { text: '', command: 'compact' })
assert.equal(commandFailure.draft.command, 'compact', 'failed command tag remains removable instead of being discarded')
assert.equal(commandFailure.commandConflict, true, 'mixed command and ordinary content must be surfaced as a conflict')
assert.equal(mergeFailedDraft({ text: 'new' }, { text: '', userContent: [{ type: 'text', text: 'old rich text' }] }).draft.text,
'old rich text\nnew', 'failed rich-only text is not lost when subsequent input exists')
const failedQueueEdit = mergeFailedDraft({ text: 'typed after submit' }, { text: 'queued edit', queueEditId: 'queue-item-a' })
assert.equal(failedQueueEdit.draft.queueEditId, 'queue-item-a', 'failed queue-edit submissions retain the durable handoff identity')
assert.equal(draftFingerprint({ text: 'queued edit', queueEditId: 'queue-item-a' }), draftFingerprint({ text: 'queued edit' }),
'queue handoff identity never changes the submitted-content fingerprint')
const userClipboard = buildUserMessageClipboard({ ...userMessage, content: '<plain>&\u200B' })
assert.equal(userClipboard.plainText, '<plain>&', 'user text/plain omits the source zero-width marker')
assert.equal(userClipboard.html, '&lt;plain&gt;&amp;\u200B')
@@ -175,7 +232,11 @@ assert.equal(composerMarkup.indexOf('Model slot') < composerMarkup.indexOf('语
assert.ok(composerMarkup.indexOf('cr-input-toolbar__left') < composerMarkup.indexOf('cr-input-toolbar__right'))
assert.ok(composerMarkup.indexOf('cr-input-toolbar__right') < composerMarkup.indexOf('wb-composer__workspace-footer'), 'workspace footer remains outside the input toolbar')
const activeRunMarkup = renderToStaticMarkup(<ComposerControls value="next draft" onChange={() => undefined} onSend={() => undefined} onStop={() => undefined} />)
assert.match(activeRunMarkup, /aria-label="停止"/)
assert.match(activeRunMarkup, /aria-label="停止当前运行"/)
assert.match(activeRunMarkup, /aria-label="发送"/)
const emptyActiveRunMarkup = renderToStaticMarkup(<ComposerControls value="" onChange={() => undefined} onSend={() => undefined} onStop={() => undefined} />)
assert.match(emptyActiveRunMarkup, /aria-label="停止"/)
assert.doesNotMatch(emptyActiveRunMarkup, /aria-label="发送"/)
assert.doesNotMatch(activeRunMarkup, /<textarea[^>]*disabled/)
console.log('Composer/message action regression cases passed')
+73
View File
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict'
import { createContextUsageService, type ContextUsageDependencies } from '../src/main/context-usage-service'
import type { ChatMessage, ContextUsageBreakdown } from '../src/shared/types'
const sample = {
usage: { inputTokens: 1200, outputTokens: 100, totalTokens: 1300 },
contextWindow: 10_000,
source: 'upstream' as const,
sampledAt: 100,
requestId: 'request-a',
modelId: 'model-a'
}
const assistant = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
id: 'assistant-a',
role: 'assistant',
content: 'answer',
modelId: 'model-a',
requestId: 'request-a',
contextUsage: sample,
createdAt: 1,
status: 'done',
...overrides
})
const breakdown: ContextUsageBreakdown = {
requestId: 'request-a',
sequence: 3,
sampledAt: 101,
modelId: 'model-a',
contextWindow: 10_000,
method: 'native-summary',
scope: 'provider-request',
counts: { system: 100, tools: 100, conversation: 900, mcp: 50, skills: 50, other: 0 },
inputTokens: 1200,
partial: false
}
function service(overrides: Partial<ContextUsageDependencies> = {}) {
const dependencies: ContextUsageDependencies = {
messages: async () => [assistant()],
modelId: async () => 'model-a',
running: () => false,
summary: async () => undefined,
persistIfCurrent: async () => undefined,
...overrides
}
return createContextUsageService(dependencies)
}
assert.deepEqual(await service({ messages: async () => [] }).get('empty'),
{ status: 'unavailable', reason: '发送消息后可查看用量' })
const latest = await service().get('latest')
assert.equal(latest.status, 'latest')
assert.equal(latest.sample?.usage.inputTokens, 1200)
const previous = await service({ modelId: async () => 'model-b' }).get('previous')
assert.equal(previous.status, 'previous')
assert.equal(previous.reason, '来自切换模型前的最近请求')
const running = await service({ modelId: async () => 'model-b', running: () => true }).get('running')
assert.equal(running.status, 'latest', 'the active run must not be relabelled when the next-send model changes')
const summary = await service({ summary: async () => breakdown }).get('summary')
assert.equal(summary.breakdown?.sequence, 3)
assert.equal(summary.status, 'latest', 'a matching native summary augments the current request without replacing its upstream total')
const mismatched = await service({ summary: async () => ({ ...breakdown, requestId: 'older-request' }) }).get('mismatch')
assert.equal(mismatched.breakdown, undefined, 'a late summary for another request must not attach to the latest assistant message')
const summaryFailure = await service({ summary: async () => { throw new Error('query failed') } }).get('failure')
assert.equal(summaryFailure.sample?.usage.inputTokens, 1200, 'summary failures retain the real streamed usage sample')
console.log('context-usage-regression: latest, previous, active-run, summary identity and failure cases passed')
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import { composeComposer, isActiveComposerInteraction, type ComposerCompositionInput } from '../src/renderer/src/workbuddy/composer/input-composition'
const idle: ComposerCompositionInput = {}
let result = composeComposer(idle)
assert.equal(result.input.mounted, true)
assert.equal(result.input.destination, 'send')
assert.equal(result.queue.visible, false)
assert.equal(result.queue.hiddenBy, 'empty')
assert.equal(result.toolbar.permissionPlacement, 'after-add')
assert.equal(result.toolbar.primaryAction, 'send')
assert.equal(result.escapeStopsGeneration, false)
result = composeComposer({ running: true, queueCount: 2, queueRevision: 4 })
assert.equal(result.input.destination, 'queue', 'running messages are queued instead of interrupting the turn')
assert.equal(result.queue.visible, true)
assert.equal(result.toolbar.primaryAction, 'stop', 'stop remains reachable while the queue is visible')
for (const kind of ['question', 'approval', 'plan', 'mcp'] as const) {
result = composeComposer({
interaction: { id: `${kind}-1`, kind, status: 'pending' },
notice: { kind: 'error', id: 'global-error' },
task: { id: 'task-1', active: true },
queueCount: 3,
queueRevision: 8,
running: true
})
assert.equal(result.interaction?.kind, kind)
assert.equal(result.input.blockedByInteraction, true)
assert.equal(result.input.destination, 'blocked')
assert.equal(result.input.preservesDraft, true, 'takeover never clears the draft')
assert.equal(result.queue.visible, false, 'takeover hides the queue')
assert.equal(result.queue.hiddenBy, 'interaction')
assert.equal(result.queue.count, 3, 'queue data remains available while hidden')
assert.equal(result.queue.preservesItems, true)
assert.equal(result.task?.id, 'task-1', 'task data remains available while hidden')
assert.equal(result.notice, null, 'interaction-local errors must not duplicate a global notice')
assert.equal(result.toolbar.primaryAction, 'stop', 'stop is not stolen by takeover')
assert.deepEqual(result.stack.beforeInput.map(panel => panel.kind), [kind])
assert.equal(result.stack.queue, null)
}
result = composeComposer({
interaction: { id: 'answered', kind: 'approval', status: 'answered' },
task: { active: true },
queueCount: 2
})
assert.equal(result.interaction, null, 'settled interactions no longer take over')
assert.equal(result.queue.visible, true)
assert.deepEqual(result.stack.beforeQueue.map(panel => panel.kind), ['task'])
assert.equal(result.stack.queue?.kind, 'queue')
result = composeComposer({ notice: { kind: 'quota', id: 'quota-1' }, task: { active: true } })
assert.deepEqual(result.stack.beforeInput.map(panel => panel.kind), ['quota'])
result = composeComposer({ notice: { kind: 'error', id: 'error-1' }, task: { id: 'task-2', active: true }, queueCount: 1 })
assert.deepEqual(result.stack.beforeInput.map(panel => panel.kind), ['error'])
assert.equal(result.stack.beforeQueue.length, 0)
assert.equal(result.task?.id, 'task-2', 'notice keeps task data while suppressing its placement')
assert.equal(result.queue.visible, false, 'notice suppresses accessory queue rendering')
assert.equal(result.queue.hiddenBy, 'notice')
assert.equal(result.stack.queue, null)
assert.equal(isActiveComposerInteraction({ id: 'q', kind: 'question', status: 'responding' }), true)
assert.equal(isActiveComposerInteraction({ id: 'q', kind: 'question', status: 'cancelled' }), false)
const clamped = composeComposer({ queueCount: Number.NaN, queueRevision: -3 })
assert.equal(clamped.queue.count, 0)
assert.equal(clamped.queue.revision, 0)
const firstLayout = composeComposer({ queueCount: 1, queueRevision: 1 }).layoutKey
const secondLayout = composeComposer({ queueCount: 1, queueRevision: 2 }).layoutKey
assert.notEqual(firstLayout, secondLayout, 'layout key changes when queue height may change')
console.log('input-composition-regression: precedence, takeover, queue preservation, task placement, toolbar and escape rules passed')
@@ -0,0 +1,415 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { hostname } from 'node:os'
import { join, resolve } from 'node:path'
import { createPromptQueue } from '../src/main/prompt-queue'
import { resolveQueuedRequestSettings } from '../src/main/agent/queued-request-settings'
import type { SendPayload } from '../src/shared/ipc'
import type { PromptQueueSnapshot } from '../src/shared/prompt-queue'
const workspace = resolve('.tmp')
await fs.mkdir(workspace, { recursive: true })
const root = await fs.mkdtemp(join(workspace, 'prompt-queue-adversarial-'))
const results: Array<{ name: string; pass: boolean; error?: string }> = []
const tick = (ms = 20) => new Promise(resolve => setTimeout(resolve, ms))
const payload = (sessionId: string, text: string, extra: Partial<SendPayload> = {}): SendPayload =>
({ sessionId, text, ...extra })
async function waitFor(check: () => boolean | Promise<boolean>, label: string): Promise<void> {
for (let attempt = 0; attempt < 150; attempt++) {
if (await check()) return
await tick(10)
}
throw new Error('Timeout: ' + label)
}
async function verify(name: string, run: () => Promise<void>): Promise<void> {
try {
await run()
results.push({ name, pass: true })
} catch (error) {
results.push({ name, pass: false, error: error instanceof Error ? error.message : String(error) })
}
}
function options(file: string, input: {
blocked?: (sessionId: string) => boolean
resolveDispatch?: (payload: SendPayload, id: string) => SendPayload | Promise<SendPayload>
deliver?: (payload: SendPayload, id: string) => Promise<{
completion: Promise<{ status: 'done' | 'cancelled' | 'error' }>
}>
changed?: (snapshot: PromptQueueSnapshot) => void
} = {}) {
return {
file,
validate: async () => undefined,
blocked: async sessionId => input.blocked?.(sessionId) ?? true,
...(input.resolveDispatch ? { resolveDispatch: input.resolveDispatch } : {}),
deliver: input.deliver ?? (async () => ({
completion: new Promise<{ status: 'done' }>(() => undefined)
})),
changed: input.changed ?? (() => undefined)
}
}
try {
await verify('parallel same-instance enqueues preserve exactly 19 items', async () => {
const file = join(root, 'cap.json')
const queue = createPromptQueue(options(file))
const outcomes = await Promise.allSettled(Array.from({ length: 25 }, (_, index) =>
queue.enqueue(payload('cap', String(index)))))
assert.equal(outcomes.filter(item => item.status === 'fulfilled').length, 19)
assert.equal(outcomes.filter(item => item.status === 'rejected').length, 6)
const snapshot = await queue.get('cap')
assert.equal(snapshot.items.length, 19)
assert.equal(new Set(snapshot.items.map(item => item.id)).size, 19)
})
await verify('duplicate take is serialized to one winner', async () => {
const file = join(root, 'take.json')
const queue = createPromptQueue(options(file))
await queue.enqueue(payload('take', 'only'))
const id = (await queue.get('take')).items[0].id
const outcomes = await Promise.allSettled([queue.take('take', id), queue.take('take', id)])
assert.equal(outcomes.filter(item => item.status === 'fulfilled').length, 1)
assert.equal(outcomes.filter(item => item.status === 'rejected').length, 1)
assert.equal((await queue.get('take')).items.length, 0)
})
await verify('delivering item is immutable and notify does not duplicate delivery', async () => {
const file = join(root, 'delivery-lock.json')
let releaseDelivery!: () => void
let releaseCompletion!: (value: { status: 'done' }) => void
const deliveryGate = new Promise<void>(resolve => { releaseDelivery = resolve })
const completion = new Promise<{ status: 'done' }>(resolve => { releaseCompletion = resolve })
const delivered: string[] = []
const queue = createPromptQueue(options(file, {
blocked: () => false,
deliver: async (_value, id) => {
delivered.push(id)
await deliveryGate
return { completion }
}
}))
await queue.enqueue(payload('delivery', 'first'))
await queue.enqueue(payload('delivery', 'second'))
await waitFor(() => delivered.length === 1, 'first delivery reserve')
const delivering = await queue.get('delivery')
assert.equal(delivering.items[0].status, 'delivering')
const observer = createPromptQueue(options(file, { blocked: () => true }))
assert.equal((await observer.get('delivery')).items[0].status, 'delivering',
'fresh reads in the same process must preserve an owned active delivery')
await assert.rejects(queue.clear('delivery'), /正在交付/)
const first = delivering.items[0].id
const second = delivering.items[1].id
await queue.remove('delivery', second)
await assert.rejects(queue.take('delivery', first), /交付/)
await assert.rejects(queue.reorder('delivery', [first]), /排序/)
queue.notify('delivery')
queue.notify('delivery')
queue.notify('delivery')
await tick(50)
assert.equal(delivered.length, 1)
releaseDelivery()
releaseCompletion({ status: 'done' })
await waitFor(async () => (await queue.get('delivery')).items.length === 0, 'delivery cleanup')
})
await verify('clear removes a non-delivering session queue and is idempotent', async () => {
const file = join(root, 'clear.json')
const queue = createPromptQueue(options(file))
await queue.enqueue(payload('clear', 'one'))
await queue.enqueue(payload('clear', 'two'))
const cleared = await queue.clear('clear')
assert.deepEqual(cleared.items, [])
assert.equal(cleared.paused, false)
assert.deepEqual((await createPromptQueue(options(file)).get('clear')).items, [])
assert.deepEqual((await queue.clear('clear')).items, [])
})
await verify('first enqueue after an empty queue was paused resumes a new run without unpausing existing work', async () => {
const file = join(root, 'empty-pause.json')
let blocked = true
let finish!: (value: { status: 'done' }) => void
const delivered: string[] = []
const queue = createPromptQueue(options(file, {
blocked: () => blocked,
deliver: async value => {
delivered.push(value.text)
return { completion: new Promise<{ status: 'done' }>(resolve => { finish = resolve }) }
}
}))
await queue.pause('lifecycle', '上一条运行已停止')
assert.equal((await queue.get('lifecycle')).paused, true)
blocked = false
await queue.enqueue(payload('lifecycle', 'new run'))
await waitFor(() => delivered.length === 1, 'new run first delivery')
const active = await queue.get('lifecycle')
assert.equal(active.paused, false, 'a new run must not inherit a pause left on an empty queue')
assert.equal(active.reason, undefined)
finish({ status: 'done' })
await waitFor(async () => (await queue.get('lifecycle')).items.length === 0, 'first delivery cleanup')
blocked = true
await queue.enqueue(payload('lifecycle', 'queued while busy'))
await queue.pause('lifecycle', '用户停止当前队列')
await queue.enqueue(payload('lifecycle', 'later item'))
const retained = await queue.get('lifecycle')
assert.equal(retained.paused, true, 'a non-empty queue must retain its pause')
assert.equal(retained.reason, '用户停止当前队列')
assert.deepEqual(retained.items.map(item => item.payload.text), ['queued while busy', 'later item'])
assert.deepEqual(delivered, ['new run'])
})
await verify('incoming payload is validated before host side effects and persistence', async () => {
const file = join(root, 'incoming-validation.json')
let hostCalls = 0
const queue = createPromptQueue({
...options(file),
validate: async () => { hostCalls++ }
})
await assert.rejects(
queue.enqueue({ sessionId: 'incoming', text: '/compact', command: 'compact' } as SendPayload),
/追加消息无效.*命令/
)
assert.equal(hostCalls, 0, 'invalid transport payload must not reach host validation')
await assert.rejects(fs.access(file), (error: NodeJS.ErrnoException) => error.code === 'ENOENT')
await assert.rejects(
queue.enqueue({
sessionId: 'incoming',
text: 'label',
skillIds: ['skill:a'],
capabilityLabels: { 'skill:a': { name: 'not-a-string' } }
} as unknown as SendPayload),
/追加消息无效.*能力标签/
)
assert.equal(hostCalls, 0, 'malformed display labels must not reach host validation')
await assert.rejects(fs.access(file), (error: NodeJS.ErrnoException) => error.code === 'ENOENT')
})
await verify('host-rebuilt capability labels are validated before persistence', async () => {
const file = join(root, 'capability-labels.json')
const queue = createPromptQueue({
...options(file),
validate: async value => {
value.capabilityLabels = { 'skill:alpha': 'Alpha skill', 'mcp:server': 'Server' }
}
})
await queue.enqueue(payload('labels', 'use labels', {
skillIds: ['alpha'],
mcpServerIds: ['server'],
capabilityLabels: { 'skill:alpha': 'untrusted renderer label' }
}))
const stored = (await queue.get('labels')).items[0].payload.capabilityLabels
assert.deepEqual(stored, { 'skill:alpha': 'Alpha skill', 'mcp:server': 'Server' })
assert.deepEqual(
(await createPromptQueue(options(file)).get('labels')).items[0].payload.capabilityLabels,
stored,
'validated labels must survive a fresh disk read'
)
})
await verify('two queue instances cannot overwrite one another', async () => {
const file = join(root, 'two-instances.json')
const a = createPromptQueue(options(file))
const b = createPromptQueue(options(file))
await Promise.all([a.get('shared'), b.get('shared')])
await Promise.all([
a.enqueue(payload('shared', 'from-a')),
b.enqueue(payload('shared', 'from-b'))
])
await tick(80)
const fresh = createPromptQueue(options(file))
const snapshot = await fresh.get('shared')
assert.deepEqual(snapshot.items.map(item => item.payload.text).sort(), ['from-a', 'from-b'])
})
await verify('corrupt queue cannot redirect or duplicate item identity', async () => {
const file = join(root, 'corrupt.json')
await fs.writeFile(file, JSON.stringify({
version: 1,
queues: [{
sessionId: 'owner',
revision: 1,
paused: false,
items: [
{ id: 'same', createdAt: 1, status: 'pending', payload: payload('other', 'redirected') },
{ id: 'same', createdAt: 2, status: 'pending', payload: payload('other', 'duplicate') }
]
}]
}))
const queue = createPromptQueue(options(file))
await assert.rejects(queue.get('owner'), /损坏|重复|会话/)
})
await verify('invalid revision, status and payload permission are rejected', async () => {
const base = {
version: 1,
queues: [{
sessionId: 'invalid', revision: 1, paused: false,
items: [{ id: 'valid-id', createdAt: 1, status: 'pending', payload: payload('invalid', 'text') }]
}]
}
for (const [name, mutate] of [
['revision', (value: any) => { value.queues[0].revision = -1 }],
['status', (value: any) => { value.queues[0].items[0].status = 'mystery' }],
['permission', (value: any) => { value.queues[0].items[0].payload.permissionMode = 'admin' }],
['capability-label', (value: any) => { value.queues[0].items[0].payload.capabilityLabels = { 'skill:a': { name: 'bad' } } }]
] as const) {
const file = join(root, `invalid-${name}.json`)
const value = structuredClone(base)
mutate(value)
await fs.writeFile(file, JSON.stringify(value))
await assert.rejects(createPromptQueue(options(file)).get('invalid'), /损坏/)
}
})
await verify('post-delivery removal failure becomes paused unknown instead of stuck delivering', async () => {
const file = join(root, 'removal-failure.json')
let sabotage: Promise<void> | undefined
const queue = createPromptQueue(options(file, {
blocked: sessionId => sessionId !== 'removal',
changed: snapshot => {
if (!sabotage && snapshot.sessionId === 'removal' && snapshot.items[0]?.status === 'delivering') {
sabotage = (async () => {
await fs.rm(file, { force: true })
await fs.mkdir(file)
})()
}
},
deliver: async () => {
await sabotage
return { completion: Promise.resolve({ status: 'done' }) }
}
}))
await queue.enqueue(payload('bystander', 'must survive fallback'))
await queue.enqueue(payload('removal', 'already persisted elsewhere'))
await waitFor(() => !!sabotage, 'removal failure sabotage')
await waitFor(async () => {
try {
const current = await queue.get('removal')
return current.paused && current.items[0]?.status === 'unknown'
} catch {
return false
}
}, 'removal uncertainty fallback')
const snapshot = await queue.get('removal')
assert.equal(snapshot.items[0].status, 'unknown', 'acknowledged delivery must become unknown when queue cleanup cannot be persisted')
assert.equal(snapshot.paused, true, 'an unconfirmed queue cleanup must pause automatic dispatch')
assert.equal((await queue.get('bystander')).items[0].payload.text, 'must survive fallback')
const unknown = snapshot.items[0]
await fs.rm(file, { recursive: true, force: true })
const taken = await queue.take('removal', unknown.id)
assert.equal(taken.status, 'unknown')
assert.equal(taken.payload.text, 'already persisted elsewhere')
assert.deepEqual((await queue.get('removal')).items, [])
assert.equal((await queue.get('bystander')).items[0].payload.text, 'must survive fallback')
})
await verify('stale owner nonce is recovered without trusting a reused PID', async () => {
const file = join(root, 'owner-nonce.json')
await fs.writeFile(file, JSON.stringify({
version: 1,
queues: [{
sessionId: 'owner',
revision: 2,
paused: false,
items: [{
id: 'owned-item', createdAt: 1, status: 'delivering',
deliveryOwner: { pid: process.pid, nonce: 'old-process-instance', hostname: hostname(), startedAt: 1 },
payload: payload('owner', 'recover me')
}]
}]
}))
const snapshot = await createPromptQueue(options(file)).get('owner')
assert.equal(snapshot.items[0].status, 'unknown')
assert.equal(snapshot.paused, true)
})
await verify('stale and expired lock files are reclaimed', async () => {
const file = join(root, 'lock-reclaim.json')
const lock = `${file}.lock`
await fs.writeFile(lock, JSON.stringify({
pid: 99999999, nonce: 'dead-owner', hostname: hostname(),
createdAt: Date.now(), expiresAt: Date.now() + 60_000
}))
assert.deepEqual((await createPromptQueue(options(file)).get('lock')).items, [])
await fs.writeFile(lock, JSON.stringify({
pid: process.pid, nonce: 'expired-owner', hostname: 'remote-test-host',
createdAt: Date.now() - 60_000, expiresAt: Date.now() - 1
}))
assert.deepEqual((await createPromptQueue(options(file)).get('lock')).items, [])
})
await verify('live foreign lock times out with an explicit error', async () => {
const file = join(root, 'lock-timeout.json')
const lock = `${file}.lock`
await fs.writeFile(lock, JSON.stringify({
pid: process.pid, nonce: 'live-foreign-owner', hostname: hostname(),
createdAt: Date.now(), expiresAt: Date.now() + 60_000
}))
try {
await assert.rejects(createPromptQueue(options(file)).get('lock'), /文件锁超时/)
} finally {
await fs.rm(lock, { force: true })
}
})
await verify('blocked notify does not create a state revision', async () => {
const file = join(root, 'blocked-revision.json')
const queue = createPromptQueue(options(file, { blocked: () => true }))
await queue.enqueue(payload('revision', 'pending'))
await tick(50)
const before = await queue.get('revision')
queue.notify('revision')
await tick(50)
const after = await queue.get('revision')
assert.equal(after.revision, before.revision, 'a blocked no-op must not persist or notify a new revision')
})
await verify('host resolver applies dispatch-time permission downgrade without mutating stored payload', async () => {
const file = join(root, 'permission.json')
let blocked = true
let deliveredPermission: SendPayload['permissionMode']
assert.deepEqual(resolveQueuedRequestSettings({
queued: false, payloadPermissionMode: 'full-access', sessionPermissionMode: 'default-sandbox'
}), { permissionMode: 'full-access', persistSessionPermission: true })
assert.deepEqual(resolveQueuedRequestSettings({
queued: true, payloadPermissionMode: 'full-access', sessionPermissionMode: 'default-sandbox'
}), { permissionMode: 'default-sandbox', persistSessionPermission: false })
const queue = createPromptQueue(options(file, {
blocked: () => blocked,
resolveDispatch: value => ({
...value,
permissionMode: resolveQueuedRequestSettings({
queued: true,
payloadPermissionMode: value.permissionMode,
sessionPermissionMode: 'default-sandbox'
}).permissionMode
}),
deliver: async value => {
deliveredPermission = value.permissionMode
return { completion: Promise.resolve({ status: 'done' }) }
}
}))
await queue.enqueue(payload('permission', 'run', { permissionMode: 'full-access' }))
assert.equal((await queue.get('permission')).items[0].payload.permissionMode, 'full-access')
blocked = false
queue.notify('permission')
await waitFor(() => deliveredPermission !== undefined, 'permission delivery')
assert.equal(deliveredPermission, 'default-sandbox')
await waitFor(async () => (await queue.get('permission')).items.length === 0, 'permission delivery cleanup')
})
} finally {
if (!resolve(root).startsWith(workspace + '\\') && !resolve(root).startsWith(workspace + '/')) {
throw new Error('Unsafe adversarial test cleanup path')
}
await fs.rm(root, { recursive: true, force: true })
}
const failures = results.filter(result => !result.pass)
console.log(JSON.stringify({ result: failures.length ? 'FAIL' : 'PASS', checks: results }, null, 2))
if (failures.length) process.exitCode = 1
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { resolve, join } from 'node:path'
import { createPromptQueue } from '../src/main/prompt-queue'
import type { PromptQueueSnapshot } from '../src/shared/prompt-queue'
const workspace = resolve('.tmp')
await fs.mkdir(workspace, { recursive: true })
const root = await fs.mkdtemp(join(workspace, 'prompt-queue-'))
const tick = () => new Promise(resolve => setTimeout(resolve, 10))
try {
let blocked = true
const delivered: string[] = []
const changes: PromptQueueSnapshot[] = []
let finish: ((value: { status: 'done' | 'cancelled' }) => void) | undefined
const options = {
file: join(root, 'queues.json'),
validate: async () => {},
blocked: async () => blocked,
changed: (snapshot: PromptQueueSnapshot) => { changes.push(snapshot) },
deliver: async (_payload: unknown, id: string) => {
delivered.push(id)
return { completion: new Promise<{ status: 'done' | 'cancelled' }>(resolve => { finish = resolve }) }
}
}
const queue = createPromptQueue(options)
for (let index = 0; index < 19; index++) await queue.enqueue({ sessionId: 'a', text: `${index}`, resources: ['C:/workspace/a'], skillIds: ['skill'], mcpServerIds: ['server'], quotes: [], attachments: [] })
await assert.rejects(queue.enqueue({ sessionId: 'a', text: 'full' }), /19/)
assert.equal((await queue.get('a')).items.length, 19)
const order = (await queue.get('a')).items.map(item => item.id).reverse()
await queue.reorder('a', order)
await assert.rejects(queue.reorder('a', [...order.slice(1), order[1]]), /排序/)
const taken = await queue.take('a', order[0])
assert.deepEqual(taken.payload.skillIds, ['skill'])
assert.deepEqual(taken.payload.mcpServerIds, ['server'])
assert.deepEqual(taken.payload.resources, ['C:/workspace/a'])
assert.equal((await queue.get('b')).items.length, 0, 'sessions are isolated')
assert.equal(delivered.length, 0, 'active run blocks ordinary dispatch')
const recovered = createPromptQueue(options)
assert.equal((await recovered.get('a')).paused, true, 'restart requires explicit resume')
blocked = false
await recovered.resume('a')
for (let attempt = 0; attempt < 100 && !delivered.length; attempt++) await tick()
assert.equal(delivered.length, 1)
for (let attempt = 0; attempt < 100 && (await recovered.get('a')).items.length === 18; attempt++) await tick()
assert.equal((await recovered.get('a')).items.length, 17, 'only acknowledged delivery leaves the queue')
finish!({ status: 'cancelled' })
for (let attempt = 0; attempt < 100 && !(await recovered.get('a')).paused; attempt++) await tick()
assert.equal((await recovered.get('a')).paused, true)
assert.equal(delivered.length, 1, 'stop pauses all remaining ordinary sends')
const uncertain = createPromptQueue({ ...options, file: join(root, 'uncertain.json'),
deliver: async () => { throw new Error('lost acknowledgement') } })
await uncertain.enqueue({ sessionId: 'c', text: 'uncertain' })
for (let attempt = 0; attempt < 100 && !(await uncertain.get('c')).paused; attempt++) await tick()
assert.equal((await uncertain.get('c')).items[0].status, 'unknown')
await assert.rejects(uncertain.resume('c'), /结果不明/)
await assert.rejects(uncertain.sendNow('c', 'unknown'), /尚未接入/)
const disk = JSON.parse(await fs.readFile(options.file, 'utf8'))
const interruptedItem = disk.queues[0].items.find((item: { status: string }) => item.status !== 'editing')
interruptedItem.status = 'delivering'
delete interruptedItem.deliveryOwner
await fs.writeFile(options.file, JSON.stringify(disk))
const interrupted = createPromptQueue(options)
assert.equal((await interrupted.get('a')).items[0].status, 'unknown')
await assert.rejects(interrupted.resume('a'), /结果不明/)
assert.ok(changes.every(snapshot => snapshot.revision >= 1))
const editFile = join(root, 'editing.json')
const editing = createPromptQueue({ ...options, file: editFile, blocked: async () => true })
await editing.enqueue({ sessionId: 'edit', text: 'original', skillIds: ['skill'] })
const originalId = (await editing.get('edit')).items[0].id
const draft = await editing.take('edit', originalId)
assert.equal(draft.payload.queueEditId, originalId)
assert.equal((await editing.get('edit')).items.length, 0)
assert.equal((await editing.get('edit')).editingDraft?.payload.text, 'original', 'take remains recoverable on disk until submission')
await editing.restoreEdit('edit', originalId)
assert.equal((await editing.get('edit')).items[0].id, originalId, 'rollback preserves identity and order')
const retaken = await editing.take('edit', originalId)
await editing.enqueue({ ...retaken.payload, text: 'edited' })
assert.equal((await editing.get('edit')).items[0].payload.text, 'edited')
assert.equal((await editing.get('edit')).editingDraft, undefined)
await assert.rejects(editing.enqueue({ sessionId: 'edit', text: 'invalid handoff', queueEditId: '' }), /编辑取回标识/)
const ordered = createPromptQueue({ ...options, file: join(root, 'edit-order.json'), blocked: async () => true })
for (const text of ['first', 'second', 'third']) await ordered.enqueue({ sessionId: 'edit-order', text })
const middle = (await ordered.get('edit-order')).items[1]
const middleDraft = await ordered.take('edit-order', middle.id)
await ordered.enqueue({ ...middleDraft.payload, text: 'second edited' })
assert.deepEqual((await ordered.get('edit-order')).items.map(item => item.payload.text),
['first', 'second edited', 'third'], 'editing and resubmitting keeps the original queue position')
const toRecover = (await editing.get('edit')).items[0].id
await editing.take('edit', toRecover)
const editingDisk = JSON.parse(await fs.readFile(editFile, 'utf8'))
editingDisk.queues[0].items[0].deliveryOwner.nonce = 'previous-process'
await fs.writeFile(editFile, JSON.stringify(editingDisk))
assert.equal((await createPromptQueue({ ...options, file: editFile }).get('edit')).items[0].payload.text, 'edited', 'restart recovers an unsubmitted editor handoff')
const guideAcks = new Map<string, { resolve(): void; reject(error: Error): void }>()
const immediate = createPromptQueue({ ...options, file: join(root, 'immediate.json'), blocked: async () => true,
deliverNow: (_payload, id) => new Promise<void>((resolve, reject) => { guideAcks.set(id, { resolve, reject }) }) })
await immediate.enqueue({ sessionId: 'guide', text: 'a' })
await immediate.enqueue({ sessionId: 'guide', text: 'b' })
const guideIds = (await immediate.get('guide')).items.map(item => item.id)
const firstGuide = immediate.sendNow('guide', guideIds[0])
const secondGuide = immediate.sendNow('guide', guideIds[1])
const secondFailure = assert.rejects(secondGuide, /cancelled/)
for (let attempt = 0; attempt < 100 && guideAcks.size !== 2; attempt++) await tick()
assert.equal(guideAcks.size, 2)
assert.ok((await immediate.get('guide')).items.every(item => item.status === 'delivering'))
guideAcks.get(guideIds[0])!.resolve()
await firstGuide
assert.equal((await immediate.get('guide')).items[0].id, guideIds[1], 'one acknowledgement cannot consume another input')
guideAcks.get(guideIds[1])!.reject(Object.assign(new Error('cancelled'), { submitted: true }))
await secondFailure
assert.equal((await immediate.get('guide')).items[0].status, 'unknown')
assert.equal((await immediate.get('guide')).paused, true)
console.log('prompt-queue-regression: cap, payload, order, isolation, delivery, stop, recovery and unknown checks passed')
} finally {
if (!resolve(root).startsWith(workspace + '\\') && !resolve(root).startsWith(workspace + '/')) throw new Error('Unsafe test cleanup path')
await fs.rm(root, { recursive: true, force: true })
}
@@ -0,0 +1,493 @@
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { randomUUID, createHash } from 'node:crypto'
import { build } from 'esbuild'
import { zipSync } from 'fflate'
const testRoot = await fs.mkdtemp(path.resolve('.tmp/recommended-skills-'))
const tempRoot = path.join(testRoot, 'installs')
await fs.mkdir(tempRoot, { recursive: true })
let userData = path.join(testRoot, 'data')
await fs.mkdir(userData, { recursive: true })
;(globalThis as Record<string, unknown>).__ycodeUserData = userData
const checks: string[] = []
const check = (name: string, actual: unknown, expected: unknown): void => {
assert.deepEqual(actual, expected, name)
checks.push(name)
}
const sha256 = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex')
/** Each group gets its own skill store so installed state never leaks between cases. */
async function useGroup(name: string): Promise<void> {
userData = path.join(testRoot, `data-${name}`)
await fs.mkdir(userData, { recursive: true })
;(globalThis as Record<string, unknown>).__ycodeUserData = userData
}
const electronStub = {
name: 'isolated-electron-path',
setup(context: import('esbuild').PluginBuild) {
context.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'capability-test' }))
context.onLoad({ filter: /.*/, namespace: 'capability-test' }, () => ({
loader: 'js',
contents: `export const app={getPath:(name)=>{if(name!=='userData')throw Error('Unexpected app path');return globalThis.__ycodeUserData}};
export const safeStorage={isEncryptionAvailable:()=>false,encryptString:()=>{throw Error('unused')},decryptString:()=>{throw Error('unused')}};`
}))
}
}
async function compile(entry: string, file: string): Promise<string> {
const outfile = path.join(testRoot, file)
await build({ entryPoints: [path.resolve(entry)], outfile, bundle: true, platform: 'node', format: 'esm',
packages: 'external', logLevel: 'warning', plugins: [electronStub] })
return outfile
}
const servicePath = await compile('src/main/capabilities/recommended-skills.ts', 'recommended-skills.mjs')
const skillsPath = await compile('src/main/capabilities/skills.ts', 'skills.mjs')
const service = await import(pathToFileURL(servicePath).href)
const skills = await import(pathToFileURL(skillsPath).href)
interface FakeEntry { name: string; description?: string; version?: string; license?: string }
function archiveFor(entry: FakeEntry, extra: Record<string, string> = {}): Uint8Array {
const front = ['---', `name: ${entry.name}`, `description: ${entry.description ?? 'Controlled recommended skill'}`,
...(entry.version ? [`version: "${entry.version}"`] : []), ...(entry.license ? [`license: ${entry.license}`] : []), '---'].join('\n')
const files: Record<string, Uint8Array> = {
'skill/SKILL.md': new TextEncoder().encode(`${front}\n\nControlled body.\n`),
'skill/LICENSE': new TextEncoder().encode('MIT License\n\nPermission is hereby granted, free of charge.\n')
}
for (const [name, value] of Object.entries(extra)) files[`skill/${name}`] = new TextEncoder().encode(value)
return zipSync(files, { level: 0 })
}
/** A local skill folder that is not a recommendation package. */
async function makeLocalSkill(name: string, description = 'Locally created skill'): Promise<string> {
const folder = path.join(testRoot, `local-${name}-${randomUUID().slice(0, 8)}`)
await fs.mkdir(folder, { recursive: true })
await fs.writeFile(path.join(folder, 'SKILL.md'),
`---\nname: ${name}\ndescription: ${description}\n---\nLocal body.\n`)
return folder
}
const baseEntry = { id: 'archify', name: 'archify', description: 'Controlled', author: 'tt-a1i', license: 'MIT',
repository: 'tt-a1i/archify', version: '3.2.0', commit: '25cf37080fc8b1dbe758a4efc3935b55f3327083',
packagePath: 'archify.zip', expectedBytes: 0, containsScripts: true }
const archive = archiveFor({ name: 'archify', version: '3.2.0', license: 'MIT' })
const archiveHash = sha256(archive)
const catalog = [{ ...baseEntry, sha256: archiveHash, expectedBytes: archive.byteLength }]
function fakeResponse(body: Uint8Array, init: { status?: number; headers?: Record<string, string> } = {}): Response {
return new Response(new Uint8Array(body), { status: init.status ?? 200,
headers: { 'content-length': String(body.byteLength), ...(init.headers ?? {}) } })
}
type FakeRequest = (url: URL, init?: RequestInit) => Promise<Response>
/** A response that never completes until the caller's abort signal fires. */
function stallingResponse(totalBytes: number, chunk: Uint8Array, init?: RequestInit): Response {
let streamController: ReadableStreamDefaultController<Uint8Array> | undefined
let timer: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream({
start(controller) {
streamController = controller
// The interval stays referenced so the test event loop keeps running.
timer = setInterval(() => { try { controller.enqueue(chunk) } catch { /* aborted */ } }, 20)
}
})
init?.signal?.addEventListener('abort', () => {
if (timer) clearInterval(timer)
try { streamController?.error(Object.assign(new Error('aborted'), { name: 'AbortError' })) } catch { /* already closed */ }
})
return new Response(stream, { headers: { 'content-length': String(totalBytes) } })
}
function makeInstaller(request: FakeRequest, overrides: Partial<{
catalog: typeof catalog; timeoutMs: number
}> = {}) {
return service.createRecommendedSkillInstaller({
tempRoot, catalog: overrides.catalog ?? catalog, request, timeoutMs: overrides.timeoutMs ?? 5000,
snapshot: skills.getSkillInstallSnapshot, importArchive: skills.importRecommendedSkillArchive
})
}
// ---- unknown and duplicate operation identifiers -----------------------------
{
await useGroup('ids')
const installer = makeInstaller(async () => fakeResponse(archive))
await assert.rejects(installer.install('missing', { operationId: randomUUID() }), /推荐技能不存在/)
check('an unknown recommendation id is rejected', true, true)
const operationId = randomUUID()
const first = installer.install('archify', { operationId })
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /已有推荐技能正在安装/)
check('a second install is rejected while one is active', true, true)
await first
await assert.rejects(installer.install('archify', { operationId }), /不能重复执行/)
check('a reused operation id cannot run twice', true, true)
}
// ---- download failures --------------------------------------------------------
{
await useGroup('official-api')
const installer = makeInstaller(async (input, init) => {
const url = new URL(String(input))
check('download uses the official GitHub contents API', url.origin, 'https://api.github.com')
check('download pins the complete commit', url.searchParams.get('ref'), catalog[0].commit)
check('download requests raw bytes rather than JSON metadata', new Headers(init?.headers).get('accept'), 'application/vnd.github.raw+json')
return fakeResponse(archive)
})
await installer.install('archify', { operationId: randomUUID() })
}
{
await useGroup('api-rate-limit')
const installer = makeInstaller(async () => fakeResponse(archive, { status: 403, headers: { 'x-ratelimit-remaining': '0' } }))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /限额/)
check('GitHub API rate limits are actionable failures', installer.state()?.phase, 'failed')
}
{
await useGroup('http-404')
const installer = makeInstaller(async () => fakeResponse(archive, { status: 404 }))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /HTTP 404/)
check('a failed download is reported as a failure', installer.state()?.phase, 'failed')
}
{
await useGroup('redirect-host')
const installer = makeInstaller(async () => fakeResponse(archive, { status: 302, headers: { location: 'https://evil.example.com/archify.zip' } }))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /不允许的地址/)
check('a redirect to another host is refused', installer.state()?.phase, 'failed')
}
{
await useGroup('redirect-http')
const installer = makeInstaller(async () => fakeResponse(archive, { status: 302, headers: { location: 'http://raw.githubusercontent.com/x' } }))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /不允许的地址/)
check('a plain http redirect is refused', installer.state()?.phase, 'failed')
}
{
await useGroup('redirect-loop')
const installer = makeInstaller(async () => fakeResponse(archive, { status: 302, headers: { location: 'https://raw.githubusercontent.com/a/b/c' } }))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /重定向过多/)
check('too many redirects are refused', installer.state()?.phase, 'failed')
}
{
await useGroup('disconnect')
const installer = makeInstaller(async () => { throw Error('socket hang up') })
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /无法连接 GitHub.*网络或代理/)
check('a disconnected transport is reported', installer.state()?.phase, 'failed')
}
{
await useGroup('timeout')
const installer = makeInstaller(async (_url, init) => {
return stallingResponse(archive.byteLength, archive.subarray(0, 16), init)
}, { timeoutMs: 60 })
const operationId = randomUUID()
const pending = installer.install('archify', { operationId })
await assert.rejects(pending, /超时/)
check('a stalled download times out', installer.state()?.phase, 'failed')
}
{
await useGroup('oversize')
const oversize = new Uint8Array(21 * 1024 * 1024)
const installer = makeInstaller(async () => new Response(new ReadableStream({
start(controller) { controller.enqueue(oversize); controller.close() }
}), { headers: { 'content-length': String(oversize.byteLength) } }))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /超过 20MB/)
check('an oversized package is refused', installer.state()?.phase, 'failed')
}
{
await useGroup('hash')
const wrong = archiveFor({ name: 'archify', version: '3.2.0', license: 'MIT' })
wrong[0] = wrong[0] ^ 0xff
const installer = makeInstaller(async () => fakeResponse(wrong))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /SHA-256 校验失败/)
check('a hash mismatch is refused', installer.state()?.phase, 'failed')
}
{
await useGroup('truncated')
const installer = makeInstaller(async () => fakeResponse(archive.subarray(0, archive.byteLength - 3)))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /大小与目录不符/)
check('a truncated package is refused', installer.state()?.phase, 'failed')
}
// ---- cancellation -------------------------------------------------------------
{
await useGroup('cancel')
const installer = makeInstaller(async (_url, init) => stallingResponse(archive.byteLength, archive.subarray(0, 64), init))
const operationId = randomUUID()
const pending = installer.install('archify', { operationId })
await new Promise(resolve => setTimeout(resolve, 40))
const cancelled = installer.cancel(operationId)
check('a running download can be cancelled', cancelled.cancelled, true)
await assert.rejects(pending, /取消/)
check('a cancelled install never reports success', installer.state()?.phase, 'cancelled')
check('a cancelled install is not reported as failed', installer.state()?.phase === 'failed', false)
}
// ---- progress ordering --------------------------------------------------------
{
await useGroup('progress')
const seen: string[] = []
const installer = makeInstaller(async () => fakeResponse(archive))
installer.subscribe(progress => seen.push(progress.phase))
await installer.install('archify', { operationId: randomUUID() })
check('progress reaches downloading, validating and installed',
['downloading', 'validating', 'installed'].every(phase => seen.includes(phase)), true)
const stamps = seen.length
const state = installer.state()
check('the install state is a snapshot object', typeof state?.updatedAt, 'number')
check('progress events are emitted at least three times', stamps >= 3, true)
}
// ---- shutdown blocks new work and initialize restores it -----------------------
{
await useGroup('shutdown')
const installer = makeInstaller(async () => fakeResponse(archive))
await installer.stop()
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /正在停止/)
check('a stopped service refuses new installs', true, true)
await installer.initialize()
const installed = await installer.install('archify', { operationId: randomUUID() })
check('initialize restores the service after a stop', installed.name, 'archify')
}
// ---- real skill store: first install, origin and idempotency --------------------
{
await useGroup('first-install')
let downloads = 0
const installer = makeInstaller(async () => { downloads += 1; return fakeResponse(archive) })
const installed = await installer.install('archify', { operationId: randomUUID() })
check('the first install returns the installed skill', installed.name, 'archify')
check('the first install records the recommendation origin', installed.origin?.recommendationId, 'archify')
check('the first install records the archive hash', installed.origin?.archiveSha256, archiveHash)
check('the first install records the commit', installed.origin?.commit, catalog[0].commit)
check('the first install is enabled', installed.enabled, true)
const listed = await skills.listSkills()
check('the installed skill appears in the skill list', listed.length, 1)
check('the listed skill keeps the origin', listed[0].origin?.recommendationId, 'archify')
check('the first install downloaded exactly once', downloads, 1)
const again = await installer.install('archify', { operationId: randomUUID() })
check('an unchanged same-version install is idempotent', again.origin?.recommendationId, 'archify')
check('an unchanged same-version install does not download again', downloads, 1)
}
// ---- conflicting local skill requires explicit overwrite -----------------------
{
await useGroup('conflict')
const installer = makeInstaller(async () => fakeResponse(archive))
const local = await makeLocalSkill('archify')
await skills.importSkill(local, true)
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /已存在同名技能/)
check('a same-name local skill blocks an unconfirmed install', true, true)
const replaced = await installer.install('archify', { operationId: randomUUID(), overwrite: true })
check('an authorized overwrite installs the recommended package', replaced.origin?.recommendationId, 'archify')
}
// ---- a manual import clears a previous recommendation origin -------------------
{
await useGroup('manual-clears-origin')
const installer = makeInstaller(async () => fakeResponse(archive))
const installed = await installer.install('archify', { operationId: randomUUID() })
check('the recommended install records an origin', installed.origin?.recommendationId, 'archify')
const folder = path.join(userData, 'capabilities', 'skills', 'packages')
const entries = await fs.readdir(folder)
const managed = entries.find(name => !name.startsWith('.'))!
const manual = await makeLocalSkill('archify', 'Manual replacement')
await skills.importSkill(manual, true)
const listed = await skills.listSkills()
check('a manual import keeps the same skill id', listed.length, 1)
check('a manual import clears the recommendation origin', listed[0].origin, undefined)
check('a manual import is not flagged as locally edited', listed[0].userModified, false)
check('a manual import keeps the local description', listed[0].description, 'Manual replacement')
void managed
}
// ---- a disabled skill keeps its disabled state across a replacement -----------
{
await useGroup('disabled-retained')
const installer = makeInstaller(async () => fakeResponse(archive))
const installed = await installer.install('archify', { operationId: randomUUID() })
await skills.setSkillEnabled(installed.id, false)
const disabled = (await skills.listSkills())[0]
check('the skill can be disabled', disabled.enabled, false)
await installer.install('archify', { operationId: randomUUID(), overwrite: true })
const after = (await skills.listSkills())[0]
check('a replacement keeps the disabled state', after.enabled, false)
check('a replacement keeps the recommendation origin', after.origin?.recommendationId, 'archify')
}
// ---- package identity is enforced -------------------------------------------
{
await useGroup('package-identity')
const wrongVersion = archiveFor({ name: 'archify', version: '9.9.9', license: 'MIT' })
const versionInstaller = makeInstaller(async () => fakeResponse(wrongVersion),
{ catalog: [{ ...catalog[0], sha256: sha256(wrongVersion), expectedBytes: wrongVersion.byteLength }] })
await assert.rejects(versionInstaller.install('archify', { operationId: randomUUID() }), /名称、版本或许可证与目录不符/)
check('a package with another version is refused', versionInstaller.state()?.phase, 'failed')
}
{
await useGroup('package-license')
const wrongLicense = archiveFor({ name: 'archify', version: '3.2.0', license: 'Apache-2.0' })
const licenseInstaller = makeInstaller(async () => fakeResponse(wrongLicense),
{ catalog: [{ ...catalog[0], sha256: sha256(wrongLicense), expectedBytes: wrongLicense.byteLength }] })
await assert.rejects(licenseInstaller.install('archify', { operationId: randomUUID() }), /名称、版本或许可证与目录不符/)
check('a package with another license is refused', licenseInstaller.state()?.phase, 'failed')
}
{
await useGroup('package-traversal')
const traversal = zipSync({ 'skill/SKILL.md': new TextEncoder().encode('---\nname: archify\ndescription: x\nversion: "3.2.0"\nlicense: MIT\n---\n'),
'../escape.txt': new TextEncoder().encode('nope') }, { level: 0 })
const traversalInstaller = makeInstaller(async () => fakeResponse(traversal),
{ catalog: [{ ...catalog[0], sha256: sha256(traversal), expectedBytes: traversal.byteLength }] })
await assert.rejects(traversalInstaller.install('archify', { operationId: randomUUID() }))
check('a package with a path traversal entry is refused', traversalInstaller.state()?.phase, 'failed')
}
{
await useGroup('package-no-skill')
const noSkill = zipSync({ 'skill/readme.md': new TextEncoder().encode('nothing here') }, { level: 0 })
const noSkillInstaller = makeInstaller(async () => fakeResponse(noSkill),
{ catalog: [{ ...catalog[0], sha256: sha256(noSkill), expectedBytes: noSkill.byteLength }] })
await assert.rejects(noSkillInstaller.install('archify', { operationId: randomUUID() }))
check('a package without SKILL.md is refused', noSkillInstaller.state()?.phase, 'failed')
}
// ---- a changed local skill is detected at commit time ------------------------
{
await useGroup('commit-conflict')
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
let calls = 0
const installer = makeInstaller(async () => {
calls += 1
if (calls > 1) await gate
return fakeResponse(archive)
})
const installed = await installer.install('archify', { operationId: randomUUID() })
const skillFolder = path.join(userData, 'capabilities', 'skills', 'packages')
const managed = (await fs.readdir(skillFolder)).find(name => !name.startsWith('.'))!
await fs.writeFile(path.join(skillFolder, managed, 'SKILL.md'), '---\nname: archify\ndescription: edited locally\n---\nedited\n')
const pending = installer.install('archify', { operationId: randomUUID(), overwrite: true })
await new Promise(resolve => setTimeout(resolve, 30))
await fs.writeFile(path.join(skillFolder, managed, 'SKILL.md'), '---\nname: archify\ndescription: edited during download\n---\nedited\n')
release!()
await assert.rejects(pending, /已变化,请重新确认替换/)
check('a skill edited during the download is not overwritten silently', true, true)
const listed = await skills.listSkills()
check('the locally edited skill is preserved', listed[0].description, 'edited during download')
void installed
}
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, phase: 'archive' }))
// ---- a registry write failure must roll the install back ----------------------
{
await useGroup('registry-failure')
const installer = makeInstaller(async () => fakeResponse(archive))
const installed = await installer.install('archify', { operationId: randomUUID() })
check('the baseline install succeeds before the injected failure', installed.origin?.recommendationId, 'archify')
// A locally edited skill bypasses the idempotent fast path, so the import really runs.
const packages = path.join(userData, 'capabilities', 'skills', 'packages')
const managed = (await fs.readdir(packages)).find(name => !name.startsWith('.'))!
await fs.writeFile(path.join(packages, managed, 'SKILL.md'),
'---\nname: archify\ndescription: locally edited before the failure\n---\nedited\n')
const registryFile = path.join(userData, 'capabilities', 'skills', 'registry.json')
const before = await fs.readFile(registryFile, 'utf8')
const realRename = fs.rename
let failed = false
;(fs as unknown as { rename: typeof fs.rename }).rename = (async (from: string, to: string) => {
if (!failed && String(to).endsWith('registry.json')) { failed = true; throw Error('injected registry write failure') }
return realRename(from, to)
}) as typeof fs.rename
try {
await assert.rejects(installer.install('archify', { operationId: randomUUID(), overwrite: true }), /injected registry write failure/)
} finally {
;(fs as unknown as { rename: typeof fs.rename }).rename = realRename
}
check('a registry write failure is reported to the caller', failed, true)
const after = await fs.readFile(registryFile, 'utf8')
check('a failed registry write leaves the previous registry intact', after, before)
const listed = await skills.listSkills()
check('a failed registry write keeps the previously installed skill', listed.length, 1)
check('a failed registry write keeps the previous origin', listed[0].origin?.recommendationId, 'archify')
}
// ---- the commit stage refuses cancellation -----------------------------------
{
await useGroup('commit-cancel')
let commitEntered: (() => void) | undefined
const commitGate = new Promise<void>(resolve => { commitEntered = resolve })
const installer = service.createRecommendedSkillInstaller({
tempRoot, catalog, request: async () => fakeResponse(archive), timeoutMs: 5000,
snapshot: skills.getSkillInstallSnapshot,
importArchive: async (source, recommendation, overwrite, fingerprint, beforeCommit) => {
const real = skills.importRecommendedSkillArchive
// Enter the commit phase and hold it, so the cancel lands on an uncommitted-free stage.
const pending = real(source, recommendation, overwrite, fingerprint, () => {
beforeCommit()
commitEntered?.()
})
await commitGate
return pending
}
})
const operationId = randomUUID()
const pending = installer.install('archify', { operationId })
await commitGate
const cancelled = installer.cancel(operationId)
check('a cancel during the commit stage is refused', cancelled.cancelled, false)
const installed = await pending
check('a refused cancel still completes the install', installed.origin?.recommendationId, 'archify')
}
// ---- stop cancels a pre-commit install and later restores the service --------
{
await useGroup('stop-cancel')
let stall = true
const installer = makeInstaller(async (_url, init) => stall
? stallingResponse(archive.byteLength, archive.subarray(0, 32), init)
: fakeResponse(archive))
const operationId = randomUUID()
const pending = installer.install('archify', { operationId })
await new Promise(resolve => setTimeout(resolve, 30))
await installer.stop()
await assert.rejects(pending, /取消/)
check('stop cancels an install that has not committed', installer.state()?.phase, 'cancelled')
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /正在停止/)
check('a stopped service refuses a new install', true, true)
await installer.initialize()
stall = false
const restored = await installer.install('archify', { operationId: randomUUID() })
check('initialize restores installs after a stop', restored.origin?.recommendationId, 'archify')
}
// ---- the shipped catalog entry must match the real published package ---------
{
const cached = path.resolve('.tmp/recommended-audit/archify.zip')
const override = process.env.YCODE_ARCHIFY_ZIP ? path.resolve(process.env.YCODE_ARCHIFY_ZIP) : undefined
let available = true
let bytes: Uint8Array | undefined
try {
bytes = await fs.readFile(override ?? cached)
} catch {
// Offline by default: the published package is audited by a separate network test.
available = false
}
if (available) {
const catalogModule = await import(pathToFileURL(await compile('src/main/capabilities/skill-recommendations.ts', 'catalog.mjs')).href)
const entry = catalogModule.SKILL_RECOMMENDATIONS.find((item: { id: string }) => item.id === 'archify')
check('the shipped catalog contains the archify recommendation', !!entry, true)
check('the shipped catalog hash matches the published package', entry.sha256, sha256(bytes))
check('the shipped catalog size matches the published package', entry.expectedBytes, bytes.byteLength)
const written = path.join(testRoot, 'real-archify.zip')
await fs.writeFile(written, bytes)
await useGroup('real-package')
const installer = makeInstaller(async () => fakeResponse(bytes),
{ catalog: [{ ...catalog[0], sha256: entry.sha256, expectedBytes: entry.expectedBytes }] })
const installed = await installer.install('archify', { operationId: randomUUID() })
check('the published package installs through the real import path', installed.name, 'archify')
check('the published package keeps its recommendation origin', installed.origin?.recommendationId, 'archify')
const document = await skills.readSkill(installed.id)
check('the published package keeps its resource files', document.files.length > 100, true)
} else {
console.log(JSON.stringify({ result: 'SKIP', phase: 'real-package', reason: 'published package not present in .tmp' }))
}
}
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, phase: 'real-package' }))
+437
View File
@@ -0,0 +1,437 @@
import assert from 'node:assert/strict'
import { nextRequestSettings } from '../src/main/agent/request-settings'
import { useChatStore } from '../src/renderer/src/stores/chat'
import type { ActiveChatRun, SendPayload, SendResult } from '../src/shared/ipc'
import type { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta } from '../src/shared/types'
import type { SessionContext } from '../src/shared/workspaces'
interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
reject(reason?: unknown): void
}
interface FakeBackend {
api: FakeApi
sessions: SessionMeta[]
messages: Record<string, ChatMessage[]>
replaceSession(session: SessionMeta): void
}
interface FakeApi {
session: {
list(): Promise<SessionMeta[]>
create(workspaceId: string | undefined, context?: SessionContext): Promise<SessionMeta>
messages(sessionId: string): Promise<ChatMessage[]>
updateContext(sessionId: string, patch: SessionContext): Promise<SessionMeta>
remove(sessionId: string, options?: { onlyIfEmpty?: boolean }): Promise<void>
rename(sessionId: string, title: string): Promise<void>
}
chat: {
send(payload: SendPayload): Promise<SendResult>
setSessionVisible(sessionId: string | null): Promise<{ accepted: boolean }>
cancel(runId: string): Promise<{ cancelled: boolean }>
revert(sessionId: string, messageId: string, options?: unknown): Promise<{ messages: ChatMessage[] }>
respond(runId: string, interactionId: string, response: unknown): Promise<{ accepted: boolean }>
activeRun(sessionId: string): Promise<ActiveChatRun | null>
onEvent(listener: (event: ChatEvent) => void): () => void
onStarted(listener: (run: ActiveChatRun) => void): () => void
queue: {
get(sessionId: string): Promise<{ items: unknown[] }>
enqueue(payload: SendPayload): Promise<unknown>
}
}
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((accept, decline) => {
resolve = accept
reject = decline
})
return { promise, resolve, reject }
}
async function flushAsync(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
async function waitFor(description: string, predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) return
await new Promise<void>((resolve) => setTimeout(resolve, 5))
}
throw new Error(`timed out waiting for ${description}`)
}
function makeSession(id: string, overrides: Partial<SessionMeta> = {}): SessionMeta {
return { id, title: id, createdAt: 1, updatedAt: 1, ...overrides }
}
function makeMessage(id: string, role: ChatMessage['role'], content: string): ChatMessage {
return { id, role, content, createdAt: 1, status: 'done', ...(role === 'assistant' ? { tools: [], parts: [] } : {}) }
}
function makeSendResult(
runId: string,
modelId: string,
modelName: string,
contextUsage?: ContextUsageSnapshot
): SendResult {
const userMessage = makeMessage(`${runId}-user`, 'user', 'question')
const assistant: ChatMessage = {
id: `${runId}-assistant`,
role: 'assistant',
content: '',
tools: [],
parts: [],
requestId: runId,
modelId,
modelName,
contextUsage,
createdAt: 2,
turnStartedAt: userMessage.createdAt,
status: 'streaming'
}
return {
runId,
assistantMessageId: assistant.id,
modelId,
modelName,
userMessage,
messages: [userMessage, assistant]
}
}
function installFakeBackend(
sessionSeeds: SessionMeta[],
messageSeeds: Record<string, ChatMessage[]> = {}
): FakeBackend {
const sessions = sessionSeeds.map((item) => ({ ...item }))
const messages = Object.fromEntries(Object.entries(messageSeeds).map(([id, items]) => [id, [...items]]))
let createdSequence = 0
const api: FakeApi = {
session: {
list: async () => sessions.map((item) => ({ ...item })),
create: async (_workspaceId, context = {}) => {
const created = makeSession(`created-${++createdSequence}`, context)
sessions.unshift(created)
messages[created.id] = []
return { ...created }
},
messages: async (sessionId) => [...(messages[sessionId] ?? [])],
updateContext: async (sessionId, patch) => {
const index = sessions.findIndex((item) => item.id === sessionId)
if (index < 0) throw new Error(`unknown session ${sessionId}`)
const updated = { ...sessions[index], ...patch }
sessions[index] = updated
return { ...updated }
},
remove: async (sessionId, options) => {
const index = sessions.findIndex((item) => item.id === sessionId)
if (index < 0) return
if (options?.onlyIfEmpty && (messages[sessionId]?.length ?? 0) > 0) throw new Error('session is not empty')
sessions.splice(index, 1)
delete messages[sessionId]
},
rename: async (sessionId, title) => {
const index = sessions.findIndex((item) => item.id === sessionId)
if (index >= 0) sessions[index] = { ...sessions[index], title }
}
},
chat: {
send: async () => { throw new Error('fake send was not configured') },
setSessionVisible: async () => ({ accepted: true }),
cancel: async () => ({ cancelled: true }),
revert: async () => ({ messages: [] }),
respond: async () => ({ accepted: true }),
activeRun: async () => null,
onEvent: () => () => undefined,
onStarted: () => () => undefined,
queue: {
get: async () => ({ items: [] }),
enqueue: async () => undefined
}
}
}
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { ycode: api }
})
return {
api,
sessions,
messages,
replaceSession(session) {
const index = sessions.findIndex((item) => item.id === session.id)
if (index >= 0) sessions[index] = { ...session }
else sessions.unshift({ ...session })
}
}
}
function resetStore(
sessions: SessionMeta[],
currentSessionId: string | null,
messages: ChatMessage[] = [],
draftContext: SessionContext = {}
): void {
useChatStore.setState({
sessions,
currentSessionId,
messages,
sessionMessages: currentSessionId ? { [currentSessionId]: messages } : {},
sessionUsage: {},
sessionContextWindow: {},
runs: {},
runSequences: {},
sendingSessions: [],
running: null,
sending: false,
loading: false,
error: null,
draftContext,
draftId: 0
})
}
async function testSerialModelWritesAndFrozenRunningTurn(): Promise<void> {
const main = makeSession('main', { modelId: 'model-a', thinkingLevel: 'low' })
resetStore([main], main.id)
const backend = installFakeBackend([main], { [main.id]: [] })
const frozenUsage: ContextUsageSnapshot = {
usage: { inputTokens: 120, outputTokens: 30, totalTokens: 150 },
contextWindow: 64_000,
sampledAt: 10,
requestId: 'run-a',
modelId: 'model-a'
}
let sentPayload: SendPayload | undefined
backend.api.chat.send = async (payload) => {
sentPayload = payload
return makeSendResult('run-a', 'model-a', 'Model A', frozenUsage)
}
await useChatStore.getState().send('freeze current run')
assert.equal(sentPayload?.sessionId, main.id)
assert.equal(useChatStore.getState().running?.runId, 'run-a')
const writes: Array<{ id: string; patch: SessionContext; gate: Deferred<SessionMeta> }> = []
backend.api.session.updateContext = (sessionId, patch) => {
const gate = deferred<SessionMeta>()
writes.push({ id: sessionId, patch, gate })
return gate.promise
}
const resolveWrite = (index: number): void => {
const write = writes[index]
const current = backend.sessions.find((item) => item.id === write.id) ?? makeSession(write.id)
const updated = { ...current, ...write.patch }
backend.replaceSession(updated)
write.gate.resolve(updated)
}
const pendingA = useChatStore.getState().selectModel('model-a')
await waitFor('first serialized model write', () => writes.length === 1)
const pendingB = useChatStore.getState().selectModel('model-b')
const pendingC = useChatStore.getState().selectModel('model-c')
await flushAsync()
assert.equal(writes.length, 1, 'model writes must be serialized instead of racing')
resolveWrite(0)
await waitFor('second serialized model write', () => writes.length === 2)
resolveWrite(1)
await waitFor('third serialized model write', () => writes.length === 3)
resolveWrite(2)
await Promise.all([pendingA, pendingB, pendingC])
assert.deepEqual(writes.map((write) => write.patch.modelId), ['model-a', 'model-b', 'model-c'])
const state = useChatStore.getState()
assert.equal(state.sessions.find((item) => item.id === main.id)?.modelId, 'model-c')
const assistant = state.messages.find((message) => message.id === 'run-a-assistant')
assert.ok(assistant)
assert.equal(assistant.modelId, 'model-a', 'the active assistant must keep its starting model')
assert.equal(assistant.modelName, 'Model A')
assert.deepEqual(assistant.contextUsage?.usage, frozenUsage.usage)
assert.equal(assistant.contextUsage?.contextWindow, 64_000)
assert.deepEqual(state.sessionUsage[main.id], frozenUsage.usage)
assert.equal(state.sessionContextWindow[main.id], 64_000)
}
async function testSendFailureDoesNotPoisonRetry(): Promise<void> {
const main = makeSession('retry-session', { modelId: 'model-a' })
resetStore([main], main.id)
const backend = installFakeBackend([main], { [main.id]: [] })
let calls = 0
backend.api.chat.send = async () => {
calls++
if (calls === 1) throw new Error('mock send failure')
return makeSendResult('run-retry', 'model-a', 'Model A')
}
const fakeWindow = (globalThis as typeof globalThis & { window: { ycode: Record<string, unknown> } }).window
assert.equal('models' in fakeWindow.ycode, false, 'the fake window must not expose paid model APIs')
assert.equal('providers' in fakeWindow.ycode, false, 'the fake window must not expose provider APIs')
await assert.rejects(useChatStore.getState().send('first attempt'), /mock send failure/)
let state = useChatStore.getState()
assert.equal(state.sending, false)
assert.deepEqual(state.sendingSessions, [])
assert.deepEqual(state.messages, [])
assert.equal(state.error, 'mock send failure')
await useChatStore.getState().send('retry attempt')
state = useChatStore.getState()
assert.equal(calls, 2, 'the retry must reach the send boundary exactly once')
assert.equal(state.error, null)
assert.equal(state.running?.runId, 'run-retry')
assert.equal(state.messages.some((message) => message.id === 'run-retry-assistant'), true)
}
async function testLateResultAndEventStayInOriginalSession(): Promise<void> {
const first = makeSession('session-a', { modelId: 'model-a' })
const second = makeSession('session-b', { modelId: 'model-b' })
resetStore([first, second], first.id)
const secondMessage = makeMessage('session-b-history', 'user', 'keep this session')
const backend = installFakeBackend([first, second], {
[first.id]: [],
[second.id]: [secondMessage]
})
const response = deferred<SendResult>()
let sendStarted = false
backend.api.chat.send = async () => {
sendStarted = true
return response.promise
}
const sending = useChatStore.getState().send('slow send')
await waitFor('slow send to reach IPC', () => sendStarted)
await useChatStore.getState().selectSession(second.id)
assert.equal(useChatStore.getState().currentSessionId, second.id)
assert.deepEqual(useChatStore.getState().messages, [secondMessage])
response.resolve(makeSendResult('late-run', 'model-a', 'Model A'))
await sending
let state = useChatStore.getState()
assert.equal(state.currentSessionId, second.id, 'a late send response must not switch sessions')
assert.deepEqual(state.messages, [secondMessage], 'a late send response must not replace the selected session messages')
assert.equal(state.running, null, 'the original session run must not become the selected session run')
assert.equal(state.runs['late-run']?.sessionId, first.id)
assert.equal(state.sessionMessages[first.id]?.some((message) => message.id === 'late-run-assistant'), true)
useChatStore.getState().applyEvent({ runId: 'late-run', sessionId: first.id, type: 'text', delta: 'late text', sequence: 1 })
useChatStore.getState().applyEvent({ runId: 'late-run', sessionId: first.id, type: 'done', content: 'late text', tools: [], sequence: 2 })
await waitFor('late terminal event to commit', () => {
const assistant = useChatStore.getState().sessionMessages[first.id]?.find((message) => message.id === 'late-run-assistant')
return assistant?.content === 'late text' && assistant.status === 'done'
})
state = useChatStore.getState()
assert.equal(state.currentSessionId, second.id)
assert.deepEqual(state.messages, [secondMessage], 'late stream events must not leak into the selected session')
const originalAssistant = state.sessionMessages[first.id]?.find((message) => message.id === 'late-run-assistant')
assert.equal(originalAssistant?.content, 'late text')
assert.equal(originalAssistant?.status, 'done')
}
async function testDraftContextWinsWhileFirstSessionIsCreated(): Promise<void> {
resetStore([], null, [], { modelId: 'draft-a', thinkingLevel: 'low' })
const backend = installFakeBackend([], {})
const createGate = deferred<SessionMeta>()
const updateGate = deferred<SessionMeta>()
let createContext: SessionContext | undefined
let updateCapture: { id: string; patch: SessionContext } | undefined
let sendCalls = 0
backend.api.session.create = async (_workspaceId, context) => {
createContext = context ? { ...context } : undefined
return createGate.promise
}
backend.api.session.updateContext = async (sessionId, patch) => {
updateCapture = { id: sessionId, patch: { ...patch } }
return updateGate.promise
}
backend.api.chat.send = async () => {
sendCalls++
return makeSendResult('run-first', 'draft-b', 'Draft B')
}
const sending = useChatStore.getState().send('first message')
await waitFor('first session creation', () => createContext !== undefined)
assert.deepEqual(createContext, { modelId: 'draft-a', thinkingLevel: 'low' })
await useChatStore.getState().selectModel('draft-b')
await useChatStore.getState().selectThinkingLevel('high')
assert.deepEqual(useChatStore.getState().draftContext, { modelId: 'draft-b', thinkingLevel: 'high' })
const created = makeSession('created-session', { modelId: 'draft-a', thinkingLevel: 'low' })
backend.replaceSession(created)
createGate.resolve(created)
await waitFor('draft context patch after session creation', () => updateCapture !== undefined)
assert.deepEqual(updateCapture, {
id: 'created-session',
patch: { modelId: 'draft-b', thinkingLevel: 'high' }
})
assert.equal(sendCalls, 0, 'the first send must wait for the newer draft settings to persist')
const updated = { ...created, ...updateCapture!.patch }
backend.replaceSession(updated)
updateGate.resolve(updated)
await sending
const state = useChatStore.getState()
assert.equal(state.currentSessionId, 'created-session')
assert.equal(state.sessions.find((item) => item.id === 'created-session')?.modelId, 'draft-b')
assert.equal(state.sessions.find((item) => item.id === 'created-session')?.thinkingLevel, 'high')
assert.deepEqual(state.draftContext, { modelId: 'draft-b', thinkingLevel: 'high' })
assert.equal(sendCalls, 1)
}
function testNextRequestSettingsWhitelist(): void {
assert.deepEqual(nextRequestSettings({ modelId: 'model-next', thinkingLevel: 'high' }), {
modelId: 'model-next',
thinkingLevel: 'high'
})
assert.deepEqual(nextRequestSettings({}), {})
const forbidden: SessionContext[] = [
{ permissionMode: 'full-access' },
{ cwd: 'C:\\workspace' },
{ agentMode: 'plan' },
{ projectId: 'project-1' },
{ workspaceId: 'workspace-1' },
{ expertId: 'expert-1' },
{ modelId: 'model-next', permissionMode: 'default-sandbox' }
]
for (const patch of forbidden) {
assert.throws(() => nextRequestSettings(patch), /运行期间只能修改下次发送的模型和思考强度/)
}
assert.throws(() => nextRequestSettings({ modelId: ' ' }), /模型标识无效/)
assert.throws(() => nextRequestSettings({ thinkingLevel: 'invalid' as never }), /无效的思考强度/)
}
const originalFetch = globalThis.fetch
globalThis.fetch = (async () => {
throw new Error('network access is forbidden in request-settings regression')
}) as typeof fetch
const completed: string[] = []
try {
await testSerialModelWritesAndFrozenRunningTurn()
completed.push('serial model writes + frozen active turn')
await testSendFailureDoesNotPoisonRetry()
completed.push('send failure retry')
await testLateResultAndEventStayInOriginalSession()
completed.push('late result/event session isolation')
await testDraftContextWinsWhileFirstSessionIsCreated()
completed.push('draft settings during first session creation')
testNextRequestSettingsWhitelist()
completed.push('nextRequestSettings whitelist')
console.log(JSON.stringify({ result: 'PASS', tests: completed }, null, 2))
} finally {
globalThis.fetch = originalFetch
}
@@ -0,0 +1,26 @@
import { build } from 'esbuild'
import { spawnSync } from 'node:child_process'
const tests = [
'prompt-queue-regression.ts',
'prompt-queue-adversarial-regression.ts',
'input-composition-regression.ts',
'capability-picker-regression.tsx',
'request-settings-regression.ts',
'context-usage-regression.ts',
'claude-capabilities-regression.ts'
]
const directory = '.tmp/composer-capabilities-tests'
await build({
entryPoints: tests.map(file => `scripts/${file}`),
outdir: directory, outExtension: { '.js': '.mjs' }, bundle: true,
platform: 'node', format: 'esm', packages: 'external', jsx: 'automatic',
loader: { '.css': 'empty' }, logLevel: 'warning'
})
for (const file of tests) {
const result = spawnSync(process.execPath, [`${directory}/${file.replace(/\.tsx?$/, '.mjs')}`], {
stdio: 'inherit', windowsHide: true, timeout: 120000
})
if (result.error) throw result.error
if (result.status !== 0) process.exit(result.status ?? 1)
}
@@ -0,0 +1,14 @@
import { build } from 'esbuild'
import { spawnSync } from 'node:child_process'
const test = 'recommended-skills-backend-regression.ts'
const directory = '.tmp/recommended-skills-tests'
await build({
entryPoints: [`scripts/${test}`], outdir: directory, outExtension: { '.js': '.mjs' },
bundle: true, platform: 'node', format: 'esm', packages: 'external', logLevel: 'warning'
})
const result = spawnSync(process.execPath, [`${directory}/${test.replace(/\.tsx?$/, '.mjs')}`], {
stdio: 'inherit', windowsHide: true, timeout: 180000
})
if (result.error) throw result.error
if (result.status !== 0) process.exit(result.status ?? 1)
+2 -1
View File
@@ -121,7 +121,8 @@ async function main(): Promise<void> {
export const ipcMain = {
handle: (name, handler) => state.handlers.set(name, handler)
};
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
export const webContents = { getAllWebContents: () => [] };
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }), showSaveDialog: async () => ({ canceled: true }) };
export const shell = { openPath: async () => '' };
export const powerSaveBlocker = { start: () => 1, stop: () => undefined };
export class BrowserWindow {}
+88
View File
@@ -0,0 +1,88 @@
// Real ComposerControls + Slate in isolated Electron. No model, network or user storage.
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { spawn } from 'node:child_process'
const require = createRequire(import.meta.url), electron = require('electron')
if (typeof electron === 'string') {
const directory = fs.mkdtempSync(path.resolve('.tmp/capability-slash-'))
const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = spawn(electron, ['--no-sandbox', path.resolve('scripts/test-capability-slash-ui.mjs'), directory], { env, windowsHide: true, stdio: 'inherit' })
const timer = setTimeout(() => child.kill(), 90000)
process.exitCode = await new Promise(resolve => child.once('exit', code => { clearTimeout(timer); resolve(code ?? 1) }))
} else {
void (async () => {
const { app, BrowserWindow } = electron, directory = path.resolve(process.argv.at(-1))
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('capability-slash-'))
app.setPath('userData', path.join(directory, 'data')); app.disableHardwareAcceleration()
const source = file => JSON.stringify(path.resolve(file).replaceAll('\\', '/'))
fs.writeFileSync(path.join(directory, 'fixture.tsx'), `import React,{useState}from'react';import{createRoot}from'react-dom/client';
import{ComposerControls}from${source('src/renderer/src/workbuddy/composer/ComposerControls')};
import{withCapabilitySelection}from${source('src/renderer/src/workbuddy/composer/capability-selection')};
import${source('src/renderer/src/styles/tokens.css')};import${source('src/renderer/src/styles/app.css')};
window.ycode={resources:{},chat:{}};
function Test(){const[state,set]=useState({text:'',skills:[]}),[version,bump]=useState(0);window.__qa={state,setup:s=>{set(s);bump(v=>v+1)},toggle:()=>set(s=>withCapabilitySelection(s,{id:'archify',name:'archify',kind:'skill'},{source:'menu'}))};return <div className="chat-pane cr-theme" style={{padding:40}}><ComposerControls key={version} value={state.text} userContent={state.userContent} skills={state.skills} attachments={state.attachments??[]} selectedCapabilities={state.skills.map(s=>({id:s.id,kind:'skill'}))} capabilityItems={[{id:'archify',name:'archify',kind:'skill'},{id:'existing',name:'existing',kind:'skill'}]} onChange={text=>set(s=>({...s,text}))} onUserContentChange={userContent=>set(s=>({...s,userContent}))} onSelectCapability={(item,intent)=>set(s=>withCapabilitySelection(s,item,intent))} onRemoveSkill={id=>set(s=>({...s,skills:s.skills.filter(v=>v.id!==id)}))} onSend={()=>{throw Error('Unexpected send')}} /></div>}createRoot(document.getElementById('root')).render(<Test/>);`)
await require('esbuild').build({ entryPoints: [path.join(directory, 'fixture.tsx')], bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic', outfile: path.join(directory, 'fixture.js'), loader: { '.svg': 'file', '.png': 'file', '.jpg': 'file', '.woff': 'file', '.woff2': 'file', '.ttf': 'file' }, logLevel: 'warning' })
fs.writeFileSync(path.join(directory, 'index.html'), '<html data-theme="light"><link rel="stylesheet" href="fixture.css"><body><div id="root"></div><script type="module" src="fixture.js"></script></body></html>')
await app.whenReady()
const win = new BrowserWindow({ show: false, width: 1100, height: 850, webPreferences: { offscreen: true, backgroundThrottling: false } }), checks = [], errors = []
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
const read = code => win.webContents.executeJavaScript(code), pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const wait = async code => { for (let n = 0; n < 150; n++) { if (await read(code)) return; await pause(30) } throw Error('Timeout ' + code) }
const check = (name, actual, expected = true) => { assert.deepEqual(actual, expected, name); checks.push(name) }
const key = async (keyCode, modifiers = []) => { win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await pause(60) }
const setup = async state => { await read(`__qa.setup(${JSON.stringify(state)})`); await pause(160); await read('document.querySelector(".wb-composer__input").focus()') }
const choose = async (keyboard = false) => {
await wait('!!document.querySelector(".yc-composer-slash-picker")')
if (keyboard) { await read('document.querySelector(".yc-capability-picker input").focus()'); await key('Enter') }
else await read('[...document.querySelectorAll(".yc-capability-picker button")].find(e=>e.textContent.includes("archify")).click()')
await wait('!document.querySelector(".yc-composer-slash-picker")'); await pause(200)
}
try {
await win.loadFile(path.join(directory, 'index.html')); await wait('!!window.__qa')
await setup({ text: '', skills: [] }); await win.webContents.insertText('/archify'); await choose()
check('mouse selection consumes the textarea slash', await read('__qa.state.text'), '')
check('structured content has no stale slash', await read('__qa.state.userContent.filter(v=>v.type==="text").map(v=>v.text).join("")'), '')
check('one selected skill survives transition into Slate', await read('__qa.state.skills.map(v=>v.id)'), ['archify'])
check('the menu stays closed after editor remount', await read('!!document.querySelector(".yc-composer-slash-picker")'), false)
check('the real rich editor is focused', await read('!!document.activeElement.closest(".cr-reference-editor")'))
await setup({ text: '', skills: [] }); await win.webContents.insertText('/'); await choose(true)
check('Enter consumes a bare slash without sending', await read('__qa.state.text'), '')
await setup({ text: 'left RIGHT', skills: [] })
await read('document.querySelector("textarea").setSelectionRange(5,5)'); await win.webContents.insertText('/archify'); await choose()
check('selection preserves text after the caret', await read('__qa.state.text'), 'left RIGHT')
await setup({ text: 'before ', skills: [{ id: 'existing', name: 'existing' }], userContent: [{ type: 'skill', id: 'existing' }, { type: 'text', text: 'before ' }] })
await key('End', ['control']); await win.webContents.insertText('/archify'); await choose(true)
check('rich text selection preserves the existing skill', await read('__qa.state.skills.map(v=>v.id)'), ['existing', 'archify'])
check('rich text selection preserves surrounding text', await read('__qa.state.text'), 'before ')
check('rich text references survive token deletion', await read('__qa.state.userContent.filter(v=>v.type==="skill").map(v=>v.id).sort()'), ['archify', 'existing'])
await win.webContents.insertText('next'); await wait('__qa.state.text.includes("next")')
check('typing continues at the consumed trigger position', await read('__qa.state.text'), 'before next')
await setup({ text: '', skills: [{ id: 'archify', name: 'archify' }] })
await key('End', ['control']); await win.webContents.insertText('/archify'); await choose()
check('slash selection of an already selected skill never toggles it off', await read('__qa.state.skills.map(v=>v.id)'), ['archify'])
await read('__qa.toggle()'); await pause(100)
check('the add-menu policy still toggles an existing selection', await read('__qa.state.skills.length'), 0)
await setup({ text: 'first\nsecond ', skills: [], attachments: [{ id: 'fixture-file', name: 'keep.txt', mimeType: 'text/plain', size: 4 }], userContent: [{ type: 'attachment', id: 'fixture-file' }, { type: 'text', text: 'first\nsecond ' }] })
await key('End', ['control']); await win.webContents.insertText('/archify'); await choose()
check('multiline rich text survives slash deletion', await read('__qa.state.text'), 'first\nsecond ')
check('attachment metadata survives slash selection', await read('__qa.state.attachments.map(v=>v.id)'), ['fixture-file'])
check('attachment inline reference survives slash selection', await read('__qa.state.userContent.some(v=>v.type==="attachment"&&v.id==="fixture-file")'))
await setup({ text: '', skills: [] }); await win.webContents.insertText('/'); await wait('!!document.querySelector(".yc-composer-slash-picker")'); await read('document.querySelector(".yc-capability-picker input").focus()'); await key('Escape')
await pause(250); check('Escape closes the menu until the trigger is edited', await read('!!document.querySelector(".yc-composer-slash-picker")'), false)
await win.webContents.insertText('a'); await wait('!!document.querySelector(".yc-composer-slash-picker")')
check('editing the dismissed trigger reopens the menu', true)
await setup({ text: '', skills: [] })
await read('document.querySelector("textarea").dispatchEvent(new CompositionEvent("compositionstart",{bubbles:true}))')
await win.webContents.insertText('/'); await pause(150)
check('IME composition does not open a slash menu', await read('!!document.querySelector(".yc-composer-slash-picker")'), false)
await read('document.querySelector("textarea").dispatchEvent(new CompositionEvent("compositionend",{bubbles:true}))'); await wait('!!document.querySelector(".yc-composer-slash-picker")')
check('composition completion allows slash detection', true)
check('no renderer errors', errors, [])
fs.writeFileSync(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, errors }, null, 2)); console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory })); app.exit(0)
} catch (error) {
fs.writeFileSync(path.join(directory, 'failure.json'), JSON.stringify({ error: String(error.stack ?? error), checks, errors, state: await read('__qa.state'), focus: await read('({active:document.activeElement.outerHTML,selection:document.getSelection()?.toString(),anchor:document.getSelection()?.anchorNode?.parentElement?.outerHTML})'), html: await read('document.body.innerHTML') }, null, 2)); console.error(error); app.exit(1)
}
})().catch(error => { console.error(error); electron.app.exit(1) })
}
+285
View File
@@ -0,0 +1,285 @@
/** Real production ConnectorsPage in isolated Electron with a controlled MCP host.
* Only the host IPC is faked; the page, styles, icons and helpers are the shipped ones.
* No WorkBuddy runtime or original evidence is accessed by this script. */
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { spawn } from 'node:child_process'
const require = createRequire(import.meta.url)
const electronModule = require('electron')
const electron = typeof electronModule === 'object' && electronModule.app ? electronModule : null
const root = path.resolve('.'), base = path.join(root, '.tmp')
if (!electron) {
fs.mkdirSync(base, { recursive: true })
const output = fs.mkdtempSync(path.join(base, 'connectors-page-')), env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = spawn(electronModule, ['--no-sandbox', '--disable-gpu-sandbox', path.resolve('scripts/test-connectors-page.mjs'), output], { env, windowsHide: true, stdio: 'inherit' })
const timer = setTimeout(() => child.kill(), 120_000)
process.exitCode = await new Promise(resolve => child.once('exit', code => { clearTimeout(timer); resolve(code ?? 1) }))
} else void (async () => {
const { app, BrowserWindow, ipcMain } = electron
const output = path.resolve(process.argv.at(-1))
assert.ok(path.relative(base, output).startsWith('connectors-page-') && !path.relative(base, output).includes('..'))
electron.app.disableHardwareAcceleration()
fs.mkdirSync(path.join(output, 'data'), { recursive: true })
app.setPath('userData', path.join(output, 'data'))
await app.whenReady()
// The controlled host mirrors the production McpApi contract, including the
// approval hash rule and the per-server enable/connect state machine.
const calls = [], servers = new Map()
let failNextEnable = false
const clone = value => JSON.parse(JSON.stringify(value))
const makeServer = (id, name, overrides = {}) => ({
id, name, transport: 'stdio', command: 'node', args: ['server.mjs'], enabled: false,
status: 'disconnected', trusted: true, configHash: `hash-${id}`, tools: [],
envKeys: [], headerKeys: [], ...overrides
})
servers.set('ready', makeServer('ready', 'Ready Server', {
enabled: true, status: 'connected', tools: [
{ name: 'echo', description: 'Echo text', inputSchema: { type: 'object' }, enabled: true },
{ name: 'search', description: 'Find needles', inputSchema: { type: 'object' }, enabled: false }
]
}))
servers.set('untrusted', makeServer('untrusted', 'Unapproved Server', { trusted: false }))
servers.set('failing', makeServer('failing', 'Failing Server', { enabled: true, status: 'error', error: 'MCP 连接失败,请检查命令、地址和运行环境' }))
const host = async (method, args) => {
calls.push([method, ...args])
if (method === 'list') return clone([...servers.values()])
if (method === 'qaFailNextEnable') { failNextEnable = true; return }
const server = servers.get(args[0])
if (!server) throw Error('MCP 服务不存在')
if (method === 'save') {
const input = args[0]
if (!input?.name?.trim()) throw Error('请输入有效的 MCP 名称')
if (input.transport !== 'stdio' && input.transport !== 'http') throw Error('不支持的 MCP 连接类型')
if (input.transport === 'stdio' && !input.command?.trim()) throw Error('请输入有效的 MCP 启动命令')
const previous = input.id ? servers.get(input.id) : undefined
if (input.id && !previous) throw Error('MCP 服务不存在')
if (input.id && input.id !== previous.id) throw Error('MCP 服务不存在')
const next = makeServer(input.id ?? `saved-${servers.size}`, input.name.trim(), {
transport: input.transport, command: input.command, args: input.args ?? [], cwd: input.cwd, url: input.url,
enabled: previous?.enabled ?? false, trusted: false, tools: previous?.tools ?? []
})
servers.set(next.id, next)
return clone(next)
}
if (method === 'trust') {
if (server.configHash !== args[1]) throw Error('MCP 配置已变化,请重新查看并批准')
server.trusted = true
return
}
if (method === 'connect') {
if (!server.enabled) throw Error('MCP 服务已禁用')
if (!server.trusted) throw Error('请先批准当前 MCP 配置')
server.status = 'connected'; delete server.error
return clone(server)
}
if (method === 'disconnect') { server.status = 'disconnected'; return }
if (method === 'setEnabled') {
if (failNextEnable) { failNextEnable = false; throw Error('MCP 服务已禁用') }
if (args[1] && !server.trusted) throw Error('请先批准当前 MCP 配置')
server.enabled = args[1]
server.status = args[1] ? 'connected' : 'disconnected'
return
}
if (method === 'setToolEnabled') {
const tool = server.tools.find(item => item.name === args[1])
if (!tool) throw Error('MCP 工具不存在')
tool.enabled = args[2]
return
}
if (method === 'remove') { servers.delete(server.id); return }
throw Error('Unknown QA method ' + method)
}
ipcMain.handle('mcp-qa', async (_event, method, args) => host(method, args))
ipcMain.handle('resources-qa', async () => undefined)
fs.writeFileSync(path.join(output, 'preload.cjs'),
`const{contextBridge,ipcRenderer}=require('electron');contextBridge.exposeInMainWorld('ycode',{mcp:Object.fromEntries(['list','save','trust','connect','disconnect','setEnabled','setToolEnabled','remove','qaFailNextEnable'].map(method=>[method,(...args)=>ipcRenderer.invoke('mcp-qa',method,args)])),resources:{openExternal:async()=>undefined}});`)
const location = file => JSON.stringify(path.join(root, 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')}
createRoot(document.getElementById('root')!).render(<div className="chat-pane cr-theme" style={{ height: '100vh' }}><Center initialTab="connectors" onBackToChat={() => {}} /></div>)
`)
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', '.woff': 'file', '.woff2': 'file', '.ttf': 'file' },
logLevel: 'warning'
})
fs.writeFileSync(path.join(output, 'index.html'),
'<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"><style>html,body,#root{width:100%;height:100%;margin:0}</style></head><body><div id="root"></div><script type="module" src="fixture.js"></script></body></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 }
})
const errors = [], checks = []
win.webContents.on('console-message', (_event, level, message) => { if (level === 3 || level === 'error') errors.push(String(message)) })
const read = code => win.webContents.executeJavaScript(code)
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const check = (name, actual, expected) => {
if (typeof expected === 'function') assert.ok(expected(actual), name + ': ' + JSON.stringify(actual))
else assert.deepEqual(actual, expected, name)
checks.push(name)
}
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 => {
const point = await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing '+${JSON.stringify(selector)});const r=e.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`)
win.webContents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
await pause(120)
}
const clickText = async (selector, label) => {
const match = await read(`(()=>{const e=[...document.querySelectorAll(${JSON.stringify(selector)})].find(e=>e.textContent.trim()===${JSON.stringify(label)});if(!e)throw Error('Missing text '+${JSON.stringify(label)});e.dataset.qaTarget='true';return '[data-qa-target="true"]'})()`)
await click(match)
await read(`document.querySelector(${JSON.stringify(match)})?.removeAttribute('data-qa-target')`)
}
const capture = async name => {
win.webContents.invalidate()
// A hidden window keeps the previous frame, so warm the paint first, give
// the compositor a frame, and keep the second capture.
await win.webContents.capturePage()
await pause(220)
const image = await win.webContents.capturePage()
fs.writeFileSync(path.join(output, name + '.png'), image.toPNG())
return !image.isEmpty()
}
try {
await win.loadFile(path.join(output, 'index.html'))
// An inactive window still composites real frames; a hidden one does not.
win.showInactive()
await wait('!!document.querySelector(".ycode-connectors")')
check('the maintenance placeholder is gone', await read('!!document.querySelector("[data-capability-maintenance]")'), false)
check('the page renders the real connector list', await read('document.querySelectorAll(".connector-card").length'), 3)
check('the connector card names come from the host', await read('[...document.querySelectorAll(".connector-card-name")].map(e=>e.textContent)'), ['Ready Server', 'Unapproved Server', 'Failing Server'])
check('the toolbar keeps the shared search and the custom connector action', await read('[!!document.querySelector(".connectors-toolbar .ec-search-wrapper"), !!document.querySelector(".connector-topbar-action-btn")]'), [true, true])
await capture('connector-list')
check('the detail overlay opens for a selected connector', await (async () => {
await click('.connector-card')
await wait('!!document.querySelector(".connector-detail-modal")')
return read('document.querySelector(".connector-detail-name").textContent')
})(), 'Ready Server')
check('the detail overlay lists the real tool count', await read('document.querySelector(".connector-detail-desc").textContent'), '已连接 · 2 个工具')
await capture('connector-detail')
await click('.connector-detail-close')
check('the detail overlay closes', await read('!!document.querySelector(".connector-detail-modal")'), false)
await click('.connector-topbar-action-btn')
await wait('!!document.querySelector(".mcp-modal")')
check('the management modal opens with the real server list', await read('document.querySelectorAll(".mcp-item").length'), 3)
check('the panel reports the enabled count from the host', await read('document.querySelector(".mcp-panel-status-summary").textContent'), '2 启用 · 1 失败')
check('an unapproved server shows the approval notice', await read('!!document.querySelector(".mcp-item-approval")'), true)
check('an unapproved server cannot be enabled by the toggle', await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Unapproved Server'));return item.querySelector('.mcp-toggle').disabled})()`), true)
await capture('mcp-panel')
await clickText('.mcp-item-approval-btn', '批准配置')
await wait(`!document.querySelector('.mcp-item-approval')`)
check('approving the configuration reaches the host', calls.some(call => call[0] === 'trust' && call[1] === 'untrusted'), true)
check('approving also enables the approved server', await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Unapproved Server'));return item.querySelector('.mcp-toggle').getAttribute('aria-checked')})()`), 'true')
await click('.mcp-item-header')
await wait('!!document.querySelector(".mcp-item-tools")')
check('the expanded server lists its real tools', await read('[...document.querySelectorAll(".mcp-item-tool")].map(e=>e.textContent)'), ['echo', 'search'])
await clickText('.mcp-item-tool', 'search')
await wait(`!document.querySelector('.mcp-item-tool')?.classList.contains('mcp-item-tool--disabled')`)
check('toggling a tool reaches the host', calls.some(call => call[0] === 'setToolEnabled' && call[1] === 'ready' && call[2] === 'search'), true)
check('a failed server shows the real host error', await read('!!document.querySelector(".mcp-item-error")'), true)
await click('.mcp-item-error-link')
await wait(`!document.querySelector('.mcp-item-error')`)
check('the retry action reconnects the failed server', calls.some(call => call[0] === 'connect' && call[1] === 'failing'), true)
await clickText('.mcp-item-action', '编辑')
await wait('!!document.querySelector(".mcp-config-editor")')
check('the config editor opens with the real server document', await read(`(()=>{const ta=document.querySelector('.mcp-config-textarea');const editor=document.querySelector('.monaco-editor');return ta?ta.value:(document.querySelector('.mcp-config-editor-monaco-wrapper')?editor?'monaco':'loading':'')})()`),
value => value === 'loading' || value === 'monaco' || JSON.parse(value).mcpServers.ReadyServer)
await capture('mcp-editor')
await clickText('.mcp-config-editor-header-actions button', '取消')
await wait('!document.querySelector(".mcp-config-editor")')
await clickText('.mcp-modal-header-right button', '添加 MCP')
await wait('!!document.querySelector(".mcp-config-editor")')
check('the add editor starts from an empty stdio document', await read(`(()=>{const ta=document.querySelector('.mcp-config-textarea');if(ta)return JSON.parse(ta.value).mcpServers;const w=document.querySelector('.mcp-config-editor-monaco-wrapper');return w?'loading-or-monaco':''})()`),
value => value === 'loading-or-monaco' || !!value['my-mcp'])
await clickText('.mcp-config-editor-header-actions button', '取消')
await wait('!document.querySelector(".mcp-config-editor")')
// The card action opens the modal with exactly that server expanded.
await click('.mcp-modal-close')
await wait('!document.querySelector(".mcp-modal")')
await read(`(()=>{const card=[...document.querySelectorAll('.connector-card')].find(e=>e.textContent.includes('Failing Server'));card.querySelector('.connector-connect-btn').click();return true})()`)
await wait('!!document.querySelector(".mcp-modal")')
await wait(`!![...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Failing Server'))?.querySelector('.mcp-item-action[aria-label="删除 Failing Server"]')`)
await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Failing Server'));item.querySelector('.mcp-item-action[aria-label="删除 Failing Server"]').click();return true})()`)
await wait('!!document.querySelector(".yc-mcp-confirm")')
check('the delete confirmation names the real server', await read('document.querySelector(".yc-mcp-confirm").textContent.includes("Failing Server")'), true)
await clickText('.yc-mcp-confirm button', '取消')
await wait('!document.querySelector(".yc-mcp-confirm")')
check('cancelling the delete keeps the server', await read('document.querySelectorAll(".mcp-item").length'), 3)
const setSearch = async value => {
await read(`(()=>{const e=document.querySelector('.cb-search-input__input');if(!e)throw Error('missing MCP search input');
const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set;setter.call(e,${JSON.stringify(value)});
e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
await pause(200)
}
await setSearch('fail')
await pause(200)
check('the management search filters the real list', await read('document.querySelectorAll(".mcp-item").length'), 1)
await setSearch('')
await click('.mcp-modal-close')
await wait('!document.querySelector(".mcp-modal")')
check('closing the management modal returns to the card list', await read('document.querySelectorAll(".connector-card").length'), 3)
// A rejected host call must stay visible inside the management modal.
await read(`window.ycode.mcp.qaFailNextEnable()`)
await read(`(()=>{document.querySelector('.connector-topbar-action-btn').click();return true})()`)
await wait('!!document.querySelector(".mcp-modal")')
await wait(`!![...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Ready Server'))`)
await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Ready Server'));item.querySelector('.mcp-toggle').click();return true})()`)
await wait('!!document.querySelector(".mcp-modal [role=alert]")')
check('a rejected action reports the real host error inside the modal',
await read('document.querySelector(".mcp-modal [role=alert]").textContent'),
value => typeof value === 'string' && value.includes('MCP 服务已禁用'))
check('the error banner is not only behind the modal overlay',
await read(`(()=>{const el=document.querySelector('.mcp-modal [role=alert]');const r=el.getBoundingClientRect();return r.width>0&&r.height>0})()`), true)
check('the error banner is painted inside the visible modal body',
await read(`(()=>{const el=document.querySelector('.mcp-modal [role=alert]');const r=el.getBoundingClientRect();
const body=document.querySelector('.mcp-modal-body').getBoundingClientRect();
const s=getComputedStyle(el);
return {text:el.textContent,top:Math.round(r.top),bottom:Math.round(r.bottom),bodyTop:Math.round(body.top),
bodyBottom:Math.round(body.bottom),color:s.color,background:s.backgroundColor,visible:s.visibility,opacity:s.opacity}})()`),
value => value.text.includes('MCP 服务已禁用') && value.top >= value.bodyTop && value.bottom <= value.bodyBottom + 1 &&
value.color !== 'rgba(0, 0, 0, 0)' && value.background !== 'rgba(0, 0, 0, 0)' && value.visible === 'visible' && value.opacity === '1')
await capture('mcp-error')
check('no renderer errors', errors, [])
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({
result: 'PASS', checks,
calls,
limits: 'Controlled MCP host in isolated Electron; the shipped page, helpers, icons and styles are used. Not WorkBuddy pixel acceptance and not a real MCP server connection.'
}, 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), checks, errors, calls }, null, 2))
console.error(error)
win.destroy(); app.exit(1)
}
})().catch(error => { console.error(error); electron.app.exit(1) })
@@ -0,0 +1,223 @@
// Real Archify package audit for the recommended-skill installer.
// Downloads the pinned archive, verifies its digest, audits the ZIP structure and
// metadata, then imports it through the production skills module against an
// isolated Electron userData directory. No package script is ever executed.
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import fsSync from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
import { unzipSync } from 'fflate'
const COMMIT = '25cf37080fc8b1dbe758a4efc3935b55f3327083'
const REPOSITORY = 'tt-a1i/archify'
const PACKAGE_PATH = 'archify.zip'
const EXPECTED_SHA256 = '70e210265c59f24dcbdf2a502ff671104b5454e7ee7d3554aecd86bad32384f5'
const EXPECTED_BYTES = 5271959
const DOWNLOAD_URL = `https://api.github.com/repos/${REPOSITORY}/contents/${PACKAGE_PATH}?ref=${COMMIT}`
const EXPECTED_SKILL_NAME = 'archify'
const EXPECTED_SKILL_VERSION = '3.2'
const EXPECTED_PACKAGE_VERSION = '3.2.0'
const EXPECTED_LICENSE = 'MIT'
const EXPECTED_FILE_COUNT = 200
const EXPECTED_EXPANDED_BYTES = 17812933
const repository = path.resolve('.')
const testRoot = await fs.mkdtemp(path.resolve('.tmp/archify-package-'))
const relative = path.relative(path.resolve('.tmp'), testRoot)
assert.ok(relative.startsWith('archify-package-') && !relative.includes('..') && !path.isAbsolute(relative))
const userData = path.join(testRoot, 'data')
const workspace = path.join(testRoot, 'workspace')
await Promise.all([fs.mkdir(userData, { recursive: true }), fs.mkdir(workspace, { recursive: true })])
const checks = []
const check = (name, actual, expected) => {
assert.deepEqual(actual, expected, name)
checks.push(name)
}
const finding = (name, detail) => checks.push(`${name} :: ${detail}`)
const note = (name, detail) => checks.push(`${name} :: ${detail}`)
async function download() {
const local = process.env.YCODE_ARCHIFY_ZIP
if (local) {
const bytes = await fs.readFile(path.resolve(local))
note('archive source', `local file ${path.resolve(local)}`)
return bytes
}
const response = await fetch(DOWNLOAD_URL, { redirect: 'error', signal: AbortSignal.timeout(120000),
headers: { Accept: 'application/vnd.github.raw+json', 'X-GitHub-Api-Version': '2022-11-28' } })
assert.equal(response.status, 200, 'pinned archive download must succeed')
const bytes = Buffer.from(await response.arrayBuffer())
note('archive source', DOWNLOAD_URL)
return bytes
}
const bytes = await download()
const archivePath = path.join(testRoot, PACKAGE_PATH)
await fs.writeFile(archivePath, bytes)
check('pinned archive byte length matches the audited package', bytes.length, EXPECTED_BYTES)
const actualSha256 = crypto.createHash('sha256').update(bytes).digest('hex')
check('pinned archive digest matches the audited package', actualSha256, EXPECTED_SHA256)
check('archive is served from the pinned commit URL', DOWNLOAD_URL.includes(COMMIT), true)
const entries = unzipSync(bytes)
const names = Object.keys(entries)
const expandedBytes = names.reduce((sum, name) => sum + entries[name].length, 0)
check('archive contains the audited file count', names.length, EXPECTED_FILE_COUNT)
check('archive expands to the audited size', expandedBytes, EXPECTED_EXPANDED_BYTES)
check('every archive entry lives under a single skill directory',
names.every(name => name.startsWith('archify/')), true)
check('archive has exactly one SKILL.md at the package root',
names.filter(name => name === 'archify/SKILL.md').length, 1)
check('archive contains no nested second skill',
names.filter(name => name.split('/').filter(part => part === 'SKILL.md').length > 1).length, 0)
const text = (name) => Buffer.from(entries[name]).toString('utf8')
const skillMarkdown = text('archify/SKILL.md')
const frontMatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMarkdown)
assert.ok(frontMatter, 'SKILL.md must carry YAML front matter')
const skillMeta = frontMatter[1]
check('SKILL.md declares the audited skill name', /(^|\n)name:\s*archify\s*(\n|$)/.test(skillMeta), true)
check('SKILL.md declares the audited license', /(^|\n)license:\s*MIT\s*(\n|$)/.test(skillMeta), true)
check('SKILL.md declares the audited metadata version', /version:\s*"?3\.2"?/.test(skillMeta), true)
check('SKILL.md declares the audited author', /author:\s*tt-a1i/.test(skillMeta), true)
const packageJson = JSON.parse(text('archify/package.json'))
check('package.json declares the audited package name', packageJson.name, EXPECTED_SKILL_NAME)
check('package.json declares the audited package version', packageJson.version, EXPECTED_PACKAGE_VERSION)
check('package.json declares the audited license', packageJson.license, EXPECTED_LICENSE)
check('package.json keeps the package private', packageJson.private, true)
const release = JSON.parse(text('archify/skill-release.json'))
check('release manifest declares the audited version', release.version, EXPECTED_PACKAGE_VERSION)
check('release manifest points at the audited repository', release.source.repository, `https://github.com/${REPOSITORY}`)
check('release manifest exposes an update URL that the host must not auto-follow',
typeof release.updateManifestUrl === 'string' && release.updateManifestUrl.length > 0, true)
check('archive ships the MIT license text', text('archify/LICENSE').includes('MIT License'), true)
check('archive ships the third-party notices', names.includes('archify/THIRD_PARTY_NOTICES.md'), true)
const scriptEntries = names.filter(name => /\.(mjs|cjs|js|ts|sh|ps1|bat|cmd)$/i.test(name))
check('archive ships executable helper scripts as inert files', scriptEntries.length > 0, true)
note('script entries (not executed)', `${scriptEntries.length} files, e.g. ${scriptEntries.slice(0, 3).join(', ')}`)
// Import through the production skills module. Only Electron's userData path is
// redirected; every other code path is the shipped implementation.
const bundled = path.join(testRoot, 'skills-archify.mjs')
await build({
entryPoints: [path.resolve('src/main/capabilities/skills.ts')],
bundle: true,
platform: 'node',
format: 'esm',
outfile: bundled,
logLevel: 'warning',
banner: { js: "import { createRequire as createArchifyTestRequire } from 'node:module';const require=createArchifyTestRequire(import.meta.url);" },
plugins: [{
name: 'isolated-electron-path',
setup(context) {
context.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'archify-test' }))
context.onLoad({ filter: /.*/, namespace: 'archify-test' }, () => ({
contents: `export const app={getPath:(name)=>{if(name!=='userData')throw Error('Unexpected app path');return ${JSON.stringify(userData)}}};export const safeStorage={isEncryptionAvailable:()=>false,encryptString:(v)=>Buffer.from(String(v),'utf8'),decryptString:(v)=>Buffer.from(v).toString('utf8')}`
}))
}
}, {
name: 'record-child-process',
setup(context) {
context.onResolve({ filter: /^(node:)?child_process$/ }, () => ({ path: 'child-process', namespace: 'archify-test' }))
context.onLoad({ filter: /.*/, namespace: 'archify-test' }, () => ({
contents: `const record=(name)=>globalThis.__archifyChildProcesses.push(name);export const spawn=(...a)=>{record('spawn');throw Error('child process blocked in package audit')};export const spawnSync=(...a)=>{record('spawnSync');throw Error('child process blocked in package audit')};export const exec=(...a)=>{record('exec');throw Error('child process blocked in package audit')};export const execSync=(...a)=>{record('execSync');throw Error('child process blocked in package audit')};export const fork=(...a)=>{record('fork');throw Error('child process blocked in package audit')};export default{spawn,spawnSync,exec,execSync,fork};`
}))
}
}]
})
globalThis.__archifyChildProcesses = []
const bundleSource = await fs.readFile(bundled, 'utf8')
check('the production skills module does not spawn a child process during import',
globalThis.__archifyChildProcesses.length, 0)
const api = await import(pathToFileURL(bundled).href)
check('the isolated skill library starts empty', await api.listSkills(), [])
const imported = await api.importSkill(archivePath)
check('the pinned archive imports as the audited skill name', imported.name, EXPECTED_SKILL_NAME)
check('the imported skill is a user-scoped import', imported.scope, 'user')
check('the imported skill is enabled by default', imported.enabled, true)
check('the imported skill is not flagged as locally modified', imported.userModified, false)
// Archify nests its version under `metadata:` in the YAML front matter. The host
// parser only reads a top-level `version`, so record what the host can see and
// keep the package manifest as the authoritative version for validation.
finding('host-visible skill version', `imported.version=${String(imported.version)} (front matter nests version under metadata:; expected ${EXPECTED_SKILL_VERSION})`)
check('the package manifest carries the authoritative version', packageJson.version, EXPECTED_PACKAGE_VERSION)
check('the imported skill keeps the audited license text in its body',
(await api.readSkill(imported.id)).content.includes('license: MIT'), true)
const installedPath = path.resolve(imported.path)
check('the installed package stays inside the isolated userData directory',
installedPath.startsWith(path.resolve(userData) + path.sep), true)
check('the installed package never lands in the real user skill library',
installedPath.includes(path.join('capabilities', 'skills', 'packages')), true)
const document = await api.readSkill(imported.id)
check('the imported skill exposes every audited file', document.files.length, EXPECTED_FILE_COUNT)
// The host strips the single enclosing directory, so installed paths are
// relative to the skill root rather than to the archive root.
const installedNames = names.map(name => name.replace(/^archify\//, ''))
check('the imported file list is relative to the skill root',
document.files.every(name => !name.startsWith('archify/')), true)
check('the imported file list is sorted and complete',
document.files.join('|') === installedNames.slice().sort().join('|'), true)
check('the imported skill body is the audited SKILL.md',
document.content === skillMarkdown, true)
const listed = (await api.listSkills(workspace)).find(item => item.id === imported.id)
check('the installed skill is listed for the workspace', listed?.name, EXPECTED_SKILL_NAME)
check('the installed skill stays unmodified after import', listed?.userModified, false)
const reference = await api.readSkillResource(imported.id, 'references/repository-authoring.md', workspace)
check('a shipped reference resource is readable through the host', reference.length > 0, true)
await assert.rejects(api.readSkillResource(imported.id, '../escape.md', workspace), /越界|非法|不安全/)
checks.push('resource reads reject traversal outside the package')
const context = await api.buildSkillContext([imported.id], workspace)
check('the explicit skill context carries the real instructions', context.includes('<skill id='), true)
check('the explicit skill context lists the package resources', context.includes('bin/archify.mjs'), true)
// Nothing in the package may run during installation.
const workspaceEntries = await fs.readdir(workspace)
check('importing the package creates no workspace output', workspaceEntries, [])
check('importing the package creates no generated artifacts directory',
fsSync.existsSync(path.join(workspace, '.archify')), false)
const exampleFiles = []
const collectExamples = async (folder, prefix) => {
for (const item of await fs.readdir(folder, { withFileTypes: true })) {
if (item.isDirectory()) await collectExamples(path.join(folder, item.name), `${prefix}${item.name}/`)
else exampleFiles.push(`${prefix}${item.name}`)
}
}
await collectExamples(path.join(installedPath, 'examples'), '')
check('importing the package leaves the shipped examples untouched',
exampleFiles.length, names.filter(name => name.startsWith('archify/examples/')).length)
await fs.writeFile(path.join(testRoot, 'report.json'), JSON.stringify({
result: 'PASS',
scope: 'Real pinned GitHub archive; digest and ZIP structure audited; import through the production skills module against an isolated Electron userData directory. No package script is executed.',
source: { repository: REPOSITORY, commit: COMMIT, packagePath: PACKAGE_PATH, downloadUrl: DOWNLOAD_URL },
archive: { bytes: bytes.length, sha256: actualSha256, fileCount: names.length, expandedBytes },
metadata: {
skillName: EXPECTED_SKILL_NAME,
skillVersion: imported.version,
packageVersion: packageJson.version,
license: packageJson.license,
author: 'tt-a1i',
releaseVersion: release.version
},
checks,
limitations: [
'The digest and metadata confirm the bytes match the audited package; they are not a security audit of the skill code.',
'Package scripts are shipped as inert files and were never executed, so runtime dependencies and rendering behavior remain unverified.',
'The update manifest URL inside the package is reported only; the host must not auto-follow it.'
]
}, null, 2))
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory: testRoot, sha256: actualSha256 }))
+382
View File
@@ -0,0 +1,382 @@
/** Isolated Electron fixture for the recommended-skill page.
* The host is fully controlled: no network, no real skill storage, no user data. */
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { spawn } from 'node:child_process'
const require = createRequire(import.meta.url)
const electronModule = require('electron')
const electron = typeof electronModule === 'object' && electronModule.app ? electronModule : null
const repository = path.resolve('.'), base = path.join(repository, '.tmp')
if (!electron) {
fs.mkdirSync(base, { recursive: true })
const output = fs.mkdtempSync(path.join(base, 'recommended-skills-ui-'))
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
delete env.ELECTRON_RENDERER_URL
const child = spawn(electronModule, ['--no-sandbox', '--disable-gpu-sandbox', path.resolve('scripts/test-recommended-skills-ui.mjs'), output], { stdio: 'inherit', windowsHide: true, env })
const timer = setTimeout(() => 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(
<div className="chat-pane cr-theme" style={{height:'100vh'}}>
<Center initialTab="skills" onBackToChat={()=>{}} onUseSkill={skill=>window.__qa.uses.push(skill)} onCreateSkill={()=>window.__qa.created++} onFindSkill={()=>window.__qa.find++}/>
</div>);`)
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'), '<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"><style>html,body,#root{width:100%;height:100%;margin:0}</style></head><body><div id="root"></div><script type="module" src="fixture.js"></script></body></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 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) })
}
+3 -3
View File
@@ -39,7 +39,7 @@ if (!electron) {
fs.writeFileSync(path.join(output,'preload.cjs'), `const{contextBridge,ipcRenderer}=require('electron');contextBridge.exposeInMainWorld('ycode',{skills:Object.fromEntries(['list','read','setEnabled','remove','export','importPath'].map(method=>[method,(...args)=>ipcRenderer.invoke('skills-qa',method,args)]))});`)
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.__skillsQa={uses:[],created:0,find:0};createRoot(document.getElementById('root')).render(<div className="chat-pane cr-theme" style={{height:'100vh'}}><Center cwd={${JSON.stringify(workspace)}} onBackToChat={()=>{}} onUseSkill={skill=>window.__skillsQa.uses.push(skill)} onCreateSkill={()=>window.__skillsQa.created++} onFindSkill={()=>window.__skillsQa.find++}/></div>);`)
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','.woff':'file','.woff2':'file','.ttf':'file'},logLevel:'warning'})
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'),'<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"><style>html,body,#root{width:100%;height:100%;margin:0}</style></head><body><div id="root"></div><script type="module" src="fixture.js"></script></body></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)})
@@ -67,9 +67,9 @@ if (!electron) {
check('success notice does not affect normal flow',await read('getComputedStyle(document.querySelector(".yc-skill-notice")).position'),'absolute');await wait('!document.querySelector(".yc-skill-notice")');check('success notice disappears automatically',await read('!!document.querySelector(".yc-skill-notice")'),false)
await clickText('.sm-installed-batch-manage','批量管理');await click('.sm-card');await clickText('.sm-batch-toolbar button','禁用');check('batch disable persists real state',(await api.listSkills(workspace))[0].enabled,false);await clickText('.sm-batch-toolbar button','启用');check('batch enable persists real state',(await api.listSkills(workspace))[0].enabled,true);await clickText('.sm-installed-batch-manage','完成')
await click('.wb-switch__input');check('native toggle persists actual disabled state',(await api.listSkills(workspace))[0].enabled,false)
await click('.wb-switch__input');await click('.sm-card-name');await wait('document.querySelector(".sm-skill-detail-overview-body")?.textContent.includes("NATIVE_SKILL_PROOF")');check('detail renders real SKILL.md markdown',await read('document.querySelector(".sm-skill-detail-overview-body").textContent.includes("Actual skill instructions")'),true);await capture('detail')
await click('.wb-switch__input');await click('.sm-card-name');await wait('document.querySelector(".yc-skill-description-body")?.textContent.includes("NATIVE_SKILL_PROOF")');check('detail renders real SKILL.md markdown',await read('document.querySelector(".yc-skill-description-body").textContent.includes("Actual skill instructions")'),true);await capture('detail')
await click('[aria-label="使用技能"]');check('use callback receives actual enabled identity',await read('window.__skillsQa.uses[0].id'),(await api.listSkills(workspace))[0].id)
await clickText('.sm-skill-detail-action-row button','导出');check('export writes real ZIP',fs.existsSync(path.join(output,'export.zip')),true)
await click('.sm-card-name');await wait('!!document.querySelector(".sm-skill-detail-card")');await clickText('.sm-skill-detail-action-row button','导出');check('export writes real ZIP',fs.existsSync(path.join(output,'export.zip')),true)
await click('[aria-label="卸载技能"]');await key('Escape');await settle();check('nested Escape closes only top uninstall dialog',await read('!!document.querySelector(".sm-skill-detail-card")&&!document.querySelector("[aria-label=确认卸载]")'),true)
await click('[aria-label="关闭技能详情"]');await click('[aria-label="搜索技能"]');await cdp('Input.insertText',{text:'NO_MATCH'});await settle();check('real search filters installed list',await read('document.querySelectorAll(".sm-card").length'),0);await click('[aria-label="清空技能搜索"]');await wait('document.querySelectorAll(".sm-card").length===1')
await click('.sm-card-pin');check('pin updates shared slash menu memory',await read(`JSON.parse(localStorage.getItem(${JSON.stringify('ycode.skills.menu.v1:'+workspace.replaceAll('\\','/').toLowerCase())})).pinned.length`),1)
+827
View File
@@ -0,0 +1,827 @@
import assert from 'node:assert/strict'
import { createHash, generateKeyPairSync } from 'node:crypto'
import { createServer, type ServerResponse } from 'node:http'
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { signPayload as releaseSignPayload } from './updates/lib.mjs'
import { FileUpdatePersistence } from '../src/main/updates/storage'
import { compareVersions, parseVersion } from '../src/main/updates/semver'
import { normalizeManifestUrl, verifySignedManifest } from '../src/main/updates/manifest'
import { isTrustedUpdateRendererUrl } from '../src/main/updates/trust'
import { createNsisInstaller, NSIS_UNINSTALL_KEY } from '../src/main/updates/installer'
import { sanitizeLogText } from '../src/main/updates/logger'
import { UpdateManager } from '../src/main/updates/manager'
import {
UpdateError,
type CachedInstaller,
type FetchLike,
type InstallEnvironment,
type InstallerAdapter,
type SignedUpdateManifest,
type UpdateManifestPayload
} from '../src/main/updates/types'
const tests: Array<[string, () => Promise<void> | void]> = []
const test = (name: string, run: () => Promise<void> | void): void => {
tests.push([name, run])
}
const sleep = (milliseconds: number): Promise<void> => new Promise(resolve => setTimeout(resolve, milliseconds))
async function waitFor(predicate: () => boolean | Promise<boolean>, timeout = 5_000): Promise<void> {
const deadline = Date.now() + timeout
while (!await predicate()) {
if (Date.now() >= deadline) throw new Error('Timed out waiting for test condition')
await sleep(10)
}
}
function sha256(bytes: Buffer): string {
return createHash('sha256').update(bytes).digest('hex')
}
function makeBytes(size: number, fill: number): Buffer {
const bytes = Buffer.allocUnsafe(size)
for (let index = 0; index < size; index += 1) bytes[index] = (fill + index) & 0xff
return bytes
}
function expectUpdateError(run: () => unknown, code: string): void {
assert.throws(run, (error: unknown) => error instanceof UpdateError && error.code === code)
}
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
const publicPem = publicKey.export({ type: 'spki', format: 'pem' }).toString()
function manifestFor(
origin: string,
version: string,
bytes: Buffer,
notes = { zh: `更新 ${version}`, en: `Release ${version}` },
filename = `YCode-${version}-Setup.exe`
): SignedUpdateManifest {
const payload: UpdateManifestPayload = {
schemaVersion: 1,
version,
publishedAt: '2026-10-20T09:00:00.000Z',
notes,
platforms: {
'windows-x64': {
url: `${origin}/releases/versions/${version}/${filename}`,
size: bytes.byteLength,
sha256: sha256(bytes)
}
}
}
return {
payload,
signature: releaseSignPayload(payload, privatePem)
}
}
interface HttpFixture {
latest: SignedUpdateManifest | null
files: Map<string, Buffer>
requests: string[]
downloadRanges: Array<string | null>
redirect: 'none' | 'same-origin' | 'cross-origin'
crossOrigin: string
oversizeManifest: boolean
interruptNextDownload: boolean
chunkSize: number
chunkDelayMs: number
downloadStarted: number
origin: string
close: () => Promise<void>
}
async function sendBody(response: ServerResponse, body: Buffer, fixture: HttpFixture): Promise<void> {
for (let offset = 0; offset < body.byteLength; offset += fixture.chunkSize) {
if (response.destroyed) return
const chunk = body.subarray(offset, Math.min(body.byteLength, offset + fixture.chunkSize))
if (!response.write(chunk)) await new Promise<void>(resolve => response.once('drain', resolve))
if (fixture.chunkDelayMs > 0) await sleep(fixture.chunkDelayMs)
}
if (!response.destroyed) response.end()
}
async function createHttpFixture(): Promise<HttpFixture> {
let server: ReturnType<typeof createServer>
const fixture: HttpFixture = {
latest: null,
files: new Map(),
requests: [],
downloadRanges: [],
redirect: 'none',
crossOrigin: '',
oversizeManifest: false,
interruptNextDownload: false,
chunkSize: 64 * 1024,
chunkDelayMs: 0,
downloadStarted: 0,
origin: '',
close: async () => {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
}
server = createServer((request, response) => {
response.on('error', () => undefined)
const url = new URL(request.url ?? '/', fixture.origin)
fixture.requests.push(url.pathname)
if (url.pathname === '/releases/latest.json') {
if (fixture.oversizeManifest) {
const length = 1024 * 1024 + 1
response.writeHead(200, { 'content-type': 'application/json', 'content-length': String(length) })
response.end(Buffer.alloc(length, 0x20))
return
}
if (fixture.redirect === 'same-origin' && !url.searchParams.has('redirected')) {
response.writeHead(302, { location: '/releases/latest.json?redirected=1' })
response.end()
return
}
if (fixture.redirect === 'cross-origin') {
response.writeHead(302, { location: `${fixture.crossOrigin}/releases/latest.json` })
response.end()
return
}
const body = Buffer.from(JSON.stringify(fixture.latest))
response.writeHead(200, { 'content-type': 'application/json', 'content-length': String(body.byteLength) })
response.end(body)
return
}
const file = fixture.files.get(url.pathname)
if (!file) {
response.writeHead(404)
response.end()
return
}
const rangeMatch = /^bytes=(\d+)-$/.exec(request.headers.range ?? '')
const range = rangeMatch ? Number(rangeMatch[1]) : null
fixture.downloadRanges.push(rangeMatch?.[0] ?? null)
fixture.downloadStarted += 1
if (range !== null && range >= file.byteLength) {
response.writeHead(416, { 'content-range': `bytes */${file.byteLength}` })
response.end()
return
}
const start = range ?? 0
if (fixture.interruptNextDownload && start === 0) {
fixture.interruptNextDownload = false
const partial = file.subarray(0, Math.max(1, Math.floor(file.byteLength / 2)))
response.writeHead(200, {
'content-type': 'application/octet-stream',
'content-length': String(file.byteLength)
})
response.write(partial)
setTimeout(() => response.destroy(new Error('simulated interruption')), 20)
return
}
response.writeHead(start > 0 ? 206 : 200, {
'content-type': 'application/octet-stream',
'content-length': String(file.byteLength - start),
...(start > 0 ? { 'content-range': `bytes ${start}-${file.byteLength - 1}/${file.byteLength}` } : {})
})
void sendBody(response, file.subarray(start), fixture).catch(() => undefined)
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => resolve())
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Fake HTTP server did not bind')
fixture.origin = `http://127.0.0.1:${address.port}`
return fixture
}
class FakeInstaller implements InstallerAdapter {
validateCount = 0
verifyCount = 0
launchCount = 0
launchError: Error | null = null
onLaunch: (() => Promise<void>) | null = null
async validateEnvironment(_requiredBytes?: number): Promise<InstallEnvironment> {
this.validateCount += 1
return {
installationDirectory: 'C:\\Users\\Test\\AppData\\Local\\Programs\\YCode',
executablePath: 'C:\\Users\\Test\\AppData\\Local\\Programs\\YCode\\YCode.exe',
registryKey: `HKCU\\${NSIS_UNINSTALL_KEY}`
}
}
async verifyInstaller(_installer: CachedInstaller): Promise<void> {
this.verifyCount += 1
}
async launch(_installer: CachedInstaller, _environment: InstallEnvironment): Promise<void> {
this.launchCount += 1
if (this.onLaunch) await this.onLaunch()
if (this.launchError) throw this.launchError
}
}
interface ManagerFixture {
manager: UpdateManager
installer: FakeInstaller
persistence: FileUpdatePersistence
cacheDirectory: string
controls: {
beforeResult: boolean
beforeGate: Promise<boolean> | null
beforeCalls: number
failedCalls: number
quitCalls: number
quitError: Error | null
}
}
let managerSequence = 0
function createManagerFixture(
root: string,
origin: string,
options: { autoDownload?: boolean; currentVersion?: string; identity?: string } = {}
): ManagerFixture {
managerSequence += 1
const directory = join(root, `manager-${options.identity ?? managerSequence}`)
const installer = new FakeInstaller()
const persistence = new FileUpdatePersistence(join(directory, 'state.json'))
const controls: ManagerFixture['controls'] = {
beforeResult: true,
beforeGate: null,
beforeCalls: 0,
failedCalls: 0,
quitCalls: 0,
quitError: null
}
const manager = new UpdateManager({
currentVersion: options.currentVersion ?? '0.0.1',
manifestUrl: `${origin}/releases/latest.json`,
cacheDirectory: join(directory, 'cache'),
logFile: join(directory, 'updater.log'),
persistence,
publicKey: async () => publicPem,
supported: true,
installer,
beforeInstall: async () => {
controls.beforeCalls += 1
if (controls.beforeGate) return controls.beforeGate
return controls.beforeResult
},
quitForUpdate: async () => {
controls.quitCalls += 1
if (controls.quitError) throw controls.quitError
},
installFailed: async () => {
controls.failedCalls += 1
},
autoDownload: options.autoDownload ?? false,
checkDelayMs: 5,
checkIntervalMs: 60_000,
upToDateResetMs: 30,
manifestTimeoutMs: 2_000,
downloadTimeoutMs: 10_000
})
return { manager, installer, persistence, cacheDirectory: join(directory, 'cache'), controls }
}
test('strict SemVer parser and comparator', () => {
assert.deepEqual(parseVersion('1.2.3-alpha.1+build.7').prerelease, ['alpha', '1'])
assert.equal(compareVersions('1.2.3+build.1', '1.2.3+build.2'), 0)
assert.equal(compareVersions('1.2.3-alpha.2', '1.2.3-alpha.10'), -1)
assert.equal(compareVersions('1.2.3', '1.2.3-rc.1'), 1)
expectUpdateError(() => parseVersion('01.2.3'), 'invalid-version')
expectUpdateError(() => parseVersion('1.2.3-01'), 'invalid-version')
})
test('manifest signature, exact metadata and URL policy', async () => {
const origin = 'http://127.0.0.1:65530'
const bytes = makeBytes(1024, 5)
const manifest = manifestFor(origin, '1.0.1', bytes)
const verified = verifySignedManifest(Buffer.from(JSON.stringify(manifest)), {
manifestUrl: `${origin}/releases/latest.json`,
publicKeyPem: publicPem
})
assert.equal(verified.payload.version, '1.0.1')
const tampered = structuredClone(manifest)
tampered.payload.platforms['windows-x64'].sha256 = '0'.repeat(64)
expectUpdateError(
() => verifySignedManifest(Buffer.from(JSON.stringify(tampered)), {
manifestUrl: `${origin}/releases/latest.json`,
publicKeyPem: publicPem
}),
'invalid-signature'
)
const invalidVersionPayload = structuredClone(manifest.payload) as unknown as Record<string, unknown>
invalidVersionPayload.version = '01.0.0'
const invalidVersionDocument = {
payload: invalidVersionPayload,
signature: releaseSignPayload(invalidVersionPayload, privatePem)
}
expectUpdateError(
() => verifySignedManifest(Buffer.from(JSON.stringify(invalidVersionDocument)), {
manifestUrl: `${origin}/releases/latest.json`,
publicKeyPem: publicPem
}),
'invalid-version'
)
const outsidePath = structuredClone(manifest.payload)
outsidePath.platforms['windows-x64'].url = `${origin}/releases/other/YCode.exe`
const outsidePathDocument = {
payload: outsidePath,
signature: releaseSignPayload(outsidePath, privatePem)
}
expectUpdateError(
() => verifySignedManifest(Buffer.from(JSON.stringify(outsidePathDocument)), {
manifestUrl: `${origin}/releases/latest.json`,
publicKeyPem: publicPem
}),
'invalid-manifest'
)
assert.equal(normalizeManifestUrl(`${origin}/releases/latest.json`), `${origin}/releases/latest.json`)
})
test('trusted renderer URL gate rejects task and foreign frames', () => {
const appPath = 'C:\\Program Files\\YCode\\resources\\app.asar'
const mainFile = pathToFileURL('C:\\Program Files\\YCode\\resources\\app.asar\\out\\renderer\\index.html').href
const settingsFile = pathToFileURL('C:\\Program Files\\YCode\\resources\\app.asar\\out\\renderer\\settings.html').href
assert.equal(isTrustedUpdateRendererUrl(mainFile, { appPath, platform: 'win32' }), true)
assert.equal(isTrustedUpdateRendererUrl(settingsFile, { appPath, platform: 'win32' }), true)
assert.equal(isTrustedUpdateRendererUrl(`${mainFile}?window=task`, { appPath, platform: 'win32' }), false)
assert.equal(isTrustedUpdateRendererUrl('file:///C:/Windows/System32/drivers/etc/hosts', { appPath, platform: 'win32' }), false)
assert.equal(isTrustedUpdateRendererUrl('http://localhost:5173/index.html?window=task', {
appPath,
rendererUrl: 'http://localhost:5173'
}), false)
assert.equal(isTrustedUpdateRendererUrl('http://localhost:5173/settings.html', {
appPath,
rendererUrl: 'http://localhost:5173'
}), true)
assert.equal(isTrustedUpdateRendererUrl('http://127.0.0.1:5173/settings.html', {
appPath,
rendererUrl: 'http://localhost:5173'
}), false)
})
test('installer adapter uses spawn, current key and NSIS arguments', async () => {
assert.equal(NSIS_UNINSTALL_KEY.includes('8a4ee941-d893-569b-8bcb-2a55d270c04b'), true)
const root = 'C:\\Users\\Test\\AppData\\Local\\YCode Custom\\Install Dir'
const executable = `${root}\\YCode.exe`
const cacheDirectory = join(await mkdtemp(join(tmpdir(), 'ycode-update-adapter-')), 'cache')
const calls: Array<{ command: string; args: readonly string[]; detached: boolean; stdio: string }> = []
const child = {
once(event: 'spawn' | 'error', listener: (() => void) | ((error: Error) => void)) {
if (event === 'spawn') queueMicrotask(() => (listener as () => void)())
return child
},
unref() {}
}
const baseOptions = {
platform: 'win32',
isPackaged: true,
execPath: executable,
appPath: `${root}\\resources\\app.asar`,
cacheDirectory,
processId: 4321,
resolveRealPath: async value => value,
readStat: async () => ({ isFile: () => true }),
queryRegistryValue: async name => {
if (name === 'UninstallString') return `"${root}\\Uninstall YCode.exe" /currentuser`
if (name === 'DisplayIcon') return `${executable},0`
return null
},
verifyFile: async () => undefined,
spawn: (command, args, options) => {
calls.push({ command, args, detached: options.detached, stdio: options.stdio })
return child
}
} as const
const adapter = createNsisInstaller({
...baseOptions,
readDiskStats: async () => ({ bsize: 4096, bavail: 1_000_000 })
})
const environment = await adapter.validateEnvironment(1024)
assert.equal(environment.installationDirectory, root)
const cached: CachedInstaller = {
version: '1.0.2',
path: join(cacheDirectory, 'YCode-1.0.2-Setup.exe'),
partPath: join(cacheDirectory, 'YCode-1.0.2-Setup.exe.part'),
size: 10,
sha256: 'a'.repeat(64)
}
await adapter.launch(cached, environment)
assert.equal(calls.length, 1)
assert.equal(calls[0].detached, true)
assert.equal(calls[0].stdio, 'ignore')
assert.deepEqual([...calls[0].args], [
'/S',
'--updated',
'--force-run',
'/currentuser',
'/YCODEWAITPID=4321',
`/D=${root}`
])
assert.equal(calls[0].args.at(-1), `/D=${root}`)
const lowSpace = createNsisInstaller({
...baseOptions,
readDiskStats: async () => ({ bsize: 1, bavail: 1 })
})
await assert.rejects(
() => lowSpace.validateEnvironment(1024),
(error: unknown) => error instanceof UpdateError && error.code === 'installer-space-check'
)
const directoryMarker = createNsisInstaller({
...baseOptions,
readStat: async value => ({ isFile: () => !value.endsWith('app.asar') })
})
await assert.rejects(
() => directoryMarker.validateEnvironment(0),
(error: unknown) => error instanceof UpdateError
&& error.code === 'installer-marker'
&& /app\.asar/.test(error.message)
)
})
test('HTTP manifest limits, same-origin redirects and cross-origin rejection', async () => {
const first = await createHttpFixture()
const second = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-http-'))
try {
const bytes = makeBytes(256 * 1024, 11)
first.latest = manifestFor(first.origin, '1.0.1', bytes)
first.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
first.redirect = 'same-origin'
const redirected = createManagerFixture(root, first.origin, { identity: 'redirect' })
const redirectedState = await redirected.manager.check()
assert.equal(redirectedState.status, 'available')
first.redirect = 'cross-origin'
first.crossOrigin = second.origin
second.latest = first.latest
const cross = createManagerFixture(root, first.origin, { identity: 'cross' })
const crossState = await cross.manager.check()
assert.equal(crossState.status, 'error')
assert.match(crossState.error ?? '', /跨源/)
first.redirect = 'none'
first.oversizeManifest = true
const oversized = createManagerFixture(root, first.origin, { identity: 'oversize' })
const oversizedState = await oversized.manager.check()
assert.equal(oversizedState.status, 'error')
assert.match(oversizedState.error ?? '', /大小限制/)
} finally {
await first.close()
await second.close()
await rm(root, { recursive: true, force: true })
}
})
test('automatic download, dismissal and up-to-date idle reset', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-auto-'))
try {
const bytes = makeBytes(192 * 1024, 17)
http.latest = manifestFor(http.origin, '1.0.1', bytes)
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
const auto = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'auto' })
const checked = await auto.manager.checkAndDownload(false)
assert.equal(checked.status, 'downloading')
assert.equal(checked.version, '1.0.1')
assert.equal(checked.lastExplicit, false)
await waitFor(async () => (await auto.manager.getState()).status === 'ready')
assert.equal((await auto.manager.getState()).progress, 100)
assert.equal((await auto.manager.getState()).lastExplicit, false)
http.oversizeManifest = true
const retainedReady = await auto.manager.check(false)
assert.equal(retainedReady.status, 'ready')
assert.equal(retainedReady.lastExplicit, false)
http.oversizeManifest = false
const dismissed = await auto.manager.dismiss()
assert.equal(dismissed.dismissedVersion, '1.0.1')
assert.equal(dismissed.lastExplicit, true)
const persisted = await auto.persistence.load()
assert.equal(persisted.dismissedVersion, '1.0.1')
const second = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'dismissed' })
await second.persistence.save({ schemaVersion: 1, dismissedVersion: '1.0.1' })
const before = http.requests.length
const dismissedCheck = await second.manager.checkAndDownload()
await sleep(80)
assert.equal(dismissedCheck.dismissedVersion, '1.0.1')
assert.equal(dismissedCheck.lastExplicit, true)
assert.equal(http.requests.length - before, 1)
http.latest = manifestFor(http.origin, '0.0.1', bytes)
const current = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'current' })
const upToDate = await current.manager.check()
assert.equal(upToDate.status, 'up-to-date')
await sleep(60)
assert.equal((await current.manager.getState()).status, 'idle')
http.latest = manifestFor(http.origin, '1.0.1', bytes)
const explicit = createManagerFixture(root, http.origin, { identity: 'explicit-reset' })
assert.equal((await explicit.manager.check(true)).lastExplicit, true)
http.oversizeManifest = true
const backgroundFailure = await explicit.manager.check(false)
assert.equal(backgroundFailure.status, 'error')
assert.equal(backgroundFailure.lastExplicit, false)
http.oversizeManifest = false
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('download interruption resumes with valid Range and fixed cache filename', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-resume-'))
try {
const bytes = makeBytes(2 * 1024 * 1024, 23)
http.latest = manifestFor(http.origin, '1.0.1', bytes)
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
const fixture = createManagerFixture(root, http.origin, { identity: 'resume' })
assert.equal((await fixture.manager.check()).status, 'available')
http.interruptNextDownload = true
const interrupted = await fixture.manager.download()
assert.equal(interrupted.status, 'error')
const partInfo = await stat(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe.part'))
assert.ok(partInfo.size > 0)
const resumed = await fixture.manager.download()
assert.equal(resumed.status, 'ready')
assert.ok(http.downloadRanges.some(range => range?.startsWith('bytes=')))
const cached = await readFile(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe'))
assert.equal(sha256(cached), sha256(bytes))
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('concurrent download is single-flight and release switch aborts stale bytes', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-race-'))
try {
const firstBytes = makeBytes(4 * 1024 * 1024, 31)
const secondBytes = makeBytes(384 * 1024, 47)
http.latest = manifestFor(http.origin, '1.0.1', firstBytes)
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', firstBytes)
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', secondBytes)
http.chunkSize = 32 * 1024
http.chunkDelayMs = 15
const fixture = createManagerFixture(root, http.origin, { identity: 'race' })
assert.equal((await fixture.manager.check()).status, 'available')
const staleDownload = fixture.manager.download()
await waitFor(() => http.downloadStarted > 0)
http.latest = manifestFor(http.origin, '1.0.2', secondBytes)
const switched = await fixture.manager.check()
assert.equal(switched.status, 'available')
assert.equal(switched.version, '1.0.2')
const staleResult = await staleDownload
assert.notEqual(staleResult.status, 'ready')
assert.equal((await fixture.manager.getState()).version, '1.0.2')
http.chunkDelayMs = 0
const rangeCount = http.downloadRanges.length
const [one, two] = await Promise.all([fixture.manager.download(), fixture.manager.download()])
assert.equal(one.status, 'ready')
assert.equal(two.status, 'ready')
assert.equal(http.downloadRanges.length - rangeCount, 1)
const cached = await readFile(join(fixture.cacheDirectory, 'YCode-1.0.2-Setup.exe'))
assert.equal(sha256(cached), sha256(secondBytes))
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('install cancellation, launch failure marker cleanup, and success ordering', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-install-'))
try {
const bytes = makeBytes(256 * 1024, 61)
http.latest = manifestFor(http.origin, '1.0.2', bytes)
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes)
const fixture = createManagerFixture(root, http.origin, { identity: 'install' })
assert.equal((await fixture.manager.check()).status, 'available')
fixture.controls.beforeResult = false
const cancelled = await fixture.manager.install()
assert.equal(cancelled.status, 'ready')
assert.equal(fixture.installer.launchCount, 0)
assert.equal(fixture.controls.failedCalls, 0)
assert.equal((await fixture.manager.download()).status, 'ready')
fixture.controls.beforeResult = true
fixture.installer.launchError = new Error('spawn failed')
const failed = await fixture.manager.install()
assert.equal(failed.status, 'error')
assert.equal(fixture.controls.failedCalls, 1)
const cleared = await fixture.persistence.load()
assert.equal(cleared.unfinishedVersion, undefined)
assert.equal(cleared.target, undefined)
assert.equal((await fixture.manager.check()).status, 'ready')
fixture.installer.launchError = null
let markerAtLaunch: string | undefined
fixture.installer.onLaunch = async () => {
markerAtLaunch = (await fixture.persistence.load()).unfinishedVersion
}
const installed = await fixture.manager.install()
assert.equal(installed.status, 'installing')
assert.equal(markerAtLaunch, '1.0.2')
assert.equal(fixture.installer.validateCount >= 2, true)
assert.equal(fixture.installer.verifyCount >= 2, true)
assert.equal(fixture.installer.launchCount, 2)
assert.equal(fixture.controls.quitCalls, 1)
const retained = await fixture.persistence.load()
assert.equal(retained.unfinishedVersion, '1.0.2')
assert.equal(retained.target?.sha256, sha256(bytes))
const afterCommittedDismiss = await fixture.manager.dismiss()
assert.equal(afterCommittedDismiss.status, 'installing')
assert.equal(afterCommittedDismiss.dismissedVersion, undefined)
const afterCommittedInstall = await fixture.manager.install()
assert.equal(afterCommittedInstall.status, 'installing')
assert.equal(fixture.installer.launchCount, 2)
assert.equal(fixture.controls.beforeCalls, 3)
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('confirmation pending blocks dismiss and repeated install', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-confirm-lock-'))
try {
const bytes = makeBytes(128 * 1024, 67)
http.latest = manifestFor(http.origin, '1.0.2', bytes)
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes)
const fixture = createManagerFixture(root, http.origin, { identity: 'confirm-lock' })
assert.equal((await fixture.manager.check()).status, 'available')
assert.equal((await fixture.manager.download()).status, 'ready')
let releaseConfirmation!: (accepted: boolean) => void
fixture.controls.beforeGate = new Promise(resolve => {
releaseConfirmation = resolve
})
const firstInstall = fixture.manager.install()
await waitFor(() => fixture.controls.beforeCalls === 1)
const dismissed = await fixture.manager.dismiss()
assert.equal(dismissed.status, 'ready')
assert.equal(dismissed.dismissedVersion, undefined)
const repeatedInstall = fixture.manager.install()
assert.equal(fixture.installer.launchCount, 0)
assert.equal(fixture.controls.beforeCalls, 1)
releaseConfirmation(false)
const [firstState, repeatedState] = await Promise.all([firstInstall, repeatedInstall])
assert.equal(firstState.status, 'ready')
assert.equal(repeatedState.status, 'ready')
assert.equal(fixture.installer.launchCount, 0)
assert.equal(fixture.controls.failedCalls, 0)
assert.equal((await fixture.persistence.load()).dismissedVersion, undefined)
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('committed installer blocks recovery after quit failure', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-committed-'))
try {
const bytes = makeBytes(128 * 1024, 73)
http.latest = manifestFor(http.origin, '1.0.2', bytes)
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes)
const fixture = createManagerFixture(root, http.origin, { identity: 'committed' })
assert.equal((await fixture.manager.check()).status, 'available')
assert.equal((await fixture.manager.download()).status, 'ready')
fixture.controls.quitError = new Error('simulated quit failure')
const committed = await fixture.manager.install()
assert.equal(committed.status, 'installing')
assert.match(committed.error ?? '', /必须退出 YCode/)
assert.equal(fixture.installer.launchCount, 1)
assert.equal(fixture.controls.beforeCalls, 1)
assert.equal(fixture.controls.quitCalls, 1)
assert.equal(fixture.controls.failedCalls, 0)
const retained = await fixture.persistence.load()
assert.equal(retained.unfinishedVersion, '1.0.2')
assert.equal(retained.target?.version, '1.0.2')
const requestCount = http.requests.length
const dismissed = await fixture.manager.dismiss()
const checked = await fixture.manager.check()
const downloaded = await fixture.manager.download()
const checkedAndDownloaded = await fixture.manager.checkAndDownload()
const repeated = await fixture.manager.install()
for (const state of [dismissed, checked, downloaded, checkedAndDownloaded, repeated]) {
assert.equal(state.status, 'installing')
assert.match(state.error ?? '', /必须退出 YCode/)
}
assert.equal(http.requests.length, requestCount)
assert.equal(fixture.installer.launchCount, 1)
assert.equal(fixture.controls.beforeCalls, 1)
assert.equal(fixture.controls.quitCalls, 1)
assert.equal(fixture.controls.failedCalls, 0)
assert.equal((await fixture.persistence.load()).unfinishedVersion, '1.0.2')
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('corrupt cache is revalidated and rejected', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-cache-'))
try {
const bytes = makeBytes(128 * 1024, 71)
http.latest = manifestFor(http.origin, '1.0.1', bytes)
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
const fixture = createManagerFixture(root, http.origin, { identity: 'cache' })
assert.equal((await fixture.manager.check()).status, 'available')
assert.equal((await fixture.manager.download()).status, 'ready')
await writeFile(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe'), Buffer.from('tampered'))
const checked = await fixture.manager.check()
assert.equal(checked.status, 'available')
const retried = await fixture.manager.download()
assert.equal(retried.status, 'ready')
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('unfinished marker clears after target version is installed', async () => {
const root = await mkdtemp(join(tmpdir(), 'ycode-update-marker-'))
try {
const persistence = new FileUpdatePersistence(join(root, 'state.json'))
await persistence.save({
schemaVersion: 1,
unfinishedVersion: '1.0.0',
target: {
version: '1.0.0',
size: 10,
sha256: 'a'.repeat(64),
manifestUrl: 'http://127.0.0.1/releases/latest.json'
}
})
const manager = new UpdateManager({
currentVersion: '1.0.0',
manifestUrl: 'http://127.0.0.1/releases/latest.json',
cacheDirectory: join(root, 'cache'),
logFile: join(root, 'updater.log'),
persistence,
publicKey: async () => publicPem,
supported: true,
installer: new FakeInstaller(),
beforeInstall: async () => false,
quitForUpdate: async () => undefined
})
const state = await manager.getState()
assert.equal(state.unfinishedVersion, undefined)
const persisted = await persistence.load()
assert.equal(persisted.target, undefined)
} finally {
await rm(root, { recursive: true, force: true })
}
})
test('log redaction removes URL credentials and query secrets', () => {
const sanitized = sanitizeLogText('GET http://user:password@example.test/a?token=secret Authorization: abc123')
assert.equal(sanitized.includes('password'), false)
assert.equal(sanitized.includes('secret'), false)
assert.equal(sanitized.includes('abc123'), false)
})
async function main(): Promise<void> {
let failures = 0
for (const [name, run] of tests) {
try {
await run()
console.log(`ok - ${name}`)
} catch (error) {
failures += 1
console.error(`not ok - ${name}`)
console.error(error)
}
}
console.log(`\n${tests.length - failures}/${tests.length} updater backend tests passed`)
if (failures > 0) process.exitCode = 1
}
void main()
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict'
import { assertRuntimeAvailable, pauseRuntimeStarts, resumeRuntimeStarts, withRuntimeStart } from '../src/main/update-maintenance'
import { createUpdateLifecycle } from '../src/main/update-lifecycle'
let release!: () => void
const running = withRuntimeStart(() => new Promise<void>(resolve => { release = resolve }))
await Promise.resolve()
let paused = false
const pendingPause = pauseRuntimeStarts().then(() => { paused = true })
assert.throws(assertRuntimeAvailable, /准备重启/)
assert.throws(() => withRuntimeStart(async () => undefined), /准备重启/)
await Promise.resolve()
assert.equal(paused, false)
release()
await running
await pendingPause
assert.equal(paused, true)
resumeRuntimeStarts()
assert.doesNotThrow(assertRuntimeAvailable)
await assert.rejects(withRuntimeStart(async () => { throw new Error('failed start') }), /failed start/)
await pauseRuntimeStarts()
resumeRuntimeStarts()
assert.equal(await withRuntimeStart(async () => 42), 42)
console.log('PASS update maintenance: in-flight start drain, new work rejection, resume, rejected-start cleanup')
let resumed = 0
let finishDirectQuiesce!: () => void
let directQuiesceStarted!: () => void
const directQuiesceStart = new Promise<void>(resolve => { directQuiesceStarted = resolve })
let directQuiesceCalls = 0
const directLifecycle = createUpdateLifecycle({
quiesce: () => {
directQuiesceCalls += 1
directQuiesceStarted()
return new Promise<void>(resolve => { finishDirectQuiesce = resolve })
},
restore: () => { throw new Error('successful quiesce must not restore') },
shutdown: async () => undefined,
exit: () => undefined,
log: () => undefined
})
const directInstall = directLifecycle.beforeInstall()
await directQuiesceStart
assert.equal(directQuiesceCalls, 1, 'install starts quiesce without a confirmation port')
let directResolved = false
void directInstall.then(() => { directResolved = true })
await Promise.resolve()
assert.equal(directResolved, false, 'beforeInstall waits for quiesce completion')
await assert.rejects(directLifecycle.beforeInstall(), /上次更新准备仍在结束中/)
assert.equal(directQuiesceCalls, 1, 'concurrent beforeInstall must not start quiesce twice')
finishDirectQuiesce()
assert.equal(await directInstall, true)
let finishQuiesce!: () => void
const deferredLifecycle = createUpdateLifecycle({
quiesce: () => new Promise<void>(resolve => { finishQuiesce = resolve }),
restore: () => { resumed += 1 },
shutdown: async () => undefined,
exit: () => undefined,
log: () => undefined,
prepareTimeoutMs: 2
})
await assert.rejects(deferredLifecycle.beforeInstall(), /清理尚未结束/)
deferredLifecycle.installFailed()
assert.equal(resumed, 0, 'timeout must not resume while old quiesce is still running')
finishQuiesce()
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(resumed, 1, 'runtime resumes only after timed-out quiesce settles')
let exited = 0
let restarts = 0
let shutdowns = 0
const committedLifecycle = createUpdateLifecycle({
quiesce: async () => undefined,
restore: () => { restarts += 1 },
shutdown: async () => { shutdowns += 1 },
exit: () => { exited += 1 },
log: () => undefined
})
assert.equal(await committedLifecycle.beforeInstall(), true)
await committedLifecycle.quitForUpdate()
committedLifecycle.installFailed()
assert.deepEqual([shutdowns, exited, restarts], [1, 1, 0], 'committed installer exit must never restore work')
await assert.rejects(committedLifecycle.beforeInstall(), /安装器已启动/)
const hungShutdown = createUpdateLifecycle({
quiesce: async () => undefined,
restore: () => { throw new Error('must not restore committed installer') },
shutdown: () => new Promise<void>(() => undefined),
exit: () => { exited += 1 },
log: () => undefined,
quitTimeoutMs: 2
})
await hungShutdown.quitForUpdate()
assert.equal(exited, 2, 'hung shutdown is bounded and still releases the installer PID')
console.log('PASS update lifecycle: direct quiesce, concurrent barrier, timeout recovery, post-launch no-resume commit')
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
import {
DEFAULT_BASE_URL,
DEFAULT_PUBLIC_KEY,
REPO_ROOT,
generateEd25519KeyPair,
normalizeBaseUrl,
readPublicKey,
signPayload
} from './lib.mjs'
async function main() {
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-contract-selftest-'))
try {
const bundlePath = path.join(temporary, 'manifest.mjs')
await build({
entryPoints: [path.join(REPO_ROOT, 'src', 'main', 'updates', 'manifest.ts')],
bundle: true,
platform: 'node',
format: 'esm',
outfile: bundlePath,
logLevel: 'silent'
})
const backend = await import(`${pathToFileURL(bundlePath).href}?v=${Date.now()}`)
assert.equal(typeof backend.verifySignedManifest, 'function')
const trustAnchor = await readPublicKey(DEFAULT_PUBLIC_KEY)
const trustKey = crypto.createPublicKey(trustAnchor)
assert.equal(trustKey.asymmetricKeyType, 'ed25519')
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl(DEFAULT_BASE_URL)
const payload = {
schemaVersion: 1,
version: '9.9.9',
publishedAt: '2026-10-20T10:00:00.000Z',
notes: { zh: '契约自测', en: 'Contract self-test' },
platforms: {
'windows-x64': {
url: `${baseUrl}versions/9.9.9/YCode-9.9.9-Setup-x64.exe`,
size: 123,
sha256: 'a'.repeat(64)
}
}
}
const document = {
payload,
signature: signPayload(payload, keys.privatePem)
}
const raw = Buffer.from(`${JSON.stringify(document)}\n`, 'utf8')
const verified = backend.verifySignedManifest(raw, {
manifestUrl: `${baseUrl}latest.json`,
publicKeyPem: keys.publicPem
})
assert.equal(verified.payload.version, payload.version)
assert.equal(verified.asset.url, payload.platforms['windows-x64'].url)
const tampered = structuredClone(document)
tampered.payload.notes.en = 'tampered'
assert.throws(
() => backend.verifySignedManifest(Buffer.from(JSON.stringify(tampered), 'utf8'), {
manifestUrl: `${baseUrl}latest.json`,
publicKeyPem: keys.publicPem
}),
/签名验证失败/
)
console.log('Release manifest matches update_backend parser/signature/path policy.')
console.log('Repository public key is a valid Ed25519 SPKI PEM trust anchor.')
} finally {
await fsp.rm(temporary, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+79
View File
@@ -0,0 +1,79 @@
import path from 'node:path'
import {
DEFAULT_BASE_URL,
DEFAULT_PUBLIC_KEY,
DEFAULT_SSH_HOST,
normalizeBaseUrl,
parseOptions,
resolvePath,
usageError
} from './lib.mjs'
import {
publishLocalRelease,
publishViaSsh,
readLatestViaSsh,
readLocalLatest,
validateReleaseBundle
} from './transport.mjs'
const usage = `Usage:
node scripts/updates/deploy.mjs --manifest <latest.json> --installer <YCode-Setup.exe>
[--transport ssh|local] [--base-url <url>] [--public-key <path>]
[--ssh-host local001] [--local-root <dir>] [--dry-run]
The SSH transport uploads to /srv/ycode-staging, verifies the current signed latest.json,
installs an immutable version directory, then atomically replaces latest.json.
The local transport is for temporary fake-transport tests only.
`
async function main() {
const options = parseOptions(process.argv.slice(2), {
values: ['manifest', 'installer', 'transport', 'base-url', 'public-key', 'ssh-host', 'local-root'],
booleans: ['dry-run']
})
if (!options.manifest || !options.installer) throw new Error('--manifest and --installer are required')
const transport = options.transport || 'ssh'
if (transport !== 'ssh' && transport !== 'local') throw new Error('--transport must be ssh or local')
if (transport === 'local' && !options['local-root']) throw new Error('--local-root is required for local transport')
const baseUrl = normalizeBaseUrl(options['base-url'] || DEFAULT_BASE_URL)
const publicKeyPath = resolvePath(options['public-key'] || DEFAULT_PUBLIC_KEY)
const release = await validateReleaseBundle({
manifestPath: resolvePath(options.manifest),
installerPath: resolvePath(options.installer),
publicKeyPath,
baseUrl
})
let current
if (transport === 'ssh') {
current = await readLatestViaSsh(options['ssh-host'] || DEFAULT_SSH_HOST, release.publicKeyPem, baseUrl)
} else {
current = await readLocalLatest(resolvePath(options['local-root']), release.publicKeyPem, baseUrl)
}
if (options['dry-run']) {
console.log(`Verified ${release.document.payload.version} for ${transport} publication.`)
console.log(`Current latest: ${current.document?.payload.version || 'none'}`)
return
}
if (transport === 'ssh') {
await publishViaSsh({
host: options['ssh-host'] || DEFAULT_SSH_HOST,
release,
current
})
} else {
await publishLocalRelease({
root: path.resolve(options['local-root']),
release,
current
})
}
console.log(`Published ${release.document.payload.version} via ${transport}.`)
}
main().catch((error) => usageError(usage, error))
+19
View File
@@ -0,0 +1,19 @@
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$ErrorActionPreference = 'Stop'
$item = Get-Item -LiteralPath $Path
if (-not $item.PSIsContainer -and $item.Extension -ieq '.exe') {
[pscustomobject]@{
productVersion = [string]$item.VersionInfo.ProductVersion
fileVersion = [string]$item.VersionInfo.FileVersion
productName = [string]$item.VersionInfo.ProductName
fileDescription = [string]$item.VersionInfo.FileDescription
} | ConvertTo-Json -Compress
exit 0
}
throw "Not an executable file: $Path"
+81
View File
@@ -0,0 +1,81 @@
import fsp from 'node:fs/promises'
import path from 'node:path'
import {
DEFAULT_PRIVATE_KEY,
DEFAULT_PUBLIC_KEY,
assertOutsideRepo,
generateEd25519KeyPair,
parseOptions,
publicKeyFingerprint,
resolvePath,
runProcess,
usageError,
writeFileAtomic
} from './lib.mjs'
const usage = `Usage:
node scripts/updates/init-key.mjs [--private-key <path>] [--public-key <path>] [--force]
The private key must remain outside the repository. The default private key is:
${DEFAULT_PRIVATE_KEY}
`
function secureWindowsAcl(filename, isDirectory = false) {
if (process.platform !== 'win32') return
const identity = runProcess('whoami', []).trim()
if (!identity) throw new Error('Unable to determine the current Windows identity')
const permission = isDirectory ? `${identity}:(OI)(CI)F` : `${identity}:(F)`
runProcess('icacls', [filename, '/inheritance:r', '/grant:r', permission])
}
async function pathExists(filename) {
try {
await fsp.access(filename)
return true
} catch {
return false
}
}
async function main() {
const options = parseOptions(process.argv.slice(2), {
values: ['private-key', 'public-key'],
booleans: ['force']
})
const privateKeyPath = await assertOutsideRepo(
resolvePath(options['private-key'] || DEFAULT_PRIVATE_KEY),
'Private key'
)
const publicKeyPath = resolvePath(options['public-key'] || DEFAULT_PUBLIC_KEY)
if (!options.force) {
if (await pathExists(privateKeyPath)) throw new Error(`Refusing to overwrite existing private key: ${privateKeyPath}`)
if (await pathExists(publicKeyPath)) throw new Error(`Refusing to overwrite existing public key: ${publicKeyPath}`)
}
const keyPair = generateEd25519KeyPair()
const directory = path.dirname(privateKeyPath)
await fsp.mkdir(directory, { recursive: true, mode: 0o700 })
try {
await fsp.chmod(directory, 0o700)
} catch {
// Windows ACLs are applied below.
}
secureWindowsAcl(directory, true)
await writeFileAtomic(privateKeyPath, keyPair.privatePem, 0o600)
try {
await fsp.chmod(privateKeyPath, 0o600)
} catch {
// Windows ACLs are applied below.
}
secureWindowsAcl(privateKeyPath)
await writeFileAtomic(publicKeyPath, keyPair.publicPem, 0o644)
console.log(`Private key: ${privateKeyPath}`)
console.log(`Public key: ${publicKeyPath}`)
console.log(`Public key SHA-256 fingerprint: ${publicKeyFingerprint(keyPair.publicPem)}`)
console.log('Private key material was not printed. Back up the private key separately before rotating it.')
}
main().catch((error) => usageError(usage, error))
+538
View File
@@ -0,0 +1,538 @@
import crypto from 'node:crypto'
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
export const DEFAULT_BASE_URL = 'http://192.168.10.6/ycode/releases/'
export const DEFAULT_SSH_HOST = 'local001'
export const DEFAULT_PRIVATE_KEY = path.join(os.homedir(), '.ycode-release', 'update-signing.key')
export const DEFAULT_PUBLIC_KEY = path.join(REPO_ROOT, 'resources', 'updates', 'public-key.pem')
export const BASELINE_VERSION = '0.0.1'
export const MAX_INSTALLER_BYTES = 4 * 1024 * 1024 * 1024
export const MIN_PUBLIC_INSTALLER_BYTES = 20 * 1024 * 1024
export const SERVER_PUBLIC_ROOT = '/srv/ycode'
export const SERVER_STAGING_ROOT = '/srv/ycode-staging'
export const SERVER_RELEASES_ROOT = '/srv/ycode/releases'
export const NGINX_SITE_LINK = '/etc/nginx/sites-enabled/default'
export const NGINX_SNIPPET = '/etc/nginx/snippets/ycode-releases.conf'
export const NGINX_MARKER = '# YCode updater releases (managed by scripts/updates)'
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/
const SHA256_RE = /^[a-f0-9]{64}$/
const EXE_NAME_RE = /^[A-Za-z0-9._-]+\.exe$/
function fail(message) {
throw new Error(message)
}
export function assert(condition, message) {
if (!condition) fail(message)
}
export function parseOptions(argv, { values = [], booleans = [] } = {}) {
const valueNames = new Set(values)
const booleanNames = new Set(booleans)
const options = {}
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (!argument.startsWith('--')) fail(`Unexpected argument: ${argument}`)
const equals = argument.indexOf('=')
const name = argument.slice(2, equals === -1 ? undefined : equals)
if (booleanNames.has(name)) {
assert(equals === -1, `--${name} does not accept a value`)
options[name] = true
continue
}
assert(valueNames.has(name), `Unknown option: --${name}`)
const value = equals === -1 ? argv[++index] : argument.slice(equals + 1)
assert(value !== undefined && value !== '', `Missing value for --${name}`)
options[name] = value
}
return options
}
export function normalizeBaseUrl(value = DEFAULT_BASE_URL) {
let url
try {
url = new URL(value)
} catch {
fail(`Invalid base URL: ${value}`)
}
assert(url.protocol === 'http:' || url.protocol === 'https:', 'Base URL must use HTTP or HTTPS')
assert(!url.username && !url.password, 'Base URL must not contain credentials')
assert(!url.search && !url.hash, 'Base URL must not contain a query or fragment')
if (!url.pathname.endsWith('/')) url.pathname += '/'
return url.href
}
export function artifactUrlFor(baseUrl, version, filename) {
parseVersion(version)
assert(EXE_NAME_RE.test(filename), `Unsafe installer filename: ${filename}`)
const base = normalizeBaseUrl(baseUrl)
return new URL(`versions/${version}/${filename}`, base).href
}
export function assertHttpUrl(value, label = 'URL') {
let url
try {
url = new URL(value)
} catch {
fail(`${label} is invalid`)
}
assert(url.protocol === 'http:' || url.protocol === 'https:', `${label} must use HTTP or HTTPS`)
assert(!url.username && !url.password, `${label} must not contain credentials`)
assert(!url.search, `${label} must not contain a query`)
assert(!url.hash, `${label} must not contain a fragment`)
return url
}
export function parseVersion(version) {
assert(typeof version === 'string' && version.length <= 256, 'Version must be a non-empty string')
const match = SEMVER_RE.exec(version)
assert(match, `Version is not strict SemVer: ${version}`)
const prerelease = match[4] ? match[4].split('.') : []
for (const identifier of prerelease) {
assert(!/^\d+$/.test(identifier) || identifier === '0' || !identifier.startsWith('0'), `Invalid SemVer prerelease: ${version}`)
}
return {
major: BigInt(match[1]),
minor: BigInt(match[2]),
patch: BigInt(match[3]),
prerelease,
build: match[5] ? match[5].split('.') : []
}
}
function compareIdentifier(left, right) {
const leftNumeric = /^\d+$/.test(left)
const rightNumeric = /^\d+$/.test(right)
if (leftNumeric && rightNumeric) {
const a = BigInt(left)
const b = BigInt(right)
return a < b ? -1 : a > b ? 1 : 0
}
if (leftNumeric) return -1
if (rightNumeric) return 1
return left < right ? -1 : left > right ? 1 : 0
}
export function compareVersions(left, right) {
const a = parseVersion(left)
const b = parseVersion(right)
for (const key of ['major', 'minor', 'patch']) {
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
}
if (!a.prerelease.length && !b.prerelease.length) return 0
if (!a.prerelease.length) return 1
if (!b.prerelease.length) return -1
const length = Math.max(a.prerelease.length, b.prerelease.length)
for (let index = 0; index < length; index += 1) {
if (a.prerelease[index] === undefined) return -1
if (b.prerelease[index] === undefined) return 1
const compared = compareIdentifier(a.prerelease[index], b.prerelease[index])
if (compared !== 0) return compared
}
return 0
}
export function normalizeProductVersion(value) {
let normalized = String(value ?? '').trim()
if (normalized.startsWith('v')) normalized = normalized.slice(1)
const windowsRevision = /^(\d+\.\d+\.\d+)\.0$/.exec(normalized)
if (windowsRevision) normalized = windowsRevision[1]
return normalized
}
export async function sha256File(filename) {
const hash = crypto.createHash('sha256')
for await (const chunk of fs.createReadStream(filename)) hash.update(chunk)
return hash.digest('hex')
}
export async function readJson(filename) {
try {
return JSON.parse(await fsp.readFile(filename, 'utf8'))
} catch (error) {
fail(`Unable to read JSON ${filename}: ${error.message}`)
}
}
export async function writeFileAtomic(filename, data, mode = 0o644) {
const directory = path.dirname(filename)
await fsp.mkdir(directory, { recursive: true })
const temporary = path.join(directory, `.${path.basename(filename)}.${crypto.randomBytes(8).toString('hex')}.tmp`)
const handle = await fsp.open(temporary, 'wx', mode)
try {
await handle.writeFile(data)
await handle.sync()
} finally {
await handle.close()
}
try {
await fsp.rename(temporary, filename)
} catch (error) {
await fsp.rm(temporary, { force: true })
throw error
}
}
export function resolvePath(value, base = process.cwd()) {
return path.resolve(base, value)
}
export async function assertOutsideRepo(filename, label) {
const candidate = path.resolve(filename)
const repo = path.resolve(REPO_ROOT)
const relative = path.relative(repo, candidate)
assert(relative.startsWith('..') || path.isAbsolute(relative), `${label} must be outside the repository: ${candidate}`)
return candidate
}
export function generateEd25519KeyPair() {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519')
return {
privatePem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
publicPem: publicKey.export({ type: 'spki', format: 'pem' }).toString()
}
}
export function createPrivateKey(privateKeyPem) {
const key = crypto.createPrivateKey(privateKeyPem)
assert(key.asymmetricKeyType === 'ed25519', 'Update signing key must be Ed25519')
return key
}
export function createPublicKey(publicKeyPem) {
const key = crypto.createPublicKey(publicKeyPem)
assert(key.asymmetricKeyType === 'ed25519', 'Update public key must be Ed25519')
return key
}
export function signPayload(payload, privateKeyPem) {
const bytes = Buffer.from(JSON.stringify(payload), 'utf8')
return crypto.sign(null, bytes, createPrivateKey(privateKeyPem)).toString('base64')
}
export function verifyPayloadSignature(payload, signature, publicKeyPem) {
assert(typeof signature === 'string', 'Manifest signature must be a string')
const signatureBytes = Buffer.from(signature, 'base64')
assert(signatureBytes.length === 64, 'Manifest signature must contain 64 bytes')
assert(signatureBytes.toString('base64') === signature, 'Manifest signature must use canonical base64')
const bytes = Buffer.from(JSON.stringify(payload), 'utf8')
return crypto.verify(null, bytes, createPublicKey(publicKeyPem), signatureBytes)
}
export function publicKeyFingerprint(publicKeyPem) {
const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' })
return crypto.createHash('sha256').update(der).digest('hex')
}
function assertExactKeys(value, expected, label) {
assert(value && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`)
const actual = Object.keys(value)
assert(actual.length === expected.length && expected.every((key) => actual.includes(key)), `${label} has unexpected or missing fields`)
}
export function validateManifestDocument(document, { baseUrl = DEFAULT_BASE_URL } = {}) {
assertExactKeys(document, ['payload', 'signature'], 'Manifest')
const payload = document.payload
assertExactKeys(payload, ['schemaVersion', 'version', 'publishedAt', 'notes', 'platforms'], 'Manifest payload')
assert(payload.schemaVersion === 1, 'Unsupported update manifest schemaVersion')
parseVersion(payload.version)
assert(typeof payload.publishedAt === 'string', 'publishedAt must be a string')
const published = new Date(payload.publishedAt)
assert(!Number.isNaN(published.valueOf()), 'publishedAt must be an ISO timestamp')
assert(published.toISOString() === payload.publishedAt, 'publishedAt must be canonical UTC ISO')
assertExactKeys(payload.notes, ['zh', 'en'], 'Manifest notes')
for (const language of ['zh', 'en']) {
assert(typeof payload.notes[language] === 'string' && payload.notes[language].trim(), `Manifest notes.${language} is required`)
assert(payload.notes[language].length <= 16_384, `Manifest notes.${language} is too long`)
}
assertExactKeys(payload.platforms, ['windows-x64'], 'Manifest platforms')
const platform = payload.platforms['windows-x64']
assertExactKeys(platform, ['url', 'size', 'sha256'], 'windows-x64 platform')
assert(Number.isSafeInteger(platform.size) && platform.size > 0 && platform.size <= MAX_INSTALLER_BYTES, 'windows-x64 size is invalid')
assert(typeof platform.sha256 === 'string' && SHA256_RE.test(platform.sha256), 'windows-x64 sha256 must be lowercase SHA-256 hex')
const url = assertHttpUrl(platform.url, 'windows-x64 URL')
const filename = path.posix.basename(url.pathname)
const expected = artifactUrlFor(baseUrl, payload.version, filename)
assert(url.href === expected, 'windows-x64 URL is outside the approved version path')
return payload
}
export function verifySignedManifest(document, publicKeyPem, options = {}) {
const payload = validateManifestDocument(document, options)
assert(verifyPayloadSignature(payload, document.signature, publicKeyPem), 'Update manifest signature is invalid')
return payload
}
export function serializeSignedManifest(document, options = {}) {
validateManifestDocument(document, options)
return `${JSON.stringify(document)}\n`
}
export async function readPublicKey(filename = DEFAULT_PUBLIC_KEY) {
try {
return await fsp.readFile(filename, 'utf8')
} catch (error) {
fail(`Unable to read update public key ${filename}: ${error.message}`)
}
}
export async function readPrivateKey(filename = DEFAULT_PRIVATE_KEY) {
try {
return await fsp.readFile(filename, 'utf8')
} catch (error) {
fail(`Unable to read update signing key ${filename}: ${error.message}`)
}
}
export function runProcess(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
input: options.input,
encoding: 'utf8',
windowsHide: true,
maxBuffer: 8 * 1024 * 1024
})
if (result.error) fail(`Unable to run ${command}: ${result.error.message}`)
if (result.status !== 0) {
const detail = String(result.stderr || result.stdout || '').trim()
fail(`${command} failed with exit code ${result.status}${detail ? `: ${detail}` : ''}`)
}
return result.stdout
}
export function validateSshHost(host = DEFAULT_SSH_HOST) {
assert(/^[A-Za-z0-9_.@:-]+$/.test(host) && !host.startsWith('-'), 'SSH host contains unsafe characters')
return host
}
export function runSsh(host, script) {
return runProcess('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', validateSshHost(host), 'bash', '-s'], {
input: script
})
}
export async function getProductVersion(installerPath) {
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), 'get-exe-version.ps1')
assert(fs.existsSync(script), `Missing version helper: ${script}`)
const output = runProcess('powershell.exe', [
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
script,
'-Path',
installerPath
])
let parsed
try {
parsed = JSON.parse(output)
} catch {
fail('Unable to parse installer version information')
}
assert(parsed && typeof parsed.productVersion === 'string', 'Installer has no ProductVersion')
return parsed
}
export async function readPackageMetadata() {
const packagePath = path.join(REPO_ROOT, 'package.json')
const pkg = await readJson(packagePath)
assert(pkg.name === 'ycode', 'package.json is not the YCode package')
assert(pkg.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app')
parseVersion(pkg.version)
return { path: packagePath, package: pkg }
}
export async function verifyReleaseInstaller(installerPath, version, { minimumSize = MIN_PUBLIC_INSTALLER_BYTES } = {}) {
parseVersion(version)
assert(compareVersions(version, BASELINE_VERSION) > 0, `Release version must be newer than the updater-less ${BASELINE_VERSION} baseline`)
assert(EXE_NAME_RE.test(path.basename(installerPath)), `Unsafe installer filename: ${path.basename(installerPath)}`)
let resolved
try {
resolved = await fsp.realpath(installerPath)
} catch (error) {
fail(`Unable to resolve installer: ${error.message}`)
}
const stat = await fsp.stat(resolved)
assert(stat.isFile(), 'Installer must be a regular file')
assert(stat.size >= minimumSize, `Installer is unexpectedly small (${stat.size} bytes)`)
assert(stat.size <= MAX_INSTALLER_BYTES, 'Installer exceeds the 4 GiB limit')
const product = await getProductVersion(resolved)
const productVersion = normalizeProductVersion(product.productVersion)
assert(productVersion === version, `Installer ProductVersion ${product.productVersion} does not match ${version}`)
const { path: packagePath, package: pkg } = await readPackageMetadata()
assert(normalizeProductVersion(pkg.version) === version, `package.json version ${pkg.version} does not match ${version}`)
return {
path: resolved,
filename: path.basename(resolved),
size: stat.size,
sha256: await sha256File(resolved),
productVersion,
packagePath
}
}
export function buildManifestPayload({ version, publishedAt, notes, installer, baseUrl = DEFAULT_BASE_URL }) {
const url = artifactUrlFor(baseUrl, version, installer.filename)
return {
schemaVersion: 1,
version,
publishedAt,
notes: {
zh: notes.zh,
en: notes.en
},
platforms: {
'windows-x64': {
url,
size: installer.size,
sha256: installer.sha256
}
}
}
}
export function createSignedManifest({ payload, privateKeyPem, publicKeyPem, baseUrl = DEFAULT_BASE_URL }) {
validateManifestDocument({ payload, signature: Buffer.alloc(64).toString('base64') }, { baseUrl })
const signature = signPayload(payload, privateKeyPem)
const document = { payload, signature }
assert(verifyPayloadSignature(payload, signature, publicKeyPem), 'Generated manifest does not verify with the configured public key')
validateManifestDocument(document, { baseUrl })
return document
}
export function canonicalNow(value = new Date()) {
return value.toISOString()
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function formatBytes(value) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB (${value.toLocaleString('en-US')} bytes)`
}
export function renderDownloadPage(payload, options = {}) {
validateManifestDocument({ payload, signature: Buffer.alloc(64).toString('base64') }, options)
const platform = payload.platforms['windows-x64']
const version = escapeHtml(payload.version)
const url = escapeHtml(platform.url)
const sha256 = escapeHtml(platform.sha256)
const publishedAt = escapeHtml(payload.publishedAt)
const zhNotes = escapeHtml(payload.notes.zh)
const enNotes = escapeHtml(payload.notes.en)
const size = escapeHtml(formatBytes(platform.size))
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<meta name="referrer" content="no-referrer">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
<title>YCode v${version} 下载</title>
<style>
:root { color-scheme: light; --ink:#17201b; --muted:#667069; --paper:#f4f1e8; --card:#fffdf7; --line:#d9d4c6; --accent:#176b4d; --accent-dark:#0d4d36; }
* { box-sizing: border-box; }
body { margin:0; min-height:100vh; background:radial-gradient(circle at top left, #e2eadf 0, transparent 34rem), var(--paper); color:var(--ink); font-family:"Segoe UI Variable","Microsoft YaHei UI","Noto Sans CJK SC",sans-serif; }
main { width:min(760px, calc(100% - 32px)); margin:0 auto; padding:clamp(48px,9vw,96px) 0; }
.eyebrow { margin:0 0 16px; color:var(--accent-dark); font-size:12px; font-weight:700; letter-spacing:.18em; text-transform:uppercase; }
h1 { margin:0; font-size:clamp(36px,7vw,64px); line-height:1; letter-spacing:-.045em; }
.summary { margin:18px 0 30px; color:var(--muted); font-size:16px; line-height:1.7; }
.card { border:1px solid var(--line); border-radius:18px; background:var(--card); box-shadow:0 24px 70px rgba(24,43,34,.08); overflow:hidden; }
.release { padding:clamp(22px,4vw,34px); border-bottom:1px solid var(--line); }
.meta { display:flex; flex-wrap:wrap; gap:8px 18px; margin:0 0 22px; color:var(--muted); font-size:13px; }
.download { display:inline-flex; align-items:center; justify-content:center; min-height:46px; padding:0 22px; border-radius:999px; background:var(--accent); color:white; font-weight:700; text-decoration:none; }
.download:hover { background:var(--accent-dark); }
.hash { display:block; overflow-wrap:anywhere; margin-top:16px; padding:12px 14px; border:1px solid var(--line); border-radius:10px; background:#f8f6ef; color:#39443d; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace; }
.notes { padding:clamp(22px,4vw,34px); display:grid; gap:24px; }
.notes h2 { margin:0 0 8px; font-size:14px; letter-spacing:.04em; }
.notes p { margin:0; color:#3e4841; line-height:1.75; white-space:pre-wrap; }
.footer { margin-top:20px; color:var(--muted); font-size:12px; line-height:1.7; }
code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; }
</style>
</head>
<body>
<main>
<p class="eyebrow">YCode / Windows x64</p>
<h1>v${version}</h1>
<p class="summary">此页面仅从 Ed25519 验签通过的更新清单生成,不加载脚本或第三方资源。</p>
<section class="card">
<div class="release">
<div class="meta">
<span>发布时间 ${publishedAt}</span>
<span>${size}</span>
</div>
<a class="download" href="${url}" rel="noopener">下载 Windows x64 安装包</a>
<code class="hash">SHA-256 ${sha256}</code>
</div>
<div class="notes">
<div>
<h2>更新说明</h2>
<p lang="zh-CN">${zhNotes}</p>
</div>
<div>
<h2>Release notes</h2>
<p lang="en">${enNotes}</p>
</div>
</div>
</section>
<p class="footer">请核对安装包 SHA-256。更新客户端会独立验证签名、文件大小和哈希。<br>
<a href="latest.json">查看签名清单</a></p>
</main>
</body>
</html>
`
}
export function renderNoReleasePage() {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
<title>YCode 下载</title>
<style>
:root { --paper:#f4f1e8; --card:#fffdf7; --ink:#17201b; --muted:#667069; --line:#d9d4c6; }
* { box-sizing:border-box; }
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:24px; background:var(--paper); color:var(--ink); font-family:"Segoe UI Variable","Microsoft YaHei UI",sans-serif; }
main { width:min(560px,100%); padding:40px; border:1px solid var(--line); border-radius:18px; background:var(--card); box-shadow:0 24px 70px rgba(24,43,34,.08); }
h1 { margin:0 0 12px; font-size:clamp(32px,8vw,52px); letter-spacing:-.04em; }
p { margin:0; color:var(--muted); line-height:1.75; }
</style>
</head>
<body data-ycode-state="no-release"><main><h1>YCode</h1><p>当前还没有发布正式安装包。此静态页不加载脚本、不提供占位版本;发布完成后再刷新。</p></main></body>
</html>
`
}
export function usageError(usage, error) {
console.error(`${error.message}\n\n${usage}`)
process.exitCode = 2
}
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict'
import fsp from 'node:fs/promises'
import path from 'node:path'
import { REPO_ROOT } from './lib.mjs'
const filename = path.join(REPO_ROOT, 'build', 'installer.nsh')
const source = await fsp.readFile(filename, 'utf8')
assert.match(source, /OpenProcess\(i 0x100000, i 0, i R1\) p \.R2 \?e/)
assert.match(source, /Pop \$R3/)
assert.doesNotMatch(source, /GetLastError\(\)/)
assert.match(source, /WaitForSingleObject\(p R2, i 120000\)/)
assert.match(source, /ElseIf\} \$R3 != 87/)
const waitIndex = source.indexOf('WaitForSingleObject')
const imageCheckIndex = source.indexOf('SetEnvironmentVariable(t "YCODE_UPDATE_TARGET_DIR"')
assert.ok(waitIndex >= 0 && imageCheckIndex > waitIndex, 'image check must run after waiting for the parent PID')
assert.ok(imageCheckIndex > source.indexOf('${ElseIf} $R3 != 87'), 'image check must run after the OpenProcess branch join')
assert.ok(source.includes('t "$INSTDIR\\${APP_EXECUTABLE_FILENAME}"'))
assert.match(source, /nsExec::ExecToStack/)
assert.match(source, /Get-CimInstance Win32_Process/)
assert.match(source, /TrimEnd\(\[char\]92\)/)
assert.match(source, /AddSeconds\(5\)/)
assert.match(source, /Start-Sleep -Milliseconds 500/)
assert.match(source, /if \(-not \$\$process\.ExecutablePath\) \{ \$\$found=\$\$true; continue \}/)
assert.match(source, /Name -ieq '\$\{APP_EXECUTABLE_FILENAME\}'/)
assert.match(source, /StrCmp \$R4 "0" \+3/)
assert.doesNotMatch(source, /KillProcess|taskkill|Stop-Process|_CHECK_APP_RUNNING/)
assert.doesNotMatch(source, /GetProcessInfo/)
assert.doesNotMatch(source, /GetLastError\(\)/)
assert.doesNotMatch(source, /Name -ieq 'YCode\.exe'/)
console.log('NSIS updater safety checks passed.')
+71
View File
@@ -0,0 +1,71 @@
import fsp from 'node:fs/promises'
import path from 'node:path'
import {
DEFAULT_BASE_URL,
DEFAULT_PRIVATE_KEY,
DEFAULT_PUBLIC_KEY,
assertOutsideRepo,
buildManifestPayload,
canonicalNow,
createSignedManifest,
normalizeBaseUrl,
parseOptions,
readPrivateKey,
readPublicKey,
resolvePath,
serializeSignedManifest,
usageError,
verifyReleaseInstaller,
verifySignedManifest,
writeFileAtomic
} from './lib.mjs'
const usage = `Usage:
node scripts/updates/prepare-release.mjs --installer <YCode-Setup.exe> --version <x.y.z>
--notes-zh <text> --notes-en <text> [--base-url <url>] [--published-at <ISO>]
[--private-key <path>] [--public-key <path>] [--output <latest.json>]
This validates the actual EXE ProductVersion, current package.json version, size and SHA-256,
then signs exact JSON.stringify(payload) with Ed25519. It never deploys.
`
async function main() {
const options = parseOptions(process.argv.slice(2), {
values: ['installer', 'version', 'notes-zh', 'notes-en', 'base-url', 'published-at', 'private-key', 'public-key', 'output']
})
if (!options.installer || !options.version || !options['notes-zh'] || !options['notes-en']) {
throw new Error('--installer, --version, --notes-zh and --notes-en are required')
}
const baseUrl = normalizeBaseUrl(options['base-url'] || DEFAULT_BASE_URL)
const installerPath = resolvePath(options.installer)
const installer = await verifyReleaseInstaller(installerPath, options.version)
const output = resolvePath(options.output || path.join('.tmp', 'updates', options.version, 'latest.json'))
const privateKeyPath = await assertOutsideRepo(
resolvePath(options['private-key'] || DEFAULT_PRIVATE_KEY),
'Private key'
)
const privateKey = await readPrivateKey(privateKeyPath)
const publicKeyPath = resolvePath(options['public-key'] || DEFAULT_PUBLIC_KEY)
const publicKey = await readPublicKey(publicKeyPath)
const payload = buildManifestPayload({
version: options.version,
publishedAt: options['published-at'] || canonicalNow(),
notes: { zh: options['notes-zh'], en: options['notes-en'] },
installer,
baseUrl
})
const document = createSignedManifest({ payload, privateKeyPem: privateKey, publicKeyPem: publicKey, baseUrl })
verifySignedManifest(document, publicKey, { baseUrl })
await writeFileAtomic(output, serializeSignedManifest(document))
await fsp.access(output)
console.log(`Manifest: ${output}`)
console.log(`Version: ${payload.version}`)
console.log(`Installer: ${installer.filename}`)
console.log(`Size: ${installer.size}`)
console.log(`SHA-256: ${installer.sha256}`)
console.log(`URL: ${payload.platforms['windows-x64'].url}`)
}
main().catch((error) => usageError(usage, error))
+234
View File
@@ -0,0 +1,234 @@
import {
DEFAULT_SSH_HOST,
NGINX_MARKER,
NGINX_SITE_LINK,
NGINX_SNIPPET,
SERVER_PUBLIC_ROOT,
SERVER_RELEASES_ROOT,
SERVER_STAGING_ROOT,
parseOptions,
renderNoReleasePage,
runSsh,
usageError,
validateSshHost
} from './lib.mjs'
const usage = `Usage:
node scripts/updates/provision-server.mjs [--ssh-host local001]
Creates /srv/ycode, /srv/ycode/releases/versions and /srv/ycode-staging, backs up the
nginx site configuration, installs a YCode-only snippet, validates nginx, then reloads.
It never serves an index and never creates a fake latest.json.
`
const snippet = `${NGINX_MARKER}
location = /ycode/releases/latest.json {
alias ${SERVER_RELEASES_ROOT}/latest.json;
default_type application/json;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
limit_except GET { deny all; }
}
location = /ycode/releases/index.html {
alias ${SERVER_RELEASES_ROOT}/index.html;
default_type text/html;
charset utf-8;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
limit_except GET { deny all; }
}
location /ycode/ {
alias ${SERVER_PUBLIC_ROOT}/;
autoindex off;
index index.html;
add_header X-Content-Type-Options "nosniff" always;
limit_except GET { deny all; }
}
`
async function main() {
const options = parseOptions(process.argv.slice(2), { values: ['ssh-host'] })
const host = validateSshHost(options['ssh-host'] || DEFAULT_SSH_HOST)
const snippetBase64 = Buffer.from(snippet, 'utf8').toString('base64')
const noReleasePageBase64 = Buffer.from(renderNoReleasePage(), 'utf8').toString('base64')
const script = `set -euo pipefail
export LC_ALL=C
site_link='${NGINX_SITE_LINK}'
snippet='${NGINX_SNIPPET}'
site="$(readlink -f "$site_link")"
test -f "$site"
test -f /etc/nginx/nginx.conf
nginx -t >/dev/null 2>&1
install -d -o root -g www-data -m 0755 '${SERVER_PUBLIC_ROOT}'
install -d -o root -g www-data -m 0755 '${SERVER_RELEASES_ROOT}'
install -d -o root -g www-data -m 0755 '${SERVER_RELEASES_ROOT}/versions'
install -d -o root -g root -m 0700 '${SERVER_STAGING_ROOT}'
page_tmp="$(mktemp '${SERVER_RELEASES_ROOT}/.index.html.XXXXXX')"
printf '%s' '${noReleasePageBase64}' | base64 -d > "$page_tmp"
chmod 0644 "$page_tmp"
if [ -f '${SERVER_RELEASES_ROOT}/latest.json' ]; then
if [ ! -f '${SERVER_RELEASES_ROOT}/index.html' ]; then
rm -f "$page_tmp"
echo "latest.json exists without a generated download page" >&2
exit 1
fi
if grep -q 'data-ycode-state="no-release"' '${SERVER_RELEASES_ROOT}/index.html'; then
rm -f "$page_tmp"
echo "latest.json exists while the download page still says no release" >&2
exit 1
fi
rm -f "$page_tmp"
elif [ ! -f '${SERVER_RELEASES_ROOT}/index.html' ]; then
mv -f "$page_tmp" '${SERVER_RELEASES_ROOT}/index.html'
else
if ! grep -q 'data-ycode-state="no-release"' '${SERVER_RELEASES_ROOT}/index.html'; then
rm -f "$page_tmp"
echo "download page exists without latest.json" >&2
exit 1
fi
rm -f "$page_tmp"
fi
backup_dir=/var/backups/nginx
install -d -o root -g root -m 0700 "$backup_dir"
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
config_backup="$backup_dir/ycode-before-${'$'}{stamp}-$(basename "$site")"
snippet_backup="$backup_dir/ycode-before-${'$'}{stamp}-ycode-releases.conf"
test ! -e "$config_backup"
cp -a "$site" "$config_backup"
snippet_existed=0
if [ -f "$snippet" ]; then
cp -a "$snippet" "$snippet_backup"
snippet_existed=1
fi
restore() {
changed=0
cp -a "$config_backup" "$site"
if [ "$snippet_existed" -eq 1 ]; then
cp -a "$snippet_backup" "$snippet"
else
rm -f "$snippet"
fi
}
changed=0
snippet_tmp=
cleanup() {
rc=$?
if [ -n "$snippet_tmp" ]; then
rm -f "$snippet_tmp"
fi
if [ "$rc" -ne 0 ] && [ "$changed" -eq 1 ]; then
restore
nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 || true
fi
exit "$rc"
}
trap cleanup EXIT
install -d -o root -g root -m 0755 /etc/nginx/snippets
snippet_tmp="$(mktemp /etc/nginx/snippets/.ycode-releases.XXXXXX)"
printf '%s' '${snippetBase64}' | base64 -d > "$snippet_tmp"
chmod 0644 "$snippet_tmp"
mv -f "$snippet_tmp" "$snippet"
changed=1
python3 - "$site" <<'PY'
import os
import re
import shutil
import sys
import tempfile
site = sys.argv[1]
marker = "${NGINX_MARKER}"
include = "\tinclude ${NGINX_SNIPPET};"
with open(site, "r", encoding="utf-8") as stream:
original = stream.read()
for required in ("location /baocode/ {", "location /baocode/releases/ {"):
if original.count(required) != 1:
raise SystemExit(f"refusing to change nginx: expected one {required}")
if marker in original:
if include not in original:
raise SystemExit("refusing to change nginx: YCode marker exists without include")
updated = original
else:
match = re.search(r"(?m)^([ \\t]*)location /baocode/ \\{", original)
if not match:
raise SystemExit("refusing to change nginx: baocode anchor not found")
indent = match.group(1)
insertion = f"{indent}{marker}\\n{indent}include ${NGINX_SNIPPET};\\n\\n"
updated = original[:match.start()] + insertion + original[match.start():]
if updated != original:
directory = os.path.dirname(site)
descriptor, temporary = tempfile.mkstemp(prefix=".ycode-nginx-", dir=directory)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
stream.write(updated)
stream.flush()
os.fsync(stream.fileno())
shutil.copystat(site, temporary)
os.replace(temporary, site)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
with open(site, "r", encoding="utf-8") as stream:
final = stream.read()
for required in ("location /baocode/ {", "location /baocode/releases/ {"):
if final.count(required) != 1:
raise SystemExit("refusing to change nginx: baocode locations changed")
if marker not in final or include not in final:
raise SystemExit("nginx include was not installed")
PY
if ! nginx -t; then
echo "nginx validation failed; configuration restored" >&2
exit 1
fi
if ! systemctl reload nginx; then
echo "nginx reload failed; configuration restored" >&2
exit 1
fi
status="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1/ycode/releases/latest.json || true)"
if [ -f '${SERVER_RELEASES_ROOT}/latest.json' ]; then
expected_status=200
else
expected_status=404
fi
if [ "$status" != "$expected_status" ]; then
echo "unexpected /ycode/releases/latest.json status: $status (expected $expected_status)" >&2
exit 1
fi
page_status="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1/ycode/releases/ || true)"
if [ "$page_status" != "200" ]; then
echo "unexpected /ycode/releases/ status: $page_status (expected 200)" >&2
exit 1
fi
printf 'site=%s\\n' "$site"
printf 'backup=%s\\n' "$config_backup"
printf 'snippet=%s\\n' "$snippet"
printf 'latest_status=%s\\n' "$status"
printf 'page_status=%s\\n' "$page_status"
`
const output = runSsh(host, script)
process.stdout.write(output)
console.log(`Provisioned ${host}; no release manifest was created.`)
}
main().catch((error) => usageError(usage, error))
+641
View File
@@ -0,0 +1,641 @@
import assert from 'node:assert/strict'
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import {
buildManifestPayload,
createSignedManifest,
generateEd25519KeyPair,
normalizeBaseUrl,
serializeSignedManifest,
writeFileAtomic
} from './lib.mjs'
import {
acquireReleaseLock,
defaultReleaseNotes,
finalizeReleaseRequest,
parseReleaseArgs,
releasePaths,
resolveVersionChoice,
runReleaseWorkflow,
updateLockText,
updatePackageText,
verifyPublishedManifest
} from './release-workflow.mjs'
let passed = 0
async function test(name, callback) {
await callback()
passed += 1
console.log(`PASS ${name}`)
}
function expectedError(pattern) {
return (error) => {
assert.match(String(error?.message || error), pattern)
return true
}
}
function packageText(version) {
return `${JSON.stringify({
name: 'ycode',
version,
private: true,
scripts: { build: 'fixture-build' },
build: { appId: 'com.ycode.app', productName: 'YCode' }
}, null, 2)}\n`
}
function lockText(version) {
return `${JSON.stringify({
name: 'ycode',
version,
lockfileVersion: 3,
requires: true,
packages: {
'': {
name: 'ycode',
version,
private: true,
dependencies: { fixture: '1.0.0' }
},
'node_modules/fixture': {
version: '1.0.0'
}
}
}, null, 2)}\n`
}
function signedManifest({ version, keys, baseUrl }) {
const payload = buildManifestPayload({
version,
publishedAt: '2026-10-20T10:00:00.000Z',
notes: defaultReleaseNotes(version),
installer: {
filename: `YCode-${version}-Setup-x64.exe`,
size: 1,
sha256: 'a'.repeat(64)
},
baseUrl
})
const document = createSignedManifest({
payload,
privateKeyPem: keys.privatePem,
publicKeyPem: keys.publicPem,
baseUrl
})
return {
document,
text: serializeSignedManifest(document, { baseUrl })
}
}
async function makeRepository({ version = '1.0.0' } = {}) {
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-release-selftest-'))
await fsp.writeFile(path.join(root, 'package.json'), packageText(version), 'utf8')
await fsp.writeFile(path.join(root, 'package-lock.json'), lockText(version), 'utf8')
return root
}
function requestFor({ version, notes, privateKeyPath, publicKeyPath, resume = false }) {
return {
help: false,
resume,
version,
notes: notes || defaultReleaseNotes(version),
sshHost: 'fake-local001',
baseUrl: normalizeBaseUrl('http://127.0.0.1/ycode/releases/'),
privateKeyPath,
publicKeyPath
}
}
function makeDependencies({
root,
keys,
current,
events,
overrides = {}
}) {
const paths = releasePaths(root, current?.version || '9.9.9')
const deps = {
root,
pathExists: (filename) => fsp.access(filename).then(() => true, () => false),
realpath: (filename) => fsp.realpath(filename),
readFile: (filename, encoding) => fsp.readFile(filename, encoding),
writeFileAtomic,
readPrivateKey: async () => keys.privatePem,
readPublicKey: async () => keys.publicPem,
validateBundle: async ({ manifestPath }) => {
events.push('bundle-validate')
const document = JSON.parse(await fsp.readFile(manifestPath, 'utf8'))
return { document }
},
acquireReleaseLock: async () => {
events.push('lock-acquire')
return async () => {
events.push('lock-release')
}
},
checkToolchain: async () => {
events.push('toolchain-check')
},
checkSsh: async () => {
events.push('ssh-check')
},
readLatest: async () => {
events.push('server-read')
return current ? { raw: current.text, document: current.document } : { raw: null, document: null }
},
runNpm: async (args) => {
events.push(`npm:${args.join(' ')}`)
},
runNode: async (args) => {
events.push(`node:${args.join(' ')}`)
if (args.some((value) => String(value).endsWith('prepare-release.mjs'))) {
const version = args[args.indexOf('--version') + 1]
const output = args[args.indexOf('--output') + 1]
await writeFileAtomic(output, signedManifest({ version, keys, baseUrl: 'http://127.0.0.1/ycode/releases/' }).text)
}
},
verifyHttp: async () => {
events.push('http-verify')
},
logWarning: (message) => events.push(`warning:${message}`),
...overrides
}
deps.paths = paths
return deps
}
async function main() {
const temporaryDirectories = []
let signingKeyProbePath
try {
const keyProbeDirectory = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-release-key-probe-'))
temporaryDirectories.push(keyProbeDirectory)
signingKeyProbePath = path.join(keyProbeDirectory, 'signing.key')
await fsp.writeFile(signingKeyProbePath, 'fixture key location only', 'utf8')
await test('stable version input rejects ambiguous and non-release versions', async () => {
for (const value of ['01.0.0', '1.01.0', '1.0.01', '1.0.0-rc.1', '1.0.0+build', 'v1.0.0', '1.0']) {
await assert.rejects(() => resolveVersionChoice({ version: value, prompt: async () => '' }), /stable x\.y\.z/)
}
for (const value of ['65536.0.0', '1.65536.0', '1.0.65536']) {
await assert.rejects(() => resolveVersionChoice({ version: value, prompt: async () => '' }), /Windows EXE metadata/)
}
assert.equal(
await resolveVersionChoice({ version: '1.2.3', prompt: async () => assert.fail('prompt should not run') }),
'1.2.3'
)
})
await test('blank interactive version cancels without starting a release', async () => {
assert.equal(await resolveVersionChoice({ prompt: async () => ' ' }), null)
})
await test('CLI defaults and unknown option handling are deterministic', async () => {
const parsed = parseReleaseArgs(['--version', '1.2.3'])
assert.equal(parsed.version, '1.2.3')
assert.deepEqual(parsed.notes, {})
const finalized = finalizeReleaseRequest(parsed, parsed.version)
assert.deepEqual(finalized.notes, {
zh: 'YCode 1.2.3 更新',
en: 'YCode 1.2.3 update'
})
assert.equal(parsed.sshHost, 'local001')
await assert.rejects(async () => parseReleaseArgs(['--dry-run']), /Unknown option/)
await assert.rejects(async () => parseReleaseArgs(['--public-key', 'other.pem']), /Unknown option/)
await assert.rejects(async () => parseReleaseArgs(['--resume']), /requires an explicit --version/)
assert.equal(parseReleaseArgs(['--help']).help, true)
})
await test('interactive version supplies notes from the actual input version', async () => {
const parsed = parseReleaseArgs([])
const version = await resolveVersionChoice({ version: parsed.version, prompt: async () => '0.0.6' })
const finalized = finalizeReleaseRequest(parsed, version)
assert.deepEqual(finalized.notes, {
zh: 'YCode 0.0.6 更新',
en: 'YCode 0.0.6 update'
})
})
await test('package and lock version updates preserve unrelated JSON content', async () => {
const nextPackage = updatePackageText(packageText('1.0.0'), '1.0.1')
const nextLock = updateLockText(lockText('1.0.0'), '1.0.1')
const pkg = JSON.parse(nextPackage)
const lock = JSON.parse(nextLock)
assert.equal(pkg.version, '1.0.1')
assert.equal(pkg.scripts.build, 'fixture-build')
assert.equal(pkg.build.appId, 'com.ycode.app')
assert.equal(lock.version, '1.0.1')
assert.equal(lock.packages[''].version, '1.0.1')
assert.equal(lock.packages['node_modules/fixture'].version, '1.0.0')
})
const normalRoot = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(normalRoot)
await test('normal release follows the required stage and command order', async () => {
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const current = signedManifest({ version: '1.0.0', keys, baseUrl })
const events = []
const deps = makeDependencies({
root: normalRoot,
keys,
current: { ...current, version: '1.0.0' },
events
})
const request = requestFor({
version: '1.0.1',
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
})
const result = await runReleaseWorkflow(request, deps)
assert.equal(result.version, '1.0.1')
const paths = releasePaths(normalRoot, '1.0.1')
assert.deepEqual(events.slice(0, 7), [
'lock-acquire',
'toolchain-check',
'ssh-check',
'server-read',
'npm:run test:updates',
'npm:run build',
`node:${paths.builderCli} --win nsis --x64 --publish never --config.directories.output=release/publish/1.0.1`
])
assert.match(events[7], /prepare-release\.mjs/)
assert.match(events[8], /deploy\.mjs.*--dry-run/)
assert.match(events[9], /deploy\.mjs/)
assert.doesNotMatch(events[9], /--dry-run/)
assert.deepEqual(events.slice(10), ['http-verify', 'lock-release'])
assert.equal(JSON.parse(await fsp.readFile(path.join(normalRoot, 'package.json'), 'utf8')).version, '1.0.1')
assert.equal(JSON.parse(await fsp.readFile(path.join(normalRoot, 'package-lock.json'), 'utf8')).version, '1.0.1')
})
await test('server/latest and repository versions are rejected before build', async () => {
for (const scenario of [
{ repo: '1.0.0', server: '1.0.0', target: '1.0.0', pattern: /strictly newer/ },
{ repo: '1.0.0', server: '0.9.0', target: '0.9.9', pattern: /repository version is newer/ }
]) {
const root = await makeRepository({ version: scenario.repo })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const server = signedManifest({ version: scenario.server, keys, baseUrl })
const events = []
const deps = makeDependencies({
root,
keys,
current: { ...server, version: scenario.server },
events
})
const before = await fsp.readFile(path.join(root, 'package.json'), 'utf8')
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: scenario.target,
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
expectedError(scenario.pattern)
)
assert.equal(await fsp.readFile(path.join(root, 'package.json'), 'utf8'), before)
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:')))
assert.equal(events.at(-1), 'lock-release')
}
})
await test('missing signing key fails in preflight without build or deploy', async () => {
const root = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const events = []
const deps = makeDependencies({
root,
keys,
current: null,
events,
overrides: {
pathExists: async (filename) =>
filename === path.join(os.tmpdir(), 'missing-signing.key')
? false
: fsp.access(filename).then(() => true, () => false),
readPrivateKey: async () => {
throw new Error('Unable to read update signing key')
}
}
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
privateKeyPath: path.join(os.tmpdir(), 'missing-signing.key'),
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'preflight' && /Signing key/.test(error.message)
)
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:')))
assert.equal(events.at(-1), 'lock-release')
})
await test('mismatched private and public keys fail before any external stage', async () => {
const root = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(root)
const privateKeys = generateEd25519KeyPair()
const publicKeys = generateEd25519KeyPair()
const events = []
const deps = makeDependencies({
root,
keys: privateKeys,
current: null,
events,
overrides: {
readPublicKey: async () => publicKeys.publicPem
}
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'preflight' && /does not match/.test(error.message)
)
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:') || event === 'ssh-check'))
assert.equal(events.at(-1), 'lock-release')
})
await test('private key canonical path cannot resolve into the repository', async () => {
const root = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const events = []
const deps = makeDependencies({
root,
keys,
current: null,
events,
overrides: {
realpath: async () => path.join(root, 'linked-signing.key')
}
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'preflight' && /resolves inside the repository/.test(error.message)
)
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:')))
assert.equal(events.at(-1), 'lock-release')
})
await test('a repository path beginning with ..foo is not treated as external', async () => {
const root = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const events = []
const deps = makeDependencies({
root,
keys,
current: null,
events
})
const nestedKey = path.join(root, '..foo', 'signing.key')
await fsp.mkdir(path.dirname(nestedKey), { recursive: true })
await fsp.writeFile(nestedKey, 'fixture inside-repository key', 'utf8')
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
privateKeyPath: nestedKey,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'preflight' && /must be outside the repository/.test(error.message)
)
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:') || event === 'ssh-check'))
assert.equal(events.at(-1), 'lock-release')
})
await test('a failed build stops before signing and deploy while retaining local version', async () => {
const root = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const current = signedManifest({ version: '1.0.0', keys, baseUrl })
const events = []
const deps = makeDependencies({
root,
keys,
current: { ...current, version: '1.0.0' },
events,
overrides: {
runNpm: async (args) => {
events.push(`npm:${args.join(' ')}`)
if (args.join(' ') === 'run build') throw new Error('fixture build failure')
}
}
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'build' && /fixture build failure/.test(error.message)
)
assert(!events.some((event) => event.includes('prepare-release') || event.includes('deploy.mjs')))
assert.equal(JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8')).version, '1.0.1')
assert.equal(events.at(-1), 'lock-release')
})
await test('resume revalidates the existing bundle and never rebuilds or re-signs', async () => {
const root = await makeRepository({ version: '1.0.1' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const local = signedManifest({ version: '1.0.1', keys, baseUrl })
const paths = releasePaths(root, '1.0.1')
await fsp.mkdir(paths.outputDir, { recursive: true })
await fsp.writeFile(paths.installerPath, 'fixture installer', 'utf8')
await fsp.writeFile(paths.manifestPath, local.text, 'utf8')
const events = []
const deps = makeDependencies({
root,
keys,
current: { ...local, version: '1.0.1' },
events
})
await runReleaseWorkflow(
requestFor({
version: '1.0.1',
resume: true,
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
)
assert(events.includes('bundle-validate'))
assert(!events.some((event) => event.startsWith('npm:') || event.includes('prepare-release')))
assert.equal(events.filter((event) => event.includes('deploy.mjs')).length, 2)
assert.equal(events.at(-2), 'http-verify')
assert.equal(events.at(-1), 'lock-release')
})
await test('HTTP verification failure is reported as a failed release', async () => {
const root = await makeRepository({ version: '1.0.1' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const local = signedManifest({ version: '1.0.1', keys, baseUrl })
const paths = releasePaths(root, '1.0.1')
await fsp.mkdir(paths.outputDir, { recursive: true })
await fsp.writeFile(paths.installerPath, 'fixture installer', 'utf8')
await fsp.writeFile(paths.manifestPath, local.text, 'utf8')
const events = []
const deps = makeDependencies({
root,
keys,
current: null,
events,
overrides: {
verifyHttp: async () => {
events.push('http-verify')
throw new Error('fixture HTTP mismatch')
}
}
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
resume: true,
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'verify-http' && /HTTP mismatch/.test(error.message)
)
assert(events.some((event) => event.includes('deploy.mjs')))
assert.equal(events.at(-1), 'lock-release')
})
await test('resume permits the same server version but rejects a newer server latest', async () => {
const root = await makeRepository({ version: '1.0.1' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const local = signedManifest({ version: '1.0.1', keys, baseUrl })
const newer = signedManifest({ version: '1.0.2', keys, baseUrl })
const paths = releasePaths(root, '1.0.1')
await fsp.mkdir(paths.outputDir, { recursive: true })
await fsp.writeFile(paths.installerPath, 'fixture installer', 'utf8')
await fsp.writeFile(paths.manifestPath, local.text, 'utf8')
const events = []
const deps = makeDependencies({
root,
keys,
current: { ...newer, version: '1.0.2' },
events
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
resume: true,
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'preflight' && /server latest is newer/.test(error.message)
)
assert(!events.some((event) => event.includes('deploy.mjs')))
assert.equal(events.at(-1), 'lock-release')
})
await test('release lock is mutually exclusive and released explicitly', async () => {
const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-release-lock-selftest-'))
temporaryDirectories.push(directory)
const lockPath = path.join(directory, '.tmp', 'updates', 'release.lock')
const release = await acquireReleaseLock(lockPath, { version: '1.0.1' })
await assert.rejects(acquireReleaseLock(lockPath, { version: '1.0.2' }), /already exists/)
assert.equal(await fsp.readFile(lockPath, 'utf8').then(() => true, () => false), true)
await release()
assert.equal(await fsp.readFile(lockPath, 'utf8').then(() => true, () => false), false)
const second = await acquireReleaseLock(lockPath, { version: '1.0.2' })
await second()
})
await test('HTTP verification checks no-cache request, exact bytes and signature', async () => {
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const local = signedManifest({ version: '1.0.2', keys, baseUrl })
let requestOptions
const response = new Response(local.text, {
status: 200,
headers: { 'content-type': 'application/json', 'content-length': String(Buffer.byteLength(local.text)) }
})
const document = await verifyPublishedManifest({
baseUrl,
expectedManifestText: local.text,
publicKeyPem: keys.publicPem,
version: '1.0.2',
fetchImpl: async (_url, options) => {
requestOptions = options
return response
}
})
assert.equal(document.payload.version, '1.0.2')
assert.equal(requestOptions.cache, 'no-store')
assert.equal(requestOptions.credentials, 'omit')
assert.equal(requestOptions.redirect, 'error')
assert.equal(requestOptions.headers.pragma, 'no-cache')
await assert.rejects(
verifyPublishedManifest({
baseUrl,
expectedManifestText: local.text,
publicKeyPem: keys.publicPem,
version: '1.0.2',
fetchImpl: async () =>
new Response(local.text.replace('YCode 1.0.2', 'YCode 1.0.3'), {
status: 200,
headers: { 'content-length': String(Buffer.byteLength(local.text)) }
})
}),
/does not exactly match/
)
})
console.log(`\n${passed} release workflow self-tests passed. No SSH, network HTTP, build, installer, signing, deployment, repository version change or production release was performed.`)
} finally {
for (const directory of temporaryDirectories) {
await fsp.rm(directory, { recursive: true, force: true })
}
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+591
View File
@@ -0,0 +1,591 @@
import crypto from 'node:crypto'
import fsp from 'node:fs/promises'
import path from 'node:path'
import {
DEFAULT_BASE_URL,
DEFAULT_PRIVATE_KEY,
DEFAULT_PUBLIC_KEY,
DEFAULT_SSH_HOST,
assert,
assertOutsideRepo,
compareVersions,
createPrivateKey,
createPublicKey,
normalizeBaseUrl,
parseVersion,
verifySignedManifest
} from './lib.mjs'
export const STABLE_VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
export const RELEASE_LOCK_RELATIVE = path.join('.tmp', 'updates', 'release.lock')
export const MAX_HTTP_MANIFEST_BYTES = 1024 * 1024
const CLI_VALUE_OPTIONS = new Set([
'version',
'notes-zh',
'notes-en',
'ssh-host',
'base-url',
'private-key'
])
export class ReleaseError extends Error {
constructor(stage, message, options = {}) {
super(message, options)
this.name = 'ReleaseError'
this.stage = stage
}
}
export function toReleaseError(stage, error) {
if (error instanceof ReleaseError) return error
return new ReleaseError(stage, String(error?.message || error), { cause: error })
}
async function runStage(stage, action) {
try {
return await action()
} catch (error) {
throw toReleaseError(stage, error)
}
}
export function validateStableVersion(value) {
const version = String(value ?? '')
assert(
STABLE_VERSION_RE.test(version),
'Release version must be a stable x.y.z version without a leading zero, prerelease or build metadata'
)
parseVersion(version)
for (const segment of version.split('.').map(BigInt)) {
assert(segment <= 65_535n, 'Release version segments must be between 0 and 65535 for Windows EXE metadata')
}
return version
}
export function defaultReleaseNotes(version) {
validateStableVersion(version)
return {
zh: `YCode ${version} 更新`,
en: `YCode ${version} update`
}
}
export async function resolveVersionChoice({ version, prompt }) {
if (version !== undefined) return validateStableVersion(version)
const answer = await prompt()
if (!answer || !answer.trim()) return null
return validateStableVersion(answer.trim())
}
export function parseReleaseArgs(argv, { cwd = process.cwd() } = {}) {
const raw = {}
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (!argument.startsWith('--')) throw new Error(`Unexpected argument: ${argument}`)
const equals = argument.indexOf('=')
const name = argument.slice(2, equals === -1 ? undefined : equals)
if (name === 'help' || name === 'resume') {
assert(equals === -1, `--${name} does not accept a value`)
assert(!(name in raw), `Option --${name} was provided more than once`)
raw[name] = true
continue
}
assert(CLI_VALUE_OPTIONS.has(name), `Unknown option: --${name}`)
assert(!(name in raw), `Option --${name} was provided more than once`)
const value = equals === -1 ? argv[++index] : argument.slice(equals + 1)
assert(value !== undefined && value !== '', `Missing value for --${name}`)
raw[name] = value
}
if (raw.help) {
return { help: true }
}
if (raw.resume) {
assert(raw.version, '--resume requires an explicit --version')
assert(!raw['notes-zh'] && !raw['notes-en'], '--notes-zh and --notes-en cannot be used with --resume')
}
const version = raw.version === undefined ? undefined : validateStableVersion(raw.version)
const notes = {}
if (raw['notes-zh'] !== undefined) notes.zh = raw['notes-zh'].trim()
if (raw['notes-en'] !== undefined) notes.en = raw['notes-en'].trim()
if ('zh' in notes) assert(notes.zh, '--notes-zh must not be empty')
if ('en' in notes) assert(notes.en, '--notes-en must not be empty')
return {
help: false,
resume: raw.resume === true,
version,
notes,
sshHost: raw['ssh-host'] || DEFAULT_SSH_HOST,
baseUrl: normalizeBaseUrl(raw['base-url'] || DEFAULT_BASE_URL),
privateKeyPath: path.resolve(cwd, raw['private-key'] || DEFAULT_PRIVATE_KEY),
publicKeyPath: path.resolve(cwd, DEFAULT_PUBLIC_KEY)
}
}
export function finalizeReleaseRequest(parsed, version) {
validateStableVersion(version)
const defaults = defaultReleaseNotes(version)
return {
...parsed,
version,
notes: {
zh: parsed.notes?.zh ?? defaults.zh,
en: parsed.notes?.en ?? defaults.en
}
}
}
export function releasePaths(root, version) {
validateStableVersion(version)
const outputDir = path.join(root, 'release', 'publish', version)
return {
root,
packagePath: path.join(root, 'package.json'),
lockPath: path.join(root, RELEASE_LOCK_RELATIVE),
outputDir,
installerPath: path.join(outputDir, `YCode-${version}-Setup-x64.exe`),
manifestPath: path.join(outputDir, 'latest.json'),
builderCli: path.join(root, 'node_modules', 'electron-builder', 'cli.js'),
prepareScript: path.join(root, 'scripts', 'updates', 'prepare-release.mjs'),
deployScript: path.join(root, 'scripts', 'updates', 'deploy.mjs')
}
}
function isOutside(root, filename) {
const relative = path.relative(path.resolve(root), path.resolve(filename))
return relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
}
async function canonicalPrivateKeyPath({ root, filename, deps }) {
const lexical = path.resolve(filename)
assert(isOutside(root, lexical), `Private key must be outside the repository: ${lexical}`)
await assertOutsideRepo(lexical, 'Private key')
if (!(await deps.pathExists(lexical))) throw new Error(`Signing key not found: ${lexical}`)
const canonical = await deps.realpath(lexical)
assert(isOutside(root, canonical), `Private key resolves inside the repository: ${canonical}`)
await assertOutsideRepo(canonical, 'Private key')
return canonical
}
export function assertKeyPairMatches(privatePem, publicPem) {
const privateKey = createPrivateKey(privatePem)
const derived = createPublicKey(privateKey).export({ type: 'spki', format: 'der' })
const supplied = createPublicKey(publicPem).export({ type: 'spki', format: 'der' })
assert(derived.equals(supplied), 'Signing private key does not match the update public key')
}
function parseJson(text, label) {
try {
return JSON.parse(text)
} catch (error) {
throw new Error(`${label} is not valid JSON: ${error.message}`)
}
}
function jsonFormat(text) {
const newline = text.includes('\r\n') ? '\r\n' : '\n'
const indentMatch = text.match(/\n([ \t]+)"/)
return {
indent: indentMatch?.[1] || ' ',
newline,
trailingNewline: text.endsWith('\n')
}
}
function rewriteJson(text, mutate) {
const value = parseJson(text, 'JSON document')
mutate(value)
const format = jsonFormat(text)
let serialized = JSON.stringify(value, null, format.indent).replace(/\n/g, format.newline)
if (format.trailingNewline) serialized += format.newline
return serialized
}
export function updatePackageText(text, version) {
validateStableVersion(version)
return rewriteJson(text, (document) => {
assert(document.name === 'ycode', 'package.json is not the YCode package')
assert(document.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app')
document.version = version
})
}
export function updateLockText(text, version) {
validateStableVersion(version)
return rewriteJson(text, (document) => {
assert(document && typeof document === 'object', 'package-lock.json must be an object')
assert(
document.packages && typeof document.packages === 'object',
'package-lock.json has no root package entry'
)
assert(
document.packages[''] && typeof document.packages[''] === 'object',
'package-lock.json has no packages[""] entry'
)
document.version = version
document.packages[''].version = version
})
}
function packageVersions(packageText, lockText) {
const pkg = parseJson(packageText, 'package.json')
const lock = parseJson(lockText, 'package-lock.json')
assert(pkg.name === 'ycode', 'package.json is not the YCode package')
assert(pkg.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app')
const version = validateStableVersion(pkg.version)
assert(
lock.version !== undefined && lock.packages?.['']?.version !== undefined,
'package-lock.json must contain top-level and packages[""] versions'
)
assert(lock.version === version, 'package-lock.json top-level version does not match package.json')
assert(lock.packages[''].version === version, 'package-lock.json root version does not match package.json')
return { pkg, lock, version }
}
function sameManifest(left, right) {
return JSON.stringify(left) === JSON.stringify(right)
}
function parseSignedManifest(text, publicKeyPem, baseUrl, label) {
const document = parseJson(text, label)
verifySignedManifest(document, publicKeyPem, { baseUrl })
return document
}
export async function preflightRelease({ request, paths, deps }) {
const publicKeyPath = path.resolve(request.publicKeyPath)
const privateKeyPath = await canonicalPrivateKeyPath({
root: paths.root,
filename: request.privateKeyPath,
deps
})
const privatePem = await deps.readPrivateKey(privateKeyPath)
const publicPem = await deps.readPublicKey(publicKeyPath)
assertKeyPairMatches(privatePem, publicPem)
const packageLockPath = path.join(paths.root, 'package-lock.json')
for (const [label, filename] of [
['package.json', paths.packagePath],
['package-lock.json', packageLockPath]
]) {
assert(await deps.pathExists(filename), `${label} not found: ${filename}`)
}
const packageText = await deps.readFile(paths.packagePath, 'utf8')
const lockText = await deps.readFile(packageLockPath, 'utf8')
const versions = packageVersions(packageText, lockText)
if (request.resume) {
assert(
versions.version === request.version,
`--resume requires repository version ${request.version}; found ${versions.version}`
)
assert(await deps.pathExists(paths.outputDir), `Resume output directory not found: ${paths.outputDir}`)
assert(await deps.pathExists(paths.installerPath), `Resume installer not found: ${paths.installerPath}`)
assert(await deps.pathExists(paths.manifestPath), `Resume manifest not found: ${paths.manifestPath}`)
} else {
const compared = compareVersions(request.version, versions.version)
assert(
compared >= 0,
`Refusing to release ${request.version}; repository version is newer (${versions.version})`
)
assert(
!(await deps.pathExists(paths.outputDir)),
`Refusing to build an existing release directory: ${paths.outputDir}`
)
}
if (deps.checkToolchain) await deps.checkToolchain(paths, { resume: request.resume })
await deps.checkSsh(request.sshHost)
const current = await deps.readLatest({
host: request.sshHost,
publicKeyPem: publicPem,
baseUrl: request.baseUrl
})
let currentVersion = null
if (current?.document) {
verifySignedManifest(current.document, publicPem, { baseUrl: request.baseUrl })
currentVersion = current.document.payload.version
const compared = compareVersions(request.version, currentVersion)
if (request.resume) {
assert(
compared >= 0,
`Refusing resume for ${request.version}; server latest is newer (${currentVersion})`
)
} else {
assert(
compared > 0,
`Release version ${request.version} must be strictly newer than server latest ${currentVersion}`
)
}
}
let localManifestText = null
let localManifest = null
if (request.resume) {
localManifestText = await deps.readFile(paths.manifestPath, 'utf8')
localManifest = parseSignedManifest(localManifestText, publicPem, request.baseUrl, 'Local latest.json')
assert(
localManifest.payload.version === request.version,
`Resume manifest version ${localManifest.payload.version} does not match ${request.version}`
)
if (currentVersion === request.version) {
assert(
sameManifest(current.document, localManifest),
`Server already has a different ${request.version} manifest`
)
}
const bundle = await deps.validateBundle({
manifestPath: paths.manifestPath,
installerPath: paths.installerPath,
publicKeyPath,
baseUrl: request.baseUrl
})
assert(
bundle?.document?.payload?.version === request.version,
`Revalidated bundle version does not match ${request.version}`
)
}
return {
repoVersion: versions.version,
privateKeyPath,
packageText,
lockText,
publicPem,
currentVersion,
currentRaw: current?.raw ?? null,
localManifestText,
localManifest
}
}
async function updateTargetVersions({ request, paths, preflight, deps }) {
if (request.resume) return { changed: false }
const nextPackageText = updatePackageText(preflight.packageText, request.version)
const nextLockText = updateLockText(preflight.lockText, request.version)
let changed = false
if (nextPackageText !== preflight.packageText) {
await deps.writeFileAtomic(paths.packagePath, nextPackageText)
changed = true
}
if (nextLockText !== preflight.lockText) {
await deps.writeFileAtomic(path.join(paths.root, 'package-lock.json'), nextLockText)
changed = true
}
return { changed }
}
export async function acquireReleaseLock(lockPath, { version } = {}) {
await fsp.mkdir(path.dirname(lockPath), { recursive: true })
const token = crypto.randomBytes(16).toString('hex')
let handle
let created = false
try {
handle = await fsp.open(lockPath, 'wx', 0o600)
created = true
await handle.writeFile(
`${JSON.stringify({
token,
pid: process.pid,
version: version || null,
startedAt: new Date().toISOString()
})}\n`,
'utf8'
)
await handle.sync()
} catch (error) {
if (handle) await handle.close().catch(() => {})
if (created) await fsp.rm(lockPath, { force: true }).catch(() => {})
if (error?.code === 'EEXIST') {
throw new Error(`Release lock already exists: ${lockPath}`)
}
throw error
}
await handle.close()
let released = false
return async function releaseLock() {
if (released) return
let raw
try {
raw = await fsp.readFile(lockPath, 'utf8')
} catch (error) {
if (error?.code === 'ENOENT') {
released = true
return
}
throw error
}
let record
try {
record = JSON.parse(raw)
} catch {
throw new Error(`Release lock was replaced or corrupted: ${lockPath}`)
}
assert(record.token === token, `Release lock ownership changed: ${lockPath}`)
await fsp.rm(lockPath, { force: true })
released = true
}
}
async function readResponseLimited(response, maxBytes) {
const declared = Number(response.headers.get('content-length'))
if (Number.isFinite(declared) && declared > maxBytes) {
throw new Error(`latest.json exceeds ${maxBytes} bytes`)
}
if (!response.body) return Buffer.alloc(0)
const reader = response.body.getReader()
const chunks = []
let total = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > maxBytes) {
await reader.cancel()
throw new Error(`latest.json exceeds ${maxBytes} bytes`)
}
chunks.push(Buffer.from(value))
}
return Buffer.concat(chunks, total)
}
export async function verifyPublishedManifest({
baseUrl,
expectedManifestText,
publicKeyPem,
version,
fetchImpl = globalThis.fetch,
timeoutMs = 30_000,
maxBytes = MAX_HTTP_MANIFEST_BYTES
}) {
validateStableVersion(version)
const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
assert(typeof fetchImpl === 'function', 'fetch is not available')
const manifestUrl = new URL('latest.json', normalizedBaseUrl)
const response = await fetchImpl(manifestUrl, {
method: 'GET',
cache: 'no-store',
credentials: 'omit',
redirect: 'error',
referrerPolicy: 'no-referrer',
headers: {
accept: 'application/json',
'cache-control': 'no-cache',
pragma: 'no-cache'
},
signal: AbortSignal.timeout(timeoutMs)
})
assert(response.status === 200, `latest.json HTTP status is ${response.status}, expected 200`)
const expected = Buffer.from(expectedManifestText, 'utf8')
assert(expected.length <= maxBytes, 'Local signed manifest exceeds the HTTP verification limit')
const actual = await readResponseLimited(response, maxBytes)
assert(actual.equals(expected), 'HTTP latest.json does not exactly match the local signed manifest')
const document = parseSignedManifest(actual.toString('utf8'), publicKeyPem, normalizedBaseUrl, 'HTTP latest.json')
assert(document.payload.version === version, `HTTP latest.json version is ${document.payload.version}, expected ${version}`)
return document
}
function deployArgs({ request, paths }) {
return [
'--manifest',
paths.manifestPath,
'--installer',
paths.installerPath,
'--transport',
'ssh',
'--base-url',
request.baseUrl,
'--public-key',
request.publicKeyPath,
'--ssh-host',
request.sshHost
]
}
export async function runReleaseWorkflow(request, deps) {
validateStableVersion(request.version)
const paths = releasePaths(deps.root, request.version)
let releaseLock
let primaryError = null
releaseLock = await runStage('lock', () => deps.acquireReleaseLock(paths.lockPath, { version: request.version }))
assert(typeof releaseLock === 'function', 'Release lock must return a release function')
try {
const preflight = await runStage('preflight', () => preflightRelease({ request, paths, deps }))
if (!request.resume) {
await runStage('version-update', () => updateTargetVersions({ request, paths, preflight, deps }))
await runStage('test-updates', () => deps.runNpm(['run', 'test:updates']))
await runStage('build', () => deps.runNpm(['run', 'build']))
await runStage('package', () =>
deps.runNode([
paths.builderCli,
'--win',
'nsis',
'--x64',
'--publish',
'never',
`--config.directories.output=${path.relative(paths.root, paths.outputDir).replaceAll(path.sep, '/')}`
])
)
await runStage('sign', () =>
deps.runNode([
paths.prepareScript,
'--installer',
paths.installerPath,
'--version',
request.version,
'--notes-zh',
request.notes.zh,
'--notes-en',
request.notes.en,
'--base-url',
request.baseUrl,
'--private-key',
preflight.privateKeyPath,
'--public-key',
request.publicKeyPath,
'--output',
paths.manifestPath
])
)
}
await runStage('deploy-dry-run', () => deps.runNode([paths.deployScript, ...deployArgs({ request, paths }), '--dry-run']))
await runStage('deploy', () => deps.runNode([paths.deployScript, ...deployArgs({ request, paths })]))
await runStage('verify-http', async () => {
const expectedManifestText =
preflight.localManifestText || (await deps.readFile(paths.manifestPath, 'utf8'))
await deps.verifyHttp({
baseUrl: request.baseUrl,
expectedManifestText,
publicKeyPem: preflight.publicPem,
version: request.version
})
})
return {
version: request.version,
outputDir: paths.outputDir,
installerPath: paths.installerPath,
manifestPath: paths.manifestPath
}
} catch (error) {
primaryError = toReleaseError('workflow', error)
throw primaryError
} finally {
try {
await releaseLock()
} catch (error) {
if (!primaryError) throw toReleaseError('lock-release', error)
if (deps.logWarning) deps.logWarning(`Release lock cleanup failed: ${error.message}`)
}
}
}
+169
View File
@@ -0,0 +1,169 @@
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import { createInterface } from 'node:readline/promises'
import {
DEFAULT_BASE_URL,
REPO_ROOT,
readPrivateKey,
readPublicKey,
runSsh,
writeFileAtomic
} from './lib.mjs'
import {
ReleaseError,
acquireReleaseLock,
finalizeReleaseRequest,
parseReleaseArgs,
resolveVersionChoice,
runReleaseWorkflow,
toReleaseError,
verifyPublishedManifest
} from './release-workflow.mjs'
import { readLatestViaSsh, validateReleaseBundle } from './transport.mjs'
const usage = `Usage:
npm run release
npm run release -- --version <x.y.z> [--notes-zh <text>] [--notes-en <text>]
npm run release -- --resume --version <x.y.z>
npm run release -- --help
Options:
--version <x.y.z> Stable Windows x64 version. Prompted when omitted.
--notes-zh <text> Chinese release notes. Defaults to "YCode <version> 更新".
--notes-en <text> English release notes. Defaults to "YCode <version> update".
--resume Reuse an existing installer and signed manifest.
--ssh-host <host> SSH host, default local001.
--base-url <url> Release base URL, default ${DEFAULT_BASE_URL}.
--private-key <path> Signing key, default in the current user profile.
--help Show this help.
The workflow runs tests, builds, signs, deploys, and verifies latest.json.
It does not create keys, roll back the server, or clean stale release locks.
`
async function pathExists(filename) {
try {
await fsp.access(filename)
return true
} catch (error) {
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false
throw error
}
}
function runExecutable(command, args, { cwd = REPO_ROOT } = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: process.env,
shell: false,
stdio: 'inherit',
windowsHide: true
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`${path.basename(command)} exited with ${signal ? `signal ${signal}` : `code ${code}`}`))
}
})
})
}
function resolveNpmCli() {
const candidates = [
path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
process.env.npm_execpath
].filter((candidate) => candidate && path.basename(candidate).toLowerCase() === 'npm-cli.js')
const npmCli = candidates.find((candidate) => fs.existsSync(candidate))
if (!npmCli) throw new Error('Unable to locate npm-cli.js; run this command through npm run release')
return npmCli
}
async function promptForVersion() {
const readline = createInterface({ input: process.stdin, output: process.stdout })
try {
return await readline.question('Release version (stable x.y.z): ')
} finally {
readline.close()
}
}
function productionDependencies() {
const npmCli = resolveNpmCli()
return {
root: REPO_ROOT,
pathExists,
realpath: fsp.realpath,
readFile: fsp.readFile,
writeFileAtomic,
readPrivateKey,
readPublicKey,
validateBundle: validateReleaseBundle,
acquireReleaseLock,
checkToolchain: async (paths, { resume }) => {
const required = [['deploy.mjs', paths.deployScript]]
if (!resume) {
required.unshift(
['npm-cli.js', npmCli],
['electron-builder CLI', paths.builderCli],
['prepare-release.mjs', paths.prepareScript]
)
}
for (const [label, filename] of required) {
if (!fs.existsSync(filename)) throw new Error(`${label} not found: ${filename}`)
}
},
checkSsh: async (host) => {
runSsh(host, 'true\n')
},
readLatest: ({ host, publicKeyPem, baseUrl }) => readLatestViaSsh(host, publicKeyPem, baseUrl),
runNpm: (args) => runExecutable(process.execPath, [npmCli, ...args]),
runNode: (args) => runExecutable(process.execPath, args),
verifyHttp: verifyPublishedManifest,
logWarning: (message) => console.warn(message)
}
}
async function main() {
let parsed
try {
parsed = parseReleaseArgs(process.argv.slice(2))
} catch (error) {
throw toReleaseError('input', error)
}
if (parsed.help) {
process.stdout.write(usage)
return
}
let version
try {
version = await resolveVersionChoice({ version: parsed.version, prompt: promptForVersion })
} catch (error) {
throw toReleaseError('prompt', error)
}
if (!version) {
console.log('Release cancelled.')
return
}
let request
try {
request = finalizeReleaseRequest(parsed, version)
} catch (error) {
throw toReleaseError('input', error)
}
const result = await runReleaseWorkflow(request, productionDependencies())
console.log(`Release ${result.version} published and verified.`)
console.log(`Installer: ${result.installerPath}`)
console.log(`Manifest: ${result.manifestPath}`)
}
main().catch((error) => {
const releaseError = error instanceof ReleaseError ? error : toReleaseError('input', error)
console.error(`Release failed during ${releaseError.stage}: ${releaseError.message}`)
process.exitCode = 1
})
+372
View File
@@ -0,0 +1,372 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import fsp from 'node:fs/promises'
import http from 'node:http'
import os from 'node:os'
import path from 'node:path'
import {
REPO_ROOT,
buildManifestPayload,
compareVersions,
createSignedManifest,
normalizeBaseUrl,
readPrivateKey,
readPublicKey,
renderDownloadPage,
runProcess,
serializeSignedManifest,
signPayload,
verifyPayloadSignature,
verifySignedManifest
} from './lib.mjs'
import {
publishLocalRelease,
readLocalLatest
} from './transport.mjs'
let passed = 0
async function test(name, callback) {
await callback()
passed += 1
console.log(`PASS ${name}`)
}
function expectedError(pattern) {
return (error) => {
assert.match(String(error?.message || error), pattern)
return true
}
}
function sha256(buffer) {
return createHash('sha256').update(buffer).digest('hex')
}
function contentType(filename) {
return filename.endsWith('.json') ? 'application/json' : 'application/octet-stream'
}
function startStaticServer(root) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1')
const pathname = decodeURIComponent(url.pathname)
const relative = pathname.startsWith('/ycode/releases/')
? pathname.slice('/ycode/releases/'.length)
: null
assert(relative !== null, 'unexpected path')
const requested = relative === '' || relative.endsWith('/') ? `${relative}index.html` : relative
const filename = path.resolve(root, 'ycode', 'releases', requested)
const releaseRoot = path.resolve(root, 'ycode', 'releases')
assert(filename.startsWith(`${releaseRoot}${path.sep}`), 'path traversal')
const data = await fsp.readFile(filename)
response.writeHead(200, {
'content-length': data.length,
'content-type': contentType(filename)
})
response.end(data)
} catch {
response.writeHead(404)
response.end('not found')
}
})
return new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
resolve({ server, origin: `http://127.0.0.1:${address.port}` })
})
})
}
async function releaseFor({ version, bytes, filename, baseUrl, privatePem, publicPem, artifactPath }) {
const installer = {
filename,
size: bytes.length,
sha256: sha256(bytes)
}
const payload = buildManifestPayload({
version,
publishedAt: '2026-10-20T10:00:00.000Z',
notes: { zh: `版本 ${version} 测试说明`, en: `Release ${version} test notes` },
installer,
baseUrl
})
const document = createSignedManifest({
payload,
privateKeyPem: privatePem,
publicKeyPem: publicPem,
baseUrl
})
return {
document,
installer: { ...installer, path: artifactPath },
manifestText: serializeSignedManifest(document, { baseUrl }),
publicKeyPem: publicPem,
baseUrl
}
}
async function main() {
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-update-selftest-'))
const privateKey = path.join(temporary, 'keys', 'update-signing.key')
const publicKey = path.join(temporary, 'keys', 'public-key.pem')
const publicRoot = path.join(temporary, 'ycode')
const artifactDirectory = path.join(temporary, 'fixtures')
await fsp.mkdir(artifactDirectory, { recursive: true })
let server
try {
await test('key generation stays outside the repository and writes a PEM trust anchor', async () => {
const initKey = path.join(REPO_ROOT, 'scripts', 'updates', 'init-key.mjs')
const output = runProcess(process.execPath, [initKey, '--private-key', privateKey, '--public-key', publicKey])
assert.match(output, /Private key:/)
assert.doesNotMatch(output, /BEGIN PRIVATE KEY/)
const privatePem = await fsp.readFile(privateKey, 'utf8')
const publicPem = await fsp.readFile(publicKey, 'utf8')
assert.match(privatePem, /BEGIN PRIVATE KEY/)
assert.match(publicPem, /BEGIN PUBLIC KEY/)
if (process.platform === 'win32') {
const acl = runProcess('icacls', [privateKey])
assert.doesNotMatch(acl, /\(I\)/)
}
})
const privatePem = await readPrivateKey(privateKey)
const publicPem = await readPublicKey(publicKey)
await test('SemVer precedence is strict and build metadata does not create a newer release', async () => {
assert(compareVersions('2.0.0', '1.9.9') > 0)
assert(compareVersions('2.0.0-rc.2', '2.0.0-rc.10') < 0)
assert(compareVersions('2.0.0+1', '2.0.0+2') === 0)
assert.throws(() => compareVersions('01.0.0', '1.0.0'), /strict SemVer/)
assert.throws(() => compareVersions('1.0', '1.0.0'), /strict SemVer/)
})
const staticServer = await startStaticServer(temporary)
server = staticServer.server
const baseUrl = normalizeBaseUrl(`${staticServer.origin}/ycode/releases/`)
const version1 = '2.0.0'
const bytes1 = Buffer.from(`fake installer ${version1} `.repeat(16_384), 'utf8')
const filename1 = `YCode-${version1}-Setup-x64.exe`
const artifact1 = path.join(artifactDirectory, filename1)
await fsp.writeFile(artifact1, bytes1)
const release1 = await releaseFor({
version: version1,
bytes: bytes1,
filename: filename1,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact1
})
await test('manifest signs exact JSON.stringify(payload) and rejects any payload or signature tamper', async () => {
verifySignedManifest(release1.document, publicPem, { baseUrl })
assert(verifyPayloadSignature(release1.document.payload, release1.document.signature, publicPem))
const notesTamper = structuredClone(release1.document)
notesTamper.payload.notes.en = 'tampered'
assert.throws(() => verifySignedManifest(notesTamper, publicPem, { baseUrl }), /signature is invalid/)
const hashTamper = structuredClone(release1.document)
hashTamper.payload.platforms['windows-x64'].sha256 = '0'.repeat(64)
assert.throws(() => verifySignedManifest(hashTamper, publicPem, { baseUrl }), /signature is invalid/)
const signatureTamper = structuredClone(release1.document)
const first = signatureTamper.signature[0] === 'A' ? 'B' : 'A'
signatureTamper.signature = first + signatureTamper.signature.slice(1)
assert.throws(() => verifySignedManifest(signatureTamper, publicPem, { baseUrl }), /signature is invalid/)
})
await test('signed manifests reject query, fragment, credentials, cross-version paths and non-canonical timestamps', async () => {
const cases = [
(payload) => { payload.platforms['windows-x64'].url += '?token=bad' },
(payload) => { payload.platforms['windows-x64'].url += '#fragment' },
(payload) => { payload.platforms['windows-x64'].url = payload.platforms['windows-x64'].url.replace('127.0.0.1', 'user:pass@127.0.0.1') },
(payload) => { payload.platforms['windows-x64'].url = payload.platforms['windows-x64'].url.replace('/versions/2.0.0/', '/versions/2.0.1/') },
(payload) => { payload.publishedAt = '2026-10-20T10:00:00Z' },
(payload) => { payload.platforms['windows-x64'].sha256 = payload.platforms['windows-x64'].sha256.toUpperCase() }
]
for (const mutate of cases) {
const payload = structuredClone(release1.document.payload)
mutate(payload)
const document = { payload, signature: signPayload(payload, privatePem) }
assert.throws(() => verifySignedManifest(document, publicPem, { baseUrl }), /Manifest|URL|publishedAt|sha256/)
}
})
await test('download page escapes signed notes and contains no script', async () => {
const payload = structuredClone(release1.document.payload)
payload.notes.zh = '<script>alert("zh")</script>\n第二行'
payload.notes.en = '<img src=x onerror=alert(1)>'
const html = renderDownloadPage(payload, { baseUrl })
assert.doesNotMatch(html, /<script>alert/)
assert.doesNotMatch(html, /<img src=x/)
assert.match(html, /&lt;script&gt;/)
assert.match(html, /&lt;img src=x onerror=alert\(1\)&gt;/)
assert.match(html, /Content-Security-Policy/)
})
await test('local fake transport publishes immutable artifact then fetches and verifies it over HTTP', async () => {
let current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await publishLocalRelease({ root: publicRoot, release: release1, current })
const response = await fetch(new URL('latest.json', baseUrl))
assert.equal(response.status, 200)
assert.equal(response.headers.get('cache-control'), null)
const text = await response.text()
const document = JSON.parse(text)
verifySignedManifest(document, publicPem, { baseUrl })
const artifactResponse = await fetch(document.payload.platforms['windows-x64'].url)
const downloaded = Buffer.from(await artifactResponse.arrayBuffer())
assert.equal(sha256(downloaded), document.payload.platforms['windows-x64'].sha256)
assert.equal(downloaded.length, document.payload.platforms['windows-x64'].size)
const pageResponse = await fetch(new URL('./', baseUrl))
assert.equal(pageResponse.status, 200)
const page = await pageResponse.text()
assert.match(page, /v2\.0\.0/)
assert.match(page, new RegExp(document.payload.platforms['windows-x64'].sha256))
assert.doesNotMatch(page, /<script\b/i)
})
await test('exact redeploy is idempotent', async () => {
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await publishLocalRelease({ root: publicRoot, release: release1, current })
})
await test('a conflicting artifact in an existing version directory is rejected', async () => {
const bytes = Buffer.from('conflicting same-version bytes', 'utf8')
const artifact = path.join(artifactDirectory, filename1)
await fsp.writeFile(artifact, bytes)
const conflicting = await releaseFor({
version: version1,
bytes,
filename: filename1,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(current.document.payload.version, version1)
await fsp.writeFile(artifact, bytes1)
await assert.rejects(
publishLocalRelease({ root: publicRoot, release: conflicting, current }),
expectedError(/different manifest/)
)
})
await test('a lower version is rejected without changing latest.json', async () => {
const bytes = Buffer.from('lower release fixture', 'utf8')
const filename = 'YCode-1.9.9-Setup-x64.exe'
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const lower = await releaseFor({
version: '1.9.9',
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await assert.rejects(
publishLocalRelease({ root: publicRoot, release: lower, current }),
expectedError(/newer/)
)
const after = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(after.raw, current.raw)
})
await test('failure before latest.json swap leaves the previous signed manifest live', async () => {
const version = '2.1.0'
const bytes = Buffer.from('failed atomic fixture', 'utf8')
const filename = `YCode-${version}-Setup-x64.exe`
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const failed = await releaseFor({
version,
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const before = await readLocalLatest(publicRoot, publicPem, baseUrl)
const pageBefore = await (await fetch(new URL('./', baseUrl))).text()
await assert.rejects(
publishLocalRelease({
root: publicRoot,
release: failed,
current: before,
beforeLatestSwap: async () => { throw new Error('injected publication failure') }
}),
expectedError(/injected publication failure/)
)
const after = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(after.raw, before.raw)
const pageAfter = await (await fetch(new URL('./', baseUrl))).text()
assert.equal(pageAfter, pageBefore)
})
await test('a different artifact cannot reuse a version directory left by a failed publication', async () => {
const version = '2.1.0'
const bytes = Buffer.from('different failed artifact', 'utf8')
const filename = `YCode-${version}-Setup-x64.exe`
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const conflicting = await releaseFor({
version,
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await assert.rejects(
publishLocalRelease({ root: publicRoot, release: conflicting, current }),
expectedError(/conflicting artifact/)
)
})
await test('successful publication swaps the generated download page with latest.json', async () => {
const version = '2.2.0'
const bytes = Buffer.from('successful next release fixture', 'utf8')
const filename = `YCode-${version}-Setup-x64.exe`
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const release = await releaseFor({
version,
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await publishLocalRelease({ root: publicRoot, release, current })
const latest = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(latest.document.payload.version, version)
const page = await (await fetch(new URL('./', baseUrl))).text()
assert.match(page, /v2\.2\.0/)
assert.match(page, new RegExp(latest.document.payload.platforms['windows-x64'].sha256))
})
console.log(`\n${passed} release-tool self-tests passed. No SSH target or production latest.json was touched.`)
} finally {
if (server) await new Promise((resolve) => server.close(resolve))
await fsp.rm(temporary, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+408
View File
@@ -0,0 +1,408 @@
import crypto from 'node:crypto'
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import {
DEFAULT_BASE_URL,
SERVER_RELEASES_ROOT,
SERVER_STAGING_ROOT,
assert,
compareVersions,
normalizeBaseUrl,
parseVersion,
readJson,
readPublicKey,
renderDownloadPage,
serializeSignedManifest,
sha256File,
verifyReleaseInstaller,
verifySignedManifest,
runProcess,
runSsh,
validateSshHost,
writeFileAtomic
} from './lib.mjs'
const MAX_MANIFEST_BYTES = 1024 * 1024
export function parseCurrentManifest(raw, publicKeyPem, baseUrl) {
assert(Buffer.byteLength(raw, 'utf8') <= MAX_MANIFEST_BYTES, 'Current latest.json exceeds 1 MiB')
let document
try {
document = JSON.parse(raw)
} catch {
throw new Error('Current latest.json is not valid JSON')
}
verifySignedManifest(document, publicKeyPem, { baseUrl })
return document
}
export function assertMonotonicTransition(current, target) {
if (!current) return
const compared = compareVersions(target.payload.version, current.payload.version)
assert(compared >= 0, `Refusing to publish ${target.payload.version}; server latest is newer (${current.payload.version})`)
if (compared === 0) {
assert(
JSON.stringify(current) === JSON.stringify(target),
`Refusing to replace ${target.payload.version} with a different manifest`
)
}
}
export async function validateReleaseBundle({
manifestPath,
installerPath,
publicKeyPath,
baseUrl = DEFAULT_BASE_URL
}) {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
const publicKeyPem = await readPublicKey(publicKeyPath)
let document
try {
document = await readJson(manifestPath)
} catch (error) {
throw new Error(`Unable to read release manifest: ${error.message}`)
}
verifySignedManifest(document, publicKeyPem, { baseUrl: normalizedBaseUrl })
const expected = document.payload.platforms['windows-x64']
const installer = await verifyReleaseInstaller(installerPath, document.payload.version)
assert(installer.size === expected.size, 'Installer size does not match the signed manifest')
assert(installer.sha256 === expected.sha256, 'Installer SHA-256 does not match the signed manifest')
assert(path.posix.basename(new URL(expected.url).pathname) === installer.filename, 'Signed URL filename does not match the installer')
return {
document,
manifestText: serializeSignedManifest(document, { baseUrl: normalizedBaseUrl }),
pageHtml: renderDownloadPage(document.payload, { baseUrl: normalizedBaseUrl }),
installer,
publicKeyPem,
baseUrl: normalizedBaseUrl
}
}
export async function readLocalLatest(root, publicKeyPem, baseUrl) {
const filename = path.join(root, 'releases', 'latest.json')
try {
const raw = await fsp.readFile(filename, 'utf8')
return { raw, document: parseCurrentManifest(raw, publicKeyPem, baseUrl) }
} catch (error) {
if (error && error.code === 'ENOENT') return { raw: null, document: null }
throw error
}
}
async function installVersionDirectory({ root, version, installerPath, expectedSha256, runId }) {
const releases = path.join(root, 'releases')
const versions = path.join(releases, 'versions')
const target = path.join(versions, version)
const filename = path.basename(installerPath)
await fsp.mkdir(versions, { recursive: true })
try {
const entries = await fsp.readdir(target)
assert(entries.length === 1 && entries[0] === filename, `Version directory ${version} already conflicts with this artifact`)
const existingHash = await sha256File(path.join(target, filename))
assert(existingHash === expectedSha256, `Version directory ${version} contains a conflicting artifact`)
return
} catch (error) {
if (!error || error.code !== 'ENOENT') throw error
}
const staging = path.join(root, '.staging', runId)
const stagedVersion = path.join(staging, 'version')
const stagedFile = path.join(stagedVersion, filename)
await fsp.mkdir(stagedVersion, { recursive: true })
try {
await fsp.copyFile(installerPath, stagedFile)
const copiedHash = await sha256File(stagedFile)
assert(copiedHash === expectedSha256, 'Staged installer hash changed')
await fsp.rename(stagedVersion, target)
} finally {
await fsp.rm(staging, { recursive: true, force: true })
}
}
export async function publishLocalRelease({
root,
release,
current,
beforeLatestSwap
}) {
const { document, installer, manifestText, publicKeyPem, baseUrl } = release
assertMonotonicTransition(current?.document, document)
await installVersionDirectory({
root,
version: document.payload.version,
installerPath: installer.path,
expectedSha256: document.payload.platforms['windows-x64'].sha256,
runId: crypto.randomBytes(12).toString('hex')
})
const latestPath = path.join(root, 'releases', 'latest.json')
const indexPath = path.join(root, 'releases', 'index.html')
let previousPage = null
try {
previousPage = await fsp.readFile(indexPath)
} catch (error) {
if (!error || error.code !== 'ENOENT') throw error
}
const assertCurrentLatest = async () => {
if (current?.raw != null) {
const liveRaw = await fsp.readFile(latestPath, 'utf8')
assert(liveRaw === current.raw, 'latest.json changed during publication')
return
}
try {
await fsp.access(latestPath)
throw new Error('latest.json appeared during publication')
} catch (error) {
if (error && error.code !== 'ENOENT') throw error
}
}
await assertCurrentLatest()
try {
await writeFileAtomic(
indexPath,
release.pageHtml || renderDownloadPage(document.payload, { baseUrl }),
0o644
)
if (beforeLatestSwap) await beforeLatestSwap()
await assertCurrentLatest()
await writeFileAtomic(latestPath, manifestText, 0o644)
} catch (error) {
try {
if (previousPage == null) {
await fsp.rm(indexPath, { force: true })
} else {
await writeFileAtomic(indexPath, previousPage, 0o644)
}
} catch (rollbackError) {
throw new Error(`${error.message}; failed to restore download page: ${rollbackError.message}`)
}
throw error
}
return { latestPath, document }
}
export async function readLatestViaSsh(host, publicKeyPem, baseUrl) {
const script = `set -euo pipefail
if [ -f '${SERVER_RELEASES_ROOT}/latest.json' ]; then
cat '${SERVER_RELEASES_ROOT}/latest.json'
fi
`
const raw = runSsh(host, script)
if (!raw) return { raw: null, document: null }
assert(Buffer.byteLength(raw, 'utf8') <= MAX_MANIFEST_BYTES, 'Server latest.json exceeds 1 MiB')
return { raw, document: parseCurrentManifest(raw, publicKeyPem, baseUrl) }
}
function sha256Text(value) {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex')
}
export async function publishViaSsh({
host,
release,
current
}) {
validateSshHost(host)
const { document, installer, manifestText, publicKeyPem, baseUrl } = release
assertMonotonicTransition(current?.document, document)
const runId = crypto.randomBytes(12).toString('hex')
const version = document.payload.version
const filename = installer.filename
parseVersion(version)
assert(/^[A-Za-z0-9._-]+\.exe$/.test(filename), 'Unsafe installer filename')
const staging = `${SERVER_STAGING_ROOT}/${runId}`
const manifestSha256 = sha256Text(manifestText)
const pageHtml = renderDownloadPage(document.payload, { baseUrl })
const pageSha256 = sha256Text(pageHtml)
const metadata = {
runId,
version,
filename,
sha256: document.payload.platforms['windows-x64'].sha256,
size: document.payload.platforms['windows-x64'].size,
manifestSha256,
pageSha256,
expectedLatestSha256: current?.raw == null ? null : sha256Text(current.raw)
}
runSsh(host, `set -euo pipefail
install -d -o root -g root -m 0700 '${SERVER_STAGING_ROOT}'
install -d -o root -g root -m 0700 '${staging}'
`)
let publicationError
try {
runProcess('scp', ['-q', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', installer.path, `${host}:${staging}/${filename}`])
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-update-deploy-'))
const localManifest = path.join(temporary, 'latest.json')
const localPage = path.join(temporary, 'index.html')
await fsp.writeFile(localManifest, manifestText, { mode: 0o600 })
await fsp.writeFile(localPage, pageHtml, { mode: 0o600 })
try {
runProcess('scp', ['-q', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', localManifest, `${host}:${staging}/latest.json`])
runProcess('scp', ['-q', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', localPage, `${host}:${staging}/index.html`])
} finally {
await fsp.rm(temporary, { recursive: true, force: true })
}
const metadataBase64 = Buffer.from(JSON.stringify(metadata), 'utf8').toString('base64')
const remoteScript = `set -euo pipefail
umask 022
metadata_file="$(mktemp /tmp/ycode-release.XXXXXX.json)"
cleanup() {
rm -f "$metadata_file"
rm -rf '${staging}'
}
trap cleanup EXIT
printf '%s' '${metadataBase64}' | base64 -d > "$metadata_file"
python3 - "$metadata_file" <<'PY'
import fcntl
import hashlib
import json
import os
import re
import shutil
import sys
meta = json.load(open(sys.argv[1], "r", encoding="utf-8"))
run_id = meta["runId"]
version = meta["version"]
filename = meta["filename"]
expected_hash = meta["sha256"]
expected_size = meta["size"]
manifest_hash = meta["manifestSha256"]
page_hash = meta["pageSha256"]
expected_latest_hash = meta["expectedLatestSha256"]
if not re.fullmatch(r"[0-9A-Fa-f]{24}", run_id):
raise SystemExit("invalid staging id")
if not re.fullmatch(r"[0-9A-Za-z._-]+\\.exe", filename):
raise SystemExit("invalid installer filename")
if not re.fullmatch(r"[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?", version):
raise SystemExit("invalid version")
public_root = "${path.posix.dirname(SERVER_RELEASES_ROOT)}"
releases = "${SERVER_RELEASES_ROOT}"
staging = "${SERVER_STAGING_ROOT}/" + run_id
versions = os.path.join(releases, "versions")
target_dir = os.path.join(versions, version)
staged_artifact = os.path.join(staging, filename)
staged_manifest = os.path.join(staging, "latest.json")
staged_page = os.path.join(staging, "index.html")
latest = os.path.join(releases, "latest.json")
index = os.path.join(releases, "index.html")
def digest(pathname):
value = hashlib.sha256()
with open(pathname, "rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
value.update(chunk)
return value.hexdigest()
if os.stat(staging).st_dev != os.stat(public_root).st_dev:
raise SystemExit("staging and release roots are on different filesystems")
os.makedirs(versions, mode=0o755, exist_ok=True)
os.makedirs("${SERVER_STAGING_ROOT}", mode=0o700, exist_ok=True)
lock_path = "/var/lock/ycode-release.lock"
with open(lock_path, "a+", encoding="utf-8") as lock:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
live_latest_hash = digest(latest) if os.path.isfile(latest) else None
if live_latest_hash != expected_latest_hash:
raise SystemExit("latest.json changed during publication")
if os.path.getsize(staged_artifact) != expected_size or digest(staged_artifact) != expected_hash:
raise SystemExit("staged installer does not match signed metadata")
if os.path.getsize(staged_manifest) > 1024 * 1024 or digest(staged_manifest) != manifest_hash:
raise SystemExit("staged manifest does not match expected bytes")
if os.path.getsize(staged_page) > 512 * 1024 or digest(staged_page) != page_hash:
raise SystemExit("staged download page does not match expected bytes")
if os.path.exists(target_dir):
entries = sorted(os.listdir(target_dir))
if entries != [filename]:
raise SystemExit("version directory conflicts with this artifact")
if digest(os.path.join(target_dir, filename)) != expected_hash:
raise SystemExit("version directory contains a conflicting artifact")
else:
version_stage = os.path.join(staging, "version")
os.makedirs(version_stage, mode=0o755, exist_ok=False)
os.replace(staged_artifact, os.path.join(version_stage, filename))
os.chmod(os.path.join(version_stage, filename), 0o644)
os.replace(version_stage, target_dir)
previous_page = None
if os.path.isfile(index):
with open(index, "rb") as stream:
previous_page = stream.read()
if len(previous_page) > 512 * 1024:
raise SystemExit("existing download page is unexpectedly large")
next_latest = os.path.join(releases, f".latest.json.{run_id}.tmp")
next_page = os.path.join(releases, f".index.html.{run_id}.tmp")
os.replace(staged_manifest, next_latest)
os.chmod(next_latest, 0o644)
with open(next_latest, "rb") as stream:
os.fsync(stream.fileno())
os.replace(staged_page, next_page)
os.chmod(next_page, 0o644)
with open(next_page, "rb") as stream:
os.fsync(stream.fileno())
page_swapped = False
try:
os.replace(next_page, index)
page_swapped = True
os.replace(next_latest, latest)
except Exception:
if page_swapped:
if previous_page is None:
try:
os.unlink(index)
except FileNotFoundError:
pass
else:
rollback_page = os.path.join(releases, f".index.html.{run_id}.rollback")
with open(rollback_page, "wb") as stream:
stream.write(previous_page)
stream.flush()
os.fsync(stream.fileno())
os.chmod(rollback_page, 0o644)
os.replace(rollback_page, index)
raise
directory_fd = os.open(releases, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
print(version)
PY
`
runSsh(host, remoteScript)
} catch (error) {
publicationError = error
throw error
} finally {
try {
runSsh(host, `set -euo pipefail
rm -rf '${staging}'
`)
} catch (cleanupError) {
if (publicationError) {
console.error(`Unable to remove remote staging path after failure: ${cleanupError.message}`)
} else {
console.warn(`Publication succeeded, but remote staging cleanup failed: ${cleanupError.message}`)
}
}
}
return { version }
}
+761
View File
@@ -0,0 +1,761 @@
// Real isolated YCode main/preload/renderer verification for native append delivery.
// The production build is loaded unchanged. A loopback Anthropic-compatible server
// holds the first response while two durable queue messages are sent as guidance.
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/claude-append-app-'))
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
await Promise.all([fs.mkdir(data, { recursive: true }), fs.mkdir(home, { recursive: true })])
const probe = require('node:net').createServer()
await new Promise((resolve, reject) => {
probe.once('error', reject)
probe.listen(0, '127.0.0.1', resolve)
})
const address = probe.address()
if (!address || typeof address === 'string') throw new Error('failed to reserve loopback port')
const appendPort = address.port
await new Promise(resolve => probe.close(resolve))
const env = {
...process.env,
YCODE_QA_DIRECTORY: directory,
YCODE_USER_DATA_DIR: data,
YCODE_APPEND_PORT: String(appendPort),
YCODE_QA_OFFSCREEN: '1',
YCODE_DISABLE_GPU: '1',
YCODE_DISABLE_SYSTEM_INTEGRATION: '1',
HOME: home,
USERPROFILE: home,
DISABLE_TELEMETRY: '1',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1'
}
delete env.ELECTRON_RUN_AS_NODE
delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(
require('electron'),
['--no-sandbox', '--disable-gpu-sandbox', __filename],
{ env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }
)
let output = ''
child.stdout.on('data', bytes => {
output += bytes
if (bytes.toString().includes('APPEND_E2E_')) process.stdout.write(bytes)
})
child.stderr.on('data', bytes => {
output += bytes
if (bytes.toString().includes('APPEND_E2E_')) process.stderr.write(bytes)
})
const timer = setTimeout(() => child.kill(), 180000)
child.once('exit', async code => {
clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code }))
if (code) console.error(output.slice(-12000))
process.exitCode = code ? 1 : 0
})
})().catch(error => {
console.error(error)
process.exitCode = 1
})
} else {
const { app, BrowserWindow } = require('electron')
const { createServer } = require('node:http')
const { pathToFileURL } = require('node:url')
const fsSync = require('node:fs')
const directory = process.env.YCODE_QA_DIRECTORY
assert.ok(directory, 'YCODE_QA_DIRECTORY is required')
const appendPort = Number(process.env.YCODE_APPEND_PORT)
assert.ok(Number.isInteger(appendPort) && appendPort > 0, 'YCODE_APPEND_PORT is required')
const relative = path.relative(path.resolve('.tmp'), path.resolve(directory))
assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative), 'unsafe QA directory')
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
const workspace = path.join(home, 'YCodeWorkspace')
const modelId = 'native-append-loopback'
const modelBId = 'native-append-loopback-b'
const modelCId = 'native-append-loopback-c'
// Each profile keeps its own wire model id so the loopback server can prove
// which model actually served a request.
const wireModelA = 'claude-native-append-a'
const wireModelB = 'claude-native-append-b'
const wireModelC = 'claude-native-append-c'
// Each profile gets its own base path so the request URL alone proves which
// model served a turn, independently of the request body fields.
const modelPathA = '/m-a'
const modelPathB = '/m-b'
const modelPathC = '/m-c'
app.disableHardwareAcceleration()
app.setPath('userData', data)
app.setPath('sessionData', data)
app.setPath('home', home)
let hidingWindows = true
app.on('browser-window-created', (_event, win) => {
win.setSkipTaskbar(true)
win.on('show', () => {
if (!hidingWindows || win.isDestroyed()) return
setImmediate(() => {
if (!win.isDestroyed()) win.hide()
})
})
})
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const checks = []
const rendererErrors = []
const requests = []
const serverEvents = []
const queueTimeline = []
const openSockets = new Set()
let firstResponseGate
let firstResponseReleased = false
const firstResponse = new Promise(resolve => { firstResponseGate = resolve })
let stage = 'boot'
function setStage(value) {
stage = value
serverEvents.push({ type: 'stage', stage: value })
}
function releaseFirstResponse() {
if (firstResponseReleased) return
firstResponseReleased = true
firstResponseGate()
}
function check(label, actual, expected) {
const pass = typeof expected === 'function'
? expected(actual)
: JSON.stringify(actual) === JSON.stringify(expected)
checks.push({
label,
pass,
actual,
expected: typeof expected === 'function' ? expected.toString() : expected
})
}
function sse(res, event, value) {
res.write(`event: ${event}\ndata: ${JSON.stringify(value)}\n\n`)
}
function messageStart(res, id) {
sse(res, 'message_start', {
type: 'message_start',
message: {
id,
type: 'message',
role: 'assistant',
model: modelId,
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 12, output_tokens: 1 }
}
})
}
function textBlock(res, text) {
sse(res, 'content_block_start', {
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: '' }
})
sse(res, 'content_block_delta', {
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text }
})
sse(res, 'content_block_stop', { type: 'content_block_stop', index: 0 })
}
function messageEnd(res) {
sse(res, 'message_delta', {
type: 'message_delta',
delta: { stop_reason: 'end_turn', stop_sequence: null },
usage: { output_tokens: 4 }
})
sse(res, 'message_stop', { type: 'message_stop' })
res.end()
}
function plainText(res, id, text) {
messageStart(res, id)
textBlock(res, text)
messageEnd(res)
}
function messageTexts(message) {
if (!message || typeof message !== 'object') return []
const content = message.content
if (typeof content === 'string') return [content]
if (!Array.isArray(content)) return []
return content.flatMap(block => block && typeof block === 'object' &&
block.type === 'text' && typeof block.text === 'string' ? [block.text] : [])
}
function userTexts(body) {
if (!Array.isArray(body.messages)) return []
return body.messages.flatMap(message => message?.role === 'user' ? messageTexts(message) : [])
}
async function read(source, win) {
let timer
try {
return await Promise.race([
win.webContents.executeJavaScript(source),
new Promise((_, reject) => {
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 140))), 20000)
})
])
} finally {
clearTimeout(timer)
}
}
async function waitFor(source, label, win, attempts = 500) {
for (let index = 0; index < attempts; index += 1) {
const value = await read(source, win)
if (value) return value
await pause(40)
}
throw Error('Timeout: ' + label)
}
async function waitForMain(predicate, label, attempts = 500) {
for (let index = 0; index < attempts; index += 1) {
const value = await predicate()
if (value) return value
await pause(40)
}
throw Error('Timeout: ' + label)
}
async function readQueue(win, sessionId) {
return read(`window.ycode.chat.queue.get(${JSON.stringify(sessionId)})`, win)
}
/**
* Click the send-now action of one specific queue row. The row is located by
* its rendered preview text, which is unique per queued message, so a
* concurrent send-now on another row can never be mistaken for this one.
*/
async function clickSendNowForRow(win, previewText) {
const clicked = await read(`(()=>{
const rows=[...document.querySelectorAll('.conversation-message-queue-item')];
const row=rows.find(node=>node.querySelector('.conversation-queue-preview-text')?.textContent?.includes(${JSON.stringify(previewText)}));
if(!row)return {ok:false,reason:'row-not-found',rows:rows.length};
const button=row.querySelector('[aria-label="立即发送"]');
if(!button)return {ok:false,reason:'no-button',status:row.textContent??''};
if(button.disabled)return {ok:false,reason:'disabled'};
button.click();
return {ok:true}
})()`, win)
assert.equal(clicked.ok, true, `send-now for row ${previewText}: ${JSON.stringify(clicked)}`)
}
/**
* A row leaves the queue only after the native identity is acknowledged.
* While the first model response is still gated, that acknowledgement cannot
* arrive, so the row stays visible in the delivering state.
*/
async function waitForRowStatus(win, sessionId, queueId, status) {
for (let index = 0; index < 500; index += 1) {
const snapshot = await readQueue(win, sessionId)
const item = snapshot.items.find(entry => entry.id === queueId)
if (item?.status === status) return snapshot
if (!item) {
const error = new Error(`queue row ${queueId} left the queue before reaching ${status}`)
error.snapshot = snapshot
throw error
}
await pause(40)
}
throw Error(`Timeout: queue row ${queueId} did not reach ${status}`)
}
async function setComposer(win, text) {
const set = await read(`(()=>{
const input=document.querySelector('.wb-composer__input');
if(!(input instanceof HTMLTextAreaElement))return false;
const setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set;
setter.call(input,${JSON.stringify(text)});
input.dispatchEvent(new Event('input',{bubbles:true}));
input.focus();
return true
})()`, win)
assert.equal(set, true, 'composer textarea must exist')
await waitFor(`!document.querySelector('.cr-input-toolbar__send:not(.cr-send-button--stop)')?.disabled`,
'composer accepts ' + text, win)
}
async function clickComposerSend(win) {
const clicked = await read(`(()=>{
const button=document.querySelector('.cr-input-toolbar__send:not(.cr-send-button--stop)');
if(!button||button.disabled)return false;
button.click();
return true
})()`, win)
assert.equal(clicked, true, 'composer send button must be enabled')
}
async function queueFromUi(win, text, expectedCount) {
await setComposer(win, text)
await clickComposerSend(win)
await waitFor(`document.querySelectorAll('.conversation-message-queue-item').length===${expectedCount}`,
`queue count ${expectedCount} after ${text}`, win)
}
async function screenshot(win, name) {
win.webContents.invalidate()
await pause(120)
const image = await win.webContents.capturePage()
await fs.writeFile(path.join(directory, name + '.png'), image.toPNG())
return !image.isEmpty()
}
async function run() {
setStage('start')
const watchdog = setTimeout(() => {
fsSync.writeFileSync(path.join(directory, 'watchdog.json'), JSON.stringify({
stage,
checks,
queueTimeline,
requests: requests.map(request => ({ index: request.index, userTexts: request.userTexts, completed: request.completed, aborted: request.aborted, answer: request.answer })),
serverEvents
}, null, 2))
console.error('APPEND_E2E_ERROR watchdog timeout at stage ' + stage)
app.exit(1)
}, 150000)
watchdog.unref?.()
fsSync.mkdirSync(workspace, { recursive: true })
fsSync.writeFileSync(path.join(workspace, 'README.md'), 'native append app verification\n', 'utf8')
const server = createServer((req, res) => {
openSockets.add(req.socket)
req.socket.on('close', () => openSockets.delete(req.socket))
let raw = ''
req.on('data', chunk => { raw += chunk })
req.on('end', async () => {
let body = {}
try { body = JSON.parse(raw || '{}') } catch { body = { raw } }
if (!req.url?.includes('/messages')) {
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{}')
return
}
const texts = userTexts(body)
const servedModel = req.url?.includes(modelPathB) ? 'B'
: req.url?.includes(modelPathC) ? 'C'
: req.url?.includes(modelPathA) ? 'A' : 'unknown'
const captured = {
index: requests.length + 1,
url: req.url,
body,
servedModel,
wireModel: typeof body.model === 'string' ? body.model : null,
userTexts: texts,
completed: false,
aborted: false
}
requests.push(captured)
serverEvents.push({ type: 'request', index: captured.index, texts, servedModel })
res.on('close', () => {
if (!captured.completed) {
captured.aborted = true
serverEvents.push({ type: 'aborted', index: captured.index })
}
})
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive'
})
res.flushHeaders()
if (captured.index === 1) await firstResponse
if (res.destroyed) return
const hasA = texts.some(text => text.includes('E2E_APPEND_A'))
const hasB = texts.some(text => text.includes('E2E_APPEND_B'))
const afterSwitch = texts.some(text => text.includes('E2E_AFTER_MODEL_SWITCH'))
const answer = afterSwitch ? 'E2E_ANSWER_AFTER_SWITCH'
: captured.index === 1 ? 'E2E_ANSWER_ONE'
: hasA && hasB ? 'E2E_ANSWER_MERGED' : 'E2E_ANSWER_UNEXPECTED'
captured.completed = true
captured.answer = answer
serverEvents.push({ type: 'response', index: captured.index, answer })
plainText(res, `msg-native-append-${captured.index}`, answer)
})
})
const serverReady = new Promise((resolve, reject) => {
server.once('listening', resolve)
server.once('error', reject)
})
server.listen(appendPort, '127.0.0.1')
const baseUrl = `http://127.0.0.1:${appendPort}`
fsSync.mkdirSync(data, { recursive: true })
fsSync.writeFileSync(path.join(data, 'models.json'), JSON.stringify({
version: 1,
models: [
{
id: modelId,
name: 'Native Append Loopback A',
providerId: 'custom',
model: wireModelA,
baseUrl: baseUrl + modelPathA,
authMode: 'api-key',
apiKeyPlain: 'ycode-loopback-append-key',
protocol: 'anthropic',
supportsToolCall: true,
supportsImages: false,
supportsReasoning: false,
contextWindowTokens: 200000,
maxInputTokens: 180000,
maxOutputTokens: 4096,
temperature: 0,
maxToolRounds: 1
},
{
id: modelBId,
name: 'Native Append Loopback B',
providerId: 'custom',
model: wireModelB,
baseUrl: baseUrl + modelPathB,
authMode: 'api-key',
apiKeyPlain: 'ycode-loopback-append-key',
protocol: 'anthropic',
supportsToolCall: true,
supportsImages: false,
supportsReasoning: false,
contextWindowTokens: 200000,
maxInputTokens: 180000,
maxOutputTokens: 4096,
temperature: 0,
maxToolRounds: 1
},
{
id: modelCId,
name: 'Native Append Loopback C',
providerId: 'custom',
model: wireModelC,
baseUrl: baseUrl + modelPathC,
authMode: 'api-key',
apiKeyPlain: 'ycode-loopback-append-key',
protocol: 'anthropic',
supportsToolCall: true,
supportsImages: false,
supportsReasoning: false,
contextWindowTokens: 200000,
maxInputTokens: 180000,
maxOutputTokens: 4096,
temperature: 0,
maxToolRounds: 1
}
],
selectedModelId: modelId
}, null, 2), 'utf8')
fsSync.writeFileSync(path.join(data, 'config.json'), JSON.stringify({
baseUrl,
model: 'claude-native-append',
temperature: 0,
maxToolRounds: 1,
apiKeyPlain: 'ycode-loopback-append-key',
workspaceRoot: workspace,
preventSleep: false,
autoLaunch: false
}, null, 2), 'utf8')
let main
try {
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await serverReady
await app.whenReady()
for (let index = 0; index < 300; index += 1) {
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
if (main && !main.webContents.isLoading()) break
await pause(25)
}
assert.ok(main, 'main window')
main.webContents.on('console-message', (...args) => {
const event = args[0]
const level = event?.level ?? args[1]
const message = event?.message ?? args[2]
if (level === 'error' || level === 3) rendererErrors.push(String(message))
})
main.setSize(1336, 902)
await waitFor('!!window.ycode', 'preload bridge', main)
await waitFor(`!!document.querySelector('.conversation-list')&&!!document.querySelector('.wb-composer__input')`,
'renderer ready', main)
check('all created windows stay hidden during verification', main.isVisible(), false)
setStage('create-session')
const session = await read(`window.ycode.session.create('Native append E2E', ${JSON.stringify({
cwd: workspace,
modelId,
agentMode: 'craft',
permissionMode: 'default-sandbox'
})})`, main)
assert.ok(session?.id, 'session creation')
await read(`(()=>{
const url=new URL(location.href);
url.searchParams.set('window','task');
url.searchParams.set('taskId',${JSON.stringify(session.id)});
location.href=url.href;
return true
})()`, main).catch(() => undefined)
await waitFor(`document.querySelector('[data-session-id=${JSON.stringify(session.id)}]')?.getAttribute('data-selected')==='true'`,
'created session selected', main)
await waitFor(`!!document.querySelector('.wb-composer__input')`, 'composer after selection', main)
setStage('send-initial')
await setComposer(main, 'E2E_INITIAL')
await clickComposerSend(main)
await waitForMain(() => requests[0], 'first model request')
check('first request reaches the loopback model', requests[0].url.includes('/messages'), true)
setStage('queue-guidance')
await queueFromUi(main, 'E2E_APPEND_A', 1)
await queueFromUi(main, 'E2E_APPEND_B', 2)
const beforeSendNow = await read(`window.ycode.chat.queue.get(${JSON.stringify(session.id)})`, main)
check('two queued guidance messages are visible in the composer', beforeSendNow.items.length, 2)
check('queued guidance retains both texts', beforeSendNow.items.map(item => item.payload.text), ['E2E_APPEND_A', 'E2E_APPEND_B'])
check('queue screenshot captured while the first response is active', await screenshot(main, 'append-queue'), true)
// Both guidance messages are handed to the running turn while the first
// model response is still gated. A row only leaves the queue after the
// native identity is acknowledged, so both rows must still be visible.
setStage('send-now-a')
await clickSendNowForRow(main, 'E2E_APPEND_A')
const afterSendNowA = await waitForRowStatus(main, session.id, beforeSendNow.items[0].id, 'delivering')
queueTimeline.push({ stage: 'after-send-now-a', items: afterSendNowA.items.map(item => ({ id: item.id, text: item.payload.text, status: item.status })) })
check('the first guidance message is handed to the running turn', afterSendNowA.items[0].status, 'delivering')
check('the second guidance message is still queued while the first is delivering',
afterSendNowA.items[1]?.status, 'pending')
setStage('send-now-b')
await clickSendNowForRow(main, 'E2E_APPEND_B')
const afterSendNowB = await waitForRowStatus(main, session.id, beforeSendNow.items[1].id, 'delivering')
queueTimeline.push({ stage: 'after-send-now-b', items: afterSendNowB.items.map(item => ({ id: item.id, text: item.payload.text, status: item.status })) })
check('both guidance messages are delivered to the running turn concurrently',
afterSendNowB.items.map(item => item.status), ['delivering', 'delivering'])
check('no guidance row is removed before the native identity is acknowledged',
afterSendNowB.items.length, 2)
// ---- Model switch while the turn is still running.
// The active run must keep model A; only later requests may use the new choice.
setStage('model-switch-b')
const switchB = await read(`window.ycode.session.updateContext(${JSON.stringify(session.id)}, { modelId: ${JSON.stringify(modelBId)} })`, main)
check('the session accepts model B while a run is active', switchB?.modelId, modelBId)
setStage('model-switch-c')
const switchC = await read(`window.ycode.session.updateContext(${JSON.stringify(session.id)}, { modelId: ${JSON.stringify(modelCId)} })`, main)
check('the session accepts model C while a run is active', switchC?.modelId, modelCId)
const modelSwitchRequests = requests.length
setStage('release-first-response')
releaseFirstResponse()
await waitForMain(() => requests.length >= 2 ? requests[1] : undefined, 'append model request')
setStage('await-queue-drain')
const drained = await waitForMain(async () => {
const snapshot = await readQueue(main, session.id)
return snapshot.items.length === 0 ? snapshot : undefined
}, 'acknowledged guidance leaves the durable queue')
const afterSendNow = drained
await waitFor(`document.querySelector('.chat-thread')?.textContent.includes('E2E_ANSWER_MERGED')`,
'merged answer rendered', main, 800)
await waitForMain(async () => {
const active = await read(`window.ycode.chat.activeRun(${JSON.stringify(session.id)})`, main)
return active === null
}, 'native run completed')
check('final conversation screenshot captured', await screenshot(main, 'append-final'), true)
const messages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main)
const finalQueue = await read(`window.ycode.chat.queue.get(${JSON.stringify(session.id)})`, main)
const users = messages.filter(message => message.role === 'user')
const assistants = messages.filter(message => message.role === 'assistant')
const queuedIds = beforeSendNow.items.map(item => item.id)
const finalText = await read(`document.querySelector('.chat-thread')?.textContent??''`, main)
const secondTexts = requests[1].userTexts
const initialPosition = secondTexts.findIndex(text => text.includes('E2E_INITIAL'))
const aPosition = secondTexts.findIndex(text => text.includes('E2E_APPEND_A'))
const bPosition = secondTexts.findIndex(text => text.includes('E2E_APPEND_B'))
const lastUser = [...requests[1].body.messages].reverse().find(message => message?.role === 'user')
const lastUserText = messageTexts(lastUser).join('\n')
const lastUserBlocks = Array.isArray(lastUser?.content) ? lastUser.content : []
const guidanceBlocks = lastUserBlocks.filter(block => block?.type === 'text' &&
(block.text?.includes('E2E_APPEND_A') || block.text?.includes('E2E_APPEND_B')))
check('first request was not interrupted by queue guidance', requests[0].aborted, false)
check('first request completed before the append request', requests[0].completed, true)
check('the append guidance produced a second model request', requests.length >= 2, true)
check('append request contains the initial message before both guidance messages',
initialPosition >= 0 && aPosition > initialPosition && bPosition > aPosition, true)
check('both guidance messages are merged into one native user content group',
lastUserText.includes('E2E_APPEND_A') && lastUserText.includes('E2E_APPEND_B'), true)
check('the merged guidance arrives as separate text blocks in one user message',
guidanceBlocks.length >= 2, true)
check('the second model request is the append request', requests[1].answer, 'E2E_ANSWER_MERGED')
check('the merged answer is the second assistant turn', assistants.at(-1)?.content?.includes('E2E_ANSWER_MERGED'), true)
check('the initial answer remains in history', assistants.some(message => message.content?.includes('E2E_ANSWER_ONE')), true)
check('both queued user message identities persist in history',
queuedIds.every(id => users.some(message => message.id === id)), true)
check('queued user messages keep their guidance text',
queuedIds.map(id => users.find(message => message.id === id)?.content),
['E2E_APPEND_A', 'E2E_APPEND_B'])
check('both appended messages share the owning run identity',
new Set(users.filter(message => queuedIds.includes(message.id)).map(message => message.requestId)).size, 1)
check('final durable queue is empty', finalQueue.items.length, 0)
check('both guidance messages were acknowledged and removed from the queue',
beforeSendNow.items.every(item => !finalQueue.items.some(entry => entry.id === item.id)), true)
check('final renderer shows both guidance messages and the merged answer',
['E2E_APPEND_A', 'E2E_APPEND_B', 'E2E_ANSWER_MERGED'].every(text => finalText.includes(text)), true)
check('no renderer errors were logged', rendererErrors, [])
// ---- Independent message after the switch must use the last selected model.
setStage('model-switch-followup')
await setComposer(main, 'E2E_AFTER_MODEL_SWITCH')
await clickComposerSend(main)
// The follow-up message is queued while the run is active and dispatched
// afterwards, so wait for the request that actually carries its text
// instead of trusting the request count.
const followUp = await waitForMain(() => requests.find(request =>
request.index > modelSwitchRequests && request.userTexts.some(text => text.includes('E2E_AFTER_MODEL_SWITCH'))),
'independent message after the model switch')
check('the independent message after switching is served by the last selected model',
followUp.servedModel, 'C')
await waitForMain(async () => {
const list = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main)
return list.some(message => message.role === 'assistant' && message.modelId === modelCId &&
message.status === 'done' && String(message.content ?? '').includes('E2E_ANSWER_AFTER_SWITCH')) ? list : undefined
}, 'post-switch assistant persisted')
// The composer must also release the running state before the final captures.
await waitForMain(async () => {
const active = await read(`window.ycode.chat.activeRun(${JSON.stringify(session.id)})`, main)
return active === null
}, 'post-switch run released')
await waitFor(`!document.querySelector('.cr-send-button--stop')&&!document.querySelector('[aria-label="停止"]')`,
'composer stop control released', main)
const switchMessages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main)
const switchAssistants = switchMessages.filter(message => message.role === 'assistant')
const lastAssistant = switchAssistants.at(-1)
check('the post-switch assistant records the last selected model identity',
lastAssistant?.modelId, modelCId)
check('the running turn kept the original model identity',
switchAssistants.find(message => message.content?.includes('E2E_ANSWER_ONE'))?.modelId, modelId)
check('the guidance turn kept the original model identity',
switchAssistants.find(message => message.content?.includes('E2E_ANSWER_MERGED'))?.modelId, modelId)
check('the running turn request was served by model A', requests[0].servedModel, 'A')
check('the guidance turn request was served by model A', requests[1].servedModel, 'A')
check('the post-switch request was served by model C', followUp.servedModel, 'C')
// ---- Dark and narrow captures of the real composer.
setStage('captures')
await read(`(()=>{
const key='ycode:theme';
localStorage.setItem(key,'dark');
window.dispatchEvent(new StorageEvent('storage',{key,newValue:'dark'}));
return true
})()`, main)
// The composer and every other themed surface must follow the shared
// theme state, not just the document element.
await waitFor(`document.querySelector('.chat-pane[data-cr-theme="dark"]')!==null`, 'dark theme applied to the chat pane', main)
check('the dark theme is applied to the real renderer',
await read(`document.querySelector('.chat-pane')?.getAttribute('data-cr-theme')??null`, main), 'dark')
check('dark conversation screenshot captured', await screenshot(main, 'append-final-dark'), true)
main.setSize(720, 900)
await pause(300)
check('narrow composer keeps the permission control visible and unobscured', await read(`(()=>{
const button=document.querySelector('.yc-source-permission-trigger .cr-permission-setting');
if(!button)return false;
const box=button.getBoundingClientRect();
const hit=document.elementFromPoint(box.x+box.width/2,box.y+box.height/2);
return box.width>0&&box.height>0&&box.x>=0&&box.right<=innerWidth&&!!hit&&button.contains(hit);
})()`, main), true)
check('narrow conversation screenshot captured', await screenshot(main, 'append-final-narrow'), true)
main.setSize(1336, 902)
await pause(200)
const report = {
result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
scope: 'Production out/main, out/preload, and out/renderer; isolated Electron userData/home; local loopback Anthropic SSE; real Claude SDK/CLI; two UI-queued guidance messages sent through chat.queue.sendNow while the first response was active.',
sessionId: session.id,
requests: requests.map(request => ({
index: request.index,
url: request.url,
servedModel: request.servedModel,
wireModel: request.wireModel,
userTexts: request.userTexts,
messageRoles: Array.isArray(request.body.messages) ? request.body.messages.map(message => message?.role) : [],
completed: request.completed,
aborted: request.aborted,
answer: request.answer
})),
beforeSendNow,
afterSendNow,
queueTimeline,
finalQueue,
messages,
switchMessages,
serverEvents,
checks,
rendererErrors,
screenshots: ['append-queue.png', 'append-final.png', 'append-final-dark.png', 'append-final-narrow.png'],
limitations: [
'Native result user_message_uuids are verified indirectly through persisted guidance identities, merged request content, the second assistant segment, and an empty queue.',
'The loopback server returns plain text only; tool calls, images, cancellation, and upstream provider failures are outside this E2E scope.'
]
}
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('APPEND_E2E_' + report.result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) {
console.log('APPEND_E2E_FAILURE ' + JSON.stringify(failure))
}
clearTimeout(watchdog)
app.exit(report.result === 'PASS' ? 0 : 1)
} finally {
clearTimeout(watchdog)
hidingWindows = false
// A gated SSE response keeps the socket open, so close() would wait for
// it. Destroy the sockets explicitly instead of hanging the harness.
for (const socket of openSockets) socket.destroy()
openSockets.clear()
await new Promise(resolve => server.close(() => resolve()))
}
}
run().catch(async error => {
hidingWindows = false
try {
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({
stage,
error: String(error),
stack: error.stack,
checks,
queueTimeline,
requests: requests.map(request => ({
index: request.index,
userTexts: request.userTexts,
completed: request.completed,
aborted: request.aborted,
answer: request.answer
})),
serverEvents,
rendererErrors
}, null, 2))
} catch { /* The failure report is best effort. */ }
console.error('APPEND_E2E_ERROR', error)
app.exit(1)
})
}
+151
View File
@@ -0,0 +1,151 @@
// Production app + real GitHub package, isolated from the user's skill registry.
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/recommended-app-'))
await Promise.all(['data', 'home', 'workspace'].map(name => fs.mkdir(path.join(directory, name))))
const now = Date.now()
await fs.writeFile(path.join(directory, 'data', 'sessions.json'), JSON.stringify({ sessions: [{
id: 'skill-existing', title: 'Skill existing draft', cwd: path.join(directory, 'workspace'),
createdAt: now, updatedAt: now, messages: []
}] }))
const env = { ...process.env, YCODE_QA_DIRECTORY: directory,
YCODE_USER_DATA_DIR: path.join(directory, 'data'), HOME: path.join(directory, 'home'),
USERPROFILE: path.join(directory, 'home'), YCODE_QA_OFFSCREEN: '1',
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
delete env.ELECTRON_RUN_AS_NODE
delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'),
['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', value => { output += value; if (String(value).includes('RECOMMENDED_APP_')) process.stdout.write(value) })
child.stderr.on('data', value => { output += value })
const timer = setTimeout(() => child.kill(), 200000)
child.once('exit', async code => {
clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code }))
if (code !== 0) console.error(output.slice(-7000))
process.exitCode = code === 0 ? 0 : 1
})
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow } = require('electron')
const { pathToFileURL } = require('node:url')
const directory = path.resolve(process.env.YCODE_QA_DIRECTORY)
const rel = path.relative(path.resolve('.tmp'), directory)
assert.ok(rel.startsWith('recommended-app-') && !rel.includes('..'))
const checks = [], errors = []
let stage = 'boot', win
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const read = source => win.webContents.executeJavaScript(source)
const check = (label, value) => { assert.ok(value, label); checks.push(label) }
async function click(selector) {
const point = await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing target');e.scrollIntoView({block:'nearest'});const r=e.getBoundingClientRect();const x=Math.round(r.x+r.width/2),y=Math.round(r.y+r.height/2);const hit=document.elementFromPoint(x,y);if(!e.contains(hit))throw Error('Target is covered: '+${JSON.stringify(selector)});return{x,y}})()`)
win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 })
await pause(100)
}
async function wait(source, label, timeout = 15000) {
const end = Date.now() + timeout
while (Date.now() < end) { const value = await read(source); if (value) return value; await pause(80) }
throw Error('Timeout: ' + label)
}
async function shot(name) {
win.webContents.invalidate()
await win.webContents.capturePage()
await pause(250)
await fs.writeFile(path.join(directory, name + '.png'), (await win.webContents.capturePage()).toPNG())
}
app.on('browser-window-created', (_event, window) => {
window.setSkipTaskbar(true)
window.on('show', () => window.hide())
window.webContents.on('console-message', (event) => { if (event.level === 'error') errors.push(event.message) })
})
;(async () => {
try {
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await app.whenReady()
for (let index = 0; index < 200; index++) {
win = BrowserWindow.getAllWindows().find(window => window.getTitle() === 'YCode')
if (win && !win.webContents.isLoading()) break
await pause(30)
}
check('production window loaded', !!win)
await wait('!!window.ycode?.skills?.recommendations', 'real preload')
await wait('!!document.querySelector("[data-session-id=skill-existing]")', 'original session')
await click('[data-session-id=skill-existing]')
await wait('!!document.querySelector(".chat-pane .wb-composer textarea")', 'initial composer')
await read('document.querySelector(".chat-pane .wb-composer textarea").focus()')
await win.webContents.insertText('KEEP_ORIGINAL_DRAFT')
const catalog = await read('window.ycode.skills.recommendations()')
check('fixed Archify recommendation is exposed by real IPC', catalog.some(item => item.id === 'archify' && item.commit === '25cf37080fc8b1dbe758a4efc3935b55f3327083'))
await read(`(()=>{window.__installEvents=[];window.ycode.skills.onInstallProgress(event=>window.__installEvents.push(event));window.__skillChanges=0;window.ycode.skills.onChanged(()=>window.__skillChanges++);window.dispatchEvent(new CustomEvent('ycode:open-capabilities',{detail:{tab:'skills'}}));return true})()`)
await wait('!!document.querySelector("[data-recommendation-id=archify]")', 'recommendation card')
await shot('recommendation')
stage = 'install'
// Use the real card and confirmation; no direct import and no picker stub.
await click('[data-recommendation-id=archify] [aria-label="安装 archify"]')
await wait('!!document.querySelector("[aria-label=确认安装推荐技能]")', 'install confirmation')
check('the modal host escapes the isolated capability center', await read('document.querySelector(".yc-skill-modal-host")?.parentElement===document.body'))
check('the background root is inert while the skill modal is open', await read('document.getElementById("root").inert'))
check('sidebar sticky headers are behind the global mask', await read(`(()=>{const e=document.querySelector('.conversation-section-label');if(!e)return false;const r=e.getBoundingClientRect();return !!document.elementFromPoint(r.left+5,r.top+5)?.closest('.yc-skill-modal-host')})()`))
await read('window.__sameDetail=document.querySelector(".yc-skill-detail-shell")')
await shot('confirm-full-window')
await read('document.documentElement.dataset.theme="dark"')
await pause(350); await shot('confirm-dark')
const originalSize = win.getSize()
win.setSize(720, 900); await pause(350)
check('the full-window skill detail fits a narrow viewport', await read('(()=>{const r=document.querySelector(".yc-skill-detail-shell").getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight})()'))
await shot('confirm-narrow')
win.setSize(...originalSize); await read('document.documentElement.dataset.theme="light"'); await pause(300)
await click('[aria-label=确认安装推荐技能]')
await wait(`window.__installEvents.some(event=>['installed','failed','cancelled'].includes(event.phase))`, 'native installation', 150000)
const progress = await read('window.__installEvents')
check('actual download, validation and commit phases are observed', ['downloading', 'validating', 'installing', 'installed'].every(phase => progress.some(event => event.phase === phase)))
const installed = await read('window.ycode.skills.list()')
const skill = installed.find(item => item.name === 'archify')
check('installed skill is enabled, intact and has source provenance', skill?.enabled && !skill.userModified && skill.origin?.commit === catalog[0].commit)
const document = await read(`window.ycode.skills.read(${JSON.stringify(skill.id)})`)
check('skill body and packaged CLI resources are present', document.content.includes('Archify') && document.files.includes('bin/archify.mjs') && document.files.length > 100)
check('committed mutation broadcasts a skill refresh', await read('window.__skillChanges>0'))
await wait(`document.querySelector('[data-recommendation-id=archify]')?.textContent.includes('已安装')`, 'installed card refresh')
check('installation keeps the same detail DOM mounted', await read('window.__sameDetail===document.querySelector(".yc-skill-detail-shell")'))
await wait('document.querySelector("[role=dialog] .sm-skill-detail-action-row")?.textContent.includes("去使用")', 'post-install action')
check('the avatar is a packaged offline image', await read('(()=>{const i=document.querySelector("[role=dialog] .sm-skill-detail-icon-wrap img");return i?.complete&&i.naturalWidth>0&&!i.src.startsWith("http")})()'))
await shot('installed')
stage = 'idempotency'
const again = await read(`window.ycode.skills.installRecommended('archify',{operationId:crypto.randomUUID()})`)
check('same unchanged version returns existing install', again.id === skill.id && again.origin.installedAt === skill.origin.installedAt)
await read(`window.ycode.skills.setEnabled(${JSON.stringify(skill.id)},false)`)
const disabled = await read(`window.ycode.skills.installRecommended('archify',{operationId:crypto.randomUUID()})`)
check('idempotent install does not silently re-enable disabled skill', disabled.enabled === false)
await read(`window.ycode.skills.setEnabled(${JSON.stringify(skill.id)},true)`)
stage = 'use-skill'
await wait(`!!document.querySelector('[role=dialog] [aria-label="使用推荐技能 archify"]')`, 'use installed recommendation')
await click('[role=dialog] [aria-label="使用推荐技能 archify"]')
await wait(`document.querySelector('.chat-pane .wb-composer')?.textContent.includes('archify')`, 'skill reference in the real composer')
check('using the installed recommendation fills the real composer', true)
check('use opens a clean new draft instead of overwriting the old draft', await read('!document.querySelector(".chat-pane .wb-composer").textContent.includes("KEEP_ORIGINAL_DRAFT")'))
check('the background is no longer inert after use', await read('!document.getElementById("root").inert'))
await shot('use-skill')
check('browsing, installation and use do not create persisted chat sessions', (await read('window.ycode.session.list()')).length === 1)
await click('[data-session-id=skill-existing]')
await wait('document.querySelector(".chat-pane .wb-composer textarea")?.value.includes("KEEP_ORIGINAL_DRAFT")', 'original session draft restored')
check('the original session draft remains intact after using the skill', true)
check('no renderer errors', errors.length === 0)
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, progress, skill, files: document.files,
errors, limits: 'Real fixed GitHub package and production IPC; no skill commands or model requests executed.' }, null, 2))
console.log('RECOMMENDED_APP_PASS ' + directory)
app.quit()
} catch (error) {
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ stage, error: String(error.stack ?? error), checks, errors }, null, 2))
if (win) await shot('failure').catch(() => {})
console.error('RECOMMENDED_APP_FAIL ' + String(error))
app.exit(1)
}
})()
}
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env node
'use strict'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const fsp = require('node:fs/promises')
const path = require('node:path')
const { execFileSync, spawnSync } = require('node:child_process')
const ROOT = path.resolve(__dirname, '..')
const REGISTRY_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\8a4ee941-d893-569b-8bcb-2a55d270c04b'
const STAMP = new Date().toISOString().replace(/[:.]/g, '-')
const RUN_ROOT = path.join(ROOT, '.tmp', 'updates-installer-marker', STAMP)
const LATEST_REPORT = path.join(ROOT, '.tmp', 'updates-installer-marker', 'latest-report.json')
function parseRegistryValue(output, name) {
const expression = new RegExp(`^\\s*${name}\\s+REG_\\w+\\s+(.+?)\\s*$`, 'mi')
const match = expression.exec(output)
return match ? match[1].replace(/^"(.*)"$/, '$1') : null
}
function queryRegistryValue(name) {
const output = execFileSync('reg.exe', ['query', REGISTRY_KEY, '/v', name], {
encoding: 'utf8',
windowsHide: true,
timeout: 10_000,
maxBuffer: 64 * 1024
})
return parseRegistryValue(output, name)
}
function registryExecutablePath(value) {
const quoted = /^\s*"([^"]+\.exe)"/i.exec(value)
if (quoted) return quoted[1]
const plain = /^\s*(.+?\.exe)(?:\s|$|,)/i.exec(value)
return plain ? plain[1] : null
}
function relativeImport(from, to) {
return `./${path.relative(path.dirname(from), to).replace(/\\/g, '/')}`
}
function createRunnerSource(filename) {
const installer = path.join(ROOT, 'src', 'main', 'updates', 'installer.ts')
const physical = path.join(ROOT, 'src', 'main', 'updates', 'physical-files.ts')
return `
import { app } from 'electron'
import fsPromises from 'node:fs/promises'
import path from 'node:path'
import { createNsisInstaller, queryNsisRegistryValue } from ${JSON.stringify(relativeImport(filename, installer))}
import { physicalRealPath, physicalStat } from ${JSON.stringify(relativeImport(filename, physical))}
function decodeConfig() {
return JSON.parse(Buffer.from(process.env.YCODE_MARKER_CONFIG || '', 'base64').toString('utf8'))
}
async function captureError(operation) {
try {
await operation()
return null
} catch (error) {
return {
code: error && typeof error === 'object' && 'code' in error ? error.code : null,
message: error instanceof Error ? error.message : String(error)
}
}
}
async function main() {
const config = decodeConfig()
const patched = await fsPromises.stat(config.appPath)
const physicalAsar = await physicalStat(config.appPath)
const physicalResources = await physicalStat(path.join(config.installationDirectory, 'resources'))
const validAdapter = createNsisInstaller({
platform: 'win32',
isPackaged: true,
execPath: config.executablePath,
appPath: config.appPath,
cacheDirectory: config.cacheDirectory,
resolveRealPath: physicalRealPath,
readStat: physicalStat,
queryRegistryValue: queryNsisRegistryValue
})
const environment = await validAdapter.validateEnvironment(1)
const directoryFixtureAdapter = createNsisInstaller({
platform: 'win32',
isPackaged: true,
execPath: config.directoryFixture.executablePath,
appPath: config.directoryFixture.appPath,
cacheDirectory: config.cacheDirectory,
resolveRealPath: physicalRealPath,
readStat: physicalStat,
queryRegistryValue: async name => {
if (name === 'UninstallString') return '"' + config.directoryFixture.uninstallerPath + '" /currentuser'
if (name === 'DisplayIcon') return config.directoryFixture.executablePath + ',0'
return null
}
})
const directoryError = await captureError(() => directoryFixtureAdapter.validateEnvironment(0))
const wrongRegistryAdapter = createNsisInstaller({
platform: 'win32',
isPackaged: true,
execPath: config.executablePath,
appPath: config.appPath,
cacheDirectory: config.cacheDirectory,
resolveRealPath: physicalRealPath,
readStat: physicalStat,
queryRegistryValue: async name => {
if (name === 'UninstallString') return '"C:\\\\Wrong\\\\Uninstall YCode.exe" /currentuser'
if (name === 'DisplayIcon') return config.executablePath + ',0'
return null
}
})
const wrongRegistryError = await captureError(() => wrongRegistryAdapter.validateEnvironment(0))
const result = {
electronVersion: process.versions.electron,
nodeVersion: process.versions.node,
installationDirectory: environment.installationDirectory,
executablePath: environment.executablePath,
appPath: config.appPath,
patchedFs: { isFile: patched.isFile(), isDirectory: patched.isDirectory() },
physicalAsar: { isFile: physicalAsar.isFile(), isDirectory: physicalAsar.isDirectory() },
physicalResources: { isFile: physicalResources.isFile(), isDirectory: physicalResources.isDirectory() },
directoryError,
wrongRegistryError,
installerLaunched: false
}
console.log('YCODE_MARKER_RESULT=' + Buffer.from(JSON.stringify(result)).toString('base64'))
app.exit(0)
}
main().catch(error => {
console.error(error)
app.exit(1)
})
`
}
async function main() {
if (process.platform !== 'win32') throw new Error('Installer marker verification requires Windows')
await fsp.mkdir(RUN_ROOT, { recursive: true })
const uninstallString = queryRegistryValue('UninstallString')
const displayIcon = queryRegistryValue('DisplayIcon')
const displayVersion = queryRegistryValue('DisplayVersion')
const uninstallerPath = uninstallString ? registryExecutablePath(uninstallString) : null
const executablePath = displayIcon
? registryExecutablePath(displayIcon.replace(/,\s*-?\d+\s*$/, ''))
: null
if (!uninstallerPath || !executablePath) throw new Error('Current HKCU YCode registry paths are incomplete')
const installationDirectory = path.dirname(executablePath)
if (path.dirname(uninstallerPath).toLowerCase() !== installationDirectory.toLowerCase()) {
throw new Error('Current HKCU uninstaller and icon paths disagree')
}
const appPath = path.join(installationDirectory, 'resources', 'app.asar')
const cacheDirectory = path.join(RUN_ROOT, 'cache')
await fsp.mkdir(cacheDirectory, { recursive: true })
const directoryFixtureRoot = path.join(RUN_ROOT, 'directory-marker-fixture')
const directoryFixtureResources = path.join(directoryFixtureRoot, 'resources')
await fsp.mkdir(path.join(directoryFixtureResources, 'app.asar'), { recursive: true })
const directoryFixtureExe = path.join(directoryFixtureRoot, 'YCode.exe')
const directoryFixtureUninstaller = path.join(directoryFixtureRoot, 'Uninstall YCode.exe')
await fsp.writeFile(directoryFixtureExe, 'fixture')
await fsp.writeFile(directoryFixtureUninstaller, 'fixture')
const runnerEntry = path.join(RUN_ROOT, 'runner-entry.ts')
const runnerOutput = path.join(RUN_ROOT, 'runner.cjs')
await fsp.writeFile(runnerEntry, createRunnerSource(runnerEntry), 'utf8')
const esbuild = path.join(ROOT, 'node_modules', 'esbuild', 'bin', 'esbuild')
execFileSync(process.execPath, [
esbuild,
runnerEntry,
'--bundle',
'--platform=node',
'--format=cjs',
'--external:electron',
`--outfile=${runnerOutput}`,
'--log-level=warning'
], { cwd: ROOT, stdio: 'inherit' })
const electronBinary = require('electron')
const config = {
installationDirectory,
executablePath,
appPath,
cacheDirectory,
directoryFixture: {
executablePath: directoryFixtureExe,
uninstallerPath: directoryFixtureUninstaller,
appPath: path.join(directoryFixtureResources, 'app.asar')
}
}
const env = {
...process.env,
YCODE_MARKER_CONFIG: Buffer.from(JSON.stringify(config)).toString('base64')
}
delete env.ELECTRON_RUN_AS_NODE
delete env.ELECTRON_NO_ASAR
const child = spawnSync(electronBinary, [runnerOutput], {
cwd: RUN_ROOT,
env,
encoding: 'utf8',
windowsHide: true,
timeout: 45_000,
maxBuffer: 4 * 1024 * 1024
})
if (child.error) throw child.error
if (child.status !== 0) {
throw new Error(`Electron marker runner failed (${child.status}): ${child.stderr || child.stdout}`)
}
const marker = child.stdout.split(/\r?\n/).find(line => line.startsWith('YCODE_MARKER_RESULT='))
if (!marker) throw new Error(`Electron marker runner returned no result: ${child.stderr || child.stdout}`)
const result = JSON.parse(Buffer.from(marker.slice('YCODE_MARKER_RESULT='.length), 'base64').toString('utf8'))
assert.equal(result.installationDirectory.toLowerCase(), installationDirectory.toLowerCase())
assert.equal(result.patchedFs.isFile, false)
assert.equal(result.patchedFs.isDirectory, true)
assert.equal(result.physicalAsar.isFile, true)
assert.equal(result.physicalAsar.isDirectory, false)
assert.equal(result.physicalResources.isFile, false)
assert.equal(result.physicalResources.isDirectory, true)
assert.equal(result.directoryError?.code, 'installer-marker')
assert.match(result.directoryError?.message || '', /app\.asar/)
assert.equal(result.wrongRegistryError?.code, 'installer-registry')
assert.equal(result.installerLaunched, false)
const report = {
schemaVersion: 1,
passed: true,
registryDisplayVersion: displayVersion,
uninstallerPath,
executablePath,
appPath,
runnerOutput,
result
}
const reportPath = path.join(RUN_ROOT, 'report.json')
await fsp.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
await fsp.mkdir(path.dirname(LATEST_REPORT), { recursive: true })
await fsp.writeFile(LATEST_REPORT, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
console.log(`PASS installer marker: patched fs directory, original-fs file, directory and registry rejection`)
console.log(`report: ${reportPath}`)
}
main().catch(error => {
console.error(error)
process.exitCode = 1
})
+666
View File
@@ -0,0 +1,666 @@
#!/usr/bin/env node
'use strict'
const assert = require('node:assert/strict')
const crypto = require('node:crypto')
const fs = require('node:fs')
const fsp = require('node:fs/promises')
const http = require('node:http')
const net = require('node:net')
const os = require('node:os')
const path = require('node:path')
const { execFileSync, spawn } = require('node:child_process')
const ROOT = path.resolve(__dirname, '..')
const MANIFEST_URL = 'http://192.168.10.6/ycode/releases/latest.json'
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
function readExpectedVersion() {
const args = process.argv.slice(2)
let value = null
for (let index = 0; index < args.length; index += 1) {
const argument = args[index]
if (argument === '--expected-version') {
value = args[++index]
} else if (argument.startsWith('--expected-version=')) {
value = argument.slice('--expected-version='.length)
} else {
throw new Error(`Unknown argument: ${argument}`)
}
}
if (value === null) value = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
if (typeof value !== 'string' || !SEMVER_RE.test(value)) throw new Error(`Invalid expected version: ${value}`)
return value
}
const EXPECTED_VERSION = readExpectedVersion()
const PACKAGED_EXE = path.resolve(process.env.YCODE_QA_PACKAGED_EXE || path.join(ROOT, 'release', 'win-unpacked', 'YCode.exe'))
const PUBLIC_KEY = path.join(ROOT, 'resources', 'updates', 'public-key.pem')
const OUTPUT_ROOT = path.join(ROOT, '.tmp', 'updates-live')
const STAMP = new Date().toISOString().replace(/[:.]/g, '-')
const RUN_ROOT = path.join(OUTPUT_ROOT, STAMP)
const REPORT_PATH = path.join(RUN_ROOT, 'report.json')
const LATEST_REPORT_PATH = path.join(OUTPUT_ROOT, 'latest-report.json')
const SCREENSHOT_PATH = path.join(RUN_ROOT, 'production-boot.png')
const REGISTRY_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\8a4ee941-d893-569b-8bcb-2a55d270c04b'
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
let activeBootChild = null
async function handleInterrupt(signal) {
await stopExactChild(activeBootChild)
process.exit(signal === 'SIGINT' ? 130 : 143)
}
process.once('SIGINT', () => void handleInterrupt('SIGINT'))
process.once('SIGTERM', () => void handleInterrupt('SIGTERM'))
function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex')
}
async function sha256File(filename) {
const hash = crypto.createHash('sha256')
for await (const chunk of fs.createReadStream(filename)) hash.update(chunk)
return hash.digest('hex')
}
async function writeJsonAtomic(filename, value) {
await fsp.mkdir(path.dirname(filename), { recursive: true })
const temporary = `${filename}.${process.pid}.tmp`
await fsp.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
await fsp.rename(temporary, filename)
}
function errorDetails(error) {
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack }
return { name: 'Error', message: String(error) }
}
async function fetchBytes(url, maximum = 1024 * 1024, timeoutMs = 30_000) {
let current = new URL(url)
const origin = current.origin
for (let redirects = 0; redirects <= 5; redirects += 1) {
const response = await fetch(current, {
redirect: 'manual',
cache: 'no-store',
signal: AbortSignal.timeout(timeoutMs)
})
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location')
await response.body?.cancel().catch(() => undefined)
if (!location || redirects === 5) throw new Error('Manifest redirect chain is invalid')
const next = new URL(location, current)
if (next.origin !== origin) throw new Error('Manifest redirect left the configured origin')
current = next
continue
}
if (!response.ok) throw new Error(`Manifest request failed with HTTP ${response.status}`)
const declared = Number(response.headers.get('content-length') || 0)
if (declared > maximum) throw new Error('Manifest response exceeds the size limit')
const bytes = Buffer.from(await response.arrayBuffer())
if (bytes.length > maximum) throw new Error('Manifest response exceeds the size limit')
return bytes
}
throw new Error('Manifest redirect chain is invalid')
}
async function reserveLoopbackPort() {
const server = net.createServer()
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
if (!address || typeof address === 'string') throw new Error('Unable to reserve a debug port')
return address.port
}
function getJson(url, timeoutMs = 2_000) {
return new Promise((resolve, reject) => {
const request = http.get(url, { timeout: timeoutMs, family: 4 }, response => {
const chunks = []
response.on('data', chunk => chunks.push(chunk))
response.on('end', () => {
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode} from ${url}`))
return
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
} catch (error) {
reject(error)
}
})
})
request.on('timeout', () => request.destroy(new Error('CDP endpoint timed out')))
request.on('error', reject)
})
}
async function waitForCdpPage(port, child) {
const deadline = Date.now() + 45_000
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`Packaged app exited early with code ${child.exitCode}`)
try {
const targets = await getJson(`http://127.0.0.1:${port}/json/list`)
const page = targets.find(target => target.type === 'page'
&& typeof target.webSocketDebuggerUrl === 'string'
&& (/\/index\.html(?:[?#]|$)/.test(target.url) || /\/$/.test(target.url)))
if (page) return page
} catch {
// Chromium's endpoint is not ready yet.
}
await sleep(250)
}
throw new Error('Timed out waiting for the packaged app renderer target')
}
function openWebSocket(url) {
const WebSocketCtor = globalThis.WebSocket || require('ws')
return new Promise((resolve, reject) => {
const socket = new WebSocketCtor(url)
const timer = setTimeout(() => reject(new Error('CDP WebSocket open timed out')), 15_000)
const cleanup = () => {
clearTimeout(timer)
socket.removeEventListener?.('open', onOpen)
socket.removeEventListener?.('error', onError)
}
const onOpen = () => {
cleanup()
resolve(socket)
}
const onError = error => {
cleanup()
reject(error instanceof Error ? error : new Error('CDP WebSocket failed'))
}
socket.addEventListener('open', onOpen)
socket.addEventListener('error', onError)
})
}
class CdpClient {
constructor(socket) {
this.socket = socket
this.nextId = 1
this.pending = new Map()
socket.addEventListener('message', event => {
let message
try {
const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8')
message = JSON.parse(text)
} catch {
return
}
if (!message.id) return
const pending = this.pending.get(message.id)
if (!pending) return
this.pending.delete(message.id)
clearTimeout(pending.timer)
if (message.error) pending.reject(new Error(`${message.error.message || 'CDP error'} (${message.error.code})`))
else pending.resolve(message.result)
})
socket.addEventListener('close', () => {
for (const pending of this.pending.values()) {
clearTimeout(pending.timer)
pending.reject(new Error('CDP WebSocket closed'))
}
this.pending.clear()
})
}
static async connect(url) {
return new CdpClient(await openWebSocket(url))
}
request(method, params = {}, timeoutMs = 30_000) {
const id = this.nextId
this.nextId += 1
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id)
reject(new Error(`CDP request timed out: ${method}`))
}, timeoutMs)
this.pending.set(id, { resolve, reject, timer })
this.socket.send(JSON.stringify({ id, method, params }))
})
}
async evaluate(expression) {
const result = await this.request('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
userGesture: false
})
if (result.exceptionDetails) {
const description = result.exceptionDetails.exception?.description
|| result.exceptionDetails.text
|| 'Renderer evaluation failed'
throw new Error(description)
}
return result.result?.value
}
close() {
try {
this.socket.close()
} catch {
// The renderer may already be gone.
}
}
}
async function waitForUpdatesApi(client, child) {
const deadline = Date.now() + 30_000
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`Packaged app exited early with code ${child.exitCode}`)
try {
const ready = await client.evaluate(
"typeof window.ycode?.updates?.getState === 'function' && document.readyState === 'complete'"
)
if (ready === true) return
} catch {
// React/preload may still be starting.
}
await sleep(250)
}
throw new Error('Timed out waiting for window.ycode.updates')
}
const UPDATER_INSPECTION_EXPRESSION = `(async () => {
const api = window.ycode?.updates
if (!api) throw new Error('window.ycode.updates is unavailable')
const apiMethods = ['getState', 'check', 'download', 'install', 'dismiss', 'openDownloadPage', 'openLog', 'onState']
.filter(name => typeof api[name] === 'function')
const before = await api.getState()
const events = []
const unsubscribe = api.onState(state => events.push(state))
let after
try {
after = await api.check()
const deadline = Date.now() + 15000
while (Date.now() < deadline) {
if (events.some(state => state.status === 'up-to-date')) break
if (events.some(state => state.status === 'error' || state.status === 'unsupported')) break
await new Promise(resolve => setTimeout(resolve, 100))
}
await new Promise(resolve => setTimeout(resolve, 500))
} finally {
unsubscribe()
}
return { apiMethods, before, after, events }
})()`
async function captureScreenshot(client, filename) {
await client.request('Page.enable')
const screenshot = await client.request('Page.captureScreenshot', {
format: 'png',
fromSurface: true,
captureBeyondViewport: false
})
await fsp.writeFile(filename, Buffer.from(screenshot.data, 'base64'))
}
async function stopExactChild(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) return
const exited = new Promise(resolve => child.once('exit', resolve))
child.kill()
await Promise.race([exited, sleep(5_000)])
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL')
await Promise.race([exited, sleep(2_000)])
}
}
async function runProductionBoot(expectedManifest) {
if (!fs.existsSync(PACKAGED_EXE)) throw new Error(`Packaged executable is missing: ${PACKAGED_EXE}`)
const port = await reserveLoopbackPort()
const userData = path.join(RUN_ROOT, 'production-boot-user-data')
await fsp.mkdir(userData, { recursive: true })
const stdoutPath = path.join(RUN_ROOT, 'production-boot.stdout.log')
const stderrPath = path.join(RUN_ROOT, 'production-boot.stderr.log')
const stdout = fs.createWriteStream(stdoutPath, { flags: 'w' })
const stderr = fs.createWriteStream(stderrPath, { flags: 'w' })
const env = {
...process.env,
YCODE_USER_DATA_DIR: userData,
YCODE_SKIP_KERNEL_DATA_RESET: '1',
YCODE_QA_OFFSCREEN: '1'
}
delete env.ELECTRON_RENDERER_URL
delete env.YCODE_QA_DIRECTORY
for (const key of ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'YCODE_API_KEY', 'CODEBUDDY_API_KEY']) delete env[key]
const child = spawn(PACKAGED_EXE, [
`--remote-debugging-port=${port}`,
'--remote-debugging-address=127.0.0.1'
], {
cwd: RUN_ROOT,
env,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe']
})
child.stdout.pipe(stdout)
child.stderr.pipe(stderr)
activeBootChild = child
let client
let inspection
let screenshotWritten = false
try {
if (!child.pid) throw new Error('Packaged app did not expose a child PID')
const page = await waitForCdpPage(port, child)
client = await CdpClient.connect(page.webSocketDebuggerUrl)
await waitForUpdatesApi(client, child)
inspection = await client.evaluate(UPDATER_INSPECTION_EXPRESSION)
await captureScreenshot(client, SCREENSHOT_PATH)
screenshotWritten = true
assert.equal(inspection.before.currentVersion, EXPECTED_VERSION)
assert.equal(inspection.after.currentVersion, EXPECTED_VERSION)
assert.equal(inspection.after.status, 'up-to-date')
assert.equal(inspection.after.lastExplicit, true)
assert.equal(inspection.apiMethods.length, 8)
assert.equal(inspection.events.some(state => state.status === 'checking'), true)
assert.equal(inspection.events.some(state => state.status === 'up-to-date'), true)
assert.equal(inspection.events.some(state => ['downloading', 'ready', 'installing'].includes(state.status)), false)
assert.equal(inspection.events.every(state => state.currentVersion === EXPECTED_VERSION), true)
return {
mode: 'actual-production-packaged-boot',
executable: PACKAGED_EXE,
childPid: child.pid,
debugPort: port,
isolatedUserData: userData,
expectedServerVersion: expectedManifest.version,
apiMethods: inspection.apiMethods,
before: inspection.before,
after: inspection.after,
sharedIpcEvents: inspection.events,
screenshot: screenshotWritten ? SCREENSHOT_PATH : null,
stdoutLog: stdoutPath,
stderrLog: stderrPath,
installerOrUpgradeRequested: false,
passed: true
}
} finally {
client?.close()
await stopExactChild(child)
if (activeBootChild === child) activeBootChild = null
stdout.end()
stderr.end()
}
}
function createRuntimeEntry(filename) {
const relative = value => `./${path.relative(path.dirname(filename), value).replace(/\\/g, '/')}`
return [
`export { UpdateManager } from ${JSON.stringify(relative(path.join(ROOT, 'src/main/updates/manager.ts')))}`,
`export { FileUpdatePersistence } from ${JSON.stringify(relative(path.join(ROOT, 'src/main/updates/storage.ts')))}`,
`export { verifySignedManifest } from ${JSON.stringify(relative(path.join(ROOT, 'src/main/updates/manifest.ts')))}`,
''
].join('\n')
}
function bundleActualManager(runtimeDirectory) {
fs.mkdirSync(runtimeDirectory, { recursive: true })
const entry = path.join(runtimeDirectory, 'entry.ts')
const output = path.join(runtimeDirectory, 'updater-runtime.cjs')
fs.writeFileSync(entry, createRuntimeEntry(entry), 'utf8')
const esbuild = path.join(ROOT, 'node_modules', 'esbuild', 'bin', 'esbuild')
if (!fs.existsSync(esbuild)) throw new Error(`esbuild is missing: ${esbuild}`)
execFileSync(process.execPath, [
esbuild,
entry,
'--bundle',
'--platform=node',
'--format=cjs',
'--target=node22',
`--outfile=${output}`,
'--log-level=warning'
], { cwd: ROOT, stdio: 'inherit' })
return { entry, output, runtime: require(output) }
}
function forbiddenInstaller() {
const calls = { validateEnvironment: 0, verifyInstaller: 0, launch: 0 }
const fail = name => {
calls[name] += 1
throw new Error('Live verification is download-only; installer access is forbidden')
}
return {
calls,
adapter: {
validateEnvironment: async () => fail('validateEnvironment'),
verifyInstaller: async () => fail('verifyInstaller'),
launch: async () => fail('launch')
}
}
}
async function runSimulatedPredecessorDownload(expectedManifest, runtime, publicKeyPem) {
const directory = path.join(RUN_ROOT, 'simulated-predecessor')
const cacheDirectory = path.join(directory, 'cache')
const persistence = new runtime.FileUpdatePersistence(path.join(directory, 'state.json'))
const forbidden = forbiddenInstaller()
const callbacks = { beforeInstall: 0, quitForUpdate: 0, installFailed: 0 }
const events = []
const manager = new runtime.UpdateManager({
currentVersion: '0.0.1',
manifestUrl: MANIFEST_URL,
cacheDirectory,
logFile: path.join(directory, 'updater.log'),
persistence,
publicKey: async () => publicKeyPem,
supported: true,
installer: forbidden.adapter,
beforeInstall: async () => {
callbacks.beforeInstall += 1
throw new Error('beforeInstall is forbidden in live download verification')
},
quitForUpdate: async () => {
callbacks.quitForUpdate += 1
throw new Error('quitForUpdate is forbidden in live download verification')
},
installFailed: () => {
callbacks.installFailed += 1
},
autoDownload: false,
manifestTimeoutMs: 60_000,
downloadTimeoutMs: 20 * 60_000
})
let lastLoggedProgress = -1
const unsubscribe = manager.subscribe(state => {
events.push(state)
if (state.status === 'downloading' && typeof state.progress === 'number') {
const bucket = Math.floor(state.progress / 10) * 10
if (bucket > lastLoggedProgress) {
lastLoggedProgress = bucket
console.log(`simulated predecessor download: ${state.progress}%`)
}
}
})
try {
const checked = await manager.check(true)
assert.equal(checked.status, 'available')
assert.equal(checked.version, expectedManifest.version)
const downloaded = await manager.download(true)
assert.equal(downloaded.status, 'ready')
assert.equal(downloaded.progress, 100)
assert.equal(downloaded.currentVersion, '0.0.1')
assert.equal(downloaded.version, expectedManifest.version)
assert.equal(downloaded.lastExplicit, true)
const filename = `YCode-${expectedManifest.version}-Setup.exe`
const cachedPath = path.join(cacheDirectory, filename)
const info = await fsp.stat(cachedPath)
const digest = await sha256File(cachedPath)
assert.equal(info.isFile(), true)
assert.equal(info.size, expectedManifest.size)
assert.equal(digest, expectedManifest.sha256)
assert.equal(path.basename(cachedPath), filename)
const persisted = await persistence.load()
assert.equal(persisted.unfinishedVersion, undefined)
assert.equal(persisted.target, undefined)
assert.deepEqual(forbidden.calls, { validateEnvironment: 0, verifyInstaller: 0, launch: 0 })
assert.deepEqual(callbacks, { beforeInstall: 0, quitForUpdate: 0, installFailed: 0 })
assert.equal(events.some(state => state.status === 'downloading'), true)
return {
mode: 'simulated-predecessor-real-network-download',
simulatedPredecessorVersion: '0.0.1',
actualApplicationVersionChanged: false,
manifestUrl: MANIFEST_URL,
expectedVersion: expectedManifest.version,
state: downloaded,
observedDownloading: true,
cachedPath,
size: info.size,
sha256: digest,
installerAdapterCalls: forbidden.calls,
installCallbacks: callbacks,
passed: true
}
} finally {
unsubscribe()
manager.dispose()
}
}
function readRealUserState() {
let registryFingerprint = null
let registryEntryPresent = false
try {
const output = execFileSync('reg.exe', ['query', REGISTRY_KEY], {
encoding: 'utf8',
windowsHide: true,
timeout: 10_000,
maxBuffer: 64 * 1024
})
registryFingerprint = sha256(output)
registryEntryPresent = true
} catch {
// The current user may not have a production installation.
}
let installedExeFingerprint = null
let installedExePresent = false
const installedExe = path.join(process.env.LOCALAPPDATA || '', 'Programs', 'YCode', 'YCode.exe')
try {
const info = fs.statSync(installedExe)
installedExePresent = true
installedExeFingerprint = sha256(`${installedExe}|${info.size}|${info.mtimeMs}`)
} catch {
// Optional read-only check only.
}
return { registryEntryPresent, registryFingerprint, installedExePresent, installedExeFingerprint }
}
async function main() {
await fsp.mkdir(RUN_ROOT, { recursive: true })
const realUserStateBefore = readRealUserState()
const report = {
schemaVersion: 1,
startedAt: new Date().toISOString(),
finishedAt: null,
manifestUrl: MANIFEST_URL,
expectedVersion: EXPECTED_VERSION,
realApplicationUpgradePerformed: false,
installerLaunched: false,
checks: {
productionPackagedBoot: null,
simulatedPredecessorDownload: null
},
realUserStateBefore,
realUserStateAfter: null,
limitations: [
'Production boot verifies only packaged updater IPC and up-to-date state.',
'Installer download uses a simulated predecessor version 0.0.1 manager.',
'No real application upgrade, installer launch, deployment, or replacement is performed.'
]
}
let exitCode = 0
try {
const publicKeyPem = await fsp.readFile(PUBLIC_KEY, 'utf8')
const runtime = bundleActualManager(path.join(RUN_ROOT, 'runtime')).runtime
const manifestBytes = await fetchBytes(MANIFEST_URL)
const manifest = runtime.verifySignedManifest(manifestBytes, {
manifestUrl: MANIFEST_URL,
publicKeyPem
})
assert.equal(manifest.payload.version, EXPECTED_VERSION)
const platform = manifest.payload.platforms['windows-x64']
const expectedManifest = {
version: manifest.payload.version,
url: platform.url,
size: platform.size,
sha256: platform.sha256
}
report.verifiedManifest = expectedManifest
try {
report.checks.productionPackagedBoot = await runProductionBoot(expectedManifest)
} catch (error) {
exitCode = 1
report.checks.productionPackagedBoot = {
mode: 'actual-production-packaged-boot',
passed: false,
error: errorDetails(error)
}
}
try {
report.checks.simulatedPredecessorDownload = await runSimulatedPredecessorDownload(
expectedManifest,
runtime,
publicKeyPem
)
} catch (error) {
exitCode = 1
report.checks.simulatedPredecessorDownload = {
mode: 'simulated-predecessor-real-network-download',
simulatedPredecessorVersion: '0.0.1',
actualApplicationVersionChanged: false,
passed: false,
error: errorDetails(error)
}
}
} catch (error) {
exitCode = 1
report.preflightError = errorDetails(error)
} finally {
report.realUserStateAfter = readRealUserState()
report.realUserStateUnchanged = JSON.stringify(report.realUserStateBefore) === JSON.stringify(report.realUserStateAfter)
report.finishedAt = new Date().toISOString()
report.passed = exitCode === 0
await writeJsonAtomic(REPORT_PATH, report)
await writeJsonAtomic(LATEST_REPORT_PATH, report)
console.log(`report: ${REPORT_PATH}`)
if (fs.existsSync(SCREENSHOT_PATH)) console.log(`screenshot: ${SCREENSHOT_PATH}`)
}
process.exitCode = exitCode
}
main().catch(async error => {
console.error(error)
process.exitCode = 1
try {
await fsp.mkdir(RUN_ROOT, { recursive: true })
await writeJsonAtomic(REPORT_PATH, {
schemaVersion: 1,
startedAt: null,
finishedAt: new Date().toISOString(),
manifestUrl: MANIFEST_URL,
expectedVersion: EXPECTED_VERSION,
fatalError: errorDetails(error),
passed: false,
realApplicationUpgradePerformed: false,
installerLaunched: false
})
} catch {
// Preserve the original failure.
}
})
+560
View File
@@ -0,0 +1,560 @@
// Real isolated YCode App/preload/renderer verification for the update UI.
// Production out/main/preload are loaded unchanged. Only the harness replaces updates:* IPC handlers
// so checking, download progress, failure/retry, dismissal and install can be exercised deterministically.
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/updates-ui-'))
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
await Promise.all([fs.mkdir(data), fs.mkdir(home)])
const env = {
...process.env,
YCODE_QA_DIRECTORY: directory,
YCODE_USER_DATA_DIR: data,
YCODE_DISABLE_GPU: '1',
YCODE_DISABLE_SYSTEM_INTEGRATION: '1'
}
delete env.ELECTRON_RUN_AS_NODE
delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(
require('electron'),
['--no-sandbox', '--disable-gpu-sandbox', __filename],
{ env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }
)
let output = ''
child.stdout.on('data', bytes => {
output += bytes
if (bytes.toString().includes('UPDATES_UI_')) process.stdout.write(bytes)
})
child.stderr.on('data', bytes => {
output += bytes
if (bytes.toString().includes('UPDATES_UI_')) process.stderr.write(bytes)
})
const timer = setTimeout(() => child.kill(), 300000)
child.once('exit', async code => {
clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code }))
if (code) console.error(output.slice(-8000))
process.exitCode = code ? 1 : 0
})
})().catch(error => {
console.error(error)
process.exitCode = 1
})
} else {
const { app, BrowserWindow, ipcMain } = require('electron')
const { pathToFileURL } = require('node:url')
const packageVersion = require('../package.json').version
const directory = process.env.YCODE_QA_DIRECTORY
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('updates-ui-'))
app.disableHardwareAcceleration()
app.setPath('userData', path.join(directory, 'data'))
app.setPath('sessionData', path.join(directory, 'data'))
app.setPath('home', path.join(directory, 'home'))
let main
let settings
let phase = 'boot'
const checks = []
const snapshots = {}
const rendererErrors = []
const actionCounts = {
check: 0,
download: 0,
install: 0,
dismiss: 0,
downloadPage: 0,
log: 0
}
let updateState = {
status: 'idle',
currentVersion: packageVersion,
lastExplicit: false
}
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
function broadcastUpdateState() {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send('updates:state', { ...updateState })
}
}
function setUpdateState(patch, replace = false) {
updateState = {
...(replace ? { currentVersion: updateState.currentVersion } : updateState),
...patch,
lastChecked: new Date().toISOString()
}
broadcastUpdateState()
return { ...updateState }
}
const nativeHandle = ipcMain.handle.bind(ipcMain)
const absorbedUpdateHandlers = []
ipcMain.handle = (channel, listener) => {
if (channel.startsWith('updates:')) {
absorbedUpdateHandlers.push(channel)
return
}
return nativeHandle(channel, listener)
}
nativeHandle('updates:get-state', () => ({ ...updateState }))
nativeHandle('updates:check', () => {
actionCounts.check += 1
return setUpdateState({ status: 'checking', error: undefined, lastExplicit: true })
})
nativeHandle('updates:download', () => {
actionCounts.download += 1
return setUpdateState({ status: 'downloading', progress: 37, error: undefined, lastExplicit: true })
})
nativeHandle('updates:install', () => {
actionCounts.install += 1
return setUpdateState({ status: 'installing', error: undefined, lastExplicit: true })
})
nativeHandle('updates:dismiss', () => {
actionCounts.dismiss += 1
return setUpdateState({
dismissedVersion: updateState.version,
status: updateState.status === 'installing' ? 'ready' : updateState.status
})
})
nativeHandle('updates:open-download-page', () => {
actionCounts.downloadPage += 1
})
nativeHandle('updates:open-log', () => {
actionCounts.log += 1
})
async function read(source, win = main) {
let timer
try {
return await Promise.race([
win.webContents.executeJavaScript(source),
new Promise((_, reject) => {
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 15000)
})
])
} catch (error) {
throw Error(error.message + ' - ' + source.slice(0, 240))
} finally {
clearTimeout(timer)
}
}
async function wait(source, label, win = main) {
for (let index = 0; index < 300; index += 1) {
const value = await read(source, win)
if (value) return value
await pause(35)
}
throw Error('Timeout: ' + label)
}
async function step(label) {
phase = label
await fs.writeFile(path.join(directory, 'phase.txt'), label)
}
function check(label, actual, expected) {
const pass = typeof expected === 'function'
? expected(actual)
: JSON.stringify(actual) === JSON.stringify(expected)
checks.push({
label,
pass,
actual,
expected: typeof expected === 'function' ? expected.toString() : expected
})
}
function observe(win) {
win.webContents.on('console-message', (_event, level, message) => {
if (level >= 3) rendererErrors.push({ windowId: win.id, message })
})
}
async function shot(name, win = main) {
const image = await win.webContents.capturePage()
await fs.writeFile(path.join(directory, name + '.png'), image.toPNG())
}
async function click(selector, win = main) {
const clicked = await read(`(()=>{
const node=document.querySelector(${JSON.stringify(selector)});
if(!node)return false;
node.click();
return true
})()`, win)
assert.equal(clicked, true, 'click target: ' + selector)
await pause(100)
}
async function openSettings(section) {
await read(`window.ycode.app.openSettings(${JSON.stringify(section)})`)
for (let index = 0; index < 240; index += 1) {
settings = BrowserWindow.getAllWindows().find(win => win !== main)
if (settings && !settings.webContents.isLoading()) break
await pause(25)
}
assert.ok(settings, 'settings window')
await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings)
await wait('!!document.querySelector(".help-feedback-panel")', 'about panel', settings)
await pause(300)
}
async function setTheme(theme) {
await read(`(()=>{
localStorage.setItem('ycode:theme',${JSON.stringify(theme)});
window.dispatchEvent(new StorageEvent('storage',{key:'ycode:theme',newValue:${JSON.stringify(theme)}}));
return true
})()`)
await wait(`document.documentElement.dataset.theme===${JSON.stringify(theme)}`, 'main theme ' + theme)
if (settings && !settings.isDestroyed()) {
await wait(`document.documentElement.dataset.theme===${JSON.stringify(theme)}`, 'settings theme ' + theme, settings)
}
}
const cardSource = `(()=>{
const card=document.querySelector('.update-sidebar-card');
const compact=document.querySelector('.update-sidebar-card-collapsed');
if(!card&&!compact)return null;
const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};
const style=node=>getComputedStyle(node);
const progress=card?.querySelector('.update-sidebar-card__progress');
const primary=card?.querySelector('[data-update-action]');
return {
status:card?.getAttribute('data-status')??null,
compact:!!compact,
card:card?{box:box(card),background:style(card).backgroundColor,border:style(card).borderTopColor,
title:card.querySelector('.update-sidebar-card__title')?.textContent??'',
description:card.querySelector('.update-sidebar-card__description')?.textContent??'',
primary:primary?{text:primary.textContent.trim(),kind:primary.getAttribute('data-update-action'),disabled:primary.disabled}:null,
progress:progress?{value:progress.getAttribute('aria-valuenow'),width:progress.getBoundingClientRect().width}:null,
manual:!!card.querySelector('[data-update-manual]'),log:!!card.querySelector('[data-update-log]')}:null,
accountCardPresent:!!document.querySelector('.conversation-list-footer .update-sidebar-card-host')
}
})()`
const accountSource = `(()=>{
const row=document.querySelector('.ycode-account-menu [data-update-action]');
if(!row)return null;
return {status:row.getAttribute('data-update-status'),kind:row.getAttribute('data-update-action'),
label:row.querySelector('.user-menu-item-label')?.textContent??'',
value:row.querySelector('.user-menu-item-value')?.textContent??'',
ariaDisabled:row.getAttribute('aria-disabled')}
})()`
const aboutSource = `(()=>{
const row=document.querySelector('#settings-about-version');
const button=row?.querySelector('[data-update-action]');
const feedback=document.querySelector('.update-ui-feedback');
if(!row||!button)return null;
return {status:button.getAttribute('data-update-status'),kind:button.getAttribute('data-update-action'),
label:button.textContent.trim(),disabled:button.disabled,feedback:feedback?.textContent.replace(/\\s+/g,' ').trim()??'',
currentVersion:row.querySelector('.help-feedback-panel__card-value')?.textContent??''}
})()`
;(async () => {
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await app.whenReady()
for (let index = 0; index < 240; index += 1) {
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
if (main && !main.webContents.isLoading()) break
await pause(25)
}
assert.ok(main, 'main window')
observe(main)
main.setSize(1336, 902)
main.showInactive()
await wait('!!window.ycode&&!!document.querySelector(".conversation-list")', 'main renderer')
updateState.currentVersion = packageVersion
broadcastUpdateState()
await pause(300)
await step('ready-state')
setUpdateState({
status: 'ready',
version: '0.0.3',
notes: '改进更新卡片、失败重试与安装确认。',
progress: undefined,
error: undefined,
dismissedVersion: undefined
}, true)
await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`, 'ready update card')
snapshots.ready = await read(cardSource)
check('ready update card is mounted permanently above the account trigger',
[snapshots.ready.status, snapshots.ready.accountCardPresent, snapshots.ready.compact], ['ready', true, false])
check('ready card offers restart and shows the target version',
[snapshots.ready.card.primary.text, /v0\.0\.3/.test(snapshots.ready.card.title)], ['重启升级', true])
await step('account-sync-ready')
await click('.user-menu-trigger--workbuddy')
await wait('!!document.querySelector(".ycode-account-menu")', 'account menu')
snapshots.accountReady = await read(accountSource)
check('account menu shares the ready state and remains actionable',
[snapshots.accountReady.status, snapshots.accountReady.kind, snapshots.accountReady.label, snapshots.accountReady.ariaDisabled],
['ready', 'install', '重启升级', 'false'])
await shot('updates-main-light-ready')
await click('.ycode-account-menu [data-update-action="install"]')
check('account menu install action reaches the updates IPC bridge', actionCounts.install, 1)
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='installing'`, 'account installing state')
await read(`document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`)
await pause(100)
await shot('updates-main-light-ready-card')
await step('dismiss-and-reveal')
setUpdateState({
status: 'ready',
version: '0.0.3',
progress: undefined,
error: undefined,
dismissedVersion: undefined
})
await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"] [data-update-dismiss]')`, 'ready card dismiss')
await click('[data-update-dismiss]')
await wait('!!document.querySelector(".update-sidebar-card-collapsed")', 'collapsed update entry')
check('closing the card dismisses this version and keeps a compact update entry',
[actionCounts.dismiss, await read(`!document.querySelector('.update-sidebar-card')`)], [1, true])
await click('[data-update-reveal]')
await wait('!!document.querySelector(".update-sidebar-card")', 'revealed collapsed update card')
check('the compact update entry can reveal the dismissed version card again',
await read(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`), true)
await step('error-and-manual-links')
setUpdateState({
status: 'error',
version: '0.0.4',
progress: undefined,
error: '模拟更新检查失败',
dismissedVersion: undefined
})
await wait(`!!document.querySelector('.update-sidebar-card[data-status="error"]')`, 'error update card')
snapshots.error = await read(cardSource)
check('error state exposes retry, manual download and update log',
[snapshots.error.card.primary.text, snapshots.error.card.manual, snapshots.error.card.log], ['重试', true, true])
await click('[data-update-manual]')
await click('[data-update-log]')
check('manual download and update log actions reach the updates IPC bridge',
[actionCounts.downloadPage, actionCounts.log], [1, 1])
await click('.update-sidebar-card [data-update-action="check"]')
await wait(`!!document.querySelector('.update-sidebar-card')===false`, 'checking hides the non-actionable card')
check('retry invokes the real check IPC action', actionCounts.check, 1)
setUpdateState({ status: 'ready', version: '0.0.4', error: undefined })
await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`, 'ready after retry')
await step('settings-ready')
await openSettings('helpFeedback')
observe(settings)
snapshots.aboutReady = await read(aboutSource, settings)
check('settings About shows the shared ready state',
[snapshots.aboutReady.status, snapshots.aboutReady.kind, snapshots.aboutReady.label, /新版本 v0\.0\.4 已就绪/.test(snapshots.aboutReady.feedback)],
['ready', 'install', '重启升级', true])
await shot('updates-settings-light-ready', settings)
await step('settings-download-sync')
setUpdateState({
status: 'available',
version: '0.0.5',
progress: undefined,
error: undefined,
dismissedVersion: undefined
})
await wait(`document.querySelector('#settings-about-version [data-update-status]')?.getAttribute('data-update-status')==='available'`, 'settings available state', settings)
await wait(`!!document.querySelector('.update-sidebar-card[data-status="available"]')`, 'main available card')
await click('#settings-about-version [data-update-action="download"]', settings)
await wait(`document.querySelector('#settings-about-version [data-update-action]')?.textContent.includes('下载中 37%')`, 'settings download progress', settings)
await wait(`document.querySelector('.update-sidebar-card[data-status="downloading"]')?.querySelector('.update-sidebar-card__progress')?.getAttribute('aria-valuenow')==='37'`, 'main download progress')
snapshots.downloading = {
main: await read(cardSource),
settings: await read(aboutSource, settings)
}
check('download initiated in settings synchronizes to the sidebar card',
[actionCounts.download, snapshots.downloading.main.status, snapshots.downloading.main.card.progress.value, snapshots.downloading.settings.label],
[1, 'downloading', '37', '下载中 37%'])
setUpdateState({ status: 'ready', version: '0.0.5', progress: 100 })
await wait(`document.querySelector('#settings-about-version [data-update-action]')?.textContent.trim()==='重启升级'`, 'settings ready after download', settings)
await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`, 'main ready after download')
check('download completion synchronizes settings and sidebar', await Promise.all([
read(`document.querySelector('#settings-about-version [data-update-action]')?.textContent.trim()`, settings),
read(`document.querySelector('.update-sidebar-card__primary')?.textContent.trim()`)
]), ['重启升级', '重启升级'])
await step('dark')
await setTheme('dark')
snapshots.dark = {
main: await read(cardSource),
settings: await read(aboutSource, settings)
}
check('update card remains rendered with shared dark theme text and surface',
[snapshots.dark.main.status, snapshots.dark.main.card.background !== 'rgba(0, 0, 0, 0)'], ['ready', true])
await shot('updates-main-dark')
await shot('updates-settings-dark', settings)
await step('narrow-light')
await setTheme('light')
main.setSize(940, 800)
await pause(300)
snapshots.narrow = await read(`(()=>{
const card=document.querySelector('.update-sidebar-card');
const sidebar=document.querySelector('.conversation-list');
if(!card||!sidebar)return null;
const c=card.getBoundingClientRect(),s=sidebar.getBoundingClientRect();
return {viewport:innerWidth,card:[c.x,c.y,c.width,c.height],sidebar:[s.x,s.y,s.width,s.height],
horizontalOverflow:document.documentElement.scrollWidth>innerWidth,
title:card.querySelector('.update-sidebar-card__title')?.textContent??''}
})()`)
check('narrow window keeps the update card inside the sidebar without horizontal overflow',
[snapshots.narrow.horizontalOverflow, snapshots.narrow.card[2] <= snapshots.narrow.sidebar[2] + 1],
[false, true])
await shot('updates-main-narrow')
await step('up-to-date')
setUpdateState({
status: 'up-to-date',
version: undefined,
progress: undefined,
error: undefined,
dismissedVersion: undefined
}, true)
await wait('!document.querySelector(".update-sidebar-card")', 'up-to-date hides sidebar card')
await wait(`document.querySelector('#settings-about-version [data-update-status]')?.getAttribute('data-update-status')==='up-to-date'`, 'settings up-to-date', settings)
await click('.user-menu-trigger--workbuddy')
await wait('!!document.querySelector(".ycode-account-menu")', 'account menu up-to-date')
snapshots.upToDate = {
mainCard: await read(`!document.querySelector('.update-sidebar-card')`),
account: await read(accountSource),
settings: await read(aboutSource, settings)
}
check('up-to-date state hides the sidebar card and synchronizes account/settings',
[snapshots.upToDate.mainCard, snapshots.upToDate.account.status, snapshots.upToDate.account.value, /已是最新版本/.test(snapshots.upToDate.settings.feedback)],
[true, 'up-to-date', '已是最新', true])
await step('unsupported')
setUpdateState({
status: 'checking',
version: undefined,
progress: undefined,
error: undefined,
dismissedVersion: undefined,
unfinishedVersion: undefined
}, true)
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='checking'`, 'background check clears explicit intent')
setUpdateState({
status: 'unsupported',
version: undefined,
progress: undefined,
error: '当前平台不支持自动更新',
dismissedVersion: undefined,
unfinishedVersion: undefined
}, true)
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='unsupported'`, 'unsupported account row')
snapshots.unsupported = {
main: await read(cardSource),
account: await read(accountSource),
settings: await read(aboutSource, settings)
}
check('background unsupported state stays out of the sidebar while account/settings remain honest',
[snapshots.unsupported.main, snapshots.unsupported.account.label, snapshots.unsupported.account.value,
snapshots.unsupported.account.kind, /当前平台不支持自动更新/.test(snapshots.unsupported.settings.feedback)],
[null, '手动下载', '需手动下载', 'manual', true])
await step('background-error-quiet')
setUpdateState({
status: 'error',
version: undefined,
progress: undefined,
error: '后台检查失败',
dismissedVersion: undefined,
unfinishedVersion: undefined
}, true)
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='error'`, 'quiet background error')
await pause(150)
check('background error with no released version does not open a sidebar card',
[await read(`!document.querySelector('.update-sidebar-card')`), (await read(accountSource)).label],
[true, '重试'])
await click('.ycode-account-menu [data-update-action="check"]')
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='checking'`, 'explicit retry checking')
setUpdateState({ status: 'error', error: '显式检查失败' }, true)
await wait(`!!document.querySelector('.update-sidebar-card[data-status="error"]')`, 'explicit error card')
check('explicit retry failure is allowed to surface in the sidebar',
[actionCounts.check, await read(`document.querySelector('.update-sidebar-card__description')?.textContent`)],
[2, '显式检查失败'])
await step('unfinished-version')
setUpdateState({
status: 'up-to-date',
version: undefined,
progress: undefined,
error: undefined,
dismissedVersion: undefined,
unfinishedVersion: '0.0.6'
}, true)
await wait(`!!document.querySelector('.update-sidebar-card[data-unfinished="true"]')`, 'unfinished update card')
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='up-to-date'`, 'unfinished account row')
snapshots.unfinished = {
main: await read(cardSource),
account: await read(accountSource),
settings: await read(aboutSource, settings)
}
check('unfinishedVersion survives an otherwise latest state as an actionable warning',
[snapshots.unfinished.main.card.title, snapshots.unfinished.main.card.primary.text,
snapshots.unfinished.account.label, snapshots.unfinished.account.value,
/上次升级至 v0\.0\.6 未完成/.test(snapshots.unfinished.settings.feedback)],
['上次升级未完成', '重试更新', '重试更新', '上次升级未完成', true])
await click('[data-update-log]')
check('unfinished update warning opens the update log', actionCounts.log, 2)
await step('errors')
check('no renderer error logs', rendererErrors, [])
const report = {
result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
scope: 'Real isolated YCode main/preload/renderer and settings window at 1336x902, 940x800 narrow, 900x680 settings; deterministic updates IPC injection; ready/downloading/error/up-to-date/unsupported/unfinished states, retry, dismissal, manual/log links, cross-window synchronization, light/dark screenshots.',
limitations: [
'This verifies renderer interaction and IPC call shape, not a real package download, signature verification, NSIS install, or restart.',
'The harness intercepts updates:install before the production native confirmation dialog, so confirmation copy and dialog behavior remain a main-process acceptance item.',
'Static REA evidence does not include final visual measurements for every WorkBuddy update-card state; screenshots are visual review artifacts, not pixel-parity claims.'
],
injectedUpdateChannels: [
'updates:get-state',
'updates:check',
'updates:download',
'updates:install',
'updates:dismiss',
'updates:open-download-page',
'updates:open-log'
],
absorbedProductionUpdateHandlers: absorbedUpdateHandlers,
actionCounts,
snapshots,
checks,
rendererErrors
}
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('UPDATES_UI_' + report.result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) {
console.log('UPDATES_UI_FAILURE ' + JSON.stringify(failure))
}
app.exit(report.result === 'PASS' ? 0 : 1)
})().catch(async error => {
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({
phase,
error: String(error),
stack: error.stack,
actionCounts,
checks,
snapshots,
rendererErrors
}, null, 2))
console.error(error)
app.exit(1)
})
}