Files
Pixhelf/frontend/scripts/visual-check.mjs
T
2026-08-25 06:50:54 +00:00

1857 lines
89 KiB
JavaScript

import { chromium } from "playwright-core";
const executablePath =
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ??
"/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome";
const baseUrl = process.env.PIXHELF_URL ?? "http://127.0.0.1:3002";
const requestedTarget = process.env.PIXHELF_VISUAL_TARGET;
const viewerReturnOnly = process.env.PIXHELF_VIEWER_RETURN_ONLY === "1";
const browser = await chromium.launch({
executablePath,
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const results = [];
try {
const targets = [
{ name: "desktop", width: 1440, height: 1000 },
{ name: "mobile", width: 390, height: 844 },
].filter((target) => !requestedTarget || target.name === requestedTarget);
for (const target of targets) {
const page = await browser.newPage({
viewport: { width: target.width, height: target.height },
deviceScaleFactor: target.name === "mobile" ? 3 : 1,
hasTouch: target.name === "mobile",
});
const browserErrors = [];
page.on("console", (message) => {
if (message.type() === "error") browserErrors.push(message.text());
});
page.on("pageerror", (error) => browserErrors.push(error.message));
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await page.waitForSelector("[data-image-id]", { timeout: 60_000 });
const minimumVisibleImages = target.name === "mobile" ? 4 : 8;
await page.waitForFunction((minimumVisible) => {
const visible = [...document.querySelectorAll("[data-image-id] img")].filter((image) => {
const rect = image.getBoundingClientRect();
return rect.bottom > 0 && rect.top < innerHeight;
});
return visible.length >= minimumVisible &&
visible.every((image) => image.complete && image.naturalWidth > 0);
}, minimumVisibleImages, { timeout: 60_000 });
await page.waitForTimeout(250);
await page.screenshot({ path: `/tmp/pixhelf-${target.name}.png` });
const layout = await page.evaluate(() => {
const path = document.querySelector(".desktop-sidebar .sidebar-gallery-path");
const count = document.querySelector(".desktop-sidebar .sidebar-gallery-count");
const search = document.querySelector(".topbar-search");
const topbar = document.querySelector(".topbar");
const sidebarStatus = document.querySelector(".desktop-sidebar .sidebar-status");
const sidebarMeta = document.querySelector(".desktop-sidebar .sidebar-gallery-meta");
const pathRect = path?.getBoundingClientRect();
const countRect = count?.getBoundingClientRect();
const searchRect = search?.getBoundingClientRect();
const exploreRect = document.querySelector(".explore-toggle")?.getBoundingClientRect();
const topbarRect = topbar?.getBoundingClientRect();
const statusRect = sidebarStatus?.getBoundingClientRect();
const metaRect = sidebarMeta?.getBoundingClientRect();
const galleryToggle = document.querySelector(".gallery-sidebar-toggle");
return {
viewport: [innerWidth, innerHeight],
bodyWidth: document.documentElement.scrollWidth,
cards: document.querySelectorAll("[data-image-id]").length,
cardTags: [...document.querySelectorAll("[data-image-id]")]
.map((element) => element.tagName),
masonryColumns: Number(
document.querySelector(".masonry")?.getAttribute("data-columns") ?? 0,
),
loadedCards: document.querySelectorAll("[data-image-id] img.loaded").length,
brokenVisibleImages: [...document.images].filter((image) => {
const rect = image.getBoundingClientRect();
return rect.bottom > 0 && rect.top < innerHeight && image.naturalWidth === 0;
}).length,
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
dialogs: document.querySelectorAll('[role="dialog"]').length,
topbarPosition: topbar ? getComputedStyle(topbar).position : "",
topbarTop: topbarRect?.top ?? -1,
topbarSearches: document.querySelectorAll(
".topbar-actions > .topbar-search",
).length,
brandHomeLinks: document.querySelectorAll('a.brand[href="/"]').length,
gallerySidebarToggles: document.querySelectorAll(".gallery-sidebar-toggle").length,
galleryToggleOpacity: galleryToggle
? Number.parseFloat(getComputedStyle(galleryToggle).opacity)
: -1,
topbarSidebarToggles: document.querySelectorAll(
".topbar .sidebar-toggle-button",
).length,
contentSearches: document.querySelectorAll(".content .search-field").length,
topbarSearchRightGap: searchRect ? innerWidth - searchRect.right : -1,
exploreButtons: document.querySelectorAll(".topbar-actions > .explore-toggle").length,
exploreSearchGap: exploreRect && searchRect ? searchRect.left - exploreRect.right : -1,
sidebarStatuses: document.querySelectorAll(
".desktop-sidebar > .sidebar-status",
).length,
sidebarGalleryMetas: document.querySelectorAll(".sidebar-gallery-meta").length,
contentInfoRows: document.querySelectorAll(
".content-heading, .content .sidebar-gallery-meta",
).length,
sortControls: document.querySelectorAll(".sort-control").length,
cardNameDisplay: getComputedStyle(document.querySelector(".image-name")).display,
cardNameOpacity: Number.parseFloat(
getComputedStyle(document.querySelector(".image-name")).opacity,
),
cardNameWhiteSpace: getComputedStyle(
document.querySelector(".image-name"),
).whiteSpace,
sidebarMetaBottomGap: metaRect ? innerHeight - metaRect.bottom : -1,
sidebarMetaStatusGap: statusRect && metaRect ? metaRect.top - statusRect.bottom : -1,
galleryPath: path?.textContent ?? "",
galleryCount: count?.textContent ?? "",
galleryMetaRowDelta: pathRect && countRect
? Math.abs(pathRect.top - countRect.top)
: -1,
};
});
let resizeStability = null;
if (target.name === "desktop") {
await page.evaluate(() => {
globalThis.__pixhelfResizeLoadedIds = [
...document.querySelectorAll('[data-image-id][data-loaded="true"]'),
].map((card) => card.getAttribute("data-image-id"));
for (const card of document.querySelectorAll("[data-image-id]")) {
card.setAttribute(
"data-resize-probe",
card.getAttribute("data-image-id") ?? "",
);
}
globalThis.__pixhelfResizeSkeletonSeen = false;
globalThis.__pixhelfResizeObserver = new MutationObserver(() => {
if (document.querySelector(".skeleton-grid")) {
globalThis.__pixhelfResizeSkeletonSeen = true;
}
});
globalThis.__pixhelfResizeObserver.observe(document.body, {
childList: true,
subtree: true,
});
globalThis.__pixhelfImageRequestsBeforeResize = performance
.getEntriesByType("resource")
.filter((entry) => new URL(entry.name).pathname === "/api/images")
.length;
});
const resizeAnchor = await page.evaluate(() => {
const cards = [...document.querySelectorAll(".masonry .image-card")];
const card = cards[Math.min(24, cards.length - 1)];
if (!(card instanceof HTMLElement)) return null;
card.scrollIntoView({ block: "center", behavior: "instant" });
card.focus({ preventScroll: true });
const rect = card.getBoundingClientRect();
const viewportTop = visualViewport?.offsetTop ?? 0;
const viewportHeight = visualViewport?.height ?? innerHeight;
const viewportBottom = viewportTop + viewportHeight;
const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom
?? viewportTop;
const safeTop = Math.min(viewportBottom, Math.max(viewportTop, topbarBottom + 8));
const safeBottom = Math.max(safeTop, viewportBottom - 8);
const visibleTop = Math.max(safeTop, rect.top);
const visibleBottom = Math.min(safeBottom, rect.bottom);
const anchorY = (visibleTop + visibleBottom) / 2;
return {
imageId: card.dataset.imageId ?? "",
cardRatio: (anchorY - rect.top) / Math.max(1, rect.height),
viewportRatio: (anchorY - viewportTop) / Math.max(1, viewportHeight),
};
});
if (!resizeAnchor) throw new Error("masonry resize anchor could not be captured");
const resizeAnchorDeltas = [];
for (const size of [
{ width: 680, columns: 3 },
{ width: 520, columns: 2 },
{ width: 1000, columns: 3 },
{ width: target.width, columns: 5 },
]) {
await page.setViewportSize({ width: size.width, height: target.height });
await page.waitForFunction((columns) =>
document.querySelector(".masonry")?.getAttribute("data-columns") === String(columns)
, size.columns);
try {
await page.waitForFunction((anchor) => {
const card = document.querySelector(
`.masonry .image-card[data-image-id="${CSS.escape(anchor.imageId)}"]`,
);
if (!(card instanceof HTMLElement)) return false;
const rect = card.getBoundingClientRect();
const viewportTop = visualViewport?.offsetTop ?? 0;
const viewportHeight = visualViewport?.height ?? innerHeight;
const viewportBottom = viewportTop + viewportHeight;
const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom
?? viewportTop;
const safeTop = Math.min(viewportBottom, Math.max(viewportTop, topbarBottom + 8));
const safeBottom = Math.max(safeTop, viewportBottom - 8);
const expectedY = Math.min(
safeBottom,
Math.max(safeTop, viewportTop + anchor.viewportRatio * viewportHeight),
);
const actualY = rect.top + rect.height * anchor.cardRatio;
return Math.abs(actualY - expectedY) <= 2;
}, resizeAnchor, { timeout: 4000 });
} catch (error) {
const actual = await page.evaluate((anchor) => {
const card = document.querySelector(
`.masonry .image-card[data-image-id="${CSS.escape(anchor.imageId)}"]`,
);
if (!(card instanceof HTMLElement)) return { missing: true };
const rect = card.getBoundingClientRect();
const viewportTop = visualViewport?.offsetTop ?? 0;
const viewportHeight = visualViewport?.height ?? innerHeight;
const viewportBottom = viewportTop + viewportHeight;
const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom
?? viewportTop;
const safeTop = Math.min(viewportBottom, Math.max(viewportTop, topbarBottom + 8));
const safeBottom = Math.max(safeTop, viewportBottom - 8);
const expectedY = Math.min(
safeBottom,
Math.max(safeTop, viewportTop + anchor.viewportRatio * viewportHeight),
);
const actualY = rect.top + rect.height * anchor.cardRatio;
return {
actualY,
columns: document.querySelector(".masonry")?.getAttribute("data-columns"),
delta: Math.abs(actualY - expectedY),
expectedY,
scrollY,
};
}, resizeAnchor);
throw new Error(
`masonry resize anchor drifted at ${size.width}px: ${JSON.stringify(actual)}`,
{ cause: error },
);
}
resizeAnchorDeltas.push(await page.evaluate((anchor) => {
const card = document.querySelector(
`.masonry .image-card[data-image-id="${CSS.escape(anchor.imageId)}"]`,
);
if (!(card instanceof HTMLElement)) return Infinity;
const rect = card.getBoundingClientRect();
const viewportTop = visualViewport?.offsetTop ?? 0;
const viewportHeight = visualViewport?.height ?? innerHeight;
const viewportBottom = viewportTop + viewportHeight;
const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom
?? viewportTop;
const safeTop = Math.min(viewportBottom, Math.max(viewportTop, topbarBottom + 8));
const safeBottom = Math.max(safeTop, viewportBottom - 8);
const expectedY = Math.min(
safeBottom,
Math.max(safeTop, viewportTop + anchor.viewportRatio * viewportHeight),
);
return Math.abs(rect.top + rect.height * anchor.cardRatio - expectedY);
}, resizeAnchor));
}
await page.waitForFunction(() => {
const loadedIds = globalThis.__pixhelfResizeLoadedIds ?? [];
return loadedIds.every((id) => {
const card = document.querySelector(`[data-image-id="${CSS.escape(id)}"]`);
const image = card?.querySelector("img");
return card?.getAttribute("data-loaded") === "true"
&& image?.complete
&& image.naturalWidth > 0;
});
});
resizeStability = await page.evaluate(() => {
globalThis.__pixhelfResizeObserver?.disconnect();
const cards = [...document.querySelectorAll("[data-image-id]")];
const imageRequestsAfter = performance
.getEntriesByType("resource")
.filter((entry) => new URL(entry.name).pathname === "/api/images")
.length;
const visibleImages = cards
.map((card) => card.querySelector("img"))
.filter((image) => {
if (!image) return false;
const rect = image.getBoundingClientRect();
return rect.bottom > 0 && rect.top < innerHeight;
});
return {
cardsPreserved: cards.every((card) =>
card.getAttribute("data-resize-probe") === card.getAttribute("data-image-id")
),
loadedCardsPreserved: (globalThis.__pixhelfResizeLoadedIds ?? []).every((id) =>
document.querySelector(`[data-image-id="${CSS.escape(id)}"]`)
?.getAttribute("data-loaded") === "true"
),
skeletonSeen: globalThis.__pixhelfResizeSkeletonSeen,
imagePageRequests: imageRequestsAfter
- globalThis.__pixhelfImageRequestsBeforeResize,
brokenVisibleImages: visibleImages.filter((image) =>
!image.complete || image.naturalWidth <= 0
).length,
columns: Number(
document.querySelector(".masonry")?.getAttribute("data-columns") ?? 0,
),
};
});
Object.assign(resizeStability, {
anchorId: resizeAnchor.imageId,
anchorDeltas: resizeAnchorDeltas,
anchorFocused: await page.evaluate((imageId) =>
document.activeElement?.getAttribute("data-image-id") === imageId
, resizeAnchor.imageId),
});
}
const firstViewerTitle = await page.locator("[data-image-id]").first().getAttribute("title");
await page.locator("[data-image-id]").first().click();
await page.waitForSelector(".image-viewer");
const viewerChrome = await page.evaluate(() => ({
fullscreenButtons: document.querySelectorAll(".viewer-fullscreen").length,
detailsPages: document.querySelectorAll(".viewer-details-page").length,
scrollCues: document.querySelectorAll(".viewer-scroll-cue").length,
zoomControls: document.querySelectorAll(
".viewer-zoom-controls, .viewer-zoom-value",
).length,
scrollCueArrowOnly: (() => {
const cue = document.querySelector(".viewer-scroll-cue");
return cue?.textContent?.trim() === ""
&& cue.children.length === 1
&& cue.firstElementChild?.tagName === "svg"
&& cue.querySelectorAll("path").length === 2;
})(),
scrollCuePresentation: (() => {
const cue = document.querySelector(".viewer-scroll-cue");
const media = document.querySelector(".viewer-media");
if (!cue || !media) return null;
const style = getComputedStyle(cue);
const cueBounds = cue.getBoundingClientRect();
const mediaBounds = media.getBoundingClientRect();
return {
borderless: Number.parseFloat(style.borderTopWidth) === 0,
transparent: style.backgroundColor === "rgba(0, 0, 0, 0)",
width: cueBounds.width,
clearsMedia: cueBounds.top >= mediaBounds.bottom - 1,
};
})(),
headingModules: document.querySelectorAll(".viewer-heading").length,
unifiedControls: document.querySelector(".image-viewer")
?.getAttribute("data-control-system") === "unified",
drawerArtifacts: document.querySelectorAll(
".viewer-details-sheet, .viewer-details-scrim",
).length,
gestureHelp: document.querySelectorAll(".viewer-gesture-help").length,
loadingStatus: document.querySelectorAll(".viewer-load-status").length,
loadingCopyVisible: document.body.textContent?.includes("正在加载原图") ?? false,
}));
await page.waitForFunction(() =>
document.querySelector(".image-viewer")?.getAttribute("data-full-loaded") === "true"
, undefined, { timeout: 60_000 });
await page.waitForFunction(() => {
const originalPaths = performance.getEntriesByType("resource")
.map((entry) => new URL(entry.name).pathname)
.filter((path) => path.endsWith("/original"));
return new Set(originalPaths).size >= 2;
}, undefined, { timeout: 60_000 });
await page.waitForTimeout(260);
const dialogsAfterImageClick = await page.locator('[role="dialog"]').count();
await page.screenshot({ path: `/tmp/pixhelf-viewer-${target.name}.png` });
const viewer = await page.evaluate(() => {
const overlay = document.querySelector(".image-viewer");
const thumbnail = document.querySelector(".viewer-thumbnail");
const original = document.querySelector(".viewer-original");
const nativeOriginal = document.querySelector(".viewer-native-original");
const mediaElement = document.querySelector(".viewer-media");
const media = mediaElement?.getBoundingClientRect();
const stage = document.querySelector(".viewer-stage")?.getBoundingClientRect();
const details = document.querySelector(".viewer-details-page")?.getBoundingClientRect();
const thumbnailStyle = thumbnail ? getComputedStyle(thumbnail) : null;
const originalStyle = original ? getComputedStyle(original) : null;
const resourcePaths = performance.getEntriesByType("resource")
.map((entry) => new URL(entry.name).pathname);
const originalSource = original instanceof HTMLImageElement
? original.currentSrc || original.src
: original?.getAttribute("data-original-url") ?? "";
return {
title: overlay?.getAttribute("data-image-name") ?? "",
fullLoaded: overlay?.getAttribute("data-full-loaded"),
displaySource: overlay?.getAttribute("data-display-source") ?? "",
nativeOriginalLoaded: overlay?.getAttribute("data-native-original-loaded") ?? "false",
nativeOriginalActive: overlay?.getAttribute("data-native-original-active") ?? "false",
nativeOriginalCount: document.querySelectorAll(".viewer-native-original").length,
nativeOriginalPath: nativeOriginal instanceof HTMLImageElement
? new URL(nativeOriginal.currentSrc || nativeOriginal.src, location.href).pathname
: "",
nativeOriginalPixels: nativeOriginal instanceof HTMLImageElement
? [nativeOriginal.naturalWidth, nativeOriginal.naturalHeight]
: [0, 0],
nativeOriginalVisible: nativeOriginal
? Number.parseFloat(getComputedStyle(nativeOriginal).opacity)
: 0,
mediaWillChange: mediaElement ? getComputedStyle(mediaElement).willChange : "",
originalPath: originalSource ? new URL(originalSource, location.href).pathname : "",
renderer: overlay?.getAttribute("data-renderer"),
sourceStrategy: overlay?.getAttribute("data-source-strategy") ?? "",
sourcePresentation: overlay?.getAttribute("data-source-presentation") ?? "",
thumbnailFilter: thumbnailStyle?.filter ?? "",
thumbnailTransform: thumbnailStyle?.transform ?? "",
thumbnailTransitionDelay: thumbnailStyle?.transitionDelay ?? "",
originalTransitionDuration: originalStyle?.transitionDuration ?? "",
gestureRenderer: overlay?.getAttribute("data-mobile-gesture-renderer") ?? "",
originalPolicy: overlay?.getAttribute("data-mobile-original-policy") ?? "",
scrollMode: overlay?.getAttribute("data-scroll-mode"),
renderedPixels: original instanceof HTMLCanvasElement
? [original.width, original.height]
: [original?.clientWidth ?? 0, original?.clientHeight ?? 0],
prefetchedOriginals: new Set(
resourcePaths.filter((path) => path.endsWith("/original")),
).size,
previewRequests: resourcePaths.filter((path) => path.endsWith("/preview")).length,
rootInert: document.querySelector("#root")?.inert ?? false,
closeFocused: document.activeElement?.classList.contains("viewer-close") ?? false,
mediaContained: media
? media.left >= -1 && media.top >= -1 && media.right <= innerWidth + 1 &&
media.bottom <= innerHeight + 1
: false,
continuousLayout: overlay && stage && details
? Math.abs(stage.top) < 1 && Math.abs(stage.height - innerHeight) < 1 &&
Math.abs(details.top - stage.bottom) < 1 &&
details.height >= innerHeight - 1 &&
overlay.scrollHeight >= innerHeight * 2 - 1
: false,
position: document.querySelector(".viewer-details-heading span")?.textContent ?? "",
};
});
Object.assign(viewer, viewerChrome);
const sideButtonStart = await page.evaluate(() => {
const activeId = document.querySelector(".image-viewer")?.getAttribute("data-image-id") ?? "";
const ids = [...document.querySelectorAll(".masonry .image-card")]
.map((card) => card.getAttribute("data-image-id") ?? "");
const index = ids.indexOf(activeId);
return {
activeId,
nextId: ids[index + 1] ?? "",
historyLength: history.length,
href: location.href,
};
});
const dispatchViewerSideButton = (button) => page.locator(".image-viewer").evaluate(
(viewerElement, sideButton) => {
const mask = sideButton === 3 ? 8 : 16;
const downPrevented = !viewerElement.dispatchEvent(new PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
button: sideButton,
buttons: mask,
pointerId: 71,
pointerType: "mouse",
}));
const upPrevented = !viewerElement.dispatchEvent(new PointerEvent("pointerup", {
bubbles: true,
cancelable: true,
button: sideButton,
buttons: 0,
pointerId: 71,
pointerType: "mouse",
}));
const auxiliaryPrevented = !viewerElement.dispatchEvent(new MouseEvent("auxclick", {
bubbles: true,
cancelable: true,
button: sideButton,
}));
return { downPrevented, upPrevented, auxiliaryPrevented };
},
button,
);
const sideForwardEvents = await dispatchViewerSideButton(4);
await page.waitForFunction((imageId) =>
document.querySelector(".image-viewer")?.getAttribute("data-image-id") === imageId
, sideButtonStart.nextId);
const sideBackEvents = await dispatchViewerSideButton(3);
await page.waitForFunction((imageId) =>
document.querySelector(".image-viewer")?.getAttribute("data-image-id") === imageId
, sideButtonStart.activeId);
await page.evaluate(() => new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve))
));
const sideButtonNavigation = await page.evaluate(({ start, forward, back }) => {
const overlay = document.querySelector(".image-viewer");
return {
forward,
back,
returnedId: overlay?.getAttribute("data-image-id") ?? "",
returnedSource: overlay?.getAttribute("data-display-source") ?? "",
returnedPresentation: overlay?.getAttribute("data-source-presentation") ?? "",
returnedLoaded: overlay?.getAttribute("data-full-loaded") ?? "false",
enabled: overlay?.getAttribute("data-mouse-side-navigation") === "true",
historyPreserved: history.length === start.historyLength && location.href === start.href,
};
}, { start: sideButtonStart, forward: sideForwardEvents, back: sideBackEvents });
if (
!sideButtonStart.nextId ||
sideButtonNavigation.returnedId !== sideButtonStart.activeId ||
sideButtonNavigation.returnedPresentation !== "direct" ||
sideButtonNavigation.returnedLoaded !== "true" ||
!["original", "viewport-bitmap"].includes(sideButtonNavigation.returnedSource) ||
!sideButtonNavigation.enabled ||
!sideButtonNavigation.historyPreserved ||
!Object.values(sideButtonNavigation.forward).every(Boolean) ||
!Object.values(sideButtonNavigation.back).every(Boolean)
) {
throw new Error(`viewer side-button navigation failed: ${JSON.stringify(sideButtonNavigation)}`);
}
Object.assign(viewer, { sideButtonNavigation });
if (target.name === "desktop") {
await page.locator(".viewer-next").click();
} else {
await page.keyboard.press("ArrowRight");
}
await page.waitForFunction((imageId) =>
document.querySelector(".image-viewer")?.getAttribute("data-image-id") === imageId
, sideButtonStart.nextId);
const switchPerformance = await page.evaluate(async (expectedId) => {
const start = performance.now();
window.dispatchEvent(new KeyboardEvent("keydown", {
key: "ArrowLeft",
bubbles: true,
cancelable: true,
}));
const dispatchDuration = performance.now() - start;
await new Promise((resolve) => requestAnimationFrame(resolve));
const firstFrameDuration = performance.now() - start;
const overlay = document.querySelector(".image-viewer");
const media = document.querySelector(".viewer-media");
return {
currentId: overlay?.getAttribute("data-image-id") ?? "",
dispatchDuration,
firstFrameDuration,
firstFrameSource: overlay?.getAttribute("data-display-source") ?? "",
focusedOnViewer: document.activeElement === overlay,
visibleControlFocusRings: document.querySelectorAll(
".image-viewer .viewer-control:focus-visible, "
+ ".image-viewer .viewer-nav:focus-visible, "
+ ".image-viewer .viewer-scroll-cue:focus-visible",
).length,
mediaAnimations: media?.getAnimations().length ?? -1,
expectedId,
};
}, sideButtonStart.activeId);
if (
switchPerformance.currentId !== switchPerformance.expectedId ||
switchPerformance.dispatchDuration > 80 ||
switchPerformance.firstFrameDuration > 100 ||
(target.name === "mobile"
? !["thumbnail", "viewport-bitmap"].includes(switchPerformance.firstFrameSource)
: switchPerformance.firstFrameSource !== "original") ||
!switchPerformance.focusedOnViewer ||
switchPerformance.visibleControlFocusRings !== 0 ||
switchPerformance.mediaAnimations !== 0
) {
throw new Error(`viewer switch responsiveness failed: ${JSON.stringify(switchPerformance)}`);
}
Object.assign(viewer, { switchPerformance });
const rapidSwitch = await page.evaluate(async () => {
const overlay = document.querySelector(".image-viewer");
const ids = [...document.querySelectorAll(".masonry .image-card")]
.map((card) => card.getAttribute("data-image-id") ?? "");
const startId = overlay?.getAttribute("data-image-id") ?? "";
const startIndex = ids.indexOf(startId);
const steps = Math.min(6, Math.max(0, ids.length - startIndex - 1));
const expectedId = ids[startIndex + steps] ?? startId;
const previousMedia = document.querySelector(".viewer-media");
for (let index = 0; index < steps; index += 1) {
window.dispatchEvent(new KeyboardEvent("keydown", {
key: "ArrowRight",
bubbles: true,
cancelable: true,
}));
}
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const currentOverlay = document.querySelector(".image-viewer");
const currentId = currentOverlay?.getAttribute("data-image-id") ?? "";
const thumbnail = document.querySelector(".viewer-thumbnail");
const original = document.querySelector(".viewer-original");
const thumbnailPath = thumbnail instanceof HTMLImageElement
? new URL(thumbnail.currentSrc || thumbnail.src, location.href).pathname
: "";
const originalSource = original instanceof HTMLImageElement
? original.currentSrc || original.src
: original?.getAttribute("data-original-url") ?? "";
const originalPath = originalSource
? new URL(originalSource, location.href).pathname
: "";
return {
steps,
startId,
expectedId,
currentId,
mediaReplaced: previousMedia !== document.querySelector(".viewer-media"),
mediaCount: document.querySelectorAll(".viewer-media").length,
thumbnailMatches: thumbnailPath.includes(`/api/images/${encodeURIComponent(currentId)}/thumbnail`),
originalMatches: originalPath.includes(`/api/images/${encodeURIComponent(currentId)}/original`),
immediateSource: currentOverlay?.getAttribute("data-display-source") ?? "",
mediaAnimations: document.querySelector(".viewer-media")?.getAnimations().length ?? -1,
};
});
await page.waitForFunction(() =>
document.querySelector(".image-viewer")?.getAttribute("data-display-source") !== "placeholder"
, undefined, { timeout: 10_000 });
rapidSwitch.readySource = await page.locator(".image-viewer").getAttribute("data-display-source");
await page.waitForFunction(() =>
document.querySelector(".image-viewer")?.getAttribute("data-full-loaded") === "true"
, undefined, { timeout: 60_000 });
rapidSwitch.upgradedSource = await page.locator(".image-viewer").getAttribute("data-display-source");
Object.assign(viewer, { rapidSwitch });
if (
rapidSwitch.steps !== 6 ||
rapidSwitch.currentId !== rapidSwitch.expectedId ||
!rapidSwitch.mediaReplaced ||
rapidSwitch.mediaCount !== 1 ||
!rapidSwitch.thumbnailMatches ||
!rapidSwitch.originalMatches ||
rapidSwitch.mediaAnimations !== 0 ||
rapidSwitch.readySource === "placeholder" ||
rapidSwitch.upgradedSource !== (
target.name === "mobile" ? "viewport-bitmap" : "original"
)
) {
throw new Error(`viewer rapid-switch pipeline failed: ${JSON.stringify(rapidSwitch)}`);
}
await page.locator(".viewer-fullscreen").click();
await page.waitForFunction(() =>
document.fullscreenElement?.classList.contains("image-viewer")
);
await page.waitForFunction(() =>
document.querySelector(".viewer-fullscreen")?.getAttribute("aria-label") === "退出全屏"
);
viewer.fullscreenEntered = true;
viewer.fullscreenLabel = await page.locator(".viewer-fullscreen").getAttribute("aria-label");
await page.locator(".viewer-fullscreen").click();
await page.waitForFunction(() => document.fullscreenElement === null);
viewer.fullscreenExited = true;
await page.keyboard.press("=");
await page.waitForFunction(() =>
document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "true"
);
viewer.zoomed = await page.locator(".image-viewer").getAttribute("data-zoomed");
await page.waitForTimeout(260);
viewer.zoomScale = await page.locator(".viewer-media").evaluate((media) =>
new DOMMatrix(getComputedStyle(media).transform).a
);
Object.assign(viewer, await page.locator(".image-viewer").evaluate((overlay) => ({
zoomKeptImagePage: overlay.scrollTop < 1 && overlay.dataset.page === "image",
zoomLockedPageScroll: getComputedStyle(overlay).overflowY === "hidden",
})));
if (target.name === "desktop") {
await page.mouse.move(target.width / 2, target.height / 2);
await page.mouse.wheel(0, 1_000);
await page.mouse.wheel(0, 1_000);
await page.waitForFunction(() =>
document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "false"
);
await page.mouse.wheel(0, 1_000);
await page.waitForTimeout(80);
viewer.zoomWheelHandoffLocked = await page.locator(".image-viewer").evaluate((overlay) =>
overlay.scrollTop < 1 && overlay.dataset.page === "image"
);
}
const keyboardStartName = await page.locator(".image-viewer").getAttribute("data-image-name");
await page.keyboard.press("ArrowRight");
await page.waitForFunction((title) =>
document.querySelector(".image-viewer")?.getAttribute("data-image-name") !== title
, keyboardStartName);
viewer.keyboardNavigation = await page.locator(".image-viewer").getAttribute("data-image-name");
const keyboardFocus = await page.evaluate(() => {
const overlay = document.querySelector(".image-viewer");
return {
parkedOnViewer: document.activeElement === overlay,
visibleControlFocusRings: document.querySelectorAll(
".image-viewer .viewer-control:focus-visible, "
+ ".image-viewer .viewer-nav:focus-visible, "
+ ".image-viewer .viewer-scroll-cue:focus-visible",
).length,
};
});
if (!keyboardFocus.parkedOnViewer || keyboardFocus.visibleControlFocusRings !== 0) {
throw new Error(`viewer keyboard focus was not parked: ${JSON.stringify(keyboardFocus)}`);
}
Object.assign(viewer, { keyboardFocus });
if (target.name === "desktop") {
await page.waitForFunction(() =>
document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "false"
);
await page.mouse.move(target.width / 2, target.height / 2);
await page.mouse.wheel(0, target.height * 1.15);
await page.waitForFunction(() =>
(document.querySelector(".image-viewer")?.scrollTop ?? 0) > innerHeight * 0.5
);
await page.waitForTimeout(320);
await page.screenshot({ path: "/tmp/pixhelf-viewer-details-desktop.png" });
Object.assign(viewer, await page.evaluate(() => {
const overlay = document.querySelector(".image-viewer");
const stage = document.querySelector(".viewer-stage")?.getBoundingClientRect();
const details = document.querySelector(".viewer-details-page");
const detailsRect = details?.getBoundingClientRect();
return {
detailsReachedByWheel: (overlay?.scrollTop ?? 0) > innerHeight * 0.5,
pageAfterDetails: overlay?.getAttribute("data-page") ?? "",
detailsName: details?.getAttribute("data-details-image-name") ?? "",
detailsHeading: document.querySelector(".viewer-details-heading h2")?.textContent ?? "",
stageScrolledAway: stage ? stage.top < -innerHeight * 0.5 : false,
detailsVisible: detailsRect
? detailsRect.top < innerHeight * 0.5 && detailsRect.bottom > innerHeight * 0.5
: false,
drawerArtifactsAfterScroll: document.querySelectorAll(
".viewer-details-sheet, .viewer-details-scrim",
).length,
};
}));
await page.locator(".viewer-details-return").click();
await page.waitForFunction(() =>
(document.querySelector(".image-viewer")?.scrollTop ?? 1) < 1
);
viewer.returnedToImage = true;
await page.mouse.wheel(0, -target.height);
await page.waitForTimeout(80);
Object.assign(viewer, await page.locator(".image-viewer").evaluate((overlay) => ({
pageAfterReturn: overlay.dataset.page ?? "",
returnWheelHandoffLocked:
overlay.scrollTop < 1 && overlay.getAttribute("data-zoomed") === "false",
})));
}
if (target.name === "mobile") {
const cdp = await page.context().newCDPSession(page);
const dispatchTouch = (type, touchPoints) => cdp.send("Input.dispatchTouchEvent", {
type,
touchPoints: touchPoints.map((point) => ({
...point,
radiusX: 2,
radiusY: 2,
force: 1,
})),
});
await page.waitForFunction(() =>
document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "false"
);
await dispatchTouch("touchStart", [
{ id: 11, x: 145, y: 420 },
{ id: 12, x: 245, y: 420 },
]);
await page.waitForTimeout(32);
await dispatchTouch("touchMove", [
{ id: 11, x: 95, y: 420 },
{ id: 12, x: 295, y: 420 },
]);
await page.waitForTimeout(32);
await dispatchTouch("touchEnd", []);
await page.waitForFunction(() =>
document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "true"
);
viewer.pinchZoomed = true;
await page.waitForFunction(() => {
const overlay = document.querySelector(".image-viewer");
const original = document.querySelector(".viewer-native-original");
return overlay?.getAttribute("data-native-original-active") === "true"
&& overlay.getAttribute("data-display-source") === "original"
&& original
&& Number.parseFloat(getComputedStyle(original).opacity) >= .99;
}, undefined, { timeout: 60_000 });
await page.waitForTimeout(40);
viewer.pinchScale = await page.locator(".viewer-media").evaluate((media) =>
new DOMMatrix(getComputedStyle(media).transform).a
);
viewer.pinchNativeOriginal = await page.evaluate(() => {
const overlay = document.querySelector(".image-viewer");
const media = document.querySelector(".viewer-media");
const original = document.querySelector(".viewer-native-original");
return {
active: overlay?.getAttribute("data-native-original-active") ?? "false",
displaySource: overlay?.getAttribute("data-display-source") ?? "",
visible: original ? Number.parseFloat(getComputedStyle(original).opacity) : 0,
settledWillChange: media ? getComputedStyle(media).willChange : "",
};
});
await page.screenshot({ path: "/tmp/pixhelf-viewer-zoom-mobile.png" });
await page.touchscreen.tap(195, 420);
await page.waitForTimeout(70);
await page.touchscreen.tap(195, 420);
await page.waitForFunction(() =>
document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "false"
);
viewer.doubleTapReset = true;
await page.waitForTimeout(260);
viewer.doubleTapScale = await page.locator(".viewer-media").evaluate((media) =>
new DOMMatrix(getComputedStyle(media).transform).a
);
viewer.nativeOriginalAfterReset = await page.evaluate(() => {
const overlay = document.querySelector(".image-viewer");
const media = document.querySelector(".viewer-media");
const original = document.querySelector(".viewer-native-original");
return {
active: overlay?.getAttribute("data-native-original-active") ?? "false",
displaySource: overlay?.getAttribute("data-display-source") ?? "",
visible: original ? Number.parseFloat(getComputedStyle(original).opacity) : 0,
settledWillChange: media ? getComputedStyle(media).willChange : "",
};
});
const titleBeforeSwipe = await page.locator(".image-viewer").getAttribute("data-image-name");
await dispatchTouch("touchStart", [{ id: 31, x: 320, y: 420 }]);
await page.waitForTimeout(32);
await dispatchTouch("touchMove", [{ id: 31, x: 90, y: 420 }]);
await page.waitForTimeout(32);
viewer.swipeDragTracking = await page.locator(".viewer-media").evaluate((media) =>
new DOMMatrix(getComputedStyle(media).transform).e
);
viewer.swipeDragWillChange = await page.locator(".viewer-media").evaluate((media) =>
getComputedStyle(media).willChange
);
await dispatchTouch("touchEnd", []);
viewer.swipeMotion = await page.evaluate(() => {
const overlay = document.querySelector(".image-viewer");
const media = document.querySelector(".viewer-media");
const outgoing = document.querySelector(".viewer-swipe-outgoing");
return {
mode: overlay?.getAttribute("data-mobile-swipe-motion") ?? "",
direction: overlay?.getAttribute("data-swipe-direction") ?? "",
snapshot: overlay?.getAttribute("data-swipe-snapshot") ?? "",
releaseCostMs: Number.parseFloat(
overlay?.getAttribute("data-swipe-release-cost-ms") ?? "Infinity",
),
incomingAnimations: media?.getAnimations().length ?? 0,
outgoingAnimations: outgoing?.getAnimations().length ?? 0,
outgoingIgnoresInput: outgoing
? getComputedStyle(outgoing).pointerEvents === "none"
: false,
};
});
await page.waitForFunction((title) =>
document.querySelector(".image-viewer")?.getAttribute("data-image-name") !== title
, titleBeforeSwipe);
viewer.swipeNavigation = await page.locator(".image-viewer").getAttribute("data-image-name");
await page.waitForFunction(() =>
!document.querySelector(".viewer-swipe-outgoing")
&& !document.querySelector(".image-viewer")?.hasAttribute("data-swipe-direction")
);
viewer.swipeMotionCleaned = true;
const shortSwipeStartName = await page.locator(".image-viewer")
.getAttribute("data-image-name");
await dispatchTouch("touchStart", [{ id: 32, x: 240, y: 420 }]);
await page.waitForTimeout(100);
await dispatchTouch("touchMove", [{ id: 32, x: 200, y: 420 }]);
await page.waitForTimeout(100);
await dispatchTouch("touchEnd", []);
await page.waitForFunction((title) =>
document.querySelector(".image-viewer")?.getAttribute("data-image-name") !== title
, shortSwipeStartName);
const shortSwipeEndName = await page.locator(".image-viewer")
.getAttribute("data-image-name");
viewer.shortSwipeNavigation = {
distance: 40,
changed: shortSwipeEndName !== shortSwipeStartName,
imageName: shortSwipeEndName,
};
await page.waitForFunction(() =>
!document.querySelector(".viewer-swipe-outgoing")
&& !document.querySelector(".image-viewer")?.hasAttribute("data-swipe-direction")
);
await dispatchTouch("touchStart", [{ id: 35, x: 195, y: 500 }]);
await page.waitForTimeout(32);
await dispatchTouch("touchMove", [{ id: 35, x: 195, y: 350 }]);
await page.waitForTimeout(32);
await dispatchTouch("touchMove", [{ id: 35, x: 195, y: 630 }]);
await page.waitForTimeout(32);
await dispatchTouch("touchEnd", []);
await page.waitForFunction(() =>
(document.querySelector(".image-viewer")?.scrollTop ?? 1) < 1
);
viewer.reversedPageGestureStayedOpen = await page.locator(".image-viewer").evaluate(
(overlay) => overlay.isConnected && overlay.dataset.page === "image",
);
await dispatchTouch("touchStart", [{ id: 36, x: 195, y: 690 }]);
await page.waitForTimeout(32);
await dispatchTouch("touchMove", [{ id: 36, x: 195, y: 510 }]);
await page.waitForTimeout(32);
viewer.detailsFollowedTouch = await page.locator(".image-viewer").evaluate((overlay) =>
overlay.scrollTop > 100 &&
overlay.scrollTop < overlay.clientHeight * 0.5 &&
overlay.dataset.page === "transition"
);
const titleDuringPageGesture = await page.locator(".image-viewer")
.getAttribute("data-image-name");
await dispatchTouch("touchMove", [{ id: 36, x: 60, y: 510 }]);
await page.waitForTimeout(32);
viewer.pageGestureIgnoredHorizontal =
await page.locator(".image-viewer").getAttribute("data-image-name")
=== titleDuringPageGesture;
await dispatchTouch("touchMove", [{ id: 36, x: 195, y: 360 }]);
await page.waitForTimeout(32);
await dispatchTouch("touchEnd", []);
await page.waitForFunction(() =>
(document.querySelector(".image-viewer")?.scrollTop ?? 0) > innerHeight * 0.5
);
await page.waitForTimeout(320);
await page.screenshot({ path: "/tmp/pixhelf-viewer-details-mobile.png" });
Object.assign(viewer, await page.evaluate(() => {
const overlay = document.querySelector(".image-viewer");
const stage = document.querySelector(".viewer-stage")?.getBoundingClientRect();
const details = document.querySelector(".viewer-details-page");
const detailsRect = details?.getBoundingClientRect();
return {
detailsReachedBySwipe: (overlay?.scrollTop ?? 0) > innerHeight * 0.5,
pageAfterDetails: overlay?.getAttribute("data-page") ?? "",
detailsName: details?.getAttribute("data-details-image-name") ?? "",
detailsHeading: document.querySelector(".viewer-details-heading h2")?.textContent ?? "",
stageScrolledAway: stage ? stage.top < -innerHeight * 0.5 : false,
detailsVisible: detailsRect
? detailsRect.top < innerHeight * 0.5 && detailsRect.bottom > innerHeight * 0.5
: false,
drawerArtifactsAfterScroll: document.querySelectorAll(
".viewer-details-sheet, .viewer-details-scrim",
).length,
};
}));
await page.locator(".viewer-details-return").click();
await page.waitForFunction(() =>
(document.querySelector(".image-viewer")?.scrollTop ?? 1) < 1
);
viewer.returnedToImage = true;
viewer.pageAfterReturn = await page.locator(".image-viewer").getAttribute("data-page");
await dispatchTouch("touchStart", [{ id: 41, x: 195, y: 210 }]);
await page.waitForTimeout(32);
await dispatchTouch("touchMove", [{ id: 41, x: 195, y: 510 }]);
await page.waitForTimeout(32);
await dispatchTouch("touchEnd", []);
viewer.closedByGesture = true;
} else {
await page.keyboard.press("Escape");
}
await page.waitForSelector(".viewer-return-layer", { timeout: 5_000 });
viewer.returnAnimationCreated = await page.locator(".viewer-return-layer").evaluate((layer) => ({
imageId: layer.getAttribute("data-image-id") ?? "",
phase: layer.getAttribute("data-phase") ?? "",
mediaCount: layer.querySelectorAll(".viewer-return-media").length,
canvasHasPixels: [...layer.querySelectorAll("canvas")]
.every((canvas) => canvas.width > 0 && canvas.height > 0),
maxCanvasEdge: Math.max(0, ...[...layer.querySelectorAll("canvas")]
.flatMap((canvas) => [canvas.width, canvas.height])),
visualReady: [...layer.querySelectorAll(".viewer-return-visual")]
.some((visual) => visual.complete && visual.naturalWidth > 0),
}));
await page.waitForSelector(".image-viewer", { state: "detached" });
await page.waitForFunction(() =>
document.querySelector(".viewer-return-layer")?.getAttribute("data-phase") === "flying"
, undefined, { timeout: 5_000 });
viewer.returnAnimationFlying = await page.locator(".viewer-return-layer").evaluate((layer) => {
const media = layer.querySelector(".viewer-return-media");
const target = document.querySelector('[data-viewer-return-target="true"]');
const animation = media?.getAnimations()[0];
const keyframes = animation?.effect instanceof KeyframeEffect
? animation.effect.getKeyframes()
: [];
const animatedProperties = [...new Set(keyframes.flatMap((keyframe) =>
Object.keys(keyframe).filter((property) =>
!["offset", "computedOffset", "easing", "composite"].includes(property)
)
))].sort();
return {
animations: media?.getAnimations().length ?? 0,
targetId: target?.getAttribute("data-image-id") ?? "",
startLatency: Number(layer.getAttribute("data-flying-at") ?? 0)
- Number(layer.getAttribute("data-created-at") ?? 0),
duration: Number(layer.getAttribute("data-duration") ?? 0),
animatedProperties,
};
});
const animationViewport = page.viewportSize();
if (animationViewport) {
await page.setViewportSize({
width: animationViewport.width + (target.name === "desktop" ? -18 : 10),
height: animationViewport.height,
});
await page.waitForFunction(() =>
Number(document.querySelector(".viewer-return-layer")?.getAttribute("data-retargets") ?? 0) > 0
);
await page.waitForFunction(() =>
document.querySelector(".viewer-return-layer")?.getAttribute("data-phase") === "flying"
);
viewer.returnAnimationRetargeted = await page.locator(".viewer-return-layer").evaluate(
(layer) => ({
count: Number(layer.getAttribute("data-retargets") ?? 0),
animations: layer.querySelector(".viewer-return-media")?.getAnimations().length ?? 0,
}),
);
}
await page.waitForTimeout(90);
await page.screenshot({ path: `/tmp/pixhelf-viewer-return-${target.name}.png` });
viewer.closed = await page.locator(".image-viewer").count();
viewer.rootRestored = await page.locator("#root").evaluate((root) => !root.inert);
await page.waitForFunction(() =>
document.querySelector(".app-shell")?.getAttribute("data-viewer-returning") === "false"
);
viewer.returnAnchorSettled = true;
viewer.returnAnimationCleared = await page.locator(".viewer-return-layer").count() === 0;
if (
viewer.returnAnimationCreated.mediaCount !== 1 ||
!viewer.returnAnimationCreated.canvasHasPixels ||
viewer.returnAnimationCreated.maxCanvasEdge > 1_440 ||
!viewer.returnAnimationCreated.visualReady ||
viewer.returnAnimationFlying.animations < 1 ||
viewer.returnAnimationFlying.targetId !== viewer.returnAnimationCreated.imageId ||
viewer.returnAnimationFlying.startLatency > 180 ||
viewer.returnAnimationFlying.duration > 280 ||
viewer.returnAnimationFlying.animatedProperties.join(",") !== "transform" ||
viewer.returnAnimationRetargeted?.count < 1 ||
viewer.returnAnimationRetargeted?.animations < 1 ||
!viewer.returnAnimationCleared
) {
throw new Error(`viewer return animation failed: ${JSON.stringify({
created: viewer.returnAnimationCreated,
flying: viewer.returnAnimationFlying,
cleared: viewer.returnAnimationCleared,
})}`);
}
const returnProbeStart = await page.evaluate(() => {
const cards = [...document.querySelectorAll(".masonry .image-card")];
const card = cards.at(-1);
if (!(card instanceof HTMLElement)) return null;
card.scrollIntoView({ block: "center", behavior: "instant" });
const rect = card.getBoundingClientRect();
const viewportTop = visualViewport?.offsetTop ?? 0;
const viewportHeight = visualViewport?.height ?? innerHeight;
const viewportBottom = viewportTop + viewportHeight;
const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom
?? viewportTop;
const visibleTop = Math.max(rect.top, viewportTop, topbarBottom);
const visibleBottom = Math.min(rect.bottom, viewportBottom);
const anchorY = visibleBottom > visibleTop
? (visibleTop + visibleBottom) / 2
: Math.min(viewportBottom, Math.max(viewportTop, rect.top + rect.height / 2));
const result = {
imageId: card.dataset.imageId ?? "",
beforeCount: cards.length,
beforeMasonryWidth: document.querySelector(".masonry")?.getBoundingClientRect().width ?? 0,
cardRatio: rect.height > 0 ? (anchorY - rect.top) / rect.height : 0.5,
viewportRatio: (anchorY - viewportTop) / Math.max(1, viewportHeight),
};
card.dispatchEvent(new MouseEvent("click", {
bubbles: true,
cancelable: true,
button: 0,
clientX: rect.left + rect.width / 2,
clientY: anchorY,
view: window,
}));
return result;
});
if (!returnProbeStart) throw new Error("viewer return probe could not find a card");
await page.waitForSelector(".image-viewer");
const returnViewerStartId = await page.locator(".image-viewer").getAttribute("data-image-id");
await page.keyboard.press("ArrowRight");
const resizedWidth = target.name === "desktop" ? 920 : 430;
await page.setViewportSize({ width: resizedWidth, height: target.height });
await page.waitForFunction((previousWidth) => {
const width = document.querySelector(".masonry")?.getBoundingClientRect().width ?? 0;
return Math.abs(width - previousWidth) > 20;
}, returnProbeStart.beforeMasonryWidth);
await page.waitForFunction(([startId, beforeCount]) =>
document.querySelector(".image-viewer")?.getAttribute("data-image-id") !== startId &&
document.querySelectorAll(".masonry .image-card").length > beforeCount
, [returnViewerStartId, returnProbeStart.beforeCount], { timeout: 60_000 });
const returnTargetId = await page.locator(".image-viewer").getAttribute("data-image-id");
await page.keyboard.press("Escape");
await page.waitForSelector(".image-viewer", { state: "detached" });
await page.waitForFunction((imageId) => {
const shell = document.querySelector(".app-shell");
const active = document.activeElement;
return shell?.getAttribute("data-viewer-returning") === "false" &&
active instanceof HTMLElement && active.dataset.imageId === imageId;
}, returnTargetId);
const returnRestoration = await page.evaluate(({ start, targetId }) => {
const card = document.querySelector(
`.masonry .image-card[data-image-id="${CSS.escape(targetId)}"]`,
);
if (!(card instanceof HTMLElement)) return null;
const rect = card.getBoundingClientRect();
const viewportTop = visualViewport?.offsetTop ?? 0;
const viewportHeight = visualViewport?.height ?? innerHeight;
const viewportBottom = viewportTop + viewportHeight;
const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom
?? viewportTop;
const safeTop = Math.min(viewportBottom, Math.max(viewportTop, topbarBottom + 8));
const safeBottom = Math.max(safeTop, viewportBottom - 8);
const expectedAnchorY = Math.min(
safeBottom,
Math.max(safeTop, viewportTop + start.viewportRatio * viewportHeight),
);
const actualAnchorY = rect.top + rect.height * start.cardRatio;
return {
openedId: start.imageId,
targetId,
cardsBefore: start.beforeCount,
cardsAfter: document.querySelectorAll(".masonry .image-card").length,
currentId: document.activeElement?.getAttribute("data-image-id") ?? "",
visible: rect.bottom > topbarBottom && rect.top < viewportBottom,
anchorDelta: Math.abs(actualAnchorY - expectedAnchorY),
returning: document.querySelector(".app-shell")
?.getAttribute("data-viewer-returning"),
};
}, { start: returnProbeStart, targetId: returnTargetId });
if (
!returnRestoration ||
returnRestoration.openedId === returnRestoration.targetId ||
returnRestoration.targetId !== returnRestoration.currentId ||
returnRestoration.cardsAfter <= returnRestoration.cardsBefore ||
!returnRestoration.visible ||
returnRestoration.anchorDelta > 2 ||
returnRestoration.returning !== "false"
) {
throw new Error(`viewer return restoration failed: ${JSON.stringify(returnRestoration)}`);
}
if (viewerReturnOnly) {
const result = {
name: target.name,
resizeStability,
sideButtonNavigation: viewer.sideButtonNavigation,
switchPerformance: viewer.switchPerformance,
rapidSwitch: viewer.rapidSwitch,
returnAnimation: {
created: viewer.returnAnimationCreated,
flying: viewer.returnAnimationFlying,
retargeted: viewer.returnAnimationRetargeted,
cleared: viewer.returnAnimationCleared,
},
returnRestoration,
browserErrors,
};
if (browserErrors.length) {
throw new Error(`viewer return browser errors: ${JSON.stringify(browserErrors)}`);
}
results.push(result);
console.log(JSON.stringify(result));
await page.close();
continue;
}
await page.setViewportSize({ width: target.width, height: target.height });
await page.waitForTimeout(120);
await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: "instant" }));
const searchToggleBefore = await page.locator(".search-toggle").boundingBox();
await page.locator(".search-toggle").click();
await page.waitForFunction(() =>
document.querySelector(".search-toggle")?.getAttribute("aria-expanded") === "true"
);
await page.waitForTimeout(220);
const searchToggleOpen = await page.locator(".search-toggle").boundingBox();
const searchPanelOpen = await page.locator("#gallery-search-field").boundingBox();
const searchDisclosure = await page.evaluate(() => {
const leading = document.querySelector(".topbar-leading")?.getBoundingClientRect();
const panel = document.querySelector("#gallery-search-field")?.getBoundingClientRect();
const topbar = document.querySelector(".topbar")?.getBoundingClientRect();
return {
expanded: document.querySelector(".search-toggle")?.getAttribute("aria-expanded"),
focused: document.activeElement?.matches("#gallery-search-field input") ?? false,
panelRole: document.querySelector("#gallery-search-field")?.getAttribute("role"),
panelInert: document.querySelector("#gallery-search-field")?.inert ?? true,
leadingGap: leading && panel ? panel.left - leading.right : -1,
panelTop: panel?.top ?? -1,
topbarTop: topbar?.top ?? -1,
topbarBottom: topbar?.bottom ?? -1,
};
});
searchDisclosure.panelWidth = searchPanelOpen?.width ?? -1;
searchDisclosure.stationaryToggleDelta = searchToggleBefore && searchToggleOpen
? Math.hypot(
searchToggleBefore.x - searchToggleOpen.x,
searchToggleBefore.y - searchToggleOpen.y,
)
: -1;
await page.locator("#gallery-search-field input").fill("x");
await page.waitForFunction(() =>
document.querySelector(".topbar-search")?.classList.contains("has-query")
);
searchDisclosure.hasQueryClass = true;
await page.locator("#gallery-search-field input").fill("");
await page.waitForFunction(() =>
!document.querySelector(".topbar-search")?.classList.contains("has-query")
);
searchDisclosure.queryClassCleared = true;
if (target.name === "mobile") {
await page.evaluate(() => {
document.documentElement.style.setProperty("--visual-viewport-top", "24px");
});
await page.waitForTimeout(50);
const compensatedTopbar = await page.locator(".topbar").boundingBox();
const compensatedPanel = await page.locator("#gallery-search-field").boundingBox();
searchDisclosure.compensatedTopbarDelta = compensatedTopbar
? compensatedTopbar.y - searchDisclosure.topbarTop
: -1;
searchDisclosure.compensatedPanelDelta = compensatedPanel
? compensatedPanel.y - searchDisclosure.panelTop
: -1;
await page.evaluate(() => {
document.documentElement.style.setProperty("--visual-viewport-top", "0px");
});
await page.waitForTimeout(50);
}
await page.locator(".search-toggle").click();
await page.waitForFunction(() =>
document.querySelector(".search-toggle")?.getAttribute("aria-expanded") === "false"
);
searchDisclosure.restored = await page.locator(".search-toggle")
.getAttribute("aria-expanded");
searchDisclosure.panelInertClosed = await page.locator("#gallery-search-field")
.evaluate((panel) => panel.inert);
const firstExploreRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return url.pathname === "/api/images" &&
url.searchParams.get("sort") === "explore" &&
url.searchParams.get("offset") === "0";
});
await page.locator(".explore-toggle").click();
const firstExplore = await firstExploreRequest;
const firstExploreUrl = new URL(firstExplore.url());
const firstExploreResponse = await firstExplore.response();
await firstExploreResponse?.finished();
await page.waitForFunction(() => !document.querySelector(".skeleton-grid"));
const secondExploreRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return url.pathname === "/api/images" &&
url.searchParams.get("sort") === "explore" &&
url.searchParams.get("offset") === "0";
});
await page.locator(".explore-toggle").click();
const secondExplore = await secondExploreRequest;
const secondExploreUrl = new URL(secondExplore.url());
const secondExploreResponse = await secondExplore.response();
await secondExploreResponse?.finished();
await page.waitForFunction(() => !document.querySelector(".skeleton-grid"));
const exploration = await page.evaluate(([firstSeed, secondSeed]) => ({
firstSeed,
secondSeed,
exploreMarker: getComputedStyle(
document.querySelector(".explore-toggle"),
"::after",
).content,
searchMarker: getComputedStyle(
document.querySelector(".search-toggle"),
"::after",
).content,
}), [
firstExploreUrl.searchParams.get("seed") ?? "",
secondExploreUrl.searchParams.get("seed") ?? "",
]);
const homeRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return url.pathname === "/api/images" &&
url.searchParams.get("sort") === "name-asc" &&
url.searchParams.get("offset") === "0" &&
!url.searchParams.has("album") &&
!url.searchParams.has("search");
});
await page.locator("a.brand").click();
const homeResponse = await (await homeRequest).response();
await homeResponse?.finished();
await page.waitForFunction(() =>
document.querySelector(".explore-toggle")?.getAttribute("aria-pressed") === "false" &&
document.querySelector(".desktop-sidebar .album-link")?.classList.contains("active") &&
document.querySelector("#gallery-search-field input")?.value === ""
, undefined, { timeout: 60_000 });
const homeNavigation = await page.evaluate(() => ({
href: document.querySelector("a.brand")?.getAttribute("href"),
galleryPath: document.querySelector(
".desktop-sidebar .sidebar-gallery-path",
)?.textContent,
allImagesActive: document.querySelector(".desktop-sidebar .album-link")
?.classList.contains("active") ?? false,
searchValue: document.querySelector("#gallery-search-field input")?.value,
}));
let mobileCardNames = null;
if (target.name === "mobile") {
const firstCard = page.locator("[data-image-id]").first();
const secondCard = page.locator("[data-image-id]").nth(1);
await firstCard.dispatchEvent("pointerdown", {
bubbles: true,
cancelable: true,
pointerId: 1,
pointerType: "touch",
isPrimary: true,
});
await page.waitForFunction(() =>
document.querySelector("[data-image-id]")?.getAttribute("data-name-visible") === "true"
);
const shownOnContact = await firstCard.getAttribute("data-name-visible");
await firstCard.dispatchEvent("pointerup", {
bubbles: true,
cancelable: true,
pointerId: 1,
pointerType: "touch",
isPrimary: true,
});
const retainedOnRelease = await firstCard.getAttribute("data-name-visible");
await page.waitForTimeout(1_900);
const retainedWithoutTimeout = await firstCard.getAttribute("data-name-visible");
await secondCard.dispatchEvent("pointerdown", {
bubbles: true,
cancelable: true,
pointerId: 2,
pointerType: "touch",
isPrimary: true,
});
await page.waitForFunction(() =>
document.querySelectorAll('[data-name-visible="true"]').length === 1 &&
document.querySelectorAll("[data-image-id]")[1]
?.getAttribute("data-name-visible") === "true"
);
await page.waitForTimeout(180);
const switchedToSecond = await secondCard.getAttribute("data-name-visible");
const firstHidden = await firstCard.getAttribute("data-name-visible");
const activeCount = await page.locator('[data-name-visible="true"]').count();
const displayedCount = await page.locator('[data-name-visible="true"] .image-name')
.evaluateAll((names) => names.filter((name) =>
Number.parseFloat(getComputedStyle(name).opacity) > 0.99
).length);
await secondCard.dispatchEvent("pointerup", {
bubbles: true,
cancelable: true,
pointerId: 2,
pointerType: "touch",
isPrimary: true,
});
await secondCard.evaluate((card) => {
globalThis.__pixhelfCardClickCount = 0;
card.addEventListener("click", () => {
globalThis.__pixhelfCardClickCount += 1;
}, { once: true });
});
const secondCardBox = await secondCard.boundingBox();
if (secondCardBox) {
await page.touchscreen.tap(
secondCardBox.x + secondCardBox.width / 2,
secondCardBox.y + secondCardBox.height / 2,
);
}
const clickCount = await page.evaluate(() => globalThis.__pixhelfCardClickCount ?? 0);
await page.waitForSelector(".image-viewer");
const viewerOpenedFromTouch = await page.locator(".image-viewer").count();
await page.locator(".viewer-close").click();
await page.waitForSelector(".image-viewer", { state: "detached" });
mobileCardNames = {
shownOnContact,
retainedOnRelease,
retainedWithoutTimeout,
switchedToSecond,
firstHidden,
activeCount,
displayedCount,
clickCount,
viewerOpenedFromTouch,
};
}
let desktopSidebar = null;
let mobileNavigation = null;
if (target.name === "desktop") {
const contentBefore = await page.locator(".content").boundingBox();
const toggleBefore = await page.locator(".gallery-sidebar-toggle").boundingBox();
const firstAlbumBefore = await page.locator(".desktop-sidebar .album-link").first()
.boundingBox();
const controlSlotBefore = await page.locator(".desktop-sidebar .sidebar-control-slot")
.boundingBox();
const metaBefore = await page.locator(".desktop-sidebar .sidebar-gallery-meta")
.boundingBox();
const galleryBefore = await page.locator(".masonry").boundingBox();
await page.locator(".gallery-sidebar-toggle").click();
await page.waitForFunction(() =>
document.querySelector(".app-shell")?.getAttribute("data-sidebar-collapsed") === "true"
);
await page.waitForTimeout(260);
const contentCollapsed = await page.locator(".content").boundingBox();
const toggleCollapsed = await page.locator(".gallery-sidebar-toggle").boundingBox();
const sidebarCollapsedBox = await page.locator(".desktop-sidebar").boundingBox();
const galleryCollapsed = await page.locator(".masonry").boundingBox();
desktopSidebar = await page.evaluate(() => ({
expanded: document.querySelector(".gallery-sidebar-toggle")?.getAttribute("aria-expanded"),
visibility: getComputedStyle(document.querySelector(".desktop-sidebar")).visibility,
navigationVisibility: getComputedStyle(
document.querySelector(".desktop-sidebar .album-nav"),
).visibility,
statusVisibility: getComputedStyle(
document.querySelector(".desktop-sidebar .sidebar-status"),
).visibility,
metaVisibility: getComputedStyle(
document.querySelector(".desktop-sidebar .sidebar-gallery-meta"),
).visibility,
ariaHidden: document.querySelector(".desktop-sidebar")?.getAttribute("aria-hidden"),
stored: localStorage.getItem("pixhelf.sidebar-collapsed"),
columns: Number(
document.querySelector(".masonry")?.getAttribute("data-columns") ?? 0,
),
redundantHeadings: document.querySelectorAll(".sidebar-heading").length,
pathDetails: document.querySelectorAll(".album-copy small").length,
progressPanels: document.querySelectorAll(".sidebar-progress").length,
inlineToggles: document.querySelectorAll(".desktop-sidebar-inline-toggle").length,
galleryToggles: document.querySelectorAll(".gallery-sidebar-toggle").length,
}));
desktopSidebar.contentBefore = contentBefore?.x ?? -1;
desktopSidebar.contentCollapsed = contentCollapsed?.x ?? -1;
desktopSidebar.expandedToggleWidth = toggleBefore?.width ?? -1;
desktopSidebar.expandedToggleHeight = toggleBefore?.height ?? -1;
desktopSidebar.collapsedToggleWidth = toggleCollapsed?.width ?? -1;
desktopSidebar.collapsedToggleHeight = toggleCollapsed?.height ?? -1;
desktopSidebar.albumClearance = controlSlotBefore && firstAlbumBefore
? firstAlbumBefore.y - (controlSlotBefore.y + controlSlotBefore.height)
: -1;
desktopSidebar.toggleControlInsetX = toggleBefore && controlSlotBefore
? toggleBefore.x - controlSlotBefore.x
: -1;
desktopSidebar.toggleControlInsetY = toggleBefore && controlSlotBefore
? toggleBefore.y - controlSlotBefore.y
: -1;
desktopSidebar.stationaryToggleDelta = toggleBefore && toggleCollapsed
? Math.hypot(toggleBefore.x - toggleCollapsed.x, toggleBefore.y - toggleCollapsed.y)
: -1;
desktopSidebar.metaIsBelowAlbums = metaBefore && firstAlbumBefore
? metaBefore.y > firstAlbumBefore.y + firstAlbumBefore.height
: false;
desktopSidebar.expandedGalleryInsetX = toggleBefore && galleryBefore
? toggleBefore.x - galleryBefore.x
: -1;
desktopSidebar.expandedGalleryInsetY = toggleBefore && galleryBefore
? toggleBefore.y - galleryBefore.y
: -1;
desktopSidebar.collapsedGalleryInsetX = toggleCollapsed && galleryCollapsed
? toggleCollapsed.x - galleryCollapsed.x
: -1;
desktopSidebar.collapsedGalleryInsetY = toggleCollapsed && galleryCollapsed
? toggleCollapsed.y - galleryCollapsed.y
: -1;
desktopSidebar.expandedGalleryOverlap = toggleBefore && galleryBefore
? !(
toggleBefore.x + toggleBefore.width <= galleryBefore.x ||
galleryBefore.x + galleryBefore.width <= toggleBefore.x ||
toggleBefore.y + toggleBefore.height <= galleryBefore.y ||
galleryBefore.y + galleryBefore.height <= toggleBefore.y
)
: false;
desktopSidebar.galleryOverlap = toggleCollapsed && galleryCollapsed
? !(
toggleCollapsed.x + toggleCollapsed.width <= galleryCollapsed.x ||
galleryCollapsed.x + galleryCollapsed.width <= toggleCollapsed.x ||
toggleCollapsed.y + toggleCollapsed.height <= galleryCollapsed.y ||
galleryCollapsed.y + galleryCollapsed.height <= toggleCollapsed.y
)
: true;
desktopSidebar.sidebarRight = sidebarCollapsedBox
? sidebarCollapsedBox.x + sidebarCollapsedBox.width
: -1;
await page.locator(".gallery-sidebar-toggle").click();
await page.waitForFunction(() =>
document.querySelector(".app-shell")?.getAttribute("data-sidebar-collapsed") === "false"
);
await page.waitForTimeout(260);
const toggleRestored = await page.locator(".gallery-sidebar-toggle").boundingBox();
desktopSidebar.restoredToggleDelta = toggleBefore && toggleRestored
? Math.hypot(toggleBefore.x - toggleRestored.x, toggleBefore.y - toggleRestored.y)
: -1;
desktopSidebar.restored = await page.locator(".gallery-sidebar-toggle")
.getAttribute("aria-expanded");
} else {
await page.evaluate(() => window.scrollTo({ top: 320, behavior: "auto" }));
await page.waitForTimeout(100);
const fixedTop = await page.locator(".topbar").boundingBox();
await page.evaluate(() => window.scrollTo({ top: 0, behavior: "auto" }));
const navigationToggleBefore = await page.locator(".gallery-sidebar-toggle").boundingBox();
const galleryBefore = await page.locator(".masonry").boundingBox();
await page.locator(".gallery-sidebar-toggle").click();
await page.waitForSelector(".mobile-nav-layer");
await page.waitForTimeout(240);
const navigationToggleOpen = await page.locator(".gallery-sidebar-toggle").boundingBox();
const navigationLayer = await page.locator(".mobile-nav-layer").boundingBox();
const navigationDrawer = await page.locator(".mobile-sidebar").boundingBox();
const controlSlot = await page.locator(".mobile-sidebar .sidebar-control-slot").boundingBox();
const mobileMeta = await page.locator(".mobile-sidebar .sidebar-gallery-meta").boundingBox();
const mobileStatus = await page.locator(".mobile-sidebar .sidebar-status").boundingBox();
const topbar = await page.locator(".topbar").boundingBox();
mobileNavigation = {
opened: await page.locator(".mobile-nav-layer").count(),
fixedTop: fixedTop?.y ?? -1,
expanded: await page.locator(".gallery-sidebar-toggle").getAttribute("aria-expanded"),
internalToggles: await page.locator(".mobile-sidebar .sidebar-toggle-button").count(),
metaRows: await page.locator(".mobile-sidebar .sidebar-gallery-meta").count(),
galleryPath: await page.locator(".mobile-sidebar .sidebar-gallery-path").textContent(),
galleryCount: await page.locator(".mobile-sidebar .sidebar-gallery-count").textContent(),
layerTopGap: navigationLayer && topbar
? navigationLayer.y - (topbar.y + topbar.height)
: -1,
toggleWidth: navigationToggleBefore?.width ?? -1,
toggleHeight: navigationToggleBefore?.height ?? -1,
expandedToggleWidth: navigationToggleOpen?.width ?? -1,
galleryInsetX: navigationToggleBefore && galleryBefore
? navigationToggleBefore.x - galleryBefore.x
: -1,
galleryInsetY: navigationToggleBefore && galleryBefore
? navigationToggleBefore.y - galleryBefore.y
: -1,
galleryOverlap: navigationToggleBefore && galleryBefore
? !(
navigationToggleBefore.x + navigationToggleBefore.width <= galleryBefore.x ||
galleryBefore.x + galleryBefore.width <= navigationToggleBefore.x ||
navigationToggleBefore.y + navigationToggleBefore.height <= galleryBefore.y ||
galleryBefore.y + galleryBefore.height <= navigationToggleBefore.y
)
: -1,
stationaryToggleDelta: navigationToggleBefore && navigationToggleOpen
? Math.hypot(
navigationToggleBefore.x - navigationToggleOpen.x,
navigationToggleBefore.y - navigationToggleOpen.y,
)
: -1,
toggleControlInsetX: navigationToggleOpen && controlSlot
? navigationToggleOpen.x - controlSlot.x
: -1,
toggleControlInsetY: navigationToggleOpen && controlSlot
? navigationToggleOpen.y - controlSlot.y
: -1,
metaBottomGap: mobileMeta ? target.height - (mobileMeta.y + mobileMeta.height) : -1,
metaStatusGap: mobileMeta && mobileStatus
? mobileMeta.y - (mobileStatus.y + mobileStatus.height)
: -1,
drawerWidth: navigationDrawer?.width ?? -1,
};
await page.locator(".gallery-sidebar-toggle").click();
await page.waitForSelector(".mobile-nav-layer", { state: "detached" });
mobileNavigation.closed = await page.locator(".mobile-nav-layer").count();
mobileNavigation.restored = await page.locator(".gallery-sidebar-toggle")
.getAttribute("aria-expanded");
}
const result = {
name: target.name,
...layout,
resizeStability,
dialogsAfterImageClick,
viewer,
returnRestoration,
searchDisclosure,
exploration,
homeNavigation,
mobileCardNames,
desktopSidebar,
mobileNavigation,
browserErrors,
};
results.push(result);
console.log(JSON.stringify(result));
const expectedColumns = target.name === "desktop" ? 5 : 2;
if (
layout.horizontalOverflow ||
layout.brokenVisibleImages ||
layout.masonryColumns !== expectedColumns ||
layout.cardTags.some((tag) => tag !== "FIGURE") ||
layout.dialogs !== 0 ||
dialogsAfterImageClick !== 1 ||
viewer.title !== firstViewerTitle ||
viewer.fullLoaded !== "true" ||
!viewer.originalPath.endsWith("/original") ||
!["native", "safe-canvas", "viewport-bitmap"].includes(viewer.renderer) ||
viewer.sourceStrategy !== (
target.name === "mobile" ? "viewport-upgrade" : "direct-original"
) ||
viewer.thumbnailFilter !== "none" ||
viewer.thumbnailTransform !== "none" ||
(viewer.sourcePresentation === "upgrade" && (
Number.parseFloat(viewer.thumbnailTransitionDelay) < .15 ||
Number.parseFloat(viewer.originalTransitionDuration) < .15
)) ||
viewer.renderedPixels.some((size) => size <= 0) ||
(viewer.renderer === "safe-canvas" && viewer.renderedPixels.some((size) => size > 4096)) ||
(viewer.renderer === "viewport-bitmap" && viewer.renderedPixels.some((size) => size > 2048)) ||
(target.name === "mobile" && viewer.renderer !== "viewport-bitmap") ||
viewer.prefetchedOriginals < 2 ||
viewer.previewRequests !== 0 ||
viewer.fullscreenButtons !== 1 ||
viewer.detailsPages !== 1 ||
viewer.scrollCues !== 1 ||
viewer.headingModules !== 0 ||
!viewer.unifiedControls ||
viewer.zoomControls !== 0 ||
!viewer.scrollCueArrowOnly ||
viewer.drawerArtifacts !== 0 ||
viewer.scrollMode !== "continuous" ||
!viewer.continuousLayout ||
viewer.gestureHelp !== 0 ||
viewer.loadingStatus !== 0 ||
viewer.loadingCopyVisible ||
!viewer.fullscreenEntered ||
viewer.fullscreenLabel !== "退出全屏" ||
!viewer.fullscreenExited ||
!viewer.rootInert ||
!viewer.closeFocused ||
!viewer.mediaContained ||
!viewer.position.includes("/") ||
viewer.zoomed !== "true" ||
Math.abs(viewer.zoomScale - 1.5) > 0.02 ||
!viewer.zoomKeptImagePage ||
!viewer.zoomLockedPageScroll ||
!viewer.keyboardNavigation ||
viewer.keyboardNavigation === firstViewerTitle ||
!viewer.keyboardFocus?.parkedOnViewer ||
viewer.keyboardFocus.visibleControlFocusRings !== 0 ||
!viewer.switchPerformance?.focusedOnViewer ||
viewer.switchPerformance.visibleControlFocusRings !== 0 ||
(target.name === "mobile"
? !["thumbnail", "viewport-bitmap"].includes(viewer.switchPerformance.firstFrameSource)
: viewer.switchPerformance.firstFrameSource !== "original") ||
viewer.switchPerformance.mediaAnimations !== 0 ||
viewer.switchPerformance.dispatchDuration > 80 ||
viewer.switchPerformance.firstFrameDuration > 100 ||
!viewerChrome.scrollCuePresentation?.borderless ||
!viewerChrome.scrollCuePresentation?.transparent ||
viewerChrome.scrollCuePresentation.width < 88 ||
!viewerChrome.scrollCuePresentation?.clearsMedia ||
(target.name === "desktop" && (
!viewer.detailsReachedByWheel ||
viewer.detailsName !== viewer.keyboardNavigation ||
!viewer.zoomWheelHandoffLocked ||
viewer.pageAfterDetails !== "details" ||
viewer.detailsHeading !== "图片详情" ||
!viewer.stageScrolledAway ||
!viewer.detailsVisible ||
viewer.drawerArtifactsAfterScroll !== 0 ||
!viewer.returnedToImage ||
viewer.pageAfterReturn !== "image" ||
!viewer.returnWheelHandoffLocked
)) ||
(target.name === "mobile" && (
viewer.nativeOriginalLoaded !== "false" ||
viewer.nativeOriginalActive !== "false" ||
viewer.nativeOriginalCount !== 0 ||
viewer.nativeOriginalPath !== "" ||
viewer.nativeOriginalVisible > .01 ||
viewer.nativeOriginalPixels.some((size) => size !== 0) ||
viewer.displaySource !== "viewport-bitmap" ||
viewer.gestureRenderer !== "raf-dom" ||
viewer.originalPolicy !== "zoom-only" ||
viewer.mediaWillChange !== "auto" ||
!viewer.pinchZoomed ||
viewer.pinchScale < 1.5 ||
viewer.pinchNativeOriginal?.active !== "true" ||
viewer.pinchNativeOriginal.displaySource !== "original" ||
viewer.pinchNativeOriginal.visible < .99 ||
viewer.pinchNativeOriginal.settledWillChange !== "auto" ||
!viewer.doubleTapReset ||
Math.abs(viewer.doubleTapScale - 1) > 0.02 ||
viewer.nativeOriginalAfterReset?.active !== "false" ||
viewer.nativeOriginalAfterReset.displaySource !== "viewport-bitmap" ||
viewer.nativeOriginalAfterReset.visible > .01 ||
viewer.nativeOriginalAfterReset.settledWillChange !== "auto" ||
!viewer.swipeNavigation ||
viewer.swipeNavigation === viewer.keyboardNavigation ||
!viewer.shortSwipeNavigation?.changed ||
viewer.shortSwipeNavigation.distance !== 40 ||
viewer.swipeDragTracking > -190 ||
viewer.swipeDragTracking < -225 ||
viewer.swipeDragWillChange !== "transform" ||
viewer.swipeMotion?.mode !== "interruptible" ||
viewer.swipeMotion.direction !== "next" ||
viewer.swipeMotion.snapshot !== "prepared" ||
viewer.swipeMotion.releaseCostMs > 12 ||
viewer.swipeMotion.incomingAnimations < 1 ||
viewer.swipeMotion.outgoingAnimations < 1 ||
!viewer.swipeMotion.outgoingIgnoresInput ||
!viewer.swipeMotionCleaned ||
!viewer.reversedPageGestureStayedOpen ||
!viewer.pageGestureIgnoredHorizontal ||
!viewer.detailsFollowedTouch ||
!viewer.detailsReachedBySwipe ||
viewer.pageAfterDetails !== "details" ||
viewer.detailsName !== viewer.shortSwipeNavigation.imageName ||
viewer.detailsHeading !== "图片详情" ||
!viewer.stageScrolledAway ||
!viewer.detailsVisible ||
viewer.drawerArtifactsAfterScroll !== 0 ||
!viewer.returnedToImage ||
viewer.pageAfterReturn !== "image" ||
!viewer.closedByGesture
)) ||
viewer.closed !== 0 ||
!viewer.rootRestored ||
!viewer.returnAnchorSettled ||
!returnRestoration ||
returnRestoration.openedId === returnRestoration.targetId ||
returnRestoration.targetId !== returnRestoration.currentId ||
returnRestoration.cardsAfter <= returnRestoration.cardsBefore ||
!returnRestoration.visible ||
returnRestoration.anchorDelta > 2 ||
returnRestoration.returning !== "false" ||
searchDisclosure.expanded !== "true" ||
!searchDisclosure.focused ||
searchDisclosure.panelRole !== "group" ||
searchDisclosure.panelInert ||
(target.name === "desktop" && searchDisclosure.leadingGap < 6) ||
searchDisclosure.panelWidth < (target.name === "desktop" ? 250 : 200) ||
searchDisclosure.stationaryToggleDelta < 0 ||
searchDisclosure.stationaryToggleDelta > 0.5 ||
searchDisclosure.restored !== "false" ||
!searchDisclosure.panelInertClosed ||
!searchDisclosure.hasQueryClass ||
!searchDisclosure.queryClassCleared ||
searchDisclosure.panelTop - searchDisclosure.topbarBottom < 6 ||
exploration.firstSeed.length !== 32 ||
exploration.secondSeed.length !== 32 ||
exploration.firstSeed === exploration.secondSeed ||
exploration.exploreMarker !== "none" ||
exploration.searchMarker !== "none" ||
homeNavigation.href !== "/" ||
homeNavigation.galleryPath !== "/" ||
!homeNavigation.allImagesActive ||
homeNavigation.searchValue !== "" ||
layout.topbarSearches !== 1 ||
layout.brandHomeLinks !== 1 ||
layout.gallerySidebarToggles !== 1 ||
layout.galleryToggleOpacity < 0.4 ||
layout.galleryToggleOpacity > 0.75 ||
layout.topbarSidebarToggles !== 0 ||
layout.contentSearches !== 0 ||
layout.contentInfoRows !== 0 ||
layout.sortControls !== 0 ||
layout.topbarSearchRightGap < 0 ||
layout.topbarSearchRightGap > 20 ||
layout.exploreButtons !== 1 ||
layout.exploreSearchGap < 4 ||
layout.exploreSearchGap > 8 ||
layout.sidebarStatuses !== 1 ||
layout.sidebarGalleryMetas !== 1 ||
(target.name === "desktop" && (
!resizeStability ||
!resizeStability.cardsPreserved ||
!resizeStability.loadedCardsPreserved ||
resizeStability.skeletonSeen ||
resizeStability.imagePageRequests !== 0 ||
resizeStability.brokenVisibleImages !== 0 ||
resizeStability.columns !== 5 ||
!resizeStability.anchorFocused ||
resizeStability.anchorDeltas.length !== 4 ||
resizeStability.anchorDeltas.some((delta) => !Number.isFinite(delta) || delta > 2)
)) ||
!layout.galleryPath.startsWith("/") ||
!layout.galleryCount.endsWith("张图片") ||
layout.galleryMetaRowDelta < 0 ||
layout.galleryMetaRowDelta > 2 ||
(target.name === "desktop" && (
!desktopSidebar ||
desktopSidebar.expanded !== "false" ||
desktopSidebar.visibility !== "visible" ||
desktopSidebar.navigationVisibility !== "hidden" ||
desktopSidebar.statusVisibility !== "hidden" ||
desktopSidebar.metaVisibility !== "hidden" ||
desktopSidebar.ariaHidden !== "true" ||
desktopSidebar.stored !== "true" ||
desktopSidebar.columns !== 6 ||
desktopSidebar.redundantHeadings !== 0 ||
desktopSidebar.pathDetails !== 0 ||
desktopSidebar.progressPanels !== 0 ||
desktopSidebar.inlineToggles !== 0 ||
desktopSidebar.galleryToggles !== 1 ||
Math.abs(desktopSidebar.expandedToggleWidth - (desktopSidebar.contentBefore - 20)) > 1 ||
Math.abs(desktopSidebar.expandedToggleHeight - 36) > 1 ||
Math.abs(desktopSidebar.collapsedToggleWidth - 40) > 1 ||
Math.abs(desktopSidebar.collapsedToggleHeight - 36) > 1 ||
desktopSidebar.albumClearance < 8 ||
desktopSidebar.toggleControlInsetX < 8 ||
desktopSidebar.toggleControlInsetX > 12 ||
desktopSidebar.toggleControlInsetY < 4 ||
desktopSidebar.toggleControlInsetY > 8 ||
desktopSidebar.stationaryToggleDelta < 0 ||
desktopSidebar.stationaryToggleDelta > 1 ||
!desktopSidebar.metaIsBelowAlbums ||
desktopSidebar.expandedGalleryOverlap ||
!desktopSidebar.galleryOverlap ||
Math.abs(desktopSidebar.sidebarRight) > 1 ||
desktopSidebar.contentBefore < 200 ||
Math.abs(desktopSidebar.contentCollapsed) > 1 ||
desktopSidebar.restoredToggleDelta < 0 ||
desktopSidebar.restoredToggleDelta > 1 ||
layout.sidebarMetaBottomGap < 0 ||
layout.sidebarMetaBottomGap > 1 ||
Math.abs(layout.sidebarMetaStatusGap) > 1 ||
desktopSidebar.restored !== "true"
)) ||
(target.name === "mobile" && (
layout.topbarPosition !== "fixed" ||
Math.abs(layout.topbarTop) > 1 ||
Math.abs(searchDisclosure.compensatedTopbarDelta - 24) > 1 ||
Math.abs(searchDisclosure.compensatedPanelDelta - 24) > 1 ||
mobileNavigation?.opened !== 1 ||
Math.abs(mobileNavigation.fixedTop) > 1 ||
mobileNavigation.expanded !== "true" ||
mobileNavigation.internalToggles !== 0 ||
mobileNavigation.metaRows !== 1 ||
!mobileNavigation.galleryPath?.startsWith("/") ||
!mobileNavigation.galleryCount?.endsWith("张图片") ||
Math.abs(mobileNavigation.layerTopGap) > 1 ||
Math.abs(mobileNavigation.toggleWidth - 40) > 1 ||
Math.abs(mobileNavigation.toggleHeight - 36) > 1 ||
Math.abs(mobileNavigation.expandedToggleWidth - (mobileNavigation.drawerWidth - 20)) > 1 ||
!mobileNavigation.galleryOverlap ||
mobileNavigation.stationaryToggleDelta < 0 ||
mobileNavigation.stationaryToggleDelta > 1 ||
mobileNavigation.toggleControlInsetX < 8 ||
mobileNavigation.toggleControlInsetX > 12 ||
mobileNavigation.toggleControlInsetY < 4 ||
mobileNavigation.toggleControlInsetY > 8 ||
mobileNavigation.metaBottomGap < 0 ||
mobileNavigation.metaBottomGap > 1 ||
Math.abs(mobileNavigation.metaStatusGap) > 1 ||
mobileNavigation.drawerWidth < 200 ||
mobileNavigation.closed !== 0 ||
mobileNavigation.restored !== "false" ||
mobileCardNames?.shownOnContact !== "true" ||
mobileCardNames.retainedOnRelease !== "true" ||
mobileCardNames.retainedWithoutTimeout !== "true" ||
mobileCardNames.switchedToSecond !== "true" ||
mobileCardNames.firstHidden !== "false" ||
mobileCardNames.activeCount !== 1 ||
mobileCardNames.displayedCount !== 1 ||
mobileCardNames.clickCount !== 1 ||
mobileCardNames.viewerOpenedFromTouch !== 1 ||
layout.cardNameDisplay === "none" ||
layout.cardNameOpacity > 0.01 ||
layout.cardNameWhiteSpace !== "nowrap"
)) ||
(target.name === "desktop" && layout.topbarPosition !== "fixed") ||
browserErrors.length
) {
throw new Error(`visual check failed: ${JSON.stringify(result)}`);
}
await page.close();
}
} finally {
await browser.close();
}
console.log(JSON.stringify(results, null, 2));