Wait for screenshot page readiness
Some checks failed
Deploy / deploy-frontend (push) Has been cancelled
Deploy / deploy-backend (push) Has been cancelled

This commit is contained in:
Matt Isenhower
2026-08-29 22:34:20 -07:00
parent 79f6232abe
commit 9dff1c01dc
8 changed files with 168 additions and 11 deletions

View File

@@ -13,7 +13,7 @@ describe('ScreenshotHelper', () => {
let page = {
setViewport: vi.fn(),
goto: vi.fn(),
waitForNetworkIdle: vi.fn(),
waitForSelector: vi.fn(),
screenshot: vi.fn().mockResolvedValue(png),
close: vi.fn(),
};
@@ -54,9 +54,12 @@ describe('ScreenshotHelper', () => {
});
expect(page.goto).toHaveBeenCalledWith(
new URL('http://app:4321/screenshots/#schedules'),
{ waitUntil: 'networkidle0' },
{ waitUntil: 'load' },
);
expect(page.waitForSelector).toHaveBeenCalledWith(
'[data-screenshot-ready="true"]',
{ timeout: 30_000 },
);
expect(page.waitForNetworkIdle).toHaveBeenCalledWith({ idleTime: 1000 });
expect(httpServer.close).toHaveBeenCalledOnce();
expect(page.close).toHaveBeenCalledOnce();
expect(browser.close).toHaveBeenCalledOnce();
@@ -90,14 +93,17 @@ describe('ScreenshotHelper', () => {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://splatoon3.ink/screenshots/#schedules?time=123&region=NA',
url: 'https://splatoon3.ink/screenshots/index.html#schedules?time=123&region=NA',
viewport: {
width: 600,
height: 675,
deviceScaleFactor: 2,
},
gotoOptions: { waitUntil: 'networkidle0' },
waitForTimeout: 1000,
gotoOptions: { waitUntil: 'load' },
waitForSelector: {
selector: '[data-screenshot-ready="true"]',
timeout: 30_000,
},
screenshotOptions: { type: 'png' },
}),
},

View File

@@ -1,5 +1,6 @@
import { URL } from 'url';
import puppeteer from 'puppeteer-core';
import { screenshotReadySelector, screenshotReadyTimeout } from '../../../src/common/screenshot.mjs';
import HttpServer from '../HttpServer.mjs';
export default class BrowserlessScreenshotDriver
@@ -42,9 +43,11 @@ export default class BrowserlessScreenshotDriver
await this._page.setViewport(viewport);
await this._page.goto(url, {
waitUntil: 'networkidle0',
waitUntil: 'load',
});
await this._page.waitForSelector(screenshotReadySelector, {
timeout: screenshotReadyTimeout,
});
await this._page.waitForNetworkIdle({ idleTime: 1000 });
return await this._page.screenshot();
}

View File

@@ -1,4 +1,5 @@
import { URL } from 'url';
import { screenshotReadySelector, screenshotReadyTimeout } from '../../../src/common/screenshot.mjs';
export default class CloudflareScreenshotDriver
{
@@ -48,8 +49,11 @@ export default class CloudflareScreenshotDriver
body: JSON.stringify({
url: url.toString(),
viewport,
gotoOptions: { waitUntil: 'networkidle0' },
waitForTimeout: 1000,
gotoOptions: { waitUntil: 'load' },
waitForSelector: {
selector: screenshotReadySelector,
timeout: screenshotReadyTimeout,
},
screenshotOptions: { type: 'png' },
}),
});

24
src/common/screenshot.mjs Normal file
View File

@@ -0,0 +1,24 @@
export const screenshotReadyAttribute = 'data-screenshot-ready';
export const screenshotReadySelector = `[${screenshotReadyAttribute}="true"]`;
export const screenshotReadyTimeout = 30_000;
function nextFrame(requestAnimationFrame) {
return new Promise(resolve => requestAnimationFrame(resolve));
}
export async function markScreenshotReady({
document = globalThis.document,
isCurrent = () => true,
requestAnimationFrame = globalThis.requestAnimationFrame,
} = {}) {
await document.fonts?.ready;
await Promise.allSettled(
[...document.images].map(image => image.decode()),
);
await nextFrame(requestAnimationFrame);
await nextFrame(requestAnimationFrame);
if (isCurrent()) {
document.documentElement.setAttribute(screenshotReadyAttribute, 'true');
}
}

