mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
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:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user