Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions cli/e2e/helpers/cleanup.ts
Original file line number Diff line number Diff line change
@@ -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<ServiceRef[]> {
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<SecretRef[]> {
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<void> {
if (!serviceId) {
return;
}
await runCli('service', 'delete', serviceId, '-f');
}

export async function deleteSecretIfPresent(secretId: string | undefined): Promise<void> {
if (!secretId) {
return;
}
await runCli('secret', 'delete', secretId);
}

export async function deleteServicesByNameVersion(name: string, version: string): Promise<void> {
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<void> {
const secrets = await listSecrets();
for (const secret of secrets) {
if (secret.name === name) {
await runCliExpectSuccess('secret', 'delete', secret.id);
}
}
}
31 changes: 30 additions & 1 deletion cli/e2e/helpers/cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export async function runCli(...args: string[]): Promise<CliResult> {
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
exitCode: result.exitCode ?? 1,
};
}

Expand All @@ -84,3 +84,32 @@ export async function runCliExpectSuccess(...args: string[]): Promise<CliResult>
return result;
}

export async function runCliJson<T = unknown>(...args: string[]): Promise<T> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it an helper to run the CLI command with -o json flag? If yes, then we should add it explicitly and not set expectations on the caller to have added -o json to the method args.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 The helper name implies that it runs a JSON-producing CLI command, so callers should not have to remember to pass -o json. I’ll update it so runCliJson() appends -o json itself and only accepts the logical CLI command arguments.

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<void> {
await runCliExpectSuccess(
'login',
'-u', 'e2euser',
'-p', 'e2e-password',
'-s', 'http://localhost:5555',
'-k',
);
}
161 changes: 161 additions & 0 deletions cli/e2e/helpers/mcp-client.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<McpResponse> {
const headers: Record<string, string> = {
'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<string, any> = {}): 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<McpTool[]> {
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<McpTool[]> {
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<string, any>, sessionId?: string): Promise<McpResponse> {
return mcpRequest(url, 'tools/call', { name, arguments: args }, sessionId);
}
1 change: 0 additions & 1 deletion cli/e2e/helpers/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,3 @@ export async function stopInfrastructure(): Promise<void> {
});
console.log('✅ Infrastructure stopped.');
}