feat(assistant): add approval inbox and e2e hardening
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const ADMIN_EMAIL = "admin@capakraken.dev";
|
||||
const ADMIN_PASSWORD = "admin123";
|
||||
const CURRENT_CONVERSATION_ID = "assistant-e2e-current";
|
||||
const DB_WORKDIR = resolve(process.cwd(), "../../packages/db");
|
||||
const WEB_ENV_PATHS = [
|
||||
resolve(process.cwd(), ".env.local"),
|
||||
resolve(process.cwd(), "apps/web/.env.local"),
|
||||
];
|
||||
|
||||
function resolveDatabaseUrl(): string {
|
||||
const explicitPlaywrightUrl = process.env["PLAYWRIGHT_DATABASE_URL"];
|
||||
if (explicitPlaywrightUrl) {
|
||||
return explicitPlaywrightUrl;
|
||||
}
|
||||
|
||||
for (const envPath of WEB_ENV_PATHS) {
|
||||
if (!existsSync(envPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const envFile = readFileSync(envPath, "utf8");
|
||||
for (const rawLine of envFile.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("PLAYWRIGHT_DATABASE_URL=")) {
|
||||
return line.slice("PLAYWRIGHT_DATABASE_URL=".length);
|
||||
}
|
||||
if (line.startsWith("DATABASE_URL_TEST=")) {
|
||||
return line.slice("DATABASE_URL_TEST=".length);
|
||||
}
|
||||
if (line.startsWith("DATABASE_URL=")) {
|
||||
return line.slice("DATABASE_URL=".length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackTestUrl = process.env["DATABASE_URL_TEST"];
|
||||
if (fallbackTestUrl) {
|
||||
return fallbackTestUrl;
|
||||
}
|
||||
|
||||
const fallback = process.env["DATABASE_URL"];
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
throw new Error("DATABASE_URL is not available for the Playwright assistant approval test.");
|
||||
}
|
||||
|
||||
function runDbJson<T>(body: string): T {
|
||||
const script = `
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
${body}
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
`;
|
||||
|
||||
const output = execFileSync("node", ["--input-type=module", "-e", script], {
|
||||
cwd: DB_WORKDIR,
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: resolveDatabaseUrl(),
|
||||
},
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
function runDb(body: string): void {
|
||||
runDbJson<null>(`${body}\nconsole.log("null");`);
|
||||
}
|
||||
|
||||
async function signInAsAdmin(page: Page) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', ADMIN_EMAIL);
|
||||
await page.fill('input[type="password"]', ADMIN_PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
}
|
||||
|
||||
test.describe("Assistant approvals", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((conversationId) => {
|
||||
window.sessionStorage.setItem("capakraken-chat-conversation-id", conversationId);
|
||||
}, CURRENT_CONVERSATION_ID);
|
||||
|
||||
runDb(`
|
||||
await prisma.systemSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
update: {
|
||||
aiProvider: "openai",
|
||||
azureOpenAiApiKey: "e2e-dummy-key",
|
||||
azureOpenAiDeployment: "gpt-4o-mini",
|
||||
},
|
||||
create: {
|
||||
id: "singleton",
|
||||
aiProvider: "openai",
|
||||
azureOpenAiApiKey: "e2e-dummy-key",
|
||||
azureOpenAiDeployment: "gpt-4o-mini",
|
||||
},
|
||||
});
|
||||
|
||||
const admin = await prisma.user.findUniqueOrThrow({
|
||||
where: { email: ${JSON.stringify(ADMIN_EMAIL)} },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await prisma.assistantApproval.deleteMany({
|
||||
where: { userId: admin.id },
|
||||
});
|
||||
|
||||
await prisma.client.deleteMany({
|
||||
where: {
|
||||
name: {
|
||||
startsWith: "E2E Approval Client",
|
||||
},
|
||||
},
|
||||
});
|
||||
`);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
runDb(`
|
||||
await prisma.assistantApproval.deleteMany({
|
||||
where: {
|
||||
conversationId: {
|
||||
startsWith: "assistant-e2e-",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.client.deleteMany({
|
||||
where: {
|
||||
name: {
|
||||
startsWith: "E2E Approval Client",
|
||||
},
|
||||
},
|
||||
});
|
||||
`);
|
||||
});
|
||||
|
||||
test("renders the pending approval inbox and handles cross-conversation actions", async ({ page }) => {
|
||||
const suffix = Date.now();
|
||||
const currentClientName = `E2E Approval Client Current ${suffix}`;
|
||||
const otherClientName = `E2E Approval Client Other ${suffix}`;
|
||||
const otherConversationId = `assistant-e2e-other-${suffix}`;
|
||||
|
||||
const { currentApproval, otherApproval } = runDbJson<{
|
||||
currentApproval: { id: string; summary: string };
|
||||
otherApproval: { id: string; summary: string };
|
||||
}>(`
|
||||
const admin = await prisma.user.findUniqueOrThrow({
|
||||
where: { email: ${JSON.stringify(ADMIN_EMAIL)} },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const currentApproval = await prisma.assistantApproval.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
conversationId: ${JSON.stringify(CURRENT_CONVERSATION_ID)},
|
||||
toolName: "create_client",
|
||||
toolArguments: ${JSON.stringify(JSON.stringify({ name: currentClientName }))},
|
||||
summary: ${JSON.stringify(`create client (name=${currentClientName})`)},
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
||||
},
|
||||
select: { id: true, summary: true },
|
||||
});
|
||||
|
||||
const otherApproval = await prisma.assistantApproval.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
conversationId: ${JSON.stringify(otherConversationId)},
|
||||
toolName: "create_client",
|
||||
toolArguments: ${JSON.stringify(JSON.stringify({ name: otherClientName }))},
|
||||
summary: ${JSON.stringify(`create client (name=${otherClientName})`)},
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
||||
},
|
||||
select: { id: true, summary: true },
|
||||
});
|
||||
|
||||
console.log(JSON.stringify({ currentApproval, otherApproval }));
|
||||
`);
|
||||
|
||||
await signInAsAdmin(page);
|
||||
await page.goto("/dashboard");
|
||||
await page.getByTestId("assistant-open-button").click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "HartBOT" })).toBeVisible();
|
||||
await expect(page.getByTestId("assistant-open-approvals")).toBeVisible();
|
||||
await expect(page.getByText(currentApproval.summary)).toBeVisible();
|
||||
await expect(page.getByText(otherApproval.summary)).toBeVisible();
|
||||
|
||||
const currentCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="current"]').first();
|
||||
const otherCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="other"]').first();
|
||||
await expect(currentCard).toContainText("This chat");
|
||||
await expect(otherCard).toContainText("Other chat");
|
||||
|
||||
await otherCard.getByTestId("assistant-approval-cancel").click();
|
||||
await expect(page.getByText(`Aktion verworfen: ${otherApproval.summary}`)).toBeVisible();
|
||||
await expect(page.locator(`[data-testid="assistant-approval-card"][data-approval-id="${otherApproval.id}"]`)).toHaveCount(0);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return runDbJson<string | null>(`
|
||||
const approval = await prisma.assistantApproval.findUnique({
|
||||
where: { id: ${JSON.stringify(otherApproval.id)} },
|
||||
select: { status: true },
|
||||
});
|
||||
console.log(JSON.stringify(approval?.status ?? null));
|
||||
`);
|
||||
})
|
||||
.toBe("CANCELLED");
|
||||
|
||||
await currentCard.getByTestId("assistant-approval-confirm").click();
|
||||
|
||||
await expect(page.getByText(`Ausgeführt: Created client: ${currentClientName}`)).toBeVisible();
|
||||
await expect(page.getByText(currentApproval.summary)).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return runDbJson<string | null>(`
|
||||
const approval = await prisma.assistantApproval.findUnique({
|
||||
where: { id: ${JSON.stringify(currentApproval.id)} },
|
||||
select: { status: true },
|
||||
});
|
||||
console.log(JSON.stringify(approval?.status ?? null));
|
||||
`);
|
||||
})
|
||||
.toBe("APPROVED");
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return runDbJson<"created" | "missing">(`
|
||||
const client = await prisma.client.findFirst({
|
||||
where: { name: ${JSON.stringify(currentClientName)} },
|
||||
select: { id: true },
|
||||
});
|
||||
console.log(JSON.stringify(client ? "created" : "missing"));
|
||||
`);
|
||||
})
|
||||
.toBe("created");
|
||||
|
||||
await expect(page.getByTestId("assistant-approval-card")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ function writeManagedWebEnv(rootEnv) {
|
||||
|
||||
const contents = managedEnvKeys
|
||||
.map((key) => {
|
||||
const value = rootEnv[key] ?? process.env[key];
|
||||
const value = process.env[key] ?? rootEnv[key];
|
||||
return value ? `${key}=${value}` : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
Reference in New Issue
Block a user