View File

@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest';
import { markScreenshotReady } from './screenshot.mjs';
describe('markScreenshotReady', () => {
it('marks the page ready after fonts, images, and layout settle', async () => {
let resolveFonts;
let resolveImage;
let fontsReady = new Promise(resolve => { resolveFonts = resolve; });
let imageReady = new Promise(resolve => { resolveImage = resolve; });
let setAttribute = vi.fn();
let document = {
documentElement: { setAttribute },
fonts: { ready: fontsReady },
images: [{ complete: true, decode: () => imageReady }],
};
let frames = [];
let requestAnimationFrame = callback => frames.push(callback);
let ready = markScreenshotReady({ document, requestAnimationFrame });
await Promise.resolve();
expect(setAttribute).not.toHaveBeenCalled();
resolveFonts();
await Promise.resolve();
expect(setAttribute).not.toHaveBeenCalled();
resolveImage();
await Promise.resolve();
await Promise.resolve();
expect(frames).toHaveLength(1);
expect(setAttribute).not.toHaveBeenCalled();
frames.shift()();
await Promise.resolve();
expect(frames).toHaveLength(1);
expect(setAttribute).not.toHaveBeenCalled();
frames.shift()();
await ready;
expect(setAttribute).toHaveBeenCalledWith('data-screenshot-ready', 'true');
});
it('does not mark a readiness run that became stale while assets settled', async () => {
let resolveImage;
let imageReady = new Promise(resolve => { resolveImage = resolve; });
let setAttribute = vi.fn();
let document = {
documentElement: { setAttribute },
fonts: { ready: Promise.resolve() },
images: [{ decode: () => imageReady }],
};
let isCurrent = true;
let ready = markScreenshotReady({
document,
isCurrent: () => isCurrent,
requestAnimationFrame: callback => callback(),
});
isCurrent = false;
resolveImage();
await ready;
expect(setAttribute).not.toHaveBeenCalled();
});
});

View File

@@ -40,8 +40,10 @@
</template>
<script setup>
import { watchEffect } from 'vue';
import { nextTick, onUnmounted, watch, watchEffect } from 'vue';
import { useRoute } from 'vue-router';
import { markScreenshotReady, screenshotReadyAttribute } from '@/common/screenshot.mjs';
import { useDataStore } from '@/stores/data';
import { useTimeStore } from '@/stores/time';
import TimeOffsetSelector from '@/components/Debug/TimeOffsetSelector.vue';
@@ -52,7 +54,28 @@ const props = defineProps({
});
const route = useRoute();
const data = useDataStore();
const time = useTimeStore();
let readinessVersion = 0;
watch(
[() => data.isLoaded, () => data.isUpdating, () => route.fullPath],
async ([isLoaded, isUpdating]) => {
let version = ++readinessVersion;
document.documentElement.removeAttribute(screenshotReadyAttribute);
if (!isLoaded || isUpdating) return;
await nextTick();
await markScreenshotReady({ isCurrent: () => version === readinessVersion });
},
{ immediate: true, flush: 'post' },
);
onUnmounted(() => {
readinessVersion++;
document.documentElement.removeAttribute(screenshotReadyAttribute);
});
watchEffect(() => {
if (route.query.time) {

View File

@@ -60,6 +60,7 @@ export const useDataStore = defineStore('data', () => {
}
const isUpdating = computed(() => Object.values(stores).some(s => s.isUpdating));
const isLoaded = computed(() => Object.values(stores).every(s => s.data !== null));
function refresh() {
const now = Date.now();
@@ -108,6 +109,7 @@ export const useDataStore = defineStore('data', () => {
return {
updateAll,
isLoaded,
isUpdating,
startUpdating,
stopUpdating,

29
src/stores/data.test.mjs Normal file
View File

@@ -0,0 +1,29 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import {
useCoopDataStore,
useDataStore,
useFestivalsDataStore,
useGearDataStore,
useSchedulesDataStore,
} from './data.mjs';
describe('useDataStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('becomes loaded only after every initial data source has content', () => {
let data = useDataStore();
expect(data.isLoaded).toBe(false);
useSchedulesDataStore().setData({ data: {} });
useGearDataStore().setData({ data: {} });
useCoopDataStore().setData({ data: {} });
expect(data.isLoaded).toBe(false);
useFestivalsDataStore().setData({});
expect(data.isLoaded).toBe(true);
});
});