feat: ship skill and MCP workflows with signed updater for v0.0.7
This commit is contained in:
1 parent
1b52df0603
commit
a01085c656
136 files changed
+18646
-217
No files matched your search
@@ -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'
|
||||
})
|
||||
@@ -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 }))
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }))
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, '<plain>&\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')
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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' }))
|
||||
@@ -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)
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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 }))
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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
|
||||
})
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
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.')
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
})
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
@@ -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, /<script>/)
|
||||
assert.match(html, /<img src=x onerror=alert\(1\)>/)
|
||||
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
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
@@ -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.
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user