merge: bring main into the computer-use worktree

main is 87 commits ahead and carries a large amount of fixed behaviour this
branch should not be re-deciding. The rule applied throughout: this worktree
owns Computer Use, main owns everything else.

Only 12 files were touched on both sides, and Git merged all of them without
reporting a conflict — but two of those silent merges were wrong, and neither
was visible until the checks ran.

`desktop/src/api/client.ts` ended up with two `apiGetBlob` implementations.
Both sides had independently hit the same problem (an `<img src>` pointed at an
API endpoint is a cross-origin subresource, so it carries no Authorization
header and the server's fetch-metadata policy refuses it) and both had written
the same fix. Git saw two additions in different places and kept both, which
does not even compile. main's version survives: it builds its headers through
the shared `buildHeaders()` rather than assembling them inline, so it inherits
whatever main adds there later.

`src/server/api/computer-use.ts` still imported `runtime/mac_helper.py` and
`runtime/requirements.txt` as compile-time text, both deleted on this branch.
Nothing at runtime referenced them, which is why the deletion looked clean; the
bundler resolves those imports when the server module is loaded, so the failure
surfaced only when the tests actually imported it. That path is now Windows-only
in the same sense the rest of the Python bridge is, and it also ships
`win_cursor_badge.py`, which the badge needs because it runs as its own process.

`computer-use-requirements.test.ts` drops its darwin half for the same reason —
the pins it guards still matter, but only one requirements file is left.

Verified: server 3869 tests / 331 files, desktop 4612 tests / 319 files
(lint + tsc + build), Swift 272 XCTest + 14 Swift Testing, Python 25.

Claude-Session: https://claude.ai/code/session_015j1yxxaoonyAS2iZ7qGnTS
This commit is contained in:
程序员阿江(Relakkes)
2026-08-23 18:39:47 +08:00
513 changed files with 73008 additions and 4148 deletions
+2 -1
View File
@@ -198,7 +198,8 @@ describe('feature quality contract', () => {
expect(instructions).toContain('Add same-area tests with the production change')
expect(instructions).toContain('Preserve or improve the coverage ratchet')
expect(instructions).toContain('changed-line coverage threshold')
expect(instructions).toContain('E2E or agent-browser smoke')
expect(instructions).toContain('E2E or desktop UI smoke')
expect(instructions).toContain('Ad-hoc browser automation')
expect(instructions).toContain('Provider/auth/runtime-env/model-window/proxy changes require offline `bun run check:provider-contract`')
expect(instructions).toContain('Live smoke is trusted-maintainer evidence only and requires explicit authorization')
expect(instructions).toContain('include changed files, tests added, commands actually run with pass/fail counts')
+5 -1
View File
@@ -36,7 +36,11 @@ async function main() {
const rootDir = resolve(import.meta.dir, '../../..')
const only = flag(argv, 'only')
const target = resolveLiveTarget(flag(argv, 'provider'), { modelId: flag(argv, 'model') || undefined })
const target = resolveLiveTarget(flag(argv, 'provider'), {
modelId: flag(argv, 'model') || undefined,
effortLevel: flag(argv, 'effort') || undefined,
disableExperimentalBetas: argv.includes('--disable-experimental-betas'),
})
const selected = only
? LIVE_AGENT_FLOW_SCENARIOS.filter((scenario) => scenario.id === only)
: LIVE_AGENT_FLOW_SCENARIOS
+59 -3
View File
@@ -3,7 +3,12 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describeLiveTarget, resolveLiveTarget } from './live.ts'
import {
applyLiveTargetSandboxOverrides,
collectLiveEffortFields,
describeLiveTarget,
resolveLiveTarget,
} from './live.ts'
import { LIVE_AGENT_FLOW_SCENARIOS, LIVE_FLOW_COVERAGE, LIVE_FLOW_EXCLUSIONS } from './liveScenarios.ts'
/**
@@ -54,9 +59,54 @@ describe('live target resolution', () => {
})
test('resolves by id, and an explicit model wins over the configured one', () => {
const target = resolveLiveTarget('aaaaaaaa-0000-4000-8000-000000000002', { configDir, modelId: 'override' })
const target = resolveLiveTarget('aaaaaaaa-0000-4000-8000-000000000002', {
configDir,
modelId: 'override',
effortLevel: 'xhigh',
disableExperimentalBetas: true,
})
expect(target.providerName).toBe('DeepSeek')
expect(target.modelId).toBe('override')
expect(target.effortLevel).toBe('xhigh')
expect(target.disableExperimentalBetas).toBe(true)
})
test('changes beta handling only in the sandboxed provider copy', () => {
const sandboxConfigDir = configDirWith([
{ id: 'provider-id', name: 'Gateway', models: { main: 'gpt-5.6-sol' } },
])
const target = resolveLiveTarget('Gateway', {
configDir: sandboxConfigDir,
disableExperimentalBetas: true,
})
applyLiveTargetSandboxOverrides(sandboxConfigDir, target)
const stored = JSON.parse(readFileSync(join(sandboxConfigDir, 'cc-haha', 'providers.json'), 'utf8')) as {
providers: Array<{ disableExperimentalBetas?: boolean }>
}
expect(stored.providers[0]?.disableExperimentalBetas).toBe(true)
})
test('finds effort in Anthropic, Chat Completions and Responses request shapes', () => {
expect(collectLiveEffortFields({
anthropic: { output_config: { effort: 'xhigh' } },
chat: { reasoning_effort: 'high' },
responses: { reasoning: { effort: 'medium' } },
})).toEqual([
{ path: 'request.body.anthropic.output_config.effort', value: 'xhigh' },
{ path: 'request.body.chat.reasoning_effort', value: 'high' },
{ path: 'request.body.responses.reasoning.effort', value: 'medium' },
])
})
test('finds effort inside the JSON snapshot stored by trace capture', () => {
expect(collectLiveEffortFields({
contentType: 'json',
preview: JSON.stringify({ output_config: { effort: 'xhigh' } }),
})).toEqual([
{ path: 'request.body.output_config.effort', value: 'xhigh' },
])
})
test('names the configured providers when nothing matches', () => {
@@ -78,10 +128,16 @@ describe('confirmation banner', () => {
}
test('states the cost, the target and how to proceed', () => {
const banner = describeLiveTarget(target, 6)
const banner = describeLiveTarget({
...target,
effortLevel: 'xhigh',
disableExperimentalBetas: true,
}, 6)
expect(banner).toContain('real money')
expect(banner).toContain('LM Studio')
expect(banner).toContain('local-model')
expect(banner).toContain('xhigh')
expect(banner).toContain('sandbox copy only')
expect(banner).toContain('--yes')
})
+137 -3
View File
@@ -39,6 +39,9 @@ export type ResolvedLiveTarget = {
providerId: string
providerName: string
modelId: string
effortLevel?: string
/** Applied only to the copied provider config used by this live run. */
disableExperimentalBetas?: boolean
/** Host only. The full base URL can carry a key in its query string. */
host: string
source: string
@@ -51,7 +54,12 @@ export type ResolvedLiveTarget = {
*/
export function resolveLiveTarget(
selector: string | undefined,
options: { configDir?: string; modelId?: string } = {},
options: {
configDir?: string
modelId?: string
effortLevel?: string
disableExperimentalBetas?: boolean
} = {},
): ResolvedLiveTarget {
const configDir = options.configDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')
const index = loadProviderIndex(configDir)
@@ -93,11 +101,31 @@ export function resolveLiveTarget(
providerId: provider.id,
providerName: provider.name,
modelId,
...(options.effortLevel ? { effortLevel: options.effortLevel } : {}),
...(options.disableExperimentalBetas ? { disableExperimentalBetas: true } : {}),
host: 'resolved by the server from the sandboxed provider config',
source: indexPath,
}
}
export function applyLiveTargetSandboxOverrides(
configDir: string,
target: ResolvedLiveTarget,
): void {
if (!target.disableExperimentalBetas) return
const indexPath = getProviderIndexPath(configDir)
const index = JSON.parse(readFileSync(indexPath, 'utf8')) as {
providers?: Array<Record<string, unknown>>
}
const provider = index.providers?.find((candidate) => candidate.id === target.providerId)
if (!provider) {
throw new Error(`Provider ${target.providerId} is missing from sandboxed provider config`)
}
provider.disableExperimentalBetas = true
writeFileSync(indexPath, `${JSON.stringify(index, null, 2)}\n`)
}
/** The banner the operator has to read before anything is sent upstream. */
export function describeLiveTarget(target: ResolvedLiveTarget, scenarioCount: number): string {
return [
@@ -106,6 +134,10 @@ export function describeLiveTarget(target: ResolvedLiveTarget, scenarioCount: nu
'',
` provider ${target.providerName} (${target.providerId})`,
` model ${target.modelId}`,
...(target.effortLevel ? [` effort ${target.effortLevel}`] : []),
...(target.disableExperimentalBetas
? [' beta mode disabled in the sandbox copy only']
: []),
` read from ${target.source}`,
` scenarios ${scenarioCount}`,
'',
@@ -118,19 +150,106 @@ export function describeLiveTarget(target: ResolvedLiveTarget, scenarioCount: nu
type LiveContext = {
baseUrl: string
workRoot: string
artifactDir: string
target: ResolvedLiveTarget
createSession(): Promise<string>
openSocket(sessionId: string): Promise<SessionSocket>
pinRuntime(socket: SessionSocket): void
}
export type LiveEffortField = {
path: string
value: string
}
export function collectLiveEffortFields(value: unknown, path = 'request.body'): LiveEffortField[] {
if (!value || typeof value !== 'object') return []
const snapshot = value as { contentType?: unknown; preview?: unknown }
if (snapshot.contentType === 'json' && typeof snapshot.preview === 'string') {
try {
return collectLiveEffortFields(JSON.parse(snapshot.preview), path)
} catch {
const fields: LiveEffortField[] = []
const pattern = /"(effort|reasoning_effort)"\s*:\s*"([^"]+)"/g
for (const match of snapshot.preview.matchAll(pattern)) {
fields.push({ path: `${path}.preview.${match[1]}`, value: match[2]! })
}
return fields
}
}
const fields: LiveEffortField[] = []
for (const [key, child] of Object.entries(value)) {
const childPath = `${path}.${key}`
if ((key === 'effort' || key === 'reasoning_effort') && typeof child === 'string') {
fields.push({ path: childPath, value: child })
}
fields.push(...collectLiveEffortFields(child, childPath))
}
return fields
}
async function captureLiveEffortEvidence(ctx: LiveContext, sessionId: string): Promise<void> {
const expected = ctx.target.effortLevel
if (!expected) return
const deadline = Date.now() + 30_000
while (Date.now() < deadline) {
const traceResponse = await fetch(`${ctx.baseUrl}/api/sessions/${sessionId}/trace`)
if (traceResponse.ok) {
const trace = await traceResponse.json() as { calls?: Array<{ id?: string }> }
for (const item of [...(trace.calls ?? [])].reverse()) {
if (!item.id) continue
const detailResponse = await fetch(
`${ctx.baseUrl}/api/sessions/${sessionId}/trace/calls/${encodeURIComponent(item.id)}`,
)
if (!detailResponse.ok) continue
const detail = await detailResponse.json() as {
call?: {
source?: string
model?: string
request?: { headers?: Record<string, unknown>; body?: unknown }
response?: { status?: number }
}
}
const call = detail.call
const effortFields = collectLiveEffortFields(call?.request?.body)
if (!effortFields.some((field) => field.value === expected)) continue
const requestHeaderNames = Object.keys(call?.request?.headers ?? {}).map((name) => name.toLowerCase())
const anthropicBetaHeaderPresent = requestHeaderNames.includes('anthropic-beta')
if (ctx.target.disableExperimentalBetas && anthropicBetaHeaderPresent) {
throw new Error('sandbox requested disabled experimental betas, but the live request still sent anthropic-beta')
}
writeFileSync(join(ctx.artifactDir, 'effort-evidence.json'), `${JSON.stringify({
providerName: ctx.target.providerName,
modelId: call?.model ?? ctx.target.modelId,
requestedEffort: expected,
disableExperimentalBetas: ctx.target.disableExperimentalBetas === true,
traceSource: call?.source ?? null,
effortFields,
anthropicBetaHeaderPresent,
responseStatus: call?.response?.status ?? null,
}, null, 2)}\n`)
return
}
}
await Bun.sleep(200)
}
throw new Error(`live request completed but no captured request body carried effort=${expected}`)
}
function assistantTextCount(socket: SessionSocket) {
return socket.messages.filter((message) => message.type === 'content_start').length
}
const runners: Record<string, (ctx: LiveContext) => Promise<void>> = {
async 'live-first-turn'(ctx) {
const socket = await ctx.openSocket(await ctx.createSession())
const sessionId = await ctx.createSession()
const socket = await ctx.openSocket(sessionId)
try {
ctx.pinRuntime(socket)
const turn = await runTurn(socket, 'Reply with a single short sentence confirming you are ready.', LIVE_STEP_TIMEOUT_MS)
@@ -138,6 +257,7 @@ const runners: Record<string, (ctx: LiveContext) => Promise<void>> = {
if (streamed.length === 0) {
throw new Error(`no assistant text streamed; saw ${turn.map((m) => m.type).join(', ')}`)
}
await captureLiveEffortEvidence(ctx, sessionId)
} finally {
socket.close()
}
@@ -289,6 +409,7 @@ export async function executeLiveAgentFlow(options: {
const { rootDir, artifactDir, target } = options
mkdirSync(artifactDir, { recursive: true })
const serverLogPath = join(artifactDir, 'server.log')
rmSync(join(artifactDir, 'effort-evidence.json'), { force: true })
writeFileSync(serverLogPath, '')
const port = await getPort()
@@ -305,6 +426,13 @@ export async function executeLiveAgentFlow(options: {
seedProviders: true,
envOverrides: { CC_HAHA_DISABLE_TERMINAL_SHELL_ENV: '1' },
})
try {
applyLiveTargetSandboxOverrides(sandbox.configDir, target)
} catch (error) {
sandbox.cleanup()
rmSync(workRoot, { recursive: true, force: true })
throw error
}
const server = Bun.spawn(['bun', 'run', 'src/server/index.ts', '--host', '127.0.0.1', '--port', String(port)], {
cwd: rootDir,
@@ -321,6 +449,7 @@ export async function executeLiveAgentFlow(options: {
const ctx: LiveContext = {
baseUrl,
workRoot,
artifactDir,
target,
async createSession() {
const response = await fetch(`${baseUrl}/api/sessions`, {
@@ -338,7 +467,12 @@ export async function executeLiveAgentFlow(options: {
return await new SessionSocket(baseUrl, sessionId).open()
},
pinRuntime(socket) {
socket.send({ type: 'set_runtime_config', providerId: target.providerId, modelId: target.modelId })
socket.send({
type: 'set_runtime_config',
providerId: target.providerId,
modelId: target.modelId,
...(target.effortLevel ? { effortLevel: target.effortLevel } : {}),
})
},
}
@@ -8,6 +8,7 @@ import {
buildDesktopUiSmokeBootstrap,
buildDesktopUiSmokePrompt,
describeDesktopUiSmokePrerequisites,
seedDesktopUiSmokeProvider,
} from './deterministic'
describe('deterministic desktop UI smoke setup', () => {
@@ -17,7 +18,7 @@ describe('deterministic desktop UI smoke setup', () => {
expect(bootstrap).toContain(`localStorage.setItem('cc-haha-locale', "${DESKTOP_UI_SMOKE_LOCALE}")`)
expect(bootstrap).toContain('cc-haha-open-tabs')
expect(bootstrap).toContain('session-1')
// No runtime is pinned: the lane must exercise the default no-provider path.
// No per-session runtime is pinned: the lane uses its isolated active provider.
expect(bootstrap).toContain("localStorage.removeItem('cc-haha-session-runtime')")
})
@@ -44,6 +45,22 @@ describe('deterministic desktop UI smoke setup', () => {
expect(payload.write.content).toBe('written-through-the-desktop-ui')
})
test('seeds only a fake provider inside the sandbox', () => {
const configDir = mkdtempSync(join(tmpdir(), 'cc-haha-ui-smoke-provider-'))
try {
seedDesktopUiSmokeProvider(configDir)
const index = JSON.parse(readFileSync(join(configDir, 'cc-haha', 'providers.json'), 'utf8'))
expect(index.activeId).toBe('desktop-ui-smoke-provider')
expect(index.providers).toHaveLength(1)
expect(index.providers[0]).toMatchObject({
apiKey: 'desktop-ui-smoke-fake-key',
baseUrl: 'http://127.0.0.1:1',
})
} finally {
rmSync(configDir, { recursive: true, force: true })
}
})
test('skips with an actionable reason instead of failing when prerequisites are missing', () => {
const empty = mkdtempSync(join(tmpdir(), 'cc-haha-ui-smoke-prereq-'))
try {
@@ -59,12 +76,15 @@ describe('deterministic desktop UI smoke setup', () => {
expect(source).toContain('src/server/__tests__/fixtures/mock-sdk-cli.ts')
expect(source).toContain('CLAUDE_CLI_PATH')
expect(source).toContain('seedProviders: false')
expect(source).toContain('seedDesktopUiSmokeProvider(sandbox.configDir)')
expect(source).toContain('...sandbox.env')
expect(source).toContain('applyUserStateGuard')
// The lane must answer the permission through the UI. Flipping the global
// permission mode is what made the old smoke both unsafe and blind to the
// approval screen.
expect(source).toContain(DESKTOP_UI_SMOKE_ALLOW_SELECTOR)
expect(source).toContain("['type', DESKTOP_SMOKE_COMPOSER_SELECTOR, prompt]")
expect(source).toContain("['click', DESKTOP_SMOKE_RUN_SELECTOR]")
// No global permission-mode switch: the old smoke PUT bypassPermissions on the
// settings API, which both skipped the approval screen and mutated user state.
expect(source).not.toContain('/api/permissions/mode')
@@ -1,4 +1,4 @@
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
@@ -10,9 +10,16 @@ import {
buildDesktopSmokeBrowserEnv,
cleanupAgentBrowserSession,
cleanupBrowserProfileProcesses,
DESKTOP_SMOKE_COMPOSER_SELECTOR,
DESKTOP_SMOKE_RUN_SELECTOR,
desktopSmokeAppUrl,
getPort,
pipeToFile,
resolveAgentBrowserExecutable,
resolveDesktopViteExecutable,
removeDesktopSmokeTree,
runLoggedCommand,
stopDesktopSmokeProcess,
waitForHttp,
} from './execute'
@@ -32,6 +39,8 @@ const FIXTURE = 'scripts/quality-gate/agent-flow/fixtures/workspace'
const MOCK_CLI = 'src/server/__tests__/fixtures/mock-sdk-cli.ts'
const TARGET_FILE = 'ui-smoke-output.txt'
const TARGET_CONTENT = 'written-through-the-desktop-ui'
const SMOKE_PROVIDER_ID = 'desktop-ui-smoke-provider'
const SMOKE_MODEL_ID = 'desktop-ui-smoke-model'
/** Locale is pinned so the approval button label is stable across contributors. */
export const DESKTOP_UI_SMOKE_LOCALE = 'en'
@@ -61,6 +70,30 @@ export function buildDesktopUiSmokePrompt(projectDir: string) {
}
}
export function seedDesktopUiSmokeProvider(configDir: string) {
const ccHahaDir = join(configDir, 'cc-haha')
mkdirSync(ccHahaDir, { recursive: true })
writeFileSync(join(ccHahaDir, 'providers.json'), JSON.stringify({
activeId: SMOKE_PROVIDER_ID,
providers: [{
id: SMOKE_PROVIDER_ID,
presetId: 'custom',
name: 'Desktop UI Smoke (isolated)',
apiKey: 'desktop-ui-smoke-fake-key',
baseUrl: 'http://127.0.0.1:1',
apiFormat: 'anthropic',
runtimeKind: 'anthropic_compatible',
models: {
main: SMOKE_MODEL_ID,
haiku: SMOKE_MODEL_ID,
sonnet: SMOKE_MODEL_ID,
opus: SMOKE_MODEL_ID,
},
}],
providerOrder: ['claude-official', 'openai-official', 'grok-official', SMOKE_PROVIDER_ID],
}, null, 2) + '\n')
}
async function pollUntil(
check: () => Promise<boolean>,
timeoutMs: number,
@@ -88,7 +121,14 @@ export function describeDesktopUiSmokePrerequisites(rootDir: string): string | n
if (!existsSync(join(rootDir, 'desktop', 'node_modules', '.bin'))) {
return 'desktop dependencies are not installed (run `bun install` in desktop/)'
}
const probe = Bun.spawnSync(['agent-browser', '--version'], { stdout: 'pipe', stderr: 'pipe' })
if (!resolveDesktopViteExecutable(rootDir)) {
return 'desktop Vite executable is missing (reinstall desktop dependencies)'
}
const agentBrowserExecutable = resolveAgentBrowserExecutable()
if (!agentBrowserExecutable) {
return 'agent-browser is not installed (see https://github.com/anthropics/agent-browser)'
}
const probe = Bun.spawnSync([agentBrowserExecutable, '--version'], { stdout: 'pipe', stderr: 'pipe' })
if (probe.exitCode !== 0) {
return 'agent-browser is not installed (see https://github.com/anthropics/agent-browser)'
}
@@ -127,7 +167,7 @@ export async function executeDeterministicDesktopSmoke(
const serverPort = await getPort()
const vitePort = await getPort()
const baseUrl = `http://127.0.0.1:${serverPort}`
const appUrl = `http://127.0.0.1:${vitePort}`
const appUrl = desktopSmokeAppUrl(vitePort)
const sessionName = `quality-gate-ui-${serverPort}-${vitePort}`
const browserEnv = buildDesktopSmokeBrowserEnv(sessionName, browserProfileDir)
@@ -139,6 +179,7 @@ export async function executeDeterministicDesktopSmoke(
CC_HAHA_DISABLE_TERMINAL_SHELL_ENV: '1',
},
})
seedDesktopUiSmokeProvider(sandbox.configDir)
const server = Bun.spawn(['bun', 'run', 'src/server/index.ts', '--host', '127.0.0.1', '--port', String(serverPort)], {
cwd: rootDir,
@@ -149,13 +190,10 @@ export async function executeDeterministicDesktopSmoke(
void pipeToFile(server.stdout, serverLogPath)
void pipeToFile(server.stderr, serverLogPath)
const viteExecutable = join(
rootDir,
'desktop',
'node_modules',
'.bin',
process.platform === 'win32' ? 'vite.cmd' : 'vite',
)
const viteExecutable = resolveDesktopViteExecutable(rootDir)
if (!viteExecutable) {
throw new Error('desktop Vite executable disappeared after prerequisite checks')
}
const vite = Bun.spawn([viteExecutable, '--host', '127.0.0.1', '--port', String(vitePort), '--strictPort'], {
cwd: join(rootDir, 'desktop'),
env: { ...process.env, VITE_DESKTOP_SERVER_URL: baseUrl },
@@ -194,10 +232,19 @@ export async function executeDeterministicDesktopSmoke(
await browserStep(['open', appUrl])
await browserStep(['eval', buildDesktopUiSmokeBootstrap(session.sessionId)], { timeoutMs: 15_000 })
await browserStep(['reload'])
await browserStep(['wait', 'textarea'])
// The composer is a ProseMirror contenteditable (MentionComposer), not a
// <textarea> — `[data-composer-editor]` is the attribute the editor sets on
// its editable node and the same hook composerTestUtils drives.
await browserStep(['wait', DESKTOP_SMOKE_COMPOSER_SELECTOR])
await browserStep(['fill', 'textarea', prompt], { timeoutMs: 20_000 })
await browserStep(['press', 'Enter'], { timeoutMs: 15_000 })
// `type` drives the editor's input path so React and ProseMirror observe the
// same update as a person typing. `fill` can replace only the DOM text while
// leaving the editor state empty.
await browserStep(['type', DESKTOP_SMOKE_COMPOSER_SELECTOR, prompt], { timeoutMs: 20_000 })
// Click the visible Run control. A standalone `press Enter` command starts a
// separate agent-browser invocation and does not reliably preserve the
// contenteditable focus on Windows.
await browserStep(['click', DESKTOP_SMOKE_RUN_SELECTOR], { timeoutMs: 15_000 })
// The permission dialog is the point of this lane: nothing may touch the
// fixture until the real button is clicked.
@@ -248,9 +295,11 @@ export async function executeDeterministicDesktopSmoke(
await cleanupAgentBrowserSession(sessionName, browserLogPath)
cleanupBrowserProfileProcesses(browserProfileDir, browserLogPath)
appendFileSync(browserLogPath, `\n[quality-gate] Removed browser profile ${browserProfileDir}\n`)
server.kill()
vite.kill()
rmSync(workRoot, { recursive: true, force: true })
await Promise.all([
stopDesktopSmokeProcess(server, 'server', serverLogPath),
stopDesktopSmokeProcess(vite, 'Vite', viteLogPath),
])
removeDesktopSmokeTree(workRoot)
sandbox.cleanup()
}
}
@@ -1,10 +1,107 @@
import { describe, expect, test } from 'bun:test'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import {
buildDesktopSmokeBrowserEnv,
DESKTOP_SMOKE_COMPOSER_SELECTOR,
DESKTOP_SMOKE_RUN_SELECTOR,
desktopSmokeAppUrl,
desktopSmokeTextShowsProject,
resolveAgentBrowserExecutable,
resolveDesktopSmokeRuntimeSelection,
resolveDesktopViteExecutable,
} from './execute'
test('uses localhost for the browser origin so Windows proxy bypass applies to Vite modules', () => {
expect(desktopSmokeAppUrl(5173)).toBe('http://localhost:5173')
})
test('targets the ProseMirror composer rendered by the desktop chat input', () => {
expect(DESKTOP_SMOKE_COMPOSER_SELECTOR).toBe('[data-composer-editor]')
expect(readFileSync('desktop/src/components/chat/MentionComposer.tsx', 'utf8'))
.toContain("'data-composer-editor': 'true'")
})
test('submits through the visible Run control instead of relying on cross-process focus', () => {
expect(DESKTOP_SMOKE_RUN_SELECTOR).toContain('button[aria-label="Run"]')
expect(DESKTOP_SMOKE_RUN_SELECTOR).toContain('button[aria-label="运行"]')
const component = readFileSync('desktop/src/components/chat/ChatInput.tsx', 'utf8')
const smoke = readFileSync('scripts/quality-gate/desktop-smoke/execute.ts', 'utf8')
expect(component).toContain("t('common.run')")
expect(smoke).toContain("['type', DESKTOP_SMOKE_COMPOSER_SELECTOR, PROMPT]")
})
test('normalizes changed fixture paths before enforcing the file allowlist', () => {
const source = readFileSync('scripts/quality-gate/desktop-smoke/execute.ts', 'utf8')
expect(source).toContain("changedFiles(originalDir, projectDir).map((file) => file.replaceAll('\\\\', '/'))")
})
test('stops spawned process trees before removing the Windows fixture directory', () => {
const source = readFileSync('scripts/quality-gate/desktop-smoke/execute.ts', 'utf8')
expect(source).toContain("stopDesktopSmokeProcess(server, 'server', serverLogPath)")
expect(source).toContain("stopDesktopSmokeProcess(vite, 'Vite', viteLogPath)")
expect(source.indexOf('stopDesktopSmokeProcess(server'))
.toBeLessThan(source.lastIndexOf('removeDesktopSmokeTree(workRoot)'))
})
describe('desktop smoke Vite executable resolution', () => {
test('uses Bun\'s Windows executable shim when vite.cmd is absent', () => {
const rootDir = mkdtempSync(join(tmpdir(), 'cc-haha-vite-shim-'))
const binDir = join(rootDir, 'desktop', 'node_modules', '.bin')
mkdirSync(binDir, { recursive: true })
const executable = join(binDir, 'vite.exe')
writeFileSync(executable, '')
try {
expect(resolveDesktopViteExecutable(rootDir, 'win32')).toBe(executable)
} finally {
rmSync(rootDir, { recursive: true, force: true })
}
})
test('falls back to npm\'s Windows command shim', () => {
const rootDir = mkdtempSync(join(tmpdir(), 'cc-haha-vite-shim-'))
const binDir = join(rootDir, 'desktop', 'node_modules', '.bin')
mkdirSync(binDir, { recursive: true })
const executable = join(binDir, 'vite.cmd')
writeFileSync(executable, '')
try {
expect(resolveDesktopViteExecutable(rootDir, 'win32')).toBe(executable)
} finally {
rmSync(rootDir, { recursive: true, force: true })
}
})
})
describe('desktop smoke agent-browser executable resolution', () => {
test('unwraps the npm command shim to the native Windows executable', () => {
const npmDir = mkdtempSync(join(tmpdir(), 'cc-haha-agent-browser-'))
const commandShim = join(npmDir, 'agent-browser.cmd')
const executable = join(
npmDir,
'node_modules',
'agent-browser',
'bin',
'agent-browser-win32-x64.exe',
)
mkdirSync(dirname(executable), { recursive: true })
writeFileSync(commandShim, '')
writeFileSync(executable, '')
try {
expect(resolveAgentBrowserExecutable(commandShim, 'win32', 'x64')).toBe(executable)
} finally {
rmSync(npmDir, { recursive: true, force: true })
}
})
test('reports a command shim without its native binary as unavailable', () => {
expect(resolveAgentBrowserExecutable('C:\\npm\\agent-browser.cmd', 'win32', 'x64')).toBeNull()
})
})
describe('desktop smoke runtime selection', () => {
test('lets current-runtime use the desktop default active provider', () => {
expect(resolveDesktopSmokeRuntimeSelection({
@@ -73,5 +170,11 @@ describe('desktop smoke restored session detection', () => {
'folder',
'project',
].join('\n'), 'project')).toBe(true)
expect(desktopSmokeTextShowsProject([
'Untitled Session',
'folder',
'C:\\isolated\\fixture\\project',
].join('\n'), 'C:\\isolated\\fixture\\project')).toBe(true)
})
})
+117 -34
View File
@@ -2,7 +2,7 @@ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, wr
import { mkdtemp } from 'node:fs/promises'
import { createServer } from 'node:net'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { basename, dirname, join } from 'node:path'
import treeKill from 'tree-kill'
import { changedFiles, writeDiffPatch } from '../baseline/execute'
import { applyUserStateGuard, createQualityGateSandbox } from '../sandbox'
@@ -11,6 +11,14 @@ import type { BaselineTarget, LaneResult } from '../types'
const FIXTURE = 'scripts/quality-gate/desktop-smoke/fixtures/chat-edit'
const AGENT_BROWSER_HOME = join(process.env.HOME ?? '', '.agent-browser')
const LOOPBACK_PROXY_BYPASS = '127.0.0.1,localhost,::1,[::1]'
export const DESKTOP_SMOKE_COMPOSER_SELECTOR = '[data-composer-editor]'
export const DESKTOP_SMOKE_RUN_SELECTOR = [
'button[aria-label="Run"]',
'button[aria-label="运行"]',
'button[aria-label="執行"]',
'button[aria-label="実行"]',
'button[aria-label="실행"]',
].join(',')
const PROMPT = [
'Run the tests in this project, fix the failing greeting implementation, and rerun the tests.',
'Only edit src/greeting.ts. Do not edit package.json or tests.',
@@ -44,6 +52,26 @@ export function resolveDesktopSmokeRuntimeSelection(target: BaselineTarget | und
}
}
export function resolveDesktopViteExecutable(
rootDir: string,
platform = process.platform,
): string | null {
const binDir = join(rootDir, 'desktop', 'node_modules', '.bin')
const candidates = platform === 'win32'
? ['vite.exe', 'vite.cmd', 'vite.bunx']
: ['vite']
for (const candidate of candidates) {
const executable = join(binDir, candidate)
if (existsSync(executable)) return executable
}
return null
}
export function desktopSmokeAppUrl(vitePort: number) {
return `http://localhost:${vitePort}`
}
function mergeProxyBypass(value: string | undefined) {
const entries = new Set(
(value ?? '')
@@ -71,8 +99,42 @@ export function buildDesktopSmokeBrowserEnv(
}
}
export function resolveAgentBrowserExecutable(
resolved = Bun.which('agent-browser'),
platform = process.platform,
arch = process.arch,
): string | null {
if (!resolved) return null
if (platform !== 'win32' || !resolved.toLowerCase().endsWith('.cmd')) return resolved
const nativeExecutable = join(
dirname(resolved),
'node_modules',
'agent-browser',
'bin',
`agent-browser-win32-${arch}.exe`,
)
return existsSync(nativeExecutable) ? nativeExecutable : null
}
function collectCommandStream(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
let output = ''
const done = (async () => {
while (true) {
const chunk = await reader.read()
if (chunk.done) break
output += decoder.decode(chunk.value, { stream: true })
}
output += decoder.decode()
return output
})()
return { done, cancel: () => reader.cancel() }
}
export function agentBrowserCommand(args: string[]) {
return ['agent-browser', '--proxy-bypass', LOOPBACK_PROXY_BYPASS, ...args]
return [resolveAgentBrowserExecutable() ?? 'agent-browser', '--proxy-bypass', LOOPBACK_PROXY_BYPASS, ...args]
}
function isProcessAlive(pid: number) {
@@ -240,12 +302,8 @@ export async function runLoggedCommand(
stdout: 'pipe',
stderr: 'pipe',
})
const outputPromise = Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
const stdout = collectCommandStream(proc.stdout)
const stderr = collectCommandStream(proc.stderr)
const timeout = options.timeoutMs
? Bun.sleep(options.timeoutMs).then(() => {
proc.kill()
@@ -253,10 +311,19 @@ export async function runLoggedCommand(
})
: null
const [stdout, stderr, exitCode] = timeout
? await Promise.race([outputPromise, timeout])
: await outputPromise
const output = `${stdout}${stderr}`
const exitCode = timeout
? await Promise.race([proc.exited, timeout])
: await proc.exited
let captured = await Promise.race([
Promise.all([stdout.done, stderr.done]),
Bun.sleep(250).then(() => null),
])
if (!captured) {
await Promise.allSettled([stdout.cancel(), stderr.cancel()])
captured = await Promise.all([stdout.done, stderr.done])
}
const [stdoutText, stderrText] = captured
const output = `${stdoutText}${stderrText}`
appendFileSync(
options.logPath,
options.maxLogChars && output.length > options.maxLogChars
@@ -268,7 +335,25 @@ export async function runLoggedCommand(
throw new Error(`Command failed (${exitCode}): ${command.join(' ')}`)
}
return { stdout, stderr, exitCode }
return { stdout: stdoutText, stderr: stderrText, exitCode }
}
export async function stopDesktopSmokeProcess(
proc: { pid: number; kill: () => void; exited: Promise<number> },
label: string,
logPath: string,
) {
try {
await killProcessTree(proc.pid, 'SIGTERM')
} catch (error) {
proc.kill()
appendFileSync(logPath, `\n[quality-gate] ${label} tree cleanup fell back to direct kill: ${error instanceof Error ? error.message : String(error)}\n`)
}
await Promise.race([proc.exited.catch(() => -1), Bun.sleep(3_000)])
}
export function removeDesktopSmokeTree(path: string) {
rmSync(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
@@ -335,12 +420,12 @@ async function waitForVerifiedProject(
throw new Error(`Timed out waiting for desktop project verification: ${lastVerificationError}`)
}
export function desktopSmokeTextShowsProject(text: string, projectName: string) {
export function desktopSmokeTextShowsProject(text: string, projectDir: string) {
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
return lines.includes(projectName)
return lines.includes(projectDir) || lines.includes(basename(projectDir))
}
async function waitForDesktopSmokeSessionReady(
@@ -350,7 +435,6 @@ async function waitForDesktopSmokeSessionReady(
projectDir: string,
timeoutMs: number,
) {
const projectName = basename(projectDir)
const deadline = Date.now() + timeoutMs
let lastText = ''
while (Date.now() < deadline) {
@@ -363,15 +447,15 @@ async function waitForDesktopSmokeSessionReady(
maxLogChars: 4_000,
})
lastText = `${body.stdout}\n${body.stderr}`
if (desktopSmokeTextShowsProject(lastText, projectName)) return
if (desktopSmokeTextShowsProject(lastText, projectDir)) return
await Bun.sleep(1_000)
}
throw new Error(`Timed out waiting for desktop smoke session to restore project "${projectName}". Last content text: ${lastText.slice(0, 500)}`)
throw new Error(`Timed out waiting for desktop smoke session to restore project "${basename(projectDir)}". Last content text: ${lastText.slice(0, 500)}`)
}
async function verifyProject(originalDir: string, projectDir: string, artifactDir: string) {
await writeDiffPatch(originalDir, projectDir, join(artifactDir, 'diff.patch'))
const changed = changedFiles(originalDir, projectDir)
const changed = changedFiles(originalDir, projectDir).map((file) => file.replaceAll('\\', '/'))
const unexpected = changed.filter((file) => file !== 'src/greeting.ts')
if (unexpected.length > 0) {
throw new Error(`desktop smoke changed unexpected files: ${unexpected.join(', ')}`)
@@ -422,7 +506,7 @@ export async function executeDesktopSmoke(
const serverPort = await getPort()
const vitePort = await getPort()
const baseUrl = `http://127.0.0.1:${serverPort}`
const appUrl = `http://127.0.0.1:${vitePort}`
const appUrl = desktopSmokeAppUrl(vitePort)
const sessionName = `quality-gate-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const browserEnv = buildDesktopSmokeBrowserEnv(sessionName, browserProfileDir)
const browserStepContext = {
@@ -453,13 +537,10 @@ export async function executeDesktopSmoke(
void pipeToFile(server.stdout, serverLogPath)
void pipeToFile(server.stderr, serverLogPath)
const viteExecutable = join(
rootDir,
'desktop',
'node_modules',
'.bin',
process.platform === 'win32' ? 'vite.cmd' : 'vite',
)
const viteExecutable = resolveDesktopViteExecutable(rootDir)
if (!viteExecutable) {
throw new Error('desktop Vite executable is missing; reinstall desktop dependencies')
}
const vite = Bun.spawn([viteExecutable, '--host', '127.0.0.1', '--port', String(vitePort), '--strictPort'], {
cwd: join(rootDir, 'desktop'),
env: {
@@ -520,7 +601,7 @@ export async function executeDesktopSmoke(
logPath: browserLogPath,
timeoutMs: 30_000,
}, browserStepContext)
await runBrowserStep('wait', ['wait', 'textarea'], {
await runBrowserStep('wait', ['wait', DESKTOP_SMOKE_COMPOSER_SELECTOR], {
cwd: rootDir,
env: browserEnv,
logPath: browserLogPath,
@@ -548,13 +629,13 @@ export async function executeDesktopSmoke(
timeoutMs: 20_000,
allowFailure: true,
}, browserStepContext)
await runBrowserStep('fill', ['fill', 'textarea', PROMPT], {
await runBrowserStep('fill', ['type', DESKTOP_SMOKE_COMPOSER_SELECTOR, PROMPT], {
cwd: rootDir,
env: browserEnv,
logPath: browserLogPath,
timeoutMs: 20_000,
}, browserStepContext)
await runBrowserStep('press', ['press', 'Enter'], {
await runBrowserStep('press', ['click', DESKTOP_SMOKE_RUN_SELECTOR], {
cwd: rootDir,
env: browserEnv,
logPath: browserLogPath,
@@ -609,11 +690,13 @@ export async function executeDesktopSmoke(
}).catch(() => {})
await cleanupAgentBrowserSession(sessionName, browserLogPath)
cleanupBrowserProfileProcesses(browserProfileDir, browserLogPath)
rmSync(browserProfileDir, { recursive: true, force: true })
removeDesktopSmokeTree(browserProfileDir)
appendFileSync(browserLogPath, `\n[quality-gate] Removed browser profile ${browserProfileDir}\n`)
server.kill()
vite.kill()
rmSync(workRoot, { recursive: true, force: true })
await Promise.all([
stopDesktopSmokeProcess(server, 'server', serverLogPath),
stopDesktopSmokeProcess(vite, 'Vite', viteLogPath),
])
removeDesktopSmokeTree(workRoot)
sandbox.cleanup()
}
}
@@ -12,6 +12,16 @@ const checks: Check[] = [
title: 'Server persistent JSON migrations',
command: ['bun', 'test', './src/server/__tests__/persistence-upgrade.test.ts'],
},
{
title: 'Desktop UI preference migrations',
command: [
'bun',
'test',
'./src/server/__tests__/desktop-ui-preferences.test.ts',
'--test-name-pattern',
'normalizes old schema files',
],
},
{
title: 'Desktop localStorage migrations',
command: ['bun', 'run', 'test', '--', '--run', 'src/lib/persistenceMigrations.test.ts'],