mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-10-25 13:11:05 +00:00
ebbd504a5a
The context-menu assertion matched both the Rename item and a leftover lifecycle-rename-dst.txt row from an earlier test, failing the first attempt in most CI runs. Match the menu item by role instead. The deploy-feedback specs now log aborted requests and attach the browser logger to the inline-progress group too, to diagnose an intermittent login failure after deploy tests.
589 lines
26 KiB
TypeScript
589 lines
26 KiB
TypeScript
/**
|
|
* Stack file explorer end-to-end coverage.
|
|
*
|
|
* Companion to e2e/stack-files.spec.ts (which covers the basic admin and
|
|
* community-tier upload/edit/delete/download flows). This spec adds the
|
|
* harder-to-reach surfaces the file-explorer audit flagged: path-traversal
|
|
* input corpus, protected-file enforcement at the API, optimistic-concurrency
|
|
* 412 on simultaneous edits, directory-truncation headers + filter behaviour,
|
|
* binary-detection override, the multer 25 MB cap, and the symlink delete
|
|
* semantics that landed in the M-3 PR.
|
|
*
|
|
* Items deliberately out of scope for this spec (each carries a `.skip()`
|
|
* with a one-line rationale near the relevant describe block):
|
|
* - Remote-node matrix: requires a real peer enrolled in the CI database.
|
|
* - Mid-op disconnect: same.
|
|
* - Developer-mode diagnostic-line matrix: requires reading the backend's
|
|
* stdout, which the Playwright harness does not have a stable hook for.
|
|
* - Viewer-role tier-persona matrix: requires seeding a viewer-role user
|
|
* and re-driving the auth flow under it; the API-only assertions below
|
|
* already cover the role-gated 403 paths.
|
|
*
|
|
* Fixture stack: a single `e2e-explorer-test` stack is seeded once per
|
|
* describe block. Helper writes go directly to COMPOSE_DIR; the API is used
|
|
* for stack create/delete only.
|
|
*/
|
|
import * as fs from 'node:fs/promises';
|
|
import * as nodePath from 'node:path';
|
|
import { test, expect, type Page } from '@playwright/test';
|
|
import { loginAs, waitForStacksLoaded } from './helpers';
|
|
|
|
const COMPOSE_DIR = process.env.COMPOSE_DIR ?? '/tmp/compose';
|
|
const STACK = 'e2e-explorer-test';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Seed / teardown helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function seedStack(page: Page): Promise<void> {
|
|
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}`);
|
|
}, STACK);
|
|
}
|
|
|
|
async function teardownStack(page: Page): Promise<void> {
|
|
await page.evaluate(async (name: string) => {
|
|
await fetch(`/api/stacks/${encodeURIComponent(name)}`, {
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
}).catch(() => {});
|
|
}, STACK);
|
|
}
|
|
|
|
async function seedSuite(browser: import('@playwright/test').Browser): Promise<void> {
|
|
const page = await browser.newPage();
|
|
try {
|
|
await loginAs(page);
|
|
await seedStack(page);
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
}
|
|
|
|
async function teardownSuite(browser: import('@playwright/test').Browser): Promise<void> {
|
|
const page = await browser.newPage();
|
|
try {
|
|
await loginAs(page);
|
|
await teardownStack(page);
|
|
} catch (e) {
|
|
console.warn('teardown failed:', e);
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
}
|
|
|
|
/** Read the cookie header so request-context calls inherit the browser session. */
|
|
async function cookieHeader(page: Page): Promise<string> {
|
|
const cookies = await page.context().cookies();
|
|
return cookies.map((c) => `${c.name}=${c.value}`).join('; ');
|
|
}
|
|
|
|
const stackDir = (): string => nodePath.join(COMPOSE_DIR, STACK);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// File-scope seed / teardown (single login pair shared across every describe)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// One file-scope beforeAll/afterAll replaces seven per-describe pairs. The
|
|
// per-describe pairs each did a fresh loginAs + stack create + stack delete;
|
|
// stacking that up made the post-spec sidebar load slow enough that
|
|
// downstream specs in the single-worker queue (stacks.spec.ts) timed out on
|
|
// their own loginAs dashboard wait. The test stack is idempotent across
|
|
// beforeAll calls anyway (the API returns 409 on a duplicate create), so
|
|
// collapsing the hooks costs nothing.
|
|
test.beforeAll(async ({ browser }) => { await seedSuite(browser); });
|
|
test.afterAll(async ({ browser }) => { await teardownSuite(browser); });
|
|
test.setTimeout(60_000);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Path traversal corpus
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('Stack file explorer: path traversal protection', () => {
|
|
test.beforeEach(async ({ page }) => { await loginAs(page); });
|
|
|
|
// Every entry must produce a 400 with code INVALID_PATH on the file-listing
|
|
// route. The validator is upstream of FileSystemService and the audit pinned
|
|
// it as the first defence; a regression here would expose path traversal.
|
|
const corpus: Array<{ label: string; query: string }> = [
|
|
{ label: 'parent-dir traversal', query: '..' },
|
|
{ label: 'parent-dir prefix', query: '../etc/passwd' },
|
|
{ label: 'embedded ..', query: 'a/../b' },
|
|
{ label: 'absolute POSIX path', query: '/etc/passwd' },
|
|
{ label: 'Windows drive prefix', query: 'C:/windows/system32' },
|
|
{ label: 'backslash separator', query: 'foo\\bar' },
|
|
{ label: 'double slash', query: 'foo//bar' },
|
|
{ label: 'NUL byte', query: 'foo\x00bar' },
|
|
{ label: 'single-segment dot', query: 'a/./b' },
|
|
];
|
|
|
|
for (const { label, query } of corpus) {
|
|
test(`rejects ${label} on GET /files with 400 INVALID_PATH`, async ({ page, request }) => {
|
|
const cookie = await cookieHeader(page);
|
|
const res = await request.get(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files?path=${encodeURIComponent(query)}`,
|
|
{ headers: { cookie } },
|
|
);
|
|
expect(res.status()).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.code).toBe('INVALID_PATH');
|
|
});
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Protected file enforcement
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('Stack file explorer: protected files', () => {
|
|
test.beforeEach(async ({ page }) => { await loginAs(page); });
|
|
|
|
test('DELETE compose.yaml via API returns 409 PROTECTED_FILE and does not remove it', async ({ page, request }) => {
|
|
const cookie = await cookieHeader(page);
|
|
const composePath = nodePath.join(stackDir(), 'compose.yaml');
|
|
// The stack-create seed wrote compose.yaml; confirm pre-state.
|
|
await fs.access(composePath);
|
|
|
|
const res = await request.delete(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files?path=${encodeURIComponent('compose.yaml')}`,
|
|
{ headers: { cookie } },
|
|
);
|
|
expect(res.status()).toBe(409);
|
|
const body = await res.json();
|
|
expect(body.code).toBe('PROTECTED_FILE');
|
|
|
|
// File still on disk.
|
|
await fs.access(composePath);
|
|
});
|
|
|
|
test('DELETE .env via API returns 409 PROTECTED_FILE', async ({ page, request }) => {
|
|
const cookie = await cookieHeader(page);
|
|
// Seed .env so the route does not 404 before the protected-file guard runs.
|
|
await fs.writeFile(nodePath.join(stackDir(), '.env'), 'KEY=value\n');
|
|
|
|
const res = await request.delete(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files?path=${encodeURIComponent('.env')}`,
|
|
{ headers: { cookie } },
|
|
);
|
|
expect(res.status()).toBe(409);
|
|
expect((await res.json()).code).toBe('PROTECTED_FILE');
|
|
});
|
|
|
|
test('DELETE a subdirectory file named compose.yaml is allowed (protection scoped to root)', async ({ page, request }) => {
|
|
const cookie = await cookieHeader(page);
|
|
const subdir = nodePath.join(stackDir(), 'backups');
|
|
await fs.mkdir(subdir, { recursive: true });
|
|
await fs.writeFile(nodePath.join(subdir, 'compose.yaml'), 'services: {}\n');
|
|
|
|
const res = await request.delete(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files?path=${encodeURIComponent('backups/compose.yaml')}`,
|
|
{ headers: { cookie } },
|
|
);
|
|
expect(res.status()).toBe(204);
|
|
await fs.rm(subdir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Optimistic concurrency (412 PRECONDITION_FAILED)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('Stack file explorer: optimistic concurrency on edits', () => {
|
|
test.beforeEach(async ({ page }) => { await loginAs(page); });
|
|
|
|
test('second concurrent save returns 412 with currentContent + currentMtimeMs', async ({ page, request }) => {
|
|
const cookie = await cookieHeader(page);
|
|
const filePath = nodePath.join(stackDir(), 'concurrent.txt');
|
|
await fs.writeFile(filePath, 'initial\n');
|
|
|
|
// Read once to capture the baseline ETag.
|
|
const read = await request.get(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('concurrent.txt')}`,
|
|
{ headers: { cookie } },
|
|
);
|
|
expect(read.status()).toBe(200);
|
|
const etag = read.headers()['etag'] ?? '';
|
|
expect(etag).toMatch(/^W\/"\d+"$/);
|
|
|
|
// First save with the captured ETag succeeds and bumps the on-disk mtime.
|
|
const firstWrite = await request.put(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('concurrent.txt')}`,
|
|
{
|
|
headers: { cookie, 'If-Match': etag, 'Content-Type': 'application/json' },
|
|
data: { content: 'first writer\n' },
|
|
},
|
|
);
|
|
expect(firstWrite.status()).toBe(204);
|
|
|
|
// Second save with the OLD ETag must return 412 and carry the current
|
|
// server-side content so the client can reconcile.
|
|
const secondWrite = await request.put(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('concurrent.txt')}`,
|
|
{
|
|
headers: { cookie, 'If-Match': etag, 'Content-Type': 'application/json' },
|
|
data: { content: 'second writer\n' },
|
|
},
|
|
);
|
|
expect(secondWrite.status()).toBe(412);
|
|
const conflict = await secondWrite.json();
|
|
expect(conflict.code).toBe('PRECONDITION_FAILED');
|
|
expect(conflict.currentContent).toBe('first writer\n');
|
|
expect(typeof conflict.currentMtimeMs).toBe('number');
|
|
|
|
// Disk content matches the winning writer; the losing writer never landed.
|
|
const onDisk = await fs.readFile(filePath, 'utf-8');
|
|
expect(onDisk).toBe('first writer\n');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Binary detection + force=text override
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('Stack file explorer: binary detection and force=text override', () => {
|
|
test.beforeEach(async ({ page }) => { await loginAs(page); });
|
|
|
|
test('default GET returns binary:true for a UTF-8 file carrying a NUL byte', async ({ page, request }) => {
|
|
const cookie = await cookieHeader(page);
|
|
const filePath = nodePath.join(stackDir(), 'utf8-with-nul.txt');
|
|
await fs.writeFile(filePath, Buffer.from('hello\0world rest is utf8 text', 'utf-8'));
|
|
|
|
const res = await request.get(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('utf8-with-nul.txt')}`,
|
|
{ headers: { cookie } },
|
|
);
|
|
expect(res.status()).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.binary).toBe(true);
|
|
expect(body.content).toBeUndefined();
|
|
});
|
|
|
|
test('GET with ?force=text returns the bytes as UTF-8 content', async ({ page, request }) => {
|
|
const cookie = await cookieHeader(page);
|
|
const raw = Buffer.from('hello\0world rest is utf8 text', 'utf-8');
|
|
await fs.writeFile(nodePath.join(stackDir(), 'force-text.txt'), raw);
|
|
|
|
const res = await request.get(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('force-text.txt')}&force=text`,
|
|
{ headers: { cookie } },
|
|
);
|
|
expect(res.status()).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.binary).toBe(false);
|
|
expect(body.content).toBe(raw.toString('utf-8'));
|
|
});
|
|
|
|
test('force=text on an oversized file still returns oversized:true (no inline content)', async ({ page, request }) => {
|
|
const cookie = await cookieHeader(page);
|
|
// 2.1 MB: just above the 2 MB inline-preview cap. Kept small on purpose
|
|
// so the test does not pile memory pressure on top of the rest of the
|
|
// CI run; the cap behaviour itself is exercised at every byte over the
|
|
// threshold by the route-level test.
|
|
await fs.writeFile(nodePath.join(stackDir(), 'oversized.txt'), 'x'.repeat(2_100_000));
|
|
|
|
const res = await request.get(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('oversized.txt')}&force=text`,
|
|
{ headers: { cookie } },
|
|
);
|
|
expect(res.status()).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.oversized).toBe(true);
|
|
expect(body.content).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Symlink semantics (delete + chmod)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('Stack file explorer: symlink semantics', () => {
|
|
test.beforeEach(async ({ page }) => { await loginAs(page); });
|
|
|
|
// Symlink creation on Windows requires admin or developer mode; this block
|
|
// runs on Linux CI where the harness has the privilege.
|
|
test.skip(process.platform === 'win32', 'symlink creation requires admin on Windows');
|
|
|
|
test('DELETE on a symlink removes the link entry; the target file stays intact', async ({ page, request }) => {
|
|
const cookie = await cookieHeader(page);
|
|
const target = nodePath.join(stackDir(), 'symlink-target.txt');
|
|
const link = nodePath.join(stackDir(), 'symlink-link.txt');
|
|
await fs.writeFile(target, 'survives\n');
|
|
await fs.symlink(target, link);
|
|
|
|
try {
|
|
const res = await request.delete(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files?path=${encodeURIComponent('symlink-link.txt')}`,
|
|
{ headers: { cookie } },
|
|
);
|
|
expect(res.status()).toBe(204);
|
|
await expect(fs.lstat(link)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
const survived = await fs.readFile(target, 'utf-8');
|
|
expect(survived).toBe('survives\n');
|
|
} finally {
|
|
await fs.unlink(target).catch(() => {});
|
|
}
|
|
});
|
|
|
|
test('PUT permissions on a symlink returns 409 LINK_CHMOD_UNSUPPORTED', async ({ page, request }) => {
|
|
const cookie = await cookieHeader(page);
|
|
const target = nodePath.join(stackDir(), 'chmod-target.txt');
|
|
const link = nodePath.join(stackDir(), 'chmod-link.txt');
|
|
await fs.writeFile(target, 'payload\n');
|
|
await fs.chmod(target, 0o644);
|
|
await fs.symlink(target, link);
|
|
|
|
try {
|
|
const res = await request.put(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files/permissions?path=${encodeURIComponent('chmod-link.txt')}`,
|
|
{
|
|
headers: { cookie, 'Content-Type': 'application/json' },
|
|
data: { mode: 0o600 },
|
|
},
|
|
);
|
|
expect(res.status()).toBe(409);
|
|
expect((await res.json()).code).toBe('LINK_CHMOD_UNSUPPORTED');
|
|
|
|
// Target mode unchanged.
|
|
const stat = await fs.stat(target);
|
|
expect(stat.mode & 0o777).toBe(0o644);
|
|
} finally {
|
|
await fs.unlink(link).catch(() => {});
|
|
await fs.unlink(target).catch(() => {});
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// UI lifecycle: mkdir, rename, chmod via the Files tab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('Stack file explorer: UI lifecycle', () => {
|
|
test.setTimeout(90_000);
|
|
|
|
function stackSlug(stackName: string): string {
|
|
return stackName.replace(/^-+/, '').replace(/\.(ya?ml)$/i, '');
|
|
}
|
|
|
|
/** Open the Files tab via the routable stack editor URL. */
|
|
async function openFilesTab(page: Page): Promise<void> {
|
|
const slug = stackSlug(STACK);
|
|
await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/files`);
|
|
await waitForStacksLoaded(page);
|
|
await expect(page.getByRole('button', { name: 'New file' })).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.locator('span.font-mono').first()).toBeVisible({ timeout: 10_000 });
|
|
}
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginAs(page);
|
|
await openFilesTab(page);
|
|
});
|
|
|
|
test('upload, rename via the API, then verify the renamed file appears in the tree', async ({ page, request }) => {
|
|
const cookie = await cookieHeader(page);
|
|
// Upload via the UI to exercise the dropzone path.
|
|
const input = page.locator('input[type="file"][aria-label="Upload file"]');
|
|
await input.setInputFiles({
|
|
name: 'lifecycle-rename-src.txt',
|
|
mimeType: 'text/plain',
|
|
buffer: Buffer.from('rename me\n'),
|
|
});
|
|
await expect(page.getByText(/uploaded/i).first()).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Rename via the API since the rename dialog is exercised in
|
|
// stack-files.spec.ts; here we focus on the file appearing post-rename.
|
|
const renameRes = await request.patch(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files/rename`,
|
|
{
|
|
headers: { cookie, 'Content-Type': 'application/json' },
|
|
data: { from: 'lifecycle-rename-src.txt', to: 'lifecycle-rename-dst.txt' },
|
|
},
|
|
);
|
|
expect(renameRes.status()).toBe(204);
|
|
|
|
await openFilesTab(page);
|
|
await expect(
|
|
page.locator('span.font-mono').filter({ hasText: /^lifecycle-rename-dst\.txt$/ }).first(),
|
|
).toBeVisible({ timeout: 8_000 });
|
|
});
|
|
|
|
test('mkdir via the API then verify the new folder is visible in the tree', async ({ page, request }) => {
|
|
const cookie = await cookieHeader(page);
|
|
const res = await request.post(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files/folder?path=${encodeURIComponent('lifecycle-new-folder')}`,
|
|
{ headers: { cookie } },
|
|
);
|
|
expect(res.status()).toBe(204);
|
|
|
|
await openFilesTab(page);
|
|
await expect(
|
|
page.locator('span.font-mono').filter({ hasText: /^lifecycle-new-folder$/ }).first(),
|
|
).toBeVisible({ timeout: 8_000 });
|
|
});
|
|
|
|
for (const width of [1280]) {
|
|
for (const kind of ['file', 'folder'] as const) {
|
|
test(`modal footer contains New ${kind} actions with a long parent at ${width}px`, async ({ page }, testInfo) => {
|
|
const segments = ['footer-layout', 'nested-parent', 'x'.repeat(100)];
|
|
const parent = segments.join('/');
|
|
await fs.mkdir(nodePath.join(stackDir(), parent), { recursive: true });
|
|
await openFilesTab(page);
|
|
for (const segment of segments.slice(0, -1)) {
|
|
await page.getByRole('treeitem').filter({ has: page.getByText(segment, { exact: true }) }).click();
|
|
}
|
|
const row = page.getByRole('treeitem').filter({ has: page.getByText(segments[2], { exact: true }) });
|
|
await row.click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: new RegExp(`^New ${kind}$`, 'i') }).click();
|
|
await page.setViewportSize({ width, height: 844 });
|
|
const dialog = page.getByRole('dialog', { name: `New ${kind}`, exact: true });
|
|
await expect(dialog).toBeVisible();
|
|
await dialog.getByLabel(kind === 'file' ? 'File name' : 'Folder name').fill(`footer-${kind}-${width}`);
|
|
await expect(async () => {
|
|
const panel = await dialog.boundingBox();
|
|
expect(panel).not.toBeNull();
|
|
if (!panel) throw new Error('Dialog has no bounding box');
|
|
expect(panel.x).toBeGreaterThanOrEqual(8);
|
|
expect(panel.x + panel.width).toBeLessThanOrEqual(width - 8);
|
|
for (const name of ['Cancel', 'Create']) {
|
|
const action = await dialog.getByRole('button', { name, exact: true }).boundingBox();
|
|
expect(action).not.toBeNull();
|
|
if (!action) throw new Error(`${name} has no bounding box`);
|
|
expect(action.x).toBeGreaterThanOrEqual(panel.x);
|
|
expect(action.x + action.width).toBeLessThanOrEqual(panel.x + panel.width);
|
|
expect(action.y + action.height).toBeLessThanOrEqual(panel.y + panel.height);
|
|
}
|
|
expect(await dialog.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
|
|
}).toPass();
|
|
await testInfo.attach(`new-${kind}-${width}`, { body: await page.screenshot(), contentType: 'image/png' });
|
|
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
const created = await fs.stat(nodePath.join(stackDir(), parent, `footer-${kind}-${width}`));
|
|
expect(kind === 'file' ? created.isFile() : created.isDirectory()).toBe(true);
|
|
});
|
|
}
|
|
}
|
|
|
|
test('the New file toolbar button creates a root-level file', async ({ page }) => {
|
|
await page.getByRole('button', { name: 'New file' }).click();
|
|
await page.getByLabel('File name').fill('ui-new-file.txt');
|
|
await page.getByRole('button', { name: /^create$/i }).click();
|
|
|
|
await expect(page.getByText(/file created/i).first()).toBeVisible({ timeout: 8_000 });
|
|
await expect(
|
|
page.locator('span.font-mono').filter({ hasText: /^ui-new-file\.txt$/ }).first(),
|
|
).toBeVisible({ timeout: 8_000 });
|
|
});
|
|
|
|
test('creating a duplicate name is blocked and does not overwrite the existing file', async ({ page, request }) => {
|
|
const cookie = await cookieHeader(page);
|
|
const seedRes = await request.put(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('dup-guard.txt')}`,
|
|
{
|
|
headers: { cookie, 'Content-Type': 'application/json' },
|
|
data: { content: 'original\n' },
|
|
},
|
|
);
|
|
expect(seedRes.ok()).toBeTruthy();
|
|
|
|
await openFilesTab(page);
|
|
await expect(
|
|
page.locator('span.font-mono').filter({ hasText: /^dup-guard\.txt$/ }).first(),
|
|
).toBeVisible({ timeout: 8_000 });
|
|
|
|
await page.getByRole('button', { name: 'New file' }).click();
|
|
await page.getByLabel('File name').fill('dup-guard.txt');
|
|
await page.getByRole('button', { name: /^create$/i }).click();
|
|
|
|
await expect(page.getByText(/a file with that name already exists/i)).toBeVisible({ timeout: 8_000 });
|
|
// The server rejected the create, so the existing contents are intact.
|
|
const verifyRes = await request.get(
|
|
`/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('dup-guard.txt')}`,
|
|
{ headers: { cookie } },
|
|
);
|
|
expect(verifyRes.ok()).toBeTruthy();
|
|
const payload = await verifyRes.json() as { content?: string };
|
|
expect(payload.content).toBe('original\n');
|
|
});
|
|
|
|
test('right-clicking away from the filename still opens the Sencho context menu', async ({ page }) => {
|
|
const row = page.locator('[role="treeitem"]').filter({ hasText: 'compose.yaml' }).first();
|
|
await expect(row).toBeVisible({ timeout: 8_000 });
|
|
const box = await row.boundingBox();
|
|
if (!box) throw new Error('no bounding box for the compose.yaml row');
|
|
|
|
// Right-click near the right edge, well past the filename text: the whole
|
|
// row is the trigger, so the Sencho menu (not the native one) must open.
|
|
await row.click({ button: 'right', position: { x: box.width - 6, y: box.height / 2 } });
|
|
// Match the menu item by role: an earlier test leaves a file named
|
|
// lifecycle-rename-dst.txt in the tree, which a bare text match also hits.
|
|
await expect(page.getByRole('menuitem', { name: 'Rename', exact: true })).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test('a long filename overflows the pane horizontally instead of being clipped', async ({ page }) => {
|
|
const longName = 'a-really-long-file-name-that-should-overflow-the-narrow-file-tree-pane.txt';
|
|
await fs.writeFile(nodePath.join(stackDir(), longName), '');
|
|
await page.reload();
|
|
await openFilesTab(page);
|
|
await expect(
|
|
page.locator('span.font-mono').filter({ hasText: longName }).first(),
|
|
).toBeVisible({ timeout: 8_000 });
|
|
|
|
// The tree's scroll viewport overflows horizontally, so the name is reachable
|
|
// by scrolling rather than truncated.
|
|
const overflows = await page.getByTestId('file-tree-root-dropzone').evaluate((el) => {
|
|
const vp = el.closest('[data-radix-scroll-area-viewport]') as HTMLElement | null;
|
|
return vp ? vp.scrollWidth > vp.clientWidth : false;
|
|
});
|
|
expect(overflows).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Out-of-scope coverage stubs (documented skips)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('Stack file explorer: deferred coverage', () => {
|
|
test.skip('remote-node matrix: upload through proxy to a peer', () => {
|
|
// Requires a remote node enrolled in the CI database and a reachable
|
|
// peer at its api_url. Covered at the unit-test layer by the multipart
|
|
// proxy regression guard in backend/src/__tests__/stack-files-remote-upload.test.ts.
|
|
});
|
|
|
|
test.skip('mid-op disconnect: stop the remote, retry upload, observe 502 toast', () => {
|
|
// Same constraint: a real peer plus a controlled-disconnect harness.
|
|
});
|
|
|
|
test.skip('developer-mode matrix: assert diag lines appear only when developer_mode = 1', () => {
|
|
// Requires reading the backend's stdout stream from the Playwright harness.
|
|
// The backend unit test in stack-files-routes.test.ts already pins this
|
|
// behaviour via a console.debug spy.
|
|
});
|
|
|
|
test.skip('viewer-role tier-persona matrix', () => {
|
|
// Requires seeding a non-admin viewer user and re-driving the auth flow.
|
|
// Role gating is covered at the route-test layer in stack-files-routes.test.ts;
|
|
// the viewer-can-read / admin-can-write split is asserted there.
|
|
});
|
|
|
|
test.skip('large directory truncation: 1100 entries surface 1000 + X-Truncated header', () => {
|
|
// Seeding 1100 files inline added enough wall-clock and inode-table
|
|
// churn that subsequent specs running in the single-worker queue saw
|
|
// their post-login dashboard render time exceed 10 s. The truncation
|
|
// contract is fully exercised at the route-test layer (the same case
|
|
// lives in backend/src/__tests__/stack-files-routes.test.ts) and at
|
|
// the service-test layer (filesystem-stack-paths.test.ts).
|
|
});
|
|
|
|
test.skip('25 MB upload cap: a 25 MB + 1 byte payload trips multer LIMIT_FILE_SIZE', () => {
|
|
// Sending a 25 MB buffer through supertest piled enough memory pressure
|
|
// on the shared worker that downstream specs timed out on their login
|
|
// dashboard wait. The cap behaviour is exercised at the route-test
|
|
// layer in backend/src/__tests__/stack-files-routes.test.ts.
|
|
});
|
|
});
|