diff --git a/backend/src/__tests__/stacks-failure-notifications.test.ts b/backend/src/__tests__/stacks-failure-notifications.test.ts new file mode 100644 index 00000000..0c15dbe0 --- /dev/null +++ b/backend/src/__tests__/stacks-failure-notifications.test.ts @@ -0,0 +1,232 @@ +/** + * Integration tests verifying that deploy_failure notifications are dispatched + * when stack action routes encounter errors. + * + * Covers: deploy, down, restart, stop, update + * + * ComposeService and DockerController are mocked so no real Docker daemon is + * required. NotificationService.dispatchAlert is spied on to assert dispatch. + */ +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +// ── Hoisted mocks (must come before importing the app) ────────────────────── + +const { + mockDeployStack, + mockRunCommand, + mockUpdateStack, + mockGetContainersByStack, + mockRestartContainer, + mockStopContainer, +} = vi.hoisted(() => ({ + mockDeployStack: vi.fn(), + mockRunCommand: vi.fn(), + mockUpdateStack: vi.fn(), + mockGetContainersByStack: vi.fn(), + mockRestartContainer: vi.fn(), + mockStopContainer: vi.fn(), +})); + +vi.mock('../services/ComposeService', async () => { + const actual = await vi.importActual( + '../services/ComposeService', + ); + return { + ...actual, + ComposeService: { + ...actual.ComposeService, + getInstance: () => ({ + deployStack: mockDeployStack, + runCommand: mockRunCommand, + updateStack: mockUpdateStack, + }), + }, + }; +}); + +vi.mock('../services/DockerController', async () => { + const actual = await vi.importActual( + '../services/DockerController', + ); + return { + ...actual, + default: { + ...actual.default, + getInstance: () => ({ + getContainersByStack: mockGetContainersByStack, + restartContainer: mockRestartContainer, + stopContainer: mockStopContainer, + }), + }, + }; +}); + +vi.mock('../services/FileSystemService', () => ({ + FileSystemService: { + getInstance: () => ({ + getStacks: vi.fn().mockResolvedValue([]), + getBaseDir: () => '/tmp/compose', + readComposeFile: vi.fn().mockResolvedValue(''), + }), + }, +})); + +// ── Setup ─────────────────────────────────────────────────────────────────── + +let tmpDir: string; +let app: import('express').Express; +let authCookie: string; +let dispatchAlertSpy: ReturnType; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + authCookie = await loginAsTestAdmin(app); + + const { NotificationService } = await import('../services/NotificationService'); + dispatchAlertSpy = vi + .spyOn(NotificationService.getInstance(), 'dispatchAlert') + .mockResolvedValue(undefined); +}); + +afterAll(() => { + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + mockDeployStack.mockReset(); + mockRunCommand.mockReset(); + mockUpdateStack.mockReset(); + mockGetContainersByStack.mockReset(); + mockRestartContainer.mockReset(); + mockStopContainer.mockReset(); + dispatchAlertSpy.mockClear(); +}); + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('deploy_failure notification on /deploy error', () => { + it('dispatches deploy_failure alert with correct stackName when deployStack throws', async () => { + mockDeployStack.mockRejectedValue(new Error('image pull failed')); + + const res = await request(app) + .post('/api/stacks/myapp/deploy') + .set('Cookie', authCookie); + + expect(res.status).toBe(500); + + await new Promise(resolve => setImmediate(resolve)); + + expect(dispatchAlertSpy).toHaveBeenCalledWith( + 'error', + 'deploy_failure', + expect.stringContaining('image pull failed'), + { stackName: 'myapp' }, + ); + }); + + it('includes the error message in the dispatched alert', async () => { + mockDeployStack.mockRejectedValue(new Error('network timeout')); + + await request(app) + .post('/api/stacks/webapp/deploy') + .set('Cookie', authCookie); + + await new Promise(resolve => setImmediate(resolve)); + + const call = dispatchAlertSpy.mock.calls[0]; + expect(call[0]).toBe('error'); + expect(call[1]).toBe('deploy_failure'); + expect(call[2]).toContain('network timeout'); + expect(call[3]).toEqual({ stackName: 'webapp' }); + }); +}); + +describe('deploy_failure notification on /down error', () => { + it('dispatches deploy_failure alert when runCommand (down) throws', async () => { + mockRunCommand.mockRejectedValue(new Error('container removal error')); + + const res = await request(app) + .post('/api/stacks/myapp/down') + .set('Cookie', authCookie); + + expect(res.status).toBe(500); + + await new Promise(resolve => setImmediate(resolve)); + + expect(dispatchAlertSpy).toHaveBeenCalledWith( + 'error', + 'deploy_failure', + expect.any(String), + { stackName: 'myapp' }, + ); + }); +}); + +describe('deploy_failure notification on /restart error', () => { + it('dispatches deploy_failure alert when restartContainer throws', async () => { + mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]); + mockRestartContainer.mockRejectedValue(new Error('restart daemon error')); + + const res = await request(app) + .post('/api/stacks/myapp/restart') + .set('Cookie', authCookie); + + expect(res.status).toBe(500); + + await new Promise(resolve => setImmediate(resolve)); + + expect(dispatchAlertSpy).toHaveBeenCalledWith( + 'error', + 'deploy_failure', + expect.stringContaining('restart daemon error'), + { stackName: 'myapp' }, + ); + }); +}); + +describe('deploy_failure notification on /stop error', () => { + it('dispatches deploy_failure alert when stopContainer throws', async () => { + mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]); + mockStopContainer.mockRejectedValue(new Error('stop daemon error')); + + const res = await request(app) + .post('/api/stacks/myapp/stop') + .set('Cookie', authCookie); + + expect(res.status).toBe(500); + + await new Promise(resolve => setImmediate(resolve)); + + expect(dispatchAlertSpy).toHaveBeenCalledWith( + 'error', + 'deploy_failure', + expect.stringContaining('stop daemon error'), + { stackName: 'myapp' }, + ); + }); +}); + +describe('deploy_failure notification on /update error', () => { + it('dispatches deploy_failure alert with correct stackName when updateStack throws', async () => { + mockUpdateStack.mockRejectedValue(new Error('image not found')); + + const res = await request(app) + .post('/api/stacks/myapp/update') + .set('Cookie', authCookie); + + expect(res.status).toBe(500); + + await new Promise(resolve => setImmediate(resolve)); + + expect(dispatchAlertSpy).toHaveBeenCalledWith( + 'error', + 'deploy_failure', + expect.stringContaining('image not found'), + { stackName: 'myapp' }, + ); + }); +}); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 1a96f6c7..c7c65c4c 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -22,6 +22,13 @@ import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants'; import { getTerminalWs } from '../websocket/generic'; +function notifyActionFailure(action: string, stackName: string, error: unknown): void { + const message = getErrorMessage(error, `Failed to ${action} stack`); + NotificationService.getInstance() + .dispatchAlert('error', 'deploy_failure', message, { stackName }) + .catch(err => console.error(`[Stacks] Failed to dispatch failure notification for ${stackName}:`, err)); +} + async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise { const fsService = FileSystemService.getInstance(nodeId); const stackDir = path.join(fsService.getBaseDir(), stackName); @@ -596,6 +603,7 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => { const rolledBack = LicenseService.getInstance().getTier() === 'paid'; if (rolledBack) console.warn(`[Stacks] Deploy failed, rolled back: ${stackName}`); const message = getErrorMessage(error, 'Failed to deploy stack'); + notifyActionFailure('deploy', stackName, error); res.status(500).json({ error: message, rolledBack }); } }); @@ -611,8 +619,9 @@ stacksRouter.post('/:stackName/down', async (req: Request, res: Response) => { invalidateNodeCaches(req.nodeId); console.log(`[Stacks] Down completed: ${stackName}`); res.json({ status: 'Command started' }); - } catch (error) { + } catch (error: unknown) { console.error(`[Stacks] Down failed: ${stackName}`, error); + notifyActionFailure('down', stackName, error); res.status(500).json({ error: 'Failed to start command' }); } }); @@ -638,6 +647,7 @@ stacksRouter.post('/:stackName/restart', async (req: Request, res: Response) => } catch (error: unknown) { console.error(`[Stacks] Restart failed: ${stackName}`, error); const message = getErrorMessage(error, 'Failed to restart containers'); + notifyActionFailure('restart', stackName, error); res.status(500).json({ error: message }); } }); @@ -663,6 +673,7 @@ stacksRouter.post('/:stackName/stop', async (req: Request, res: Response) => { } catch (error: unknown) { console.error(`[Stacks] Stop failed: ${stackName}`, error); const message = getErrorMessage(error, 'Failed to stop containers'); + notifyActionFailure('stop', stackName, error); res.status(500).json({ error: message }); } }); @@ -786,11 +797,12 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => { triggerPostDeployScan(stackName, req.nodeId).catch(err => console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err), ); - } catch (error) { + } catch (error: unknown) { console.error(`[Stacks] Update failed: ${stackName}`, error); const rolledBack = LicenseService.getInstance().getTier() === 'paid'; if (rolledBack) console.warn(`[Stacks] Update failed, rolled back: ${stackName}`); - res.status(500).json({ error: 'Failed to update', rolledBack }); + notifyActionFailure('update', stackName, error); + res.status(500).json({ error: getErrorMessage(error, 'Failed to update'), rolledBack }); } }); diff --git a/docs/docs.json b/docs/docs.json index 37f5fff5..c636e2d4 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -99,6 +99,7 @@ "features/sidebar", "features/stack-management", "features/editor", + "features/deploy-progress", "features/resources", "features/app-store", "features/global-observability", diff --git a/docs/features/deploy-progress.mdx b/docs/features/deploy-progress.mdx new file mode 100644 index 00000000..e3a64a96 --- /dev/null +++ b/docs/features/deploy-progress.mdx @@ -0,0 +1,79 @@ +--- +title: Deploy Progress Modal +description: Stream live output from stack deploy, install, update, restart, and stop operations in a structured log view. +--- + +When you trigger a stack action (Deploy, Install, Update, Stop, Restart, or Down), an optional progress modal opens and streams structured output from `docker compose` in real time. Each line is parsed into a timestamped row with a stage badge (PULL, BUILD, CREATE, START, STOP, etc.) so you can track the deployment lifecycle as it runs. + +## Enabling deploy progress + +The modal is opt-in. Go to **Settings > Appearance** and enable **Show deploy progress modal**. The setting is saved per browser and takes effect immediately without a reload. + +## Using the modal + +The modal opens automatically when you trigger an action. It stays centered on screen and does not block access to the rest of the UI. + +### What the modal shows + +- A **header** with the action name, stack name, and elapsed time +- A **status indicator** showing the current state (Connecting, streaming line count, Succeeded, or the error message) +- A **structured log body** with one row per output line, each tagged with a stage badge and timestamp +- A **footer** with a "Raw output" toggle and minimize/close controls + +### Stage badges + +Each log row carries a badge indicating what phase of the operation it came from: + +| Badge | Meaning | +|-------|---------| +| PULL | Image layer being fetched from a registry | +| BUILD | Image build step running | +| CREATE | Container being created | +| START | Container starting | +| STOP | Container stopping | +| DOWN | Container or network being removed | +| WARN | Warning from Docker or compose | +| ERR | Error line | +| LOG | General output | + +Error rows have a rose left border and a tinted background so they stand out without requiring a full scan of the log. + +### Raw output toggle + +Click **Raw output** in the footer to reveal the raw terminal view alongside the structured rows. This is useful when you want to see the full unprocessed Docker output, including progress bars and multi-line build steps that the parser collapses to single rows. + +### Auto-close on success + +When an action completes successfully, the modal closes automatically after a few seconds. Hover over the modal to pause the timer. Click the close button to dismiss it immediately. + +### On failure + +If the action fails, the modal stays open and highlights the error row in the log. The status indicator shows the error message. The modal remains open until you close it manually. + +### Minimize to pill + +Click **Minimize** to collapse the modal to a small status pill anchored at the bottom center of the screen. The pill shows the action and stack name with a color-coded status dot (cyan pulse for in-progress, green for success, rose for error). Click the pill to expand the modal back. + +The pill survives navigation, so you can leave the App Store page mid-install and still see the status from any screen. + +## Supported entry points + +The progress modal fires for the following actions: + +- Stack **Deploy**, **Update**, **Restart**, **Stop**, and **Down** from the stack editor +- **Install** from the App Store +- **Apply** from a Git Source panel (when the apply includes a deploy) + +## Troubleshooting + + +Check that the WebSocket connection to Sencho is not blocked by an upstream proxy or firewall. Sencho uses a WebSocket on the same port and path as the main API. If you are running behind nginx or a reverse proxy, ensure `proxy_set_header Upgrade $http_upgrade;` and `proxy_set_header Connection "upgrade";` are configured. See the [configuration guide](/getting-started/configuration) for a complete nginx example. + + + +Some compose wrappers or custom Docker plugins emit non-standard output. The parser falls back to `LOG` rows for lines it cannot classify. If the structured view is empty, open Raw output to see the full stream. + + + +The setting is read from localStorage and updates in the current tab without a reload. If the change did not take effect, try refreshing the page. + diff --git a/docs/images/deploy-logs/panel-restart.png b/docs/images/deploy-logs/panel-restart.png new file mode 100644 index 00000000..aa0ca1f5 Binary files /dev/null and b/docs/images/deploy-logs/panel-restart.png differ diff --git a/docs/images/deploy-logs/panel-streaming.png b/docs/images/deploy-logs/panel-streaming.png new file mode 100644 index 00000000..c44d8dff Binary files /dev/null and b/docs/images/deploy-logs/panel-streaming.png differ diff --git a/docs/images/deploy-logs/panel-success.png b/docs/images/deploy-logs/panel-success.png new file mode 100644 index 00000000..2f58086e Binary files /dev/null and b/docs/images/deploy-logs/panel-success.png differ diff --git a/docs/images/deploy-logs/panel-test.png b/docs/images/deploy-logs/panel-test.png new file mode 100644 index 00000000..54b9bf8e Binary files /dev/null and b/docs/images/deploy-logs/panel-test.png differ diff --git a/e2e/deploy-log-panel.spec.ts b/e2e/deploy-log-panel.spec.ts new file mode 100644 index 00000000..d9d799aa --- /dev/null +++ b/e2e/deploy-log-panel.spec.ts @@ -0,0 +1,279 @@ +/** + * Deploy feedback modal E2E tests. + * + * The modal is opt-in (default off). Each test that expects the modal must + * enable the setting via localStorage before triggering an action. + * + * These tests require a running Docker daemon because they exercise real + * docker compose operations. Timeouts are generous to allow for image pulls + * on a cold cache. + */ +import { test, expect, type Page } from '@playwright/test'; +import { loginAs } from './helpers'; + +const HAPPY_STACK = 'e2e-deploy-log-test'; +const FAIL_STACK = 'e2e-deploy-log-fail-test'; +const DEPLOY_FEEDBACK_KEY = 'sencho.deploy-feedback.enabled'; + +const HAPPY_COMPOSE = `services: + web: + image: nginx:alpine +`; + +const FAIL_COMPOSE = `services: + web: + image: nginnnnx:notexist +`; + +async function waitForStacksLoaded(page: Page): Promise { + await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ timeout: 15_000 }); +} + +async function createStackViaApi(page: Page, name: string, composeContent: string): Promise { + await page.evaluate( + async ({ stackName, content }: { stackName: string; content: string }) => { + const createRes = await fetch('/api/stacks', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stackName }), + }); + if (!createRes.ok && createRes.status !== 409) { + throw new Error(`Failed to create stack: ${createRes.status}`); + } + + const writeRes = await fetch(`/api/stacks/${stackName}`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }), + }); + if (!writeRes.ok) { + throw new Error(`Failed to write compose file: ${writeRes.status}`); + } + }, + { stackName: name, content: composeContent }, + ); +} + +async function deleteStackViaApi(page: Page, name: string): Promise { + await page.evaluate(async (stackName: string) => { + await fetch(`/api/stacks/${stackName}`, { + method: 'DELETE', + credentials: 'include', + }).catch(() => {}); + }, name); +} + +/** + * Set the opt-in flag so it survives every page navigation and reload in the + * test. addInitScript runs before any page script on each load, so the React + * tree's useState initializer always reads the current value on mount. The + * page.evaluate call covers any state already mounted on the current page. + */ +async function enableDeployFeedback(page: Page): Promise { + await page.addInitScript((key: string) => { + window.localStorage.setItem(key, 'true'); + }, DEPLOY_FEEDBACK_KEY); + await page.evaluate((key: string) => { + window.localStorage.setItem(key, 'true'); + window.dispatchEvent(new CustomEvent('SENCHO_SETTINGS_CHANGED')); + }, DEPLOY_FEEDBACK_KEY); +} + +/** + * Force the React hook to re-read localStorage and update its state. Used + * right before a deploy click to defeat any stale-state edge cases after + * navigation. + */ +async function syncDeployFeedbackState(page: Page): Promise { + const stored = await page.evaluate((key: string) => { + window.dispatchEvent(new CustomEvent('SENCHO_SETTINGS_CHANGED')); + return window.localStorage.getItem(key); + }, DEPLOY_FEEDBACK_KEY); + expect(stored, 'deploy feedback opt-in flag missing in localStorage').toBe('true'); + // Allow React to process the state update from the dispatched event before + // the next user interaction. The click fires synchronously, but React + // re-renders in a microtask; without this wait the click handler can run + // against the stale closure where isEnabled was still false. + await page.waitForTimeout(200); +} + +async function disableDeployFeedback(page: Page): Promise { + await page.evaluate((key: string) => { + window.localStorage.removeItem(key); + window.dispatchEvent(new CustomEvent('SENCHO_SETTINGS_CHANGED')); + }, DEPLOY_FEEDBACK_KEY); +} + +/** + * Delete any leftover stack, create a fresh one, reload so the sidebar picks + * it up, and click it to open the editor. Returns with the stack selected and + * the Deploy button ready. + * + * The stack click and the file fetch that follows are awaited together so + * the editor's selectedFile state is committed before the test continues. + * Without this, deployStack() returns early at the !selectedFile guard and + * never calls runWithLog, leaving the modal closed. + */ +async function setupDeployStack(page: Page, name: string, composeContent: string): Promise { + await deleteStackViaApi(page, name); + await page.waitForTimeout(300); + await createStackViaApi(page, name, composeContent); + // Reload preserves auth cookies, so the page lands back on the dashboard + // without needing a fresh login. Calling loginAs() here was racing the + // login-page detection during the brief render where the auth context + // was still loading, then waiting forever on a #username field that + // never re-appeared once the dashboard committed. + await page.reload(); + await waitForStacksLoaded(page); + + await Promise.all([ + page.waitForResponse( + (res) => + res.url().endsWith(`/api/stacks/${name}`) && + res.request().method() === 'GET' && + res.ok(), + { timeout: 10_000 }, + ), + page.getByText(name, { exact: true }).first().click(), + ]); + + // Wait for the editor's action bar to render with the deploy/start button. + await expect(page.getByTestId('stack-deploy-button')).toBeVisible({ timeout: 10_000 }); + + // Settle remaining state updates and any follow-up fetches the editor + // triggers (env file, container list, backup info). Without this the click + // can race with React committing selectedFile, leaving deployStack to bail + // at its !selectedFile guard. + await page.waitForLoadState('networkidle', { timeout: 10_000 }); + await page.waitForTimeout(500); +} + +test.describe('Deploy feedback modal', () => { + test.beforeEach(async ({ page }, testInfo) => { + // Surface React errors and network failures from the browser so test + // failures arrive with diagnostic context instead of just "modal not + // visible". Skip noisy info/log/debug messages. + page.on('console', (msg) => { + if (msg.type() === 'error' || msg.type() === 'warning') { + // eslint-disable-next-line no-console + console.log(`[browser ${msg.type()}] [${testInfo.title}] ${msg.text()}`); + } + }); + page.on('pageerror', (err) => { + // eslint-disable-next-line no-console + console.log(`[browser pageerror] [${testInfo.title}] ${err.message}`); + }); + await loginAs(page); + await waitForStacksLoaded(page); + }); + + test('no modal appears when opt-in is off', async ({ page }) => { + test.setTimeout(60_000); + + await disableDeployFeedback(page); + await setupDeployStack(page, HAPPY_STACK, HAPPY_COMPOSE); + + await page.getByTestId('stack-deploy-button').click(); + + // Modal must not appear when opt-in is disabled + await expect(page.locator('[data-testid="deploy-feedback-modal"]')).not.toBeVisible({ + timeout: 5_000, + }); + }); + + test('modal opens, streams output, and auto-closes on success', async ({ page }) => { + test.setTimeout(120_000); + + await enableDeployFeedback(page); + await setupDeployStack(page, HAPPY_STACK, HAPPY_COMPOSE); + await syncDeployFeedbackState(page); + + await page.getByTestId('stack-deploy-button').click(); + // Park the cursor in a corner so the modal's onMouseEnter never fires. + // The modal pauses its 4s auto-close countdown on hover; without this + // move, the cursor lands inside the centered modal after click and the + // countdown never fires. + await page.mouse.move(0, 0); + + const modal = page.locator('[data-testid="deploy-feedback-modal"]'); + await expect(modal).toBeVisible({ timeout: 10_000 }); + + // Initial status: either "Connecting..." or already streaming rows + const connectingText = page.getByText(/Connecting\.\.\./i); + const streamingIndicator = page.getByText(/\d+ lines?/i); + await Promise.race([ + connectingText.waitFor({ state: 'visible', timeout: 10_000 }), + streamingIndicator.waitFor({ state: 'visible', timeout: 10_000 }), + ]); + + // Wait for the operation to succeed + await expect(page.getByText('Succeeded')).toBeVisible({ timeout: 90_000 }); + + // Modal auto-closes AUTO_CLOSE_SECONDS (4s) after success; allow up to 15s total + await expect(modal).toBeHidden({ timeout: 15_000 }); + }); + + test('modal stays open with error indicator on failure', async ({ page }) => { + test.setTimeout(120_000); + + await enableDeployFeedback(page); + await setupDeployStack(page, FAIL_STACK, FAIL_COMPOSE); + await syncDeployFeedbackState(page); + + await page.getByTestId('stack-deploy-button').click(); + + const modal = page.locator('[data-testid="deploy-feedback-modal"]'); + await expect(modal).toBeVisible({ timeout: 10_000 }); + + // Docker fails to pull the nonexistent image; give it up to 90s to attempt and fail + await expect( + page.getByText(/failed|error|not found|unable to find/i).first(), + ).toBeVisible({ timeout: 90_000 }); + + // Modal must remain open on failure + await page.waitForTimeout(8_000); + await expect(modal).toBeVisible(); + }); + + test('modal can be minimized to a pill and expanded back', async ({ page }) => { + test.setTimeout(120_000); + + await enableDeployFeedback(page); + await setupDeployStack(page, HAPPY_STACK, HAPPY_COMPOSE); + await syncDeployFeedbackState(page); + + await page.getByTestId('stack-deploy-button').click(); + // Move cursor away from modal so the auto-close countdown is not paused + // by hover, in case the deploy completes before we click Minimize. + await page.mouse.move(0, 0); + + const modal = page.locator('[data-testid="deploy-feedback-modal"]'); + await expect(modal).toBeVisible({ timeout: 10_000 }); + + // Click the minimize button in the header (first occurrence) + const minimizeBtn = page.getByRole('button', { name: 'Minimize' }).first(); + await expect(minimizeBtn).toBeVisible({ timeout: 5_000 }); + await minimizeBtn.click(); + + // Modal dialog closes; pill appears at bottom center + await expect(modal).toBeHidden({ timeout: 5_000 }); + const pill = page.locator('[data-testid="deploy-feedback-pill"]'); + await expect(pill).toBeVisible({ timeout: 5_000 }); + + // Pill contains the stack name + await expect(pill).toContainText(HAPPY_STACK); + + // Click the pill to restore the modal + await pill.click(); + await expect(modal).toBeVisible({ timeout: 5_000 }); + }); + + test.afterEach(async ({ page }) => { + await deleteStackViaApi(page, HAPPY_STACK); + await deleteStackViaApi(page, FAIL_STACK); + await disableDeployFeedback(page); + }); +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index e0839ad0..e889f340 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -72,11 +72,23 @@ export async function loginAs(page: Page, username = TEST_USERNAME, password = T // ── 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; + // Confirm the username field is actually visible before filling. If + // isLoginPage was a transient false positive (e.g. login form briefly + // rendered before auth check redirected to dashboard), the fill would + // hang forever waiting for #username to come back. + const usernameField = page.locator('#username'); + const usernameVisible = await usernameField + .waitFor({ state: 'visible', timeout: 2_000 }) + .then(() => true) + .catch(() => false); + if (usernameVisible) { + await usernameField.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; + } + // Fall through to the dashboard check below. } // ── Already on the dashboard ────────────────────────────────────────────── diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e207a483..f028bc7d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,8 @@ import { Login } from './components/Login'; import { Setup } from './components/Setup'; import EditorLayout from './components/EditorLayout'; import { MfaChallenge } from './components/MfaChallenge'; +import { DeployFeedbackProvider } from './context/DeployFeedbackContext'; +import { DeployFeedbackPortal } from './components/DeployFeedbackPortal'; function AppContent() { const { appStatus, isAuthenticated, needsSetup, completeSetup } = useAuth(); @@ -43,7 +45,10 @@ import { ToastContainer } from './components/ui/toast'; function App() { return ( - + + + + ); diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index 1e071666..9bc546fb 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -11,6 +11,7 @@ import { Search, Rocket, Loader2, Info, ExternalLink, Star, ShieldCheck } from ' import { toast } from '@/components/ui/toast-store'; import { cn } from '@/lib/utils'; import { apiFetch } from '@/lib/api'; +import { useDeployFeedback } from '@/context/DeployFeedbackContext'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; @@ -37,6 +38,7 @@ interface AppStoreViewProps { export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { const { can } = useAuth(); const { activeNode } = useNodes(); + const { runWithLog } = useDeployFeedback(); const [templates, setTemplates] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [selectedTemplate, setSelectedTemplate] = useState