Skip to content

Commit fb41513

Browse files
unraidclaude
andcommitted
feat: 添加工具类增强与状态管理改进
- 新增 workflowRuns、remoteTriggerAudit、pipeStatus 等工具 - 增强 permissionSetup: auto mode 和 bypass permissions 始终可用 - 新增多组测试覆盖 (modifiers, teamDiscovery, deepLink 等) - 修复 parseInt 缺少 radix 参数 - 移除多余 biome-ignore 注释 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 94c4b37 commit fb41513

54 files changed

Lines changed: 1037 additions & 102 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/components/CustomSelect/use-multi-select-state.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ export function useMultiSelectState<T>({
381381

382382
// Handle numeric keys (1-9) for direct selection
383383
if (!hideIndexes && /^[0-9]+$/.test(normalizedInput)) {
384-
const index = parseInt(normalizedInput) - 1
384+
const index = parseInt(normalizedInput, 10) - 1
385385
if (index >= 0 && index < options.length) {
386386
const value = options[index]!.value
387387
const newValues = selectedValues.includes(value)

src/components/CustomSelect/use-select-input.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ export const useSelectInput = <T>({
255255
disableSelection !== 'numeric' &&
256256
/^[0-9]+$/.test(normalizedInput)
257257
) {
258-
const index = parseInt(normalizedInput) - 1
258+
const index = parseInt(normalizedInput, 10) - 1
259259
if (index >= 0 && index < state.options.length) {
260260
const selectedOption = state.options[index]!
261261
if (selectedOption.disabled === true) {

src/components/messageActions.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ export function isNavigableMessage(msg: NavigableMessage): boolean {
6262
return !stripSystemReminders(b.text!).startsWith('<')
6363
}
6464
case 'system':
65-
// biome-ignore lint/nursery/useExhaustiveSwitchCases: blocklist — fallthrough return-true is the design
6665
switch (msg.subtype) {
6766
case 'api_metrics':
6867
case 'stop_hook_summary':

src/context/notifications.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,6 @@ export function useNotifications(): {
288288
// Imperative read (not useAppState) — a subscription in a mount-only
289289
// effect would be vestigial and make every caller re-render on queue changes.
290290
// eslint-disable-next-line react-hooks/exhaustive-deps
291-
// biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect, store is a stable context ref
292291
useEffect(() => {
293292
if (store.getState().notifications.queue.length > 0) {
294293
processQueue()
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2+
3+
let nativePrewarmCalls = 0
4+
let nativeReturnValue = false
5+
let nativeShouldThrow = false
6+
7+
const nativeIsModifierPressed = mock((modifier: string) => {
8+
if (nativeShouldThrow) {
9+
throw new Error('native modifier failure')
10+
}
11+
return nativeReturnValue
12+
})
13+
14+
mock.module('modifiers-napi', () => ({
15+
prewarm: async () => {
16+
nativePrewarmCalls++
17+
},
18+
isModifierPressed: nativeIsModifierPressed,
19+
}))
20+
21+
const originalPlatform = process.platform
22+
23+
async function loadModule() {
24+
return import(`../modifiers.ts?case=${Math.random()}`)
25+
}
26+
27+
beforeEach(() => {
28+
nativePrewarmCalls = 0
29+
nativeReturnValue = false
30+
nativeShouldThrow = false
31+
nativeIsModifierPressed.mockClear()
32+
Object.defineProperty(process, 'platform', {
33+
value: originalPlatform,
34+
configurable: true,
35+
})
36+
})
37+
38+
afterEach(() => {
39+
Object.defineProperty(process, 'platform', {
40+
value: originalPlatform,
41+
configurable: true,
42+
})
43+
})
44+
45+
describe('src/utils/modifiers', () => {
46+
test('does not touch the native module on non-darwin', async () => {
47+
Object.defineProperty(process, 'platform', {
48+
value: 'win32',
49+
configurable: true,
50+
})
51+
const mod = await loadModule()
52+
53+
mod.prewarmModifiers()
54+
expect(nativePrewarmCalls).toBe(0)
55+
expect(mod.isModifierPressed('shift')).toBe(false)
56+
expect(nativeIsModifierPressed).not.toHaveBeenCalled()
57+
})
58+
59+
test('caches native prewarm after the first darwin call', async () => {
60+
Object.defineProperty(process, 'platform', {
61+
value: 'darwin',
62+
configurable: true,
63+
})
64+
const mod = await loadModule()
65+
66+
mod.prewarmModifiers()
67+
mod.prewarmModifiers()
68+
69+
// prewarm is fire-and-forget async — flush microtasks
70+
await new Promise(resolve => setTimeout(resolve, 0))
71+
expect(nativePrewarmCalls).toBe(1)
72+
})
73+
74+
test('forwards modifier checks to the native module on darwin', async () => {
75+
Object.defineProperty(process, 'platform', {
76+
value: 'darwin',
77+
configurable: true,
78+
})
79+
nativeReturnValue = true
80+
const mod = await loadModule()
81+
82+
expect(mod.isModifierPressed('shift')).toBe(true)
83+
expect(nativeIsModifierPressed).toHaveBeenCalledWith('shift')
84+
})
85+
86+
test('returns false when native modifier checks throw on darwin', async () => {
87+
Object.defineProperty(process, 'platform', {
88+
value: 'darwin',
89+
configurable: true,
90+
})
91+
nativeShouldThrow = true
92+
const mod = await loadModule()
93+
94+
expect(mod.isModifierPressed('shift')).toBe(false)
95+
})
96+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2+
import { rm } from 'fs/promises'
3+
import { tmpdir } from 'os'
4+
import { join } from 'path'
5+
import { writeRegistry } from '../pipeRegistry'
6+
import { formatPipeRegistryStatus } from '../pipeStatus'
7+
8+
let tempDir: string
9+
let previousConfigDir: string | undefined
10+
11+
beforeEach(() => {
12+
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
13+
tempDir = join(
14+
tmpdir(),
15+
`pipe-status-${Date.now()}-${Math.random().toString(16).slice(2)}`,
16+
)
17+
process.env.CLAUDE_CONFIG_DIR = tempDir
18+
})
19+
20+
afterEach(async () => {
21+
if (previousConfigDir === undefined) {
22+
delete process.env.CLAUDE_CONFIG_DIR
23+
} else {
24+
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
25+
}
26+
await rm(tempDir, { recursive: true, force: true })
27+
})
28+
29+
describe('pipe status', () => {
30+
test('formats registry main and sub pipe communication state', async () => {
31+
await writeRegistry({
32+
version: 1,
33+
mainMachineId: 'machine-main-123456',
34+
main: {
35+
id: 'main-id',
36+
pid: 123,
37+
machineId: 'machine-main-123456',
38+
startedAt: 1,
39+
ip: '127.0.0.1',
40+
mac: '00:11:22:33:44:55',
41+
hostname: 'main-host',
42+
pipeName: 'main-pipe',
43+
tcpPort: 43123,
44+
},
45+
subs: [
46+
{
47+
id: 'sub-id',
48+
pid: 456,
49+
machineId: 'machine-sub-123456',
50+
startedAt: 2,
51+
ip: '127.0.0.2',
52+
mac: '66:77:88:99:aa:bb',
53+
hostname: 'sub-host',
54+
pipeName: 'sub-pipe',
55+
tcpPort: 43124,
56+
subIndex: 1,
57+
boundToMain: 'main-pipe',
58+
},
59+
],
60+
})
61+
62+
const formatted = await formatPipeRegistryStatus()
63+
64+
expect(formatted).toContain('Pipe registry: 1 main, 1 sub(s)')
65+
expect(formatted).toContain('[main] main-pipe')
66+
expect(formatted).toContain('[sub-1] sub-pipe')
67+
expect(formatted).toContain('bound=main-pipe')
68+
})
69+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2+
import { formatRemoteControlLocalStatus } from '../remoteControlStatus'
3+
4+
let previousBaseUrl: string | undefined
5+
let previousToken: string | undefined
6+
7+
beforeEach(() => {
8+
previousBaseUrl = process.env.CLAUDE_BRIDGE_BASE_URL
9+
previousToken = process.env.CLAUDE_BRIDGE_OAUTH_TOKEN
10+
})
11+
12+
afterEach(() => {
13+
if (previousBaseUrl === undefined) {
14+
delete process.env.CLAUDE_BRIDGE_BASE_URL
15+
} else {
16+
process.env.CLAUDE_BRIDGE_BASE_URL = previousBaseUrl
17+
}
18+
if (previousToken === undefined) {
19+
delete process.env.CLAUDE_BRIDGE_OAUTH_TOKEN
20+
} else {
21+
process.env.CLAUDE_BRIDGE_OAUTH_TOKEN = previousToken
22+
}
23+
})
24+
25+
describe('remote control status', () => {
26+
test('formats self-hosted bridge local config without remote calls', () => {
27+
process.env.CLAUDE_BRIDGE_BASE_URL = 'http://127.0.0.1:8787'
28+
process.env.CLAUDE_BRIDGE_OAUTH_TOKEN = 'token'
29+
30+
const status = formatRemoteControlLocalStatus()
31+
32+
expect(status).toContain('Remote Control: self-hosted')
33+
expect(status).toContain('base_url=http://127.0.0.1:8787')
34+
expect(status).toContain('token=present')
35+
expect(status).toContain('entitlement=checked at remote-control startup')
36+
})
37+
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2+
import { rm } from 'fs/promises'
3+
import { tmpdir } from 'os'
4+
import { join } from 'path'
5+
import {
6+
appendRemoteTriggerAuditRecord,
7+
formatRemoteTriggerAuditStatus,
8+
listRemoteTriggerAuditRecords,
9+
} from '../remoteTriggerAudit'
10+
11+
let tempDir = ''
12+
13+
beforeEach(() => {
14+
tempDir = join(
15+
tmpdir(),
16+
`remote-trigger-audit-${Date.now()}-${Math.random().toString(16).slice(2)}`,
17+
)
18+
})
19+
20+
afterEach(async () => {
21+
await rm(tempDir, { recursive: true, force: true })
22+
})
23+
24+
describe('remote trigger audit', () => {
25+
test('records and formats local remote trigger audit events', async () => {
26+
await appendRemoteTriggerAuditRecord(
27+
{ action: 'run', triggerId: 'abc', ok: true, status: 200, createdAt: 1 },
28+
tempDir,
29+
)
30+
await appendRemoteTriggerAuditRecord(
31+
{ action: 'create', ok: false, error: 'bad request', createdAt: 2 },
32+
tempDir,
33+
)
34+
35+
const records = await listRemoteTriggerAuditRecords(tempDir)
36+
expect(records).toHaveLength(2)
37+
expect(records[0].action).toBe('create')
38+
expect(formatRemoteTriggerAuditStatus(records)).toContain(
39+
'RemoteTrigger audit records: 2',
40+
)
41+
expect(formatRemoteTriggerAuditStatus(records)).toContain('Failures: 1')
42+
})
43+
})
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { getTeammateStatuses } from '../teamDiscovery'
6+
7+
let tempHome: string
8+
let previousConfigDir: string | undefined
9+
10+
beforeEach(() => {
11+
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
12+
tempHome = join(
13+
tmpdir(),
14+
`team-discovery-${Date.now()}-${Math.random().toString(16).slice(2)}`,
15+
)
16+
process.env.CLAUDE_CONFIG_DIR = tempHome
17+
})
18+
19+
afterEach(() => {
20+
if (previousConfigDir === undefined) {
21+
delete process.env.CLAUDE_CONFIG_DIR
22+
} else {
23+
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
24+
}
25+
rmSync(tempHome, { recursive: true, force: true })
26+
})
27+
28+
function writeTeamConfig(teamName: string, config: unknown): void {
29+
const teamDir = join(tempHome, 'teams', teamName)
30+
mkdirSync(teamDir, { recursive: true })
31+
writeFileSync(join(teamDir, 'config.json'), JSON.stringify(config, null, 2))
32+
}
33+
34+
describe('getTeammateStatuses', () => {
35+
test('preserves in-process backend type for lifecycle actions', () => {
36+
writeTeamConfig('alpha', {
37+
name: 'alpha',
38+
createdAt: Date.now(),
39+
leadAgentId: 'team-lead@alpha',
40+
members: [
41+
{
42+
agentId: 'team-lead@alpha',
43+
name: 'team-lead',
44+
joinedAt: Date.now(),
45+
tmuxPaneId: '',
46+
cwd: tempHome,
47+
subscriptions: [],
48+
},
49+
{
50+
agentId: 'worker@alpha',
51+
name: 'worker',
52+
joinedAt: Date.now(),
53+
tmuxPaneId: 'in-process',
54+
cwd: tempHome,
55+
subscriptions: [],
56+
backendType: 'in-process',
57+
},
58+
],
59+
})
60+
61+
expect(getTeammateStatuses('alpha')).toEqual([
62+
expect.objectContaining({
63+
agentId: 'worker@alpha',
64+
backendType: 'in-process',
65+
}),
66+
])
67+
})
68+
})

src/utils/__tests__/tokens.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ mock.module("src/services/tokenEstimation.ts", () => ({
3030
countTokensViaHaikuFallback: async () => 0,
3131
}));
3232

33+
// Mock slowOperations to avoid bun:bundle import
34+
mock.module('src/utils/slowOperations.ts', () => ({
35+
jsonStringify: JSON.stringify,
36+
jsonParse: JSON.parse,
37+
slowLogging: { enabled: false },
38+
clone: (v: any) => structuredClone(v),
39+
cloneDeep: (v: any) => structuredClone(v),
40+
callerFrame: () => '',
41+
SLOW_OPERATION_THRESHOLD_MS: 100,
42+
writeFileSync_DEPRECATED: () => {},
43+
}))
44+
3345
const {
3446
getTokenCountFromUsage,
3547
getTokenUsage,

0 commit comments

Comments
 (0)