cc-haha/desktop/electron/services/appMode.test.ts
程序员阿江(Relakkes) 718e80f8c5 fix(desktop): harden portable storage writes (#949)
Guard best-effort Electron window-state persistence so an unwritable data directory cannot crash the main process. Validate portable data directories before app-mode persistence and surface failures back to settings so restart is not attempted after rollback.

Tested: cd desktop && bun test electron/services/windows.test.ts electron/services/appMode.test.ts
Tested: cd desktop && bun run test src/stores/settingsStore.test.ts -t "settingsStore app mode" --run
Tested: cd desktop && bun run check:electron
Tested: bun run check:desktop
Not-tested: bun run check:native; bun run verify; coverage gates not run for scoped local handoff.
Confidence: high
Scope-risk: narrow
2026-07-03 18:23:35 +08:00

143 lines
5.3 KiB
TypeScript

import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
applyStartupPortableMode,
defaultPortableDir,
detectPortableDir,
determineStartupPortableDir,
dirHasPortableData,
getAppMode,
setAppMode,
type AppModeAppLike,
} from './appMode'
const tempDirs: string[] = []
function tempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-app-mode-'))
tempDirs.push(dir)
return dir
}
function app(root = tempDir()): AppModeAppLike & { root: string } {
const exe = path.join(root, 'Claude Code Haha.app', 'Contents', 'MacOS', 'Claude Code Haha')
const userData = path.join(root, 'user-data')
fs.mkdirSync(path.dirname(exe), { recursive: true })
fs.writeFileSync(exe, '')
return {
root,
getPath(name) {
return name === 'exe' ? exe : userData
},
}
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true })
}
})
describe('Electron app mode service', () => {
it('detects portable data using the same sentinel files and directories as Tauri', () => {
const root = tempDir()
expect(dirHasPortableData(root)).toBe(false)
fs.writeFileSync(path.join(root, 'settings.json'), '{}')
expect(dirHasPortableData(root)).toBe(true)
fs.rmSync(path.join(root, 'settings.json'))
fs.mkdirSync(path.join(root, 'projects'))
expect(dirHasPortableData(root)).toBe(true)
})
it('resolves startup portable mode from default portable data or app-mode config', () => {
const fakeApp = app()
const defaultDir = defaultPortableDir(fakeApp)
fs.mkdirSync(defaultDir, { recursive: true })
fs.writeFileSync(path.join(defaultDir, 'settings.json'), '{}')
expect(determineStartupPortableDir(fakeApp, {})).toBe(defaultDir)
expect(determineStartupPortableDir(fakeApp, { CLAUDE_CONFIG_DIR: '/external' })).toBeNull()
fs.writeFileSync(path.join(defaultDir, 'app-mode.json'), JSON.stringify({ mode: 'default' }))
expect(determineStartupPortableDir(fakeApp, {})).toBeNull()
})
it('sets portable environment variables before sidecars start', () => {
const fakeApp = app()
const env: NodeJS.ProcessEnv = {}
const defaultDir = defaultPortableDir(fakeApp)
fs.mkdirSync(defaultDir, { recursive: true })
fs.writeFileSync(path.join(defaultDir, 'settings.json'), '{}')
expect(applyStartupPortableMode(fakeApp, env)).toBe(defaultDir)
expect(env.CLAUDE_CONFIG_DIR).toBe(defaultDir)
expect(env.CC_HAHA_APP_PORTABLE_DIR).toBe('1')
expect(env.WEBVIEW2_USER_DATA_FOLDER).toBe(path.join(defaultDir, 'EBWebView'))
})
it('returns the active app mode shape expected by settingsStore', () => {
const fakeApp = app()
expect(getAppMode(fakeApp, {})).toEqual({
mode: 'default',
portableDir: defaultPortableDir(fakeApp),
defaultPortableDir: defaultPortableDir(fakeApp),
activeConfigDir: fakeApp.getPath('userData'),
configDirSource: 'system',
})
expect(getAppMode(fakeApp, { CLAUDE_CONFIG_DIR: '/portable', CC_HAHA_APP_PORTABLE_DIR: '1' })).toMatchObject({
mode: 'portable',
portableDir: '/portable',
activeConfigDir: '/portable',
configDirSource: 'portable',
})
expect(getAppMode(fakeApp, { CLAUDE_CONFIG_DIR: '/external' })).toMatchObject({
configDirSource: 'environment',
})
})
it('writes app-mode.json to active, target portable, and system config dirs', () => {
const fakeApp = app()
const active = tempDir()
const selected = path.join(tempDir(), 'portable')
setAppMode(fakeApp, { mode: 'portable', portableDir: selected }, { CLAUDE_CONFIG_DIR: active })
const expected = { mode: 'portable', portable_dir: selected }
expect(JSON.parse(fs.readFileSync(path.join(active, 'app-mode.json'), 'utf8'))).toEqual(expected)
expect(JSON.parse(fs.readFileSync(path.join(selected, 'app-mode.json'), 'utf8'))).toEqual(expected)
expect(JSON.parse(fs.readFileSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'), 'utf8'))).toEqual(expected)
setAppMode(fakeApp, { mode: 'default', portableDir: null }, { CLAUDE_CONFIG_DIR: active })
expect(JSON.parse(fs.readFileSync(path.join(active, 'app-mode.json'), 'utf8'))).toEqual({
mode: 'default',
portable_dir: null,
})
})
it('does not point existing config at a portable dir that cannot persist app-mode.json', () => {
const fakeApp = app()
const active = tempDir()
const selected = path.join(tempDir(), 'portable')
fs.mkdirSync(path.join(selected, 'app-mode.json'), { recursive: true })
expect(() => setAppMode(fakeApp, { mode: 'portable', portableDir: selected }, { CLAUDE_CONFIG_DIR: active }))
.toThrow()
expect(fs.existsSync(path.join(active, 'app-mode.json'))).toBe(false)
expect(fs.existsSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'))).toBe(false)
})
it('reports whether the default portable dir already has data', () => {
const fakeApp = app()
expect(detectPortableDir(fakeApp)).toEqual({
defaultPortableDir: defaultPortableDir(fakeApp),
hasData: false,
})
fs.mkdirSync(defaultPortableDir(fakeApp), { recursive: true })
fs.writeFileSync(path.join(defaultPortableDir(fakeApp), '.mcp.json'), '{}')
expect(detectPortableDir(fakeApp).hasData).toBe(true)
})
})