mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Introduce the Electron desktop shell alongside the existing React renderer and local Bun server boundary. The migration keeps the DesktopHost contract explicit across Tauri, Electron, and browser runtimes while adding Electron main/preload services for dialogs, shell, notifications, updates, tray/window lifecycle, terminal, preview WebContentsView, app mode, and release/package validation.
The commit also carries the latest local main desktop command updates, including agent slash entries and hidden-by-default markdown thinking details, so the packaged Electron build matches the current main UX surface.
Constraint: React renderer, local Bun server, REST/WebSocket, and sidecar boundaries must remain reusable during the migration
Constraint: macOS dev packages are ad-hoc signed and cannot prove Developer ID notarization or Gatekeeper release launch
Rejected: Browser-only smoke validation | it cannot exercise native dialogs, keychain prompts, notification behavior, or packaged app startup
Confidence: medium
Scope-risk: broad
Directive: Do not remove Tauri host support until signed Electron release artifacts pass native OS smoke on macOS, Windows, and Linux
Tested: bun run check:desktop
Tested: cd desktop && bun run check:electron
Tested: CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron📦dir
Tested: bun run test:package-smoke --platform macos --package-kind dir --artifacts-dir desktop/build-artifacts/electron
Tested: Computer Use read packaged Electron app window at desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app
Not-tested: Developer ID signed/notarized Gatekeeper launch
Not-tested: Real OS notification click-to-session action
Not-tested: Windows and Linux packaged app smoke on real hosts
562 lines
18 KiB
TypeScript
562 lines
18 KiB
TypeScript
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { mkdtemp } from 'node:fs/promises'
|
|
import { createServer } from 'node:net'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import treeKill from 'tree-kill'
|
|
import { changedFiles, writeDiffPatch } from '../baseline/execute'
|
|
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]'
|
|
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.',
|
|
'When the tests pass, briefly say done.',
|
|
].join(' ')
|
|
|
|
type DesktopSmokeStage = 'open' | 'eval' | 'reload' | 'wait' | 'screenshot' | 'fill' | 'press' | 'verify'
|
|
|
|
type DesktopSmokeFailureContext = {
|
|
stage: DesktopSmokeStage
|
|
sessionName: string
|
|
browserProfileDir: string
|
|
artifactDir: string
|
|
browserLogPath: string
|
|
serverLogPath: string
|
|
viteLogPath: string
|
|
appUrl: string
|
|
baseUrl: string
|
|
serverPort: number
|
|
vitePort: number
|
|
}
|
|
|
|
export function resolveDesktopSmokeRuntimeSelection(target: BaselineTarget | undefined) {
|
|
if (!target) return null
|
|
if (!target.providerId && target.modelId === 'current' && target.label === 'current-runtime') {
|
|
return null
|
|
}
|
|
return {
|
|
providerId: target.providerId ?? null,
|
|
modelId: target.modelId,
|
|
}
|
|
}
|
|
|
|
function mergeProxyBypass(value: string | undefined) {
|
|
const entries = new Set(
|
|
(value ?? '')
|
|
.split(',')
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean),
|
|
)
|
|
for (const entry of LOOPBACK_PROXY_BYPASS.split(',')) {
|
|
entries.add(entry)
|
|
}
|
|
return Array.from(entries).join(',')
|
|
}
|
|
|
|
export function buildDesktopSmokeBrowserEnv(
|
|
sessionName: string,
|
|
browserProfileDir: string,
|
|
baseEnv: Record<string, string | undefined> = process.env,
|
|
) {
|
|
const noProxy = mergeProxyBypass(baseEnv.NO_PROXY ?? baseEnv.no_proxy)
|
|
return {
|
|
AGENT_BROWSER_SESSION: sessionName,
|
|
AGENT_BROWSER_PROFILE: browserProfileDir,
|
|
NO_PROXY: noProxy,
|
|
no_proxy: noProxy,
|
|
}
|
|
}
|
|
|
|
function agentBrowserCommand(args: string[]) {
|
|
return ['agent-browser', '--proxy-bypass', LOOPBACK_PROXY_BYPASS, ...args]
|
|
}
|
|
|
|
function isProcessAlive(pid: number) {
|
|
try {
|
|
process.kill(pid, 0)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function waitForProcessExit(pid: number, timeoutMs: number) {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
if (!isProcessAlive(pid)) return true
|
|
await Bun.sleep(100)
|
|
}
|
|
return !isProcessAlive(pid)
|
|
}
|
|
|
|
async function killProcessTree(pid: number, signal: 'SIGTERM' | 'SIGKILL') {
|
|
await new Promise<void>((resolve, reject) => {
|
|
treeKill(pid, signal, (error) => {
|
|
if (error) reject(error)
|
|
else resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
async function cleanupAgentBrowserSession(sessionName: string, logPath: string) {
|
|
if (!sessionName || !existsSync(AGENT_BROWSER_HOME)) return
|
|
|
|
const metadataSuffixes = ['pid', 'sock', 'stream', 'engine', 'version']
|
|
const pidPath = join(AGENT_BROWSER_HOME, `${sessionName}.pid`)
|
|
if (existsSync(pidPath)) {
|
|
const rawPid = readFileSync(pidPath, 'utf8').trim()
|
|
const pid = Number(rawPid)
|
|
if (Number.isInteger(pid) && pid > 0) {
|
|
try {
|
|
await killProcessTree(pid, 'SIGTERM')
|
|
if (!await waitForProcessExit(pid, 1_500)) {
|
|
await killProcessTree(pid, 'SIGKILL')
|
|
}
|
|
appendFileSync(logPath, `\n[quality-gate] Killed agent-browser session process tree pid=${pid}\n`)
|
|
} catch (error) {
|
|
appendFileSync(logPath, `\n[quality-gate] Failed to kill agent-browser session process tree pid=${pid}: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const suffix of metadataSuffixes) {
|
|
rmSync(join(AGENT_BROWSER_HOME, `${sessionName}.${suffix}`), { force: true })
|
|
}
|
|
}
|
|
|
|
function cleanupBrowserProfileProcesses(browserProfileDir: string, logPath: string) {
|
|
if (process.platform === 'win32') {
|
|
appendFileSync(logPath, '\n[quality-gate] Skipped browser profile process cleanup on Windows\n')
|
|
return
|
|
}
|
|
|
|
const proc = Bun.spawnSync(['pkill', '-9', '-f', browserProfileDir], {
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
})
|
|
const output = `${proc.stdout ? Buffer.from(proc.stdout).toString('utf8') : ''}${proc.stderr ? Buffer.from(proc.stderr).toString('utf8') : ''}`.trim()
|
|
if (proc.exitCode === 0) {
|
|
appendFileSync(logPath, `\n[quality-gate] Killed browser processes for profile ${browserProfileDir}\n`)
|
|
} else if (output) {
|
|
appendFileSync(logPath, `\n[quality-gate] Browser profile process cleanup reported: ${output}\n`)
|
|
}
|
|
}
|
|
|
|
function writeDesktopSmokeFailure(context: DesktopSmokeFailureContext, error: unknown) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
const diagnostics = {
|
|
stage: context.stage,
|
|
message,
|
|
sessionName: context.sessionName,
|
|
browserProfileDir: context.browserProfileDir,
|
|
appUrl: context.appUrl,
|
|
baseUrl: context.baseUrl,
|
|
serverPort: context.serverPort,
|
|
vitePort: context.vitePort,
|
|
logs: {
|
|
browser: context.browserLogPath,
|
|
server: context.serverLogPath,
|
|
vite: context.viteLogPath,
|
|
},
|
|
}
|
|
writeFileSync(join(context.artifactDir, 'desktop-smoke-failure.json'), JSON.stringify(diagnostics, null, 2) + '\n')
|
|
return new Error(`Desktop smoke failed during ${context.stage}: ${message}. session=${context.sessionName} appUrl=${context.appUrl} profile=${context.browserProfileDir} diagnostics=${join(context.artifactDir, 'desktop-smoke-failure.json')}`)
|
|
}
|
|
|
|
async function runBrowserStep(
|
|
stage: DesktopSmokeStage,
|
|
args: string[],
|
|
options: Parameters<typeof runLoggedCommand>[1],
|
|
context: Omit<DesktopSmokeFailureContext, 'stage'>,
|
|
) {
|
|
try {
|
|
return await runLoggedCommand(agentBrowserCommand(args), options)
|
|
} catch (error) {
|
|
throw writeDesktopSmokeFailure({ ...context, stage }, error)
|
|
}
|
|
}
|
|
|
|
async function getPort(): Promise<number> {
|
|
return await new Promise((resolve, reject) => {
|
|
const server = createServer()
|
|
server.on('error', reject)
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const address = server.address()
|
|
if (!address || typeof address === 'string') {
|
|
server.close(() => reject(new Error('Failed to allocate a local port')))
|
|
return
|
|
}
|
|
const port = address.port
|
|
server.close(() => resolve(port))
|
|
})
|
|
})
|
|
}
|
|
|
|
async function pipeToFile(stream: ReadableStream<Uint8Array> | null, path: string) {
|
|
if (!stream) return
|
|
const reader = stream.getReader()
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
appendFileSync(path, Buffer.from(value))
|
|
}
|
|
}
|
|
|
|
async function waitForHttp(url: string, timeoutMs: number) {
|
|
const deadline = Date.now() + timeoutMs
|
|
let lastError = ''
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(url)
|
|
if (response.ok) return
|
|
lastError = `HTTP ${response.status}`
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error.message : String(error)
|
|
}
|
|
await Bun.sleep(500)
|
|
}
|
|
throw new Error(`Timed out waiting for ${url}${lastError ? ` (${lastError})` : ''}`)
|
|
}
|
|
|
|
async function runLoggedCommand(
|
|
command: string[],
|
|
options: {
|
|
cwd: string
|
|
logPath: string
|
|
env?: Record<string, string>
|
|
timeoutMs?: number
|
|
allowFailure?: boolean
|
|
maxLogChars?: number
|
|
},
|
|
) {
|
|
appendFileSync(options.logPath, `\n$ ${command.join(' ')}\n`)
|
|
const proc = Bun.spawn(command, {
|
|
cwd: options.cwd,
|
|
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
})
|
|
|
|
const outputPromise = Promise.all([
|
|
new Response(proc.stdout).text(),
|
|
new Response(proc.stderr).text(),
|
|
proc.exited,
|
|
])
|
|
const timeout = options.timeoutMs
|
|
? Bun.sleep(options.timeoutMs).then(() => {
|
|
proc.kill()
|
|
throw new Error(`Command timed out after ${options.timeoutMs}ms: ${command.join(' ')}`)
|
|
})
|
|
: null
|
|
|
|
const [stdout, stderr, exitCode] = timeout
|
|
? await Promise.race([outputPromise, timeout])
|
|
: await outputPromise
|
|
const output = `${stdout}${stderr}`
|
|
appendFileSync(
|
|
options.logPath,
|
|
options.maxLogChars && output.length > options.maxLogChars
|
|
? `${output.slice(0, options.maxLogChars)}\n[quality-gate] output truncated at ${options.maxLogChars} chars\n`
|
|
: output,
|
|
)
|
|
|
|
if (exitCode !== 0 && !options.allowFailure) {
|
|
throw new Error(`Command failed (${exitCode}): ${command.join(' ')}`)
|
|
}
|
|
|
|
return { stdout, stderr, exitCode }
|
|
}
|
|
|
|
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
|
const response = await fetch(url, init)
|
|
if (!response.ok) {
|
|
throw new Error(`${init?.method ?? 'GET'} ${url} failed with HTTP ${response.status}: ${await response.text()}`)
|
|
}
|
|
return response.json() as Promise<T>
|
|
}
|
|
|
|
async function setPermissionMode(baseUrl: string, mode: string) {
|
|
await fetchJson<{ ok: true; mode: string }>(`${baseUrl}/api/permissions/mode`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ mode }),
|
|
})
|
|
}
|
|
|
|
async function waitForVerifiedProject(
|
|
browserEnv: Record<string, string>,
|
|
browserLogPath: string,
|
|
rootDir: string,
|
|
originalDir: string,
|
|
projectDir: string,
|
|
artifactDir: string,
|
|
timeoutMs: number,
|
|
) {
|
|
const deadline = Date.now() + timeoutMs
|
|
let lastVerificationError = 'project verification has not run yet'
|
|
while (Date.now() < deadline) {
|
|
const body = await runLoggedCommand(agentBrowserCommand(['get', 'text', '#content-area']), {
|
|
cwd: rootDir,
|
|
env: browserEnv,
|
|
logPath: browserLogPath,
|
|
timeoutMs: 15_000,
|
|
allowFailure: true,
|
|
maxLogChars: 4_000,
|
|
})
|
|
const browserText = `${body.stdout}\n${body.stderr}`
|
|
|
|
if (browserText.includes('CLI_START_FAILED') || browserText.includes('CLI_RESTART_FAILED')) {
|
|
throw new Error('Desktop session reported a CLI startup failure')
|
|
}
|
|
if (
|
|
browserText.includes('API Error: 429') ||
|
|
browserText.includes('AccountQuotaExceeded') ||
|
|
browserText.includes('TooManyRequests')
|
|
) {
|
|
throw new Error('Desktop session reported provider quota/rate-limit failure')
|
|
}
|
|
if (browserText.includes('处理过程中发生错误') || browserText.includes('API Error:')) {
|
|
throw new Error('Desktop session reported an API error')
|
|
}
|
|
|
|
try {
|
|
await verifyProject(originalDir, projectDir, artifactDir)
|
|
return
|
|
} catch (error) {
|
|
lastVerificationError = error instanceof Error ? error.message : String(error)
|
|
}
|
|
await Bun.sleep(5_000)
|
|
}
|
|
|
|
throw new Error(`Timed out waiting for desktop project verification: ${lastVerificationError}`)
|
|
}
|
|
|
|
async function verifyProject(originalDir: string, projectDir: string, artifactDir: string) {
|
|
await writeDiffPatch(originalDir, projectDir, join(artifactDir, 'diff.patch'))
|
|
const changed = changedFiles(originalDir, projectDir)
|
|
const unexpected = changed.filter((file) => file !== 'src/greeting.ts')
|
|
if (unexpected.length > 0) {
|
|
throw new Error(`desktop smoke changed unexpected files: ${unexpected.join(', ')}`)
|
|
}
|
|
if (!changed.includes('src/greeting.ts')) {
|
|
throw new Error('desktop smoke did not change src/greeting.ts')
|
|
}
|
|
|
|
const implementation = readFileSync(join(projectDir, 'src/greeting.ts'), 'utf8')
|
|
if (!implementation.includes('from desktop smoke!')) {
|
|
throw new Error('desktop smoke implementation is missing the expected marker text')
|
|
}
|
|
|
|
const proc = Bun.spawn(['bun', 'test'], {
|
|
cwd: projectDir,
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
})
|
|
const stdout = await new Response(proc.stdout).text()
|
|
const stderr = await new Response(proc.stderr).text()
|
|
const exitCode = await proc.exited
|
|
writeFileSync(join(artifactDir, 'verification.log'), `${stdout}${stderr}`)
|
|
if (exitCode !== 0) {
|
|
throw new Error(`desktop smoke verification failed with exit code ${exitCode}`)
|
|
}
|
|
}
|
|
|
|
export async function executeDesktopSmoke(
|
|
rootDir: string,
|
|
artifactDir: string,
|
|
resultId: string,
|
|
resultTitle: string,
|
|
target: BaselineTarget | undefined,
|
|
): Promise<LaneResult> {
|
|
const started = Date.now()
|
|
mkdirSync(artifactDir, { recursive: true })
|
|
|
|
const serverLogPath = join(artifactDir, 'server.log')
|
|
const viteLogPath = join(artifactDir, 'vite.log')
|
|
const browserLogPath = join(artifactDir, 'browser.log')
|
|
const workRoot = await mkdtemp(join(tmpdir(), 'quality-gate-desktop-smoke-'))
|
|
const originalDir = join(workRoot, 'original')
|
|
const projectDir = join(workRoot, 'project')
|
|
const browserProfileDir = join(workRoot, 'browser-profile')
|
|
cpSync(join(rootDir, FIXTURE), originalDir, { recursive: true })
|
|
cpSync(join(rootDir, FIXTURE), projectDir, { recursive: true })
|
|
|
|
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 sessionName = `quality-gate-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
const browserEnv = buildDesktopSmokeBrowserEnv(sessionName, browserProfileDir)
|
|
const browserStepContext = {
|
|
sessionName,
|
|
browserProfileDir,
|
|
artifactDir,
|
|
browserLogPath,
|
|
serverLogPath,
|
|
viteLogPath,
|
|
appUrl,
|
|
baseUrl,
|
|
serverPort,
|
|
vitePort,
|
|
}
|
|
|
|
const server = Bun.spawn(['bun', 'run', 'src/server/index.ts', '--host', '127.0.0.1', '--port', String(serverPort)], {
|
|
cwd: rootDir,
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
})
|
|
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 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,
|
|
},
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
})
|
|
void pipeToFile(vite.stdout, viteLogPath)
|
|
void pipeToFile(vite.stderr, viteLogPath)
|
|
|
|
let previousPermissionMode: string | null = null
|
|
try {
|
|
await waitForHttp(`${baseUrl}/health`, 20_000)
|
|
await waitForHttp(appUrl, 30_000)
|
|
|
|
const permission = await fetchJson<{ mode: string }>(`${baseUrl}/api/permissions/mode`)
|
|
previousPermissionMode = permission.mode
|
|
await setPermissionMode(baseUrl, 'bypassPermissions')
|
|
|
|
const session = await fetchJson<{ sessionId: string }>(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir: projectDir }),
|
|
})
|
|
const runtimeSelection = resolveDesktopSmokeRuntimeSelection(target)
|
|
writeFileSync(join(artifactDir, 'runtime-selection.json'), JSON.stringify(runtimeSelection
|
|
? { source: 'explicit-target', ...runtimeSelection }
|
|
: { source: 'default-runtime' }, null, 2) + '\n')
|
|
|
|
await runBrowserStep('open', ['open', appUrl], {
|
|
cwd: rootDir,
|
|
env: browserEnv,
|
|
logPath: browserLogPath,
|
|
timeoutMs: 30_000,
|
|
}, browserStepContext)
|
|
const browserSetup = [
|
|
`localStorage.setItem('cc-haha-open-tabs', ${JSON.stringify(JSON.stringify({
|
|
openTabs: [{ sessionId: session.sessionId, title: 'Desktop Smoke', type: 'session' }],
|
|
activeTabId: session.sessionId,
|
|
}))})`,
|
|
runtimeSelection
|
|
? `localStorage.setItem('cc-haha-session-runtime', ${JSON.stringify(JSON.stringify({
|
|
[session.sessionId]: runtimeSelection,
|
|
}))})`
|
|
: `localStorage.removeItem('cc-haha-session-runtime')`,
|
|
]
|
|
await runBrowserStep('eval', ['eval', browserSetup.join(';')], {
|
|
cwd: rootDir,
|
|
env: browserEnv,
|
|
logPath: browserLogPath,
|
|
timeoutMs: 15_000,
|
|
}, browserStepContext)
|
|
await runBrowserStep('reload', ['reload'], {
|
|
cwd: rootDir,
|
|
env: browserEnv,
|
|
logPath: browserLogPath,
|
|
timeoutMs: 30_000,
|
|
}, browserStepContext)
|
|
await runBrowserStep('wait', ['wait', 'textarea'], {
|
|
cwd: rootDir,
|
|
env: browserEnv,
|
|
logPath: browserLogPath,
|
|
timeoutMs: 30_000,
|
|
}, browserStepContext)
|
|
await runBrowserStep('screenshot', ['screenshot', join(artifactDir, 'initial.png')], {
|
|
cwd: rootDir,
|
|
env: browserEnv,
|
|
logPath: browserLogPath,
|
|
timeoutMs: 20_000,
|
|
allowFailure: true,
|
|
}, browserStepContext)
|
|
await runBrowserStep('fill', ['fill', 'textarea', PROMPT], {
|
|
cwd: rootDir,
|
|
env: browserEnv,
|
|
logPath: browserLogPath,
|
|
timeoutMs: 20_000,
|
|
}, browserStepContext)
|
|
await runBrowserStep('press', ['press', 'Enter'], {
|
|
cwd: rootDir,
|
|
env: browserEnv,
|
|
logPath: browserLogPath,
|
|
timeoutMs: 15_000,
|
|
}, browserStepContext)
|
|
|
|
await waitForVerifiedProject(
|
|
browserEnv,
|
|
browserLogPath,
|
|
rootDir,
|
|
originalDir,
|
|
projectDir,
|
|
artifactDir,
|
|
360_000,
|
|
)
|
|
await runBrowserStep('screenshot', ['screenshot', join(artifactDir, 'final.png')], {
|
|
cwd: rootDir,
|
|
env: browserEnv,
|
|
logPath: browserLogPath,
|
|
timeoutMs: 20_000,
|
|
allowFailure: true,
|
|
}, browserStepContext)
|
|
|
|
return {
|
|
id: resultId,
|
|
title: resultTitle,
|
|
status: 'passed',
|
|
durationMs: Date.now() - started,
|
|
artifactDir,
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
id: resultId,
|
|
title: resultTitle,
|
|
status: 'failed',
|
|
durationMs: Date.now() - started,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
artifactDir,
|
|
}
|
|
} finally {
|
|
if (previousPermissionMode) {
|
|
await setPermissionMode(baseUrl, previousPermissionMode).catch((error) => {
|
|
appendFileSync(serverLogPath, `\n[quality-gate] Failed to restore permission mode: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
})
|
|
}
|
|
await runLoggedCommand(agentBrowserCommand(['close']), {
|
|
cwd: rootDir,
|
|
env: browserEnv,
|
|
logPath: browserLogPath,
|
|
timeoutMs: 10_000,
|
|
allowFailure: true,
|
|
}).catch(() => {})
|
|
await cleanupAgentBrowserSession(sessionName, browserLogPath)
|
|
cleanupBrowserProfileProcesses(browserProfileDir, browserLogPath)
|
|
rmSync(browserProfileDir, { recursive: true, force: true })
|
|
appendFileSync(browserLogPath, `\n[quality-gate] Removed browser profile ${browserProfileDir}\n`)
|
|
server.kill()
|
|
vite.kill()
|
|
rmSync(workRoot, { recursive: true, force: true })
|
|
}
|
|
}
|