Files
sencho/e2e/deploy-log-panel.spec.ts
T
Anso 801a098a5b feat(files): per-stack file explorer (#780)
* feat(files): backend foundation for stack file explorer

Install multer for multipart file upload handling. Add
isValidRelativeStackPath to validation.ts to guard client-supplied
relative paths against traversal, absolute paths, NUL bytes, backslash
injection, and double-slash segments. Add isBinaryBuffer to a new
binaryDetect.ts utility for heuristic text/binary detection via
NUL-byte fast exit and non-printable byte ratio sampling.

* fix(files): reject bare dot segments in isValidRelativeStackPath

* feat(files): add safe stack-scoped file I/O methods to FileSystemService

Adds FileEntry interface and seven new public methods to FileSystemService
for stack-scoped file operations: listStackDirectory, readStackFile,
streamStackFile, writeStackFile, deleteStackPath, mkdirStackPath, and
statStackEntry.

Each method routes through a private resolveSafeStackPath helper that
enforces two-phase path containment: a pre-realpath lexical check plus a
post-realpath symlink-escape check. ENOENT targets are handled by walking
up to the deepest existing ancestor, realpaths that ancestor, and
reattaching the remaining suffix.

Binary detection delegates to isBinaryBuffer; path safety delegates to
isPathWithinBase. Protected file names and the MIME map are module-level
constants to avoid repeated allocation.

* feat(files): frontend API wrappers and Monaco language helper

* fix(files): tighten stackFilesApi error handling and localOnly support

* fix(files): FileSystemService safety and correctness fixes

* feat(files): add file explorer API endpoints to stacks router

* feat(files): FileTree and FileTreeNode components

* fix(files): route security hardening and stream cleanup

* fix(files): FileTree accessibility, icon stroke, stale fetch guard

Add strokeWidth={1.5} to all Lucide icons in FileTreeNode to match the
design system. Add aria-expanded to directory rows for accessibility.
Guard handleDirClick .then() callbacks against stale stack name
references when the component re-renders with a new stack. Add
toast.info fallbacks when compose.yaml or .env is clicked without a
navigation callback registered.

* feat(files): FileViewer, FileUploadDropzone, NewFolderDialog, DeleteFileConfirm

* fix(files): resolve code quality findings in file explorer components

- Move editorOptions useMemo above conditional returns in FileViewer (Rules of Hooks fix)
- Fix blob download: append anchor to DOM before click, defer URL revoke 100ms
- Keep protected-file confirm input visible during NOT_EMPTY recursive retry in DeleteFileConfirm
- Remove non-functional cursor-pointer/onClick from Community upgrade pill in FileUploadDropzone
- Add success toast on folder creation in NewFolderDialog
- Switch all (e as Error).message casts to instanceof Error narrowing

* test(files): unit tests for binary detection, stack path safety, and file explorer routes

- binary-detection.test.ts: covers isBinaryBuffer edge cases (empty, NUL,
  PNG header, threshold boundary, sampleBytes parameter)
- filesystem-stack-paths.test.ts: covers isValidRelativeStackPath (accepts/
  rejects matrix) and FileSystemService stack methods against a real temp dir
  (listStackDirectory sort and protection flags, readStackFile text/binary/
  oversized paths, writeStackFile/Buffer, deleteStackPath, mkdirStackPath,
  traversal guard); platform-specific empty-dir/NOT_EMPTY cases skip on Windows
- stack-files-routes.test.ts: route-level integration tests for all seven
  file explorer endpoints; covers auth gating, Community-tier 403 gates,
  input validation, 413 TOO_LARGE upload limit, and 204/200 happy paths

* feat(files): StackFileExplorer container with lazy tree, viewer, and action bar

* fix(files): add Download button to explorer toolbar, fix Community upgrade pill, reset state on stack change

* test(files): add missing test coverage for file explorer routes and service

* feat(files): add Files tab to EditorLayout with StackFileExplorer integration

* fix(files): add defensive activeTab guard to saveFile and discardChanges

* test(files): unit tests for FileTree expand/collapse and FileViewer render modes

Covers the three FileViewer content modes (text/Monaco, binary panel,
oversized panel) and the FileTree expand/collapse/cache cycle: first
expand fetches the subdirectory, second click collapses without a fetch,
third click re-expands from the in-memory cache without a second fetch.

* test(e2e): file explorer community and skipper+ flows

Covers the full file-explorer feature surface in two describe blocks:

Community (read-only): intercepts /api/license to simulate community
tier, confirms the upgrade pill is visible in the left pane, and
asserts that the Save button is absent after opening a text file.

Skipper+ (full CRUD): uploads a text file and confirms it appears in
the tree; edits config/app.conf and saves via Monaco; deletes an
uploaded file and asserts the tree entry is gone; issues a raw HTTP
request to the download endpoint and checks for status 200 and the
content-disposition: attachment header.

Also adds data-testid="file-action-delete" to the action bar Delete
button in StackFileExplorer for stable targeting, and exports
waitForStacksLoaded from e2e/helpers.ts to eliminate the three
identical local copies in stacks, deploy-log-panel, and stack-files
spec files.

* fix(e2e): improve test isolation and selector stability in stack-files spec

Move beforeEach seed to beforeAll/afterAll so fixtures are created once per
suite, not before every test. Extract shared seedSuite/teardownSuite helpers
to eliminate the duplicate beforeAll/afterAll blocks. Wrap teardown in
try/catch so failures log a warning rather than masking test results.

Replace waitForTimeout(500) with a deterministic expect on the file tree
sentinel. Add data-testid="anatomy-files-btn" and data-testid="delete-confirm-btn"
to replace the fragile button text/positional selectors. Assert Save button
starts disabled before editing.

* docs(files): add stack file explorer documentation

Add user-facing guide for the stack file explorer feature covering
tier access (Community read-only, Skipper+ read-write), viewing
limits, upload/download caps, protected file routing, and
troubleshooting. Update the editor page to reference the new guide
and register the page in the navigation.

* fix(docs): use canonical Skipper tier name in file explorer overview card

* fix(files): resolve lint errors blocking CI

Remove unnecessary backslash escape before double-quote in the
Content-Disposition regex (no-useless-escape). Replace five synchronous
setState resets at the top of the FileTree mount effect with a React key
prop on the FileTree element in StackFileExplorer so remounting resets
state automatically, eliminating the react-hooks/set-state-in-effect
violation.

* test(files): fix e2e seeding to work on community-tier CI

Replace the browser-side paid upload/mkdir API calls in seedTestStack with
direct Node fs writes. The upload and folder endpoints require Skipper+ so
they returned 403 on CI, which runs with no license set. Stack creation
via POST /api/stacks stays as an API call since it is community-allowed and
keeps the backend registry in sync.

Add a per-test tier check in the Skipper+ beforeEach that skips gracefully
when the instance is community, matching the pattern in auto-heal-policies.
2026-04-26 13:05:19 -04:00

275 lines
10 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 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);
}
/**
* 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> {
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<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 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);
});
});