- triggersApi: 添加 assertSubscriptionBaseUrl 防止 OAuth token 泄露 - claude.ts: 修复流式响应 O(n^2) 字符串拼接,改用数组累积 - claude.ts: 移除未使用的 import,动态 import 改为静态 import - StatusLine: BuiltinStatusLine 仅在 statusLineEnabled 时显示,修复双行问题 - local-vault: 修复 --reveal 标志位置解析 bug - share: 修复 sk-proj-* OpenAI 密钥未脱敏问题 - store.ts: 临时文件改用同目录创建,避免跨文件系统 rename 失败 - store.ts: 添加空字符串 key 校验 - permissionValidation: 端口正则限制为有效 TCP 范围 0-65535 - 测试 mock 补全: schedule/vault/skill-store 测试文件 - 移除过期的 biome-ignore 注释 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
423 lines
16 KiB
TypeScript
423 lines
16 KiB
TypeScript
/**
|
|
* Tests for launchSkillStore.tsx
|
|
*
|
|
* Strategy per feedback_mock_dependency_not_subject:
|
|
* - DO NOT mock skillsApi.ts itself (would pollute api.test.ts)
|
|
* - Mock axios (the underlying HTTP layer) to control API responses
|
|
* - Mock fs/promises for install filesystem operations
|
|
* - Let real skillsApi functions run real code paths
|
|
*/
|
|
|
|
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeAll,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
mock,
|
|
test,
|
|
} from 'bun:test'
|
|
import { debugMock } from '../../../../tests/mocks/debug.js'
|
|
import { logMock } from '../../../../tests/mocks/log.js'
|
|
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
|
|
|
mock.module('src/utils/log.ts', logMock)
|
|
mock.module('src/utils/debug.ts', debugMock)
|
|
|
|
// ── Analytics mock ──────────────────────────────────────────────────────────
|
|
const realAnalytics = await import('src/services/analytics/index.js')
|
|
const logEventMock = mock(() => {})
|
|
mock.module('src/services/analytics/index.js', () => ({
|
|
...realAnalytics,
|
|
logEvent: logEventMock,
|
|
}))
|
|
|
|
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
|
|
const realAuth = await import('src/utils/auth.js')
|
|
mock.module('src/utils/auth.js', () => ({
|
|
...realAuth,
|
|
getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token' }),
|
|
}))
|
|
mock.module('src/services/oauth/client.js', () => ({
|
|
getOrganizationUUID: async () => 'org-uuid',
|
|
}))
|
|
mock.module('src/constants/oauth.js', () => ({
|
|
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
|
}))
|
|
// Spread real teleport/api so any export not explicitly stubbed (like
|
|
// prepareWorkspaceApiRequest, axiosGetWithRetry, type guards, schemas)
|
|
// remains available to transitive importers.
|
|
const realTeleportApi = await import('src/utils/teleport/api.js')
|
|
mock.module('src/utils/teleport/api.js', () => ({
|
|
...realTeleportApi,
|
|
getOAuthHeaders: (token: string) => ({ Authorization: `Bearer ${token}` }),
|
|
prepareWorkspaceApiRequest: async () => ({
|
|
apiKey: 'test-workspace-key',
|
|
}),
|
|
}))
|
|
|
|
// ── envUtils config dir injection ────────────────────────────────────────────
|
|
// Don't mock the envUtils module — that's process-level and leaks to other
|
|
// tests' getClaudeConfigHomeDir consumers (see feedback_mock_dependency_not_subject).
|
|
// Instead inject CLAUDE_CONFIG_DIR via process.env and clear the lodash memoize
|
|
// cache around each test so the real getClaudeConfigHomeDir reads our value.
|
|
const mockConfigDir = '/tmp/test-claude-config'
|
|
|
|
// ── Axios mock ──────────────────────────────────────────────────────────────
|
|
const axiosGetMock = mock(async () => ({}))
|
|
const axiosPostMock = mock(async () => ({}))
|
|
const axiosDeleteMock = mock(async () => ({}))
|
|
const axiosIsAxiosError = mock((err: unknown) => {
|
|
return (
|
|
typeof err === 'object' &&
|
|
err !== null &&
|
|
'isAxiosError' in err &&
|
|
(err as { isAxiosError: boolean }).isAxiosError === true
|
|
)
|
|
})
|
|
|
|
const axiosHandle = setupAxiosMock()
|
|
axiosHandle.stubs.get = axiosGetMock
|
|
axiosHandle.stubs.post = axiosPostMock
|
|
axiosHandle.stubs.delete = axiosDeleteMock
|
|
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
|
|
|
// ── fs/promises mock ─────────────────────────────────────────────────────────
|
|
// Bun's mock.module is global per-process and last-write-wins. Replacing
|
|
// node:fs/promises with only mkdir + writeFile breaks every other test in
|
|
// the same `bun test` run that imports readFile / readdir / unlink / chmod /
|
|
// etc. (notably src/services/localVault/__tests__/store.test.ts).
|
|
//
|
|
// Use require() INSIDE the factory (same trick as SessionMemory/prompts.test)
|
|
// so we get the truly-real module bypassing the mock registry. Gate our two
|
|
// stubs behind useSkillStoreFsStubs (default off; beforeAll flips on; afterAll
|
|
// flips off).
|
|
const mkdirMock = mock(async (..._args: unknown[]) => undefined)
|
|
const writeFileMock = mock(async (..._args: unknown[]) => undefined)
|
|
let useSkillStoreFsStubs = false
|
|
mock.module('node:fs/promises', () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const real = require('node:fs/promises') as Record<string, unknown>
|
|
return {
|
|
...real,
|
|
default: real,
|
|
mkdir: (...args: unknown[]) =>
|
|
useSkillStoreFsStubs
|
|
? mkdirMock(...args)
|
|
: (real.mkdir as (...a: unknown[]) => Promise<unknown>)(...args),
|
|
writeFile: (...args: unknown[]) =>
|
|
useSkillStoreFsStubs
|
|
? writeFileMock(...args)
|
|
: (real.writeFile as (...a: unknown[]) => Promise<unknown>)(...args),
|
|
}
|
|
})
|
|
|
|
// ── Lazy imports ─────────────────────────────────────────────────────────────
|
|
let callSkillStore: typeof import('../launchSkillStore.js').callSkillStore
|
|
let getClaudeConfigHomeDir: typeof import('../../../utils/envUtils.js').getClaudeConfigHomeDir
|
|
let origConfigDir: string | undefined
|
|
|
|
beforeAll(async () => {
|
|
axiosHandle.useStubs = true
|
|
const mod = await import('../launchSkillStore.js')
|
|
callSkillStore = mod.callSkillStore
|
|
const envMod = await import('../../../utils/envUtils.js')
|
|
getClaudeConfigHomeDir = envMod.getClaudeConfigHomeDir
|
|
origConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
useSkillStoreFsStubs = true
|
|
})
|
|
|
|
// Flip the stub flag off after this suite so localVault/store and other
|
|
// fs-dependent tests in the same process see real readFile/readdir/etc.
|
|
afterAll(() => {
|
|
axiosHandle.useStubs = false
|
|
useSkillStoreFsStubs = false
|
|
})
|
|
|
|
beforeEach(() => {
|
|
axiosGetMock.mockClear()
|
|
axiosPostMock.mockClear()
|
|
axiosDeleteMock.mockClear()
|
|
mkdirMock.mockClear()
|
|
writeFileMock.mockClear()
|
|
logEventMock.mockClear()
|
|
// Inject our mock config dir + bust lodash memoize so real
|
|
// getClaudeConfigHomeDir reads the freshly-set env var.
|
|
process.env.CLAUDE_CONFIG_DIR = mockConfigDir
|
|
getClaudeConfigHomeDir.cache?.clear?.()
|
|
})
|
|
|
|
afterEach(() => {
|
|
// Restore env so we don't leak mockConfigDir into other test files.
|
|
if (origConfigDir === undefined) {
|
|
delete process.env.CLAUDE_CONFIG_DIR
|
|
} else {
|
|
process.env.CLAUDE_CONFIG_DIR = origConfigDir
|
|
}
|
|
getClaudeConfigHomeDir.cache?.clear?.()
|
|
})
|
|
|
|
// ── Helper ────────────────────────────────────────────────────────────────────
|
|
function makeOnDone() {
|
|
const calls: [string | undefined, unknown][] = []
|
|
const onDone = (msg?: string, opts?: unknown) => calls.push([msg, opts])
|
|
return { onDone, calls }
|
|
}
|
|
|
|
// ── list ──────────────────────────────────────────────────────────────────────
|
|
describe('list action', () => {
|
|
test('calls listSkills and returns element on success', async () => {
|
|
const skills = [
|
|
{ skill_id: 'sk_1', name: 'skill-a', owner: 'alice', deprecated: false },
|
|
]
|
|
axiosGetMock.mockResolvedValueOnce({ data: { data: skills }, status: 200 })
|
|
const { onDone } = makeOnDone()
|
|
const result = await callSkillStore(onDone, {} as never, 'list')
|
|
expect(result).not.toBeNull()
|
|
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('empty list returns element', async () => {
|
|
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
|
const { onDone, calls } = makeOnDone()
|
|
await callSkillStore(onDone, {} as never, 'list')
|
|
expect(calls[0]?.[0]).toContain('No skills')
|
|
})
|
|
|
|
test('API error reports failure', async () => {
|
|
axiosGetMock.mockRejectedValueOnce({
|
|
isAxiosError: true,
|
|
response: { status: 401 },
|
|
message: 'Unauthorized',
|
|
})
|
|
const { onDone, calls } = makeOnDone()
|
|
await callSkillStore(onDone, {} as never, 'list')
|
|
expect(calls[0]?.[0]).toContain('Failed')
|
|
})
|
|
})
|
|
|
|
// ── get ───────────────────────────────────────────────────────────────────────
|
|
describe('get action', () => {
|
|
test('fetches and returns skill detail', async () => {
|
|
const skill = {
|
|
skill_id: 'sk_1',
|
|
name: 'my-skill',
|
|
owner: 'user',
|
|
deprecated: false,
|
|
}
|
|
axiosGetMock.mockResolvedValueOnce({ data: skill, status: 200 })
|
|
const { onDone } = makeOnDone()
|
|
const result = await callSkillStore(onDone, {} as never, 'get sk_1')
|
|
expect(result).not.toBeNull()
|
|
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('API 404 reports failure', async () => {
|
|
axiosGetMock.mockRejectedValueOnce({
|
|
isAxiosError: true,
|
|
response: { status: 404 },
|
|
message: 'Not found',
|
|
})
|
|
const { onDone, calls } = makeOnDone()
|
|
await callSkillStore(onDone, {} as never, 'get missing_id')
|
|
expect(calls[0]?.[0]).toContain('Failed')
|
|
})
|
|
})
|
|
|
|
// ── versions ──────────────────────────────────────────────────────────────────
|
|
describe('versions action', () => {
|
|
test('fetches and returns versions', async () => {
|
|
const versions = [
|
|
{
|
|
version: 'v1',
|
|
skill_id: 'sk_1',
|
|
body: '# v1',
|
|
created_at: '2024-01-01',
|
|
},
|
|
]
|
|
axiosGetMock.mockResolvedValueOnce({
|
|
data: { data: versions },
|
|
status: 200,
|
|
})
|
|
const { onDone } = makeOnDone()
|
|
const result = await callSkillStore(onDone, {} as never, 'versions sk_1')
|
|
expect(result).not.toBeNull()
|
|
})
|
|
})
|
|
|
|
// ── version ───────────────────────────────────────────────────────────────────
|
|
describe('version action', () => {
|
|
test('fetches specific version', async () => {
|
|
const ver = {
|
|
version: 'v2',
|
|
skill_id: 'sk_1',
|
|
body: '# v2',
|
|
created_at: '2024-02-01',
|
|
}
|
|
axiosGetMock.mockResolvedValueOnce({ data: ver, status: 200 })
|
|
const { onDone } = makeOnDone()
|
|
const result = await callSkillStore(onDone, {} as never, 'version sk_1 v2')
|
|
expect(result).not.toBeNull()
|
|
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
// ── create ────────────────────────────────────────────────────────────────────
|
|
describe('create action', () => {
|
|
test('creates skill and returns result', async () => {
|
|
const skill = {
|
|
skill_id: 'sk_new',
|
|
name: 'new-skill',
|
|
owner: 'user',
|
|
deprecated: false,
|
|
}
|
|
axiosPostMock.mockResolvedValueOnce({ data: skill, status: 201 })
|
|
const { onDone } = makeOnDone()
|
|
const result = await callSkillStore(
|
|
onDone,
|
|
{} as never,
|
|
'create new-skill # Skill Content',
|
|
)
|
|
expect(result).not.toBeNull()
|
|
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
// ── delete ────────────────────────────────────────────────────────────────────
|
|
describe('delete action', () => {
|
|
test('deletes skill and confirms', async () => {
|
|
axiosDeleteMock.mockResolvedValueOnce({ data: {}, status: 204 })
|
|
const { onDone, calls } = makeOnDone()
|
|
const result = await callSkillStore(onDone, {} as never, 'delete sk_del')
|
|
expect(result).not.toBeNull()
|
|
expect(calls[0]?.[0]).toContain('deleted')
|
|
})
|
|
})
|
|
|
|
// ── install ───────────────────────────────────────────────────────────────────
|
|
describe('install action', () => {
|
|
test('install <id> fetches skill + versions, writes SKILL.md', async () => {
|
|
const skill = {
|
|
skill_id: 'sk_1',
|
|
name: 'my-skill',
|
|
owner: 'user',
|
|
deprecated: false,
|
|
}
|
|
const versions = [
|
|
{
|
|
version: 'v1',
|
|
skill_id: 'sk_1',
|
|
body: '# My Skill Content',
|
|
created_at: '2024-01-01',
|
|
},
|
|
]
|
|
// First call: getSkill, Second call: getSkillVersions
|
|
axiosGetMock
|
|
.mockResolvedValueOnce({ data: skill, status: 200 })
|
|
.mockResolvedValueOnce({ data: { data: versions }, status: 200 })
|
|
|
|
const { onDone, calls } = makeOnDone()
|
|
const result = await callSkillStore(onDone, {} as never, 'install sk_1')
|
|
expect(result).not.toBeNull()
|
|
expect(mkdirMock).toHaveBeenCalledTimes(1)
|
|
expect(writeFileMock).toHaveBeenCalledTimes(1)
|
|
const writeCall = writeFileMock.mock.calls[0] as unknown as [
|
|
string,
|
|
string,
|
|
string,
|
|
]
|
|
expect(writeCall[0]).toContain('SKILL.md')
|
|
expect(writeCall[0]).toContain('my-skill')
|
|
expect(writeCall[1]).toBe('# My Skill Content')
|
|
expect(calls[0]?.[0]).toContain('installed')
|
|
})
|
|
|
|
test('install <id>@<version> fetches specific version and writes SKILL.md', async () => {
|
|
const ver = {
|
|
version: 'v2',
|
|
skill_id: 'sk_1',
|
|
body: '# v2 Content',
|
|
created_at: '2024-02-01',
|
|
}
|
|
axiosGetMock.mockResolvedValueOnce({ data: ver, status: 200 })
|
|
|
|
const { onDone, calls } = makeOnDone()
|
|
const result = await callSkillStore(onDone, {} as never, 'install sk_1@v2')
|
|
expect(result).not.toBeNull()
|
|
expect(writeFileMock).toHaveBeenCalledTimes(1)
|
|
const writeCall = writeFileMock.mock.calls[0] as unknown as [
|
|
string,
|
|
string,
|
|
string,
|
|
]
|
|
expect(writeCall[1]).toBe('# v2 Content')
|
|
expect(calls[0]?.[0]).toContain('installed')
|
|
})
|
|
|
|
test('install skill with no versions shows error', async () => {
|
|
const skill = {
|
|
skill_id: 'sk_nover',
|
|
name: 'no-ver-skill',
|
|
owner: 'user',
|
|
deprecated: false,
|
|
}
|
|
axiosGetMock
|
|
.mockResolvedValueOnce({ data: skill, status: 200 })
|
|
.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
|
|
|
const { onDone, calls } = makeOnDone()
|
|
const result = await callSkillStore(onDone, {} as never, 'install sk_nover')
|
|
expect(result).not.toBeNull()
|
|
expect(calls[0]?.[0]).toContain('no published versions')
|
|
expect(writeFileMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('install writes to ~/.claude/skills/<name>/SKILL.md path', async () => {
|
|
const skill = {
|
|
skill_id: 'sk_path',
|
|
name: 'path-test',
|
|
owner: 'user',
|
|
deprecated: false,
|
|
}
|
|
const versions = [
|
|
{
|
|
version: 'v1',
|
|
skill_id: 'sk_path',
|
|
body: '# Path Test',
|
|
created_at: '2024-01-01',
|
|
},
|
|
]
|
|
axiosGetMock
|
|
.mockResolvedValueOnce({ data: skill, status: 200 })
|
|
.mockResolvedValueOnce({ data: { data: versions }, status: 200 })
|
|
|
|
const { onDone } = makeOnDone()
|
|
await callSkillStore(onDone, {} as never, 'install sk_path')
|
|
|
|
const mkdirCall = mkdirMock.mock.calls[0] as unknown as [
|
|
string,
|
|
{ recursive: boolean },
|
|
]
|
|
expect(mkdirCall[0]).toContain('skills')
|
|
expect(mkdirCall[0]).toContain('path-test')
|
|
|
|
const writeCall = writeFileMock.mock.calls[0] as unknown as [
|
|
string,
|
|
string,
|
|
string,
|
|
]
|
|
expect(writeCall[0]).toContain('SKILL.md')
|
|
})
|
|
})
|
|
|
|
// ── invalid args ──────────────────────────────────────────────────────────────
|
|
describe('invalid args', () => {
|
|
test('invalid subcommand returns null and calls onDone with usage', async () => {
|
|
const { onDone, calls } = makeOnDone()
|
|
const result = await callSkillStore(onDone, {} as never, 'unknowncmd')
|
|
expect(result).toBeNull()
|
|
expect(calls[0]?.[0]).toContain('Usage')
|
|
})
|
|
})
|