diff --git a/.env.example b/.env.example index 1fc6efa..2b03b27 100644 --- a/.env.example +++ b/.env.example @@ -20,11 +20,18 @@ SENTRY_DSN= # Archive all data (can use a lot of disk space) ARCHIVE_DATA=false -# Browserless (for screenshots) +# Screenshots (choose cloudflare or browserless explicitly) +SCREENSHOT_PROVIDER=browserless + +# Browserless (local development) BROWSERLESS_ENDPOINT=ws://localhost:3000 SCREENSHOT_HOST=host.docker.internal BROWSERLESS_CONCURRENT=2 +# Cloudflare Browser Run Quick Actions (production) +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_BROWSER_RUN_API_TOKEN= + # S3 parameters AWS_S3_ENDPOINT= AWS_REGION= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2b59893..5293f48 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -26,6 +26,8 @@ jobs: - name: Build run: npm run build + env: + VITE_DATA_FROM: ${{ vars.VITE_DATA_FROM }} - name: Deploy uses: jakejarvis/s3-sync-action@master diff --git a/CLAUDE.md b/CLAUDE.md index b042c41..cc81236 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ npm start # Full production: sync → splatnet → social → cron **Data pipeline**: `NsoClient` (Nintendo auth) → `SplatNet3Client` (GraphQL queries) → DataUpdaters (`app/data/updaters/`) → JSON files in `dist/data/` → Frontend Pinia stores → Vue components. Images are processed via sharp. Data is optionally archived and synced to S3. -**Social media**: StatusGenerators (`app/social/generators/`) create content from data. Clients (`app/social/clients/`) post to each platform. Screenshots are generated via puppeteer-core + a browserless service. +**Social media**: StatusGenerators (`app/social/generators/`) create content from data. Clients (`app/social/clients/`) post to each platform. `ScreenshotHelper` delegates rendering to drivers in `app/screenshots/drivers/`: production uses Cloudflare Browser Run Quick Actions against the public site, while local development can use puppeteer-core + Browserless. **Scheduling**: Cron jobs (`app/cron.mjs`) run data updates and social posting at intervals. @@ -56,4 +56,4 @@ Tests use Vitest. Test files live alongside source: `app/**/*.test.mjs` and `src - Frontend: built to `dist/` and deployed to AWS S3 (static hosting) - Backend: Docker container (`docker/app/Dockerfile`) pushed to GitHub Container Registry - `dist/` is not emptied on build (preserves generated `dist/data/` from backend) -- Browserless runs as a separate Docker service for screenshot generation +- Browserless runs as a separate Docker service for local screenshot generation diff --git a/app/screenshots/ScreenshotHelper.mjs b/app/screenshots/ScreenshotHelper.mjs index 710ff2d..79452cf 100644 --- a/app/screenshots/ScreenshotHelper.mjs +++ b/app/screenshots/ScreenshotHelper.mjs @@ -1,6 +1,4 @@ -import { URL } from 'url'; -import puppeteer from 'puppeteer-core'; -import HttpServer from './HttpServer.mjs'; +import createScreenshotDriver from './drivers/createScreenshotDriver.mjs'; const defaultViewport = { // Using a 16:9 ratio here by default to match Twitter's image card dimensions @@ -11,48 +9,29 @@ const defaultViewport = { export default class ScreenshotHelper { - /** @type {HttpServer} */ - _httpServer = null; - /** @type {puppeteer.Browser} */ - _browser = null; - /** @type {puppeteer.Page} */ - _page = null; + _driver = null; + _driverDependencies; + _isOpen = false; defaultParams = null; - get isOpen() { - return !!this._browser; + constructor(driverDependencies = {}) { + this._driverDependencies = driverDependencies; } - /** @type {puppeteer.Page} */ - get page() { - return this._page; + get isOpen() { + return this._isOpen; } async open() { await this.close(); - // Start the HTTP server - this._httpServer = new HttpServer; - await this._httpServer.open(); - - // Connect to Browserless - this._browser = await puppeteer.connect({ - browserWSEndpoint: process.env.BROWSERLESS_ENDPOINT, - }); - - // Create a new page and set the viewport - this._page = await this._browser.newPage(); - await this.applyViewport(); - } - - async applyViewport(viewport = {}) { - if (this._page) { - await this._page.setViewport({ - ...defaultViewport, - ...viewport, - }); - } + this._driver = createScreenshotDriver( + process.env.SCREENSHOT_PROVIDER, + this._driverDependencies, + ); + await this._driver.open(); + this._isOpen = true; } async capture(path, options = {}) { @@ -60,51 +39,32 @@ export default class ScreenshotHelper await this.open(); } - await this.applyViewport(options.viewport); - - // Navigate to the URL - let host = process.env.SCREENSHOT_HOST || 'localhost'; - let url = new URL(`http://${host}:${this._httpServer.port}/screenshots/`); - url.hash = path; - let params = { ...this.defaultParams, ...options.params, }; + let route = path; - if (params) { - // We can't use url.searchParams because they need to come after the hash - url.hash += '?'; - url.hash += Object.keys(params) + if (Object.keys(params).length) { + route += '?'; + route += Object.keys(params) .map(key => `${key}=${params[key]}`) .join('&'); } - await this._page.goto(url, { - waitUntil: 'networkidle0', // Wait until the network is idle - }); + let viewport = { + ...defaultViewport, + ...options.viewport, + }; - // Wait an additional 1000ms - await this._page.waitForNetworkIdle({ idleTime: 1000 }); - - // Take the screenshot - return await this._page.screenshot(); + return await this._driver.capture(route, viewport); } async close() { - if (this._httpServer) { - await this._httpServer.close(); + if (this._driver) { + await this._driver.close(); } - this._httpServer = null; - - if (this._page) { - await this._page.close(); - } - this._page = null; - - if (this._browser) { - await this._browser.close(); - } - this._browser = null; + this._driver = null; + this._isOpen = false; } } diff --git a/app/screenshots/ScreenshotHelper.test.mjs b/app/screenshots/ScreenshotHelper.test.mjs new file mode 100644 index 0000000..5913c5c --- /dev/null +++ b/app/screenshots/ScreenshotHelper.test.mjs @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import ScreenshotHelper from './ScreenshotHelper.mjs'; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('ScreenshotHelper', () => { + it('captures a local screenshot through Browserless', async () => { + vi.stubEnv('SCREENSHOT_PROVIDER', 'browserless'); + + let png = Buffer.from([137, 80, 78, 71]); + let page = { + setViewport: vi.fn(), + goto: vi.fn(), + waitForSelector: vi.fn(), + screenshot: vi.fn().mockResolvedValue(png), + close: vi.fn(), + }; + let browser = { + newPage: vi.fn().mockResolvedValue(page), + close: vi.fn(), + }; + let puppeteerClient = { + connect: vi.fn().mockResolvedValue(browser), + }; + let httpServer = { + port: 4321, + open: vi.fn(), + close: vi.fn(), + }; + let helper = new ScreenshotHelper({ + env: { + BROWSERLESS_ENDPOINT: 'ws://browserless:3000', + SCREENSHOT_HOST: 'app', + }, + httpServerFactory: () => httpServer, + puppeteerClient, + }); + + let screenshot = await helper.capture('schedules', { + viewport: { height: 400 }, + }); + await helper.close(); + + expect(screenshot).toEqual(png); + expect(puppeteerClient.connect).toHaveBeenCalledWith({ + browserWSEndpoint: 'ws://browserless:3000', + }); + expect(page.setViewport).toHaveBeenCalledWith({ + width: 1200, + height: 400, + deviceScaleFactor: 2, + }); + expect(page.goto).toHaveBeenCalledWith( + new URL('http://app:4321/screenshots/#schedules'), + { waitUntil: 'load' }, + ); + expect(page.waitForSelector).toHaveBeenCalledWith( + '[data-screenshot-ready="true"]', + { timeout: 30_000 }, + ); + expect(httpServer.close).toHaveBeenCalledOnce(); + expect(page.close).toHaveBeenCalledOnce(); + expect(browser.close).toHaveBeenCalledOnce(); + }); + + it('captures a public screenshot through Cloudflare Browser Run', async () => { + vi.stubEnv('SCREENSHOT_PROVIDER', 'cloudflare'); + vi.stubEnv('SITE_URL', 'https://splatoon3.ink'); + vi.stubEnv('CLOUDFLARE_ACCOUNT_ID', 'account-id'); + vi.stubEnv('CLOUDFLARE_BROWSER_RUN_API_TOKEN', 'api-token'); + + let png = new Uint8Array([137, 80, 78, 71]); + let fetch = vi.fn().mockResolvedValue(new Response(png, { + headers: { 'Content-Type': 'image/png' }, + })); + let helper = new ScreenshotHelper({ fetch }); + helper.defaultParams = { time: 123 }; + + let screenshot = await helper.capture('schedules', { + params: { region: 'NA' }, + viewport: { width: 600 }, + }); + + expect(screenshot).toEqual(Buffer.from(png)); + expect(fetch).toHaveBeenCalledWith( + 'https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/screenshot?cacheTTL=0', + { + method: 'POST', + headers: { + Authorization: 'Bearer api-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: 'https://splatoon3.ink/screenshots/index.html#schedules?time=123®ion=NA', + viewport: { + width: 600, + height: 675, + deviceScaleFactor: 2, + }, + gotoOptions: { waitUntil: 'load' }, + waitForSelector: { + selector: '[data-screenshot-ready="true"]', + timeout: 30_000, + }, + screenshotOptions: { type: 'png' }, + }), + }, + ); + }); + + it('surfaces a Cloudflare rate limit response without retrying', async () => { + vi.stubEnv('SCREENSHOT_PROVIDER', 'cloudflare'); + vi.stubEnv('SITE_URL', 'https://splatoon3.ink'); + vi.stubEnv('CLOUDFLARE_ACCOUNT_ID', 'account-id'); + vi.stubEnv('CLOUDFLARE_BROWSER_RUN_API_TOKEN', 'api-token'); + + let fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + success: false, + errors: [{ code: 2001, message: 'Rate limit exceeded' }], + }), { + status: 429, + headers: { 'Content-Type': 'application/json' }, + })); + let helper = new ScreenshotHelper({ fetch }); + + await expect(helper.capture('schedules')).rejects.toThrow( + 'Cloudflare Browser Run screenshot failed (429): Rate limit exceeded', + ); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it('requires the Cloudflare configuration before opening', async () => { + vi.stubEnv('SCREENSHOT_PROVIDER', 'cloudflare'); + vi.stubEnv('SITE_URL', ''); + vi.stubEnv('CLOUDFLARE_ACCOUNT_ID', ''); + vi.stubEnv('CLOUDFLARE_BROWSER_RUN_API_TOKEN', ''); + + let helper = new ScreenshotHelper; + + await expect(helper.open()).rejects.toThrow( + 'Missing Cloudflare screenshot configuration: SITE_URL, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_BROWSER_RUN_API_TOKEN', + ); + }); + + it('requires an explicitly supported screenshot provider', async () => { + vi.stubEnv('SCREENSHOT_PROVIDER', 'auto'); + + let helper = new ScreenshotHelper; + + await expect(helper.open()).rejects.toThrow( + 'SCREENSHOT_PROVIDER must be "cloudflare" or "browserless"', + ); + }); +}); diff --git a/app/screenshots/drivers/BrowserlessScreenshotDriver.mjs b/app/screenshots/drivers/BrowserlessScreenshotDriver.mjs new file mode 100644 index 0000000..03bab9f --- /dev/null +++ b/app/screenshots/drivers/BrowserlessScreenshotDriver.mjs @@ -0,0 +1,71 @@ +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 +{ + _browser = null; + _env; + _httpServer = null; + _httpServerFactory; + _page = null; + _puppeteer; + + constructor({ + env = process.env, + httpServerFactory = () => new HttpServer, + puppeteerClient = puppeteer, + } = {}) { + this._env = env; + this._httpServerFactory = httpServerFactory; + this._puppeteer = puppeteerClient; + } + + async open() { + if (!this._env.BROWSERLESS_ENDPOINT) { + throw new Error('Missing Browserless screenshot configuration: BROWSERLESS_ENDPOINT'); + } + + this._httpServer = this._httpServerFactory(); + await this._httpServer.open(); + + this._browser = await this._puppeteer.connect({ + browserWSEndpoint: this._env.BROWSERLESS_ENDPOINT, + }); + this._page = await this._browser.newPage(); + } + + async capture(route, viewport) { + let host = this._env.SCREENSHOT_HOST || 'localhost'; + let url = new URL(`http://${host}:${this._httpServer.port}/screenshots/`); + url.hash = route; + + await this._page.setViewport(viewport); + await this._page.goto(url, { + waitUntil: 'load', + }); + await this._page.waitForSelector(screenshotReadySelector, { + timeout: screenshotReadyTimeout, + }); + + return await this._page.screenshot(); + } + + async close() { + if (this._httpServer) { + await this._httpServer.close(); + } + this._httpServer = null; + + if (this._page) { + await this._page.close(); + } + this._page = null; + + if (this._browser) { + await this._browser.close(); + } + this._browser = null; + } +} diff --git a/app/screenshots/drivers/CloudflareScreenshotDriver.mjs b/app/screenshots/drivers/CloudflareScreenshotDriver.mjs new file mode 100644 index 0000000..feaae3f --- /dev/null +++ b/app/screenshots/drivers/CloudflareScreenshotDriver.mjs @@ -0,0 +1,88 @@ +import { URL } from 'url'; +import { screenshotReadySelector, screenshotReadyTimeout } from '../../../src/common/screenshot.mjs'; + +export default class CloudflareScreenshotDriver +{ + _config = null; + _env; + _fetch; + + constructor({ env = process.env, fetch = globalThis.fetch } = {}) { + this._env = env; + this._fetch = fetch; + } + + async open() { + let names = [ + 'SITE_URL', + 'CLOUDFLARE_ACCOUNT_ID', + 'CLOUDFLARE_BROWSER_RUN_API_TOKEN', + ]; + let missing = names.filter(name => !this._env[name]); + if (missing.length) { + throw new Error(`Missing Cloudflare screenshot configuration: ${missing.join(', ')}`); + } + + this._config = { + siteUrl: this._env.SITE_URL, + accountId: this._env.CLOUDFLARE_ACCOUNT_ID, + apiToken: this._env.CLOUDFLARE_BROWSER_RUN_API_TOKEN, + }; + } + + async capture(route, viewport) { + let url = new URL('/screenshots/index.html', this._config.siteUrl); + url.hash = route; + + let endpoint = new URL( + `/client/v4/accounts/${this._config.accountId}/browser-rendering/screenshot`, + 'https://api.cloudflare.com', + ); + endpoint.searchParams.set('cacheTTL', '0'); + + let response = await this._fetch(endpoint.toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${this._config.apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: url.toString(), + viewport, + gotoOptions: { waitUntil: 'load' }, + waitForSelector: { + selector: screenshotReadySelector, + timeout: screenshotReadyTimeout, + }, + screenshotOptions: { type: 'png' }, + }), + }); + + if (!response.ok) { + let message = await this._errorMessage(response); + throw new Error(`Cloudflare Browser Run screenshot failed (${response.status}): ${message}`); + } + + return Buffer.from(await response.arrayBuffer()); + } + + async _errorMessage(response) { + let body = await response.text(); + + try { + let result = JSON.parse(body); + let messages = result.errors?.map(error => error.message).filter(Boolean); + if (messages?.length) { + return messages.join('; '); + } + } catch { + // Use the response body as-is when Cloudflare does not return JSON. + } + + return body || response.statusText || 'Unknown error'; + } + + async close() { + this._config = null; + } +} diff --git a/app/screenshots/drivers/createScreenshotDriver.mjs b/app/screenshots/drivers/createScreenshotDriver.mjs new file mode 100644 index 0000000..b4c9259 --- /dev/null +++ b/app/screenshots/drivers/createScreenshotDriver.mjs @@ -0,0 +1,14 @@ +import BrowserlessScreenshotDriver from './BrowserlessScreenshotDriver.mjs'; +import CloudflareScreenshotDriver from './CloudflareScreenshotDriver.mjs'; + +export default function createScreenshotDriver(name, dependencies = {}) { + if (name === 'browserless') { + return new BrowserlessScreenshotDriver(dependencies); + } + + if (name === 'cloudflare') { + return new CloudflareScreenshotDriver(dependencies); + } + + throw new Error('SCREENSHOT_PROVIDER must be "cloudflare" or "browserless"'); +} diff --git a/app/social/StatusGeneratorManager.mjs b/app/social/StatusGeneratorManager.mjs index 2e6a0c4..a0fa68a 100644 --- a/app/social/StatusGeneratorManager.mjs +++ b/app/social/StatusGeneratorManager.mjs @@ -25,7 +25,7 @@ export default class StatusGeneratorManager async sendStatuses(force = false) { let availableClients = await this._getAvailableClients(); - // Create screenshots in parallel (via Browserless) + // Create screenshots in parallel let statusPromises = this._getStatuses(availableClients, force); // Process each client in parallel (while maintaining post order) diff --git a/docker-compose.override.yml.dev.example b/docker-compose.override.yml.dev.example index a663fc2..d5df252 100644 --- a/docker-compose.override.yml.dev.example +++ b/docker-compose.override.yml.dev.example @@ -8,6 +8,7 @@ services: init: true restart: unless-stopped environment: + SCREENSHOT_PROVIDER: browserless BROWSERLESS_ENDPOINT: ws://browserless:3000 SCREENSHOT_HOST: app depends_on: @@ -16,6 +17,11 @@ services: - .:/app browserless: + image: ghcr.io/browserless/chromium platform: linux/arm64 # Needed for Apple Silicon + restart: unless-stopped + environment: + CONCURRENT: ${BROWSERLESS_CONCURRENT:-1} + QUEUED: ${BROWSERLESS_QUEUED:-100} ports: - 3000:3000 diff --git a/docker-compose.override.yml.prod.example b/docker-compose.override.yml.prod.example index 97697de..877b21b 100644 --- a/docker-compose.override.yml.prod.example +++ b/docker-compose.override.yml.prod.example @@ -6,17 +6,11 @@ services: init: true restart: unless-stopped environment: - BROWSERLESS_ENDPOINT: ws://browserless:3000 - SCREENSHOT_HOST: app - depends_on: - - browserless + SCREENSHOT_PROVIDER: cloudflare env_file: - .env labels: [ "com.centurylinklabs.watchtower.scope=splatoon3ink" ] - browserless: - labels: [ "com.centurylinklabs.watchtower.scope=splatoon3ink" ] - watchtower: image: containrrr/watchtower volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 5091e6e..964dc66 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,3 @@ # See docker-compose.override.yml.* example files for dev/prod environments -services: - browserless: - image: ghcr.io/browserless/chromium - restart: unless-stopped - environment: - CONCURRENT: ${BROWSERLESS_CONCURRENT:-1} - QUEUED: ${BROWSERLESS_QUEUED:-100} +services: {} diff --git a/readme.md b/readme.md index 4849a35..dd7aaa3 100644 --- a/readme.md +++ b/readme.md @@ -27,6 +27,33 @@ npm run dev npm run build ``` +### Screenshot Generation + +Set `SCREENSHOT_PROVIDER` explicitly for social-media screenshots. Use `browserless` for local development with the Docker Compose development configuration. Use `cloudflare` in production to call Cloudflare Browser Run Quick Actions against `${SITE_URL}/screenshots/`. + +The Cloudflare provider requires `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_BROWSER_RUN_API_TOKEN`. Create the API token with the **Browser Rendering Write** permission. The provider does not automatically fall back to Browserless when a Cloudflare request fails. + +Wrangler can run a local browser for Puppeteer, Playwright, and CDP-based Workers, but Quick Actions are not supported by its local browser binding. Quick Actions require remote mode, so testing this provider still requires Cloudflare to reach the rendered page. + +To test the real Cloudflare provider against a local build, build and serve `dist` in one terminal: + +```sh +npm run build +npm run preview +``` + +Expose that server through a temporary Wrangler tunnel in a second terminal: + +```sh +npx wrangler@latest tunnel quick-start http://localhost:5050 +``` + +In a third terminal, use the printed `https://*.trycloudflare.com` URL for that run: + +```sh +SCREENSHOT_PROVIDER=cloudflare SITE_URL=https://example.trycloudflare.com npm run social:test +``` + ### Lint with [ESLint](https://eslint.org/) ```sh diff --git a/src/common/screenshot.mjs b/src/common/screenshot.mjs new file mode 100644 index 0000000..ef28323 --- /dev/null +++ b/src/common/screenshot.mjs @@ -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'); + } +} diff --git a/src/common/screenshot.test.mjs b/src/common/screenshot.test.mjs new file mode 100644 index 0000000..d28ff4f --- /dev/null +++ b/src/common/screenshot.test.mjs @@ -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(); + }); +}); diff --git a/src/layouts/ScreenshotLayout.vue b/src/layouts/ScreenshotLayout.vue index 8f2c67e..186e6d2 100644 --- a/src/layouts/ScreenshotLayout.vue +++ b/src/layouts/ScreenshotLayout.vue @@ -40,8 +40,10 @@