Ctrl K
ComunidadGuías…

tests/util.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { createHash } from "node:crypto";
import {
  argosScreenshot,
  type ArgosScreenshotOptions,
} from "@argos-ci/playwright";
import {
  devices,
  expect,
  test,
  type Page,
  type TestInfo,
} from "@playwright/test";

import {
  TeamUser,
  type Team,
  type User,
} from "../apps/backend/src/database/models";

export async function ensureTeamOwner({
  team,
  user,
}: {
  team: Team;
  user: User;
}) {
  await TeamUser.query()
    .insert({ teamId: team.id, userId: user.id, userLevel: "owner" })
    .onConflict(["teamId", "userId"])
    .ignore();
}

/**
 * Get a unique test identifier across retries.
 */
export function getUniqueTestIdentifier(testInfo: TestInfo) {
  const shortId = createHash("sha256")
    .update(testInfo.testId)
    .digest("hex")
    .slice(0, 6);
  if (testInfo.retry > 0) {
    return `${shortId}-${testInfo.retry}`;
  }
  return shortId;
}

async function replaceText(
  page: Page,
  replacements: Record<string, string>,
  options: {
    /** Replace the texts that are there, without requiring each to show. */
    lenient?: boolean;
  } = {},
): Promise<() => Promise<void>> {
  const storeKey = `__replaceTextStore_${Date.now()}`;

  await page.evaluate((storeKey) => {
    (window as any)[storeKey] = [];
  }, storeKey);

  for (const [search, replace] of Object.entries(replacements)) {
    // Expect that at least one text is visible.
    if (!options.lenient) {
      await expect(
        page.getByText(search, { exact: false }).first(),
      ).toBeVisible();
    }
    await page.getByText(search, { exact: false }).evaluateAll(
      (elements, { search, replace, storeKey }) => {
        type TextMod = { type: "text"; node: Text; original: string };
        type MergeMod = {
          type: "merge";
          anchor: Text;
          anchorOriginal: string;
          removed: Text[];
        };
        type Mod = TextMod | MergeMod;

        const mods: Mod[] = (window as any)[storeKey];

        function processElement(el: Element) {
          let run: Text[] = [];

          function flushRun() {
            if (run.length === 0) {
              return;
            }

            if (run.length === 1) {
              const node = run[0]!;
              const original = node.textContent ?? "";
              const replaced = original.replaceAll(search, replace);
              if (replaced !== original) {
                mods.push({ type: "text", node, original });
                node.textContent = replaced;
              }
            } else {
              const combined = run.map((n) => n.textContent ?? "").join("");
              const replaced = combined.replaceAll(search, replace);
              if (replaced !== combined) {
                const anchor = run[0]!;
                const anchorOriginal = anchor.textContent ?? "";
                const removed = run.slice(1);
                mods.push({ type: "merge", anchor, anchorOriginal, removed });
                anchor.textContent = replaced;
                for (const node of removed) {
                  node.remove();
                }
              }
            }

            run = [];
          }

          for (const child of Array.from(el.childNodes)) {
            if (child.nodeType === Node.TEXT_NODE) {
              run.push(child as Text);
            } else {
              flushRun();
              if (child.nodeType === Node.ELEMENT_NODE) {
                processElement(child as Element);
              }
            }
          }
          flushRun();
        }

        for (const el of elements) {
          processElement(el);
        }
      },
      { search, replace, storeKey },
    );
  }

  return async () => {
    await page.evaluate((storeKey) => {
      type TextMod = { type: "text"; node: Text; original: string };
      type MergeMod = {
        type: "merge";
        anchor: Text;
        anchorOriginal: string;
        removed: Text[];
      };
      type Mod = TextMod | MergeMod;

      const mods: Mod[] = (window as any)[storeKey] ?? [];
      for (const mod of [...mods].reverse()) {
        if (mod.type === "text") {
          mod.node.textContent = mod.original;
        } else {
          mod.anchor.textContent = mod.anchorOriginal;
          let ref: ChildNode = mod.anchor;
          for (const node of mod.removed) {
            ref.after(node);
            ref = node;
          }
        }
      }
      delete (window as any)[storeKey];
    }, storeKey);
  };
}

/**
 * Wait for the snapshot list to stop moving.
 *
 * Selecting a diff scrolls the list to it with `behavior: "smooth"`, and the
 * heading of the diff resolves long before the scroll lands, so a navigation
 * has to be settled before the next one is driven — otherwise two animations
 * overlap and the list comes to rest wherever the last one was interrupted,
 * which is a different offset every run.
 *
 * The quiet period is timed inside the page rather than by polling from here:
 * a loaded machine stretches a round trip as much as it stretches the
 * animation, so a sampler that pays four of them per attempt reads a moving
 * list and never agrees with itself.
 */
