Files
openwork/evals/specs/org-connection-lifecycle.e2e.test.ts

244 lines
11 KiB
TypeScript

import { expect, onTestFinished, test } from "vitest";
import { createVisualEvidence, screenshot, validate } from "@openwork/test-evidence";
import { desktop } from "@openwork/hosts";
import { startMockMcp } from "@openwork/labs";
import {
clickButton,
createAndSelectWorkspace,
currentHash,
deleteConnection,
deleteConnectionsNamed,
enabledButtons,
ensureMemberSession,
evalIn,
go,
readUsableConnection,
signIn,
signInDesktopAs,
waitFor,
waitForButtonGone,
waitForText,
createOrgConnection,
} from "@openwork/behaviors";
import type { Surface } from "@openwork/cdp";
const apiUrl = process.env.OPENWORK_EVAL_DEN_API_URL?.trim().replace(/\/+$/, "") ?? "";
const e2eTestsEnabled = process.env.OPENWORK_EVAL_E2E_TESTS === "1";
const title = !e2eTestsEnabled
? "organization connection lifecycle skipped: set OPENWORK_EVAL_E2E_TESTS=1 to opt in"
: !apiUrl
? "organization connection lifecycle skipped: set OPENWORK_EVAL_DEN_API_URL"
: "member connects, reconnects, and disconnects an organization OAuth connection";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForConnectionCard(app: Surface, name: string, workspaceId: string): Promise<void> {
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
const found = await evalIn(app, `([...document.querySelectorAll('button')]
.some((button) => (button.textContent ?? '').includes(${JSON.stringify(name)})))`);
if (found === true) return;
const onExtensions = await evalIn(app, `window.location.hash.includes("/extensions")`).catch(() => false);
if (onExtensions !== true) {
await go(app, `/workspace/${workspaceId}/settings/extensions/connections`);
await sleep(1_500);
continue;
}
await evalIn(
app,
"window.__openworkControl.execute('extensions.refresh-marketplace', null)",
{ awaitPromise: true },
).catch(() => undefined);
await evalIn(app, `(() => {
const button = [...document.querySelectorAll('button')]
.find((element) => (element.textContent ?? '').trim() === 'Refresh' && !element.disabled);
button?.click();
return Boolean(button);
})()`).catch(() => undefined);
await sleep(2_000);
}
const seen = await evalIn(app, `({
hash: window.location.hash,
buttons: [...document.querySelectorAll('button')].map((b) => (b.textContent ?? '').replace(/\\s+/g, ' ').trim()).filter(Boolean).slice(0, 40),
text: (document.body.innerText ?? '').replace(/\\s+/g, ' ').slice(0, 600),
})`).catch(() => null);
throw new Error(`The connection card ${name} never appeared. On screen: ${JSON.stringify(seen)}`);
}
async function revealText(app: Surface, text: string, timeoutMs = 45_000): Promise<void> {
await waitFor(app, `(() => {
const wanted = ${JSON.stringify(text)}.toLowerCase();
const nodes = [...document.querySelectorAll("button, h1, h2, h3, p, span, div")];
const node = nodes.reverse().find((element) => ((element.innerText ?? element.textContent ?? "")).toLowerCase().includes(wanted));
if (!node) return false;
const rect = node.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
node.scrollIntoView({ block: "center" });
return true;
})()`, { timeoutMs, label: `visible text ${JSON.stringify(text)}` });
await sleep(750);
}
async function openConnectionDetail(app: Surface, name: string): Promise<void> {
const opened = await evalIn(app, `(() => {
const button = [...document.querySelectorAll('button')]
.find((element) => (element.textContent ?? '').includes(${JSON.stringify(name)}) && !element.disabled);
button?.scrollIntoView({ block: 'center' });
button?.click();
return Boolean(button);
})()`);
expect(opened).toBe(true);
}
async function waitForNotConnected(app: Surface, name: string): Promise<void> {
await waitFor(app, `(() => {
const text = document.body.innerText;
const hasConnect = [...document.querySelectorAll('button')]
.some((element) => (element.textContent ?? '').trim() === 'Connect your account' && !element.disabled);
return text.includes(${JSON.stringify(name)}) && text.includes('Not connected') && hasConnect;
})()`, { timeoutMs: 60_000, label: "not connected connection detail" });
}
test.skipIf(!apiUrl || !e2eTestsEnabled)(title, async () => {
const den = {
apiUrl,
webUrl: (process.env.OPENWORK_EVAL_DEN_WEB_URL?.trim() || apiUrl.replace("127.0.0.1", "localhost")).replace(/\/+$/, ""),
};
const admin = await signIn(den, {
email: process.env.OPENWORK_EVAL_DEMO_EMAIL?.trim() || "alex@acme.test",
password: process.env.OPENWORK_EVAL_DEMO_PASSWORD?.trim() || "OpenWorkDemo123!",
});
const member = await ensureMemberSession(den, admin, {
email: process.env.OPENWORK_EVAL_MEMBER_EMAIL?.trim() || "jordan.demo@acme.test",
password: process.env.OPENWORK_EVAL_MEMBER_PASSWORD?.trim() || "OpenWorkDemo123!",
name: "Jordan Demo",
markVerifiedCmd: process.env.OPENWORK_EVAL_MARK_VERIFIED_CMD?.trim(),
});
await using mock = await startMockMcp({
port: Number(process.env.OPENWORK_EVAL_LIFECYCLE_MOCK_PORT ?? 3979),
publicUrl: process.env.OPENWORK_EVAL_LIFECYCLE_MOCK_PUBLIC_URL?.trim() || undefined,
});
await deleteConnectionsNamed(admin, "Meeting Notes ");
const connection = await createOrgConnection(admin, {
name: `Meeting Notes ${Date.now()}`,
url: `${mock.url}/mcp`,
authType: "oauth",
credentialMode: "per_member",
access: { orgWide: true },
});
onTestFinished(async () => deleteConnection(admin, connection.id));
expect((await readUsableConnection(member, connection.id))?.connectedForMe).toBe(false);
await using app = await desktop({
name: "org-connection-lifecycle",
bootstrap: { baseUrl: den.webUrl, requireSignin: false },
});
await using visualEvidence = createVisualEvidence("org-connection-lifecycle");
// Workspace first, then the org sign-in: the signed-in org shell offers no
// Add workspace entry, so a member's workspace exists before they connect.
const workspacePath = `/tmp/openwork-org-connection-lifecycle-${Date.now()}`;
await createAndSelectWorkspace(app, { path: workspacePath });
await signInDesktopAs(app, den, member);
const { workspaceId } = await createAndSelectWorkspace(app, { path: workspacePath });
await go(app, `/workspace/${workspaceId}/settings/extensions/connections`);
await waitFor(app, `window.location.hash.includes("/extensions") && document.body.innerText.includes("Library")`, {
timeoutMs: 60_000,
label: "extensions connections route (app canonicalises away /settings)",
});
await waitForConnectionCard(app, connection.name, workspaceId);
await revealText(app, "NEEDS YOUR SIGN-IN", 30_000);
await openConnectionDetail(app, connection.name);
await waitForNotConnected(app, connection.name);
await waitForText(app, "OAuth required", { timeoutMs: 30_000 });
await revealText(app, "OAuth required");
{
const shot = await screenshot(app);
const seen = await validate(shot, [
"The organization connection detail visibly says Not connected and OAuth required with a Connect your account action",
"No generic error or 'Something went wrong' crash message is visible",
]);
expect(seen.ok, seen.why).toBe(true);
await visualEvidence.recordScreenshot(shot, seen);
}
const connectClickedAt = new Date().toISOString();
await clickButton(app, "Connect your account");
const authorize = await mock.authorizeRequestSince(connectClickedAt);
expect(authorize.params.get("state")).toBeTruthy();
expect(authorize.params.get("client_id")).toBeTruthy();
const redirectUri = authorize.params.get("redirect_uri") ?? "";
expect(redirectUri).toContain("/v1/mcp-connections/");
expect(redirectUri.includes("/oauth/callback") || redirectUri.includes(connection.id)).toBe(true);
{
const shot = await screenshot(app);
const seen = await validate(shot, [
"The connection flow visibly indicates that browser authorization is in progress or required",
"No OAuth launch error or 'Something went wrong' crash message is visible",
]);
expect(seen.ok, seen.why).toBe(true);
await visualEvidence.recordScreenshot(shot, seen);
}
await waitForText(app, "Connected with your own account.", { timeoutMs: 90_000 });
await waitForButtonGone(app, "Connect your account");
await expect.poll(
async () => (await readUsableConnection(member, connection.id))?.connectedForMe,
{ timeout: 90_000, interval: 1_000 },
).toBe(true);
const firstConnectedAt = (await readUsableConnection(member, connection.id))?.connectedAt;
expect(firstConnectedAt).toBeTruthy();
if (!firstConnectedAt) throw new Error("The first OAuth connection did not record connectedAt.");
await revealText(app, "Connected with your own account.");
{
const shot = await screenshot(app);
const seen = await validate(shot, [
"The connection detail visibly says Connected with your own account",
"Reconnect and Disconnect lifecycle actions are both visibly available",
"No Connect your account action or 'Something went wrong' crash message is visible",
]);
expect(seen.ok, seen.why).toBe(true);
await visualEvidence.recordScreenshot(shot, seen);
}
const focused = await evalIn(app, `(() => {
const button = [...document.querySelectorAll('button')]
.find((element) => (element.textContent ?? '').trim() === 'Reconnect');
button?.scrollIntoView({ block: 'center' });
button?.focus({ focusVisible: true });
return Boolean(button);
})()`);
expect(focused).toBe(true);
const actions = await enabledButtons(app);
expect(actions).toContain("Reconnect");
expect(actions).toContain("Disconnect");
const reconnectClickedAt = new Date().toISOString();
await clickButton(app, "Reconnect");
await mock.authorizeRequestSince(reconnectClickedAt);
await expect.poll(async () => {
const current = await readUsableConnection(member, connection.id);
return current?.connectedForMe === true && Boolean(current.connectedAt) && current.connectedAt !== firstConnectedAt;
}, { timeout: 90_000, interval: 1_000 }).toBe(true);
await waitForText(app, "Connected with your own account.", { timeoutMs: 90_000 });
await revealText(app, "Connected with your own account.");
{
const shot = await screenshot(app);
const seen = await validate(shot, [
"The connection visibly returns to Connected with your own account after reconnecting",
"No reconnect error or 'Something went wrong' crash message is visible",
]);
expect(seen.ok, seen.why).toBe(true);
await visualEvidence.recordScreenshot(shot, seen);
}
await clickButton(app, "Disconnect");
await expect.poll(
async () => (await readUsableConnection(member, connection.id))?.connectedForMe,
{ timeout: 30_000, interval: 1_000 },
).toBe(false);
await waitForNotConnected(app, connection.name);
await waitForText(app, "Connect your account", { timeoutMs: 30_000 });
await revealText(app, "Connect your account");
expect(await currentHash(app)).toContain("/extensions/");
});