mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
The check sampled getAnimations() once after the sheet showed, which passes or fails on whether WebKit drew a frame in between. It now reads the sheet and backdrop the moment they are inserted, the worst case, and is deterministic: it fails every time against the old classes. The other measurements in the spec already wait on polls, rendered frames or layout.
561 lines
21 KiB
TypeScript
561 lines
21 KiB
TypeScript
import type { Locator, Page } from "@playwright/test";
|
|
|
|
import { mockDay } from "./setup/day-mock";
|
|
import { expect, test } from "./setup/test";
|
|
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
|
|
/**
|
|
* The bottom sheet's drag on WebKit with an iPhone profile (the
|
|
* `webkit-iphone` project), against the three kinds of bottom sheet the app
|
|
* has: the More sheet (a plain `<Sheet side="bottom">`), the day sheet (the
|
|
* preview step) and a capture form (`ResponsiveSheet` on a phone).
|
|
*
|
|
* Playwright can tap on WebKit but not move a touch, so a drag is dispatched
|
|
* through the page as the browser would deliver it: `pointerdown` /
|
|
* `pointermove` / `pointerup` with `pointerType: "touch"` and the matching
|
|
* `touchstart` / `touchmove` / `touchend`, on the element under the finger,
|
|
* paced in real time so the release speed is real. Nothing calls into the
|
|
* sheet's own code. What synthetic events cannot do is scroll natively, so
|
|
* "content scrolls first" is proven as "a scrolled body does not move the
|
|
* sheet", with the scroll position set the way a scroll would leave it.
|
|
*
|
|
* Stable hooks only: `bottom-nav-more` / `bottom-nav-more-sheet`,
|
|
* `day-panel` with `data-sheet-snap`, `day-body`, `day-footer`,
|
|
* `sheet-grip`, `sheet-close-button`, `sheet-overlay`, `capture-picker-*`.
|
|
*/
|
|
|
|
interface Drag {
|
|
dx?: number;
|
|
dy: number;
|
|
/** Duration of the movement, ms. */
|
|
ms: number;
|
|
steps?: number;
|
|
/** Rest before lifting the finger, ms (a slow, deliberate release). */
|
|
holdMs?: number;
|
|
/**
|
|
* After the rest, keep the finger down until the page has rendered this
|
|
* many frames. The sheet writes its position once per animation frame, and
|
|
* WebKit in the CI container can go a second or more without rendering a
|
|
* frame while timers and events run on (the trace shows the More sheet
|
|
* still sliding in 1.5 s after its animation reported finished). A check
|
|
* on where the sheet was drawn needs those frames; a check on where it
|
|
* ends up does not, because the release is decided from the touch alone.
|
|
*/
|
|
holdFrames?: number;
|
|
}
|
|
|
|
/** A touch drag from (x, y), dispatched as WebKit delivers one. */
|
|
async function touchDrag(page: Page, x: number, y: number, drag: Drag) {
|
|
await page.evaluate(
|
|
async ({ x, y, dx, dy, ms, steps, holdMs, holdFrames }) => {
|
|
const target = document.elementFromPoint(x, y);
|
|
if (!target) throw new Error(`nothing under ${x},${y}`);
|
|
const id = 11;
|
|
const touchAt = (cx: number, cy: number) => {
|
|
const init = {
|
|
identifier: id,
|
|
target,
|
|
clientX: cx,
|
|
clientY: cy,
|
|
pageX: cx,
|
|
pageY: cy,
|
|
screenX: cx,
|
|
screenY: cy,
|
|
radiusX: 4,
|
|
radiusY: 4,
|
|
force: 1,
|
|
};
|
|
try {
|
|
return new Touch(init);
|
|
} catch {
|
|
return init as unknown as Touch;
|
|
}
|
|
};
|
|
const touchEvent = (type: string, cx: number, cy: number) => {
|
|
const touch = touchAt(cx, cy);
|
|
const touches = type === "touchend" ? [] : [touch];
|
|
try {
|
|
return new TouchEvent(type, {
|
|
touches,
|
|
targetTouches: touches,
|
|
changedTouches: [touch],
|
|
bubbles: true,
|
|
cancelable: true,
|
|
composed: true,
|
|
});
|
|
} catch {
|
|
const event = new Event(type, {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
composed: true,
|
|
});
|
|
Object.defineProperty(event, "touches", { value: touches });
|
|
Object.defineProperty(event, "targetTouches", { value: touches });
|
|
Object.defineProperty(event, "changedTouches", { value: [touch] });
|
|
return event;
|
|
}
|
|
};
|
|
const fire = (
|
|
pointer: "pointerdown" | "pointermove" | "pointerup",
|
|
cx: number,
|
|
cy: number,
|
|
) => {
|
|
target.dispatchEvent(
|
|
new PointerEvent(pointer, {
|
|
pointerId: id,
|
|
pointerType: "touch",
|
|
isPrimary: true,
|
|
clientX: cx,
|
|
clientY: cy,
|
|
buttons: pointer === "pointerup" ? 0 : 1,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
const touch = {
|
|
pointerdown: "touchstart",
|
|
pointermove: "touchmove",
|
|
pointerup: "touchend",
|
|
}[pointer];
|
|
target.dispatchEvent(touchEvent(touch, cx, cy));
|
|
};
|
|
const wait = (t: number) => new Promise((r) => setTimeout(r, t));
|
|
fire("pointerdown", x, y);
|
|
for (let i = 1; i <= steps; i += 1) {
|
|
await wait(ms / steps);
|
|
fire("pointermove", x + (dx * i) / steps, y + (dy * i) / steps);
|
|
}
|
|
if (holdMs) await wait(holdMs);
|
|
for (let i = 0; i < holdFrames; i += 1) {
|
|
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
|
}
|
|
fire("pointerup", x + dx, y + dy);
|
|
},
|
|
{
|
|
x,
|
|
y,
|
|
dx: drag.dx ?? 0,
|
|
dy: drag.dy,
|
|
ms: drag.ms,
|
|
steps: drag.steps ?? Math.max(4, Math.round(drag.ms / 16)),
|
|
holdMs: drag.holdMs ?? 0,
|
|
holdFrames: drag.holdFrames ?? 0,
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The browser's pointer capture for a dispatched pointer. A real touch is
|
|
* captured by the browser; a dispatched one is not, and Radix's slider only
|
|
* follows a pointer it holds. This gives it the same bookkeeping, nothing
|
|
* more: events still go to the element they are dispatched on.
|
|
*/
|
|
async function emulateCapture(page: Page) {
|
|
await page.addInitScript(() => {
|
|
const held = new WeakMap<Element, Set<number>>();
|
|
Element.prototype.setPointerCapture = function (id: number) {
|
|
const set = held.get(this) ?? new Set<number>();
|
|
set.add(id);
|
|
held.set(this, set);
|
|
};
|
|
Element.prototype.hasPointerCapture = function (id: number) {
|
|
return held.get(this)?.has(id) ?? false;
|
|
};
|
|
Element.prototype.releasePointerCapture = function (id: number) {
|
|
held.get(this)?.delete(id);
|
|
};
|
|
});
|
|
}
|
|
|
|
async function centre(locator: Locator) {
|
|
const box = await locator.boundingBox();
|
|
if (!box) throw new Error("not on screen");
|
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2, box };
|
|
}
|
|
|
|
async function topOf(locator: Locator) {
|
|
return (await locator.boundingBox())?.y ?? NaN;
|
|
}
|
|
|
|
/** Waits for the sheet to come to rest (open slide or settle finished). */
|
|
async function settled(locator: Locator) {
|
|
await expect
|
|
.poll(() =>
|
|
locator.evaluate((el) =>
|
|
el.getAnimations().every((a) => a.playState !== "running"),
|
|
),
|
|
)
|
|
.toBe(true);
|
|
// A finished animation is not a drawn one: WebKit can report the slide
|
|
// done while it has not rendered a frame of it yet. Two frames, then rest.
|
|
await locator
|
|
.page()
|
|
.evaluate(
|
|
() =>
|
|
new Promise<void>((resolve) =>
|
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
|
),
|
|
);
|
|
await locator.page().waitForTimeout(300);
|
|
}
|
|
|
|
const more = (page: Page) =>
|
|
page.locator('[data-testid="bottom-nav-more-sheet"]');
|
|
|
|
async function openMore(page: Page) {
|
|
await page.locator('[data-testid="bottom-nav-more"]').tap();
|
|
await expect(more(page)).toBeVisible();
|
|
await settled(more(page));
|
|
}
|
|
|
|
function isoDaysAgo(days: number): string {
|
|
const d = new Date(Date.now() - days * 86_400_000);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
test.describe("the bottom sheet", () => {
|
|
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
|
|
|
test.beforeEach(async ({ page, context }, testInfo) => {
|
|
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
|
|
await context.addCookies([
|
|
{ name: "healthlog-locale", value: "en", url: baseURL },
|
|
]);
|
|
await mockDay(page);
|
|
});
|
|
|
|
test("a slow drag past a quarter of its height closes it", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/mood");
|
|
await openMore(page);
|
|
const title = more(page).getByRole("heading").first();
|
|
const { x, y } = await centre(title);
|
|
const { box } = await centre(more(page));
|
|
await touchDrag(page, x, y, {
|
|
dy: box.height * 0.4,
|
|
ms: 700,
|
|
holdMs: 160,
|
|
});
|
|
await expect(more(page)).toHaveCount(0);
|
|
});
|
|
|
|
test("a short drag springs back", async ({ page }) => {
|
|
await page.goto("/mood");
|
|
await openMore(page);
|
|
await expect(more(page)).toBeVisible();
|
|
const before = await topOf(more(page));
|
|
const { x, y } = await centre(more(page).getByRole("heading").first());
|
|
// Dragging: the sheet follows the finger and the backdrop lightens. A
|
|
// frame loop records the furthest the sheet travelled and the lightest
|
|
// the backdrop got for the whole gesture, so a slow runner cannot make
|
|
// a single fixed-time sample land before the drag has begun.
|
|
await page.evaluate(() => {
|
|
const w = window as unknown as {
|
|
__sheetDrag?: { moved: number; opacity: number; on: boolean };
|
|
};
|
|
w.__sheetDrag = { moved: 0, opacity: 1, on: true };
|
|
const tick = () => {
|
|
const rec = w.__sheetDrag!;
|
|
if (!rec.on) return;
|
|
const sheet = document.querySelector(
|
|
'[data-testid="bottom-nav-more-sheet"]',
|
|
) as HTMLElement | null;
|
|
const overlay = document.querySelector(
|
|
'[data-slot="sheet-overlay"]',
|
|
) as HTMLElement | null;
|
|
if (sheet) {
|
|
const moved = new DOMMatrix(getComputedStyle(sheet).transform).f;
|
|
rec.moved = Math.max(rec.moved, moved);
|
|
}
|
|
if (overlay) {
|
|
rec.opacity = Math.min(
|
|
rec.opacity,
|
|
Number(getComputedStyle(overlay).opacity),
|
|
);
|
|
}
|
|
requestAnimationFrame(tick);
|
|
};
|
|
requestAnimationFrame(tick);
|
|
});
|
|
// Two rendered frames with the finger still down: the first paints the
|
|
// sheet where the finger is, the second lets the recorder read it.
|
|
await touchDrag(page, x, y, {
|
|
dy: 60,
|
|
ms: 300,
|
|
holdMs: 200,
|
|
holdFrames: 2,
|
|
});
|
|
const seen = await page.evaluate(() => {
|
|
const w = window as unknown as {
|
|
__sheetDrag: { moved: number; opacity: number; on: boolean };
|
|
};
|
|
w.__sheetDrag.on = false;
|
|
return { moved: w.__sheetDrag.moved, opacity: w.__sheetDrag.opacity };
|
|
});
|
|
expect(seen.moved).toBeGreaterThan(40);
|
|
expect(seen.opacity).toBeLessThan(1);
|
|
await settled(more(page));
|
|
await expect(more(page)).toBeVisible();
|
|
expect(Math.abs((await topOf(more(page))) - before)).toBeLessThan(1);
|
|
});
|
|
|
|
test("a fast flick closes it", async ({ page }) => {
|
|
await page.goto("/mood");
|
|
await openMore(page);
|
|
const { x, y } = await centre(more(page).getByRole("heading").first());
|
|
const { box } = await centre(more(page));
|
|
// Well short of a quarter of the height, but fast.
|
|
await touchDrag(page, x, y, {
|
|
dy: Math.min(70, box.height * 0.2),
|
|
ms: 40,
|
|
steps: 4,
|
|
});
|
|
await expect(more(page)).toHaveCount(0);
|
|
});
|
|
|
|
test("the backdrop, the X and Escape close it; focus goes back to the trigger", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/mood");
|
|
const trigger = page.locator('[data-testid="bottom-nav-more"]');
|
|
|
|
await openMore(page);
|
|
await page.touchscreen.tap(195, 40);
|
|
await expect(more(page)).toHaveCount(0);
|
|
|
|
await openMore(page);
|
|
const close = more(page).locator('[data-slot="sheet-close-button"]');
|
|
const closeBox = (await close.boundingBox())!;
|
|
expect(closeBox.width).toBeGreaterThanOrEqual(44);
|
|
expect(closeBox.height).toBeGreaterThanOrEqual(44);
|
|
await close.tap();
|
|
await expect(more(page)).toHaveCount(0);
|
|
|
|
// The keyboard path: open from the focused trigger, close with Escape.
|
|
await trigger.focus();
|
|
await page.keyboard.press("Enter");
|
|
await expect(more(page)).toBeVisible();
|
|
await expect(more(page)).toHaveAttribute("role", "dialog");
|
|
await expect(more(page)).toHaveAttribute("aria-modal", "true");
|
|
await expect(more(page)).toHaveAccessibleName(/more/i);
|
|
await expect
|
|
.poll(() =>
|
|
more(page).evaluate((el) => el.contains(document.activeElement)),
|
|
)
|
|
.toBe(true);
|
|
// Tab stays inside.
|
|
for (let i = 0; i < 14; i += 1) await page.keyboard.press("Tab");
|
|
expect(
|
|
await more(page).evaluate((el) => el.contains(document.activeElement)),
|
|
).toBe(true);
|
|
await page.keyboard.press("Escape");
|
|
await expect(more(page)).toHaveCount(0);
|
|
await expect(trigger).toBeFocused();
|
|
});
|
|
|
|
test("the page behind keeps its scroll position and is never pulled to refresh", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
const main = page.locator("#main-content");
|
|
await expect(main).toBeVisible();
|
|
await page.waitForTimeout(800);
|
|
|
|
// Watch for the refresh disc for the rest of the test.
|
|
await page.evaluate(() => {
|
|
(window as unknown as { ptrSeen: number }).ptrSeen = 0;
|
|
new MutationObserver(() => {
|
|
if (document.querySelector('[data-slot="pull-to-refresh-indicator"]')) {
|
|
(window as unknown as { ptrSeen: number }).ptrSeen += 1;
|
|
}
|
|
}).observe(document.body, { childList: true, subtree: true });
|
|
});
|
|
const ptrSeen = () =>
|
|
page.evaluate(() => (window as unknown as { ptrSeen: number }).ptrSeen);
|
|
|
|
// The control: the same pull on the page itself does arm the disc, so
|
|
// the watch above can see it (short of the refresh threshold).
|
|
await touchDrag(page, 195, 320, { dy: 90, ms: 400, holdMs: 120 });
|
|
await expect.poll(ptrSeen).toBeGreaterThan(0);
|
|
await page.waitForTimeout(300);
|
|
await page.evaluate(() => {
|
|
(window as unknown as { ptrSeen: number }).ptrSeen = 0;
|
|
});
|
|
|
|
// A pull on the sheet, short (it springs back) and long (it closes),
|
|
// from the dashboard at the top: the page behind never takes it.
|
|
await openMore(page);
|
|
const { x, y } = await centre(more(page).getByRole("heading").first());
|
|
await touchDrag(page, x, y, { dy: 60, ms: 400, holdMs: 150 });
|
|
await settled(more(page));
|
|
const { box } = await centre(more(page));
|
|
await touchDrag(page, x, y, { dy: box.height * 0.5, ms: 600, holdMs: 150 });
|
|
await expect(more(page)).toHaveCount(0);
|
|
expect(await ptrSeen()).toBe(0);
|
|
|
|
// Scrolled down, the page is where it was after the sheet has gone.
|
|
await main.evaluate((el) => el.scrollTo({ top: 400 }));
|
|
const before = await main.evaluate((el) => el.scrollTop);
|
|
expect(before).toBeGreaterThan(0);
|
|
await openMore(page);
|
|
const at = await centre(more(page).getByRole("heading").first());
|
|
const sheetBox = (await more(page).boundingBox())!;
|
|
await touchDrag(page, at.x, at.y, {
|
|
dy: sheetBox.height * 0.5,
|
|
ms: 600,
|
|
holdMs: 150,
|
|
});
|
|
await expect(more(page)).toHaveCount(0);
|
|
expect(await main.evaluate((el) => el.scrollTop)).toBe(before);
|
|
expect(await ptrSeen()).toBe(0);
|
|
});
|
|
|
|
test("the day opens on its preview step, rises on a pull up, and its content scrolls before it drags", async ({
|
|
page,
|
|
}) => {
|
|
// A short window, so the day's content is longer than its sheet on
|
|
// both steps and the body really scrolls.
|
|
await page.setViewportSize({ width: 390, height: 480 });
|
|
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
|
|
const panel = page.locator('[data-slot="day-panel"]');
|
|
await expect(panel).toBeVisible();
|
|
await expect(panel).toHaveAttribute("data-shell", "bottom");
|
|
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
|
|
await settled(panel);
|
|
const viewport = page.viewportSize()!;
|
|
|
|
// On the preview step the footer stands on the window's bottom edge.
|
|
const footer = panel.locator('[data-slot="day-footer"]');
|
|
const footerBox = (await footer.boundingBox())!;
|
|
expect(
|
|
Math.abs(footerBox.y + footerBox.height - viewport.height),
|
|
).toBeLessThan(2);
|
|
|
|
// A pull up on the content raises it to the full 90 dvh.
|
|
const body = panel.locator('[data-slot="day-body"]');
|
|
const b = await centre(body);
|
|
await touchDrag(page, b.x, b.y, { dy: -180, ms: 500, holdMs: 150 });
|
|
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
|
|
await settled(panel);
|
|
expect(await topOf(panel)).toBeCloseTo(viewport.height * 0.1, -1);
|
|
|
|
// Scrolled content: a pull down scrolls (the browser's), the sheet stays.
|
|
const scrollable = await body.evaluate(
|
|
(el) => el.scrollHeight > el.clientHeight,
|
|
);
|
|
expect(scrollable).toBe(true);
|
|
await body.evaluate((el) => el.scrollTo({ top: 60 }));
|
|
const top = await topOf(panel);
|
|
const inner = await centre(body);
|
|
await touchDrag(page, inner.x, inner.y, {
|
|
dy: 260,
|
|
ms: 500,
|
|
holdMs: 150,
|
|
});
|
|
await settled(panel);
|
|
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
|
|
expect(Math.abs((await topOf(panel)) - top)).toBeLessThan(1);
|
|
|
|
// At its very top the same pull drags the sheet: down to the preview
|
|
// step, and from there out.
|
|
await body.evaluate((el) => el.scrollTo({ top: 0 }));
|
|
await touchDrag(page, inner.x, inner.y - 60, {
|
|
dy: 150,
|
|
ms: 500,
|
|
holdMs: 150,
|
|
});
|
|
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
|
|
await settled(panel);
|
|
const grip = panel.locator('[data-slot="sheet-grip"]');
|
|
const g = await centre(grip);
|
|
await touchDrag(page, g.x, g.y, { dy: 300, ms: 600, holdMs: 150 });
|
|
await expect(panel).toHaveCount(0);
|
|
await expect(page).toHaveURL(/\/mood$/);
|
|
});
|
|
|
|
test("the grip is a button that switches the day's two heights", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
|
|
const panel = page.locator('[data-slot="day-panel"]');
|
|
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
|
|
const grip = panel.locator('[data-slot="sheet-grip"]');
|
|
await expect(grip).toHaveAttribute("aria-expanded", "false");
|
|
await grip.tap();
|
|
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
|
|
await expect(grip).toHaveAttribute("aria-expanded", "true");
|
|
});
|
|
|
|
test("a slider inside a sheet moves sideways and the sheet stays", async ({
|
|
page,
|
|
}) => {
|
|
await emulateCapture(page);
|
|
await page.goto("/mood");
|
|
await page.locator('[data-testid="bottom-nav-capture"]').tap();
|
|
await page.locator('[data-testid="capture-picker-symptom"]').tap();
|
|
const form = page.locator(
|
|
'[data-slot="responsive-sheet-content"][data-state="open"]',
|
|
);
|
|
await expect(form).toBeVisible();
|
|
await settled(form);
|
|
const slider = form.locator('[data-slot="slider"]').first();
|
|
await slider.scrollIntoViewIfNeeded();
|
|
await settled(form);
|
|
const top = await topOf(form);
|
|
const s = await centre(slider);
|
|
const thumb = form.locator('[role="slider"]').first();
|
|
const valueOf = async () =>
|
|
(await thumb.count()) ? thumb.getAttribute("aria-valuenow") : null;
|
|
const before = await valueOf();
|
|
// A sideways drag with a little vertical wobble, across the track.
|
|
await touchDrag(page, s.box.x + 8, s.y, {
|
|
dx: s.box.width * 0.7,
|
|
dy: 6,
|
|
ms: 400,
|
|
});
|
|
await expect.poll(valueOf).not.toBe(before);
|
|
await settled(form);
|
|
await expect(form).toBeVisible();
|
|
expect(Math.abs((await topOf(form)) - top)).toBeLessThan(1);
|
|
});
|
|
|
|
test("under reduced motion it opens without a slide", async ({ page }) => {
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await page.goto("/mood");
|
|
await expect(page.locator('[data-testid="bottom-nav-more"]')).toBeVisible();
|
|
// Read the sheet the moment it is inserted, before the browser has drawn
|
|
// a frame of it: that is when a slide that exists at all is still
|
|
// running, however short it was made. A read after it shows depends on
|
|
// frames having been drawn in between, which WebKit in the CI container
|
|
// does not promise, so a later read alone can pass or fail at random.
|
|
await page.evaluate(() => {
|
|
const w = window as unknown as { __sheetAtInsert?: unknown };
|
|
const watch = new MutationObserver(() => {
|
|
const sheet = document.querySelector(
|
|
'[data-testid="bottom-nav-more-sheet"]',
|
|
);
|
|
const overlay = document.querySelector('[data-slot="sheet-overlay"]');
|
|
if (!sheet || !overlay || w.__sheetAtInsert) return;
|
|
watch.disconnect();
|
|
w.__sheetAtInsert = [sheet, overlay].map((el) => ({
|
|
name: getComputedStyle(el).animationName,
|
|
animations: el.getAnimations().length,
|
|
}));
|
|
});
|
|
watch.observe(document.body, { childList: true, subtree: true });
|
|
});
|
|
await page.locator('[data-testid="bottom-nav-more"]').tap();
|
|
await expect(more(page)).toBeVisible();
|
|
const atInsert = await page.evaluate(
|
|
() =>
|
|
(window as unknown as { __sheetAtInsert?: unknown }).__sheetAtInsert,
|
|
);
|
|
expect(atInsert).toEqual([
|
|
{ name: "none", animations: 0 },
|
|
{ name: "none", animations: 0 },
|
|
]);
|
|
});
|
|
});
|