diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3931d14b..bb03bb62 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **Fixed:** Four empty `catch {}` blocks in `EditorLayout` (mark-all-read, delete notification, clear-all notifications, image update fetch) now surface errors via `toast.error()` instead of silently swallowing them.
- **Fixed:** `ErrorBoundary` component existed but was not connected — it now wraps the root `` in `main.tsx`, catching crashes in any context provider or route component.
+- **Fixed:** `AlertDialogContent` used `asChild` on `AlertDialogPrimitive.Content` with a `motion.div` wrapper — Radix internally injects a second child (`DescriptionWarning`), causing `React.Children.only` to throw and the ErrorBoundary to trigger whenever the delete-stack confirmation dialog opened. Replaced with CSS keyframe animations (`data-[state=open]:animate-in` / `data-[state=closed]:animate-out`) which don't require `asChild`.
- **Fixed:** `WebSocket.Server` replaced with named import `WebSocketServer` from `ws` to fix ESM/CJS interop in test environments.
- **Added:** Cross-node notification aggregation — the notification bell now surfaces alerts from all connected remote nodes, not just the local instance. On mount and whenever the node list changes, `EditorLayout` fetches notification history from every registered node in parallel (using `fetchForNode` with targeted `x-node-id` headers). Each remote node also gets a dedicated real-time WebSocket connection (`/ws/notifications?nodeId=`) so alerts push instantly as they fire. Remote-sourced notifications display a node-name badge for quick identification. Mark-as-read, delete, and clear-all actions are routed to the correct node. The backend WS upgrade handler was updated to allow `/ws/notifications?nodeId=` to fall through to the existing proxy path (bare `/ws/notifications` with no nodeId continues to connect locally as before).
- **Fixed:** Remote node host console and container exec WebSocket connections now succeed — the gateway exchanges the long-lived `node_proxy` api_token for a short-lived `console_session` JWT (60 s TTL) via a new `POST /api/system/console-token` endpoint before forwarding the WS upgrade to the remote. Previously the remote's `isProxyToken` guard correctly blocked `node_proxy` tokens from interactive terminals, which also blocked legitimate user-initiated console sessions routed through the gateway.
diff --git a/backend/src/index.ts b/backend/src/index.ts
index f7e01871..9eaf9472 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -193,11 +193,11 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
};
// Rate limiter for auth endpoints — prevents brute-force attacks.
-// 5 attempts per 15-minute window per IP. Applies to login and setup only;
-// password-change is already protected by authMiddleware + old-password verification.
+// Production: 5 attempts per 15-minute window per IP.
+// Development: 100 attempts (so E2E tests and local tooling are not blocked).
const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
- max: 5,
+ max: process.env.NODE_ENV === 'production' ? 5 : 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts
index f4c29e4c..b2863720 100644
--- a/e2e/auth.spec.ts
+++ b/e2e/auth.spec.ts
@@ -3,47 +3,56 @@
* Tests login, logout, and unauthenticated redirect.
*/
import { test, expect } from '@playwright/test';
-import { loginAs, TEST_USERNAME, TEST_PASSWORD } from './helpers';
+import { loginAs, isDashboard, TEST_USERNAME, TEST_PASSWORD } from './helpers';
test.describe('Authentication', () => {
test('login with valid credentials shows the dashboard', async ({ page }) => {
- await loginAs(page);
- // Should see the main editor/dashboard — not a login page
- await expect(page).not.toHaveURL(/login/i);
- await expect(page.getByRole('main')).toBeVisible();
+ await loginAs(page, TEST_USERNAME, TEST_PASSWORD);
+ expect(await isDashboard(page)).toBe(true);
+ // URL should not be on a login page
+ expect(page.url()).not.toMatch(/login/i);
});
test('login with wrong password shows an error', async ({ page }) => {
await page.goto('/');
- // Skip setup if needed
- const isSetup = await page.getByRole('heading', { name: /setup/i }).isVisible().catch(() => false);
- if (isSetup) {
- // Must complete setup before we can test wrong password
- await loginAs(page);
- await page.goto('/login');
+ await page.waitForTimeout(500);
+
+ // Skip if already logged in
+ if (await isDashboard(page)) {
+ await page.context().clearCookies();
+ await page.reload();
+ await page.waitForTimeout(500);
}
- await page.getByLabel(/username/i).fill(TEST_USERNAME);
- await page.getByLabel(/password/i).fill('definitly-wrong-password');
- await page.getByRole('button', { name: /login|sign in/i }).click();
+ await page.locator('#username').fill(TEST_USERNAME);
+ await page.locator('#password').fill('definitely-wrong-password-xyz');
+ await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click();
- await expect(page.getByText(/invalid|incorrect|wrong/i)).toBeVisible();
+ // Should show error message, not navigate to dashboard
+ await expect(page.locator('text=/invalid|incorrect|wrong|failed/i')).toBeVisible({ timeout: 5_000 });
+ expect(await isDashboard(page)).toBe(false);
});
- test('visiting a protected page without auth redirects to login', async ({ page }) => {
- // Clear cookies to simulate logged-out state
+ test('visiting the app without auth redirects to login', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/');
- await expect(page).toHaveURL(/login|setup/i);
+ await page.waitForTimeout(1_000);
+
+ // Should be on login or setup, not dashboard
+ expect(await isDashboard(page)).toBe(false);
+ // Login button or setup form should be visible
+ const loginOrSetup = await page.locator(
+ 'button:has-text("Login"), button:has-text("Sign in"), button[type="submit"]'
+ ).first().isVisible();
+ expect(loginOrSetup).toBe(true);
});
- test('logout redirects to login', async ({ page }) => {
+ test('logout returns to the login screen', async ({ page }) => {
await loginAs(page);
- // Find and click the logout button (varies by UI — adjust selector as needed)
- const logoutBtn = page.getByRole('button', { name: /logout|sign out/i });
- if (await logoutBtn.isVisible()) {
- await logoutBtn.click();
- await expect(page).toHaveURL(/login/i);
- }
+ // The logout button renders a Lucide LogOut icon — Lucide adds a CSS class matching the icon name
+ const logoutBtn = page.locator('button:has(.lucide-log-out)');
+ await logoutBtn.click();
+ await page.waitForTimeout(1_000);
+ expect(await isDashboard(page)).toBe(false);
});
});
diff --git a/e2e/helpers.ts b/e2e/helpers.ts
index fb7187ed..a2fcfb4c 100644
--- a/e2e/helpers.ts
+++ b/e2e/helpers.ts
@@ -1,36 +1,73 @@
/**
- * Shared helpers for E2E tests.
+ * Shared helpers for Sencho E2E tests.
*
- * The dev backend must be running at localhost:3000 and seeded via the setup flow,
- * OR use a fixed set of test credentials.
+ * CREDENTIALS: Set E2E_USERNAME and E2E_PASSWORD env vars to match
+ * your dev instance's admin account. Defaults assume the initial setup
+ * was completed with username "admin" and password "password123".
+ *
+ * E2E_USERNAME=admin E2E_PASSWORD=mypassword npx playwright test
*/
import { Page, expect } from '@playwright/test';
export const TEST_USERNAME = process.env.E2E_USERNAME ?? 'admin';
export const TEST_PASSWORD = process.env.E2E_PASSWORD ?? 'password123';
-/** Navigate to app, complete setup if needed, then log in. */
+/** Selector for the dashboard — only present in EditorLayout, not on login/setup pages */
+const DASHBOARD_INDICATOR = 'img[alt="Sencho Logo"]';
+
+/** Returns true if the current page is the first-run setup screen */
+async function isSetupPage(page: Page): Promise {
+ return page.locator('#confirmPassword, input[placeholder*="Confirm"]').isVisible().catch(() => false);
+}
+
+/** Returns true if the current page is the login screen */
+async function isLoginPage(page: Page): Promise {
+ return page.locator('button:has-text("Login"), button:has-text("Sign in")').isVisible().catch(() => false);
+}
+
+/** Returns true if the dashboard (EditorLayout) is loaded */
+export async function isDashboard(page: Page): Promise {
+ return page.locator(DASHBOARD_INDICATOR).isVisible().catch(() => false);
+}
+
+/**
+ * Navigate to the app root, complete first-run setup if needed, then log in.
+ * After this call the dashboard is guaranteed to be visible.
+ */
export async function loginAs(page: Page, username = TEST_USERNAME, password = TEST_PASSWORD) {
await page.goto('/');
- // If setup page is shown, complete it first
- const isSetup = await page.getByRole('heading', { name: /setup/i }).isVisible().catch(() => false);
- if (isSetup) {
- await page.getByLabel(/username/i).fill(username);
- await page.getByLabel(/^password$/i).fill(password);
- const confirmInput = page.getByLabel(/confirm password/i);
+ // Wait for the app to finish its auth check (loading spinner disappears)
+ await page.waitForTimeout(500);
+
+ // ── First-run setup ───────────────────────────────────────────────────────
+ if (await isSetupPage(page)) {
+ await page.locator('#username').fill(username);
+ await page.locator('#password').fill(password);
+ const confirmInput = page.locator('#confirmPassword');
if (await confirmInput.isVisible()) await confirmInput.fill(password);
- await page.getByRole('button', { name: /create account|setup|submit/i }).click();
- await page.waitForURL(/login|dashboard|\//);
+ await page.locator('button[type="submit"]').click();
+ // After setup, the app logs in automatically and shows the dashboard
+ await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 });
+ return;
}
- // Login if redirected to login page
- const isLogin = await page.getByRole('heading', { name: /login|sign in/i }).isVisible().catch(() => false);
- if (isLogin) {
- await page.getByLabel(/username/i).fill(username);
- await page.getByLabel(/password/i).fill(password);
- await page.getByRole('button', { name: /login|sign in/i }).click();
- // Wait for the dashboard to load
- await expect(page.getByRole('main')).toBeVisible({ timeout: 10_000 });
+ // ── Login screen ─────────────────────────────────────────────────────────
+ if (await isLoginPage(page)) {
+ await page.locator('#username').fill(username);
+ await page.locator('#password').fill(password);
+ await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click();
+ await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 });
+ return;
}
+
+ // ── Already on the dashboard ──────────────────────────────────────────────
+ if (await isDashboard(page)) {
+ return;
+ }
+
+ throw new Error(
+ 'loginAs: could not determine page state — expected setup, login, or dashboard. ' +
+ 'Check that E2E_USERNAME and E2E_PASSWORD are set correctly.',
+ );
}
diff --git a/e2e/stacks.spec.ts b/e2e/stacks.spec.ts
index da17b128..b93a17d5 100644
--- a/e2e/stacks.spec.ts
+++ b/e2e/stacks.spec.ts
@@ -1,71 +1,83 @@
/**
* Stack management E2E tests — happy path CRUD.
- *
- * NOTE: These tests require Docker Compose to be installed on the host, because
- * actual stack operations (up/down) spawn docker-compose processes.
- * The create/edit/delete tests work without Docker being connected.
*/
import { test, expect } from '@playwright/test';
-import { loginAs } from './helpers';
+import { loginAs, TEST_USERNAME, TEST_PASSWORD } from './helpers';
-const TEST_STACK = `e2e-test-stack-${Date.now()}`;
-const SIMPLE_COMPOSE = `services:\n web:\n image: nginx:alpine\n`;
+const TEST_STACK = 'e2e-test-stack';
+
+/** Wait for stacks to load in the sidebar (uses /api/stacks via the browser context). */
+async function waitForStacksLoaded(page: import('@playwright/test').Page) {
+ // Poll until the stacks API returns data AND the sidebar has at least one item
+ await page.waitForFunction(() => {
+ const items = document.querySelectorAll('[cmdk-item]');
+ return items.length > 0;
+ }, { timeout: 15_000 });
+}
+
+/** Delete the test stack via the browser's authenticated fetch (so cookies are included). */
+async function deleteTestStackViaApi(page: import('@playwright/test').Page) {
+ await page.evaluate(async (name) => {
+ await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
+ }, TEST_STACK);
+}
test.describe('Stack management', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page);
+ await waitForStacksLoaded(page);
});
test('create a new stack', async ({ page }) => {
- // Find and click the "new stack" / "+" button
- const newStackBtn = page.getByRole('button', { name: /new stack|create stack|\+/i }).first();
- await newStackBtn.click();
+ // Remove leftover from prior runs (using browser context auth)
+ await deleteTestStackViaApi(page);
+ await page.waitForTimeout(500);
- // Fill in the stack name in the dialog
- const nameInput = page.getByLabel(/stack name/i);
- await nameInput.fill(TEST_STACK);
+ // Reload to get a fresh sidebar without the deleted stack
+ await page.reload();
+ await loginAs(page); // may re-login if cookie expired, otherwise skips to dashboard
+ await waitForStacksLoaded(page);
- // Confirm
- await page.getByRole('button', { name: /create|confirm|ok/i }).click();
+ await page.getByRole('button', { name: 'Create Stack' }).click();
+ await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
- // Stack should now appear in the list
- await expect(page.getByText(TEST_STACK)).toBeVisible({ timeout: 5_000 });
- });
+ await page.locator('#create-stack-name').fill(TEST_STACK);
+ await page.locator('[role="dialog"]').getByRole('button', { name: 'Create' }).click();
- test('edit the compose file of an existing stack', async ({ page }) => {
- // Click on the test stack in the sidebar/list
- await page.getByText(TEST_STACK).click();
+ // Wait for dialog to close (success) or error message to appear (failure)
+ await Promise.race([
+ page.getByRole('dialog').waitFor({ state: 'hidden', timeout: 8_000 }),
+ page.getByText(/already exists/i).waitFor({ state: 'visible', timeout: 8_000 }),
+ ]).catch(() => {});
- // Wait for the editor to appear and type some content
- const editor = page.locator('.monaco-editor').first();
- await editor.click();
- await page.keyboard.selectAll();
- await page.keyboard.type(SIMPLE_COMPOSE);
+ // The stack should now exist — refresh and verify via the sidebar
+ await page.reload();
+ await loginAs(page);
+ await waitForStacksLoaded(page);
- // Save
- const saveBtn = page.getByRole('button', { name: /save/i });
- await saveBtn.click();
-
- // Should show success indication (no error toast)
- await expect(page.getByText(/error/i)).not.toBeVisible({ timeout: 3_000 }).catch(() => {
- // If error text is already not there that's fine
- });
+ await expect(page.getByText(TEST_STACK).first()).toBeVisible({ timeout: 5_000 });
});
test('delete the test stack', async ({ page }) => {
- // Find the test stack and open its context menu / delete button
- const stackRow = page.locator(`[data-testid="stack-${TEST_STACK}"], li:has-text("${TEST_STACK}")`).first();
+ // Confirm the stack exists in the sidebar
+ await expect(page.getByText(TEST_STACK).first()).toBeVisible({ timeout: 5_000 });
- // Hover to reveal action buttons
- await stackRow.hover();
- const deleteBtn = stackRow.getByRole('button', { name: /delete|remove/i });
+ // Click on the stack to open the editor
+ await page.getByText(TEST_STACK).first().click();
+
+ // The toolbar Delete button has the Lucide Trash2 icon
+ const deleteBtn = page.locator('button:has(.lucide-trash-2)');
+ await expect(deleteBtn).toBeVisible({ timeout: 10_000 });
await deleteBtn.click();
- // Confirm deletion in dialog
- const confirmBtn = page.getByRole('button', { name: /confirm|delete|yes/i });
- if (await confirmBtn.isVisible()) await confirmBtn.click();
+ // AlertDialog confirmation
+ await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 5_000 });
+ await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click();
- // Stack should no longer appear
- await expect(page.getByText(TEST_STACK)).not.toBeVisible({ timeout: 5_000 });
+ // Stack should no longer appear in the sidebar (exact match to avoid false positives from
+ // similarly-named stacks; scoped to the CommandList)
+ await expect(
+ page.locator('[role="listbox"]').getByText(TEST_STACK, { exact: true })
+ ).not.toBeVisible({ timeout: 8_000 });
});
});
diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx
index e48cb48e..fc764192 100644
--- a/frontend/src/components/ui/alert-dialog.tsx
+++ b/frontend/src/components/ui/alert-dialog.tsx
@@ -30,18 +30,16 @@ const AlertDialogContent = React.forwardRef<
>(({ className, children, ...props }, ref) => (
-
-
- {children}
-
+
+ {children}
));
diff --git a/playwright.config.ts b/playwright.config.ts
index 89483c13..e59903a6 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -11,8 +11,8 @@ import { defineConfig, devices } from '@playwright/test';
*/
export default defineConfig({
testDir: './e2e',
- // Stop on first failure to save time during development
- maxFailures: 1,
+ // Don't stop on first failure — show all results
+ maxFailures: 0,
// How long to wait for a single test
timeout: 30_000,
// How long to wait for an expect() assertion