mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 20:00:08 +00:00
6fa0272e79
* fix(frontend): replace post-dismissal blur with click-to-restore pill PaidGate and AdmiralGate fell through to a "blurred children + small pill" render when a user clicked Dismiss on the full-page upsell, for the next 24h. The blurred-children path rendered the gated subtree, so any lazy chunk behind the gate (FleetView, AuditLogView, etc.) fetched on click during the dismissal window even though the user saw only an obscured preview. This was the last lazy-chunk leakage path remaining after the recent splitting work; CapabilityGate's short-circuit refactor closed the others. Split the dismissed branch from the compact branch. Compact mode (used for inline list-item locks like a single SSO provider card) is unchanged: its blur is intentional UX and the IP exposure is minor because the children are tiny inline UI. Dismissed mode now renders only a small pill in an empty 200px-tall area, with no children mounted, so the lazy chunks behind the gate never fetch during the dismissal window. The pill is a button: clicking it removes the dismissal flag from localStorage and re-renders the full upsell card. Users who dismissed accidentally or want to revisit pricing have a way back without clearing site data manually. The dismissal flow itself is preserved: users who want to mute the upsell pressure for 24h still can, they just get the static pill instead of a blurred preview during that window. * test(e2e): narrow upgrade-pill locator to file-upload button The dismissed PaidGate branch introduced in this PR now renders a <button> (was a <div>), which matched the same /upgrade to unlock/i regex as the FileUploadDropzone button. Narrowing to /upgrade to unlock upload/i targets only the file-upload pill and resolves the strict-mode locator ambiguity.
352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
/**
|
|
* 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 upload/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);
|
|
});
|
|
});
|