← back to Open Seo

e2e/domain-overview-test-utils.ts

376 lines

import { writeFile } from "node:fs/promises";
import {
  expect,
  type CDPSession,
  type Locator,
  type Page,
  type TestInfo,
} from "@playwright/test";

// Real .com suffix on purpose: form-submit validation (isValidDomainHost via
// tldts) rejects the reserved .example TLD, and these constants reach the one
// test that submits through the real form instead of URL navigation.
export const PRIMARY_TEST_DOMAIN = "primary.example.com";
export const SECONDARY_TEST_DOMAIN = "secondary.example.com";
const RESPONSIVE_TIMEOUT_MS = 1_500;
const INPUT_LATENCY_BUDGET_MS = 8_000;

type DomainTab = "keywords" | "pages";

type DomainLongTask = {
  name: string;
  startTime: number;
  duration: number;
};

type DomainRafGap = {
  startTime: number;
  duration: number;
};

type DomainInputEvent = {
  label: string;
  duration: number;
  time: number;
};

type DomainPerfState = {
  startedAt: number;
  longTasks: DomainLongTask[];
  rafGaps: DomainRafGap[];
  maxRafGap: number;
  inputEvents: DomainInputEvent[];
  errors: string[];
};

type WindowWithDomainPerf = Window &
  typeof globalThis & {
    __domainPerf?: DomainPerfState;
  };

export type DomainPerfMetrics = DomainPerfState & {
  url: string;
  userAgent: string;
  maxLongTaskDuration: number;
  totalLongTaskDuration: number;
  maxInputDuration: number;
};

export async function installDomainPerfProbe(page: Page) {
  await page.addInitScript(() => {
    const state: DomainPerfState = {
      startedAt: performance.now(),
      longTasks: [],
      rafGaps: [],
      maxRafGap: 0,
      inputEvents: [],
      errors: [],
    };
    const win = window as WindowWithDomainPerf;
    win.__domainPerf = state;

    try {
      const observer = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          state.longTasks.push({
            name: entry.name,
            startTime: entry.startTime,
            duration: entry.duration,
          });
        }
      });
      observer.observe({ type: "longtask", buffered: true });
    } catch {
      // Long Tasks are Chromium-only. The rest of the probe still catches stalls.
    }

    let lastFrame = performance.now();
    const tick = (now: number) => {
      const gap = now - lastFrame;
      state.maxRafGap = Math.max(state.maxRafGap, gap);
      if (gap > 100) {
        state.rafGaps.push({ startTime: lastFrame, duration: gap });
      }
      lastFrame = now;
      requestAnimationFrame(tick);
    };
    requestAnimationFrame(tick);

    window.addEventListener("error", (event) => {
      state.errors.push(event.message);
    });
    window.addEventListener("unhandledrejection", (event) => {
      state.errors.push(String(event.reason));
    });
  });
}

export async function resetDomainPerfMetrics(page: Page) {
  await page.evaluate(() => {
    const state = (window as WindowWithDomainPerf).__domainPerf;
    if (!state) return;
    state.startedAt = performance.now();
    state.longTasks = [];
    state.rafGaps = [];
    state.maxRafGap = 0;
    state.inputEvents = [];
    state.errors = [];
  });
}

export async function getDomainPerfMetrics(
  page: Page,
): Promise<DomainPerfMetrics> {
  return page.evaluate(() => {
    const state = (window as WindowWithDomainPerf).__domainPerf;
    if (!state) {
      throw new Error("Domain perf probe was not installed");
    }

    return {
      ...state,
      url: window.location.href,
      userAgent: navigator.userAgent,
      maxLongTaskDuration: Math.max(
        0,
        ...state.longTasks.map((entry) => entry.duration),
      ),
      totalLongTaskDuration: state.longTasks.reduce(
        (total, entry) => total + entry.duration,
        0,
      ),
      maxInputDuration: Math.max(
        0,
        ...state.inputEvents.map((entry) => entry.duration),
      ),
    };
  });
}

export async function attachDomainPerfMetrics(
  testInfo: TestInfo,
  metrics: DomainPerfMetrics,
) {
  await attachJsonArtifact(testInfo, "domain-filter-perf.json", metrics);
}

export async function attachJsonArtifact(
  testInfo: TestInfo,
  name: string,
  value: unknown,
) {
  const outputPath = testInfo.outputPath(name);
  await writeFile(outputPath, JSON.stringify(value, null, 2));
  await testInfo.attach(name, {
    path: outputPath,
    contentType: "application/json",
  });
}

export async function openDomainOverview(page: Page, tab: DomainTab) {
  await page.addInitScript(() => {
    if (window.sessionStorage.getItem("domain-overview-e2e-cleared") === "1") {
      return;
    }
    for (let index = window.localStorage.length - 1; index >= 0; index -= 1) {
      const key = window.localStorage.key(index);
      if (key?.startsWith("domain-overview-filter-defaults:")) {
        window.localStorage.removeItem(key);
      }
    }
    window.sessionStorage.setItem("domain-overview-e2e-cleared", "1");
  });
  await page.goto("/");
  await page.waitForURL(/\/p\/([^/]+)\/keywords(?:\?.*)?$/, {
    timeout: 30_000,
  });

  const match = page.url().match(/\/p\/([^/]+)\/keywords/);
  if (!match) throw new Error(`Could not read project id from ${page.url()}`);

  const params = new URLSearchParams({
    domain: PRIMARY_TEST_DOMAIN,
    subdomains: "true",
    sort: "traffic",
    order: "desc",
  });
  if (tab === "pages") params.set("tab", "pages");

  await page.goto(`/p/${match[1]}/domain?${params.toString()}`);
  await expect(
    page.getByRole("heading", { name: "Domain Overview" }),
  ).toBeVisible();
  await dismissSetupModal(page);
  await expect(page.getByRole("button", { name: /Filters/ })).toBeVisible({
    timeout: 30_000,
  });
  await expectPageResponsive(page, "after opening Domain Overview");
}

