Files
crank/apps/ui/tests/e2e/onboarding.spec.js
T

638 lines
29 KiB
JavaScript

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');
});