Files
sencho/e2e/helpers.ts
T
Anso 5289f01bfd feat(onboarding): add first-run environment checker (#1290)
* feat(onboarding): add first-run environment checker

Add a preflight that checks whether the host can run Docker deploys before a
deploy fails for an avoidable reason. It verifies the Docker engine is reachable
and permitted, the Compose plugin is present, the compose directory is writable
and mounted at a matching host path, the dashboard is behind TLS, and the
compose volume has disk headroom. Each result that needs attention carries a
specific fix rather than a generic error, and the checks never block: an
operator who knows their setup can continue.

The checks run as the final step of first-boot setup and can be re-run any time
from the Recovery settings tab. A new admin-only endpoint,
GET /api/diagnostics/environment, backs both surfaces.

* fix(onboarding): distinguish unverified path mapping and support parent binds

Treat a container whose self-inspect fails as an unverified path-mapping warning
instead of a false "not containerized" pass, so an unverifiable mapping never
reads as healthy. Resolve the compose directory through the longest-prefix bind
mount and compare the host path it resolves to, so a parent bind such as
-v /opt:/opt correctly covers COMPOSE_DIR=/opt/compose instead of warning that
the directory is not bind-mounted.

* test(e2e): advance the setup wizard past the environment step in loginAs

The first-run setup helper clicked "Initialize console" and immediately waited
for the dashboard, but setup now shows an environment-preflight step before
landing the console. Click "Enter Sencho" to complete onboarding before
asserting the dashboard, so the first test on a fresh instance passes.
2026-06-02 21:40:38 -04:00

112 lines
5.0 KiB
TypeScript

/**
* Shared helpers for Sencho E2E tests.
*
* CREDENTIALS: Set E2E_USERNAME and E2E_PASSWORD env vars to match
* your dev instance's admin account. Defaults assume the initial setup
* was completed with username "admin" and password "password123".
*
* E2E_USERNAME=admin E2E_PASSWORD=mypassword npx playwright test
*/
import { Page, expect } from '@playwright/test';
import { OTP } from 'otplib';
const totp = new OTP({ strategy: 'totp' });
const TOTP_PARAMS = { algorithm: 'sha1' as const, digits: 6, period: 30 };
export const TEST_USERNAME = process.env.E2E_USERNAME ?? 'admin';
export const TEST_PASSWORD = process.env.E2E_PASSWORD ?? 'password123';
/**
* Generate a current TOTP code for the given base32 secret. Used by the MFA
* E2E tests to drive the login challenge without a real authenticator app.
*/
export function totpNow(secret: string): string {
return totp.generateSync({ secret: secret.replace(/\s+/g, ''), ...TOTP_PARAMS });
}
/** Selector for the dashboard - only present in EditorLayout, not on login/setup pages */
const DASHBOARD_INDICATOR = 'img[src*="sencho-logo"]';
/** Returns true if the current page is the first-run setup screen */
async function isSetupPage(page: Page): Promise<boolean> {
return page.locator('#confirmPassword, input[placeholder*="Confirm"]').isVisible().catch(() => false);
}
/** Returns true if the current page is the login screen */
async function isLoginPage(page: Page): Promise<boolean> {
return page.locator('button:has-text("Login"), button:has-text("Sign in")').isVisible().catch(() => false);
}
/** Returns true if the dashboard (EditorLayout) is loaded */
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.
*/
export async function loginAs(page: Page, username = TEST_USERNAME, password = TEST_PASSWORD) {
await page.goto('/');
// Wait for the app to finish its auth check (loading spinner disappears)
await page.waitForTimeout(500);
// ── First-run setup ───────────────────────────────────────────────────────
if (await isSetupPage(page)) {
await page.locator('#username').fill(username);
await page.locator('#password').fill(password);
const confirmInput = page.locator('#confirmPassword');
if (await confirmInput.isVisible()) await confirmInput.fill(password);
await page.locator('button[type="submit"]').click();
// Setup signs the new admin in and then shows an environment-preflight
// step; clicking "Enter Sencho" completes onboarding and lands the dashboard.
const enterButton = page.getByRole('button', { name: /enter sencho/i });
await expect(enterButton).toBeVisible({ timeout: 10_000 });
await enterButton.click();
await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 });
return;
}
// ── Login screen ─────────────────────────────────────────────────────────
if (await isLoginPage(page)) {
// Confirm the username field is actually visible before filling. If
// isLoginPage was a transient false positive (e.g. login form briefly
// rendered before auth check redirected to dashboard), the fill would
// hang forever waiting for #username to come back.
const usernameField = page.locator('#username');
const usernameVisible = await usernameField
.waitFor({ state: 'visible', timeout: 2_000 })
.then(() => true)
.catch(() => false);
if (usernameVisible) {
await usernameField.fill(username);
await page.locator('#password').fill(password);
await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click();
await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 });
return;
}
// Fall through to the dashboard check below.
}
// ── Already on the dashboard ──────────────────────────────────────────────
if (await isDashboard(page)) {
return;
}
throw new Error(
'loginAs: could not determine page state - expected setup, login, or dashboard. ' +
'Check that E2E_USERNAME and E2E_PASSWORD are set correctly.',
);
}