Files
sencho/e2e/deploy-log-panel.spec.ts
T
Anso 5fe0843eb2 feat: add routable browser URLs for stacks and shell views (#1586)
* feat: add routable browser URLs for stacks and shell views

Sync in-memory navigation to the address bar via a History API hook so
deep links, refresh, Back/Forward, and bookmarks work across nodes,
views, stack editor tabs, and mobile surfaces. Gate role/tier URL
normalization on permissions and license readiness, preserve URLs on
metadata fetch failure, and surface retryable stack-list errors without
rewriting pending stack paths.

* fix: preserve deep-link views on cold load and refresh

Stop the node-switch effect from resetting to dashboard on initial mount.

Defer URL writer settlement until hydrated activeView matches the route.

Adds E2E coverage for shell cold loads, stack refresh, and compose env tab.

* fix: keep mobile dashboard on list surface so sidebar renders

On mobile, the URL sync hook was routing /nodes/<slug>/dashboard to the
content surface, hiding the stack list sidebar. This prevented the
data-stacks-loaded sentinel from appearing, causing sidebar truncation
E2E tests to time out after reload on a mobile viewport.

Mobile dashboard now stays on the list surface; other non-editor views
still render on the content surface.

* fix: complete mobile URL routing follow-ups for stack deep links

Restore mobile /dashboard vs /stacks, list surface always writes /stacks.

Hydrate pendingDetailStack, freeze compose failures with routeDetailError,

and add unit plus E2E coverage.

* fix: hydrate shell views from URL and sync in-app navigation

Bootstrap activeView and tab state from the pathname on cold load.

Settle route phase when state already matches, normalize unknown segments,

and open Monaco editor tabs from stack deep links via applyEditorRouteState.

* fix: prevent mobile stack deep links from hanging on cold load

The resolvePendingStack effect did not re-fire when the pending stack ref
was populated during URL hydration, because the urlHydratingStack state
set in the same callback was not listed in the effect's dependency array.
Adding it causes the effect to retry once hydration has committed.

A resolvingRef mutex prevents concurrent invocations. When the target file
is already loaded, route state is applied directly without calling
loadFileForRoute, which avoids unmounting the editor (and hiding the
recovery chip) if a background refresh triggers route resolution during
a deploy operation.

* test: adapt stack, deploy, and sidebar e2e specs to routable stack URLs

* ci: raise E2E Playwright job timeout to 20 minutes
2026-07-08 13:07:16 -04:00

477 lines
20 KiB
TypeScript

/**
* 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, waitForStacksLoaded } 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 DEPLOY_FEEDBACK_STYLE_KEY = 'sencho.deploy-feedback.style';
const HAPPY_COMPOSE = `services:
web:
image: nginx:alpine
`;
const FAIL_COMPOSE = `services:
web:
image: nginnnnx:notexist
`;
async function createStackViaApi(page: Page, name: string, composeContent: string): Promise<void> {
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<void> {
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<void> {
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);
}
/**
* Select the progress presentation style (Modal or Inline). addInitScript keeps
* it set across the reload setupDeployStack performs; the evaluate updates any
* already-mounted tree.
*/
async function setDeployStyle(page: Page, style: 'modal' | 'inline'): Promise<void> {
await page.addInitScript(([key, value]) => {
window.localStorage.setItem(key, value);
}, [DEPLOY_FEEDBACK_STYLE_KEY, style] as const);
await page.evaluate(([key, value]) => {
window.localStorage.setItem(key, value);
window.dispatchEvent(new CustomEvent('sencho-settings-changed'));
}, [DEPLOY_FEEDBACK_STYLE_KEY, style] as const);
}
/**
* Shrink the post-deploy health gate observation window so success-path tests
* can watch the full deploy, verify, succeed sequence without waiting out the
* 90s default. 15 seconds is the smallest value the settings API accepts.
*/
async function setHealthGateWindow(page: Page, seconds: number): Promise<void> {
await page.evaluate(async (windowSeconds: number) => {
const res = await fetch('/api/settings', {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ health_gate_window_seconds: windowSeconds }),
});
if (!res.ok) {
throw new Error(`Failed to set health gate window: ${res.status}`);
}
}, seconds);
}
/**
* 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<void> {
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<void> {
// The panel is on by default (opt-out), so disabling means writing an
// explicit 'false' that survives the reload, not clearing the key.
await page.addInitScript((key: string) => {
window.localStorage.setItem(key, 'false');
}, DEPLOY_FEEDBACK_KEY);
await page.evaluate((key: string) => {
window.localStorage.setItem(key, 'false');
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<void> {
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 the progress panel is turned 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 the panel has been explicitly 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 }) => {
// Worst-case assertion budget: 90s compose run + 45s gate verdict + 15s
// auto-close, so the per-assertion timeout fires before the test timeout.
test.setTimeout(160_000);
await enableDeployFeedback(page);
await setHealthGateWindow(page, 15);
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 }),
]);
// The compose run finishes first (up to 90s for a cold image pull), then
// the post-deploy health gate observes the containers before the modal
// commits to a verdict.
await expect(modal.getByText('Verifying health')).toBeVisible({ timeout: 90_000 });
// Gate window is 15s; allow the 4s status polling cadence plus slack.
await expect(modal.getByText('Succeeded')).toBeVisible({ timeout: 45_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('deploy continues to success after minimizing mid-deploy', async ({ page }) => {
test.setTimeout(120_000);
await enableDeployFeedback(page);
await setHealthGateWindow(page, 15);
await setupDeployStack(page, HAPPY_STACK, HAPPY_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 });
// Wait until output is actually streaming so the minimize happens mid-deploy,
// not after a too-fast completion (which would pass vacuously).
await Promise.race([
page.getByText(/Connecting\.\.\./i).waitFor({ state: 'visible', timeout: 10_000 }),
page.getByText(/\d+ lines?/i).waitFor({ state: 'visible', timeout: 10_000 }),
]);
// Minimize while the deploy is in flight. The progress socket closes when
// the dialog unmounts; the deploy is owned by its HTTP request and must
// keep running. Before the output-only fix this aborted the deploy with a
// "client disconnected" failure.
await page.getByRole('button', { name: 'Minimize' }).first().click();
const pill = page.locator('[data-testid="deploy-feedback-pill"]');
await expect(pill).toBeVisible({ timeout: 5_000 });
// Re-expand and confirm the deploy reached success, never a disconnect failure.
await pill.click();
await expect(modal).toBeVisible({ timeout: 5_000 });
await expect(page.getByText('Succeeded')).toBeVisible({ timeout: 90_000 });
await expect(page.getByText(/client disconnected/i)).toHaveCount(0);
});
test('failed operation shows a recovery panel with retry on desktop and mobile', async ({ page }) => {
test.setTimeout(90_000);
// The recovery panel lives in the stack detail and must work without the
// streaming modal, so disable the modal and assert the panel directly.
await disableDeployFeedback(page);
let opCalls = 0;
await page.route('**/api/stacks/**/deploy*', async (route) => {
opCalls += 1;
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'pull failed: connection reset' }),
});
});
await setupDeployStack(page, HAPPY_STACK, HAPPY_COMPOSE);
// Desktop: a failed operation surfaces a recovery chip and never leaves the
// action button stuck in a spinner. Clicking the chip opens the popover with
// the recovery actions.
await page.getByTestId('stack-deploy-button').click();
await expect(page.getByTestId('recovery-chip')).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Deploy failed')).toBeVisible();
await expect(page.getByTestId('stack-deploy-button')).toBeEnabled({ timeout: 10_000 });
await page.getByTestId('recovery-chip').click();
await expect(page.getByTestId('recovery-panel')).toBeVisible({ timeout: 10_000 });
// Retry re-issues the operation through the shared handler.
await page.getByRole('button', { name: 'Retry deploy' }).click();
await expect.poll(() => opCalls, { timeout: 15_000 }).toBeGreaterThanOrEqual(2);
// Mobile shell (below the md break) renders the recovery card inline.
// Open from the stack list so URL hydration does not drop us into the
// full-screen compose editor surface (the /compose deep link does that).
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/nodes/local/stacks');
await waitForStacksLoaded(page);
await page.getByText(HAPPY_STACK, { exact: true }).first().click();
await expect(page.getByRole('tablist', { name: 'Stack detail sections' })).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('stack-deploy-button')).toBeEnabled({ timeout: 10_000 });
await page.getByTestId('stack-deploy-button').click();
await expect(page.getByText('Deploy failed')).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('recovery-panel')).toBeVisible({ timeout: 10_000 });
});
test.afterEach(async ({ page }) => {
await deleteStackViaApi(page, HAPPY_STACK);
await deleteStackViaApi(page, FAIL_STACK);
await disableDeployFeedback(page);
// Restore the default observation window so the shortened test value does
// not leak into later suites or manual sessions against the same instance.
// Best-effort: a failed test may have already lost the page session.
await setHealthGateWindow(page, 90).catch(() => {});
});
});
test.describe('Inline deploy progress', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page);
await waitForStacksLoaded(page);
});
test.afterEach(async ({ page }) => {
await deleteStackViaApi(page, HAPPY_STACK);
await disableDeployFeedback(page);
await page.evaluate((key) => window.localStorage.removeItem(key), DEPLOY_FEEDBACK_STYLE_KEY).catch(() => {});
await setHealthGateWindow(page, 90).catch(() => {});
});
test('inline style shows the in-page banner instead of the modal, and View output opens it', async ({ page }) => {
test.setTimeout(120_000);
await enableDeployFeedback(page);
await setDeployStyle(page, 'inline');
await setHealthGateWindow(page, 15);
await setupDeployStack(page, HAPPY_STACK, HAPPY_COMPOSE);
await syncDeployFeedbackState(page);
await page.getByTestId('stack-deploy-button').click();
const banner = page.getByTestId('stack-operation-banner');
await expect(banner).toBeVisible({ timeout: 10_000 });
// The modal must not auto-open in Inline style.
await expect(page.locator('[data-testid="deploy-feedback-modal"]')).toBeHidden();
// View output summons the full modal on demand.
await banner.getByRole('button', { name: /view output/i }).click();
await expect(page.locator('[data-testid="deploy-feedback-modal"]')).toBeVisible({ timeout: 5_000 });
});
test('inline banner is visible on the mobile detail', async ({ page }) => {
test.setTimeout(120_000);
await enableDeployFeedback(page);
await setDeployStyle(page, 'inline');
await setHealthGateWindow(page, 15);
await setupDeployStack(page, HAPPY_STACK, HAPPY_COMPOSE);
await syncDeployFeedbackState(page);
// Resize below md so the detail re-renders as the mobile segmented layout
// with the same selected stack.
await page.setViewportSize({ width: 390, height: 844 });
await page.getByTestId('stack-deploy-button').click();
await expect(page.getByTestId('stack-operation-banner')).toBeVisible({ timeout: 10_000 });
});
test('modal style shows no inline banner', async ({ page }) => {
test.setTimeout(120_000);
await enableDeployFeedback(page);
await setDeployStyle(page, 'modal');
await setHealthGateWindow(page, 15);
await setupDeployStack(page, HAPPY_STACK, HAPPY_COMPOSE);
await syncDeployFeedbackState(page);
await page.getByTestId('stack-deploy-button').click();
await page.mouse.move(0, 0);
await expect(page.locator('[data-testid="deploy-feedback-modal"]')).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId('stack-operation-banner')).toHaveCount(0);
});
});