const { expect } = require('@playwright/test'); const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process'); const ADMIN_EMAIL = process.env.CRANK_E2E_ADMIN_EMAIL || 'owner@crank.local'; const ADMIN_PASSWORD = process.env.CRANK_E2E_ADMIN_PASSWORD || 'change-me-admin-password'; const MCP_PORT = Number(process.env.CRANK_E2E_MCP_PORT || 3302); const STREAM_FIXTURE_PORT = Number(process.env.CRANK_E2E_STREAM_FIXTURE_PORT || 3310); const GRPC_FIXTURE_BIND = process.env.CRANK_E2E_GRPC_FIXTURE_BIND || '127.0.0.1:3311'; const REPO_ROOT = path.resolve(__dirname, '../../../..'); let cachedEchoDescriptorSetB64 = null; function localized(en, ru) { return new RegExp(`(?:${en}|${ru})`, 'i'); } async function login(page) { await page.goto('/login'); await expect(page.locator('#login-form')).toBeVisible(); await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await page.locator('.btn-signin').click(); await expect(page).toHaveURL(/\/$/); await expect(page.locator('.page-title, .page-heading').first()).toBeVisible(); } async function browserJson(page, method, urlPath, body, okStatuses = [200]) { const response = await page.evaluate(async ({ method, urlPath, body }) => { const request = { method, credentials: 'same-origin', headers: {}, }; if (body !== undefined) { request.headers['Content-Type'] = 'application/json'; request.body = JSON.stringify(body); } const result = await fetch(urlPath, request); const text = await result.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch (_error) { json = null; } return { status: result.status, text, json, }; }, { method, urlPath, body }); if (!okStatuses.includes(response.status)) { throw new Error(`HTTP ${response.status} ${urlPath}: ${response.text}`); } return response.json; } async function getSession(page) { return browserJson(page, 'GET', '/api/auth/session'); } async function getCurrentWorkspace(page) { const session = await getSession(page); const workspaceId = session.current_workspace_id || (session.memberships && session.memberships[0] && session.memberships[0].workspace.id); const membership = (session.memberships || []).find((item) => item.workspace.id === workspaceId) || (session.memberships || [])[0]; return membership ? membership.workspace : null; } async function createOperation(page, workspaceId, payload) { return browserJson(page, 'POST', `/api/admin/workspaces/${encodeURIComponent(workspaceId)}/operations`, payload); } async function publishOperation(page, workspaceId, operationId, version = 1) { return browserJson( page, 'POST', `/api/admin/workspaces/${encodeURIComponent(workspaceId)}/operations/${encodeURIComponent(operationId)}/publish`, { version }, ); } async function createPlatformApiKey(page, workspaceId, name, scopes) { return browserJson( page, 'POST', `/api/admin/workspaces/${encodeURIComponent(workspaceId)}/platform-api-keys`, { name, scopes }, ); } async function createAgent(page, workspaceId, payload) { return browserJson(page, 'POST', `/api/admin/workspaces/${encodeURIComponent(workspaceId)}/agents`, payload); } async function saveAgentBindings(page, workspaceId, agentId, bindings) { return browserJson( page, 'POST', `/api/admin/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/bindings`, bindings, ); } async function publishAgent(page, workspaceId, agentId, version = 1) { return browserJson( page, 'POST', `/api/admin/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/publish`, { version }, ); } function schemaObject(fields) { return { type: 'object', required: true, fields: fields || {}, }; } function schemaString(required = true) { return { type: 'string', required, }; } function uniqueName(prefix) { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } function findEchoDescriptorSetB64() { if (cachedEchoDescriptorSetB64) { return cachedEchoDescriptorSetB64; } const escapedRoot = REPO_ROOT.replace(/'/g, "'\\''"); const descriptorPath = execFileSync( 'bash', ['-lc', `find '${escapedRoot}/target/debug/build' -path '*/out/echo_descriptor.bin' -print -quit`], { encoding: 'utf8' }, ).trim(); if (!descriptorPath) { throw new Error('echo_descriptor.bin was not found under target/debug/build'); } cachedEchoDescriptorSetB64 = fs.readFileSync(descriptorPath).toString('base64'); return cachedEchoDescriptorSetB64; } function buildRestWindowOperationPayload(name) { return { name, display_name: 'Playwright Window Logs', category: 'streaming', protocol: 'rest', target: { kind: 'rest', base_url: `http://127.0.0.1:${STREAM_FIXTURE_PORT}`, method: 'GET', path_template: '/sse/logs', static_headers: {}, }, input_schema: schemaObject({ window: schemaString(false), }), output_schema: schemaObject({}), input_mapping: { rules: [ { source: '$.mcp.window', target: '$.request.query.window', required: false, default_value: 'recent', }, ], }, output_mapping: { rules: [] }, execution_config: { timeout_ms: 1000, headers: {}, streaming: { mode: 'window', transport_behavior: 'server_stream', window_duration_ms: 1000, upstream_timeout_ms: 1000, max_items: 3, max_bytes: 16384, aggregation_mode: 'summary_plus_samples', items_path: '$.items', done_path: '$.done', redacted_paths: [], truncate_item_fields: false, drop_duplicates: false, tool_family: {}, }, }, tool_description: { title: 'Playwright Window Logs', description: 'Collects a bounded SSE log window from the local fixture.', tags: ['playwright', 'streaming', 'window'], examples: [{ input: {} }], }, }; } function buildGrpcSessionOperationPayload(name) { return { name, display_name: 'Playwright gRPC Session', category: 'streaming', protocol: 'grpc', target: { kind: 'grpc', server_addr: `http://${GRPC_FIXTURE_BIND}`, package: 'echo', service: 'EchoService', method: 'ServerEcho', descriptor_ref: 'desc_echo_playwright', descriptor_set_b64: findEchoDescriptorSetB64(), }, input_schema: schemaObject({ message: schemaString(true), }), output_schema: schemaObject({}), input_mapping: { rules: [ { source: '$.mcp.message', target: '$.request.grpc.message', required: true, }, ], }, output_mapping: { rules: [] }, execution_config: { timeout_ms: 1000, headers: {}, streaming: { mode: 'session', transport_behavior: 'server_stream', window_duration_ms: 1000, poll_interval_ms: 250, upstream_timeout_ms: 1000, idle_timeout_ms: 5000, max_session_lifetime_ms: 60000, max_items: 1, max_bytes: 16384, aggregation_mode: 'summary_plus_samples', items_path: '$.items', done_path: '$.done', redacted_paths: [], truncate_item_fields: false, drop_duplicates: false, tool_family: { start_tool_name: `${name}_start`, poll_tool_name: `${name}_poll`, stop_tool_name: `${name}_stop`, }, }, }, tool_description: { title: 'Playwright gRPC Session', description: 'Streams gRPC echo messages through a bounded MCP session tool family.', tags: ['playwright', 'streaming', 'session', 'grpc'], examples: [{ input: { message: 'hello' } }], }, }; } function buildRestAsyncJobOperationPayload(name) { return { name, display_name: 'Playwright Async Lead', category: 'streaming', protocol: 'rest', target: { kind: 'rest', base_url: `http://127.0.0.1:${STREAM_FIXTURE_PORT}`, method: 'POST', path_template: '/crm/leads', static_headers: {}, }, input_schema: schemaObject({ email: schemaString(true), }), output_schema: schemaObject({ id: schemaString(true), }), input_mapping: { rules: [ { source: '$.mcp.email', target: '$.request.body.email', required: true, }, ], }, output_mapping: { rules: [ { source: '$.response.body.id', target: '$.output.id', required: true, }, ], }, execution_config: { timeout_ms: 2000, headers: {}, streaming: { mode: 'async_job', transport_behavior: 'deferred_result', poll_interval_ms: 250, upstream_timeout_ms: 2000, max_session_lifetime_ms: 300000, max_bytes: 16384, aggregation_mode: 'summary_only', redacted_paths: [], truncate_item_fields: false, drop_duplicates: false, tool_family: { start_tool_name: `${name}_start`, status_tool_name: `${name}_status`, result_tool_name: `${name}_result`, cancel_tool_name: `${name}_cancel`, }, }, }, tool_description: { title: 'Playwright Async Lead', description: 'Creates a lead through the async job MCP tool family.', tags: ['playwright', 'streaming', 'async_job'], examples: [{ input: { email: 'user@example.com' } }], }, }; } async function setupPublishedAgent(page, { operationPayload, agentSlug, toolName, toolTitle, toolDescription }) { const workspace = await getCurrentWorkspace(page); if (!workspace) { throw new Error('current workspace was not resolved'); } const createdOperation = await createOperation(page, workspace.id, operationPayload); await publishOperation(page, workspace.id, createdOperation.operation_id, createdOperation.version); const createdAgent = await createAgent(page, workspace.id, { slug: agentSlug, display_name: toolTitle, description: toolDescription, instructions: {}, tool_selection_policy: {}, }); await saveAgentBindings(page, workspace.id, createdAgent.agent_id, [ { operation_id: createdOperation.operation_id, operation_version: createdOperation.version, tool_name: toolName, tool_title: toolTitle, tool_description_override: toolDescription, enabled: true, }, ]); await publishAgent(page, workspace.id, createdAgent.agent_id, createdAgent.version); const key = await createPlatformApiKey(page, workspace.id, uniqueName('playwright_key'), ['read', 'write']); return { workspace, operationId: createdOperation.operation_id, agentId: createdAgent.agent_id, apiKeySecret: key.secret, }; } function mcpUrl(workspaceSlug, agentSlug) { return `http://127.0.0.1:${MCP_PORT}/v1/${workspaceSlug}/${agentSlug}`; } async function initializeMcpSession({ workspaceSlug, agentSlug, apiKey }) { const response = await fetch(mcpUrl(workspaceSlug, agentSlug), { method: 'POST', headers: { Accept: 'application/json, text/event-stream', Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', }, }), }); if (!response.ok) { throw new Error(`initialize failed: ${response.status} ${await response.text()}`); } const sessionId = response.headers.get('MCP-Session-Id'); if (!sessionId) { throw new Error('initialize response did not include MCP-Session-Id'); } const initialized = await fetch(mcpUrl(workspaceSlug, agentSlug), { method: 'POST', headers: { Accept: 'application/json, text/event-stream', Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'MCP-Session-Id': sessionId, 'MCP-Protocol-Version': '2025-11-25', }, body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {}, }), }); if (initialized.status !== 202) { throw new Error(`initialized notification failed: ${initialized.status} ${await initialized.text()}`); } return { sessionId, workspaceSlug, agentSlug, apiKey, }; } async function mcpToolCall(session, toolName, argumentsValue) { const response = await fetch(mcpUrl(session.workspaceSlug, session.agentSlug), { method: 'POST', headers: { Accept: 'application/json, text/event-stream', Authorization: `Bearer ${session.apiKey}`, 'Content-Type': 'application/json', 'MCP-Session-Id': session.sessionId, 'MCP-Protocol-Version': '2025-11-25', }, body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method: 'tools/call', params: { name: toolName, arguments: argumentsValue || {}, }, }), }); const payload = await response.json(); if (!response.ok || payload.error) { throw new Error(`tools/call failed for ${toolName}: ${JSON.stringify(payload)}`); } return payload.result.structuredContent; } module.exports = { ADMIN_EMAIL, ADMIN_PASSWORD, browserJson, buildGrpcSessionOperationPayload, buildRestAsyncJobOperationPayload, buildRestWindowOperationPayload, createAgent, createOperation, createPlatformApiKey, getCurrentWorkspace, getSession, initializeMcpSession, login, localized, mcpToolCall, publishAgent, publishOperation, saveAgentBindings, setupPublishedAgent, uniqueName, };