mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +08:00
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.
233 lines
8.9 KiB
TypeScript
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)
|
|
})
|
|
})
|