cc-haha/desktop/electron/services/nativeAppearance.test.ts
程序员阿江(Relakkes) 137895f23e fix(test): isolate the appearance cache from a real CLAUDE_CONFIG_DIR
The same suite passed under `check:desktop` and failed under
`check:coverage` — 271 files and 3332 tests either way, with one case red
in the second: "returns null rather than throwing on a missing or corrupt
cache" read back `{isDark: true, background: '#201D17', ...}`, which is
exactly what the case above it writes.

`appearanceStatePath` resolves `env.CLAUDE_CONFIG_DIR` before falling back
to `app.getPath('home')`, and these cases passed no env, so they defaulted
to `process.env`. `check:coverage` runs its suites through
`createSandboxedTestEnvironment`, which sets CLAUDE_CONFIG_DIR
(scripts/pr/test-environment.ts:74). With it set, the per-case temp
directories from `makeApp()` stop deciding anything: every read and write
collapses onto one shared file, and `afterEach` only removes the temp
directories, never that file. So the round-trip case wrote it and the
missing-cache case read it. Only that one case is ordered to notice — the
others write before they read.

Nothing about the product is wrong here: defaulting to `process.env` is
what the shipped code should do, and a portable install setting
CLAUDE_CONFIG_DIR is a supported mode. The defect is that the tests never
opted out of it.

Each case now passes the isolated env `makeApp()` hands back, which is the
convention `windows.test.ts` already follows for the same path shape (it
threads `{}` or `{CLAUDE_CONFIG_DIR: tmp}` through every call). Verified
both ways: with CLAUDE_CONFIG_DIR set — the condition that reproduced the
failure — and without it, 23/23 each time. `check:coverage` now reports
5/5 suites, and `check:desktop` stays green.

`sidecarManager.ts` and `windows.ts` resolve paths the same way; their
suites were checked and already pass an explicit env.
2026-07-27 07:49:12 +08:00

233 lines
8.9 KiB
TypeScript

