rename(phase 3): compose/DB/infra + stray code refs capakraken → nexus (#62)
CI / Lint (push) Successful in 3m4s
CI / Typecheck (push) Successful in 3m6s
CI / Architecture Guardrails (push) Successful in 3m8s
CI / Assistant Split Regression (push) Successful in 3m48s
CI / Build (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Lint (push) Successful in 3m4s
CI / Typecheck (push) Successful in 3m6s
CI / Architecture Guardrails (push) Successful in 3m8s
CI / Assistant Split Regression (push) Successful in 3m48s
CI / Build (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 3): compose/DB/infra + stray code refs capakraken → nexus (#62) Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
This commit was merged in pull request #62.
This commit is contained in:
@@ -101,7 +101,7 @@ test.describe("Assistant approvals", () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((conversationId) => {
|
||||
window.sessionStorage.setItem("capakraken-chat-conversation-id", conversationId);
|
||||
window.sessionStorage.setItem("nexus-chat-conversation-id", conversationId);
|
||||
}, CURRENT_CONVERSATION_ID);
|
||||
|
||||
runDb(`
|
||||
|
||||
@@ -42,9 +42,9 @@ test.describe("Auth — login / logout", () => {
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 5000 });
|
||||
// Error message visible
|
||||
await expect(
|
||||
page.locator("text=/invalid|incorrect|wrong|credentials/i"),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator("text=/invalid|incorrect|wrong|credentials/i")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
test("after logout, protected routes redirect to sign-in", async ({ page }) => {
|
||||
@@ -75,7 +75,7 @@ test.describe("Session registry — no tRPC 401s after login", () => {
|
||||
|
||||
// At least one user row should be visible
|
||||
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first()).toBeVisible({
|
||||
await expect(page.locator("text=/planarchy\\.dev|nexus\\.dev/").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.locator("text=No users found")).toHaveCount(0);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* - Creates a temporary test user via tRPC (admin session) for isolation.
|
||||
* - Cleans up the test user in afterAll.
|
||||
* - Uses an empty storageState to ensure no cross-user localStorage bleed.
|
||||
* - localStorage key is user-scoped: "capakraken_dashboard_v1_{userId}".
|
||||
* - localStorage key is user-scoped: "nexus_dashboard_v1_{userId}".
|
||||
*/
|
||||
|
||||
import { expect, test, type Browser, type Page } from "@playwright/test";
|
||||
@@ -20,9 +20,16 @@ import { STORAGE_STATE } from "../../playwright.dev.config.js";
|
||||
|
||||
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
type TrpcResult = { result?: { data?: unknown }; error?: { data?: { code?: string }; message?: string } };
|
||||
type TrpcResult = {
|
||||
result?: { data?: unknown };
|
||||
error?: { data?: { code?: string }; message?: string };
|
||||
};
|
||||
|
||||
async function trpcMutation(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
||||
async function trpcMutation(
|
||||
page: Page,
|
||||
procedure: string,
|
||||
input: unknown = null,
|
||||
): Promise<TrpcResult> {
|
||||
return page.evaluate(
|
||||
async ({ procedure, input }) => {
|
||||
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
|
||||
@@ -38,7 +45,11 @@ async function trpcMutation(page: Page, procedure: string, input: unknown = null
|
||||
);
|
||||
}
|
||||
|
||||
async function trpcQuery(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
||||
async function trpcQuery(
|
||||
page: Page,
|
||||
procedure: string,
|
||||
input: unknown = null,
|
||||
): Promise<TrpcResult> {
|
||||
return page.evaluate(
|
||||
async ({ procedure, input }) => {
|
||||
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
|
||||
@@ -128,7 +139,9 @@ test.describe("Dashboard — widget management", () => {
|
||||
|
||||
// Default layout should show at least the stat-cards widget
|
||||
// (from createDefaultDashboardLayout in useDashboardLayout)
|
||||
await expect(page.locator('[data-testid="widget-stat-cards"], .react-grid-item').first()).toBeVisible({
|
||||
await expect(
|
||||
page.locator('[data-testid="widget-stat-cards"], .react-grid-item').first(),
|
||||
).toBeVisible({
|
||||
timeout: 8000,
|
||||
});
|
||||
});
|
||||
@@ -138,16 +151,21 @@ test.describe("Dashboard — widget management", () => {
|
||||
await navigateToDashboard(page);
|
||||
|
||||
// Open modal
|
||||
await page.getByRole("button", { name: /add widget/i }).first().click();
|
||||
await page
|
||||
.getByRole("button", { name: /add widget/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Verify modal is open
|
||||
await expect(page.getByRole("heading", { name: /add widget/i })).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByRole("heading", { name: /add widget/i })).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Verify widget entries are visible in the modal
|
||||
// The catalog has 11 widgets; check for at least 5 visible buttons inside the modal
|
||||
const widgetButtons = page.locator(
|
||||
'[role="dialog"] button, .fixed button[type="button"]',
|
||||
).filter({ hasText: /./ });
|
||||
const widgetButtons = page
|
||||
.locator('[role="dialog"] button, .fixed button[type="button"]')
|
||||
.filter({ hasText: /./ });
|
||||
|
||||
// Count items in the grid (the ×-close button is excluded by checking for icon content)
|
||||
const modalContent = page.locator(".fixed.inset-0 .grid");
|
||||
@@ -166,10 +184,16 @@ test.describe("Dashboard — widget management", () => {
|
||||
const initialCount = await page.locator(".react-grid-item").count();
|
||||
|
||||
// Open modal and add "Resource Table" widget
|
||||
await page.getByRole("button", { name: /add widget/i }).first().click();
|
||||
await page
|
||||
.getByRole("button", { name: /add widget/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await page.locator(".fixed.inset-0 button").filter({ hasText: /resource table/i }).click();
|
||||
await page
|
||||
.locator(".fixed.inset-0 button")
|
||||
.filter({ hasText: /resource table/i })
|
||||
.click();
|
||||
|
||||
// Modal should close after adding
|
||||
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
|
||||
@@ -184,9 +208,15 @@ test.describe("Dashboard — widget management", () => {
|
||||
await navigateToDashboard(page);
|
||||
|
||||
// Add a recognizable widget
|
||||
await page.getByRole("button", { name: /add widget/i }).first().click();
|
||||
await page
|
||||
.getByRole("button", { name: /add widget/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
|
||||
await page.locator(".fixed.inset-0 button").filter({ hasText: /project overview/i }).click();
|
||||
await page
|
||||
.locator(".fixed.inset-0 button")
|
||||
.filter({ hasText: /project overview/i })
|
||||
.click();
|
||||
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
const countAfterAdd = await page.locator(".react-grid-item").count();
|
||||
@@ -214,19 +244,23 @@ test.describe("Dashboard — widget management", () => {
|
||||
|
||||
// Read the admin's localStorage key to verify it is user-scoped
|
||||
const adminUserId = await adminPage.evaluate(async () => {
|
||||
const res = await fetch("/api/trpc/user.me?batch=1&input=" + encodeURIComponent(JSON.stringify({ "0": { json: null } })), {
|
||||
credentials: "include",
|
||||
});
|
||||
const body = await res.json() as [{ result?: { data?: { json?: { id?: string } } } }];
|
||||
const res = await fetch(
|
||||
"/api/trpc/user.me?batch=1&input=" +
|
||||
encodeURIComponent(JSON.stringify({ "0": { json: null } })),
|
||||
{
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
const body = (await res.json()) as [{ result?: { data?: { json?: { id?: string } } } }];
|
||||
return body[0]?.result?.data?.json?.id ?? null;
|
||||
});
|
||||
|
||||
// Verify admin has a user-scoped storage key (not shared "capakraken_dashboard_v1")
|
||||
// Verify admin has a user-scoped storage key (not shared "nexus_dashboard_v1")
|
||||
if (adminUserId) {
|
||||
const storageKey = await adminPage.evaluate((userId) => {
|
||||
// Check both old (unscoped) and new (user-scoped) key formats
|
||||
const oldKey = "capakraken_dashboard_v1";
|
||||
const newKey = `capakraken_dashboard_v1_${userId}`;
|
||||
const oldKey = "nexus_dashboard_v1";
|
||||
const newKey = `nexus_dashboard_v1_${userId}`;
|
||||
const oldValue = localStorage.getItem(oldKey);
|
||||
const newValue = localStorage.getItem(newKey);
|
||||
return { oldKey: oldValue !== null, newKey: newValue !== null };
|
||||
@@ -244,8 +278,13 @@ test.describe("Dashboard — widget management", () => {
|
||||
|
||||
// Inject the admin's storage key to simulate same browser
|
||||
await newUserPage.evaluate(
|
||||
({ key, value }) => { localStorage.setItem(key, value ?? ""); },
|
||||
{ key: `capakraken_dashboard_v1_${adminUserId}`, value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }) },
|
||||
({ key, value }) => {
|
||||
localStorage.setItem(key, value ?? "");
|
||||
},
|
||||
{
|
||||
key: `nexus_dashboard_v1_${adminUserId}`,
|
||||
value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }),
|
||||
},
|
||||
);
|
||||
|
||||
// Log in as test user
|
||||
@@ -262,7 +301,10 @@ test.describe("Dashboard — widget management", () => {
|
||||
const gridItems = await newUserPage.locator(".react-grid-item").count();
|
||||
// Either show default layout (≥1 widget) OR the properly-scoped empty state with Add Widget CTA
|
||||
// The key check: the test user's Add Widget button should still work
|
||||
await newUserPage.getByRole("button", { name: /add widget/i }).first().click();
|
||||
await newUserPage
|
||||
.getByRole("button", { name: /add widget/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Modal must show widgets to choose from
|
||||
const modalContent = newUserPage.locator(".fixed.inset-0 .grid");
|
||||
|
||||
@@ -25,9 +25,9 @@ const RESET_TEST_USER = {
|
||||
password: "Dev123456!",
|
||||
};
|
||||
|
||||
const DB_CONTAINER = "capakraken-postgres-1";
|
||||
const DB_USER = "capakraken";
|
||||
const DB_NAME = "capakraken";
|
||||
const DB_CONTAINER = "nexus-postgres-1";
|
||||
const DB_USER = "nexus";
|
||||
const DB_NAME = "nexus";
|
||||
|
||||
function psqlExec(sql: string): string {
|
||||
return execSync(
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function signOut(page: Page) {
|
||||
await page.goto("/dashboard"); // land on any authenticated page for cookie context
|
||||
await page.evaluate(async () => {
|
||||
const csrfRes = await fetch("/api/auth/csrf");
|
||||
const { csrfToken } = await csrfRes.json() as { csrfToken: string };
|
||||
const { csrfToken } = (await csrfRes.json()) as { csrfToken: string };
|
||||
await fetch("/api/auth/signout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
@@ -62,11 +62,9 @@ function decodeMimeBody(body: string, encoding: string | undefined): string {
|
||||
const enc = (encoding ?? "").toLowerCase().trim();
|
||||
if (enc === "quoted-printable") {
|
||||
return body
|
||||
.replace(/=\r\n/g, "") // soft line break (CRLF)
|
||||
.replace(/=\n/g, "") // soft line break (LF)
|
||||
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) =>
|
||||
String.fromCharCode(parseInt(hex, 16)),
|
||||
);
|
||||
.replace(/=\r\n/g, "") // soft line break (CRLF)
|
||||
.replace(/=\n/g, "") // soft line break (LF)
|
||||
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
|
||||
}
|
||||
if (enc === "base64") {
|
||||
return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8");
|
||||
@@ -90,7 +88,10 @@ export async function clearMailhog(): Promise<void> {
|
||||
*/
|
||||
export async function getLatestEmailTo(
|
||||
address: string,
|
||||
{ timeoutMs = 10_000, pollIntervalMs = 500 }: { timeoutMs?: number; pollIntervalMs?: number } = {},
|
||||
{
|
||||
timeoutMs = 10_000,
|
||||
pollIntervalMs = 500,
|
||||
}: { timeoutMs?: number; pollIntervalMs?: number } = {},
|
||||
): Promise<{ subject: string; body: string; html: string }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
@@ -144,7 +145,9 @@ export function extractUrlFromEmail(
|
||||
pathPrefix: string,
|
||||
): string {
|
||||
const text = email.html || email.body;
|
||||
const match = text.match(new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`));
|
||||
const match = text.match(
|
||||
new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`),
|
||||
);
|
||||
if (!match?.[0]) {
|
||||
throw new Error(`No URL with prefix "${pathPrefix}" found in email`);
|
||||
}
|
||||
@@ -166,10 +169,10 @@ export async function resetPasswordViaApi(
|
||||
// argon2id hashes use base64 chars only — safe inside a SQL single-quoted string
|
||||
// Column name is camelCase (Prisma default) — must be double-quoted in SQL
|
||||
const sql = `UPDATE users SET "passwordHash" = '${passwordHash}' WHERE email = '${email}';`;
|
||||
execSync(
|
||||
`docker exec -i capakraken-postgres-1 psql -U capakraken -d capakraken`,
|
||||
{ input: sql, encoding: "utf8" },
|
||||
);
|
||||
execSync(`docker exec -i nexus-postgres-1 psql -U nexus -d nexus`, {
|
||||
input: sql,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
// ── tRPC helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -27,7 +27,7 @@ test.describe("invite flow", () => {
|
||||
});
|
||||
|
||||
test("admin invites a new user and invited user can sign in", async ({ page, browser }) => {
|
||||
const testEmail = `invite-e2e-${Date.now()}@capakraken.test`;
|
||||
const testEmail = `invite-e2e-${Date.now()}@nexus.test`;
|
||||
|
||||
// Step 1: Navigate to admin users page
|
||||
await page.goto("/admin/users");
|
||||
@@ -36,7 +36,7 @@ test.describe("invite flow", () => {
|
||||
// Step 2: Open invite modal
|
||||
await page.click('button:has-text("Invite User")');
|
||||
// Wait for the modal heading — AnimatedModal does not use role="dialog"
|
||||
await page.waitForSelector('text=Invite User', { state: "visible" });
|
||||
await page.waitForSelector("text=Invite User", { state: "visible" });
|
||||
|
||||
// Step 3: Fill in invite form
|
||||
await page.fill('input[type="email"]', testEmail);
|
||||
@@ -45,7 +45,9 @@ test.describe("invite flow", () => {
|
||||
await page.click('button:has-text("Send Invite")');
|
||||
|
||||
// Step 5: Wait for success message (exact text from InviteUserModal.tsx)
|
||||
await expect(page.locator("text=Invitation sent successfully.")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator("text=Invitation sent successfully.")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Step 6: Read invite email from Mailhog
|
||||
const email = await getLatestEmailTo(testEmail, { timeoutMs: 15_000 });
|
||||
|
||||
@@ -28,7 +28,7 @@ test.describe("RBAC — admin routes (admin session)", () => {
|
||||
|
||||
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
||||
// Seed users have planarchy.dev or nexus.dev email domains
|
||||
await expect(page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first()).toBeVisible({
|
||||
await expect(page.locator("text=/planarchy\\.dev|nexus\\.dev/").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,9 +16,9 @@ const webDistDirPath = resolve(webRoot, webDistDir);
|
||||
const managedEnvBanner = "# Managed by apps/web/e2e/test-server.mjs";
|
||||
const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110";
|
||||
const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`;
|
||||
const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `capakraken-e2e-${randomBytes(24).toString("hex")}`;
|
||||
const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `nexus-e2e-${randomBytes(24).toString("hex")}`;
|
||||
const manageWebEnvFile = process.env.PLAYWRIGHT_MANAGE_WEB_ENV_FILE === "true";
|
||||
const composeProjectName = `capakraken-e2e-${process.pid}`;
|
||||
const composeProjectName = `nexus-e2e-${process.pid}`;
|
||||
const managedEnvKeys = [
|
||||
"DATABASE_URL",
|
||||
"REDIS_URL",
|
||||
@@ -29,7 +29,7 @@ const managedEnvKeys = [
|
||||
"NODE_ENV",
|
||||
"PORT",
|
||||
];
|
||||
const e2eComposePrefix = "capakraken-e2e-";
|
||||
const e2eComposePrefix = "nexus-e2e-";
|
||||
|
||||
function dockerComposeArgs(...args) {
|
||||
return ["compose", "-p", composeProjectName, ...args];
|
||||
@@ -256,7 +256,7 @@ async function ensureE2eDatabaseContainer() {
|
||||
try {
|
||||
await runQuiet(
|
||||
"docker",
|
||||
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "capakraken", "-d", "capakraken_test", "-q"),
|
||||
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "nexus", "-d", "nexus_test", "-q"),
|
||||
workspaceRoot,
|
||||
);
|
||||
return;
|
||||
@@ -360,7 +360,7 @@ process.env.PLAYWRIGHT_DATABASE_URL = playwrightDatabaseUrl;
|
||||
if (selectedTestDbPort !== undefined) {
|
||||
process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort);
|
||||
}
|
||||
process.env.CAPAKRAKEN_EXPECTED_DB_NAME = playwrightDatabaseName;
|
||||
process.env.NEXUS_EXPECTED_DB_NAME = playwrightDatabaseName;
|
||||
process.env.ALLOW_DESTRUCTIVE_DB_TOOLS = "true";
|
||||
process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName;
|
||||
process.env.NODE_ENV = process.env.NODE_ENV ?? "development";
|
||||
|
||||
@@ -856,10 +856,10 @@ async function switchToResourceView(page: Page, readySelector?: string) {
|
||||
|
||||
async function ensureOpenDemandVisibilityEnabled(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem("capakraken_prefs");
|
||||
const raw = window.localStorage.getItem("nexus_prefs");
|
||||
const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||
window.localStorage.setItem(
|
||||
"capakraken_prefs",
|
||||
"nexus_prefs",
|
||||
JSON.stringify({
|
||||
...parsed,
|
||||
showDemandProjects: true,
|
||||
@@ -874,9 +874,9 @@ test.describe("Timeline", () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" }));
|
||||
localStorage.setItem("nexus_theme", JSON.stringify({ mode: "dark" }));
|
||||
localStorage.setItem(
|
||||
"capakraken_prefs",
|
||||
"nexus_prefs",
|
||||
JSON.stringify({
|
||||
hideCompletedProjects: true,
|
||||
timelineDisplayMode: "strip",
|
||||
|
||||
Reference in New Issue
Block a user