mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
216 lines
7.1 KiB
TypeScript
216 lines
7.1 KiB
TypeScript
/**
|
|
* Base class for browser-mode page test harnesses.
|
|
*
|
|
* Owns the generic DOM / wait / interaction plumbing every page harness needs,
|
|
* so concrete harnesses (providers, attack-paths, …) only declare their own
|
|
* domain vocabulary. The DOM / wait / interaction primitives are `protected` —
|
|
* subclasses build their semantic API on top of them and tests don't reach
|
|
* them directly. The public members are the deliberate exceptions: `user`
|
|
* (harness tests spy on it) and the request-tracking assertion helpers
|
|
* (`requestLog`, `countRequests`, `lastRequestBody`) that page harnesses expose
|
|
* as domain vocab.
|
|
*
|
|
* Mount-agnostic on purpose: some pages are mounted by their harness, others
|
|
* (attack-paths) are rendered by the test directly, so a `render` here would
|
|
* only serve half the call sites. Mounting lives in `render-browser.tsx`, which
|
|
* wraps every render in the shared app shell (`app-shell.tsx`) — both kinds of
|
|
* call site reach it.
|
|
*
|
|
* Request tracking is opt-in via `trackRequests(worker)`, and unregisters
|
|
* itself when the test ends.
|
|
*/
|
|
import type { SetupWorker } from "msw/browser";
|
|
import { onTestFinished, vi } from "vitest";
|
|
import { userEvent } from "vitest/browser";
|
|
|
|
type RequestStartListener = (event: { request: Request }) => void;
|
|
|
|
export abstract class BrowserHarness<TFixture> {
|
|
readonly user = userEvent;
|
|
|
|
/**
|
|
* Every request MSW saw since `trackRequests` was wired, for assertions. The
|
|
* entry keeps a clone, so a payload assertion can read a body the app's own
|
|
* fetch already consumed.
|
|
*/
|
|
readonly requestLog: Array<{
|
|
method: string;
|
|
url: string;
|
|
request: Request;
|
|
}> = [];
|
|
|
|
private trackedWorker: SetupWorker | null = null;
|
|
private requestListener: RequestStartListener | null = null;
|
|
|
|
constructor(readonly fixture: TFixture) {}
|
|
|
|
// --- Request tracking (opt-in) ------------------------------------------
|
|
|
|
/** Start recording MSW requests into `requestLog`. Call once, after mounting. */
|
|
protected trackRequests(worker: SetupWorker): void {
|
|
const listener: RequestStartListener = ({ request }) => {
|
|
this.requestLog.push({
|
|
method: request.method,
|
|
url: request.url,
|
|
request: request.clone(),
|
|
});
|
|
};
|
|
this.trackedWorker = worker;
|
|
this.requestListener = listener;
|
|
worker.events.on("request:start", listener);
|
|
// The worker is module-level and shared across harnesses, so listeners
|
|
// would otherwise accumulate run over run. Drop only this harness's
|
|
// listener when the test ends — clearing the emitter would also silence
|
|
// listeners another harness or diagnostic owns.
|
|
onTestFinished(() => this.untrackRequests());
|
|
}
|
|
|
|
private untrackRequests(): void {
|
|
const worker = this.trackedWorker;
|
|
const listener = this.requestListener;
|
|
if (!worker || !listener) return;
|
|
|
|
worker.events.removeListener("request:start", listener);
|
|
this.trackedWorker = null;
|
|
this.requestListener = null;
|
|
}
|
|
|
|
countRequests(method: string, pathIncludes: string): number {
|
|
return this.requestLog.filter(
|
|
(r) => r.method === method && r.url.includes(pathIncludes),
|
|
).length;
|
|
}
|
|
|
|
/** Parsed JSON body of the most recent request matching method + path. */
|
|
async lastRequestBody<T = unknown>(
|
|
method: string,
|
|
pathIncludes: string,
|
|
): Promise<T | null> {
|
|
const entry = [...this.requestLog]
|
|
.reverse()
|
|
.find((r) => r.method === method && r.url.includes(pathIncludes));
|
|
return entry ? ((await entry.request.clone().json()) as T) : null;
|
|
}
|
|
|
|
// --- Low-level DOM ------------------------------------------------------
|
|
|
|
protected get container(): HTMLElement {
|
|
return document.body;
|
|
}
|
|
|
|
protected q(selector: string): HTMLElement | null {
|
|
return this.container.querySelector<HTMLElement>(selector);
|
|
}
|
|
|
|
protected byRoleName(
|
|
role: string,
|
|
name: RegExp,
|
|
scope: ParentNode = document,
|
|
): HTMLElement | null {
|
|
const explicit = Array.from(
|
|
scope.querySelectorAll<HTMLElement>(`[role="${role}"]`),
|
|
).find((el) => name.test(el.textContent ?? ""));
|
|
if (explicit) return explicit;
|
|
// A native <button> exposes role "button" implicitly, without the
|
|
// attribute — so it isn't matched by the `[role="button"]` query above.
|
|
if (role === "button") {
|
|
return (
|
|
Array.from(scope.querySelectorAll<HTMLElement>("button")).find(
|
|
(el) => !el.hasAttribute("role") && name.test(el.textContent ?? ""),
|
|
) ?? null
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
protected buttonByText(
|
|
name: RegExp,
|
|
scope: ParentNode = document,
|
|
): HTMLButtonElement | null {
|
|
return (
|
|
Array.from(scope.querySelectorAll<HTMLButtonElement>("button")).find(
|
|
(b) => name.test(b.textContent ?? ""),
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
protected inputByName(name: string): HTMLInputElement | null {
|
|
return this.q(`input[name="${name}"]`) as HTMLInputElement | null;
|
|
}
|
|
|
|
protected containsText(pattern: RegExp): boolean {
|
|
return pattern.test(this.container.textContent ?? "");
|
|
}
|
|
|
|
// --- Sync helpers -------------------------------------------------------
|
|
|
|
/** Wait until the predicate returns truthy and return that value. */
|
|
protected async waitFor<T>(
|
|
fn: () => T | null | undefined | false,
|
|
timeoutMs = 5000,
|
|
intervalMs = 30,
|
|
): Promise<T> {
|
|
return vi.waitFor(
|
|
() => {
|
|
const v = fn();
|
|
if (!v) throw new Error("waitFor predicate not yet truthy");
|
|
return v;
|
|
},
|
|
{ timeout: timeoutMs, interval: intervalMs },
|
|
) as Promise<T>;
|
|
}
|
|
|
|
protected async waitForText(
|
|
pattern: RegExp,
|
|
timeoutMs = 5000,
|
|
): Promise<void> {
|
|
await this.waitFor(() => this.containsText(pattern), timeoutMs);
|
|
}
|
|
|
|
protected async waitForButton(
|
|
name: RegExp,
|
|
timeoutMs = 5000,
|
|
): Promise<HTMLButtonElement> {
|
|
return this.waitFor(() => {
|
|
const btn = this.buttonByText(name);
|
|
return btn && !btn.disabled ? btn : null;
|
|
}, timeoutMs);
|
|
}
|
|
|
|
/**
|
|
* Sleep for a fixed duration to let a CSS/layout transition settle. Public
|
|
* because a few flows assert on animation-tail state that has no queryable
|
|
* settled signal; prefer waiting on an observable post-condition when one
|
|
* exists.
|
|
*/
|
|
async waitForTransition(ms = 350): Promise<void> {
|
|
await new Promise((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
// --- Interactions -------------------------------------------------------
|
|
|
|
/** Click via user-event, optionally falling back to a native DOM click. */
|
|
protected async clickElement(
|
|
element: HTMLElement,
|
|
options?: { fallbackToDomClick?: boolean },
|
|
): Promise<void> {
|
|
try {
|
|
await this.user.click(element);
|
|
} catch (error) {
|
|
if (!options?.fallbackToDomClick) throw error;
|
|
element.click();
|
|
}
|
|
}
|
|
|
|
protected async clickButton(name: RegExp): Promise<void> {
|
|
const btn = await this.waitForButton(name);
|
|
await this.user.click(btn);
|
|
}
|
|
|
|
/** Click a dropdown/menu item (rendered in a Radix portal) by its label. */
|
|
protected async clickMenuItem(name: RegExp): Promise<void> {
|
|
const item = await this.waitFor(() => this.byRoleName("menuitem", name));
|
|
await this.user.click(item);
|
|
}
|
|
}
|