Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,187 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
export type TenantCredentials = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export function getTenantCredentials(): TenantCredentials | null {
|
||||
const email = process.env.E2E_TENANT_EMAIL;
|
||||
const password = process.env.E2E_TENANT_PASSWORD;
|
||||
if (!email || !password) {
|
||||
return null;
|
||||
}
|
||||
return { email, password };
|
||||
}
|
||||
|
||||
const LOGIN_URL_PATTERN = /\/login(?:[?#].*)?$/u;
|
||||
// Server-side auth throttling (apps/web/app/modules/users/services/AuthService.ts:
|
||||
// checkAuthRateLimit, 10 logins / 60s keyed by IP + email) trips during large
|
||||
// serial runs where every spec re-logs-in as the same tenant user from one IP.
|
||||
// On a 429 the action re-renders the login form (the URL stays on /login), so a
|
||||
// single submit can fail for an environmental reason rather than a real auth
|
||||
// failure. We retry the submit with a bounded backoff so the window can reset,
|
||||
// without weakening the final off-/login assertion.
|
||||
const LOGIN_MAX_ATTEMPTS = 4;
|
||||
const LOGIN_RETRY_DELAY_MS = 15_000;
|
||||
const LOGIN_OFF_URL_TIMEOUT_MS = 15_000;
|
||||
// Matches the "Too many attempts" copy (EN + DE) surfaced on a 429 so we only
|
||||
// back off for the rate-limit case and surface other errors immediately.
|
||||
const RATE_LIMIT_TEXT_PATTERN = /too many|zu viele/iu;
|
||||
|
||||
async function attemptLogin(page: Page, credentials: TenantCredentials): Promise<boolean> {
|
||||
await page.goto("/login", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.locator('[data-ui-id="ui.auth.login.email"], input[type="email"], input[name="email"], input#email').first().fill(credentials.email);
|
||||
await page
|
||||
.locator('[data-ui-id="ui.auth.login.password"], input[type="password"], input[name="password"], input#password')
|
||||
.first()
|
||||
.fill(credentials.password);
|
||||
|
||||
await Promise.all([page.waitForLoadState("domcontentloaded"), page.locator('button[type="submit"]').first().click()]);
|
||||
|
||||
// Give the session redirect time to complete before deciding the attempt
|
||||
// failed; waitForURL resolves as soon as we leave /login.
|
||||
await page.waitForURL((url) => !LOGIN_URL_PATTERN.test(url.toString()), { timeout: LOGIN_OFF_URL_TIMEOUT_MS }).catch(() => undefined);
|
||||
|
||||
return !LOGIN_URL_PATTERN.test(page.url());
|
||||
}
|
||||
|
||||
// Cache the authenticated cookies after the first real login and reuse them for
|
||||
// every later spec in the same (workers:1) run. Re-logging-in per test
|
||||
// saturates the server auth rate limit (10/60s per IP+email), which surfaces as
|
||||
// flaky "stayed on /login" failures across the large serial suite. /login
|
||||
// redirects an authenticated session away, so the cache can be validated
|
||||
// cheaply and a stale entry (e.g. after a sign-out test) falls back to a real
|
||||
// login.
|
||||
//
|
||||
// The cache is keyed by EMAIL: the suite logs in as multiple users (the tenant
|
||||
// admin AND a read-only fixture). A single global cache would hand a later
|
||||
// `loginAsTenantUser(readonlyCredentials)` call the admin's still-valid session
|
||||
// (so /login redirects away and the cache "matches"), silently running the
|
||||
// read-only test as the admin — which then sees mutation controls and fails.
|
||||
type StoredCookies = Awaited<ReturnType<ReturnType<Page["context"]>["cookies"]>>;
|
||||
const cachedCookiesByEmail = new Map<string, StoredCookies>();
|
||||
|
||||
async function reuseCachedLogin(page: Page, credentials: TenantCredentials): Promise<boolean> {
|
||||
const cached = cachedCookiesByEmail.get(credentials.email);
|
||||
if (!cached || cached.length === 0) return false;
|
||||
await page.context().addCookies(cached);
|
||||
await page.goto("/login", { waitUntil: "domcontentloaded" });
|
||||
if (!LOGIN_URL_PATTERN.test(page.url())) return true;
|
||||
cachedCookiesByEmail.delete(credentials.email);
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function loginAsTenantUser(page: Page, credentials: TenantCredentials): Promise<void> {
|
||||
if (await reuseCachedLogin(page, credentials)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rateLimited = false;
|
||||
for (let attempt = 0; attempt < LOGIN_MAX_ATTEMPTS; attempt += 1) {
|
||||
if (await attemptLogin(page, credentials)) {
|
||||
cachedCookiesByEmail.set(credentials.email, await page.context().cookies());
|
||||
return;
|
||||
}
|
||||
|
||||
// Still on /login. If the page is showing a rate-limit error, wait for the
|
||||
// window to reset and retry; anything else is a genuine failure.
|
||||
const rateLimitVisible = await page
|
||||
.getByText(RATE_LIMIT_TEXT_PATTERN)
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
rateLimited = rateLimited || rateLimitVisible;
|
||||
|
||||
if (!rateLimitVisible || attempt === LOGIN_MAX_ATTEMPTS - 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
await page.waitForTimeout(LOGIN_RETRY_DELAY_MS);
|
||||
}
|
||||
|
||||
const hint = rateLimited
|
||||
? " Server auth rate limit (10 logins / 60s per IP+email) appears to be throttling repeated logins; consider reusing a shared storageState for large runs."
|
||||
: "";
|
||||
await expect(page, `Expected to leave /login after submitting tenant credentials but stayed on ${page.url()}.${hint}`).not.toHaveURL(LOGIN_URL_PATTERN);
|
||||
}
|
||||
|
||||
export async function expectPasswordRequirementsVisible(requirements: Locator): Promise<void> {
|
||||
await expect(requirements).toBeVisible();
|
||||
const items = requirements.locator("li");
|
||||
await expect.poll(async () => items.count()).toBeGreaterThan(0);
|
||||
const labels = await items.evaluateAll((requirementsItems) => requirementsItems.map((item) => item.getAttribute("aria-label") ?? ""));
|
||||
for (const label of labels) {
|
||||
expect(label).toMatch(/^(met|not met|erfüllt|nicht erfüllt|account\.register\.passwordRequirements\.(?:met|notMet)):/iu);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectSomePasswordRequirementsMissing(requirements: Locator): Promise<void> {
|
||||
await expectPasswordRequirementsVisible(requirements);
|
||||
const labels = await requirements.locator("li").evaluateAll((items) => items.map((item) => item.getAttribute("aria-label") ?? ""));
|
||||
expect(labels.some((label) => /^(not met|nicht erfüllt|account\.register\.passwordRequirements\.notMet):/iu.test(label))).toBe(true);
|
||||
}
|
||||
|
||||
export async function expectAllPasswordRequirementsMet(requirements: Locator): Promise<void> {
|
||||
await expectPasswordRequirementsVisible(requirements);
|
||||
const items = requirements.locator("li");
|
||||
const count = await items.count();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
await expect(items.nth(index)).toHaveAttribute("aria-label", /^(met|erfüllt|account\.register\.passwordRequirements\.met):/iu);
|
||||
await expect(items.nth(index)).toContainText("[x]");
|
||||
}
|
||||
}
|
||||
|
||||
export function tenantSettingsProfilePath(dashboardPath: string): string {
|
||||
return tenantSettingsPath(dashboardPath, "profile", process.env.E2E_TENANT_SETTINGS_PROFILE_PATH ?? "/app/settings/profile");
|
||||
}
|
||||
|
||||
export function tenantSettingsAccountPath(dashboardPath: string): string {
|
||||
return tenantSettingsPath(dashboardPath, "account", process.env.E2E_TENANT_SETTINGS_ACCOUNT_PATH ?? "/app/settings/account");
|
||||
}
|
||||
|
||||
export function tenantSettingsSubscriptionPath(dashboardPath: string): string {
|
||||
return tenantSettingsPath(dashboardPath, "subscription", process.env.E2E_TENANT_SUBSCRIPTION_PATH ?? "/app/settings/subscription");
|
||||
}
|
||||
|
||||
export function tenantSettingsPoliciesPath(dashboardPath: string): string {
|
||||
return tenantSettingsPath(dashboardPath, "policies", process.env.E2E_TENANT_POLICIES_PATH ?? "/app/settings/policies");
|
||||
}
|
||||
|
||||
export function tenantSettingsNotificationsPath(dashboardPath: string): string {
|
||||
return tenantSettingsPath(dashboardPath, "notifications", process.env.E2E_TENANT_NOTIFICATIONS_PATH ?? "/app/settings/notifications");
|
||||
}
|
||||
|
||||
export function tenantSettingsMembersPath(dashboardPath: string): string {
|
||||
return tenantSettingsPath(dashboardPath, "members", process.env.E2E_TENANT_MEMBERS_PATH ?? "/app/settings/members");
|
||||
}
|
||||
|
||||
export function tenantSettingsLogsPath(dashboardPath: string): string {
|
||||
return tenantSettingsPath(dashboardPath, "logs", process.env.E2E_TENANT_LOGS_PATH ?? "/app/settings/logs");
|
||||
}
|
||||
|
||||
export function tenantSettingsLogEventsPath(dashboardPath: string): string {
|
||||
return tenantSettingsPath(dashboardPath, "logs/events", process.env.E2E_TENANT_LOG_EVENTS_PATH ?? "/app/settings/logs/events");
|
||||
}
|
||||
|
||||
export function tenantSettingsAuditExportPath(dashboardPath: string): string {
|
||||
return tenantSettingsPath(dashboardPath, "audit-export", process.env.E2E_TENANT_AUDIT_EXPORT_PATH ?? "/app/settings/audit-export");
|
||||
}
|
||||
|
||||
export function tenantSettingsChatbotPath(dashboardPath: string): string {
|
||||
return tenantSettingsPath(dashboardPath, "chatbot", process.env.E2E_TENANT_CHATBOT_PATH ?? "/app/settings/chatbot");
|
||||
}
|
||||
|
||||
export function tenantRolesAndPermissionsPath(dashboardPath: string): string {
|
||||
return tenantSettingsPath(dashboardPath, "roles-and-permissions", process.env.E2E_TENANT_ROLES_AND_PERMISSIONS_PATH ?? "/app/settings/roles-and-permissions");
|
||||
}
|
||||
|
||||
function tenantSettingsPath(dashboardPath: string, section: string, fallbackPath: string): string {
|
||||
const normalized = dashboardPath.startsWith("/") ? dashboardPath : `/${dashboardPath}`;
|
||||
const marker = "/dashboard";
|
||||
if (!normalized.endsWith(marker)) {
|
||||
return fallbackPath;
|
||||
}
|
||||
return `${normalized.slice(0, -marker.length)}/settings/${section}`;
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,56 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export const narrowMobileViewport = { width: 390, height: 844 };
|
||||
export const compactMobileViewport = { width: 320, height: 568 };
|
||||
|
||||
export async function expectNoHorizontalOverflow(page: Page): Promise<void> {
|
||||
const metrics = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const body = document.body;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const overflowingElements = Array.from(document.body.querySelectorAll<HTMLElement>("*"))
|
||||
.flatMap((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const overflowsViewport = rect.left < -1 || rect.right > viewportWidth + 1;
|
||||
const overflowsOwnBox = element.scrollWidth > element.clientWidth + 1 && rect.width > 0;
|
||||
if (!overflowsViewport && !overflowsOwnBox) return [];
|
||||
|
||||
const selector = [
|
||||
element.tagName.toLowerCase(),
|
||||
element.id ? `#${element.id}` : "",
|
||||
element.dataset.uiId ? `[data-ui-id="${element.dataset.uiId}"]` : "",
|
||||
element.className && typeof element.className === "string" ? `.${element.className.trim().split(/\s+/u).slice(0, 3).join(".")}` : "",
|
||||
].join("");
|
||||
|
||||
return [
|
||||
{
|
||||
clientWidth: element.clientWidth,
|
||||
left: Math.round(rect.left),
|
||||
right: Math.round(rect.right),
|
||||
scrollWidth: element.scrollWidth,
|
||||
selector,
|
||||
},
|
||||
];
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
return {
|
||||
bodyScrollWidth: body.scrollWidth,
|
||||
clientWidth: root.clientWidth,
|
||||
rootScrollWidth: root.scrollWidth,
|
||||
overflowingElements,
|
||||
viewportWidth,
|
||||
};
|
||||
});
|
||||
const overflowDetails = metrics.overflowingElements.length > 0 ? `; overflowing elements: ${JSON.stringify(metrics.overflowingElements)}` : "";
|
||||
|
||||
expect(
|
||||
metrics.rootScrollWidth,
|
||||
`root scrollWidth ${metrics.rootScrollWidth} exceeds viewport ${metrics.viewportWidth}${overflowDetails}`
|
||||
).toBeLessThanOrEqual(metrics.viewportWidth + 1);
|
||||
expect(
|
||||
metrics.bodyScrollWidth,
|
||||
`body scrollWidth ${metrics.bodyScrollWidth} exceeds viewport ${metrics.viewportWidth}${overflowDetails}`
|
||||
).toBeLessThanOrEqual(metrics.viewportWidth + 1);
|
||||
expect(metrics.clientWidth).toBeLessThanOrEqual(metrics.viewportWidth);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { test, type Page, type Response } from "@playwright/test";
|
||||
import { encode } from "turbo-stream";
|
||||
|
||||
const SKIPPABLE_STATUSES = new Set([400, 403, 404]);
|
||||
const STRIPE_HOST_PATTERN = /(^|\.)stripe\.com$/u;
|
||||
|
||||
/**
|
||||
* Wait until the client app has hydrated (`<html data-hydrated="true">`, set by
|
||||
* entry.client after the hydrateRoot commit). Server-rendered markup and
|
||||
* progressive-enhanced <Form>s work before this, but pure-JS controls (sidebar
|
||||
* drawer, command palette, dropdowns) only respond once React has attached
|
||||
* event handlers. On slower production targets (boiler) a click fired before
|
||||
* hydration is silently dropped, so interaction tests must wait for this first.
|
||||
*/
|
||||
export async function waitForHydration(page: Page, timeout = 15000): Promise<void> {
|
||||
await page
|
||||
.locator("html[data-hydrated='true']")
|
||||
.waitFor({ state: "attached", timeout })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function skipIfUnavailable(page: Page, response: Response | null, reason: string): Promise<void> {
|
||||
if (/\/login(?:[?#].*)?$/u.test(new URL(page.url()).pathname)) {
|
||||
if (process.env.REQUIRE_E2E_READINESS === "1") {
|
||||
throw new Error(`${reason}: redirected to login while REQUIRE_E2E_READINESS=1`);
|
||||
}
|
||||
test.skip(true, `${reason}: redirected to login`);
|
||||
}
|
||||
const responseStatus = response?.status();
|
||||
const bodyStatus = Number(await page.evaluate(() => document.body.dataset.statusCode ?? ""));
|
||||
const status = SKIPPABLE_STATUSES.has(responseStatus ?? 0) ? responseStatus : bodyStatus;
|
||||
if (SKIPPABLE_STATUSES.has(status)) {
|
||||
if (process.env.REQUIRE_E2E_READINESS === "1") {
|
||||
throw new Error(`${reason}: returned ${status} while REQUIRE_E2E_READINESS=1`);
|
||||
}
|
||||
test.skip(true, `${reason}: returned ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
export function postDataIncludesField(postData: string | null, name: string, valuePattern: RegExp): boolean {
|
||||
if (!postData) return false;
|
||||
try {
|
||||
const params = new URLSearchParams(postData);
|
||||
const value = params.get(name);
|
||||
if (value !== null) return valuePattern.test(value);
|
||||
} catch {
|
||||
// Fall through to multipart/string matching below.
|
||||
}
|
||||
const escapedName = escapeRegExp(name);
|
||||
if (!new RegExp(`name="${escapedName}"`, "u").test(postData)) return false;
|
||||
const multipartValue = postData.match(new RegExp(`name="${escapedName}"\\r?\\n\\r?\\n([^\\r\\n-]+)`, "u"))?.[1]?.trim();
|
||||
if (multipartValue !== undefined) return valuePattern.test(multipartValue);
|
||||
return valuePattern.test(postData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a React Router v7 single-fetch action error response. Form actions post
|
||||
* to the route's `.data` endpoint and the client decodes a turbo-stream body
|
||||
* (content-type `text/x-script`), NOT raw JSON — a faked `application/json` body
|
||||
* breaks the decoder so the route never receives `actionData` and the error UI
|
||||
* never renders. This reproduces the exact envelope the server emits for an
|
||||
* action that returns `{ error }` (captured live: an empty new-account name
|
||||
* yields `[{"_1":2},"data",{"_3":4},"error","Name is required"]\n`), letting a
|
||||
* test assert the real error path without triggering the action's side effect.
|
||||
* The `X-Remix-Response: yes` header is required — RR7's single-fetch client
|
||||
* rejects the response and renders the error boundary without it.
|
||||
*/
|
||||
export function singleFetchActionError(message: string): { status: number; body: string; contentType: string; headers: Record<string, string> } {
|
||||
return {
|
||||
status: 400,
|
||||
contentType: "text/x-script",
|
||||
body: `${JSON.stringify([{ _1: 2 }, "data", { _3: 4 }, "error", message])}\n`,
|
||||
headers: { "x-remix-response": "yes" },
|
||||
};
|
||||
}
|
||||
|
||||
async function encodeTurboStream(value: unknown): Promise<string> {
|
||||
const stream = encode(value);
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let out = "";
|
||||
for (;;) {
|
||||
const { done, value: chunk } = await reader.read();
|
||||
if (done) break;
|
||||
out += decoder.decode(chunk, { stream: true });
|
||||
}
|
||||
out += decoder.decode();
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a React Router v7 single-fetch action *success* response for an action
|
||||
* that returns `payload`. Like {@link singleFetchActionError}, but for the
|
||||
* resolved-data path: the server encodes `{ data: <actionReturnValue> }` via
|
||||
* turbo-stream and the client (`unwrapSingleFetchResult`) hands `.data` to
|
||||
* `useActionData()`. Fulfilling the `.data` POST with raw `application/json`
|
||||
* instead breaks RR7's turbo-stream decoder, so `actionData` is never populated
|
||||
* and the result/waiting UI never renders. We call the real `turbo-stream`
|
||||
* `encode` (same package RR7 uses) so arbitrarily-shaped payloads — e.g. a full
|
||||
* waiting-block execution — encode exactly as the server would. The
|
||||
* `X-Remix-Response: yes` header is required or RR7 rejects the response.
|
||||
*/
|
||||
export async function singleFetchActionData(
|
||||
payload: unknown,
|
||||
status = 200
|
||||
): Promise<{ status: number; body: string; contentType: string; headers: Record<string, string> }> {
|
||||
return {
|
||||
status,
|
||||
contentType: "text/x-script",
|
||||
body: await encodeTurboStream({ data: payload }),
|
||||
headers: { "x-remix-response": "yes" },
|
||||
};
|
||||
}
|
||||
|
||||
export async function interceptCurrentRoutePost(
|
||||
page: Page,
|
||||
// When `passthrough` is set, the POST is captured but allowed to reach the
|
||||
// server (used to assert the *real* single-fetch error response renders, since
|
||||
// a faked JSON body is not valid turbo-stream and breaks RR7's decoder).
|
||||
fulfill: { status?: number; body?: string; contentType?: string; headers?: Record<string, string>; passthrough?: boolean } = {}
|
||||
): Promise<{ value: string | null }> {
|
||||
const submittedPostData = { value: null as string | null };
|
||||
const currentUrl = new URL(page.url());
|
||||
// React Router v7 single-fetch posts form actions to the route's `.data`
|
||||
// endpoint (e.g. `/path.data`) rather than the bare pathname. Accept either
|
||||
// so the contract capture works for single-fetch and document submissions.
|
||||
const singleFetchPathname = `${currentUrl.pathname}.data`;
|
||||
|
||||
await page.route("**/*", async (route) => {
|
||||
const request = route.request();
|
||||
const requestUrl = new URL(request.url());
|
||||
if (
|
||||
request.method() === "POST" &&
|
||||
requestUrl.origin === currentUrl.origin &&
|
||||
(requestUrl.pathname === currentUrl.pathname || requestUrl.pathname === singleFetchPathname)
|
||||
) {
|
||||
submittedPostData.value = request.postData();
|
||||
if (fulfill.passthrough) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: fulfill.status ?? 204,
|
||||
body: fulfill.body ?? "",
|
||||
...(fulfill.contentType ? { contentType: fulfill.contentType } : {}),
|
||||
...(fulfill.headers ? { headers: fulfill.headers } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
return submittedPostData;
|
||||
}
|
||||
|
||||
export async function failOnStripeNetworkRequests(page: Page): Promise<void> {
|
||||
await page.route("**/*", async (route) => {
|
||||
const request = route.request();
|
||||
const requestUrl = new URL(request.url());
|
||||
if (STRIPE_HOST_PATTERN.test(requestUrl.hostname)) {
|
||||
throw new Error(`Unexpected Stripe network request during E2E smoke: ${request.method()} ${request.url()}`);
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { test } from "@playwright/test";
|
||||
|
||||
export function missingE2eInputs(keys: readonly string[], env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
return keys.filter((key) => !env[key]?.trim());
|
||||
}
|
||||
|
||||
export function optionalE2eBooleanInput(key: string, env: NodeJS.ProcessEnv = process.env): boolean | undefined {
|
||||
const value = env[key]?.trim();
|
||||
if (!value) {
|
||||
if (env.REQUIRE_E2E_READINESS === "1") {
|
||||
throw new Error(`${key}=0|1 must be set while REQUIRE_E2E_READINESS=1`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value === "0") return false;
|
||||
if (value === "1") return true;
|
||||
|
||||
throw new Error(`${key} must be 0 or 1, got ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
export function skipOrFailWhenE2eInputsMissing(keys: readonly string[], reason: string, env: NodeJS.ProcessEnv = process.env): void {
|
||||
const missing = missingE2eInputs(keys, env);
|
||||
if (missing.length === 0) return;
|
||||
|
||||
const message = `${reason}: missing ${missing.join(", ")}`;
|
||||
if (env.REQUIRE_E2E_READINESS === "1") {
|
||||
throw new Error(`${message} while REQUIRE_E2E_READINESS=1`);
|
||||
}
|
||||
test.skip(true, message);
|
||||
}
|
||||
|
||||
export function skipOrFailWhenE2eOptInMissing(key: string, reason: string, env: NodeJS.ProcessEnv = process.env): void {
|
||||
if (env[key] === "1") return;
|
||||
|
||||
const message = `${key}=1 not set - ${reason}`;
|
||||
if (env.REQUIRE_E2E_MUTATING_READINESS === "1") {
|
||||
throw new Error(`${message} while REQUIRE_E2E_MUTATING_READINESS=1`);
|
||||
}
|
||||
test.skip(true, message);
|
||||
}
|
||||
|
||||
export function skipOrFailWhenE2eReadinessOptInMissing(key: string, reason: string, env: NodeJS.ProcessEnv = process.env): void {
|
||||
if (env[key] === "1") return;
|
||||
|
||||
const message = `${key}=1 not set - ${reason}`;
|
||||
if (env.REQUIRE_E2E_READINESS === "1" || env.REQUIRE_E2E_MUTATING_READINESS === "1") {
|
||||
throw new Error(`${message} while REQUIRE_E2E_READINESS=1`);
|
||||
}
|
||||
test.skip(true, message);
|
||||
}
|
||||
|
||||
export function isE2eOperatorOptInEnabled(key: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return env[key] === "1";
|
||||
}
|
||||
|
||||
export function skipWhenE2eOperatorOptInMissing(key: string, reason: string, env: NodeJS.ProcessEnv = process.env): void {
|
||||
if (isE2eOperatorOptInEnabled(key, env)) return;
|
||||
|
||||
test.skip(true, `${key}=1 not set - ${reason}`);
|
||||
}
|
||||
|
||||
export function skipOrFailWhenE2eFixtureMissing(reason: string, env: NodeJS.ProcessEnv = process.env): never {
|
||||
if (env.REQUIRE_E2E_READINESS === "1") {
|
||||
throw new Error(`${reason} while REQUIRE_E2E_READINESS=1`);
|
||||
}
|
||||
test.skip(true, reason);
|
||||
throw new Error(reason);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
|
||||
import { waitForHydration } from "./navigation";
|
||||
|
||||
/**
|
||||
* Shared Playwright test wired to auto-wait for client hydration after every
|
||||
* full navigation (`page.goto` / `page.reload`). Server-rendered markup and
|
||||
* progressive-enhanced <Form>s are interactive immediately, but pure-JS shell
|
||||
* controls (sidebar drawer, command palette, dropdowns, locale picker) only
|
||||
* respond once React has hydrated. On slower production targets (boiler) a
|
||||
* click fired before hydration is silently dropped, which made every JS-driven
|
||||
* interaction test flaky/failing. Waiting on the `data-hydrated` marker here
|
||||
* keeps each spec free of per-navigation hydration boilerplate.
|
||||
*/
|
||||
export const test = base.extend<{ page: Page }>({
|
||||
page: async ({ page }, use) => {
|
||||
const originalGoto = page.goto.bind(page);
|
||||
page.goto = async (url, options) => {
|
||||
const response = await originalGoto(url, options);
|
||||
await waitForHydration(page);
|
||||
return response;
|
||||
};
|
||||
|
||||
const originalReload = page.reload.bind(page);
|
||||
page.reload = async (options) => {
|
||||
const response = await originalReload(options);
|
||||
await waitForHydration(page);
|
||||
return response;
|
||||
};
|
||||
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
export type { Page, Locator, Frame } from "@playwright/test";
|
||||
@@ -0,0 +1,121 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { missingE2eInputs, skipWhenE2eOperatorOptInMissing } from "./readiness";
|
||||
|
||||
/**
|
||||
* Shared helpers for asserting that a workflow execution reaches a terminal
|
||||
* status after the worker drains the outbox. Extracted from the workflow
|
||||
* builder-run spec so other readiness slices (e.g. plugin or subscription
|
||||
* flows that kick off workflows) can reuse the same polling contract.
|
||||
*/
|
||||
|
||||
export const workflowTerminalStatuses = new Set<string>(["success", "error"]);
|
||||
|
||||
const workflowExecutionStatusAliases = new Map([
|
||||
["success", "success"],
|
||||
["error", "error"],
|
||||
["running", "running"],
|
||||
["waiting", "waiting"],
|
||||
["waitingblock", "waitingBlock"],
|
||||
["incomplete", "incomplete"],
|
||||
]);
|
||||
|
||||
export const workflowOutboxDrainRequiredInputs = ["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_WORKFLOW_RUN_MANUAL_PATH"] as const;
|
||||
|
||||
/**
|
||||
* Operator gate for the outbox-drain readiness smoke. Skips when the operator
|
||||
* opt-in is missing, and fails loudly when the opt-in is set but its required
|
||||
* fixture inputs are absent — so a required readiness lane can never go green
|
||||
* by silently skipping.
|
||||
*/
|
||||
export function requireWorkflowOutboxDrainReadiness(env: NodeJS.ProcessEnv = process.env): void {
|
||||
if (env.E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN !== "1") {
|
||||
skipWhenE2eOperatorOptInMissing("E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN", "worker/outbox drain readiness is operator-gated");
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = missingE2eInputs(workflowOutboxDrainRequiredInputs, env);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`E2E workflow outbox-drain readiness opt-in missing ${missing.join(", ")} while E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN=1.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowOutboxDrainPollTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
||||
const parsed = Number.parseInt(env.E2E_WORKFLOW_OUTBOX_DRAIN_TIMEOUT_MS ?? "", 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 90_000;
|
||||
}
|
||||
|
||||
export function getWorkflowOutboxDrainExpectedStatus(env: NodeJS.ProcessEnv = process.env): "success" | "error" {
|
||||
const raw = env.E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS?.trim().toLowerCase() || "success";
|
||||
if (raw === "success" || raw === "error") return raw;
|
||||
throw new Error("E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS must be either success or error.");
|
||||
}
|
||||
|
||||
export function normalizeWorkflowExecutionStatus(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const normalized = value.trim().replace(/\s+/gu, "").toLowerCase();
|
||||
return workflowExecutionStatusAliases.get(normalized) ?? null;
|
||||
}
|
||||
|
||||
export function extractWorkflowExecutionStatusFromText(text: string): string | null {
|
||||
const lines = text
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const inlineStatus = line.match(/^status\s+([a-z]+)$/iu)?.[1];
|
||||
const normalizedInlineStatus = normalizeWorkflowExecutionStatus(inlineStatus);
|
||||
if (normalizedInlineStatus) return normalizedInlineStatus;
|
||||
|
||||
if (/^status$/iu.test(line)) {
|
||||
for (const candidate of lines.slice(index + 1, index + 4)) {
|
||||
const normalizedCandidate = normalizeWorkflowExecutionStatus(candidate);
|
||||
if (normalizedCandidate) return normalizedCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readWorkflowExecutionStatus(page: Page, executionDetailsHref: string, pollIndex: number): Promise<string | null> {
|
||||
const pollUrl = new URL(executionDetailsHref, page.url());
|
||||
pollUrl.searchParams.set("e2ePoll", String(pollIndex));
|
||||
await page.goto(pollUrl.toString(), { waitUntil: "domcontentloaded" });
|
||||
await page
|
||||
.getByText(/^status$/i)
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 5_000 })
|
||||
.catch(() => undefined);
|
||||
return extractWorkflowExecutionStatusFromText(await page.locator("body").innerText());
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the execution details view until the workflow reaches the expected
|
||||
* terminal status (success/error) after the worker drains the outbox, or fails
|
||||
* once the drain timeout elapses. Returns the observed terminal status.
|
||||
*/
|
||||
export async function waitForWorkflowExecutionTerminal(page: Page, executionDetailsHref: string): Promise<"success" | "error"> {
|
||||
const expectedStatus = getWorkflowOutboxDrainExpectedStatus();
|
||||
let pollIndex = 0;
|
||||
let observedStatus: string | null = null;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
pollIndex += 1;
|
||||
observedStatus = await readWorkflowExecutionStatus(page, executionDetailsHref, pollIndex);
|
||||
return observedStatus ?? "unknown";
|
||||
},
|
||||
{
|
||||
message: `workflow execution reached expected ${expectedStatus} status after worker/outbox drain`,
|
||||
timeout: getWorkflowOutboxDrainPollTimeoutMs(),
|
||||
intervals: [1_000, 2_000, 5_000],
|
||||
}
|
||||
)
|
||||
.toBe(expectedStatus);
|
||||
|
||||
// The poll only resolves once the observed status equals the expected terminal
|
||||
// status, so the observed value is guaranteed to be a terminal status here.
|
||||
expect(observedStatus).toBe(expectedStatus);
|
||||
return expectedStatus;
|
||||
}
|
||||
Reference in new issue
Block a user