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, options: { /** Replace the texts that are there, without requiring each to show. */ lenient?: boolean; } = {}, ): Promise<() => Promise> { 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((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('meta[name="viewport"]') ?.content.includes("device-width"), ), ); } export async function screenshot( page: Page, name: string, options: ArgosScreenshotOptions & { replacements?: Record; } = {}, ) { const { replacements, ...otherOptions } = options; const ctx: { restore: (() => Promise) | 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; replacements?: Record; }, ) { 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(); } }