feat: complete Epic 1 production foundation

This commit is contained in:
2026-08-25 01:24:11 +03:00
parent 767428436d
commit 182bde8ac0
298 changed files with 35719 additions and 5299 deletions
+42 -1
View File
@@ -11,6 +11,9 @@ test('agents page shows demo cards and edit drawer opens', async ({ page }) => {
await expect(page.locator('.agent-card-date').first()).toContainText(
localized('Set this endpoint', 'Данный эндпоинт')
);
await expect(page.locator('.agent-card-date').filter({
hasText: localized('Catalog rev', 'Ревизия каталога'),
}).first()).toBeVisible();
await page.getByRole('button', { name: localized('New agent', 'Новый агент') }).click();
await expect(page.locator('.drawer-title')).toHaveText(localized('New agent', 'Новый агент'));
await expect(page.locator('.drawer-subtitle')).toContainText(
@@ -40,7 +43,45 @@ test('agent drawer configures on-demand tool discovery and catalog sections', as
await expect(group.locator('input').nth(1)).toHaveValue('finance');
await expect(page.locator('.tool-search-preview')).toBeVisible();
await page.locator('.tool-group-chip').first().click();
await page.locator('.tool-search-preview input').fill('currency rate');
await page.locator('.tool-search-preview input').fill('frankfurter_latest_rate');
await page.locator('.tool-search-preview .btn-primary-sm').click();
await expect(page.locator('.tool-search-result').first()).toBeVisible();
});
test('agent lifecycle stale conflict shows localized recovery message', async ({ page }) => {
await login(page);
await page.goto('/agents');
await expect(page.locator('.agent-card').first()).toBeVisible();
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/unpublish$/, async (route) => {
await route.fulfill({
status: 409,
contentType: 'application/json',
headers: {
'x-request-id': '01a00000-0000-7000-8000-000000000001',
'x-trace-id': '01a00000000070008000000000000001',
},
body: JSON.stringify({
error: {
code: 'conflict',
message: 'agent changed',
context: {
error_code: 'agent_stale_revision',
},
},
}),
});
});
page.once('dialog', async (dialog) => {
expect(dialog.message()).toMatch(localized('Unpublish', 'Снять'));
await dialog.accept();
});
await page.getByRole('button', { name: localized('Unpublish', 'Снять с публикации') }).first().click();
await expect(page.locator('.toast-error')).toContainText(
localized(
'Agent changed in another request. Reload the drawer and retry.',
'Агент изменился в другом запросе. Перезагрузите форму и повторите действие.',
),
);
});
+262 -1
View File
@@ -1,7 +1,8 @@
const { test, expect } = require('@playwright/test');
const { createAgent, getCurrentWorkspace, login, localized, uniqueName } = require('./helpers');
const { browserJson, createAgent, getCurrentWorkspace, login, localized, uniqueName } = require('./helpers');
test('api keys page opens create key flow', async ({ page }) => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await login(page);
const workspace = await getCurrentWorkspace(page);
await createAgent(page, workspace.id, {
@@ -23,10 +24,21 @@ test('api keys page opens create key flow', async ({ page }) => {
await page.locator('#btn-create-key').click();
await expect(page.locator('#modal-create')).toHaveClass(/open/);
await expect(page.locator('.modal-title')).toHaveText(localized('Create MCP client key', 'Создать ключ MCP-клиента'));
await expect(page.locator('[data-scope="read"]')).toBeChecked();
await expect(page.locator('[data-scope="write"]')).toBeChecked();
await page.locator('#new-key-name').fill(`playwright-${Date.now()}`);
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#modal-reveal-body')).toContainText(localized('Copy this key now', 'Скопируйте этот ключ сейчас'));
await expect(page.locator('#reveal-key-value')).toContainText('crk_');
const config = page.getByTestId('onboarding-connection-config');
await expect(config).toBeVisible();
await config.getByRole('button', { name: localized('Copy configuration', 'Скопировать конфигурацию') }).first().click();
await expect(page.locator('#reveal-key-value')).toHaveText('');
await expect(config).toBeHidden();
await expect(page.locator('#onboarding-connection-clients')).toBeEmpty();
await page.locator('#modal-done-btn').click();
await expect(page.locator('#modal-create')).not.toHaveClass(/open/);
await expect(page.locator('#reveal-key-value')).toHaveText('');
await page.locator('#key-kind-approval').click();
await expect(page.locator('#key-kind-hint')).toContainText(
@@ -43,4 +55,253 @@ test('api keys page opens create key flow', async ({ page }) => {
await page.locator('#new-key-name').fill(`playwright-approval-${Date.now()}`);
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#reveal-key-value')).toContainText('crk_appr_');
await expect(page.locator('#onboarding-connection-config')).toBeHidden();
const approvalRawKey = await page.locator('#reveal-key-value').textContent();
await page.goto('/agents');
await page.goBack();
await expect(page.locator('#reveal-key-value')).not.toContainText(approvalRawKey || 'crk_appr_');
});
test('failed clipboard write preserves one-time key material', async ({ page }) => { // community-scope: allow=one-time-token
await login(page);
const workspace = await getCurrentWorkspace(page);
await createAgent(page, workspace.id, {
slug: uniqueName('playwright_clipboard_agent'),
display_name: 'Playwright Clipboard Agent',
description: 'Agent for clipboard failure recovery.',
instructions: {},
tool_selection_policy: {},
});
await page.addInitScript(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: () => Promise.reject(new Error('clipboard denied')) },
});
});
await page.goto('/api-keys');
await page.locator('#btn-create-key').click();
await page.locator('#new-key-name').fill(uniqueName('clipboard_failure'));
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#reveal-key-value')).toHaveText(/^crk_/);
const rawKey = await page.locator('#reveal-key-value').textContent();
expect(rawKey).toMatch(/^crk_/);
await page.locator('#copy-key-btn').click();
await expect(page.locator('#reveal-key-value')).toHaveText(rawKey);
await expect(page.locator('#modal-reveal-body')).toContainText(
localized('Copy this key now', 'Скопируйте ключ сейчас')
);
const config = page.getByTestId('onboarding-connection-config');
await expect(config).toBeVisible();
await config.getByRole('button', { name: localized('Copy configuration', 'Скопировать конфигурацию') }).first().click();
await expect(page.locator('#reveal-key-value')).toHaveText(rawKey);
await expect(config).toBeVisible();
await expect(page.locator('#onboarding-connection-clients')).not.toBeEmpty();
});
test('ambiguous create failure reconciles metadata before deliberate retry', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
await createAgent(page, workspace.id, {
slug: uniqueName('playwright_ambiguous_agent'),
display_name: 'Playwright Ambiguous Agent',
description: 'Agent for ambiguous key creation recovery.',
instructions: {},
tool_selection_policy: {},
});
let failCreate = true;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/platform-api-keys$/, async (route) => {
if (route.request().method() === 'POST' && failCreate) {
failCreate = false;
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'key_create_outcome_unknown', message: 'unknown' } }),
});
return;
}
await route.continue();
});
await page.goto('/api-keys');
await page.locator('#btn-create-key').click();
await page.locator('#new-key-name').fill(uniqueName('ambiguous_key'));
await page.locator('#modal-confirm-btn').click();
await expect(page.getByTestId('ambiguous-create-warning')).toBeVisible();
await expect(page.locator('#modal-confirm-btn')).toBeDisabled();
await page.locator('#ambiguous-create-retry-btn').click();
await expect(page.getByTestId('ambiguous-create-warning')).toBeHidden();
await expect(page.locator('#modal-confirm-btn')).toBeEnabled();
});
test('stale create response reconciles metadata without changing a newer modal generation', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
const agent = await createAgent(page, workspace.id, {
slug: uniqueName('playwright_keys_stale_agent'),
display_name: 'Playwright Keys Stale Agent',
description: 'Agent for API key stale response test.',
instructions: {},
tool_selection_policy: {},
});
let releaseFirstCreate;
let releaseSecondCreate;
const firstCreateReleased = new Promise((resolve) => {
releaseFirstCreate = resolve;
});
const secondCreateReleased = new Promise((resolve) => {
releaseSecondCreate = resolve;
});
let createCount = 0;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/platform-api-keys$/, async (route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
const requestNumber = ++createCount;
await (requestNumber === 1 ? firstCreateReleased : secondCreateReleased);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
secret: requestNumber === 1
? 'crk_ui_stale_canary_should_not_render'
: 'crk_ui_current_canary_may_render',
api_key: {
api_key: {
id: requestNumber === 1 ? 'pk_ui_stale' : 'pk_ui_current',
workspace_id: workspace.id,
agent_id: agent.agent_id,
key_kind: 'mcp_client',
name: requestNumber === 1 ? 'ui-stale-key' : 'ui-current-key',
prefix: requestNumber === 1 ? 'crk_ui_stale' : 'crk_ui_current',
scopes: ['read'],
status: 'active',
created_at: '2026-08-15T00:00:00Z',
last_used_at: null,
expires_at: null,
allowed_origins: [],
},
},
}),
});
});
await page.goto('/api-keys');
await page.locator('#btn-create-key').click();
await page.locator('#new-key-name').fill(uniqueName('ui_stale_key'));
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#modal-confirm-btn')).toBeDisabled();
await page.locator('#modal-cancel-btn').click();
await expect(page.locator('#modal-create')).not.toHaveClass(/open/);
await page.locator('#btn-create-key').click();
await page.locator('#new-key-name').fill(uniqueName('ui_current_key'));
await page.locator('#modal-confirm-btn').click();
const confirm = page.locator('#modal-confirm-btn');
await expect(confirm).toBeDisabled();
await expect(confirm).toContainText(localized('Creating', 'Создание'));
releaseFirstCreate();
await expect(page.getByTestId('onboarding-key-lost')).toBeVisible();
await expect(confirm).toBeDisabled();
await expect(confirm).toContainText(localized('Creating', 'Создание'));
releaseSecondCreate();
await expect(page.locator('#modal-reveal-body')).toBeVisible();
await expect(page.locator('#reveal-key-value')).not.toContainText('crk_ui_stale_canary_should_not_render');
await expect(page.locator('#reveal-key-value')).toContainText('crk_ui_current_canary_may_render');
});
test('duplicate key revoke and delete clicks send one request while the first request is pending', async ({ page }) => {
await page.addInitScript(() => {
window.confirm = () => true;
});
await login(page);
const workspace = await getCurrentWorkspace(page);
const agent = await createAgent(page, workspace.id, {
slug: uniqueName('playwright_key_mutation_agent'),
display_name: 'Playwright Key Mutation Agent',
description: 'Agent for duplicate key mutation guards.',
instructions: {},
tool_selection_policy: {},
});
const revokeName = uniqueName('revoke_once');
const deleteName = uniqueName('delete_once');
await browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/agents/${encodeURIComponent(agent.agent_id)}/platform-api-keys`,
{ name: revokeName, key_kind: 'mcp_client', scopes: ['read'] },
);
const revokedKey = await browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/agents/${encodeURIComponent(agent.agent_id)}/platform-api-keys`,
{ name: deleteName, key_kind: 'mcp_client', scopes: ['read'] },
);
const revokedKeyId = revokedKey.api_key.api_key.id;
await browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/agents/${encodeURIComponent(agent.agent_id)}/platform-api-keys/${encodeURIComponent(revokedKeyId)}/revoke`,
{},
[204],
);
let releaseRevoke;
const revokeReleased = new Promise((resolve) => {
releaseRevoke = resolve;
});
let revokeRequests = 0;
let releaseDelete;
const deleteReleased = new Promise((resolve) => {
releaseDelete = resolve;
});
let deleteRequests = 0;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/platform-api-keys\/[^/]+(?:\/revoke)?$/, async (route) => {
const method = route.request().method();
if (method === 'POST' && /\/revoke$/.test(route.request().url())) {
revokeRequests += 1;
await revokeReleased;
await route.fulfill({ status: 204 });
return;
}
if (method === 'DELETE') {
deleteRequests += 1;
await deleteReleased;
await route.fulfill({ status: 204 });
return;
}
await route.continue();
});
await page.goto('/api-keys');
await page.locator('#agent-select').selectOption(agent.agent_id);
await expect(page.locator('#keys-tbody')).toContainText(revokeName);
await expect(page.locator('#keys-tbody')).toContainText(deleteName);
await page.evaluate((name) => {
const row = Array.from(document.querySelectorAll('#keys-tbody tr'))
.find((candidate) => candidate.textContent.includes(name));
const button = row.querySelector('[title="Revoke key"]');
button.click();
button.click();
}, revokeName);
await expect.poll(() => revokeRequests).toBe(1);
releaseRevoke();
await expect(page.locator('#keys-tbody')).toContainText(revokeName);
const deleteRow = page.locator('#keys-tbody tr').filter({ hasText: deleteName }).first();
const deleteButton = deleteRow.locator('[title="Delete"]');
await expect(deleteRow).toBeVisible();
await expect(deleteButton).toBeEnabled();
await deleteButton.evaluate((button) => {
button.click();
button.click();
});
await expect.poll(() => deleteRequests).toBe(1);
releaseDelete();
await expect(page.locator('#keys-tbody')).toContainText(deleteName);
});
+90
View File
@@ -0,0 +1,90 @@
const { test, expect } = require('@playwright/test');
const { login, localized } = require('./helpers');
test('logs approval panel renders safe pending approval metadata', async ({ page }) => {
await login(page);
let approveCalled = false;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/approvals(?:\?.*)?$/, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{
approval: {
id: 'approval_ui_safe_summary',
agent_id: 'agent_sales',
operation_id: 'op_charge_customer',
operation_version: 3,
status: 'pending',
risk_level: 'dangerous',
request_id: 'req_approval_ui',
trace_id: '0af7651916cd43dd8448eb211c80319c',
request_payload: {
email: 'customer@example.com',
api_key: '[REDACTED]',
},
response_payload: null,
created_at: '2026-08-22T10:00:00Z',
expires_at: '2026-08-22T10:05:00Z',
decided_at: null,
decision_note: null,
},
},
],
}),
});
});
await page.route(/\/api\/admin\/workspaces\/[^/]+\/approvals\/approval_ui_safe_summary\/approve$/, async (route) => {
approveCalled = true;
const body = JSON.parse(route.request().postData() || '{}');
expect(body).toEqual({ approve: 'yes' });
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
approval: {
id: 'approval_ui_safe_summary',
agent_id: 'agent_sales',
operation_id: 'op_charge_customer',
operation_version: 3,
status: 'approved',
risk_level: 'dangerous',
request_id: 'req_approval_ui',
trace_id: '0af7651916cd43dd8448eb211c80319c',
request_payload: { email: 'customer@example.com', api_key: '[REDACTED]' },
response_payload: { approve: 'yes' },
created_at: '2026-08-22T10:00:00Z',
expires_at: '2026-08-22T10:05:00Z',
decided_at: '2026-08-22T10:01:00Z',
decision_note: null,
},
}),
});
});
page.on('dialog', async (dialog) => {
expect(dialog.message()).toMatch(localized(
'Approve this pending confirmation request?',
'Подтвердить эту заявку?',
));
await dialog.accept();
});
await page.goto('/logs');
await expect(page.locator('.approval-panel-title')).toHaveText(
localized('Human confirmations', 'Подтверждения человеком'),
);
await expect(page.locator('#approval-list')).toContainText('approval_ui_safe_summary');
await expect(page.locator('#approval-list')).toContainText('op_charge_customer v3');
await expect(page.locator('#approval-list')).toContainText('agent_sales');
await expect(page.locator('#approval-list')).toContainText('customer@example.com');
await expect(page.locator('#approval-list')).toContainText('[REDACTED]');
await expect(page.locator('#approval-list')).toContainText('req_approval_ui');
await expect(page.locator('#approval-list')).toContainText('0af7651916cd43dd8448eb211c80319c');
await expect(page.locator('.approval-approve')).toHaveText(localized('Approve', 'Подтвердить'));
await expect(page.locator('.approval-deny')).toHaveText(localized('Deny', 'Отклонить'));
await expect(page.locator('#approval-list')).not.toContainText('SECRET_APPROVAL_CANARY');
await page.locator('.approval-approve').click();
await expect.poll(() => approveCalled).toBe(true);
});
+19 -5
View File
@@ -28,6 +28,14 @@ async function browserJson(page, method, urlPath, body, okStatuses = [200]) {
request.headers['Content-Type'] = 'application/json';
request.body = JSON.stringify(body);
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase())) {
let csrfToken = window.CrankAuth && window.CrankAuth.getCsrfToken();
if (!csrfToken && window.CrankApi && window.CrankApi.refreshSessionCsrf) {
const csrf = await window.CrankApi.refreshSessionCsrf();
csrfToken = csrf && csrf.csrf_token;
}
if (csrfToken) request.headers['X-CSRF-Token'] = csrfToken;
}
const result = await fetch(urlPath, request);
const text = await result.text();
@@ -66,11 +74,17 @@ async function getCurrentWorkspace(page) {
}
async function createAgent(page, workspaceId, payload) {
return browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspaceId)}/agents`,
payload,
return page.evaluate(
async ({ workspaceId, payload }) => {
var session = await window.CrankApi.getSession();
if (!session.csrf_token) {
var csrf = await window.CrankApi.refreshSessionCsrf();
session.csrf_token = csrf.csrf_token;
}
window.CrankAuth.replaceSession(session);
return window.CrankApi.createAgent(workspaceId, payload);
},
{ workspaceId, payload },
);
}
+110
View File
@@ -1,6 +1,43 @@
const { test, expect } = require('@playwright/test');
const { ADMIN_EMAIL, ADMIN_PASSWORD, localized } = require('./helpers');
const SESSION_FIXTURE = {
user: {
id: 'user-test',
email: ADMIN_EMAIL,
display_name: 'Crank Owner',
},
memberships: [
{
role: 'owner',
workspace: {
id: 'workspace-test',
slug: 'default',
name: 'Default Workspace',
},
},
],
current_workspace_id: 'workspace-test',
csrf_token: 'csrf_test_token',
};
async function stubLoginPageSession(page, bootstrapRequired) {
await page.route('**/api/auth/session', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { message: 'unauthorized' } }),
});
});
await page.route('**/api/auth/bootstrap/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ bootstrap_required: bootstrapRequired }),
});
});
}
test('login page rejects invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.locator('#email').fill(ADMIN_EMAIL);
@@ -10,6 +47,29 @@ test('login page rejects invalid credentials', async ({ page }) => {
});
test('login page signs in and redirects to operations', async ({ page }) => {
let loggedIn = false;
await page.route('**/api/auth/session', async (route) => {
await route.fulfill({
status: loggedIn ? 200 : 401,
contentType: 'application/json',
body: JSON.stringify(loggedIn ? SESSION_FIXTURE : { error: { message: 'unauthorized' } }),
});
});
await page.route('**/api/auth/bootstrap/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ bootstrap_required: false }),
});
});
await page.route('**/api/auth/login', async (route) => {
loggedIn = true;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(SESSION_FIXTURE),
});
});
await page.goto('/login');
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
@@ -17,3 +77,53 @@ test('login page signs in and redirects to operations', async ({ page }) => {
await expect(page).toHaveURL(/\/$/);
await expect(page.locator('.page-title, .page-heading').first()).toHaveText(localized('Operations', 'Операции'));
});
test('login page switches to bootstrap mode and submits one-time token', async ({ page }) => { // community-scope: allow=one-time-token
await stubLoginPageSession(page, true);
let completePayload = null;
await page.route('**/api/auth/bootstrap/complete', async (route) => {
completePayload = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(SESSION_FIXTURE),
});
});
await page.goto('/login');
await expect(page.locator('#login-form')).toHaveAttribute('data-bootstrap', 'true');
await expect(page.locator('#login-email-field')).toBeHidden();
await expect(page.locator('#login-bootstrap-token-field')).toBeVisible();
await page.locator('#bootstrap-token').fill('boot_test_one_time_token'); // community-scope: allow=one-time-token
await page.locator('#password').fill('new-admin-password');
await page.locator('.btn-signin').click();
await expect.poll(() => completePayload).toMatchObject({
token: 'boot_test_one_time_token', // community-scope: allow=one-time-token
password: 'new-admin-password',
});
});
test('CrankApi attaches csrf token to unsafe auth requests', async ({ page }) => {
await stubLoginPageSession(page, false);
let csrfHeader = null;
await page.route('**/api/auth/password', async (route) => {
csrfHeader = route.request().headers()['x-csrf-token'] || null;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
});
await page.goto('/login');
await page.evaluate((session) => {
window.CrankAuth.replaceSession(session);
}, SESSION_FIXTURE);
await page.evaluate(() => window.CrankApi.changePassword({
current_password: 'old-password',
new_password: 'new-password',
}));
await expect.poll(() => csrfHeader).toBe('csrf_test_token');
});
+6
View File
@@ -6,11 +6,17 @@ test('logs and usage pages show seeded data', async ({ page }) => {
await page.goto('/logs');
await expect(page.locator('.page-title')).toHaveText(localized('Logs', 'Логи'));
await expect(page.locator('#status-filter')).toBeVisible();
await expect(page.locator('#export-logs-btn')).toBeVisible();
await expect(page.locator('#log-list')).toBeVisible();
await expect(page.locator('#log-list').locator('.empty-state, .log-entry, .log-row, .log-item').first()).toBeVisible();
await page.locator('#status-filter').selectOption('ok');
await expect(page.locator('#log-list').locator('.empty-state, .log-entry, .log-row, .log-item').first()).toBeVisible();
await page.goto('/usage');
await expect(page.locator('.page-title')).toHaveText(localized('Usage', 'Использование'));
await expect(page.locator('#chart-bars .chart-col')).toHaveCount(7);
await expect(page.locator('#usage-outcome-list')).toBeVisible();
await expect(page.locator('#usage-outcome-list').locator('.empty-state, .resource-card').first()).toBeVisible();
await expect(page.locator('#usage-tbody tr')).toHaveCount(1);
});
+637
View File
@@ -0,0 +1,637 @@
const { test, expect } = require('@playwright/test');
const {
browserJson,
createAgent,
getCurrentWorkspace,
login,
uniqueName,
} = require('./helpers');
function onboardingSnapshot(workspaceId, overrides = {}) {
return {
schema_version: 1,
workspace_id: workspaceId,
revision: 1,
status: 'in_progress',
dismissed: false,
eligible_since: '2026-08-23T08:00:00Z',
started_at: '2026-08-23T08:01:00Z',
completed_at: null,
steps: [
{ kind: 'operation', status: 'complete', action: 'open_operation' },
{ kind: 'test', status: 'complete', action: 'open_operation' },
{ kind: 'publish', status: 'complete', action: 'open_operation' },
{ kind: 'agent', status: 'current', action: 'create_agent' },
{ kind: 'key', status: 'pending', action: 'create_key' },
{ kind: 'connection', status: 'pending', action: 'show_connection' },
{ kind: 'first_call', status: 'pending', action: 'open_logs' },
],
operation: {
id: 'op_onboarding_contract',
published_version: 1,
last_test: {
status: 'ok',
request_id: 'req_onboarding_test',
trace_id: '0123456789abcdef0123456789abcdef',
occurred_at: '2026-08-23T08:02:00Z',
},
},
agent: null,
key: null,
connection: { status: 'pending', reason_code: 'key_required' },
first_call: null,
...overrides,
};
}
async function routeOnboarding(page, responder) {
await page.route(/\/api\/admin\/workspaces\/[^/]+\/onboarding(?:\/events)?(?:\?.*)?$/, responder);
}
async function parseMcpResponse(response) {
const text = await response.text();
const dataLine = text.split(/\r?\n/).find((line) => line.startsWith('data:'));
return JSON.parse(dataLine ? dataLine.slice(5).trim() : text);
}
async function mcpPost(request, endpoint, key, payload, sessionId) {
const headers = {
Authorization: `Bearer ${key}`,
Accept: 'application/json, text/event-stream',
'Content-Type': 'application/json',
};
if (sessionId) {
headers['MCP-Session-Id'] = sessionId;
headers['MCP-Protocol-Version'] = '2025-06-18';
}
return request.post(endpoint, { headers, data: payload });
}
test('optional Getting Started checklist is server-derived, resumable, bilingual and accessible', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
let snapshotRequests = 0;
const presentationEvents = [];
await routeOnboarding(page, async (route) => {
snapshotRequests += 1;
if (route.request().method() === 'POST') {
presentationEvents.push((await route.request().postDataJSON()).event);
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id)),
});
});
await page.goto('/');
const trigger = page.getByTestId('onboarding-trigger');
await expect(trigger).toContainText(/Getting Started|Начало работы/i);
const checklist = page.getByTestId('onboarding-checklist');
if (!await checklist.isVisible()) await trigger.click();
await expect(checklist).toBeVisible();
await expect.poll(() => presentationEvents).toContain('started');
await expect(checklist.locator('ol > li')).toHaveCount(7);
await expect(checklist.locator('[aria-current="step"]')).toContainText(/Agent|Агент/i);
await expect(checklist.getByRole('status')).toContainText(/3\s*\/\s*7/);
await expect(checklist.getByRole('button', { name: /Create agent|Создать агента/i })).toBeFocused();
await page.getByTestId('onboarding-collapse').click();
await expect(checklist).toBeHidden();
await page.reload();
await expect(page.getByTestId('onboarding-trigger')).toBeVisible();
expect(snapshotRequests).toBeGreaterThanOrEqual(2);
await page.getByTestId('onboarding-trigger').click();
await page.getByTestId('onboarding-dismiss').click();
await expect(checklist).toBeHidden();
await expect(page.getByTestId('onboarding-trigger')).toContainText(/Resume Getting Started|Продолжить начало работы/i);
await expect.poll(() => presentationEvents).toEqual(expect.arrayContaining(['dismissed', 'abandoned']));
await page.reload();
await expect(page.getByTestId('onboarding-trigger')).toContainText(/Resume Getting Started|Продолжить начало работы/i);
await page.getByTestId('onboarding-trigger').click();
await expect(checklist).toBeVisible();
await expect(page.getByTestId('onboarding-trigger')).toContainText(/Getting Started|Начало работы/i);
await page.evaluate(() => localStorage.setItem('crank_lang', 'ru'));
await page.reload();
await expect(page.getByTestId('onboarding-trigger')).toContainText('Начало работы');
await page.getByTestId('onboarding-trigger').click();
await expect(page.getByTestId('onboarding-checklist')).toContainText(/агент/i);
await page.evaluate(() => window.setLang('en'));
await expect(page.getByTestId('onboarding-checklist').locator('.onboarding-title')).toHaveText('Getting Started');
await expect(page.getByTestId('onboarding-collapse')).toHaveAttribute('aria-label', /Collapse Getting Started/i);
await page.keyboard.press('Escape');
await expect(page.getByTestId('onboarding-checklist')).toBeHidden();
await expect(page.getByTestId('onboarding-trigger')).toBeFocused();
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.getByTestId('onboarding-trigger').click();
await expect(page.getByTestId('onboarding-checklist')).toHaveCSS('scroll-behavior', 'auto');
});
test('onboarding error exposes safe support correlation and retries without a client completion bypass', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
let fail = false;
let calls = 0;
await routeOnboarding(page, async (route) => {
calls += 1;
if (fail) {
await route.fulfill({
status: 503,
contentType: 'application/json',
headers: {
'x-request-id': 'req_onboarding_retry',
'x-trace-id': '0123456789abcdef0123456789abcdef',
},
body: JSON.stringify({ error: { code: 'onboarding_unavailable', message: 'unavailable' } }),
});
return;
}
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 2 })) });
});
await page.goto('/');
await page.getByTestId('onboarding-trigger').click();
fail = true;
await page.getByTestId('onboarding-refresh').click();
const alert = page.getByRole('alert');
await expect(alert).toContainText('onboarding_unavailable');
await expect(alert).toContainText('req_onboarding_retry');
await expect(alert).toContainText('0123456789abcdef0123456789abcdef');
await expect(page.getByTestId('onboarding-retry')).toBeFocused();
fail = false;
await page.getByTestId('onboarding-retry').click();
await expect(page.getByTestId('onboarding-checklist').locator('ol > li')).toHaveCount(7);
expect(calls).toBeGreaterThanOrEqual(2);
});
test('validation, upstream and compatibility errors recover from the last authoritative onboarding step', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
const failures = [
{ status: 422, code: 'onboarding_validation_failed' },
{ status: 502, code: 'onboarding_upstream_unavailable' },
{ status: 426, code: 'onboarding_client_incompatible' },
];
let failureIndex = -1;
await routeOnboarding(page, async (route) => {
if (failureIndex >= 0) {
const failure = failures[failureIndex];
await route.fulfill({
status: failure.status,
contentType: 'application/json',
headers: {
'x-request-id': `req_onboarding_${failure.status}`,
'x-trace-id': '0123456789abcdef0123456789abcdef',
},
body: JSON.stringify({ error: { code: failure.code, message: 'safe failure' } }),
});
failureIndex = -1;
return;
}
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 3 })) });
});
await page.goto('/');
await page.getByTestId('onboarding-trigger').click();
for (let index = 0; index < failures.length; index += 1) {
failureIndex = index;
await page.getByTestId('onboarding-refresh').click();
const alert = page.getByRole('alert');
await expect(alert).toContainText(failures[index].code);
await expect(alert).toContainText(`req_onboarding_${failures[index].status}`);
await expect(alert).toContainText('0123456789abcdef0123456789abcdef');
await page.getByTestId('onboarding-retry').click();
await expect(page.getByTestId('onboarding-trigger')).toContainText(/3\s*\/\s*7/);
await expect(page.getByTestId('onboarding-checklist').locator('ol > li')).toHaveCount(7);
await expect(page.getByTestId('onboarding-completion')).toHaveCount(0);
}
});
test('authentication recovery returns to an incomplete authoritative onboarding projection', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
let rejectOnce = false;
await routeOnboarding(page, async (route) => {
if (rejectOnce) {
rejectOnce = false;
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'onboarding_auth_required', message: 'sign in again' } }),
});
return;
}
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 4 })) });
});
await page.goto('/');
await page.getByTestId('onboarding-trigger').click();
rejectOnce = true;
await page.getByTestId('onboarding-refresh').click();
await expect(page).toHaveURL(/\/login/);
await page.context().clearCookies();
await login(page);
await expect(page.getByTestId('onboarding-trigger')).toContainText(/3\s*\/\s*7/);
await expect(page.getByTestId('onboarding-completion')).toHaveCount(0);
});
test('a BroadcastChannel refresh updates a second open tab from server progress', async ({ page, context }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
var revision = 1;
const respond = async (route) => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id, {
revision,
steps: revision === 1 ? onboardingSnapshot(workspace.id).steps : onboardingSnapshot(workspace.id).steps.map((step) => ({ ...step, status: 'complete' })),
})),
});
await routeOnboarding(page, respond);
await page.goto('/');
await expect(page.getByTestId('onboarding-trigger')).toContainText(/3\s*\/\s*7/);
const other = await context.newPage();
await routeOnboarding(other, respond);
await other.goto('/');
await expect(other.locator('.page-title, .page-heading').first()).toBeVisible();
revision = 2;
await other.evaluate(() => window.CrankOnboarding.signalRefresh());
await expect(page.getByTestId('onboarding-trigger')).toContainText(/7\s*\/\s*7/);
await other.close();
});
test('bursty browser refresh signals coalesce into one request plus one trailing refresh', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
let requestNumber = 0;
let releaseRefresh;
const refreshReleased = new Promise((resolve) => { releaseRefresh = resolve; });
await routeOnboarding(page, async (route) => {
if (route.request().method() !== 'GET') {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 1 })) });
return;
}
requestNumber += 1;
if (requestNumber === 1) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 1 })),
});
return;
}
if (requestNumber === 2) await refreshReleased;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id, {
revision: 2,
steps: onboardingSnapshot(workspace.id).steps.map((step) => ({ ...step, status: 'complete' })),
status: 'complete',
completed_at: '2026-08-23T08:10:00Z',
first_call: {
log_id: 'log_newest',
tool_name: 'frankfurter_latest_rate',
request_id: 'req_newest',
trace_id: 'abcdefabcdefabcdefabcdefabcdefab',
occurred_at: '2026-08-23T08:10:00Z',
},
})),
});
});
await page.goto('/');
if (!await page.getByTestId('onboarding-checklist').isVisible()) {
await page.getByTestId('onboarding-trigger').click();
}
await page.evaluate(() => {
window.CrankOnboarding.refresh();
window.CrankOnboarding.refresh();
window.CrankOnboarding.refresh();
window.CrankOnboarding.refresh();
});
await expect.poll(() => requestNumber).toBe(2);
releaseRefresh();
await expect(page.getByTestId('onboarding-trigger')).toContainText(/7\s*\/\s*7/);
await expect(page.getByTestId('onboarding-completion')).toContainText('req_newest');
await expect(page.getByTestId('onboarding-completion')).toContainText('frankfurter_latest_rate');
await expect(page.getByTestId('onboarding-completion')).toContainText('2026-08-23T08:10:00Z');
await expect.poll(() => requestNumber).toBe(3);
});
test('stale onboarding deep link requires server reset before explicit reselection', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
let resetPayload = null;
await routeOnboarding(page, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id, {
revision: 17,
operation_id: 'op_current',
operation_version: 4,
agent_id: 'agent_current',
catalog_revision: 9,
})),
});
});
await page.route(/\/api\/admin\/workspaces\/[^/]+\/onboarding\/reset-selection$/, async (route) => {
resetPayload = await route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 18 })),
});
});
await page.goto('/agents?onboarding=1&action=create&operationId=op_stale&operationVersion=3');
const recovery = page.getByTestId('onboarding-deep-link-stale');
await expect(recovery).toBeVisible();
await expect(recovery).toContainText(/changed Operation|изменённой операции/i);
await page.getByTestId('onboarding-reselect').click();
await expect.poll(() => resetPayload).toEqual({ expected_revision: 17 });
await expect(page).toHaveURL('/agents');
});
test('one-time key connection config is never recoverable after the reveal is lost', async ({ page }) => { // community-scope: allow=one-time-token
await login(page);
const workspace = await getCurrentWorkspace(page);
const agent = await createAgent(page, workspace.id, {
slug: uniqueName('onboarding_lost_key'),
display_name: 'Onboarding lost key Agent',
description: 'Proves one-time onboarding key handling.', // community-scope: allow=one-time-token
instructions: {},
tool_selection_policy: { mode: 'direct', groups: [], search: { max_results: 8 } },
});
await page.goto(`/api-keys?onboarding=1&action=create&agentId=${encodeURIComponent(agent.agent_id)}`);
await page.waitForFunction(() => Boolean(window.CrankAuth && window.CrankAuth.getCsrfToken()));
await expect(page.locator('#agent-select')).toHaveValue(agent.agent_id);
await expect(page.locator('#modal-create')).toHaveClass(/open/);
await page.locator('#new-key-name').fill(uniqueName('onboarding_client'));
await page.locator('#modal-confirm-btn').click();
const revealKey = page.locator('#reveal-key-value');
await expect(revealKey).toHaveText(/^crk_/);
const rawKey = await revealKey.textContent();
await expect(page.getByTestId('onboarding-connection-config')).toContainText('/mcp/v1/');
await expect(page.getByTestId('onboarding-clipboard-warning')).toBeVisible();
const leakedBeforeNavigation = await page.evaluate((secret) => ({
local: Object.values(localStorage).some((value) => String(value).includes(secret)),
session: Object.values(sessionStorage).some((value) => String(value).includes(secret)),
url: location.href.includes(secret),
}), rawKey);
expect(leakedBeforeNavigation).toEqual({ local: false, session: false, url: false });
await page.goto('/agents');
await page.goBack();
await expect(page.locator('body')).not.toContainText(rawKey);
await expect(page.getByTestId('onboarding-key-lost')).toContainText(/create|rotate|созда|ротац/i);
await expect(page.getByTestId('onboarding-connection-config')).toBeHidden();
await expect(page.locator('#onboarding-connection-clients')).toBeEmpty();
});
test('first value is proven only by a real tools/call through the public MCP endpoint', async ({ page, request }) => {
test.setTimeout(90_000);
await login(page);
const workspace = await getCurrentWorkspace(page);
await page.waitForFunction(() => Boolean(window.CrankAuth && window.CrankAuth.getCsrfToken()));
const suffix = uniqueName('mcp').replace(/_/g, '-');
const operationName = uniqueName('onboarding_real_rest');
const agentSlug = `onboarding-real-${suffix}`.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// Exercise the actual wizard controls and lifecycle buttons. This is not an
// API provisioning shortcut: every mutation below is initiated by visible UI.
await page.goto('/wizard/?onboarding=1');
await page.waitForFunction(() => window.CrankWizardReady === true && window.currentStep === 1);
await page.locator('[data-testid="wizard-protocol-rest"]').click();
await page.locator('#btn-continue').click();
await expect(page.locator('#upstream-new-trigger')).toBeVisible();
await page.locator('#upstream-new-trigger').click();
await page.locator('#new-upstream-name').fill(`onboarding-${suffix}`);
await page.locator('#new-upstream-url').fill('http://127.0.0.1:3310');
await page.locator('#new-upstream-static-headers').fill('{"Accept":"application/json"}');
await page.locator('[data-wizard-action="save-upstream"]').click();
await expect(page.locator('#upstream-preview-url')).toHaveText('http://127.0.0.1:3310');
await page.locator('#endpoint-path').fill('/rates');
await page.locator('#btn-continue').click();
await page.locator('.method-card[data-method="GET"]').click();
await page.locator('#btn-continue').click();
await page.locator('#tool-name').fill(operationName);
await page.locator('#tool-display-name').fill('Deterministic onboarding REST Operation');
await page.locator('#tool-title').fill('Deterministic onboarding REST Operation');
await page.locator('#tool-description').fill(
'A local deterministic REST target exercised through the complete onboarding UI flow.',
);
await page.locator('#tool-input-schema').fill(JSON.stringify({
type: 'object',
properties: {
base: { type: 'string', description: 'Base currency.' },
quote: { type: 'string', description: 'Quote currency.' },
},
required: ['base', 'quote'],
}));
await page.locator('#tool-output-schema').fill(JSON.stringify({
type: 'object',
properties: { base: { type: 'string', description: 'Returned base currency.' } },
required: ['base'],
}));
await page.locator('#btn-continue').click();
await page.locator('details.advanced-mapping-details').filter({ has: page.locator('#tool-input-mapping') }).locator('summary').click();
await page.locator('#tool-input-mapping').fill(JSON.stringify({
'query.base': '$.input.base',
'query.quote': '$.input.quote',
}));
await page.locator('details.advanced-mapping-details').filter({ has: page.locator('#tool-output-mapping') }).locator('summary').click();
await page.locator('#tool-output-mapping').fill(JSON.stringify({
base: '$.response.body.base',
}));
await page.evaluate(() => window.CrankWizardMapping.renderFromEditors());
await page.locator('#tool-exec-config').fill('{"timeout_ms":2000,"headers":{}}');
await page.locator('#wizard-test-input').fill('{"base":"USD","quote":"EUR"}');
const createOperationResponse = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& /\/api\/admin\/workspaces\/[^/]+\/operations$/.test(response.url())
));
await page.locator('.btn-save-draft').click();
const createdOperation = await (await createOperationResponse).json();
const operationId = createdOperation.operation_id;
await expect(page).toHaveURL(new RegExp(`operationId=${operationId}`));
const testRunResponse = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& response.url().includes(`/operations/${operationId}/test-runs`)
));
await page.locator('#wizard-run-test').click();
const testRun = await (await testRunResponse).json();
expect(testRun.ok).toBe(true);
await expect(page.locator('#wizard-test-request-id')).not.toHaveText('');
page.once('dialog', (dialog) => dialog.accept());
const publishOperationResponse = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& response.url().includes(`/operations/${operationId}/publish`)
));
await page.locator('#wizard-publish-operation').click();
const published = await (await publishOperationResponse).json();
expect(published.published_version).toBe(1);
// Continue through the actual onboarding Agent drawer. The deep link selects
// the exact published Operation/version; the visible Create action publishes it.
await page.goto(`/agents?onboarding=1&action=create&operationId=${encodeURIComponent(operationId)}&operationVersion=1`);
await expect(page.locator('.drawer')).toHaveClass(/open/);
const identityInputs = page.locator('.drawer-section').first().locator('input.form-input');
await identityInputs.nth(0).fill('Onboarding real public MCP Agent');
await identityInputs.nth(1).fill(agentSlug);
await page.locator('.drawer-section').first().locator('textarea').fill(
'Real public MCP onboarding acceptance flow.',
);
await expect(page.locator('.ops-picker-item.selected')).toContainText(operationName);
const createAgentResponse = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& /\/api\/admin\/workspaces\/[^/]+\/agents$/.test(response.url())
));
const bindAgentResponse = page.waitForResponse((response) => (
response.request().method() === 'POST' && /\/agents\/[^/]+\/bindings$/.test(response.url())
));
const publishAgentResponse = page.waitForResponse((response) => (
response.request().method() === 'POST' && /\/agents\/[^/]+\/publish$/.test(response.url())
));
await page.locator('.drawer-footer .btn-primary-sm').click();
const createdResponse = await createAgentResponse;
const bindingResponse = await bindAgentResponse;
expect(bindingResponse.ok(), await bindingResponse.text()).toBe(true);
const agentPublishResponse = await publishAgentResponse;
expect(agentPublishResponse.ok(), await agentPublishResponse.text()).toBe(true);
const createdAgent = await createdResponse.json();
const provisioned = {
agentId: createdAgent.agent_id,
operationId,
operationVersion: 1,
toolName: operationName,
};
await expect(page.locator('.drawer')).not.toHaveClass(/open/);
// The key is likewise created through the visible onboarding key flow.
await page.goto(`/api-keys?onboarding=1&action=create&agentId=${encodeURIComponent(provisioned.agentId)}`);
await expect(page.locator('#agent-select')).toHaveValue(provisioned.agentId);
await expect(page.locator('#modal-create')).toHaveClass(/open/);
await expect(page.locator('[data-scope="read"]')).toBeChecked();
await expect(page.locator('[data-scope="write"]')).toBeChecked();
await page.locator('#new-key-name').fill(uniqueName('onboarding_real_key'));
const keyResponsePromise = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& /\/platform-api-keys$/.test(response.url())
));
await page.locator('#modal-confirm-btn').click();
const keyResponse = await keyResponsePromise;
expect(keyResponse.ok()).toBe(true);
const keyBody = await keyResponse.json();
provisioned.key = await page.locator('#reveal-key-value').textContent();
provisioned.keyId = keyBody.api_key.id || keyBody.api_key.api_key.id;
provisioned.endpoint = await page.locator('#onboarding-connection-clients > code').textContent();
expect(provisioned.key).toMatch(/^crk_/);
await expect(page.getByTestId('onboarding-connection-config')).toContainText(provisioned.endpoint);
expect(provisioned.endpoint).toContain('/mcp/v1/');
const initialize = await mcpPost(request, provisioned.endpoint, provisioned.key, {
jsonrpc: '2.0', id: 1, method: 'initialize', params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'crank-onboarding-playwright', version: '1.0.0' },
},
});
const initializeBody = await parseMcpResponse(initialize);
expect(initialize.ok(), JSON.stringify(initializeBody)).toBe(true);
expect(initializeBody.result.protocolVersion).toBe('2025-06-18');
const sessionId = initialize.headers()['mcp-session-id'];
expect(sessionId).toBeTruthy();
const initialized = await mcpPost(request, provisioned.endpoint, provisioned.key, {
jsonrpc: '2.0', method: 'notifications/initialized', params: {},
}, sessionId);
expect([200, 202]).toContain(initialized.status());
const listed = await mcpPost(request, provisioned.endpoint, provisioned.key, {
jsonrpc: '2.0', id: 2, method: 'tools/list', params: {},
}, sessionId);
expect(listed.ok()).toBe(true);
const listedBody = await parseMcpResponse(listed);
expect(listedBody.result.tools.map((tool) => tool.name)).toContain(provisioned.toolName);
const called = await mcpPost(request, provisioned.endpoint, provisioned.key, {
jsonrpc: '2.0', id: 3, method: 'tools/call', params: {
name: provisioned.toolName,
arguments: { base: 'USD', quote: 'EUR' },
},
}, sessionId);
expect(called.ok()).toBe(true);
const calledBody = await parseMcpResponse(called);
expect(calledBody.error).toBeUndefined();
expect(calledBody.result.isError).not.toBe(true);
// This is deliberately the real authoritative endpoint. The test never posts a
// browser-only "complete" flag and never calls a test-only onboarding bypass.
const progress = await browserJson(
page,
'GET',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/onboarding`,
);
expect(progress.status).toBe('complete');
expect(progress.agent_id).toBe(provisioned.agentId);
expect(progress.platform_api_key_id).toBe(provisioned.keyId);
expect(progress.operation_id).toBe(provisioned.operationId);
expect(progress.operation_version).toBe(provisioned.operationVersion);
expect(progress.first_call.agent_id).toBe(provisioned.agentId);
expect(progress.first_call.key_id).toBe(provisioned.keyId);
expect(progress.first_call.operation_id).toBe(provisioned.operationId);
expect(progress.first_call.operation_version).toBe(provisioned.operationVersion);
expect(progress.first_call.tool_name).toBe(provisioned.toolName);
expect(progress.first_call.request_id).toBeTruthy();
expect(progress.first_call.trace_id).toMatch(/^[0-9a-f]{32}$/);
await page.goto('/');
await expect(page.getByTestId('onboarding-trigger')).toContainText(/7\s*\/\s*7/);
await page.getByTestId('onboarding-trigger').click();
const completion = page.getByTestId('onboarding-completion');
await expect(completion).toContainText(provisioned.toolName);
await expect(completion).toContainText(progress.first_call.occurred_at);
await expect(completion).toContainText(progress.first_call.request_id);
await expect(completion).toContainText(progress.first_call.trace_id);
await page.goto(`/logs?log_id=${encodeURIComponent(progress.first_call.log_id)}`);
await expect(page.locator(`[data-id="${progress.first_call.log_id}"]`)).toBeVisible();
await expect(page.locator(`[data-exp="${progress.first_call.log_id}"]`)).toContainText(progress.first_call.trace_id);
await page.waitForFunction(() => Boolean(window.CrankAuth && window.CrankAuth.getCsrfToken()));
const revokeEvidence = await page.evaluate(async ({ workspaceId, agentId, keyId }) => {
const before = await window.CrankApi.listAgentPlatformApiKeys(workspaceId, agentId);
await window.CrankApi.revokeAgentPlatformApiKey(workspaceId, agentId, keyId);
const after = await window.CrankApi.listAgentPlatformApiKeys(workspaceId, agentId);
const onboarding = await window.CrankApi.getOnboarding(workspaceId);
return {
beforeCount: before.items.length,
afterCount: after.items.length,
revoked: after.items.find((item) => (item.api_key || item).id === keyId),
onboarding,
};
}, { workspaceId: workspace.id, agentId: provisioned.agentId, keyId: provisioned.keyId });
expect(revokeEvidence.afterCount).toBe(revokeEvidence.beforeCount);
expect((revokeEvidence.revoked.api_key || revokeEvidence.revoked).status).toBe('revoked');
expect(revokeEvidence.onboarding.status).not.toBe('complete');
const keyStep = revokeEvidence.onboarding.steps.find((step) => step.id === 'key');
expect(keyStep.completed).toBe(false);
expect(keyStep.status).toBe('regressed');
});
@@ -0,0 +1,190 @@
const { test, expect } = require('@playwright/test');
const { getCurrentWorkspace, login, uniqueName } = require('./helpers');
test('Operation lifecycle keeps published versions immutable and exposes safe correlation', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
const name = uniqueName('immutable_lifecycle');
const evidence = await page.evaluate(async ({ workspaceId, operationName }) => {
const schema = (field, description) => ({
type: 'object',
description,
required: true,
nullable: false,
fields: {
[field]: {
type: 'string',
description: `${field} value used by the lifecycle acceptance test`,
required: true,
nullable: false,
},
},
});
const payload = {
name: operationName,
display_name: 'Immutable lifecycle acceptance Operation',
category: 'quality',
protocol: 'rest',
security_level: 'standard',
target: {
kind: 'rest',
base_url: 'https://httpbin.org',
method: 'GET',
path_template: '/anything',
static_headers: {},
},
input_schema: schema('input', 'Lifecycle input contract'),
output_schema: schema('output', 'Lifecycle output contract'),
input_mapping: {
rules: [{
source: '$.mcp.input',
target: '$.request.query.input',
required: true,
}],
},
output_mapping: {
rules: [{
source: '$.response.body.output',
target: '$.output.output',
required: true,
}],
},
execution_config: { timeout_ms: 1000, headers: {} },
tool_description: {
title: 'Immutable lifecycle acceptance Operation',
description: 'Exercises immutable Draft, Published Version, YAML, archive and correlation behavior.',
tags: ['quality', 'lifecycle'],
examples: [],
},
wizard_state: null,
};
const created = await window.CrankApi.createOperation(workspaceId, payload);
const operationId = created.operation_id;
await window.CrankApi.getOperation(workspaceId, operationId);
let testResult;
try {
testResult = await window.CrankApi.runOperationTest(workspaceId, operationId, {
version: 1,
input: { input: 'acceptance' },
});
} catch (error) {
testResult = {
tested_version: 1,
request_id: error.requestId,
trace_id: error.traceId,
rejected_safely: true,
};
}
const published = await window.CrankApi.publishOperation(workspaceId, operationId, 1);
const publishedV1 = await window.CrankApi.getOperationVersion(workspaceId, operationId, 1);
await window.CrankApi.getOperation(workspaceId, operationId);
const changedPayload = JSON.parse(JSON.stringify(payload));
changedPayload.display_name = 'Changed Draft after publication';
changedPayload.tool_description.title = 'Changed Draft after publication';
const changed = await window.CrankApi.updateOperation(workspaceId, operationId, changedPayload);
const publishedV1AfterEdit = await window.CrankApi.getOperationVersion(workspaceId, operationId, 1);
const yamlV1 = await window.CrankApi.exportOperation(workspaceId, operationId, { version: 1 });
await window.CrankApi.getOperation(workspaceId, operationId);
const imported = await window.CrankApi.importOperation(
workspaceId,
yamlV1,
'upsert',
operationId,
);
await window.CrankApi.getOperation(workspaceId, operationId);
const archived = await window.CrankApi.archiveOperation(workspaceId, operationId);
const publishedV1AfterArchive = await window.CrankApi.getOperationVersion(workspaceId, operationId, 1);
return {
operationId,
testResult,
published,
changed,
imported,
archived,
originalSnapshot: publishedV1.snapshot,
afterEditSnapshot: publishedV1AfterEdit.snapshot,
afterArchiveSnapshot: publishedV1AfterArchive.snapshot,
yamlV1,
};
}, { workspaceId: workspace.id, operationName: name });
expect(evidence.testResult.tested_version).toBe(1);
expect(evidence.testResult.request_id).toMatch(/^[!-~]{1,128}$/);
expect(evidence.testResult.trace_id).toMatch(/^[0-9a-f]{32}$/);
expect(evidence.published.published_version).toBe(1);
expect(evidence.changed.version).toBe(2);
expect(evidence.imported.version).toBe(3);
expect(evidence.archived.status).toBe('archived');
expect(evidence.afterEditSnapshot).toEqual(evidence.originalSnapshot);
expect(evidence.afterArchiveSnapshot).toEqual(evidence.originalSnapshot);
expect(evidence.yamlV1).toContain("format_version: '2'");
for (const forbidden of ['wizard_state:', 'created_at:', 'published_at:', 'operation_id:']) {
expect(evidence.yamlV1).not.toContain(forbidden);
}
});
for (const locale of ['ru', 'en']) {
test(`catalog lifecycle actions are state-aware in ${locale}`, async ({ page }) => {
await login(page);
await page.evaluate((language) => localStorage.setItem('crank_lang', language), locale);
const workspace = await getCurrentWorkspace(page);
const name = uniqueName(`catalog_lifecycle_${locale}`);
const operation = await page.evaluate(async ({ workspaceId, operationName, language }) => {
const schema = {
type: 'object',
description: 'Catalog lifecycle UI contract',
required: true,
nullable: false,
fields: {},
};
const created = await window.CrankApi.createOperation(workspaceId, {
name: operationName,
display_name: `Catalog lifecycle ${language}`,
category: 'quality',
protocol: 'rest',
security_level: 'standard',
target: { kind: 'rest', base_url: 'https://example.test', method: 'GET', path_template: '/', static_headers: {} },
input_schema: schema,
output_schema: schema,
input_mapping: { rules: [] },
output_mapping: { rules: [] },
execution_config: { timeout_ms: 1000, headers: {} },
tool_description: {
title: `Catalog lifecycle ${language}`,
description: 'A sufficiently detailed description for lifecycle UI acceptance.',
tags: ['quality'],
examples: [],
},
wizard_state: null,
});
await window.CrankApi.getOperation(workspaceId, created.operation_id);
await window.CrankApi.publishOperation(workspaceId, created.operation_id, 1);
return created;
}, { workspaceId: workspace.id, operationName: name, language: locale });
await page.goto('/');
await page.getByPlaceholder(locale === 'ru' ? 'Поиск операций' : 'Search operations').fill(name);
const row = page.locator('tbody tr').filter({ hasText: name });
await expect(row).toHaveCount(1);
await expect(row.locator('.row-btn-delete')).toBeHidden();
await expect(row.locator('.row-btn-edit')).toBeVisible();
await row.locator('.row-btn-edit').click();
await expect(page).toHaveURL(new RegExp(`/wizard/\\?mode=edit&operationId=${operation.operation_id}`));
await expect(page.locator('.wizard-shell')).toBeVisible();
await expect(page.locator('#back-to-catalog')).toBeVisible();
await page.locator('#back-to-catalog').click();
await expect(page).toHaveURL(/\/$/);
await page.getByPlaceholder(locale === 'ru' ? 'Поиск операций' : 'Search operations').fill(name);
const refreshedRow = page.locator('tbody tr').filter({ hasText: name });
page.once('dialog', (dialog) => dialog.accept());
await refreshedRow.locator('[data-testid="operation-archive"]').click();
await expect(refreshedRow).toContainText(locale === 'ru' ? 'Неактивные' : 'Inactive');
await expect(refreshedRow.locator('.row-btn-delete')).toBeHidden();
});
}
+2 -1
View File
@@ -6,7 +6,8 @@ test('operations page shows demo catalog and filter works', async ({ page }) =>
await expect(page.locator('.page-heading')).toHaveText(localized('Operations', 'Операции'));
await expect(page.locator('.ws-switcher-trigger')).toBeVisible();
await expect(page.locator('#ws-dropdown')).toHaveCount(0);
await expect(page.locator('tbody tr')).toHaveCount(1);
await expect(page.getByText(localized('Loading operations', 'Загрузка операций'))).toBeHidden();
expect(await page.locator('tbody tr').count()).toBeGreaterThanOrEqual(1);
await page.getByPlaceholder(localized('Search operations', 'Поиск операций')).fill('frankfurter');
await expect(page.locator('tbody tr')).toHaveCount(1);
await expect(page.locator('tbody tr').first()).toContainText(/frankfurter_latest_rate/i);
+131
View File
@@ -84,3 +84,134 @@ test('generic json secret modal stays inside compact viewport', async ({ page })
await expect(page.locator('[data-testid="secret-name-input"]')).toBeVisible();
await expect(page.locator('[data-testid="secret-submit-button"]')).toBeVisible();
});
test('secret modal clears sensitive values and locks duplicate submit while saving', async ({ page }) => {
await login(page);
await page.goto('/secrets');
await page.locator('[data-testid="secret-create-button"]').click();
await page.locator('[data-testid="secret-name-input"]').fill(uniqueName('ui_secret_guard'));
await page.locator('[data-testid="secret-value-input"]').fill('ui-secret-canary-close');
await page.locator('#secret-modal-cancel-btn').click();
await page.locator('[data-testid="secret-create-button"]').click();
await expect(page.locator('[data-testid="secret-name-input"]')).toHaveValue('');
await expect(page.locator('[data-testid="secret-value-input"]')).toHaveValue('');
let releaseCreate;
const createReleased = new Promise((resolve) => {
releaseCreate = resolve;
});
let createPosts = 0;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/secrets$/, async (route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
createPosts += 1;
await createReleased;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'secret_ui_guard',
workspace_id: 'ws_test',
name: 'ui_secret_guard',
kind: 'token',
status: 'active',
current_version: 1,
created_at: '2026-08-15T00:00:00Z',
updated_at: '2026-08-15T00:00:00Z',
last_used_at: null,
}),
});
});
await page.locator('[data-testid="secret-name-input"]').fill(uniqueName('ui_secret_guard'));
await page.locator('[data-testid="secret-value-input"]').fill('ui-secret-canary-submit');
const submit = page.locator('[data-testid="secret-submit-button"]');
await submit.click();
await expect(submit).toBeDisabled();
await submit.click({ force: true });
expect(createPosts).toBe(1);
releaseCreate();
await expect(page.locator('[data-testid="secret-create-modal"]')).toBeHidden();
expect(createPosts).toBe(1);
});
test('stale secret rotation cannot close or report success for a newer modal context', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
const firstName = uniqueName('rotate_stale_first');
const secondName = uniqueName('rotate_stale_second');
const firstSecret = await browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/secrets`,
{ name: firstName, kind: 'token', value: 'rotate-stale-first-value' },
);
await browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/secrets`,
{ name: secondName, kind: 'token', value: 'rotate-stale-second-value' },
);
let releaseRotate;
const rotateReleased = new Promise((resolve) => {
releaseRotate = resolve;
});
let secretListRequests = 0;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/secrets(?:\/[^/]+\/rotate)?$/, async (route) => {
const request = route.request();
if (request.method() === 'GET') {
secretListRequests += 1;
await route.continue();
return;
}
if (request.method() === 'POST' && /\/rotate$/.test(request.url())) {
await rotateReleased;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: firstSecret.id,
workspace_id: workspace.id,
name: firstName,
kind: 'token',
status: 'active',
current_version: 2,
created_at: '2026-08-15T00:00:00Z',
updated_at: '2026-08-15T00:00:00Z',
last_used_at: null,
}),
});
return;
}
await route.continue();
});
await page.goto('/secrets');
const firstRow = page.locator('#secrets-tbody tr').filter({ hasText: firstName }).first();
const secondRow = page.locator('#secrets-tbody tr').filter({ hasText: secondName }).first();
await expect(firstRow).toBeVisible();
await expect(secondRow).toBeVisible();
const listRequestsBeforeRelease = secretListRequests;
await firstRow.getByTestId('secret-rotate-action').click();
await page.locator('[data-testid="secret-value-input"]').fill('rotate-stale-first-new-value');
await page.locator('[data-testid="secret-submit-button"]').click();
await expect(page.locator('[data-testid="secret-submit-button"]')).toBeDisabled();
await page.locator('#secret-modal-cancel-btn').click();
await expect(page.getByTestId('secret-rotate-modal')).toBeHidden();
await secondRow.getByTestId('secret-rotate-action').click();
await expect(page.getByTestId('secret-rotate-modal')).toBeVisible();
await expect(page.locator('[data-testid="secret-name-input"]')).toHaveValue(secondName);
releaseRotate();
await page.waitForTimeout(150);
await expect(page.getByTestId('secret-rotate-modal')).toBeVisible();
await expect(page.locator('[data-testid="secret-name-input"]')).toHaveValue(secondName);
await expect(page.locator('.toast-success')).toHaveCount(0);
expect(secretListRequests).toBe(listRequestsBeforeRelease);
});
+2 -9
View File
@@ -164,7 +164,6 @@ test('wizard builds visual request mappings from JSON sample and path params', a
await page.evaluate(() => window.CrankWizardShell.goToStep(2));
await expect(page.locator('#step-panel-2')).toBeVisible();
await page.locator('#endpoint-path').fill('/rates/{date}');
await page.evaluate(() => window.CrankWizardShell.goToStep(3));
await expect(page.locator('#step-panel-3-rest')).toBeVisible();
await page.locator('.method-card[data-method="GET"]').click();
@@ -356,8 +355,6 @@ test('wizard edit mode hydrates fields from operation version snapshot', async (
retry_policy: { max_attempts: 2 },
auth_profile_ref: null,
headers: {},
protocol_options: null,
streaming: null,
},
tool_description: {
title: 'Получить историю курсов за месяц',
@@ -571,8 +568,6 @@ test('wizard shows agent-facing MCP preview from current draft fields', async ({
retry_policy: null,
auth_profile_ref: null,
headers: {},
protocol_options: null,
streaming: null,
approval_policy: {
required: true,
risk_level: 'financial',
@@ -655,6 +650,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/operations/${operationId}`,
async (route) => {
if (route.request().method() === 'PATCH') {
expect(route.request().headers()['if-match']).toBe('"operation-etag-v3"');
updatePayload = route.request().postDataJSON();
await route.fulfill({
contentType: 'application/json',
@@ -671,6 +667,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
await route.fulfill({
contentType: 'application/json',
headers: { ETag: '"operation-etag-v3"' },
body: JSON.stringify({
id: operationId,
workspace_id: workspace.id,
@@ -793,8 +790,6 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
retry_policy: null,
auth_profile_ref: null,
headers: {},
protocol_options: null,
streaming: null,
approval_policy: {
required: true,
risk_level: 'financial',
@@ -877,8 +872,6 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
retry_policy: null,
auth_profile_ref: null,
headers: {},
protocol_options: null,
streaming: null,
approval_policy: {
required: true,
mode: 'custom',