Files
sencho/e2e/preferences-helpers.ts
Anso a54ebcc7e2 feat(fleet): separate the Fleet tabs into two sections, with a layout setting (#2092)
* feat(fleet): group the Fleet tabs into Observe and Operate, with a layout setting

The Fleet tab strip was ten tabs in one row, wider than a 1280px window.

The strip now sets the tabs under two labelled groups on one strip: Observe
(Overview, Readiness, Map, Docker Labels) and Operate (Snapshots, Blueprints,
Routing, Federation, Actions, Secrets). It scrolls sideways when the window is
too narrow and keeps the tab you are on in view. Every tab still opens with one
click and every tab keeps its value, so deep links and URL sync are unchanged.

A new account preference, Settings, Appearance, Fleet, Fleet tab layout, picks
how the strip is laid out: Grouped (default), Flat (the previous single row), or
Compact (Overview, Readiness, Map, Blueprints and Actions, with the rest under a
More menu that names the active hidden tab). It is saved to the account and
synced like the other appearance preferences, defaults to Grouped for a document
that lacks it, and is cleared with the rest of the preference cache when another
account signs in.

* refactor(fleet): drop the Observe and Operate labels and the old flat strip

The group labels only lengthened the tab row, and the previous single-row
layout is no longer needed. The Fleet tab strip is now one row: Overview,
Readiness, Map and Docker Labels, a vertical separator, then Snapshots,
Blueprints, Routing, Federation, Actions and Secrets. It still scrolls sideways
when the window is too narrow and keeps the active tab in view.

The Fleet tab layout setting now offers Flat (the default, this row) and
Compact (five tabs with the rest under More). The retired grouped value reads
as Flat.

* test(fleet): raise the cross-tab storage event without the StorageEvent constructor

The code quality scan flagged the two-argument StorageEvent calls as
superfluous trailing arguments. The tests now build the event from a
plain Event carrying the key and the new value, which is all the hook
reads.
2026-10-07 09:14:25 -04:00

197 lines
7.8 KiB
TypeScript

/**
* Shared helpers for seeding server-backed per-user preferences in E2E tests.
*
* The preference API is identity-guarded: every request carries the
* x-sencho-pref-user header, which the server compares against the
* authenticated user. Helpers accept any authenticated APIRequestContext:
* the per-test `request` fixture after its own login, or `page.request`
* (which shares the browser context's auth cookie).
*/
import type { APIRequestContext, APIResponse } from '@playwright/test';
import { request as pwRequest } from '@playwright/test';
import { TEST_USERNAME, TEST_PASSWORD } from './helpers';
/** The full 20-field appearance document the server schema validates. */
export const APPEARANCE_DOC = {
theme: 'oled', accent: 'violet', uiFont: 'Geist', monoFont: 'Geist Mono',
visualStyle: 'calm', headingStyle: 'clean', chartStyle: 'muted',
density: 'compact', logChipColorMode: 'unified',
borderBoost: 0, glow: 0.16, contrast: 0, typeScale: 1,
reducedEffects: true, reducedMotion: true, readability: false,
sidebarMode: 'fixed', sidebarWidth: 256,
anatomyMode: 'fixed', anatomyWidth: 640, fleetTabLayout: 'flat',
};
/** The four-field navigation document. */
export const NAVIGATION_DOC = {
mode: 'compact', quickLinks: ['dashboard', 'networking', 'auto-updates'], labels: true, align: 'left',
};
export interface PreferenceEnvelope {
schemaVersion: number;
revision: number;
updatedAt: number;
data?: Record<string, unknown>;
corrupt?: boolean;
}
/** A PUT response body: a live-document envelope without schemaVersion (the
* write path always produces a v1 live document, and the field is server
* metadata the writer does not echo). */
export interface PutResponseEnvelope {
domain: string;
revision: number;
updatedAt: number;
data: Record<string, unknown>;
}
export function prefHeaders(userId: number): Record<string, string> {
return { 'x-sencho-pref-user': String(userId) };
}
/** The authenticated user's numeric id (from the session the request carries). */
export async function currentUserId(request: APIRequestContext): Promise<number> {
const res = await request.get('/api/auth/check');
if (!res.ok()) throw new Error(`auth/check failed with ${res.status()}`);
const body = (await res.json()) as { user?: { userId?: number } };
const userId = body.user?.userId;
if (typeof userId !== 'number') throw new Error('auth/check returned no numeric userId');
return userId;
}
/** Per-minute limiter: wait ~5s and retry up to 4 times (~20s worst case). Non-429 responses return as-is. */
async function retryOn429(send: () => Promise<APIResponse>): Promise<APIResponse> {
let res = await send();
for (let attempt = 0; !res.ok() && res.status() === 429 && attempt < 4; attempt += 1) {
await new Promise((r) => setTimeout(r, 5_000));
res = await send();
}
return res;
}
/**
* Create (or reuse) a dedicated suite account, so the suite's preference rows
* are isolated from the shared admin account and from other suites. Returns
* the account id; use `deleteE2EUser` in afterAll. Defaults to viewer (the
* account must never mutate stacks, only its own preference rows); pass a
* role when the behavior under test needs that role's reachable views (the
* Smart bar's More group exists only where overflow-classified views survive
* the role filter).
*/
export async function ensureE2EUser(
request: APIRequestContext,
username: string,
password: string,
role: 'admin' | 'viewer' = 'viewer',
): Promise<number> {
const existing = await request.get('/api/users');
if (existing.ok()) {
const users = (await existing.json()) as Array<{ id: number; username: string }>;
const found = users.find((u) => u.username === username);
if (found) return found.id;
}
const create = await retryOn429(() => request.post('/api/users', {
data: { username, password, role },
}));
if (!create.ok()) throw new Error(`create ${username} failed with ${create.status()}`);
const body = (await create.json()) as { id: number };
return body.id;
}
/** Delete a dedicated suite account (best effort; the suite is over either way). */
export async function deleteE2EUser(request: APIRequestContext, userId: number): Promise<void> {
await request.delete(`/api/users/${userId}`).catch(() => undefined);
}
/** Read both domain envelopes for the user (identity-guarded GET). */
export async function getPreferences(request: APIRequestContext, userId: number): Promise<{
preferences: Record<string, PreferenceEnvelope | null>;
}> {
const res = await retryOn429(() =>
request.get('/api/user-preferences', { headers: prefHeaders(userId) }),
);
if (!res.ok()) throw new Error(`GET preferences failed with ${res.status()}`);
return (await res.json()) as { preferences: Record<string, PreferenceEnvelope | null> };
}
/**
* Write a full domain document as a known baseline. The row's current revision
* is read first, so the conditional PUT always targets it; a 409 is retried
* because a concurrent boot migration can move the revision between the read
* and the write. A 429 is retried in place so a hot limiter does not burn
* the 409 attempts.
*/
export async function putDomain(
request: APIRequestContext,
userId: number,
domain: 'appearance' | 'navigation',
doc: Record<string, unknown>,
): Promise<PutResponseEnvelope> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const rows = await getPreferences(request, userId);
const row = rows.preferences[domain];
const data = row
? { expectedRevision: row.revision, ...doc }
: { absent: true, ...doc };
const res = await retryOn429(() => request.put(`/api/user-preferences/${domain}`, {
headers: prefHeaders(userId),
data,
}));
if (res.ok()) return (await res.json()) as PutResponseEnvelope;
if (res.status() !== 409) throw new Error(`PUT ${domain} failed with ${res.status()}`);
}
throw new Error(`PUT ${domain} kept conflicting after 3 attempts`);
}
/**
* A suite-owned request context logged in as its own account, so suites that
* need an appearance/navigation baseline can seed it without touching the
* shared admin's rows. `disposePrefUser` cleans both up.
*/
export interface PrefUserContext {
request: APIRequestContext;
userId: number;
}
export async function seedPrefUser(
username: string,
password: string,
role: 'admin' | 'viewer' = 'viewer',
): Promise<PrefUserContext> {
const admin = await pwRequest.newContext({ baseURL: 'http://localhost:5173' });
const login = await admin.post('/api/auth/login', {
data: { username: TEST_USERNAME, password: TEST_PASSWORD },
});
if (!login.ok()) throw new Error(`admin login failed with ${login.status()}`);
const userId = await ensureE2EUser(admin, username, password, role);
await admin.dispose();
const request = await pwRequest.newContext({ baseURL: 'http://localhost:5173' });
const suiteLogin = await request.post('/api/auth/login', {
data: { username, password },
});
if (!suiteLogin.ok()) throw new Error(`login ${username} failed with ${suiteLogin.status()}`);
return { request, userId: await currentUserId(request) };
}
export async function disposePrefUser(
ctx: PrefUserContext | undefined,
admin: APIRequestContext | undefined,
): Promise<void> {
if (ctx && admin) await deleteE2EUser(admin, ctx.userId).catch(() => undefined);
if (ctx) await ctx.request.dispose().catch(() => undefined);
if (admin) await admin.dispose().catch(() => undefined);
}
/**
* An admin request context for suite-account lifecycle (create/delete users).
*/
export async function adminApiContext(): Promise<APIRequestContext> {
const admin = await pwRequest.newContext({ baseURL: 'http://localhost:5173' });
const login = await admin.post('/api/auth/login', {
data: { username: TEST_USERNAME, password: TEST_PASSWORD },
});
if (!login.ok()) throw new Error(`admin login failed with ${login.status()}`);
return admin;
}