tests/util.ts
Rama main · réplica de lectura del repositorio original.
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();
}
}