cc-haha/desktop/electron/services/terminal.test.ts
程序员阿江(Relakkes) 2c1af7a843 fix(desktop): clear macOS quarantine from node-pty cache
Remove macOS download quarantine/provenance attributes from the packaged node-pty runtime cache before loading the native module. Existing matching caches are repaired before require, and read-only helper permissions are restored after cleanup.\n\nTested: bun test desktop/electron/services/terminal.test.ts\nTested: cd desktop && bun run build:electron\nTested: git diff --check\nTested: prepared the installed 0.4.3 node-pty cache and required it with Electron's Node runtime\nScope-risk: narrow\nConfidence: high
2026-06-17 17:06:24 +08:00

257 lines
9.7 KiB
TypeScript

import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
import {
ElectronTerminalService,
defaultShell,
desktopTerminalSettingsPath,
ensureUtf8Locale,
normalizeTerminalBashPath,
parseEnvBlock,
prepareNodePtyRuntime,
resolveDesktopTerminalShell,
terminalConfigPath,
type TerminalPtyFactory,
type TerminalPtyProcess,
} from './terminal'
class FakePty implements TerminalPtyProcess {
writes: string[] = []
resizes: Array<{ cols: number, rows: number }> = []
killed = false
private dataHandler: ((data: string) => void) | null = null
private exitHandler: ((event: { exitCode: number, signal?: number | string | null }) => void) | null = null
write(data: string) {
this.writes.push(data)
}
resize(cols: number, rows: number) {
this.resizes.push({ cols, rows })
}
kill() {
this.killed = true
}
onData(handler: (data: string) => void) {
this.dataHandler = handler
}
onExit(handler: (event: { exitCode: number, signal?: number | string | null }) => void) {
this.exitHandler = handler
}
emitData(data: string) {
this.dataHandler?.(data)
}
emitExit(event: { exitCode: number, signal?: number | string | null }) {
this.exitHandler?.(event)
}
}
const tempDirs: string[] = []
const itOnDarwin = process.platform === 'darwin' ? it : it.skip
function tempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-terminal-'))
tempDirs.push(dir)
return dir
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true })
}
vi.restoreAllMocks()
})
describe('Electron terminal service', () => {
it('uses the portable terminal config path before app userData', () => {
const app = { getPath: vi.fn(() => '/app/user-data') }
expect(terminalConfigPath(app, { CLAUDE_CONFIG_DIR: '/portable' })).toBe('/portable/terminal-config.json')
expect(terminalConfigPath(app, {})).toBe('/app/user-data/terminal-config.json')
})
it('persists the legacy bash path config and validates saved paths', () => {
const dir = tempDir()
const bash = path.join(dir, 'bash.exe')
fs.writeFileSync(bash, '')
const service = new ElectronTerminalService({
env: { CLAUDE_CONFIG_DIR: dir },
isFile: filePath => filePath === bash,
})
service.setBashPath(` ${bash} `)
expect(service.getBashPath()).toBe(bash)
expect(JSON.parse(fs.readFileSync(path.join(dir, 'terminal-config.json'), 'utf8'))).toEqual({
bash_path: bash,
})
expect(() => service.setBashPath('/missing/bash')).toThrow('terminal bash path does not exist')
expect(normalizeTerminalBashPath(' ', () => false)).toBeNull()
})
it('resolves platform-specific shells from the same settings shape as Tauri', () => {
expect(resolveDesktopTerminalShell('win32', { startupShell: 'pwsh' })).toBe('pwsh.exe')
expect(resolveDesktopTerminalShell('win32', { startupShell: 'powershell' })).toBe('powershell.exe')
expect(resolveDesktopTerminalShell('win32', { startupShell: 'cmd' })).toBe('cmd.exe')
expect(resolveDesktopTerminalShell('win32', { startupShell: 'custom', customShellPath: ' C:\\Tools\\shell.exe ' })).toBe('C:\\Tools\\shell.exe')
expect(() => resolveDesktopTerminalShell('win32', { startupShell: 'custom' })).toThrow('custom terminal shell path is empty')
expect(resolveDesktopTerminalShell('darwin', { startupShell: 'pwsh' })).toBeNull()
})
it('prefers Windows custom bash when valid and falls back to COMSPEC', () => {
expect(defaultShell('win32', { COMSPEC: 'cmd.exe' }, 'C:\\Git\\bin\\bash.exe', file => file.endsWith('bash.exe'))).toBe(
'C:\\Git\\bin\\bash.exe',
)
expect(defaultShell('win32', { COMSPEC: 'cmd.exe' }, 'C:\\missing\\bash.exe', () => false)).toBe('cmd.exe')
expect(defaultShell('linux', { SHELL: '/bin/fish' }, null, () => false)).toBe('/bin/fish')
})
it('reads desktop terminal settings from the Claude config directory', () => {
const dir = tempDir()
fs.mkdirSync(path.join(dir, '.claude'), { recursive: true })
fs.writeFileSync(
path.join(dir, '.claude', 'settings.json'),
JSON.stringify({ desktopTerminal: { startupShell: 'cmd' } }),
)
const service = new ElectronTerminalService({
env: { HOME: dir, COMSPEC: 'powershell.exe' },
platform: 'win32',
})
expect(desktopTerminalSettingsPath({ HOME: dir })).toBe(path.join(dir, '.claude', 'settings.json'))
expect(service.resolveShell()).toBe('cmd.exe')
})
it('normalizes terminal environment data to UTF-8 locale', () => {
expect(parseEnvBlock(Buffer.from('A=1\0B=two=2\0\0'))).toEqual({ A: '1', B: 'two=2' })
expect(ensureUtf8Locale({ LANG: 'C' }, 'darwin')).toMatchObject({
LANG: 'en_US.UTF-8',
LC_CTYPE: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
})
})
it('copies packaged node-pty to a writable runtime cache and restores helper executable bits', () => {
const source = tempDir()
const cache = path.join(tempDir(), 'node-pty-cache')
const helper = path.join(source, 'prebuilds', 'darwin-arm64', 'spawn-helper')
fs.mkdirSync(path.dirname(helper), { recursive: true })
fs.writeFileSync(path.join(source, 'package.json'), JSON.stringify({ name: 'node-pty', main: 'index.js' }))
fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = { spawn() {} }\n')
fs.writeFileSync(helper, 'helper')
fs.chmodSync(helper, 0o644)
expect(prepareNodePtyRuntime(source, cache)).toBe(cache)
expect(fs.existsSync(path.join(cache, 'index.js'))).toBe(true)
expect(fs.statSync(cache).mode & 0o077).toBe(0)
expect(fs.statSync(path.join(cache, 'prebuilds', 'darwin-arm64', 'spawn-helper')).mode & 0o777).toBe(0o500)
expect(fs.existsSync(path.join(cache, '.cc-haha-node-pty-manifest.json'))).toBe(true)
})
it('rebuilds the packaged node-pty runtime cache when cached files are tampered', () => {
const source = tempDir()
const cache = path.join(tempDir(), 'node-pty-cache')
fs.writeFileSync(path.join(source, 'package.json'), JSON.stringify({ name: 'node-pty', main: 'index.js' }))
fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = { spawn() { return "source" } }\n')
prepareNodePtyRuntime(source, cache)
fs.writeFileSync(path.join(cache, 'index.js'), 'module.exports = { spawn() { return "tampered" } }\n')
prepareNodePtyRuntime(source, cache)
expect(fs.readFileSync(path.join(cache, 'index.js'), 'utf8')).toBe('module.exports = { spawn() { return "source" } }\n')
})
itOnDarwin('removes stale macOS quarantine attributes from the node-pty runtime cache', () => {
const source = tempDir()
const cache = path.join(tempDir(), 'node-pty-cache')
fs.writeFileSync(path.join(source, 'package.json'), JSON.stringify({ name: 'node-pty', main: 'index.js' }))
fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = { spawn() { return "source" } }\n')
prepareNodePtyRuntime(source, cache)
const cachedEntry = path.join(cache, 'index.js')
execFileSync('/usr/bin/xattr', ['-w', 'com.apple.quarantine', '0381;00000000;Chrome;CC-HAHA-TEST', cachedEntry])
execFileSync('/usr/bin/xattr', ['-p', 'com.apple.quarantine', cachedEntry], { stdio: 'ignore' })
fs.chmodSync(cachedEntry, 0o500)
prepareNodePtyRuntime(source, cache)
expect(() => execFileSync('/usr/bin/xattr', ['-p', 'com.apple.quarantine', cachedEntry], { stdio: 'ignore' })).toThrow()
expect(fs.statSync(cachedEntry).mode & 0o777).toBe(0o500)
})
it('spawns a PTY, forwards events, and controls the active session', async () => {
const dir = tempDir()
const fakePty = new FakePty()
const spawn = vi.fn(() => fakePty)
const sent: Array<{ channel: string, payload: unknown }> = []
const service = new ElectronTerminalService({
env: { HOME: dir, SHELL: '/bin/test-shell' },
platform: 'linux',
ptyFactory: { spawn } satisfies TerminalPtyFactory,
fileExists: filePath => filePath === '/bin/test-shell',
})
const session = await service.spawn(
{ cols: 10, rows: 4, cwd: dir },
{ send: (channel, payload) => sent.push({ channel, payload }) },
)
expect(session).toEqual({ session_id: 1, shell: '/bin/test-shell', cwd: dir })
expect(spawn).toHaveBeenCalledWith('/bin/test-shell', [], expect.objectContaining({
name: 'xterm-256color',
cols: 20,
rows: 8,
cwd: dir,
env: expect.objectContaining({
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
}),
}))
service.write(1, 'echo hello\r')
service.resize(1, 12, 6)
fakePty.emitData('hello\r\n')
fakePty.emitExit({ exitCode: 0 })
expect(fakePty.writes).toEqual(['echo hello\r'])
expect(fakePty.resizes).toEqual([{ cols: 20, rows: 8 }])
expect(sent).toEqual([
{
channel: ELECTRON_EVENT_CHANNELS.terminalOutput,
payload: { session_id: 1, data: 'hello\r\n' },
},
{
channel: ELECTRON_EVENT_CHANNELS.terminalExit,
payload: { session_id: 1, code: 0, signal: null },
},
])
expect(() => service.write(1, 'after exit')).toThrow('terminal session is not running')
})
it('kills a running PTY session without failing when the session is already gone', async () => {
const dir = tempDir()
const fakePty = new FakePty()
const service = new ElectronTerminalService({
env: { HOME: dir, SHELL: '/bin/test-shell' },
platform: 'linux',
ptyFactory: { spawn: vi.fn(() => fakePty) },
})
await service.spawn({ cols: 80, rows: 24, cwd: dir }, { send: vi.fn() })
service.kill(1)
service.kill(1)
expect(fakePty.killed).toBe(true)
})
})