Files
claude-code-haha/desktop/src/pages/ComputerUseSettings.test.tsx
T
程序员阿江(Relakkes) d14866f6d7 fix(computer-use): load app icons through the authenticated channel
The settings page pointed an `<img src>` straight at `/api/computer-use/app-icon`
and got nothing. The packaged renderer is loaded with `loadFile`, so the page
origin is `file://` and that image is a cross-origin subresource: it can carry
neither the Authorization header nor a trusted Origin, and the server's
fetch-metadata policy refuses exactly that shape. Reproduced with curl —
`Sec-Fetch-Site: cross-site` + `Sec-Fetch-Mode: no-cors` turns a 200 into
`401 Missing H5 access token`. The same request without those headers succeeds,
which is why it looked fine from a terminal.

Icons now come through `apiGetBlob`, the credential path every other call
already uses, and reach the DOM as blob URLs (the CSP already allows `blob:`).

Two things this forces that the `<img>` version got for free:

  - Caching, including the misses. `null` means "this bundle ships no icon";
    without caching that, a list re-render re-requests every iconless bundle.
  - A concurrency ceiling. Opening the picker renders every installed
    application at once — 208 on the dev machine — so an uncapped fetch is one
    request and one server-side `sips` per row, simultaneously.

`getAppIconUrl` is gone; `loadAppIcon` returns a blob URL or null. The letter
tile now covers both "loading" and "no icon", which is what the picker should
show either way — a spinner per row would read as broken on the utilities that
genuinely have no icon.
2026-08-06 04:50:56 +08:00

