mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 14:08:19 +00:00
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.
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
* on a cold cache.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { loginAs } from './helpers';
|
||||
import { loginAs, waitForStacksLoaded } from './helpers';
|
||||
|
||||
const HAPPY_STACK = 'e2e-deploy-log-test';
|
||||
const FAIL_STACK = 'e2e-deploy-log-fail-test';
|
||||
@@ -25,11 +25,6 @@ const FAIL_COMPOSE = `services:
|
||||
image: nginnnnx:notexist
|
||||
`;
|
||||
|
||||
async function waitForStacksLoaded(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
await page.evaluate(
|
||||
async ({ stackName, content }: { stackName: string; content: string }) => {
|
||||
|
||||
@@ -48,6 +48,16 @@ export async function isDashboard(page: Page): Promise<boolean> {
|
||||
return page.locator(DASHBOARD_INDICATOR).isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the stacks sidebar to finish loading. Waits for the Create Stack
|
||||
* button and the data-stacks-loaded sentinel set by the CommandList after its
|
||||
* async refreshStacks() call resolves.
|
||||
*/
|
||||
export async function waitForStacksLoaded(page: Page): Promise<void> {
|
||||
await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the app root, complete first-run setup if needed, then log in.
|
||||
* After this call the dashboard is guaranteed to be visible.
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* File explorer E2E tests.
|
||||
*
|
||||
* Two scenarios are covered:
|
||||
*
|
||||
* 1. Community flow (read-only): the license endpoint is intercepted so the
|
||||
* frontend believes the instance is community tier. Only viewing files is
|
||||
* allowed; the upgrade pill is visible in the left pane and the Save button
|
||||
* is absent from the editor toolbar.
|
||||
*
|
||||
* 2. Skipper+ flow (full CRUD): the real license state (paid) is used.
|
||||
* Upload, edit-and-save, delete, and download are exercised end-to-end.
|
||||
* These tests skip gracefully when the instance is community tier (CI).
|
||||
*
|
||||
* Fixture files (config/app.conf and assets/logo.png) are seeded once via
|
||||
* direct filesystem writes in a beforeAll hook so seeding works on any tier.
|
||||
* The entire test stack is torn down in afterAll.
|
||||
*/
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as nodePath from 'node:path';
|
||||
import { test, expect, type Page, type BrowserContext } from '@playwright/test';
|
||||
import { loginAs, waitForStacksLoaded } from './helpers';
|
||||
|
||||
// Matches the COMPOSE_DIR passed to the backend in CI (start-app default) and
|
||||
// in local dev. The test runner and the backend share the same host so both
|
||||
// see the same path.
|
||||
const COMPOSE_DIR = process.env.COMPOSE_DIR ?? '/tmp/compose';
|
||||
|
||||
const TEST_STACK = 'e2e-files-test';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Dismiss any "requires a paid license" or upgrade gate overlays that may
|
||||
* appear when operating in community mode. These are informational overlays
|
||||
* rendered by CapabilityGate for features like Auto-Heal Policies.
|
||||
*/
|
||||
async function dismissUpgradeOverlays(page: Page): Promise<void> {
|
||||
const dismissBtn = page.getByRole('button', { name: /dismiss/i });
|
||||
if (await dismissBtn.isVisible().catch(() => false)) {
|
||||
await dismissBtn.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the test stack in the sidebar, then click the "files" button in the
|
||||
* anatomy panel header to enter the Files tab. This works regardless of the
|
||||
* current license tier because the Files panel itself is always rendered
|
||||
* (isPaid only gates edit/upload/delete within the panel).
|
||||
*/
|
||||
async function openFilesTab(page: Page): Promise<void> {
|
||||
await waitForStacksLoaded(page);
|
||||
|
||||
// Ensure the stack appears in the sidebar (reload if necessary after seeding)
|
||||
const stackInSidebar = page.getByText(TEST_STACK, { exact: true }).first();
|
||||
if (!await stackInSidebar.isVisible().catch(() => false)) {
|
||||
await page.reload();
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
}
|
||||
|
||||
// Click the stack in the sidebar
|
||||
await page.getByText(TEST_STACK, { exact: true }).first().click();
|
||||
|
||||
// Dismiss any upgrade/capability-gate overlays that block navigation
|
||||
await dismissUpgradeOverlays(page);
|
||||
|
||||
// The anatomy panel header has a stable testid on the "files" button.
|
||||
const filesBtn = page.getByTestId('anatomy-files-btn');
|
||||
await expect(filesBtn).toBeVisible({ timeout: 8_000 });
|
||||
await filesBtn.click();
|
||||
|
||||
// Wait for the file tree to populate: span.font-mono appears once the
|
||||
// tree data has loaded for both paid and community tiers.
|
||||
await expect(page.locator('span.font-mono').first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the test stack with fixture files.
|
||||
*
|
||||
* Stack creation uses the API (community-allowed) so the backend registry
|
||||
* stays in sync. Fixture files are written directly via Node fs so seeding
|
||||
* works on any tier without hitting the paid upload/mkdir endpoints.
|
||||
*/
|
||||
async function seedTestStack(page: Page): Promise<void> {
|
||||
// Create the stack via API (community-OK; ignore 409 if already exists).
|
||||
await page.evaluate(async (name: string) => {
|
||||
const res = await fetch('/api/stacks', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ stackName: name }),
|
||||
});
|
||||
if (!res.ok && res.status !== 409) throw new Error(`stack create: ${res.status}`);
|
||||
}, TEST_STACK);
|
||||
|
||||
// Write fixture files directly on the host filesystem.
|
||||
const stackDir = nodePath.join(COMPOSE_DIR, TEST_STACK);
|
||||
await fs.mkdir(nodePath.join(stackDir, 'config'), { recursive: true });
|
||||
await fs.mkdir(nodePath.join(stackDir, 'assets'), { recursive: true });
|
||||
await fs.writeFile(nodePath.join(stackDir, 'config', 'app.conf'), 'key=value\n');
|
||||
const pngB64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==';
|
||||
await fs.writeFile(nodePath.join(stackDir, 'assets', 'logo.png'), Buffer.from(pngB64, 'base64'));
|
||||
}
|
||||
|
||||
/** Delete the test stack via the authenticated browser session. */
|
||||
async function teardownTestStack(page: Page): Promise<void> {
|
||||
await page.evaluate(async (name: string) => {
|
||||
await fetch(`/api/stacks/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
}).catch(() => {});
|
||||
}, TEST_STACK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a new page, login, seed the test stack, then close the page.
|
||||
* Used as a beforeAll body so each describe suite shares one seed call.
|
||||
*/
|
||||
async function seedSuite(browser: import('@playwright/test').Browser): Promise<void> {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await loginAs(page);
|
||||
await seedTestStack(page);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a new page, login, tear down the test stack, then close the page.
|
||||
* Logs a warning on failure so a teardown error never masks test failures.
|
||||
*/
|
||||
async function teardownSuite(browser: import('@playwright/test').Browser, label: string): Promise<void> {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await loginAs(page);
|
||||
await teardownTestStack(page);
|
||||
} catch (e) {
|
||||
console.warn(`teardown failed (${label}):`, e);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
const COMMUNITY_LICENSE_BODY = JSON.stringify({
|
||||
tier: 'community',
|
||||
status: 'community',
|
||||
variant: null,
|
||||
customerName: null,
|
||||
productName: null,
|
||||
maskedKey: null,
|
||||
validUntil: null,
|
||||
trialDaysRemaining: null,
|
||||
instanceId: 'test-instance',
|
||||
portalUrl: null,
|
||||
isLifetime: false,
|
||||
});
|
||||
|
||||
/** Stub the /api/license endpoint so the frontend treats the session as community tier. */
|
||||
async function mockCommunityLicense(context: BrowserContext): Promise<void> {
|
||||
await context.route('/api/license', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: COMMUNITY_LICENSE_BODY });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Community flow (read-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('File explorer - community (read-only)', () => {
|
||||
// Increase timeout: seeding + navigation add overhead
|
||||
test.setTimeout(60_000);
|
||||
|
||||
test.beforeAll(async ({ browser }) => { await seedSuite(browser); });
|
||||
test.afterAll(async ({ browser }) => { await teardownSuite(browser, 'community'); });
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
// Login without the license stub first so cookies are established.
|
||||
await loginAs(page);
|
||||
|
||||
// Install the community-tier stub and reload so the frontend picks it up.
|
||||
await mockCommunityLicense(context);
|
||||
await page.reload();
|
||||
await loginAs(page);
|
||||
|
||||
await openFilesTab(page);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ context }) => {
|
||||
// Remove the stub so subsequent requests use the real license state.
|
||||
await context.unroute('/api/license');
|
||||
});
|
||||
|
||||
test('upgrade pill is visible in the left pane', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('button', { name: /upgrade to unlock/i })
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('can expand config/ and click config/app.conf - Save button is absent', async ({ page }) => {
|
||||
// The config/ directory should be visible in the tree
|
||||
const configNode = page.locator('span.font-mono').filter({ hasText: /^config$/ }).first();
|
||||
await expect(configNode).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
// Click to expand the directory
|
||||
await configNode.click();
|
||||
|
||||
// app.conf should now appear under config/
|
||||
const appConfNode = page.locator('span.font-mono').filter({ hasText: /^app\.conf$/ }).first();
|
||||
await expect(appConfNode).toBeVisible({ timeout: 8_000 });
|
||||
await appConfNode.click();
|
||||
|
||||
// The editor header should show "Read-only" badge (isPaid is false)
|
||||
await expect(page.getByText('Read-only')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The Save button must NOT be present in community mode
|
||||
await expect(page.getByRole('button', { name: /^save$/i })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skipper+ flow (full CRUD)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('File explorer - skipper+ (full CRUD)', () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
test.beforeAll(async ({ browser }) => { await seedSuite(browser); });
|
||||
test.afterAll(async ({ browser }) => { await teardownSuite(browser, 'skipper'); });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page);
|
||||
// Skip when the instance is community tier: upload/edit/delete/download
|
||||
// all require Skipper+ and would 403. Same pattern as auto-heal-policies.
|
||||
const tier = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/license', { credentials: 'include' });
|
||||
const json = await res.json() as { tier?: string };
|
||||
return json.tier;
|
||||
});
|
||||
test.skip(tier !== 'paid', 'Skipper+ tier required; instance is community.');
|
||||
await openFilesTab(page);
|
||||
});
|
||||
|
||||
test('upload a text file and verify it appears in the tree', async ({ page }) => {
|
||||
// Guard: paid users see the upload dropzone, not the upgrade pill.
|
||||
await expect(
|
||||
page.locator('[role="button"]').filter({ hasText: /upload file/i }).first()
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Set a file on the hidden input
|
||||
const input = page.locator('input[type="file"][aria-label="Upload file"]');
|
||||
await input.setInputFiles({
|
||||
name: 'e2e-upload-test.txt',
|
||||
mimeType: 'text/plain',
|
||||
buffer: Buffer.from('hello e2e\n'),
|
||||
});
|
||||
|
||||
// Success toast
|
||||
await expect(page.getByText(/uploaded/i).first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// File appears in the tree at root level
|
||||
await expect(
|
||||
page.locator('span.font-mono').filter({ hasText: /^e2e-upload-test\.txt$/ }).first()
|
||||
).toBeVisible({ timeout: 8_000 });
|
||||
});
|
||||
|
||||
test('edit config/app.conf and save - success toast appears', async ({ page }) => {
|
||||
// Expand config/ and open app.conf
|
||||
const configNode = page.locator('span.font-mono').filter({ hasText: /^config$/ }).first();
|
||||
await expect(configNode).toBeVisible({ timeout: 8_000 });
|
||||
await configNode.click();
|
||||
|
||||
const appConfNode = page.locator('span.font-mono').filter({ hasText: /^app\.conf$/ }).first();
|
||||
await expect(appConfNode).toBeVisible({ timeout: 8_000 });
|
||||
await appConfNode.click();
|
||||
|
||||
// Wait for Monaco to load
|
||||
await page.waitForLoadState('networkidle', { timeout: 10_000 });
|
||||
|
||||
// Save button must be present and initially disabled (no changes yet)
|
||||
const saveBtn = page.getByRole('button', { name: /^save$/i });
|
||||
await expect(saveBtn).toBeVisible({ timeout: 8_000 });
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
|
||||
// Edit the file content via Monaco
|
||||
const editorTextarea = page.locator('.monaco-editor textarea').first();
|
||||
await editorTextarea.click({ force: true });
|
||||
await page.keyboard.press('ControlOrMeta+A');
|
||||
await page.keyboard.type('key=value\ne2e-edited=true\n');
|
||||
|
||||
// Save button should now be enabled
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 5_000 });
|
||||
await saveBtn.click();
|
||||
|
||||
// Success toast
|
||||
await expect(page.getByText(/saved/i).first()).toBeVisible({ timeout: 8_000 });
|
||||
});
|
||||
|
||||
test('delete an uploaded file - it disappears from the tree', async ({ page }) => {
|
||||
// Upload a disposable file first
|
||||
const input = page.locator('input[type="file"][aria-label="Upload file"]');
|
||||
await input.setInputFiles({
|
||||
name: 'e2e-to-delete.txt',
|
||||
mimeType: 'text/plain',
|
||||
buffer: Buffer.from('delete me\n'),
|
||||
});
|
||||
await expect(page.getByText(/uploaded/i).first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Click the file to select it
|
||||
const fileNode = page.locator('span.font-mono').filter({ hasText: /^e2e-to-delete\.txt$/ }).first();
|
||||
await expect(fileNode).toBeVisible({ timeout: 8_000 });
|
||||
await fileNode.click();
|
||||
|
||||
// The action bar Delete button has a stable data-testid for reliable targeting.
|
||||
const actionBarDeleteBtn = page.getByTestId('file-action-delete');
|
||||
await expect(actionBarDeleteBtn).toBeVisible({ timeout: 5_000 });
|
||||
await actionBarDeleteBtn.click();
|
||||
|
||||
// Wait for the DeleteFileConfirm dialog to open, then confirm deletion.
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 8_000 });
|
||||
await page.getByTestId('delete-confirm-btn').click();
|
||||
|
||||
// Use the tree-specific class (text-sm) to avoid a strict-mode violation:
|
||||
// the FileViewer header renders the same filename in a different span until
|
||||
// handleDeleted() clears selectedPath.
|
||||
await expect(
|
||||
page.locator('span.font-mono.text-sm').filter({ hasText: /^e2e-to-delete\.txt$/ })
|
||||
).not.toBeAttached({ timeout: 8_000 });
|
||||
});
|
||||
|
||||
test('download assets/logo.png - response is 200 with attachment header', async ({ page, request }) => {
|
||||
// Replay the browser's session cookies in a raw request to the download endpoint.
|
||||
const cookies = await page.context().cookies();
|
||||
const cookieHeader = cookies.map((c) => `${c.name}=${c.value}`).join('; ');
|
||||
|
||||
const res = await request.get(
|
||||
`/api/stacks/${encodeURIComponent(TEST_STACK)}/files/download` +
|
||||
`?path=${encodeURIComponent('assets/logo.png')}`,
|
||||
{ headers: { cookie: cookieHeader } },
|
||||
);
|
||||
|
||||
expect(res.status()).toBe(200);
|
||||
const disposition = res.headers()['content-disposition'] ?? '';
|
||||
expect(disposition).toMatch(/attachment/i);
|
||||
});
|
||||
});
|
||||
+1
-9
@@ -2,18 +2,10 @@
|
||||
* Stack management E2E tests - happy path CRUD.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs } from './helpers';
|
||||
import { loginAs, waitForStacksLoaded } from './helpers';
|
||||
|
||||
const TEST_STACK = 'e2e-test-stack';
|
||||
|
||||
/** Wait for the stacks sidebar to finish loading (skeletons replaced with actual stack list). */
|
||||
async function waitForStacksLoaded(page: import('@playwright/test').Page) {
|
||||
await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 });
|
||||
// The CommandList has data-stacks-loaded="true" once the async refreshStacks() completes.
|
||||
// Without this, tests can race against the loading state and miss newly created stacks.
|
||||
await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ 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) => {
|
||||
|
||||
Reference in New Issue
Block a user