@membranehq/cli 0.1.1
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/.turbo/turbo-build.log +9 -0
- package/CHANGELOG.md +7 -0
- package/README.md +54 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +335 -0
- package/package.json +46 -0
- package/scripts/add-shebang.sh +6 -0
- package/scripts/prepare-package-json.ts +29 -0
- package/src/agent.tsx +50 -0
- package/src/cli.ts +72 -0
- package/src/commands/open.command.ts +51 -0
- package/src/commands/pull.command.ts +75 -0
- package/src/commands/push.command.ts +79 -0
- package/src/commands/test.command.ts +99 -0
- package/src/components/AddMcpServerScreen.tsx +215 -0
- package/src/components/AgentStatus.tsx +15 -0
- package/src/components/Main.tsx +64 -0
- package/src/components/OverviewSection.tsx +24 -0
- package/src/components/PersonalAccessTokenInput.tsx +56 -0
- package/src/components/RecentChanges.tsx +65 -0
- package/src/components/SelectWorkspace.tsx +112 -0
- package/src/components/Setup.tsx +121 -0
- package/src/components/WorkspaceStatus.tsx +61 -0
- package/src/contexts/FileWatcherContext.tsx +81 -0
- package/src/index.ts +27 -0
- package/src/legacy/commands/pullWorkspace.ts +70 -0
- package/src/legacy/commands/pushWorkspace.ts +246 -0
- package/src/legacy/integrationElements.ts +78 -0
- package/src/legacy/push/types.ts +17 -0
- package/src/legacy/reader/index.ts +113 -0
- package/src/legacy/types.ts +17 -0
- package/src/legacy/util.ts +149 -0
- package/src/legacy/workspace-elements/connectors.ts +397 -0
- package/src/legacy/workspace-elements/index.ts +27 -0
- package/src/legacy/workspace-tools/commands/pullWorkspace.ts +70 -0
- package/src/legacy/workspace-tools/integrationElements.ts +78 -0
- package/src/legacy/workspace-tools/util.ts +149 -0
- package/src/mcp/server-status.ts +27 -0
- package/src/mcp/server.ts +36 -0
- package/src/mcp/tools/getTestAccessToken.ts +32 -0
- package/src/modules/api/account-api-client.ts +89 -0
- package/src/modules/api/index.ts +3 -0
- package/src/modules/api/membrane-api-client.ts +116 -0
- package/src/modules/api/workspace-api-client.ts +11 -0
- package/src/modules/config/cwd-context.tsx +11 -0
- package/src/modules/config/project/getAgentVersion.ts +16 -0
- package/src/modules/config/project/index.ts +8 -0
- package/src/modules/config/project/paths.ts +25 -0
- package/src/modules/config/project/readProjectConfig.ts +27 -0
- package/src/modules/config/project/useProjectConfig.tsx +103 -0
- package/src/modules/config/system/index.ts +35 -0
- package/src/modules/file-watcher/index.ts +166 -0
- package/src/modules/file-watcher/types.ts +14 -0
- package/src/modules/setup/steps.ts +9 -0
- package/src/modules/setup/useSetup.ts +16 -0
- package/src/modules/status/useStatus.ts +16 -0
- package/src/modules/workspace-element-service/constants.ts +121 -0
- package/src/modules/workspace-element-service/getTypeAndKeyFromPath.ts +69 -0
- package/src/modules/workspace-element-service/index.ts +304 -0
- package/src/testing/environment.ts +172 -0
- package/src/testing/runners/base.runner.ts +27 -0
- package/src/testing/runners/test.runner.ts +123 -0
- package/src/testing/scripts/generate-test-report.ts +757 -0
- package/src/testing/test-suites/base.ts +92 -0
- package/src/testing/test-suites/data-collection.ts +128 -0
- package/src/testing/testers/base.ts +115 -0
- package/src/testing/testers/create.ts +273 -0
- package/src/testing/testers/delete.ts +155 -0
- package/src/testing/testers/find-by-id.ts +135 -0
- package/src/testing/testers/list.ts +110 -0
- package/src/testing/testers/match.ts +149 -0
- package/src/testing/testers/search.ts +148 -0
- package/src/testing/testers/spec.ts +30 -0
- package/src/testing/testers/update.ts +284 -0
- package/src/utils/auth.ts +19 -0
- package/src/utils/constants.ts +27 -0
- package/src/utils/fields.ts +83 -0
- package/src/utils/logger.ts +106 -0
- package/src/utils/templating.ts +50 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { WorkspaceElementSpecs, WorkspaceElementType, type IntegrationAppClient } from '@integration-app/sdk'
|
|
2
|
+
import { toCamelCase } from 'js-convert-case'
|
|
3
|
+
|
|
4
|
+
import { getTypeAndKeyFromPath } from './getTypeAndKeyFromPath'
|
|
5
|
+
import { membraneClient } from '../api'
|
|
6
|
+
import { INTEGRATION_ELEMENTS } from './constants'
|
|
7
|
+
import { FileChangeEventType, FileChangeEvent } from '../file-watcher/types'
|
|
8
|
+
|
|
9
|
+
import type { FileWatcher } from '../file-watcher'
|
|
10
|
+
|
|
11
|
+
interface WorkspaceDataCache {
|
|
12
|
+
[key: string]: unknown[]
|
|
13
|
+
integration?: Array<{ id: string; key: string; name: string }>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class WorkspaceElementService {
|
|
17
|
+
private client: IntegrationAppClient
|
|
18
|
+
private workspaceDataCache: WorkspaceDataCache = {}
|
|
19
|
+
private cacheExpirationTime = 5 * 60 * 1000 // 5 minutes
|
|
20
|
+
private lastCacheUpdate = 0
|
|
21
|
+
|
|
22
|
+
constructor(private readonly fileWatcher: FileWatcher) {
|
|
23
|
+
this.setupEventHandlers()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async init(): Promise<void> {
|
|
27
|
+
this.client = await membraneClient.getClient(this.fileWatcher.getCwd())
|
|
28
|
+
|
|
29
|
+
// Perform initial pull (same as `membrane pull`).
|
|
30
|
+
// 1. Get all remote elements.
|
|
31
|
+
// 2. Match all local elements with all the remote elements.
|
|
32
|
+
// 3. Compare local and remote elements. If there is a conflict (some local data will be overwritten) - ask for confirmation
|
|
33
|
+
// 4. Compare the list of remote and local elements. If there are elements in local that are not in remote - ask for confirmation to delete.
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private setupEventHandlers(): void {
|
|
37
|
+
this.fileWatcher.on('added', (event) => this.processElementChange(event, 'added'))
|
|
38
|
+
this.fileWatcher.on('changed', (event) => this.processElementChange(event, 'changed'))
|
|
39
|
+
this.fileWatcher.on('deleted', (event) => this.processElementChange(event, 'deleted'))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async processElementChange(event: FileChangeEvent, eventType: FileChangeEventType): Promise<void> {
|
|
43
|
+
try {
|
|
44
|
+
const ref = getTypeAndKeyFromPath(event.relativePath)
|
|
45
|
+
if (!ref || !ref.key || !ref.type) {
|
|
46
|
+
console.debug(`Skipping file change - could not extract element reference from: ${event.relativePath}`)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.debug(`Processing ${eventType} element: ${ref.key} of type ${ref.type}`)
|
|
51
|
+
|
|
52
|
+
switch (eventType) {
|
|
53
|
+
case 'added':
|
|
54
|
+
case 'changed':
|
|
55
|
+
await this.pushElement(ref.type, { ...event.data, key: ref.key })
|
|
56
|
+
break
|
|
57
|
+
case 'deleted':
|
|
58
|
+
// ToDo: implement UI for confirming deleting of the remote elements
|
|
59
|
+
break
|
|
60
|
+
}
|
|
61
|
+
} catch (e) {
|
|
62
|
+
// ToDo: save errors and display them in the UI
|
|
63
|
+
console.error(`Failed to process event ${eventType} for element ${event.relativePath}:`, e)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async pushElement(elementType: WorkspaceElementType, elementData: Record<string, unknown>): Promise<void> {
|
|
68
|
+
const elementConfig = INTEGRATION_ELEMENTS[elementType]
|
|
69
|
+
|
|
70
|
+
if (!elementConfig) {
|
|
71
|
+
throw new Error(`Unknown element type: ${elementType}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const element = { ...elementData }
|
|
75
|
+
|
|
76
|
+
const workspaceData = await this.getWorkspaceData()
|
|
77
|
+
|
|
78
|
+
// Clean up element data
|
|
79
|
+
delete element.integrationId
|
|
80
|
+
|
|
81
|
+
const destinationElement = this.matchElement(element, elementType, workspaceData)
|
|
82
|
+
|
|
83
|
+
if (destinationElement) {
|
|
84
|
+
// Element exists - update or reset logic
|
|
85
|
+
await this.handleExistingElement(elementType, element, destinationElement)
|
|
86
|
+
} else {
|
|
87
|
+
// Element doesn't exist - create logic
|
|
88
|
+
await this.handleNewElement(elementType, element, workspaceData)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async handleExistingElement(
|
|
93
|
+
elementType: WorkspaceElementType,
|
|
94
|
+
element: Record<string, unknown>,
|
|
95
|
+
destinationElement: Record<string, unknown>,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const elementConfig = INTEGRATION_ELEMENTS[elementType]
|
|
98
|
+
|
|
99
|
+
const singleElementAccessorKey = toCamelCase(WorkspaceElementSpecs[elementType].name)
|
|
100
|
+
|
|
101
|
+
if (!this.hasParent(element) || (this.elementIsCustomized(element) && this.hasParent(element))) {
|
|
102
|
+
// Update the element without parent OR the integration-specific element that should be customized
|
|
103
|
+
await this.client[singleElementAccessorKey](destinationElement.id).put(element)
|
|
104
|
+
console.debug(`Updated ${element.integrationKey || 'universal'} ${elementConfig.element} ${element.key}`)
|
|
105
|
+
} else if (this.hasParent(element)) {
|
|
106
|
+
// Reset Integration-specific element if it has universal parent
|
|
107
|
+
try {
|
|
108
|
+
await this.client[singleElementAccessorKey](destinationElement.id).reset()
|
|
109
|
+
console.debug(
|
|
110
|
+
`Customization reset ${element.integrationKey || 'universal'} ${elementConfig.element} ${element.key}`,
|
|
111
|
+
)
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.debug('Error resetting element:', destinationElement, element)
|
|
114
|
+
throw error
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
console.warn(
|
|
118
|
+
`Corrupted element detected. Manual migration required: ${element.integrationKey || 'universal'} ${elementConfig.element} ${element.key}`,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async handleNewElement(
|
|
124
|
+
elementType: WorkspaceElementType,
|
|
125
|
+
element: Record<string, unknown>,
|
|
126
|
+
workspaceData: WorkspaceDataCache,
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
const elementConfig = INTEGRATION_ELEMENTS[elementType]
|
|
129
|
+
const singleElementAccessorKey = toCamelCase(WorkspaceElementSpecs[elementType].name)
|
|
130
|
+
const pluralElementAccessorKey = toCamelCase(WorkspaceElementSpecs[elementType].namePlural)
|
|
131
|
+
|
|
132
|
+
if (this.hasParent(element)) {
|
|
133
|
+
// Element has parent - apply universal to integration first
|
|
134
|
+
try {
|
|
135
|
+
await this.client[singleElementAccessorKey]({
|
|
136
|
+
key: element.key,
|
|
137
|
+
}).apply([element.integrationKey])
|
|
138
|
+
} catch (_error) {
|
|
139
|
+
// If apply fails, try direct put or create
|
|
140
|
+
try {
|
|
141
|
+
await this.client[singleElementAccessorKey]({
|
|
142
|
+
key: element.key,
|
|
143
|
+
integrationKey: element.integrationKey,
|
|
144
|
+
}).put(element)
|
|
145
|
+
} catch (_error) {
|
|
146
|
+
// Last resort - create with integration ID
|
|
147
|
+
const integrationId = this.findIntegrationId(element.integrationKey as string, workspaceData)
|
|
148
|
+
await this.client[pluralElementAccessorKey].create({
|
|
149
|
+
...element,
|
|
150
|
+
integrationId,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// If element is customized, apply customization
|
|
156
|
+
if (this.elementIsCustomized(element)) {
|
|
157
|
+
try {
|
|
158
|
+
await this.client[singleElementAccessorKey]({
|
|
159
|
+
key: element.key,
|
|
160
|
+
integrationKey: element.integrationKey,
|
|
161
|
+
}).put(element)
|
|
162
|
+
console.debug(
|
|
163
|
+
`Applied & Customized ${element.integrationKey || 'universal'} ${elementConfig.element} ${element.key}`,
|
|
164
|
+
)
|
|
165
|
+
} catch (_error) {
|
|
166
|
+
console.debug('Error customizing element:', element)
|
|
167
|
+
throw _error
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
console.debug(`Applied universal ${elementConfig.element} ${element.key} to ${element.integrationKey}`)
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
// Element has no parent - create directly
|
|
174
|
+
try {
|
|
175
|
+
if (element.integrationKey) {
|
|
176
|
+
delete element.integration
|
|
177
|
+
const integrationId = this.findIntegrationId(element.integrationKey as string, workspaceData)
|
|
178
|
+
element.integrationId = integrationId
|
|
179
|
+
}
|
|
180
|
+
await this.client[pluralElementAccessorKey].create(element)
|
|
181
|
+
console.debug(`Created ${element.integrationKey || 'universal'} ${elementConfig.element} ${element.key}`)
|
|
182
|
+
} catch (_error) {
|
|
183
|
+
console.debug(`Error creating element ${elementType}`, this.hasParent(element), element.integrationKey, element)
|
|
184
|
+
throw _error
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private hasParent(element: Record<string, unknown>): boolean {
|
|
190
|
+
return Object.keys(element).some((key) => /universal.*Id/g.test(key) || /parentId/g.test(key))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private elementIsCustomized(element: Record<string, unknown>): boolean {
|
|
194
|
+
return !!(element.integrationKey && (element.customized || element.isCustomized))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private matchElement(
|
|
198
|
+
element: Record<string, unknown>,
|
|
199
|
+
elementType: WorkspaceElementType,
|
|
200
|
+
workspaceData: WorkspaceDataCache,
|
|
201
|
+
): Record<string, unknown> | undefined {
|
|
202
|
+
const matchedElements = workspaceData[elementType]?.filter((item: any) => {
|
|
203
|
+
return item.key === element.key && item.integrationKey === element.integrationKey && !item.archivedAt
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
if (!matchedElements || matchedElements.length === 0) {
|
|
207
|
+
return undefined
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (matchedElements.length > 1) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`More than one ${element.integrationKey || 'universal'} ${elementType} with key ${element.key} found in the workspace`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return matchedElements[0] as Record<string, unknown>
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private findIntegrationId(integrationKey: string, workspaceData: WorkspaceDataCache): string {
|
|
220
|
+
const integration = workspaceData.integration?.find((item) => item.key === integrationKey)
|
|
221
|
+
if (!integration) {
|
|
222
|
+
throw new Error(`Integration with key ${integrationKey} not found`)
|
|
223
|
+
}
|
|
224
|
+
return integration.id
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async getWorkspaceData(): Promise<WorkspaceDataCache> {
|
|
228
|
+
const now = Date.now()
|
|
229
|
+
|
|
230
|
+
// Return cached data if it's still valid
|
|
231
|
+
if (this.lastCacheUpdate && now - this.lastCacheUpdate < this.cacheExpirationTime) {
|
|
232
|
+
return this.workspaceDataCache
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Refresh cache
|
|
236
|
+
console.debug('Refreshing workspace data cache')
|
|
237
|
+
const workspaceData: WorkspaceDataCache = {}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
// Get integrations first (needed for integration-specific elements)
|
|
241
|
+
const integrations = await this.client.integrations.findAll()
|
|
242
|
+
workspaceData.integration = integrations.map((item: any) => ({
|
|
243
|
+
id: item.id,
|
|
244
|
+
key: item.key,
|
|
245
|
+
name: item.name,
|
|
246
|
+
}))
|
|
247
|
+
|
|
248
|
+
// Get all exportable element types
|
|
249
|
+
for (const elementType of Object.keys(INTEGRATION_ELEMENTS)) {
|
|
250
|
+
const elementConfig = INTEGRATION_ELEMENTS[elementType as WorkspaceElementType]
|
|
251
|
+
if (elementConfig.exportable === false) continue
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const elements = []
|
|
255
|
+
|
|
256
|
+
// Get all universal elements
|
|
257
|
+
const universalElements = await this.client[elementConfig.elements].findAll()
|
|
258
|
+
elements.push(...universalElements)
|
|
259
|
+
|
|
260
|
+
// Get all integration-specific elements if applicable
|
|
261
|
+
if (elementConfig.integrationSpecific) {
|
|
262
|
+
for (const integration of integrations) {
|
|
263
|
+
try {
|
|
264
|
+
const integrationElements = await this.client[elementConfig.elements].findAll({
|
|
265
|
+
integrationId: integration.id,
|
|
266
|
+
})
|
|
267
|
+
elements.push(
|
|
268
|
+
...integrationElements.map((item: any) => ({
|
|
269
|
+
...item,
|
|
270
|
+
integrationKey: integration.key,
|
|
271
|
+
})),
|
|
272
|
+
)
|
|
273
|
+
} catch (error) {
|
|
274
|
+
console.error(`Failed to fetch ${elementType} for integration ${integration.key}:`, error)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
workspaceData[elementType] = elements
|
|
280
|
+
} catch (error) {
|
|
281
|
+
console.error(`Failed to fetch ${elementType} elements:`, error)
|
|
282
|
+
workspaceData[elementType] = []
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
this.workspaceDataCache = workspaceData
|
|
287
|
+
this.lastCacheUpdate = now
|
|
288
|
+
console.debug('Workspace data cache refreshed successfully')
|
|
289
|
+
|
|
290
|
+
return workspaceData
|
|
291
|
+
} catch (error) {
|
|
292
|
+
console.error('Failed to refresh workspace data cache:', error)
|
|
293
|
+
|
|
294
|
+
// Return existing cache if available, otherwise empty cache
|
|
295
|
+
if (Object.keys(this.workspaceDataCache).length > 0) {
|
|
296
|
+
console.debug('Using existing workspace data cache due to refresh failure')
|
|
297
|
+
return this.workspaceDataCache
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Return empty cache as fallback
|
|
301
|
+
return { integration: [] }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
|
|
4
|
+
import Anthropic from '@anthropic-ai/sdk'
|
|
5
|
+
import { IntegrationAppClient } from '@integration-app/sdk'
|
|
6
|
+
import yaml from 'js-yaml'
|
|
7
|
+
import jwt, { Algorithm } from 'jsonwebtoken'
|
|
8
|
+
import merge from 'lodash/merge.js'
|
|
9
|
+
|
|
10
|
+
import { BaseTestSuite } from './test-suites/base'
|
|
11
|
+
import { readProjectConfig } from '../modules/config/project/readProjectConfig'
|
|
12
|
+
|
|
13
|
+
import type { ProjectConfig } from '../modules/config/project/useProjectConfig'
|
|
14
|
+
|
|
15
|
+
const LLM_MODEL = 'claude-sonnet-4-20250514'
|
|
16
|
+
|
|
17
|
+
export interface TestRunOptions {
|
|
18
|
+
fix?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class TestEnvironment {
|
|
22
|
+
client: IntegrationAppClient
|
|
23
|
+
|
|
24
|
+
connectionId: string
|
|
25
|
+
|
|
26
|
+
testsDir: string
|
|
27
|
+
|
|
28
|
+
testBasePath: string
|
|
29
|
+
|
|
30
|
+
options: TestRunOptions
|
|
31
|
+
|
|
32
|
+
llm: {
|
|
33
|
+
complete: (params: { prompt: string; maxTokens: number }) => Promise<string>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
state: Record<string, unknown> = {}
|
|
37
|
+
|
|
38
|
+
constructor({
|
|
39
|
+
connectionId,
|
|
40
|
+
testsDir,
|
|
41
|
+
testBasePath,
|
|
42
|
+
client,
|
|
43
|
+
options,
|
|
44
|
+
llm,
|
|
45
|
+
}: {
|
|
46
|
+
connectionId: string
|
|
47
|
+
testsDir: string
|
|
48
|
+
testBasePath: string
|
|
49
|
+
options: TestRunOptions
|
|
50
|
+
client: IntegrationAppClient
|
|
51
|
+
llm: {
|
|
52
|
+
complete: (params: { prompt: string; maxTokens: number }) => Promise<string>
|
|
53
|
+
}
|
|
54
|
+
}) {
|
|
55
|
+
this.client = client
|
|
56
|
+
this.connectionId = connectionId
|
|
57
|
+
this.testsDir = testsDir
|
|
58
|
+
this.testBasePath = testBasePath
|
|
59
|
+
this.llm = llm
|
|
60
|
+
this.options = options
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static async create({
|
|
64
|
+
connectionId,
|
|
65
|
+
testBasePath,
|
|
66
|
+
options,
|
|
67
|
+
}: {
|
|
68
|
+
connectionId: string
|
|
69
|
+
testBasePath: string
|
|
70
|
+
options: TestRunOptions
|
|
71
|
+
}): Promise<TestEnvironment> {
|
|
72
|
+
const workspaceConfig = readProjectConfig()
|
|
73
|
+
|
|
74
|
+
if (!workspaceConfig) {
|
|
75
|
+
throw new Error('No membrane.config.yml found. Please run `membrane init` first.')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!workspaceConfig.workspaceKey || !workspaceConfig.workspaceSecret) {
|
|
79
|
+
throw new Error('Missing workspace credentials')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!workspaceConfig.anthropicApiKey) {
|
|
83
|
+
throw new Error('Anthropic API key not configured. Run `membrane init` to set up testing.')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const client = new IntegrationAppClient({
|
|
87
|
+
token: await this.createMembraneToken(workspaceConfig),
|
|
88
|
+
apiUri: workspaceConfig.apiUri,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const anthropicClient = new Anthropic({
|
|
92
|
+
apiKey: workspaceConfig.anthropicApiKey,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const llm = {
|
|
96
|
+
complete: async ({ prompt, maxTokens }) => {
|
|
97
|
+
const response = await anthropicClient.messages.create({
|
|
98
|
+
model: LLM_MODEL,
|
|
99
|
+
max_tokens: maxTokens,
|
|
100
|
+
messages: [
|
|
101
|
+
{
|
|
102
|
+
role: 'user',
|
|
103
|
+
content: prompt,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
})
|
|
107
|
+
return response.content[0].type === 'text' ? response.content[0].text : ''
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return new TestEnvironment({
|
|
112
|
+
client,
|
|
113
|
+
options,
|
|
114
|
+
connectionId,
|
|
115
|
+
testsDir: `src/testing/tests`,
|
|
116
|
+
testBasePath,
|
|
117
|
+
llm,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async run(suites: BaseTestSuite[]) {
|
|
122
|
+
this.state = {}
|
|
123
|
+
const allResults = {}
|
|
124
|
+
for (const suite of suites) {
|
|
125
|
+
await suite.run()
|
|
126
|
+
merge(allResults, suite.getResult())
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.writeResults(allResults)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async readYaml<T>(yamlPath: string): Promise<T | undefined> {
|
|
133
|
+
const fullConfigPath = path.join(this.testsDir, this.testBasePath, this.connectionId, yamlPath)
|
|
134
|
+
if (!fs.existsSync(fullConfigPath)) {
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return yaml.load(fs.readFileSync(fullConfigPath, 'utf8')) as T
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async writeYaml(yamlPath: string, config: any) {
|
|
142
|
+
const fullConfigPath = path.join(this.testsDir, this.testBasePath, this.connectionId, yamlPath)
|
|
143
|
+
fs.mkdirSync(path.dirname(fullConfigPath), { recursive: true })
|
|
144
|
+
fs.writeFileSync(fullConfigPath, yaml.dump(config, { noRefs: true }))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
writeResults(results: any) {
|
|
148
|
+
const connectionTestsDir = path.join(this.testsDir, this.testBasePath, this.connectionId)
|
|
149
|
+
fs.mkdirSync(connectionTestsDir, { recursive: true })
|
|
150
|
+
|
|
151
|
+
const resultsPath = path.join(connectionTestsDir, 'test-results.yaml')
|
|
152
|
+
fs.writeFileSync(resultsPath, yaml.dump(results, { noRefs: true }))
|
|
153
|
+
console.debug(`[TestRunner] Results written to: ${resultsPath}`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private static async createMembraneToken(workspaceConfig: ProjectConfig) {
|
|
157
|
+
const customerId = workspaceConfig.testCustomerId || 'test-customer'
|
|
158
|
+
|
|
159
|
+
const tokenData = {
|
|
160
|
+
id: customerId,
|
|
161
|
+
name: customerId,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const options = {
|
|
165
|
+
issuer: workspaceConfig.workspaceKey,
|
|
166
|
+
expiresIn: 7200,
|
|
167
|
+
algorithm: 'HS512' as Algorithm,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return jwt.sign(tokenData, workspaceConfig.workspaceSecret, options)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { getPaths } from '../../utils/constants'
|
|
2
|
+
|
|
3
|
+
import type { IntegrationAppClient } from '@integration-app/sdk'
|
|
4
|
+
|
|
5
|
+
export abstract class BaseRunner<T> {
|
|
6
|
+
protected client!: IntegrationAppClient
|
|
7
|
+
protected workspace?: string
|
|
8
|
+
|
|
9
|
+
protected fsPaths: ReturnType<typeof getPaths>
|
|
10
|
+
|
|
11
|
+
constructor(protected options?: T) {
|
|
12
|
+
this.fsPaths = getPaths()
|
|
13
|
+
|
|
14
|
+
if (options && typeof options === 'object') {
|
|
15
|
+
const opts = options as Record<string, unknown>
|
|
16
|
+
if ('client' in opts && opts.client) {
|
|
17
|
+
this.client = opts.client as IntegrationAppClient
|
|
18
|
+
}
|
|
19
|
+
if ('workspace' in opts) {
|
|
20
|
+
this.workspace = opts.workspace as string
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
abstract initialize(): Promise<void>
|
|
26
|
+
abstract run(): Promise<void>
|
|
27
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { ConnectorDataCollection } from '@integration-app/sdk'
|
|
2
|
+
import merge from 'lodash/merge.js'
|
|
3
|
+
|
|
4
|
+
import { BaseRunner } from './base.runner'
|
|
5
|
+
import { Logger } from '../../utils/logger'
|
|
6
|
+
import { TestEnvironment } from '../environment'
|
|
7
|
+
import { DataCollectionTestSuite } from '../test-suites/data-collection'
|
|
8
|
+
|
|
9
|
+
interface TestOptions {
|
|
10
|
+
testPath: string
|
|
11
|
+
path?: string
|
|
12
|
+
fix?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class TestRunner extends BaseRunner<TestOptions> {
|
|
16
|
+
constructor(options: TestOptions) {
|
|
17
|
+
super(options)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async initialize(): Promise<void> {
|
|
21
|
+
Logger.debug('Initializing test runner', {
|
|
22
|
+
prefix: 'TestRunner',
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async run(): Promise<void> {
|
|
27
|
+
try {
|
|
28
|
+
const { testPath, path, fix } = this.options
|
|
29
|
+
|
|
30
|
+
// Parse the test path (e.g., "connectors/netsuite/data/contacts/create")
|
|
31
|
+
const pathParts = testPath.split('/')
|
|
32
|
+
|
|
33
|
+
if (pathParts.length < 2) {
|
|
34
|
+
Logger.error('Invalid test path. Expected format: <type>/<name>[/additional/path][/method]')
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const [testType, testName, ...additionalPath] = pathParts
|
|
39
|
+
|
|
40
|
+
// For now, only connectors are fully supported
|
|
41
|
+
if (testType !== 'connectors') {
|
|
42
|
+
Logger.error(
|
|
43
|
+
`Test type "${testType}" is not yet fully implemented. Currently only "connectors" is fully supported.`,
|
|
44
|
+
)
|
|
45
|
+
Logger.error('Supported test types: connectors')
|
|
46
|
+
process.exit(1)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const connectionId = testName
|
|
50
|
+
|
|
51
|
+
const testBasePath = testType
|
|
52
|
+
|
|
53
|
+
const combinedPath = [...additionalPath, ...(path ? path.split('/') : [])].join('/')
|
|
54
|
+
|
|
55
|
+
const environment = await TestEnvironment.create({
|
|
56
|
+
connectionId,
|
|
57
|
+
testBasePath,
|
|
58
|
+
options: { fix },
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const client = environment.client
|
|
62
|
+
|
|
63
|
+
const dataCollections = (await client.get(`connections/${connectionId}/data`)) as ConnectorDataCollection[]
|
|
64
|
+
|
|
65
|
+
const suites = []
|
|
66
|
+
|
|
67
|
+
const pathSegments = combinedPath ? combinedPath.split('/') : []
|
|
68
|
+
|
|
69
|
+
if (pathSegments.length === 0 || pathSegments[0] === 'data') {
|
|
70
|
+
if (pathSegments[0] === 'data') {
|
|
71
|
+
pathSegments.shift()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Extract test method if present (e.g., "contacts/create" -> dataCollectionKey="contacts", testMethod="create")
|
|
75
|
+
let testMethod: string | undefined
|
|
76
|
+
let dataCollectionKey: string | undefined
|
|
77
|
+
|
|
78
|
+
if (pathSegments.length >= 1) {
|
|
79
|
+
dataCollectionKey = pathSegments[0]
|
|
80
|
+
|
|
81
|
+
if (pathSegments.length >= 2 && pathSegments[1].trim() !== '') {
|
|
82
|
+
testMethod = pathSegments[1]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const dataCollection of dataCollections) {
|
|
87
|
+
if (dataCollectionKey && dataCollectionKey !== dataCollection.key) {
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const suite = new DataCollectionTestSuite({
|
|
92
|
+
environment,
|
|
93
|
+
dataCollectionKey: dataCollection.key,
|
|
94
|
+
testMethod,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
suites.push(suite)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (suites.length === 0) {
|
|
102
|
+
Logger.error(`No test suites found for path: ${testPath}${combinedPath ? '/' + combinedPath : ''}`)
|
|
103
|
+
process.exit(1)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Run suites while preserving state between data collections
|
|
107
|
+
const allResults = {}
|
|
108
|
+
for (const suite of suites) {
|
|
109
|
+
await suite.run()
|
|
110
|
+
const suiteResult = suite.getResult()
|
|
111
|
+
Logger.debug(`Suite ${suite.constructor.name} result:`, { prefix: 'TestRunner' })
|
|
112
|
+
merge(allResults, suiteResult)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.debug(`[TestRunner] All results collected:`, Object.keys(allResults))
|
|
116
|
+
// Write the collected results
|
|
117
|
+
environment.writeResults(allResults)
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error('Error in TestRunner.run():', error)
|
|
120
|
+
throw error
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|