@vibe-forge/adapter-opencode 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +13 -0
- package/LICENSE +21 -0
- package/__tests__/runtime-common.spec.ts +95 -0
- package/__tests__/runtime-config.spec.ts +124 -0
- package/__tests__/runtime-permissions.spec.ts +181 -0
- package/__tests__/runtime-test-helpers.ts +142 -0
- package/__tests__/session-runtime-config.spec.ts +156 -0
- package/__tests__/session-runtime-direct.spec.ts +141 -0
- package/__tests__/session-runtime-stream.spec.ts +181 -0
- package/package.json +59 -0
- package/src/AGENTS.md +38 -0
- package/src/adapter-config.ts +21 -0
- package/src/icon.ts +17 -0
- package/src/index.ts +11 -0
- package/src/models.ts +24 -0
- package/src/paths.ts +30 -0
- package/src/runtime/common/agent.ts +11 -0
- package/src/runtime/common/inline-config.ts +45 -0
- package/src/runtime/common/mcp.ts +47 -0
- package/src/runtime/common/model.ts +115 -0
- package/src/runtime/common/object-utils.ts +35 -0
- package/src/runtime/common/permission-node.ts +119 -0
- package/src/runtime/common/permissions.ts +73 -0
- package/src/runtime/common/prompt.ts +56 -0
- package/src/runtime/common/session-records.ts +61 -0
- package/src/runtime/common/tools.ts +129 -0
- package/src/runtime/common.ts +17 -0
- package/src/runtime/init.ts +55 -0
- package/src/runtime/session/child-env.ts +100 -0
- package/src/runtime/session/direct.ts +109 -0
- package/src/runtime/session/process.ts +55 -0
- package/src/runtime/session/shared.ts +72 -0
- package/src/runtime/session/skill-config.ts +84 -0
- package/src/runtime/session/stream.ts +198 -0
- package/src/runtime/session.ts +13 -0
- package/src/schema.ts +1 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# OpenCode Adapter 目录说明
|
|
2
|
+
|
|
3
|
+
- `src/runtime/common/`:纯配置与参数映射层,只做 prompt、tools、permissions、model、mcp、session record 转换,不承载进程控制
|
|
4
|
+
- `src/runtime/session/`:运行时层,负责 child env、skill bridge、direct/stream 会话执行与错误传播
|
|
5
|
+
- `src/runtime/common.ts` / `src/runtime/session.ts`:稳定入口文件,只做 re-export 或轻量路由
|
|
6
|
+
- `__tests__/runtime-*.spec.ts`:按能力拆分测试;公共 mock 和文件系统 helper 放在 `__tests__/runtime-test-helpers.ts`
|
|
7
|
+
|
|
8
|
+
约束:
|
|
9
|
+
|
|
10
|
+
- 单文件保持在 200 行以内;接近 160 行时优先继续拆,而不是继续堆分支
|
|
11
|
+
- 新增逻辑先判断属于“映射层”还是“运行时层”,不要再把两类职责混回一个文件
|
|
12
|
+
- 需要 mock `child_process` 的测试,mock 定义放在各自 spec 顶部;helper 不直接持有全局 mock 状态
|
|
13
|
+
- 修改 adapter 行为时,至少补一条对应的 runtime 或 common 回归测试
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-present Vibe-Forge.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { resolveOpenCodeBinaryPath } from '#~/paths.js'
|
|
4
|
+
import {
|
|
5
|
+
buildOpenCodeRunArgs,
|
|
6
|
+
buildOpenCodeSessionTitle,
|
|
7
|
+
extractOpenCodeSessionRecords,
|
|
8
|
+
normalizeOpenCodePrompt,
|
|
9
|
+
resolveLocalAttachmentPath,
|
|
10
|
+
resolveOpenCodeAgent,
|
|
11
|
+
selectOpenCodeSessionByTitle
|
|
12
|
+
} from '#~/runtime/common.js'
|
|
13
|
+
|
|
14
|
+
describe('resolveOpenCodeBinaryPath', () => {
|
|
15
|
+
it('resolves to the adapter bundled binary by default', () => {
|
|
16
|
+
expect(resolveOpenCodeBinaryPath({})).toMatch(/node_modules\/\.bin\/opencode$/)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns the env-specified path when set', () => {
|
|
20
|
+
expect(resolveOpenCodeBinaryPath({
|
|
21
|
+
__VF_PROJECT_AI_ADAPTER_OPENCODE_CLI_PATH__: '/usr/local/bin/opencode'
|
|
22
|
+
})).toBe('/usr/local/bin/opencode')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('OpenCode prompt and session helpers', () => {
|
|
27
|
+
it('normalizes prompt text and attachments', () => {
|
|
28
|
+
const result = normalizeOpenCodePrompt([
|
|
29
|
+
{ type: 'text', text: 'Explain this diff' },
|
|
30
|
+
{ type: 'image', url: 'file:///tmp/screenshot.png' }
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
expect(result.prompt).toBe('Explain this diff')
|
|
34
|
+
expect(result.files).toEqual(['/tmp/screenshot.png'])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('builds a deterministic session title', () => {
|
|
38
|
+
expect(buildOpenCodeSessionTitle('session-1', 'VF')).toBe('VF:session-1')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('filters managed CLI flags from passthrough options', () => {
|
|
42
|
+
expect(buildOpenCodeRunArgs({
|
|
43
|
+
prompt: 'hello',
|
|
44
|
+
files: ['/tmp/a.txt'],
|
|
45
|
+
model: 'openai/gpt-5',
|
|
46
|
+
title: 'VF:session-1',
|
|
47
|
+
extraOptions: ['--log-level', 'DEBUG', '--format', 'json', '--session', 'abc']
|
|
48
|
+
})).toEqual([
|
|
49
|
+
'run',
|
|
50
|
+
'--format',
|
|
51
|
+
'default',
|
|
52
|
+
'--title',
|
|
53
|
+
'VF:session-1',
|
|
54
|
+
'--model',
|
|
55
|
+
'openai/gpt-5',
|
|
56
|
+
'--file',
|
|
57
|
+
'/tmp/a.txt',
|
|
58
|
+
'--log-level',
|
|
59
|
+
'DEBUG',
|
|
60
|
+
'hello'
|
|
61
|
+
])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('extracts session records from common JSON shapes', () => {
|
|
65
|
+
const records = extractOpenCodeSessionRecords({
|
|
66
|
+
sessions: [
|
|
67
|
+
{ id: 'sess-old', title: 'VF:one', updatedAt: '2026-03-25T10:00:00.000Z' },
|
|
68
|
+
{ sessionId: 'sess-new', title: 'VF:one', updated_at: '2026-03-25T11:00:00.000Z' }
|
|
69
|
+
]
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
expect(records).toHaveLength(2)
|
|
73
|
+
expect(selectOpenCodeSessionByTitle(records, 'VF:one')?.id).toBe('sess-new')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('resolves only local attachment paths', () => {
|
|
77
|
+
expect(resolveLocalAttachmentPath('file:///tmp/file.txt')).toBe('/tmp/file.txt')
|
|
78
|
+
expect(resolveLocalAttachmentPath('/tmp/file.txt')).toBe('/tmp/file.txt')
|
|
79
|
+
expect(resolveLocalAttachmentPath('https://example.com/file.txt')).toBeUndefined()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('uses the plan agent by default in plan mode', () => {
|
|
83
|
+
expect(resolveOpenCodeAgent({ permissionMode: 'plan' })).toBe('plan')
|
|
84
|
+
expect(resolveOpenCodeAgent({
|
|
85
|
+
permissionMode: 'plan',
|
|
86
|
+
agent: 'build',
|
|
87
|
+
planAgent: false
|
|
88
|
+
})).toBe('build')
|
|
89
|
+
expect(resolveOpenCodeAgent({
|
|
90
|
+
permissionMode: 'plan',
|
|
91
|
+
agent: 'build',
|
|
92
|
+
planAgent: 'review'
|
|
93
|
+
})).toBe('review')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { buildInlineConfigContent, resolveOpenCodeModel } from '#~/runtime/common.js'
|
|
4
|
+
|
|
5
|
+
describe('OpenCode config and model helpers', () => {
|
|
6
|
+
it('builds inline config overrides for permissions, mcp, tools, and instructions', () => {
|
|
7
|
+
const config = buildInlineConfigContent({
|
|
8
|
+
envConfigContent: {
|
|
9
|
+
instructions: ['CONTRIBUTING.md']
|
|
10
|
+
},
|
|
11
|
+
adapterConfigContent: {
|
|
12
|
+
provider: {
|
|
13
|
+
existing: {
|
|
14
|
+
name: 'Existing'
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
permissionMode: 'default',
|
|
19
|
+
tools: {
|
|
20
|
+
exclude: ['write']
|
|
21
|
+
},
|
|
22
|
+
mcpServers: {
|
|
23
|
+
include: ['local-docs']
|
|
24
|
+
},
|
|
25
|
+
availableMcpServers: {
|
|
26
|
+
'local-docs': {
|
|
27
|
+
command: 'npx',
|
|
28
|
+
args: ['-y', '@modelcontextprotocol/server-everything']
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
systemPromptFile: '/tmp/system.md',
|
|
32
|
+
providerConfig: {
|
|
33
|
+
custom: {
|
|
34
|
+
npm: '@ai-sdk/openai-compatible'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
expect(config).toMatchObject({
|
|
40
|
+
instructions: ['CONTRIBUTING.md', '/tmp/system.md'],
|
|
41
|
+
permission: {
|
|
42
|
+
'*': 'allow',
|
|
43
|
+
bash: 'ask',
|
|
44
|
+
edit: 'deny',
|
|
45
|
+
task: 'ask'
|
|
46
|
+
},
|
|
47
|
+
tools: {
|
|
48
|
+
write: false
|
|
49
|
+
},
|
|
50
|
+
mcp: {
|
|
51
|
+
'local-docs': {
|
|
52
|
+
type: 'local',
|
|
53
|
+
command: ['npx', '-y', '@modelcontextprotocol/server-everything'],
|
|
54
|
+
enabled: true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
provider: {
|
|
58
|
+
existing: {
|
|
59
|
+
name: 'Existing'
|
|
60
|
+
},
|
|
61
|
+
custom: {
|
|
62
|
+
npm: '@ai-sdk/openai-compatible'
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('maps custom model services to an OpenCode provider/model pair', () => {
|
|
69
|
+
const result = resolveOpenCodeModel('gateway,gpt-5', {
|
|
70
|
+
gateway: {
|
|
71
|
+
apiBaseUrl: 'https://example.test/v1',
|
|
72
|
+
apiKey: 'secret',
|
|
73
|
+
title: 'Gateway',
|
|
74
|
+
timeoutMs: 600000,
|
|
75
|
+
maxOutputTokens: 8192
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
expect(result.cliModel).toBe('gateway/gpt-5')
|
|
80
|
+
expect(result.providerConfig).toMatchObject({
|
|
81
|
+
gateway: {
|
|
82
|
+
npm: '@ai-sdk/openai-compatible',
|
|
83
|
+
options: {
|
|
84
|
+
apiKey: 'secret',
|
|
85
|
+
baseURL: 'https://example.test/v1',
|
|
86
|
+
timeout: 600000,
|
|
87
|
+
chunkTimeout: 600000
|
|
88
|
+
},
|
|
89
|
+
models: {
|
|
90
|
+
'gpt-5': {
|
|
91
|
+
name: 'gpt-5',
|
|
92
|
+
limit: {
|
|
93
|
+
output: 8192
|
|
94
|
+
},
|
|
95
|
+
options: {
|
|
96
|
+
maxOutputTokens: 8192
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('preserves provider prefix when explicit service syntax has no local mapping', () => {
|
|
105
|
+
expect(resolveOpenCodeModel('openrouter,claude-sonnet-4.5', {})).toEqual({
|
|
106
|
+
cliModel: 'openrouter/claude-sonnet-4.5',
|
|
107
|
+
providerConfig: undefined
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('does not inject permission overrides when no runtime permission inputs are provided', () => {
|
|
112
|
+
expect(buildInlineConfigContent({})).not.toHaveProperty('permission')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('preserves inherited string permission config when runtime permission is unset', () => {
|
|
116
|
+
expect(buildInlineConfigContent({
|
|
117
|
+
adapterConfigContent: {
|
|
118
|
+
permission: 'ask'
|
|
119
|
+
}
|
|
120
|
+
})).toMatchObject({
|
|
121
|
+
permission: 'ask'
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildInlineConfigContent,
|
|
5
|
+
buildToolConfig,
|
|
6
|
+
buildToolPermissionConfig
|
|
7
|
+
} from '#~/runtime/common.js'
|
|
8
|
+
|
|
9
|
+
describe('OpenCode permission and tool helpers', () => {
|
|
10
|
+
it('derives permission overrides from tool selection aliases', () => {
|
|
11
|
+
expect(buildToolPermissionConfig({
|
|
12
|
+
include: ['grep', 'view'],
|
|
13
|
+
exclude: ['bash']
|
|
14
|
+
})).toEqual({
|
|
15
|
+
'*': 'deny',
|
|
16
|
+
bash: 'deny',
|
|
17
|
+
edit: 'deny',
|
|
18
|
+
glob: 'deny',
|
|
19
|
+
grep: 'allow',
|
|
20
|
+
question: 'deny',
|
|
21
|
+
read: 'allow',
|
|
22
|
+
list: 'deny',
|
|
23
|
+
lsp: 'deny',
|
|
24
|
+
skill: 'deny',
|
|
25
|
+
task: 'deny',
|
|
26
|
+
todoread: 'deny',
|
|
27
|
+
todowrite: 'deny',
|
|
28
|
+
webfetch: 'deny',
|
|
29
|
+
websearch: 'deny',
|
|
30
|
+
codesearch: 'deny'
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('preserves permission mode semantics for included tools', () => {
|
|
35
|
+
expect(buildToolPermissionConfig(
|
|
36
|
+
{ include: ['bash', 'write'] },
|
|
37
|
+
{ '*': 'allow', bash: 'ask', edit: 'deny' }
|
|
38
|
+
)).toMatchObject({
|
|
39
|
+
'*': 'deny',
|
|
40
|
+
bash: 'ask',
|
|
41
|
+
edit: 'deny'
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('preserves custom tool permissions and nested rules when includes are narrowed', () => {
|
|
46
|
+
expect(buildToolPermissionConfig(
|
|
47
|
+
{
|
|
48
|
+
include: ['bash', 'mcp__docs__*'],
|
|
49
|
+
exclude: ['websearch']
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
bash: {
|
|
53
|
+
'*': 'ask',
|
|
54
|
+
'rm *': 'deny'
|
|
55
|
+
},
|
|
56
|
+
'mcp__docs__*': 'ask'
|
|
57
|
+
} as any
|
|
58
|
+
)).toMatchObject({
|
|
59
|
+
'*': 'deny',
|
|
60
|
+
bash: {
|
|
61
|
+
'*': 'ask',
|
|
62
|
+
'rm *': 'deny'
|
|
63
|
+
},
|
|
64
|
+
'mcp__docs__*': 'ask',
|
|
65
|
+
websearch: 'deny',
|
|
66
|
+
edit: 'deny'
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('normalizes tool aliases to OpenCode tool names', () => {
|
|
71
|
+
expect(buildToolConfig({
|
|
72
|
+
include: ['view', 'fetch', 'question', 'mcp__docs__*', 'agent'],
|
|
73
|
+
exclude: ['ls', 'write', 'task', 'custom-tool']
|
|
74
|
+
})).toEqual({
|
|
75
|
+
read: true,
|
|
76
|
+
webfetch: true,
|
|
77
|
+
question: true,
|
|
78
|
+
'mcp__docs__*': true,
|
|
79
|
+
list: false,
|
|
80
|
+
write: false,
|
|
81
|
+
'custom-tool': false
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('treats wildcard include as a no-op for tool toggles while preserving explicit excludes', () => {
|
|
86
|
+
expect(buildToolConfig({
|
|
87
|
+
include: ['*', 'agent'],
|
|
88
|
+
exclude: ['bash', '*']
|
|
89
|
+
})).toEqual({
|
|
90
|
+
bash: false
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
expect(buildToolPermissionConfig({
|
|
94
|
+
include: ['*'],
|
|
95
|
+
exclude: ['bash']
|
|
96
|
+
})).toEqual({
|
|
97
|
+
bash: 'deny'
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('keeps dontAsk mode non-interactive while preserving explicit denies', () => {
|
|
102
|
+
const config = buildInlineConfigContent({
|
|
103
|
+
adapterConfigContent: {
|
|
104
|
+
permission: {
|
|
105
|
+
bash: 'ask',
|
|
106
|
+
edit: 'deny',
|
|
107
|
+
skill: {
|
|
108
|
+
'*': 'ask',
|
|
109
|
+
'internal-*': 'deny'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
permissionMode: 'dontAsk'
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(config).toMatchObject({
|
|
117
|
+
permission: {
|
|
118
|
+
'*': 'allow',
|
|
119
|
+
bash: 'allow',
|
|
120
|
+
edit: 'deny',
|
|
121
|
+
skill: {
|
|
122
|
+
'*': 'allow',
|
|
123
|
+
'internal-*': 'deny'
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('lets bypassPermissions clear inherited denies recursively', () => {
|
|
130
|
+
expect(buildInlineConfigContent({
|
|
131
|
+
adapterConfigContent: {
|
|
132
|
+
permission: {
|
|
133
|
+
'*': 'deny',
|
|
134
|
+
bash: 'ask',
|
|
135
|
+
skill: {
|
|
136
|
+
'*': 'deny',
|
|
137
|
+
'internal-*': 'deny'
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
permissionMode: 'bypassPermissions'
|
|
142
|
+
})).toMatchObject({
|
|
143
|
+
permission: {
|
|
144
|
+
'*': 'allow',
|
|
145
|
+
bash: 'allow',
|
|
146
|
+
skill: {
|
|
147
|
+
'*': 'allow',
|
|
148
|
+
'internal-*': 'allow'
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('preserves nested skill permission config while applying runtime overrides', () => {
|
|
155
|
+
expect(buildInlineConfigContent({
|
|
156
|
+
adapterConfigContent: {
|
|
157
|
+
permission: {
|
|
158
|
+
skill: {
|
|
159
|
+
'*': 'allow',
|
|
160
|
+
'internal-*': 'deny'
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
permissionMode: 'default',
|
|
165
|
+
tools: {
|
|
166
|
+
exclude: ['bash']
|
|
167
|
+
}
|
|
168
|
+
})).toMatchObject({
|
|
169
|
+
permission: {
|
|
170
|
+
'*': 'allow',
|
|
171
|
+
bash: 'deny',
|
|
172
|
+
edit: 'ask',
|
|
173
|
+
task: 'ask',
|
|
174
|
+
skill: {
|
|
175
|
+
'*': 'allow',
|
|
176
|
+
'internal-*': 'deny'
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { PassThrough } from 'node:stream'
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, vi } from 'vitest'
|
|
7
|
+
|
|
8
|
+
const tempDirs: string[] = []
|
|
9
|
+
|
|
10
|
+
function makeMockLogger() {
|
|
11
|
+
return {
|
|
12
|
+
info: vi.fn(),
|
|
13
|
+
warn: vi.fn(),
|
|
14
|
+
error: vi.fn(),
|
|
15
|
+
debug: vi.fn()
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function registerRuntimeTestHooks() {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
delete process.env.HOME
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function makeCtx(overrides: {
|
|
34
|
+
env?: Record<string, string | null | undefined>
|
|
35
|
+
configs?: [unknown?, unknown?]
|
|
36
|
+
cacheSeed?: Record<string, unknown>
|
|
37
|
+
cwd?: string
|
|
38
|
+
} = {}) {
|
|
39
|
+
const cacheStore = new Map<string, unknown>(Object.entries(overrides.cacheSeed ?? {}))
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
cacheStore,
|
|
43
|
+
ctx: {
|
|
44
|
+
ctxId: 'test-ctx',
|
|
45
|
+
cwd: overrides.cwd ?? '/tmp',
|
|
46
|
+
env: overrides.env ?? {},
|
|
47
|
+
cache: {
|
|
48
|
+
set: async (key: string, value: unknown) => {
|
|
49
|
+
cacheStore.set(key, value)
|
|
50
|
+
return { cachePath: `/tmp/${key}.json` }
|
|
51
|
+
},
|
|
52
|
+
get: async (key: string) => cacheStore.get(key)
|
|
53
|
+
},
|
|
54
|
+
logger: makeMockLogger(),
|
|
55
|
+
configs: overrides.configs ?? [undefined, undefined]
|
|
56
|
+
} as any
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const createWorkspace = async () => {
|
|
61
|
+
const dir = await mkdtemp(join(tmpdir(), 'opencode-adapter-'))
|
|
62
|
+
tempDirs.push(dir)
|
|
63
|
+
return dir
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const writeDocument = async (filePath: string, content: string) => {
|
|
67
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
68
|
+
await writeFile(filePath, content)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function makeProc(options: {
|
|
72
|
+
stdout?: string
|
|
73
|
+
stderr?: string
|
|
74
|
+
exitCode?: number
|
|
75
|
+
pid?: number
|
|
76
|
+
} = {}) {
|
|
77
|
+
const stdout = new PassThrough()
|
|
78
|
+
const stderr = new PassThrough()
|
|
79
|
+
const handlers = new Map<string, (...args: any[]) => void>()
|
|
80
|
+
|
|
81
|
+
const proc = {
|
|
82
|
+
stdout,
|
|
83
|
+
stderr,
|
|
84
|
+
pid: options.pid ?? 4321,
|
|
85
|
+
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
|
|
86
|
+
handlers.set(event, handler)
|
|
87
|
+
return proc
|
|
88
|
+
}),
|
|
89
|
+
kill: vi.fn(() => {
|
|
90
|
+
queueMicrotask(() => handlers.get('exit')?.(130))
|
|
91
|
+
return true
|
|
92
|
+
})
|
|
93
|
+
} as any
|
|
94
|
+
|
|
95
|
+
queueMicrotask(() => {
|
|
96
|
+
if (options.stdout) stdout.write(options.stdout)
|
|
97
|
+
stdout.end()
|
|
98
|
+
if (options.stderr) stderr.write(options.stderr)
|
|
99
|
+
stderr.end()
|
|
100
|
+
handlers.get('exit')?.(options.exitCode ?? 0)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
return proc
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function makeErrorProc(error: Error) {
|
|
107
|
+
const stdout = new PassThrough()
|
|
108
|
+
const stderr = new PassThrough()
|
|
109
|
+
const handlers = new Map<string, (...args: any[]) => void>()
|
|
110
|
+
|
|
111
|
+
const proc = {
|
|
112
|
+
stdout,
|
|
113
|
+
stderr,
|
|
114
|
+
pid: 4321,
|
|
115
|
+
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
|
|
116
|
+
handlers.set(event, handler)
|
|
117
|
+
return proc
|
|
118
|
+
}),
|
|
119
|
+
kill: vi.fn(() => true)
|
|
120
|
+
} as any
|
|
121
|
+
|
|
122
|
+
queueMicrotask(() => handlers.get('error')?.(error))
|
|
123
|
+
return proc
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function mockExecFileJsonResponses(
|
|
127
|
+
execFileMock: { mockImplementation: (impl: (...args: unknown[]) => unknown) => unknown },
|
|
128
|
+
...payloads: unknown[]
|
|
129
|
+
) {
|
|
130
|
+
execFileMock.mockImplementation(((...args: unknown[]) => {
|
|
131
|
+
const callback = args.at(-1) as ((err: Error | null, stdout: string, stderr: string) => void) | undefined
|
|
132
|
+
const payload = payloads.shift() ?? []
|
|
133
|
+
queueMicrotask(() => {
|
|
134
|
+
callback?.(null, JSON.stringify(payload), '')
|
|
135
|
+
})
|
|
136
|
+
return {} as any
|
|
137
|
+
}) as any)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function flushAsyncWork() {
|
|
141
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
142
|
+
}
|