ui: gate community wizard capabilities
This commit is contained in:
@@ -31,8 +31,10 @@ Implementation slices:
|
||||
Progress:
|
||||
- done: shared capability payload exists in `crank-core` and `admin-api`;
|
||||
- done: authenticated `/api/admin/capabilities` exposes Community defaults to UI;
|
||||
- in_progress: first UI consumer uses capability payload in `Settings`;
|
||||
- pending: gate premium protocols and security levels in wizard and agents flows.
|
||||
- done: first UI consumer uses capability payload in `Settings`;
|
||||
- done: `protocol-capabilities` is edition-aware and Community wizard hides premium protocol cards;
|
||||
- in_progress: surface Community-only security guidance inside wizard flow;
|
||||
- pending: add server-side validation so unsupported protocol and security-level drafts are rejected, not only hidden in UI.
|
||||
|
||||
DoD:
|
||||
- UI знает, какие функции входят в текущую редакцию;
|
||||
|
||||
@@ -1038,6 +1038,35 @@ mod tests {
|
||||
assert_eq!(response["limits"]["max_agents_per_workspace"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn returns_community_protocol_capabilities_only_for_supported_protocols() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("community_protocol_capabilities");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let response = assert_success_json(
|
||||
client
|
||||
.get(format!("{base_url}/protocol-capabilities"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response["items"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(
|
||||
response["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|item| item["protocol"].as_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["rest", "graphql", "grpc"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn manages_workspace_access_lifecycle() {
|
||||
|
||||
@@ -1556,16 +1556,12 @@ impl AdminService {
|
||||
}
|
||||
|
||||
pub async fn list_protocol_capabilities(&self) -> Vec<ProtocolCapabilityView> {
|
||||
[
|
||||
Protocol::Rest,
|
||||
Protocol::Graphql,
|
||||
Protocol::Grpc,
|
||||
Protocol::Websocket,
|
||||
Protocol::Soap,
|
||||
]
|
||||
.into_iter()
|
||||
.map(protocol_capability_view)
|
||||
.collect()
|
||||
self.get_capabilities()
|
||||
.await
|
||||
.supported_protocols
|
||||
.into_iter()
|
||||
.map(protocol_capability_view)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn get_capabilities(&self) -> EditionCapabilities {
|
||||
|
||||
+21
-4
@@ -1,10 +1,29 @@
|
||||
/* ══════════════════════════════════════════════════
|
||||
WIZARD — shell
|
||||
══════════════════════════════════════════════════ */
|
||||
.wizard-page {
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.wizard-page .navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.wizard-page .progress-strip {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.wizard-shell {
|
||||
min-height: calc(100vh - 60px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 56px;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════
|
||||
@@ -18,8 +37,6 @@
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
gap: 20px;
|
||||
position: sticky;
|
||||
top: 60px;
|
||||
z-index: 30;
|
||||
box-shadow: 0 1px 0 var(--border-subtle), 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
@@ -244,9 +261,9 @@
|
||||
.steps-list::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 17px;
|
||||
left: 27px;
|
||||
top: 32px;
|
||||
bottom: 32px;
|
||||
bottom: 40px;
|
||||
width: 1px;
|
||||
background: var(--border);
|
||||
z-index: 0;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<link rel="stylesheet" href="../../css/pages.css">
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body>
|
||||
<body class="wizard-page">
|
||||
|
||||
<!-- ═══════════════════ NAVBAR ═══════════════════ -->
|
||||
<nav class="navbar">
|
||||
|
||||
@@ -134,4 +134,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-callout" id="wizard-edition-protocol-note" hidden>
|
||||
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="7"/>
|
||||
<path d="M8 11V8M8 5v-.5"/>
|
||||
</svg>
|
||||
<div class="info-callout-body">
|
||||
<div class="info-callout-title" data-i18n="wizard.step1.community_protocol_title">Community protocol scope</div>
|
||||
<div class="info-callout-text" id="wizard-edition-protocol-note-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /step-pane-1 -->
|
||||
|
||||
@@ -87,6 +87,17 @@ tls:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-callout" id="wizard-security-level-note" hidden style="margin-bottom: 20px;">
|
||||
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="7"/>
|
||||
<path d="M8 11V8M8 5v-.5"/>
|
||||
</svg>
|
||||
<div class="info-callout-body">
|
||||
<div class="info-callout-title" data-i18n="wizard.step5.security_level_title">Operation security in Community</div>
|
||||
<div class="info-callout-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wizard-streaming-config-card" class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
|
||||
@@ -575,6 +575,8 @@ var TRANSLATIONS = {
|
||||
'wizard.step1.unary': 'Unary',
|
||||
'wizard.step1.tip_title': 'You can change this later',
|
||||
'wizard.step1.tip_body': 'Switching protocol after configuring subsequent steps will clear schema and mapping data. Save your operation as a draft before experimenting. See protocol migration guide for details.',
|
||||
'wizard.step1.community_protocol_title': 'Community protocol scope',
|
||||
'wizard.step1.community_protocol_note': 'This Community build hides {protocols}. Commercial editions unlock those protocol surfaces.',
|
||||
'wizard.step2.title': 'Select upstream & endpoint',
|
||||
'wizard.step2.subtitle': 'Choose an existing upstream or register a new one. Then define the specific endpoint path this operation will call.',
|
||||
'wizard.required': 'Required',
|
||||
@@ -762,6 +764,8 @@ var TRANSLATIONS = {
|
||||
'wizard.step5.execution': 'Execution',
|
||||
'wizard.step5.exec_title': 'Execution config',
|
||||
'wizard.step5.exec_subtitle': 'Timeouts, retry policy and auth profile reference',
|
||||
'wizard.step5.security_level_title': 'Operation security in Community',
|
||||
'wizard.step5.community_security_note': 'Community supports only the standard operation security level with static AI-agent keys. Short-lived and one-time token flows require a commercial edition.',
|
||||
'wizard.step5.live_title': 'Live validation and publishing',
|
||||
'wizard.step5.draft_actions': 'Draft actions',
|
||||
'wizard.step5.live_save_title': 'Live actions save the draft first',
|
||||
@@ -931,6 +935,8 @@ var TRANSLATIONS = {
|
||||
'agents.title': 'Agents',
|
||||
'agents.subtitle': 'Named MCP endpoints that expose a curated subset of operations to an LLM',
|
||||
'agents.new': 'New agent',
|
||||
'agents.limit.title': 'Agent limit reached',
|
||||
'agents.limit.message': 'This Community build allows up to {limit} AI agent. Upgrade the edition or remove an existing agent to add another one.',
|
||||
'agents.stats.total': 'Total agents',
|
||||
'agents.stats.total_sub': 'across this workspace',
|
||||
'agents.stats.published': 'Published',
|
||||
@@ -1623,6 +1629,8 @@ var TRANSLATIONS = {
|
||||
'wizard.step1.unary': 'Unary',
|
||||
'wizard.step1.tip_title': 'Это можно изменить позже',
|
||||
'wizard.step1.tip_body': 'Смена протокола после настройки следующих шагов очистит схемы и mapping. Сохраните операцию как черновик перед экспериментами. См. гайд по миграции протоколов.',
|
||||
'wizard.step1.community_protocol_title': 'Граница протоколов Community',
|
||||
'wizard.step1.community_protocol_note': 'В этой Community-сборке скрыты {protocols}. Эти протокольные поверхности открываются в коммерческих редакциях.',
|
||||
'wizard.step2.title': 'Выберите upstream и endpoint',
|
||||
'wizard.step2.subtitle': 'Выберите существующий upstream или зарегистрируйте новый. Затем задайте конкретный путь endpoint-а для этой операции.',
|
||||
'wizard.required': 'Обязательно',
|
||||
@@ -1812,6 +1820,8 @@ var TRANSLATIONS = {
|
||||
'wizard.step5.execution': 'Исполнение',
|
||||
'wizard.step5.exec_title': 'Execution config',
|
||||
'wizard.step5.exec_subtitle': 'Таймауты, retry policy и ссылка на auth profile',
|
||||
'wizard.step5.security_level_title': 'Защита операций в Community',
|
||||
'wizard.step5.community_security_note': 'Community поддерживает только стандартный уровень защиты операций со статическими ключами AI-агента. Короткоживущие и одноразовые токены требуют коммерческую редакцию.',
|
||||
'wizard.step5.live_title': 'Live-проверка и публикация',
|
||||
'wizard.step5.draft_actions': 'Действия черновика',
|
||||
'wizard.step5.live_save_title': 'Live-действия сначала сохраняют черновик',
|
||||
@@ -1985,6 +1995,8 @@ var TRANSLATIONS = {
|
||||
'agents.title': 'Агенты',
|
||||
'agents.subtitle': 'Именованные MCP endpoint-ы, открывающие LLM ограниченный набор операций',
|
||||
'agents.new': 'Новый агент',
|
||||
'agents.limit.title': 'Достигнут лимит агентов',
|
||||
'agents.limit.message': 'В этой Community-сборке доступно не более {limit} AI-агента. Чтобы добавить еще одного, удалите существующего агента или перейдите на другую редакцию.',
|
||||
'agents.stats.total': 'Всего агентов',
|
||||
'agents.stats.total_sub': 'в этом воркспейсе',
|
||||
'agents.stats.published': 'Опубликованы',
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
function bindProtocolCards() {
|
||||
document.querySelectorAll('.protocol-card').forEach(function(card) {
|
||||
card.addEventListener('click', function() {
|
||||
if (card.hidden || card.getAttribute('aria-disabled') === 'true') {
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('.protocol-card').forEach(function(item) {
|
||||
item.classList.remove('selected');
|
||||
item.setAttribute('aria-checked', 'false');
|
||||
@@ -214,6 +217,56 @@
|
||||
});
|
||||
}
|
||||
|
||||
function applyEditionProtocolVisibility(capabilities) {
|
||||
var supported = capabilities && Array.isArray(capabilities.supported_protocols)
|
||||
? capabilities.supported_protocols.slice()
|
||||
: ['rest', 'graphql', 'grpc'];
|
||||
var hiddenProtocols = [];
|
||||
|
||||
document.querySelectorAll('.protocol-card').forEach(function(card) {
|
||||
var protocol = card.dataset.protocol || 'rest';
|
||||
var isSupported = supported.indexOf(protocol) >= 0;
|
||||
card.hidden = !isSupported;
|
||||
card.setAttribute('aria-disabled', isSupported ? 'false' : 'true');
|
||||
if (!isSupported) {
|
||||
card.classList.remove('selected');
|
||||
card.setAttribute('aria-checked', 'false');
|
||||
hiddenProtocols.push(protocol);
|
||||
}
|
||||
});
|
||||
|
||||
if (supported.indexOf(window.wizardProtocol) === -1) {
|
||||
window.wizardProtocol = supported[0] || 'rest';
|
||||
}
|
||||
|
||||
var selectedCard = document.querySelector('.protocol-card[data-protocol="' + window.wizardProtocol + '"]');
|
||||
if (selectedCard) {
|
||||
selectedCard.classList.add('selected');
|
||||
selectedCard.setAttribute('aria-checked', 'true');
|
||||
}
|
||||
|
||||
var step3Name = document.getElementById('sidebar-step-3-name');
|
||||
var labels = window.CrankWizardState.step3Labels();
|
||||
if (step3Name) {
|
||||
step3Name.textContent = labels[window.wizardProtocol] || tKey('wizard.step3.rest.label');
|
||||
}
|
||||
|
||||
var note = document.getElementById('wizard-edition-protocol-note');
|
||||
var noteText = document.getElementById('wizard-edition-protocol-note-text');
|
||||
if (note && noteText) {
|
||||
if (hiddenProtocols.length) {
|
||||
note.hidden = false;
|
||||
noteText.textContent = tfKey('wizard.step1.community_protocol_note', {
|
||||
protocols: hiddenProtocols.map(function(protocol) {
|
||||
return tKey('settings.capability.' + protocol);
|
||||
}).join(', ')
|
||||
});
|
||||
} else {
|
||||
note.hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.CrankWizardShell = {
|
||||
TOTAL_STEPS: TOTAL_STEPS,
|
||||
step3PanelId: step3PanelId,
|
||||
@@ -222,5 +275,6 @@
|
||||
goToStep: goToStep,
|
||||
loadWizardPanels: loadWizardPanels,
|
||||
bindProtocolCards: bindProtocolCards,
|
||||
applyEditionProtocolVisibility: applyEditionProtocolVisibility,
|
||||
};
|
||||
}());
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
grpcDescriptorServices: [],
|
||||
soapServiceCatalog: [],
|
||||
wizardProtocolCapabilities: null,
|
||||
wizardEditionCapabilities: null,
|
||||
wizardSecrets: [],
|
||||
wizardAuthProfiles: [],
|
||||
selectedUpstreamId: null,
|
||||
@@ -68,11 +69,29 @@
|
||||
};
|
||||
}
|
||||
|
||||
function defaultEditionCapabilities() {
|
||||
return {
|
||||
edition: 'community',
|
||||
supported_protocols: ['rest', 'graphql', 'grpc'],
|
||||
supported_security_levels: ['standard'],
|
||||
machine_access_modes: ['static_agent_key'],
|
||||
limits: {
|
||||
max_workspaces: 1,
|
||||
max_users_per_workspace: 1,
|
||||
max_agents_per_workspace: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function currentProtocolCapabilities() {
|
||||
var capabilities = window.wizardProtocolCapabilities || defaultProtocolCapabilities();
|
||||
return capabilities[window.wizardProtocol] || capabilities.rest;
|
||||
}
|
||||
|
||||
function currentEditionCapabilities() {
|
||||
return window.wizardEditionCapabilities || defaultEditionCapabilities();
|
||||
}
|
||||
|
||||
async function loadProtocolCapabilities() {
|
||||
if (!window.wizardWorkspaceId || !window.CrankApi || typeof window.CrankApi.getProtocolCapabilities !== 'function') {
|
||||
window.wizardProtocolCapabilities = defaultProtocolCapabilities();
|
||||
@@ -93,6 +112,21 @@
|
||||
return window.wizardProtocolCapabilities;
|
||||
}
|
||||
|
||||
async function loadEditionCapabilities() {
|
||||
if (!window.CrankApi || typeof window.CrankApi.getCapabilities !== 'function') {
|
||||
window.wizardEditionCapabilities = defaultEditionCapabilities();
|
||||
return window.wizardEditionCapabilities;
|
||||
}
|
||||
|
||||
try {
|
||||
window.wizardEditionCapabilities = await window.CrankApi.getCapabilities();
|
||||
} catch (_error) {
|
||||
window.wizardEditionCapabilities = defaultEditionCapabilities();
|
||||
}
|
||||
|
||||
return window.wizardEditionCapabilities;
|
||||
}
|
||||
|
||||
function step3Labels() {
|
||||
return {
|
||||
rest: tKey('wizard.step3.rest.label'),
|
||||
@@ -106,8 +140,11 @@
|
||||
window.CrankWizardState = {
|
||||
state: window,
|
||||
defaultProtocolCapabilities: defaultProtocolCapabilities,
|
||||
defaultEditionCapabilities: defaultEditionCapabilities,
|
||||
currentProtocolCapabilities: currentProtocolCapabilities,
|
||||
currentEditionCapabilities: currentEditionCapabilities,
|
||||
loadProtocolCapabilities: loadProtocolCapabilities,
|
||||
loadEditionCapabilities: loadEditionCapabilities,
|
||||
step3Labels: step3Labels,
|
||||
};
|
||||
}());
|
||||
|
||||
@@ -6,12 +6,14 @@ function tfKey(key, vars) {
|
||||
}
|
||||
var TOTAL_STEPS = window.CrankWizardShell.TOTAL_STEPS;
|
||||
var loadProtocolCapabilities = window.CrankWizardState.loadProtocolCapabilities;
|
||||
var loadEditionCapabilities = window.CrankWizardState.loadEditionCapabilities;
|
||||
var currentProtocolCapabilities = window.CrankWizardState.currentProtocolCapabilities;
|
||||
var loadStep = window.CrankWizardShell.loadStep;
|
||||
var goToStep = window.CrankWizardShell.goToStep;
|
||||
var _doGoToStep = window.CrankWizardShell.doGoToStep;
|
||||
var loadWizardPanels = window.CrankWizardShell.loadWizardPanels;
|
||||
var bindProtocolCards = window.CrankWizardShell.bindProtocolCards;
|
||||
var applyEditionProtocolVisibility = window.CrankWizardShell.applyEditionProtocolVisibility;
|
||||
var step3PanelId = window.CrankWizardShell.step3PanelId;
|
||||
var bindStreamingConfigControls = window.CrankStreamingForm.bindStreamingConfigControls;
|
||||
var collectStreamingConfig = window.CrankStreamingForm.collectStreamingConfig;
|
||||
@@ -85,14 +87,17 @@ async function initWizardPage() {
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
wizardWorkspaceId = workspace ? workspace.id : null;
|
||||
window.wizardWorkspaceId = wizardWorkspaceId;
|
||||
var editionCapabilities = await loadEditionCapabilities();
|
||||
await loadProtocolCapabilities();
|
||||
|
||||
await loadWizardPanels([1, 2, 3, 4, 5]);
|
||||
applyEditionProtocolVisibility(editionCapabilities);
|
||||
await loadWizardAuthResources();
|
||||
bindProtocolCards();
|
||||
bindWizardLiveActions();
|
||||
bindStreamingConfigControls();
|
||||
updateUpstreamAuthUi();
|
||||
renderEditionCapabilityHints(editionCapabilities);
|
||||
|
||||
var quickSecretModal = document.getElementById('quick-secret-modal');
|
||||
var quickSecretClose = document.getElementById('quick-secret-close-btn');
|
||||
@@ -141,6 +146,19 @@ async function initWizardPage() {
|
||||
_doGoToStep(1);
|
||||
}
|
||||
|
||||
function renderEditionCapabilityHints(capabilities) {
|
||||
var securityNote = document.getElementById('wizard-security-level-note');
|
||||
var securityText = securityNote ? securityNote.querySelector('.info-callout-text') : null;
|
||||
if (!securityText) return;
|
||||
var levels = capabilities && Array.isArray(capabilities.supported_security_levels)
|
||||
? capabilities.supported_security_levels
|
||||
: ['standard'];
|
||||
securityNote.hidden = !(levels.length === 1 && levels[0] === 'standard');
|
||||
if (!securityNote.hidden) {
|
||||
securityText.textContent = tKey('wizard.step5.community_security_note');
|
||||
}
|
||||
}
|
||||
|
||||
if (window.CrankDiagnostics && typeof window.CrankDiagnostics.bootstrap === 'function') {
|
||||
window.CrankDiagnostics.bootstrap('wizard', initWizardPage);
|
||||
} else {
|
||||
|
||||
@@ -13,8 +13,8 @@ test('shell and wizard expose stable diagnostics hooks', async ({ page }) => {
|
||||
await page.goto('/wizard/');
|
||||
await expect(page.locator('[data-testid="wizard-protocol-grid"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="wizard-protocol-rest"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="wizard-protocol-websocket"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="wizard-protocol-soap"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="wizard-protocol-websocket"]')).toBeHidden();
|
||||
await expect(page.locator('[data-testid="wizard-protocol-soap"]')).toBeHidden();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-crank-bootstrap-state', 'ready');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const {
|
||||
SOAP_TEST_WSDL,
|
||||
buildSoapOperationPayload,
|
||||
buildWebsocketWindowOperationPayload,
|
||||
createOperation,
|
||||
getCurrentWorkspace,
|
||||
login,
|
||||
localized,
|
||||
uniqueName,
|
||||
} = require('./helpers');
|
||||
const { login, localized } = require('./helpers');
|
||||
|
||||
test('wizard loads and protocol selection updates flow', async ({ page }) => {
|
||||
await login(page);
|
||||
@@ -20,201 +11,13 @@ test('wizard loads and protocol selection updates flow', async ({ page }) => {
|
||||
await expect(page.locator('#step-panel-2 .step-panel-title')).toContainText(localized('Select upstream', 'Выберите upstream'));
|
||||
});
|
||||
|
||||
test('soap wizard uploads wsdl, applies discovered binding and runs test', async ({ page }) => {
|
||||
test('community wizard hides premium protocols and explains edition scope', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
const operation = await createOperation(
|
||||
page,
|
||||
workspace.id,
|
||||
buildSoapOperationPayload(uniqueName('playwright_soap_wizard')),
|
||||
);
|
||||
|
||||
await page.goto(`/wizard/?mode=edit&operationId=${encodeURIComponent(operation.operation_id)}`);
|
||||
await page.locator('#btn-continue').click();
|
||||
await page.locator('#btn-continue').click();
|
||||
|
||||
await expect(page.locator('#step-panel-3-soap .step-panel-title')).toContainText(/WSDL/i);
|
||||
await page.evaluate((wsdl) => {
|
||||
const fileName = document.getElementById('wizard-soap-wsdl-name');
|
||||
if (fileName) fileName.textContent = 'lead.wsdl';
|
||||
}, SOAP_TEST_WSDL);
|
||||
await page.evaluate(async ({ workspaceId, operationId, wsdl }) => {
|
||||
const uploaded = await window.CrankApi.uploadWsdlFile(
|
||||
workspaceId,
|
||||
operationId,
|
||||
new TextEncoder().encode(wsdl),
|
||||
'lead.wsdl',
|
||||
);
|
||||
const services = await window.CrankApi.listSoapServices(
|
||||
workspaceId,
|
||||
operationId,
|
||||
null,
|
||||
);
|
||||
window.renderSoapServiceCatalog(services.services || []);
|
||||
return uploaded;
|
||||
}, {
|
||||
workspaceId: workspace.id,
|
||||
operationId: operation.operation_id,
|
||||
wsdl: SOAP_TEST_WSDL,
|
||||
});
|
||||
|
||||
await expect(page.locator('#wizard-soap-services-list button')).toContainText('CreateLead');
|
||||
await page.locator('#wizard-soap-services-list button').getByText('CreateLead').click();
|
||||
await expect(page.locator('#soap-service-name')).toHaveValue('LeadService');
|
||||
await expect(page.locator('#soap-port-name')).toHaveValue('LeadPort');
|
||||
await expect(page.locator('#soap-operation-name')).toHaveValue('CreateLead');
|
||||
|
||||
await page.locator('#btn-continue').click();
|
||||
await expect(page.locator('#step-panel-4 .step-panel-title')).toBeVisible();
|
||||
await page.locator('#tool-input-schema').fill(JSON.stringify({
|
||||
type: 'object',
|
||||
required: ['email'],
|
||||
properties: {
|
||||
email: { type: 'string' },
|
||||
},
|
||||
}, null, 2));
|
||||
await page.locator('#tool-output-schema').fill(JSON.stringify({
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
},
|
||||
}, null, 2));
|
||||
await page.locator('#btn-continue').click();
|
||||
await expect(page.locator('[data-i18n="wizard.step5.test_title"]')).toContainText(localized('Test run', 'Тестовый запуск'));
|
||||
await page.locator('#tool-input-mapping').fill(JSON.stringify({
|
||||
email: '$.input.email',
|
||||
}, null, 2));
|
||||
await page.locator('#tool-output-mapping').fill(JSON.stringify({
|
||||
id: '$.response.body.id',
|
||||
}, null, 2));
|
||||
await page.locator('#tool-exec-config').fill('timeout_ms: 1000');
|
||||
|
||||
await page.locator('#wizard-test-input').fill(JSON.stringify({ email: 'user@example.com' }, null, 2));
|
||||
await page.locator('#wizard-run-test').click();
|
||||
|
||||
await expect(page.locator('#wizard-test-request-preview')).toHaveValue(/user@example.com/);
|
||||
await expect(page.locator('#wizard-test-response-preview')).toHaveValue(/lead_soap_123/);
|
||||
await expect(page.locator('#wizard-test-errors')).toHaveValue('[]');
|
||||
});
|
||||
|
||||
test('websocket wizard reports bounded window success and reconnect exhaustion clearly', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
const successOperation = await createOperation(
|
||||
page,
|
||||
workspace.id,
|
||||
buildWebsocketWindowOperationPayload(uniqueName('playwright_websocket_success')),
|
||||
);
|
||||
const failureOperation = await createOperation(
|
||||
page,
|
||||
workspace.id,
|
||||
buildWebsocketWindowOperationPayload(uniqueName('playwright_websocket_failure'), {
|
||||
streaming: {
|
||||
max_items: 5,
|
||||
},
|
||||
websocketOptions: {
|
||||
reconnect_max_attempts: 0,
|
||||
reconnect_backoff_ms: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto(`/wizard/?mode=edit&operationId=${encodeURIComponent(successOperation.operation_id)}`);
|
||||
await page.locator('#btn-continue').click();
|
||||
await page.locator('#upstream-new-trigger').click();
|
||||
await page.locator('#new-upstream-name').fill('fixture-websocket');
|
||||
await page.locator('#new-upstream-url').fill('ws://127.0.0.1:3310');
|
||||
await page.locator('#btn-continue').click();
|
||||
await expect(page.locator('#step-panel-3-websocket .step-panel-title')).toContainText(/WebSocket/i);
|
||||
await page.locator('#websocket-path').fill('/events');
|
||||
await page.locator('#websocket-subprotocols').fill('');
|
||||
await page.locator('#websocket-subscribe-template').fill('');
|
||||
await page.locator('#websocket-unsubscribe-template').fill('');
|
||||
await page.locator('#btn-continue').click();
|
||||
await page.locator('#btn-continue').click();
|
||||
await page.locator('#wizard-test-input').fill('{}');
|
||||
await page.evaluate(() => {
|
||||
const original = window.CrankApi.runOperationTest;
|
||||
window.__wizardRunTestOriginal = original;
|
||||
window.CrankApi.runOperationTest = async () => ({
|
||||
ok: true,
|
||||
mode: 'window',
|
||||
request_preview: {
|
||||
body: { topic: 'telemetry' },
|
||||
},
|
||||
response_preview: {
|
||||
items: [
|
||||
{ seq: 1, value: 101 },
|
||||
{ seq: 2, value: 102 },
|
||||
{ seq: 3, value: 103 },
|
||||
],
|
||||
window_complete: true,
|
||||
truncated: false,
|
||||
has_more: false,
|
||||
},
|
||||
errors: [],
|
||||
window: {
|
||||
window_complete: true,
|
||||
truncated: false,
|
||||
has_more: false,
|
||||
cursor: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.locator('#wizard-run-test').click();
|
||||
await expect(page.locator('#wizard-live-status-title')).toContainText(localized('WebSocket window test completed', 'WebSocket оконный тест завершен'));
|
||||
await expect(page.locator('#wizard-live-status-text')).toContainText(localized('bounded test window', 'тестового окна'));
|
||||
await expect(page.locator('#wizard-test-response-preview')).toHaveValue(/"seq": 1/);
|
||||
await expect(page.locator('#wizard-test-request-preview')).toHaveValue(/"topic": "telemetry"/);
|
||||
await expect(page.locator('#wizard-test-errors')).toHaveValue('[]');
|
||||
await page.evaluate(() => {
|
||||
if (window.__wizardRunTestOriginal) {
|
||||
window.CrankApi.runOperationTest = window.__wizardRunTestOriginal;
|
||||
delete window.__wizardRunTestOriginal;
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(`/wizard/?mode=edit&operationId=${encodeURIComponent(failureOperation.operation_id)}`);
|
||||
await page.locator('#btn-continue').click();
|
||||
await page.locator('#upstream-new-trigger').click();
|
||||
await page.locator('#new-upstream-name').fill('fixture-websocket');
|
||||
await page.locator('#new-upstream-url').fill('ws://127.0.0.1:3310');
|
||||
await page.locator('#btn-continue').click();
|
||||
await page.locator('#websocket-path').fill('/events');
|
||||
await page.locator('#websocket-subprotocols').fill('');
|
||||
await page.locator('#websocket-subscribe-template').fill('');
|
||||
await page.locator('#websocket-unsubscribe-template').fill('');
|
||||
await page.locator('#btn-continue').click();
|
||||
await page.locator('#btn-continue').click();
|
||||
await page.locator('#wizard-test-input').fill('{}');
|
||||
await page.evaluate(() => {
|
||||
const original = window.CrankApi.runOperationTest;
|
||||
window.__wizardRunTestOriginal = original;
|
||||
window.CrankApi.runOperationTest = async () => ({
|
||||
ok: false,
|
||||
mode: 'window',
|
||||
request_preview: {
|
||||
body: { topic: 'telemetry' },
|
||||
},
|
||||
response_preview: null,
|
||||
errors: [
|
||||
{
|
||||
code: 'runtime_websocket_error',
|
||||
message: 'websocket reconnect policy exhausted',
|
||||
},
|
||||
],
|
||||
window: null,
|
||||
});
|
||||
});
|
||||
await page.locator('#wizard-run-test').click();
|
||||
await expect(page.locator('#wizard-live-status-title')).toContainText(localized('WebSocket test returned errors', 'WebSocket тест завершился с ошибками'));
|
||||
await expect(page.locator('#wizard-live-status-text')).toContainText(localized('reconnect policy was exhausted', 'reconnect policy была исчерпана'));
|
||||
await expect(page.locator('#wizard-test-errors')).toHaveValue(/runtime_websocket_error/);
|
||||
await page.evaluate(() => {
|
||||
if (window.__wizardRunTestOriginal) {
|
||||
window.CrankApi.runOperationTest = window.__wizardRunTestOriginal;
|
||||
delete window.__wizardRunTestOriginal;
|
||||
}
|
||||
});
|
||||
await page.goto('/wizard/');
|
||||
await expect(page.locator('[data-testid="wizard-protocol-rest"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="wizard-protocol-graphql"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="wizard-protocol-grpc"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="wizard-protocol-websocket"]')).toBeHidden();
|
||||
await expect(page.locator('[data-testid="wizard-protocol-soap"]')).toBeHidden();
|
||||
await expect(page.locator('#wizard-edition-protocol-note')).toContainText(localized('Commercial editions unlock', 'коммерческих редакциях'));
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
### 5.0. Build capabilities
|
||||
|
||||
- `GET /api/admin/capabilities`
|
||||
- `GET /api/admin/workspaces/{workspace_id}/protocol-capabilities`
|
||||
|
||||
Контракт:
|
||||
|
||||
@@ -64,6 +65,11 @@
|
||||
- `machine_access_modes`
|
||||
- `limits`
|
||||
- UI использует этот ответ для edition-aware gating и честного product copy.
|
||||
- `GET /protocol-capabilities` возвращает только те протоколы и execution capabilities, которые реально доступны в текущей редакции;
|
||||
- для `Community` это означает только:
|
||||
- `REST`
|
||||
- `GraphQL`
|
||||
- `gRPC`
|
||||
|
||||
### 5.1. Workspaces and members
|
||||
|
||||
|
||||
Reference in New Issue
Block a user