async function dismissSetupModal(page: Page) {
  const dismissButton = page.getByRole("button", { name: "Dismiss" });
  if (await dismissButton.isVisible()) {
    await dismissButton.click();
  }
}

export async function waitForDomainRows(page: Page, label: string) {
  const marker = label.includes("Pages")
    ? page.getByRole("link", { name: /\/section-a\/page-001/ }).first()
    : page
        .getByRole("row", {
          name: /primary sample query.*\/section-a\/page-001/,
        })
        .first();
  await expect(marker, label).toBeVisible({ timeout: 30_000 });
  await expectPageResponsive(page, `after table rows loaded for ${label}`);
}

export async function switchDomainTab(page: Page, tab: DomainTab) {
  const label = tab === "keywords" ? "Top Keywords" : "Top Pages";
  await page.getByRole("tab", { name: label }).click();
  await expect(page.getByRole("tab", { name: label })).toHaveAttribute(
    "aria-selected",
    "true",
  );
  await expectPageResponsive(page, `after switching to ${label}`);
}

export async function openFilters(page: Page) {
  await page.getByRole("button", { name: /Filters/ }).click();
  await expect(page.getByText("Refine table results")).toBeVisible();
  await expectPageResponsive(page, "after opening filters");
}

export async function closeFilters(page: Page) {
  await page.getByRole("button", { name: /Filters/ }).click();
  await expect(page.getByText("Refine table results")).toBeHidden();
  await expectPageResponsive(page, "after closing filters");
}

export async function ensureFiltersOpen(page: Page, expectedLabel: string) {
  if (!(await page.getByText(expectedLabel).isVisible())) {
    await openFilters(page);
  }
  await expect(page.getByText(expectedLabel)).toBeVisible();
}

export async function applyFilters(
  page: Page,
  expectedParam = "minTraffic",
  expectedValue = "10",
) {
  await page.getByRole("button", { name: /Apply filters/ }).click();
  await expect
    .poll(() => new URL(page.url()).searchParams.get(expectedParam))
    .toBe(expectedValue);
  await expect(page.getByRole("button", { name: /Filters/ })).toContainText(
    "1",
  );
  await expectPageResponsive(page, "after applying filters");
}

export async function typeIntoDraftInput(
  page: Page,
  input: Locator,
  value: string,
  label: string,
  options?: {
    actionTimeoutMs?: number;
    cdpSession?: CDPSession;
    inputLatencyBudgetMs?: number;
    recordPerf?: boolean;
  },
) {
  const actionTimeout = options?.actionTimeoutMs ?? 5_000;
  const budget = options?.inputLatencyBudgetMs ?? INPUT_LATENCY_BUDGET_MS;
  const started = Date.now();
  await input.click({ timeout: actionTimeout });
  if (options?.cdpSession) {
    await withTimeout(
      input.evaluate((element) => {
        if (!(element instanceof HTMLInputElement)) {
          throw new Error("Expected a text input");
        }
        element.value = "";
        element.dispatchEvent(new Event("input", { bubbles: true }));
      }),
      actionTimeout,
      `Input clear did not settle for ${label}`,
    );
  } else {
    await input.press(process.platform === "darwin" ? "Meta+A" : "Control+A", {
      timeout: actionTimeout,
    });
    await input.press("Backspace", { timeout: actionTimeout });
  }

  for (let index = 0; index < value.length; index += 1) {
    const char = value[index] ?? "";
    const keyStarted = Date.now();
    if (options?.cdpSession) {
      await withTimeout(
        options.cdpSession.send("Input.dispatchKeyEvent", {
          type: "char",
          text: char,
        }),
        actionTimeout,
        `CDP input did not settle for ${label} char ${index + 1}`,
      );
    } else {
      await input.press(char, { timeout: actionTimeout });
    }
    const keyDuration = Date.now() - keyStarted;
    if (options?.recordPerf) {
      await recordInputLatency(page, `${label} char ${index + 1}`, keyDuration);
    }
    await expectPageResponsive(page, `after typing ${label}`);
  }

  const duration = Date.now() - started;
  if (options?.recordPerf) {
    await recordInputLatency(page, label, duration);
  }
  expect(duration, `${label} input latency`).toBeLessThan(budget);
}

export async function expectPageResponsive(page: Page, label: string) {
  await withTimeout(
    page.evaluate(() => document.body.textContent?.includes("Domain Overview")),
    RESPONSIVE_TIMEOUT_MS,
    `Page did not respond ${label}`,
  );
}

async function recordInputLatency(page: Page, label: string, duration: number) {
  await page.evaluate(
    ({ label: eventLabel, duration: eventDuration }) => {
      const state = (window as WindowWithDomainPerf).__domainPerf;
      state?.inputEvents.push({
        label: eventLabel,
        duration: eventDuration,
        time: performance.now(),
      });
    },
    { label, duration },
  );
}

async function withTimeout<T>(
  promise: Promise<T>,
  timeoutMs: number,
  message: string,
): Promise<T> {
  let timer: ReturnType<typeof setTimeout> | undefined;
  try {
    return await Promise.race([
      promise,
      new Promise<never>((_, reject) => {
        timer = setTimeout(() => reject(new Error(message)), timeoutMs);
      }),
    ]);
  } finally {
    if (timer) clearTimeout(timer);
  }
}