export async function waitForDiffListToSettle(page: Page) {
  const scroller = page.getByTestId("diff-list-scroller");
  await expect(scroller).toBeVisible();
  await scroller.evaluate(
    (el, { quietMs, timeoutMs }) =>
      new Promise<void>((resolve, reject) => {
        let quiet = 0;
        const stop = () => {
          clearTimeout(quiet);
          clearTimeout(expired);
          el.removeEventListener("scroll", restartQuietPeriod);
        };
        const expired = setTimeout(() => {
          stop();
          reject(
            new Error(`the snapshot list still scrolls after ${timeoutMs}ms`),
          );
        }, timeoutMs);
        const restartQuietPeriod = () => {
          clearTimeout(quiet);
          quiet = setTimeout(() => {
            stop();
            resolve();
          }, quietMs);
        };
        // The scroll is started by an effect, so it has not necessarily been
        // scheduled yet when this runs: give it a frame before counting.
        requestAnimationFrame(() =>
          requestAnimationFrame(() => {
            el.addEventListener("scroll", restartQuietPeriod);
            restartQuietPeriod();
          }),
        );
      }),
    { quietMs: 300, timeoutMs: 10_000 },
  );
}

/**
 * The phone a page laid out for phones is also captured at: the one the email
 * and notification previews use, so the build has a single phone width.
 */
const PHONE_VIEWPORT = "iphone-x";

/**
 * Whether the page renders at the device width on a phone, as
 * `ResponsiveViewport` sets the viewport. The build page, a test's page and a
 * shared media keep the desktop width there, so a narrow capture of them would
 * show a layout no phone ever gets.
 */
function checkIsPhoneLayout(page: Page) {
  return page.evaluate(() =>
    Boolean(
      document
        .querySelector<HTMLMetaElement>('meta[name="viewport"]')
        ?.content.includes("device-width"),
    ),
  );
}

export async function screenshot(
  page: Page,
  name: string,
  options: ArgosScreenshotOptions & {
    replacements?: Record<string, string>;
  } = {},
) {
  const { replacements, ...otherOptions } = options;
  const ctx: {
    restore: (() => Promise<void>) | null;
    lenient: boolean;
  } = { restore: null, lenient: false };
  const argosOptions: ArgosScreenshotOptions = {
    beforeScreenshot: async () => {
      if (replacements) {
        ctx.restore = await replaceText(page, replacements, {
          lenient: ctx.lenient,
        });
      }
    },
    afterScreenshot: async () => {
      if (ctx.restore) {
        await ctx.restore();
        ctx.restore = null;
      }
    },
    argosCSS: `
    [data-testid="avatar"] {
      background-color: #4527a0 !important;
    }
    ${otherOptions.argosCSS ?? ""}
    `,
    ...otherOptions,
  };
  await argosScreenshot(page, name, argosOptions);
  // A page laid out for phones is captured at a phone's width too, in the same
  // test: one more screenshot instead of a second run of the suite. Chromium
  // only, and whole pages only, which is what the layout changes.
  if (
    test.info().project.name === "chromium" &&
    !options.element &&
    !options.viewports &&
    (await checkIsPhoneLayout(page))
  ) {
    // A phone hides some of what the desktop shows, the test crumb among
    // them: a text to replace may not be there.
    ctx.lenient = true;
    await argosScreenshot(page, name, {
      ...argosOptions,
      viewports: [PHONE_VIEWPORT],
    });
  }
}

/**
 * Capture the page as a phone shows it, for a page that keeps the desktop
 * width there: `screenshot` only captures a phone layout. A desktop browser
 * ignores the viewport tag, so the page is opened again in an emulated phone,
 * where the desktop layout is zoomed out to the screen. Chromium only: Firefox
 * cannot emulate one.
 */
export async function phoneScreenshot(
  page: Page,
  name: string,
  options: {
    ready: (phone: Page) => Promise<void>;
    replacements?: Record<string, string>;
  },
) {
  if (test.info().project.name !== "chromium") {
    return;
  }
  const browser = page.context().browser();
  if (!browser) {
    throw new Error("The page should belong to a launched browser");
  }
  const { defaultBrowserType: _, ...phone } = devices["iPhone X"];
  const context = await browser.newContext({
    ...phone,
    storageState: await page.context().storageState(),
  });
  try {
    const phonePage = await context.newPage();
    await phonePage.goto(page.url());
    await options.ready(phonePage);
    if (await checkIsPhoneLayout(phonePage)) {
      throw new Error(
        `"${name}" is laid out for phones now: \`screenshot\` captures it on one already`,
      );
    }
    // The screen, as on the phone: the page is not scrolled to be captured.
    await screenshot(phonePage, `${name} vw-${phone.viewport.width}`, {
      fullPage: false,
      replacements: options.replacements,
    });
  } finally {
    await context.close();
  }
}