638 lines
29 KiB
JavaScript
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');
|
|
});
|