mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
275 lines
10 KiB
TypeScript
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'])
|
|
})
|
|
})
|