Files
Nexus/apps/web/e2e/assistant-approvals.spec.ts
Hartmut 4a5edeef3e
CI / Unit Tests (pull_request) Successful in 5m46s
CI / Lint (pull_request) Failing after 3m49s
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Failing after 35s
CI / Architecture Guardrails (pull_request) Failing after 2m14s
CI / Typecheck (pull_request) Successful in 4m22s
CI / Build (pull_request) Has been skipped
CI / Release Images (pull_request) Has been skipped
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI
- @capakraken/* → @nexus/* across 12 packages (root + 11 workspaces),
  1551 import lines migrated via codemod
- User-visible brand strings renamed (emails, page titles, PWA
  manifest, mobile header, MFA backup-codes header, tooltips, signin
  page, invite page, weekly digest, install prompt)
- TOTP issuer "CapaKraken" → "Nexus" (existing secrets still valid;
  re-enrollment relabels them in users' authenticator apps)
- Function rename: assertCapaKrakenDbTarget → assertNexusDbTarget
- LocalStorage migration shim in apps/web/src/app/layout.tsx copies
  capakraken_* → nexus_* on first load (guarded by nexus_migrated_v1
  sentinel; runs once per browser, then never again)
- Service-worker cache name capakraken-v2 → nexus-v2 with one-time
  caches.delete('capakraken-v2') from the same shim
- Email-domain fixtures @capakraken.{dev,app} → @nexus.{dev,app} in
  seed data, e2e specs, SMTP default fallback
- Dockerfile.dev / Dockerfile.prod / all .github/workflows/*.yml
  pnpm --filter @capakraken/* → @nexus/*
- README, CLAUDE.md, LEARNINGS.md, all docs/*.md, .env.example,
  tooling/deploy/.env.production.example brand sweep

Phase 1 deliberately leaves untouched (handled in Phase 3 cutover):
- PostgreSQL DB name "capakraken" and POSTGRES_USER "capakraken"
- Volume names capakraken_pgdata etc.
- Compose project name "capakraken" / "capakraken-prod"
- db-target-guard default expectedDatabase
- env-var CAPAKRAKEN_EXPECTED_DB_NAME
- Container DNS names in docker-compose.ci.yml

Quality gates green: pnpm typecheck (7/7), pnpm test:unit (7/7),
pnpm lint (0 errors), check:exports/imports/architecture all pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:10:44 +02:00

276 lines
8.4 KiB
TypeScript

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@nexus.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);
});
});