Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions e2e/setup/global-setup.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { request } from "@playwright/test";

/**
* Playwright global setup: ensures a test user exists and the app is seeded.
*
* The dev server (PGlite) auto-seeds via `pnpm dev`, so we just need to
* create a test user account for authenticated tests.
* Playwright global setup: creates the first-user account. Individual tests
* opt into authentication with the login helpers so unauthenticated API
* coverage stays meaningful.
*/
async function globalSetup() {
const baseURL = process.env.BASE_URL ?? "http://localhost:3000";
const baseURL = process.env.BASE_URL ?? "http://127.0.0.1:3100";
const api = await request.newContext({ baseURL });

// Wait for the app to be ready (healthcheck via auth status)
Expand All @@ -26,7 +25,8 @@ async function globalSetup() {
}
if (!ready) throw new Error("App not ready after 20s");

// Create a test user (first user gets super_admin role)
// Create a test user. The e2e web server starts with a freshly seeded DB, so
// this is the first user and receives the super_admin role.
const signupRes = await api.post("/api/auth/signup", {
data: {
name: "E2E Test User",
Expand All @@ -38,7 +38,7 @@ async function globalSetup() {
// 200 = created, 409 = already exists — both fine
if (!signupRes.ok() && signupRes.status() !== 409) {
const body = await signupRes.text();
console.warn(`[global-setup] Signup response ${signupRes.status()}: ${body}`);
throw new Error(`[global-setup] Signup response ${signupRes.status()}: ${body}`);
}

await api.dispose();
Expand Down
19 changes: 15 additions & 4 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { defineConfig } from "@playwright/test";

const e2ePort = process.env.E2E_PORT ?? "3100";
const e2eBaseURL = process.env.BASE_URL ?? `http://127.0.0.1:${e2ePort}`;
const e2eDataDir = process.env.CREWCMD_PGLITE_DATA_DIR ?? ".data/e2e-pglite";

export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
Expand All @@ -8,7 +12,7 @@ export default defineConfig({
workers: 1,
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: "http://localhost:3000",
baseURL: e2eBaseURL,
trace: "on-first-retry",
},
projects: [
Expand All @@ -19,9 +23,16 @@ export default defineConfig({
],
globalSetup: "./e2e/setup/global-setup.ts",
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
command: `rm -rf ${e2eDataDir} && (pnpm db:seed || test "$?" = "100") && pnpm dev`,
url: e2eBaseURL,
reuseExistingServer: false,
timeout: 30_000,
env: {
...process.env,
PORT: e2ePort,
BASE_URL: e2eBaseURL,
NEXT_PUBLIC_APP_URL: e2eBaseURL,
CREWCMD_PGLITE_DATA_DIR: e2eDataDir,
},
},
});
24 changes: 21 additions & 3 deletions src/app/api/agents/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@ import {

export const dynamic = "force-dynamic";

function isUniqueViolation(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const cause = error instanceof Error ? error.cause : null;
const causeRecord = cause && typeof cause === "object"
? cause as Record<string, unknown>
: {};
const causeMessage = cause instanceof Error ? cause.message : String(cause ?? "");

return (
message.includes("unique") ||
message.includes("duplicate") ||
causeMessage.includes("unique") ||
causeMessage.includes("duplicate") ||
causeRecord.code === "23505" ||
causeRecord.constraint === "agents_callsign_unique"
);
}

export async function GET(request: NextRequest) {
if (!db) {
return NextResponse.json({ agents: [], source: "none" });
Expand Down Expand Up @@ -268,11 +286,11 @@ export async function POST(request: NextRequest) {

return NextResponse.json(created, { status: 201 });
} catch (err) {
console.error("[api/agents] POST Error:", err);
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("unique") || msg.includes("duplicate")) {
if (isUniqueViolation(err)) {
return NextResponse.json({ error: "An agent with that callsign already exists" }, { status: 409 });
}
console.error("[api/agents] POST Error:", err);
const msg = err instanceof Error ? err.message : String(err);
if (err instanceof Error && err.name === "PolicyViolation") {
return NextResponse.json({ error: msg }, { status: 403 });
}
Expand Down
27 changes: 22 additions & 5 deletions src/app/api/tasks/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { db } from "@/db";
import * as schema from "@/db/schema";
import { requireAuth } from "@/lib/require-auth";
Expand Down Expand Up @@ -31,6 +31,23 @@ function getOperatingRolePack(agent: typeof schema.agents.$inferSelect | undefin
return typeof rolePack === "string" ? (rolePack as CrewCmdRolePack) : null;
}

function isUuid(value: string) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}

async function resolveAssignedAgent(agentRef: string) {
const whereClause = isUuid(agentRef)
? eq(schema.agents.id, agentRef)
: sql`lower(${schema.agents.callsign}) = ${agentRef.toLowerCase()}`;

const [agent] = await db!
.select()
.from(schema.agents)
.where(whereClause)
.limit(1);
return agent ?? null;
}

export async function GET(
_request: NextRequest,
{ params }: RouteParams
Expand Down Expand Up @@ -119,9 +136,9 @@ export async function PATCH(
}

const nextAssignedAgentId = body.assignedAgentId ?? oldTask.assignedAgentId ?? null;
const [assignedAgent] = nextAssignedAgentId
? await db.select().from(schema.agents).where(eq(schema.agents.id, nextAssignedAgentId)).limit(1)
: [null];
const assignedAgent = nextAssignedAgentId
? await resolveAssignedAgent(nextAssignedAgentId)
: null;
const rolePack = getOperatingRolePack(assignedAgent ?? undefined);
const nextPrUrl = body.prUrl ?? oldTask.prUrl ?? null;

Expand All @@ -131,7 +148,7 @@ export async function PATCH(
(targetStatus === "review" || targetStatus === "done") &&
nextAssignedAgentId
) {
const validationResult = await verifyTaskCompletion(id, nextAssignedAgentId);
const validationResult = await verifyTaskCompletion(id, assignedAgent?.id ?? null);
if (!validationResult.valid) {
const rejection = evaluateSupervisorRejection(validationResult, targetStatus);
if (rejection.rejected) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/tasks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export async function POST(request: NextRequest) {
)
);
if (existing) {
return NextResponse.json({ existing }, { status: 409 });
return NextResponse.json({ existing, existingTask: existing }, { status: 409 });
}
}

Expand Down
Loading
Loading