cc-haha/desktop/electron/services/appMode.test.ts
2026-07-11 21:09:19 +08:00

275 lines
10 KiB
TypeScript

import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
applyStartupPortableMode,
determineStartupPortableDir,
getAppMode,
setAppMode,
systemClaudeConfigDir,
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, 'install', 'Claude Code Haha')
const home = path.join(root, 'home')
const userData = path.join(root, 'user-data')
fs.mkdirSync(path.dirname(exe), { recursive: true })
fs.writeFileSync(exe, '')
return {
root,
getPath(name) {
if (name === 'exe') return exe
if (name === 'home') return home
return userData
},
}
}
function writeMode(fakeApp: AppModeAppLike, value: unknown) {
const userData = fakeApp.getPath('userData')
fs.mkdirSync(userData, { recursive: true })
fs.writeFileSync(path.join(userData, 'app-mode.json'), JSON.stringify(value))
}
afterEach(() => {
vi.restoreAllMocks()
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true })
}
})
describe('Electron app mode service', () => {
it('always uses ~/.claude in system mode and ignores app-adjacent legacy data at runtime', () => {
const fakeApp = app()
const legacyDir = path.join(path.dirname(fakeApp.getPath('exe')), 'CLAUDE_CONFIG_DIR')
fs.mkdirSync(legacyDir, { recursive: true })
fs.writeFileSync(path.join(legacyDir, 'settings.json'), '{"legacy":true}')
expect(systemClaudeConfigDir(fakeApp)).toBe(path.join(fakeApp.root, 'home', '.claude'))
expect(determineStartupPortableDir(fakeApp, {})).toBeNull()
expect(applyStartupPortableMode(fakeApp, {})).toBeNull()
expect(getAppMode(fakeApp, {})).toEqual({
mode: 'default',
portableDir: null,
activeConfigDir: path.join(fakeApp.root, 'home', '.claude'),
configDirSource: 'system',
})
})
it('activates only an explicit absolute custom directory persisted in userData', () => {
const fakeApp = app()
const customDir = path.join(fakeApp.root, 'custom-data')
writeMode(fakeApp, { mode: 'portable', portable_dir: customDir })
const env: NodeJS.ProcessEnv = {}
expect(determineStartupPortableDir(fakeApp, env)).toBe(customDir)
expect(applyStartupPortableMode(fakeApp, env)).toBe(customDir)
expect(env).toMatchObject({
CLAUDE_CONFIG_DIR: customDir,
CC_HAHA_APP_PORTABLE_DIR: '1',
WEBVIEW2_USER_DATA_FOLDER: path.join(customDir, 'EBWebView'),
})
expect(getAppMode(fakeApp, env)).toEqual({
mode: 'portable',
portableDir: customDir,
activeConfigDir: customDir,
configDirSource: 'portable',
})
})
it('treats an externally supplied CLAUDE_CONFIG_DIR as a read-only override', () => {
const fakeApp = app()
const externalDir = path.join(fakeApp.root, 'external-data')
const env: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: externalDir }
expect(determineStartupPortableDir(fakeApp, env)).toBeNull()
expect(applyStartupPortableMode(fakeApp, env)).toBeNull()
expect(env).toEqual({ CLAUDE_CONFIG_DIR: externalDir })
expect(getAppMode(fakeApp, env)).toEqual({
mode: 'portable',
portableDir: externalDir,
activeConfigDir: externalDir,
configDirSource: 'environment',
})
expect(() => setAppMode(fakeApp, { mode: 'default', portableDir: null }, env))
.toThrow('CLAUDE_CONFIG_DIR is controlled by the launch environment')
})
it('rejects relative or install-contained external custom directories', () => {
const fakeApp = app()
const installData = path.join(path.dirname(fakeApp.getPath('exe')), 'external-data')
expect(() => applyStartupPortableMode(fakeApp, {
CLAUDE_CONFIG_DIR: 'relative-data',
})).toThrow('absolute path')
expect(() => getAppMode(fakeApp, {
CLAUDE_CONFIG_DIR: 'relative-data',
})).toThrow('absolute path')
expect(() => applyStartupPortableMode(fakeApp, {
CLAUDE_CONFIG_DIR: installData,
})).toThrow('outside the application install directory')
})
it('drops inherited app-managed env so switching back to ~/.claude survives relaunch', () => {
const fakeApp = app()
writeMode(fakeApp, { mode: 'default', portable_dir: null })
const oldCustomDir = path.join(fakeApp.root, 'old-custom')
const env: NodeJS.ProcessEnv = {
CLAUDE_CONFIG_DIR: oldCustomDir,
CC_HAHA_APP_PORTABLE_DIR: '1',
WEBVIEW2_USER_DATA_FOLDER: path.join(oldCustomDir, 'EBWebView'),
}
expect(applyStartupPortableMode(fakeApp, env)).toBeNull()
expect(env.CLAUDE_CONFIG_DIR).toBeUndefined()
expect(env.CC_HAHA_APP_PORTABLE_DIR).toBeUndefined()
expect(env.WEBVIEW2_USER_DATA_FOLDER).toBeUndefined()
expect(getAppMode(fakeApp, env)).toMatchObject({
mode: 'default',
activeConfigDir: systemClaudeConfigDir(fakeApp),
})
})
it('replaces an inherited app-managed env with the newly persisted custom directory', () => {
const fakeApp = app()
const newCustomDir = path.join(fakeApp.root, 'new-custom')
writeMode(fakeApp, { mode: 'portable', portable_dir: newCustomDir })
const env: NodeJS.ProcessEnv = {
CLAUDE_CONFIG_DIR: path.join(fakeApp.root, 'old-custom'),
CC_HAHA_APP_PORTABLE_DIR: '1',
WEBVIEW2_USER_DATA_FOLDER: path.join(fakeApp.root, 'old-custom', 'EBWebView'),
}
expect(applyStartupPortableMode(fakeApp, env)).toBe(newCustomDir)
expect(env.CLAUDE_CONFIG_DIR).toBe(newCustomDir)
expect(env.WEBVIEW2_USER_DATA_FOLDER).toBe(path.join(newCustomDir, 'EBWebView'))
})
it.each([
{ mode: 'portable', portable_dir: null },
{ mode: 'portable', portable_dir: '' },
{ mode: 'portable', portable_dir: 'relative-data' },
{ mode: 'unknown', portable_dir: '/tmp/custom' },
])('falls back to system mode for invalid custom metadata: %o', value => {
const fakeApp = app()
writeMode(fakeApp, value)
expect(determineStartupPortableDir(fakeApp, {})).toBeNull()
expect(getAppMode(fakeApp, {})).toMatchObject({
mode: 'default',
portableDir: null,
activeConfigDir: systemClaudeConfigDir(fakeApp),
configDirSource: 'system',
})
})
it('persists one atomic system-owned mode record for a custom directory', () => {
const fakeApp = app()
const customDir = path.join(fakeApp.root, 'custom-data')
const previousActive = path.join(fakeApp.root, 'previous-custom')
setAppMode(fakeApp, { mode: 'portable', portableDir: customDir }, {
CLAUDE_CONFIG_DIR: previousActive,
CC_HAHA_APP_PORTABLE_DIR: '1',
})
expect(JSON.parse(fs.readFileSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'), 'utf8'))).toEqual({
mode: 'portable',
portable_dir: customDir,
})
expect(fs.existsSync(path.join(customDir, 'app-mode.json'))).toBe(false)
expect(fs.existsSync(path.join(previousActive, 'app-mode.json'))).toBe(false)
expect(fs.readdirSync(fakeApp.getPath('userData'))).toEqual(['app-mode.json'])
})
it('switches back to system mode without touching the custom directory', () => {
const fakeApp = app()
const customDir = path.join(fakeApp.root, 'custom-data')
fs.mkdirSync(customDir, { recursive: true })
fs.writeFileSync(path.join(customDir, 'settings.json'), '{"keep":true}')
writeMode(fakeApp, { mode: 'portable', portable_dir: customDir })
setAppMode(fakeApp, { mode: 'default', portableDir: null }, {
CLAUDE_CONFIG_DIR: customDir,
CC_HAHA_APP_PORTABLE_DIR: '1',
})
expect(JSON.parse(fs.readFileSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'), 'utf8'))).toEqual({
mode: 'default',
portable_dir: null,
})
expect(fs.readFileSync(path.join(customDir, 'settings.json'), 'utf8')).toBe('{"keep":true}')
})
it.each([
{ label: 'missing', value: null },
{ label: 'empty', value: ' ' },
{ label: 'relative', value: 'relative-data' },
])('rejects a $label custom directory', ({ value }) => {
const fakeApp = app()
expect(() => setAppMode(fakeApp, { mode: 'portable', portableDir: value }, {})).toThrow()
expect(fs.existsSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'))).toBe(false)
})
it('rejects custom directories inside the application install tree, including symlink aliases', () => {
const fakeApp = app()
const installDir = path.dirname(fakeApp.getPath('exe'))
const aliasedInstallDir = path.join(fakeApp.root, 'install-alias')
fs.symlinkSync(installDir, aliasedInstallDir, 'dir')
expect(() => setAppMode(fakeApp, {
mode: 'portable',
portableDir: path.join(installDir, 'data'),
}, {})).toThrow('outside the application install directory')
expect(() => setAppMode(fakeApp, {
mode: 'portable',
portableDir: path.join(aliasedInstallDir, 'data'),
}, {})).toThrow('outside the application install directory')
})
it('does not partially mutate process.env when custom startup preparation fails', () => {
const fakeApp = app()
const customDir = path.join(fakeApp.root, 'custom-data')
writeMode(fakeApp, { mode: 'portable', portable_dir: customDir })
const env: NodeJS.ProcessEnv = {}
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
throw new Error('mkdir failed')
})
expect(() => applyStartupPortableMode(fakeApp, env)).toThrow('mkdir failed')
expect(env.CLAUDE_CONFIG_DIR).toBeUndefined()
expect(env.CC_HAHA_APP_PORTABLE_DIR).toBeUndefined()
expect(env.WEBVIEW2_USER_DATA_FOLDER).toBeUndefined()
})
it('keeps the previous mode record if the atomic replacement fails', () => {
const fakeApp = app()
const modeFile = path.join(fakeApp.getPath('userData'), 'app-mode.json')
writeMode(fakeApp, { mode: 'default', portable_dir: null })
vi.spyOn(fs, 'renameSync').mockImplementation(() => {
throw new Error('rename failed')
})
expect(() => setAppMode(fakeApp, {
mode: 'portable',
portableDir: path.join(fakeApp.root, 'custom-data'),
}, {})).toThrow('rename failed')
expect(JSON.parse(fs.readFileSync(modeFile, 'utf8'))).toEqual({
mode: 'default',
portable_dir: null,
})
expect(fs.readdirSync(fakeApp.getPath('userData'))).toEqual(['app-mode.json'])
})
})