diff --git a/cli/e2e/helpers/cleanup.ts b/cli/e2e/helpers/cleanup.ts new file mode 100644 index 0000000..35a1dd3 --- /dev/null +++ b/cli/e2e/helpers/cleanup.ts @@ -0,0 +1,75 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { runCli, runCliExpectSuccess } from './cli-runner.js'; + +interface ServiceRef { + id: string; + name: string; + version: string; +} + +interface SecretRef { + id: string; + name: string; +} + +async function listServices(): Promise { + const result = await runCli('service', 'list', '-o', 'json'); + if (result.exitCode !== 0 || !result.stdout.trim()) { + return []; + } + return JSON.parse(result.stdout) as ServiceRef[]; +} + +async function listSecrets(): Promise { + const result = await runCli('secret', 'list', '-o', 'json'); + if (result.exitCode !== 0 || !result.stdout.trim()) { + return []; + } + return JSON.parse(result.stdout) as SecretRef[]; +} + +export async function deleteServiceIfPresent(serviceId: string | undefined): Promise { + if (!serviceId) { + return; + } + await runCli('service', 'delete', serviceId, '-f'); +} + +export async function deleteSecretIfPresent(secretId: string | undefined): Promise { + if (!secretId) { + return; + } + await runCli('secret', 'delete', secretId); +} + +export async function deleteServicesByNameVersion(name: string, version: string): Promise { + const services = await listServices(); + for (const service of services) { + if (service.name === name && service.version === version) { + await runCliExpectSuccess('service', 'delete', service.id, '-f'); + } + } +} + +export async function deleteSecretsByName(name: string): Promise { + const secrets = await listSecrets(); + for (const secret of secrets) { + if (secret.name === name) { + await runCliExpectSuccess('secret', 'delete', secret.id); + } + } +} diff --git a/cli/e2e/helpers/cli-runner.ts b/cli/e2e/helpers/cli-runner.ts index e101ab8..89f863a 100644 --- a/cli/e2e/helpers/cli-runner.ts +++ b/cli/e2e/helpers/cli-runner.ts @@ -63,7 +63,7 @@ export async function runCli(...args: string[]): Promise { return { stdout: result.stdout, stderr: result.stderr, - exitCode: result.exitCode, + exitCode: result.exitCode ?? 1, }; } @@ -84,3 +84,32 @@ export async function runCliExpectSuccess(...args: string[]): Promise return result; } +export async function runCliJson(...args: string[]): Promise { + const jsonArgs = [...args, '-o', 'json']; + const result = await runCliExpectSuccess(...jsonArgs); + const output = result.stdout.trim(); + if (!output) { + throw new Error(`CLI command did not return JSON\nargs: ${jsonArgs.join(' ')}`); + } + + try { + return JSON.parse(output) as T; + } catch (error) { + throw new Error( + `CLI command returned invalid JSON\n` + + `args: ${jsonArgs.join(' ')}\n` + + `stdout: ${result.stdout}\n` + + `error: ${(error as Error).message}` + ); + } +} + +export async function login(): Promise { + await runCliExpectSuccess( + 'login', + '-u', 'e2euser', + '-p', 'e2e-password', + '-s', 'http://localhost:5555', + '-k', + ); +} diff --git a/cli/e2e/helpers/mcp-client.ts b/cli/e2e/helpers/mcp-client.ts new file mode 100644 index 0000000..5c3ece2 --- /dev/null +++ b/cli/e2e/helpers/mcp-client.ts @@ -0,0 +1,161 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { GATEWAY_URL } from './setup.js'; + +export interface McpTool { + name: string; +} + +export interface McpResponse { + jsonrpc: string; + id: number; + result?: any; + error?: { + code: number; + message: string; + data?: any; + }; +} + +interface ToolExpectation { + exact?: number; + minimum?: number; + include?: string[]; + exclude?: string[]; +} + +const MCP_PROTOCOL_VERSION = '2025-06-18'; +const E2E_ORG = 'e2eorg'; + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export function mcpServiceName(name: string): string { + return name.replaceAll(' ', '+'); +} + +export function mcpUrl(serviceName: string, serviceVersion: string): string { + return `${GATEWAY_URL}/mcp/${E2E_ORG}/${mcpServiceName(serviceName)}/${serviceVersion}`; +} + +export async function mcpRequest(url: string, method: string, params: any = {}, sessionId?: string): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (sessionId) { + headers['MCP-Session-Id'] = sessionId; + headers['MCP-Protocol-Version'] = MCP_PROTOCOL_VERSION; + } + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + id: Date.now(), + method, + params, + }), + }); + + const body = await response.text(); + if (!response.ok) { + throw new Error(`MCP ${method} failed with HTTP ${response.status}: ${body}`); + } + return JSON.parse(body) as McpResponse; +} + +export async function initializeMcp(url: string, capabilities: Record = {}): Promise<{ body: McpResponse; sessionId?: string }> { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: Date.now(), + method: 'initialize', + params: { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities, + clientInfo: { name: 'reshapr-cli-e2e', version: '0.0.1' }, + }, + }), + }); + + const bodyText = await response.text(); + if (!response.ok) { + throw new Error(`MCP initialize failed with HTTP ${response.status}: ${bodyText}`); + } + + return { + body: JSON.parse(bodyText) as McpResponse, + sessionId: response.headers.get('mcp-session-id') ?? undefined, + }; +} + +export async function listMcpTools(url: string): Promise { + const body = await mcpRequest(url, 'tools/list', {}); + if (body.error) { + throw new Error(`MCP tools/list returned error: ${JSON.stringify(body.error)}`); + } + const tools = body.result?.tools; + if (!Array.isArray(tools)) { + throw new Error(`MCP tools/list did not return result.tools[]: ${JSON.stringify(body)}`); + } + return tools as McpTool[]; +} + +export async function waitForMcpTools(url: string, expectation: ToolExpectation): Promise { + const deadline = Date.now() + 60_000; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const tools = await listMcpTools(url); + assertToolExpectation(tools, expectation); + return tools; + } catch (error) { + lastError = error; + await sleep(2_000); + } + } + + throw lastError instanceof Error ? lastError : new Error('MCP tools did not satisfy expectation'); +} + +function assertToolExpectation(tools: McpTool[], expectation: ToolExpectation): void { + const names = new Set(tools.map(tool => tool.name)); + if (expectation.exact !== undefined && tools.length !== expectation.exact) { + throw new Error(`Expected exactly ${expectation.exact} MCP tools, got ${tools.length}`); + } + if (expectation.minimum !== undefined && tools.length < expectation.minimum) { + throw new Error(`Expected at least ${expectation.minimum} MCP tools, got ${tools.length}`); + } + for (const name of expectation.include ?? []) { + if (!names.has(name)) { + throw new Error(`Expected MCP tool '${name}' to be present`); + } + } + for (const name of expectation.exclude ?? []) { + if (names.has(name)) { + throw new Error(`Expected MCP tool '${name}' to be absent`); + } + } +} + +export async function callMcpTool(url: string, name: string, args: Record, sessionId?: string): Promise { + return mcpRequest(url, 'tools/call', { name, arguments: args }, sessionId); +} diff --git a/cli/e2e/helpers/setup.ts b/cli/e2e/helpers/setup.ts index 027bd03..6c89430 100644 --- a/cli/e2e/helpers/setup.ts +++ b/cli/e2e/helpers/setup.ts @@ -123,4 +123,3 @@ export async function stopInfrastructure(): Promise { }); console.log('✅ Infrastructure stopped.'); } -