523 lines
15 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { ComputerUseSettings } from './ComputerUseSettings'
import { useSettingsStore } from '../stores/settingsStore'
import { browserHost } from '../lib/desktopHost/browserHost'
const computerUseApiMock = vi.hoisted(() => ({
getStatus: vi.fn(),
getInstalledApps: vi.fn(),
getAuthorizedApps: vi.fn(),
setAuthorizedApps: vi.fn(),
runSetup: vi.fn(),
openSettings: vi.fn(),
openPermissionCard: vi.fn(),
loadAppIcon: vi.fn(),
}))
vi.mock('../api/computerUse', () => ({
computerUseApi: computerUseApiMock,
}))
const readyStatus = {
platform: 'darwin',
supported: true,
cuHelper: { available: false },
python: {
installed: true,
version: '3.12.0',
path: '/usr/bin/python3',
source: 'system',
error: null,
},
venv: {
created: false,
path: '/tmp/venv',
},
dependencies: {
installed: false,
requirementsFound: true,
},
permissions: {
accessibility: null,
screenRecording: null,
},
}
const enabledConfig = {
enabled: true,
authorizedApps: [],
grantFlags: {
clipboardRead: true,
clipboardWrite: true,
systemKeyCombos: true,
},
pythonPath: null,
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(res => {
resolve = res
})
return { promise, resolve }
}
describe('ComputerUseSettings', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
computerUseApiMock.getStatus.mockReset()
computerUseApiMock.getInstalledApps.mockReset()
computerUseApiMock.getAuthorizedApps.mockReset()
computerUseApiMock.setAuthorizedApps.mockReset()
computerUseApiMock.runSetup.mockReset()
computerUseApiMock.openSettings.mockReset()
computerUseApiMock.openPermissionCard.mockReset()
computerUseApiMock.loadAppIcon.mockReset()
computerUseApiMock.loadAppIcon.mockResolvedValue(null)
Reflect.deleteProperty(window, 'desktopHost')
computerUseApiMock.getStatus.mockResolvedValue(readyStatus)
computerUseApiMock.getAuthorizedApps.mockResolvedValue(enabledConfig)
computerUseApiMock.setAuthorizedApps.mockResolvedValue({ ok: true })
computerUseApiMock.openPermissionCard.mockResolvedValue({
ok: true,
accessibility: true,
screenRecording: true,
})
})
it('renders the stored disabled state with the MCP exposure hint', async () => {
computerUseApiMock.getAuthorizedApps.mockResolvedValue({
...enabledConfig,
enabled: false,
})
render(<ComputerUseSettings />)
const toggle = await screen.findByLabelText('Enabled')
await waitFor(() => expect(toggle).not.toBeChecked())
expect(
screen.getByText(/will not inject the computer-use MCP server/i),
).toBeInTheDocument()
})
it('saves the Computer Use enablement toggle independently', async () => {
render(<ComputerUseSettings />)
const toggle = await screen.findByLabelText('Enabled')
await waitFor(() => expect(computerUseApiMock.getAuthorizedApps).toHaveBeenCalled())
await act(async () => {
fireEvent.click(toggle)
await Promise.resolve()
})
expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({
enabled: false,
})
})
it('saves a custom Python interpreter path and rechecks status', async () => {
render(<ComputerUseSettings />)
const input = await screen.findByLabelText('Python Interpreter Path')
await act(async () => {
fireEvent.change(input, {
target: { value: ' C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe ' },
})
fireEvent.click(screen.getByText('Apply'))
await Promise.resolve()
})
expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({
pythonPath: 'C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe',
})
expect(computerUseApiMock.getStatus).toHaveBeenCalledTimes(2)
})
it('selects a custom Python interpreter through the injected desktop host', async () => {
const open = vi.fn().mockResolvedValue('/opt/python/bin/python3')
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
dialogs: true,
},
dialogs: {
...browserHost.dialogs,
open,
},
}
render(<ComputerUseSettings />)
await screen.findByLabelText('Python Interpreter Path')
await act(async () => {
fireEvent.click(screen.getByText('Browse'))
await Promise.resolve()
})
expect(open).toHaveBeenCalledWith({
multiple: false,
directory: false,
title: 'Select Python Interpreter',
})
expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({
pythonPath: '/opt/python/bin/python3',
})
})
it('falls back to manual Python path entry when dialogs are unavailable', async () => {
window.desktopHost = {
...browserHost,
kind: 'browser',
isDesktop: false,
capabilities: {
...browserHost.capabilities,
dialogs: false,
},
}
render(<ComputerUseSettings />)
await screen.findByLabelText('Python Interpreter Path')
await act(async () => {
fireEvent.click(screen.getByText('Browse'))
await Promise.resolve()
})
expect(screen.getByText('Could not open the file picker. Paste the path manually.')).toBeInTheDocument()
expect(computerUseApiMock.setAuthorizedApps).not.toHaveBeenCalled()
})
it('keeps the user-selected enablement when a stale refresh resolves later', async () => {
const staleRefresh = deferred<typeof enabledConfig>()
computerUseApiMock.getStatus.mockResolvedValue({
...readyStatus,
venv: {
...readyStatus.venv,
created: true,
},
dependencies: {
...readyStatus.dependencies,
installed: true,
},
})
computerUseApiMock.getInstalledApps.mockResolvedValue({ apps: [] })
computerUseApiMock.getAuthorizedApps
.mockResolvedValueOnce({
...enabledConfig,
enabled: false,
})
.mockReturnValueOnce(staleRefresh.promise)
render(<ComputerUseSettings />)
const toggle = await screen.findByLabelText('Enabled')
await waitFor(() => expect(toggle).not.toBeChecked())
await waitFor(() => expect(computerUseApiMock.getInstalledApps).toHaveBeenCalled())
await act(async () => {
fireEvent.click(toggle)
await Promise.resolve()
})
expect(toggle).toBeChecked()
await act(async () => {
staleRefresh.resolve({
...enabledConfig,
enabled: false,
})
await staleRefresh.promise
})
expect(toggle).toBeChecked()
})
it('saves app and grant flag changes from the ready environment view', async () => {
computerUseApiMock.getStatus.mockResolvedValue({
...readyStatus,
venv: {
...readyStatus.venv,
created: true,
},
dependencies: {
...readyStatus.dependencies,
installed: true,
},
})
computerUseApiMock.getInstalledApps.mockResolvedValue({
apps: [
{
bundleId: 'com.example.Preview',
displayName: 'Preview',
path: '/Applications/Preview.app',
},
],
})
render(<ComputerUseSettings />)
await screen.findByText('Preview')
await act(async () => {
fireEvent.click(screen.getByText('Preview'))
await Promise.resolve()
})
expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({
authorizedApps: [
expect.objectContaining({
bundleId: 'com.example.Preview',
displayName: 'Preview',
}),
],
grantFlags: {
clipboardRead: true,
clipboardWrite: true,
systemKeyCombos: true,
},
})
await act(async () => {
fireEvent.click(screen.getByLabelText('Clipboard Access'))
await Promise.resolve()
})
expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({
authorizedApps: [
expect.objectContaining({
bundleId: 'com.example.Preview',
displayName: 'Preview',
}),
],
grantFlags: {
clipboardRead: false,
clipboardWrite: false,
systemKeyCombos: true,
},
})
})
describe('native cu-helper branch', () => {
const nativeStatus = {
...readyStatus,
cuHelper: { available: true },
permissions: {
accessibility: false,
screenRecording: true,
},
}
/**
* Rows show the application's own icon, served per bundle id. The letter
* tile is the fallback for bundles that ship no icon, so it must appear on
* image error and NOT before — a page that renders letters while perfectly
* good icons exist looks broken.
*/
describe('app icons', () => {
const authorizedConfig = {
...enabledConfig,
authorizedApps: [
{
bundleId: 'com.example.Preview',
displayName: 'Preview',
authorizedAt: '2026-01-01T00:00:00.000Z',
},
],
}
it('renders the icon it loaded for the row bundle id', async () => {
computerUseApiMock.getStatus.mockResolvedValue(nativeStatus)
computerUseApiMock.getAuthorizedApps.mockResolvedValue(authorizedConfig)
computerUseApiMock.loadAppIcon.mockResolvedValue('blob:icon-preview')
render(<ComputerUseSettings />)
await screen.findByText('Preview')
await waitFor(() => {
expect(document.querySelector('img[src="blob:icon-preview"]')).not.toBeNull()
})
expect(computerUseApiMock.loadAppIcon).toHaveBeenCalledWith('com.example.Preview')
// The letter tile is the fallback, so it must be gone once the icon
// arrives — otherwise both would render.
expect(screen.queryByText('P')).toBeNull()
})
it('keeps the letter tile when the bundle has no icon', async () => {
computerUseApiMock.getStatus.mockResolvedValue(nativeStatus)
computerUseApiMock.getAuthorizedApps.mockResolvedValue(authorizedConfig)
// null is the ordinary "this bundle ships no icon" answer.
computerUseApiMock.loadAppIcon.mockResolvedValue(null)
render(<ComputerUseSettings />)
await screen.findByText('Preview')
await waitFor(() => expect(screen.getByText('P')).toBeInTheDocument())
expect(document.querySelector('img')).toBeNull()
})
it('never points an img straight at the endpoint', async () => {
// The packaged renderer is a file:// page, so a cross-origin <img> to
// /api/... is refused by the server and silently shows nothing. Icons
// must arrive as blob URLs through the authenticated channel.
computerUseApiMock.getStatus.mockResolvedValue(nativeStatus)
computerUseApiMock.getAuthorizedApps.mockResolvedValue(authorizedConfig)
computerUseApiMock.loadAppIcon.mockResolvedValue('blob:icon-preview')
render(<ComputerUseSettings />)
await screen.findByText('Preview')
await waitFor(() => {
expect(document.querySelector('img[src="blob:icon-preview"]')).not.toBeNull()
})
expect(document.querySelector('img[src*="/api/"]')).toBeNull()
})
})
it('pops the native permission card when enabling Any App with missing permissions', async () => {
computerUseApiMock.getStatus.mockResolvedValue(nativeStatus)
computerUseApiMock.getAuthorizedApps.mockResolvedValue({
...enabledConfig,
enabled: false,
})
render(<ComputerUseSettings />)
const toggle = await screen.findByLabelText('Any App')
await waitFor(() => expect(toggle).not.toBeChecked())
await act(async () => {
fireEvent.click(toggle)
await Promise.resolve()
})
expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({ enabled: true })
expect(computerUseApiMock.openPermissionCard).toHaveBeenCalledTimes(1)
})
it('does not pop the card when enabling with all permissions granted', async () => {
computerUseApiMock.getStatus.mockResolvedValue({
...nativeStatus,
permissions: { accessibility: true, screenRecording: true },
})
computerUseApiMock.getAuthorizedApps.mockResolvedValue({
...enabledConfig,
enabled: false,
})
render(<ComputerUseSettings />)
const toggle = await screen.findByLabelText('Any App')
await waitFor(() => expect(toggle).not.toBeChecked())
await act(async () => {
fireEvent.click(toggle)
await Promise.resolve()
})
expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({ enabled: true })
expect(computerUseApiMock.openPermissionCard).not.toHaveBeenCalled()
})
it('reopens the native card from the permission section button', async () => {
computerUseApiMock.getStatus.mockResolvedValue(nativeStatus)
render(<ComputerUseSettings />)
const button = await screen.findByText('Reopen authorization card')
await act(async () => {
fireEvent.click(button)
await Promise.resolve()
})
expect(computerUseApiMock.openPermissionCard).toHaveBeenCalledTimes(1)
})
it('removes an always-allowed app via the trash button', async () => {
computerUseApiMock.getStatus.mockResolvedValue(nativeStatus)
computerUseApiMock.getAuthorizedApps.mockResolvedValue({
...enabledConfig,
authorizedApps: [
{
bundleId: 'com.example.Preview',
displayName: 'Preview',
authorizedAt: '2026-01-01T00:00:00.000Z',
},
],
})
render(<ComputerUseSettings />)
const remove = await screen.findByLabelText('Remove Preview')
await act(async () => {
fireEvent.click(remove)
await Promise.resolve()
})
expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({
authorizedApps: [],
grantFlags: {
clipboardRead: true,
clipboardWrite: true,
systemKeyCombos: true,
},
})
})
it('adds an app from the picker fed by getInstalledApps', async () => {
computerUseApiMock.getStatus.mockResolvedValue(nativeStatus)
computerUseApiMock.getInstalledApps.mockResolvedValue({
apps: [
{
bundleId: 'com.example.Notes',
displayName: 'Notes',
path: '/Applications/Notes.app',
},
],
})
render(<ComputerUseSettings />)
const addButton = await screen.findByText('Add App')
await act(async () => {
fireEvent.click(addButton)
await Promise.resolve()
})
await waitFor(() => expect(computerUseApiMock.getInstalledApps).toHaveBeenCalled())
const notesEntry = await screen.findByText('Notes')
await act(async () => {
fireEvent.click(notesEntry)
await Promise.resolve()
})
expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({
authorizedApps: [
expect.objectContaining({
bundleId: 'com.example.Notes',
displayName: 'Notes',
}),
],
grantFlags: {
clipboardRead: true,
clipboardWrite: true,
systemKeyCombos: true,
},
})
})
})
})