feat: complete Epic 1 production foundation
This commit is contained in:
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user