import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { App, BrowserWindow } from 'electron'
import {
DEFAULT_DARK_THEME,
DEFAULT_LIGHT_THEME,
THEME_BACKGROUNDS,
} from '../../src/theme/systemAppearance'
import {
APPEARANCE_STATE_FILE,
applyAppliedAppearance,
appearanceStatePath,
DARK_WINDOW_BACKGROUND,
isAppliedAppearance,
LIGHT_WINDOW_BACKGROUND,
readAppearanceState,
startupWindowBackground,
writeAppearanceState,
type AppliedAppearance,
} from './nativeAppearance'
const tempDirs: string[] = []
const WARM_LIGHT = '#F6F0E1'
/** A well-formed report, overridable per case. */
function appearance(overrides: Partial<AppliedAppearance> = {}): AppliedAppearance {
return {
isDark: false,
background: '#FFFFFF',
lightBackground: '#FFFFFF',
followSystem: false,
...overrides,
}
}
function makeApp(): { app: App; home: string; env: NodeJS.ProcessEnv } {
const home = mkdtempSync(path.join(tmpdir(), 'electron-appearance-'))
tempDirs.push(home)
return {
home,
// Every case below has to resolve inside its own `home`. Left to default,
// these functions read `process.env`, and a CLAUDE_CONFIG_DIR set there
// outranks `app.getPath('home')` — collapsing every case onto one shared
// file, where one reads what another wrote. That is not hypothetical: the
// sandboxed environment `check:coverage` runs under sets it, which is why
// this file passed under `check:desktop` and failed under coverage.
env: {},
app: { getPath: vi.fn(() => home) } as unknown as App,
}
}
function fakeWindow(destroyed = false) {
return {
isDestroyed: vi.fn(() => destroyed),
setBackgroundColor: vi.fn(),
} as unknown as BrowserWindow & {
isDestroyed: ReturnType<typeof vi.fn>
setBackgroundColor: ReturnType<typeof vi.fn>
}
}
afterEach(() => {
while (tempDirs.length) rmSync(tempDirs.pop()!, { recursive: true, force: true })
})
describe('appearance state persistence', () => {
it('stores the cache next to the other desktop state under ~/.claude', () => {
const { app, home } = makeApp()
expect(appearanceStatePath(app, {})).toBe(path.join(home, '.claude', APPEARANCE_STATE_FILE))
})
it('honours CLAUDE_CONFIG_DIR for portable installs', () => {
const { app } = makeApp()
expect(appearanceStatePath(app, { CLAUDE_CONFIG_DIR: '/portable/config' }))
.toBe(path.join('/portable/config', APPEARANCE_STATE_FILE))
})
it('round-trips what the renderer reported', () => {
const { app, env } = makeApp()
const state = appearance({ isDark: true, background: '#201D17', lightBackground: WARM_LIGHT, followSystem: true })
writeAppearanceState(app, state, env)
expect(readAppearanceState(app, env)).toEqual(state)
})
it('returns null rather than throwing on a missing or corrupt cache', () => {
const { app, env } = makeApp()
expect(readAppearanceState(app, env)).toBeNull()
const statePath = appearanceStatePath(app, env)
writeAppearanceState(app, appearance(), env)
writeFileSync(statePath, '{ not json')
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(readAppearanceState(app, env)).toBeNull()
error.mockRestore()
})
it('rejects a cache whose background is not a plain hex color', () => {
const { app, env } = makeApp()
writeAppearanceState(app, appearance(), env)
writeFileSync(
appearanceStatePath(app, env),
JSON.stringify({ ...appearance(), background: 'url(evil)' }),
)
expect(readAppearanceState(app, env)).toBeNull()
})
it('refuses to persist a malformed payload', () => {
const { app, env } = makeApp()
writeAppearanceState(app, appearance({ background: 'red' }), env)
expect(readAppearanceState(app, env)).toBeNull()
})
})
describe('isAppliedAppearance', () => {
it('accepts a well formed payload', () => {
expect(isAppliedAppearance(appearance({ isDark: true, background: '#201d17' }))).toBe(true)
})
it.each([
['null', null],
['a non-object', 'dark'],
['a missing flag', { background: '#FFFFFF', lightBackground: '#FFFFFF', followSystem: true }],
['a missing light background', { isDark: false, background: '#FFFFFF', followSystem: true }],
['a shorthand hex', appearance({ background: '#FFF' })],
['a named color', appearance({ background: 'white' })],
['a CSS function', appearance({ background: 'rgb(0,0,0)' })],
['a bad light background', appearance({ lightBackground: 'white' })],
])('rejects %s', (_label, payload) => {
expect(isAppliedAppearance(payload)).toBe(false)
})
})
describe('startupWindowBackground', () => {
it('uses the OS setting on a first launch with no cache', () => {
expect(startupWindowBackground(null, true)).toBe(DARK_WINDOW_BACKGROUND)
expect(startupWindowBackground(null, false)).toBe(LIGHT_WINDOW_BACKGROUND)
})
it('replays a fixed theme regardless of the OS setting', () => {
const cached = appearance({ background: WARM_LIGHT, lightBackground: WARM_LIGHT })
expect(startupWindowBackground(cached, true)).toBe(WARM_LIGHT)
expect(startupWindowBackground(cached, false)).toBe(WARM_LIGHT)
})
it('re-resolves a following install against the OS as it is now', () => {
// Cached light, but the user turned the OS dark while the app was closed.
const cached = appearance({ background: WARM_LIGHT, lightBackground: WARM_LIGHT, followSystem: true })
expect(startupWindowBackground(cached, true)).toBe(DARK_WINDOW_BACKGROUND)
expect(startupWindowBackground(cached, false)).toBe(WARM_LIGHT)
})
it('repaints the light half with the theme the user actually picked', () => {
// Cached at night on the dark theme. Opening in the morning must land on
// the user's warm light theme, not on plain white — the white step is the
// exact flash this feature exists to remove.
const cached = appearance({
isDark: true,
background: '#201D17',
lightBackground: WARM_LIGHT,
followSystem: true,
})
expect(startupWindowBackground(cached, false)).toBe(WARM_LIGHT)
})
})
describe('applyAppliedAppearance', () => {
it('repaints every live window and caches the result', () => {
const { app, env } = makeApp()
const first = fakeWindow()
const second = fakeWindow()
const state = appearance({ isDark: true, background: '#201D17', lightBackground: WARM_LIGHT })
applyAppliedAppearance(state, { app, windows: () => [first, second], env })
expect(first.setBackgroundColor).toHaveBeenCalledWith('#201D17')
expect(second.setBackgroundColor).toHaveBeenCalledWith('#201D17')
expect(JSON.parse(readFileSync(appearanceStatePath(app, env), 'utf-8'))).toEqual(state)
})
it('skips windows that were already torn down', () => {
// quit/quitAndInstall can leave destroyed windows in the list; touching
// one throws "Object has been destroyed".
const { app, env } = makeApp()
const destroyed = fakeWindow(true)
applyAppliedAppearance(appearance({ followSystem: true }), { app, windows: () => [destroyed], env })
expect(destroyed.setBackgroundColor).not.toHaveBeenCalled()
})
it('never pins nativeTheme.themeSource anywhere in the main process', () => {
// Pinning it would override prefers-color-scheme in every renderer — the
// very signal "follow the system" reads, so re-enabling the switch would
// resolve against the pinned value — and it leaks into the preview
// WebContentsView, forcing third-party pages to the app's theme. Reading
// shouldUseDarkColors stays fine; only assignment is banned. See the note
// at the top of nativeAppearance.ts before reintroducing it.
for (const file of ['./nativeAppearance.ts', '../main.ts']) {
const source = readFileSync(new URL(file, import.meta.url), 'utf-8')
// Drop comments so the explanatory notes do not trip the check.
const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
expect(code, `${file} assigns themeSource`).not.toMatch(/themeSource\s*=[^=]/)
}
})
})
describe('window background constants', () => {
it('match the renderer theme backgrounds', () => {
// Third leg of the guard: globals.test.ts binds THEME_BACKGROUNDS to the
// CSS and bootstrapScript.test.ts binds it to index.html, but the main
// process had its own copies. Without this, changing the palette would
// leave the pre-paint window color silently behind.
expect(LIGHT_WINDOW_BACKGROUND).toBe(THEME_BACKGROUNDS.white)
expect(DARK_WINDOW_BACKGROUND).toBe(THEME_BACKGROUNDS.dark)
// The defaults of each half — what a first launch paints before the
// renderer has reported a preference.
expect(THEME_BACKGROUNDS[DEFAULT_LIGHT_THEME]).toBe(LIGHT_WINDOW_BACKGROUND)
expect(THEME_BACKGROUNDS[DEFAULT_DARK_THEME]).toBe(DARK_WINDOW_BACKGROUND)
})
})