ui: gate community wizard capabilities

This commit is contained in:
a.tolmachev
2026-05-03 17:11:30 +00:00
parent 10433d5193
commit 49c46c0d0b
14 changed files with 221 additions and 225 deletions
+4 -2
View File
@@ -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 знает, какие функции входят в текущую редакцию;
+29
View File
@@ -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() {
+6 -10
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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">
+11
View File
@@ -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 -->
+11
View File
@@ -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">
+12
View File
@@ -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': 'Опубликованы',
+54
View File
@@ -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,
};
}());
+37
View File
@@ -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,
};
}());
+18
View File
@@ -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 {
+2 -2
View File
@@ -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');
});
+9 -206
View File
@@ -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', 'коммерческих редакциях'));
});
+6
View File
@@ -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