From 5deac8c8cb3e211c3d703a5602ef33d8ec7467fb Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Mon, 7 Sep 2026 18:06:15 -0700 Subject: [PATCH] Use Browser binding for screenshots and local Wrangler testing --- .env.example | 4 -- package.json | 3 +- ...wserRunClient.js => BrowserRunRenderer.js} | 54 +++++------------- src/app/screenshots/cli.js | 5 +- src/app/screenshots/node.js | 23 +------- test/screenshots/node.test.js | 42 +------------- test/screenshots/screenshots.test.js | 44 +++++--------- test/screenshots/worker.test.js | 34 +++++++++++ workers/screenshots/index.mjs | 37 ++++++++++++ workers/screenshots/wrangler.jsonc | 11 ++++ workers/updater/README.md | 57 ++++++++++--------- workers/updater/Scheduler.spec.mjs | 19 +++---- workers/updater/posters.spec.mjs | 21 +++---- workers/updater/src/posters.mjs | 7 +-- workers/updater/vitest.config.mjs | 2 + workers/updater/wrangler.jsonc | 2 +- 16 files changed, 174 insertions(+), 191 deletions(-) rename src/app/screenshots/{BrowserRunClient.js => BrowserRunRenderer.js} (54%) create mode 100644 test/screenshots/worker.test.js create mode 100644 workers/screenshots/index.mjs create mode 100644 workers/screenshots/wrangler.jsonc diff --git a/.env.example b/.env.example index 5b16c3d..9475406 100644 --- a/.env.example +++ b/.env.example @@ -12,13 +12,9 @@ VUE_APP_GOOGLE_ANALYTICS_ID= # (Optional) Sentry error reporting (https://sentry.io) SENTRY_DSN= -# Node screenshot provider: puppeteer (local Chrome) or cloudflare (Browser Run API) -SCREENSHOT_PROVIDER=puppeteer # Leave empty to serve dist/ temporarily with Puppeteer; set a dev/deployed URL otherwise. # Cloudflare requires a URL it can reach. The Worker selects Cloudflare directly. SITE_URL= -CLOUDFLARE_ACCOUNT_ID= -CLOUDFLARE_BROWSER_RUN_API_TOKEN= # (Optional) Bluesky API parameters BLUESKY_SERVICE=https://bsky.social diff --git a/package.json b/package.json index 0512db7..2b63e2a 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "social": "node src/app/index.js social", "social:test": "node src/app/index.js socialTest", "admin:preview": "node workers/updater/preview/server.mjs", - "screenshot": "node src/app/screenshots/cli.js" + "screenshot": "node src/app/screenshots/cli.js", + "screenshot:cloudflare": "wrangler dev --config workers/screenshots/wrangler.jsonc" }, "dependencies": { "@atproto/api": "^0.20.42", diff --git a/src/app/screenshots/BrowserRunClient.js b/src/app/screenshots/BrowserRunRenderer.js similarity index 54% rename from src/app/screenshots/BrowserRunClient.js rename to src/app/screenshots/BrowserRunRenderer.js index ddbbadd..3fba070 100644 --- a/src/app/screenshots/BrowserRunClient.js +++ b/src/app/screenshots/BrowserRunRenderer.js @@ -1,5 +1,4 @@ import { logMessage } from '../log.js'; -import { fetchWithTimeout } from '../../common/fetch.js'; async function errorMessage(response) { let body = await response.text(); @@ -18,53 +17,28 @@ async function errorMessage(response) { return body || response.statusText || 'Unknown error'; } -// Browser Run's REST client works in both Node and Workers. -export default class BrowserRunClient { - constructor({ accountId, apiToken }) { - this.accountId = accountId; - this.apiToken = apiToken; +// Browser Run captures remotely through the Worker's Browser binding. +export default class BrowserRunRenderer { + constructor(browser) { + this.browser = browser; } async capture({ url, viewport, readySelector }) { - let missing = []; - - if (!this.accountId) - missing.push('CLOUDFLARE_ACCOUNT_ID'); - - if (!this.apiToken) - missing.push('CLOUDFLARE_BROWSER_RUN_API_TOKEN'); - - if (missing.length) - throw new Error(`Missing screenshot configuration: ${missing.join(', ')}`); - - let endpoint = new URL( - `/client/v4/accounts/${this.accountId}/browser-rendering/screenshot`, - 'https://api.cloudflare.com', - ); - endpoint.searchParams.set('cacheTTL', '0'); - - let request = { - method: 'POST', - headers: { - Authorization: `Bearer ${this.apiToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - url: url.toString(), - viewport, - gotoOptions: { waitUntil: 'domcontentloaded', timeout: 10_000 }, - waitForSelector: { selector: readySelector, timeout: 10_000 }, - actionTimeout: 10_000, - setExtraHTTPHeaders: { 'Cache-Control': 'no-cache' }, - screenshotOptions: { type: 'png' }, - }), + let options = { + url: url.toString(), + viewport, + cacheTTL: 0, + gotoOptions: { waitUntil: 'domcontentloaded', timeout: 10_000 }, + waitForSelector: { selector: readySelector, timeout: 10_000 }, + actionTimeout: 10_000, + setExtraHTTPHeaders: { 'Cache-Control': 'no-cache' }, + screenshotOptions: { type: 'png' }, }; let image; for (let attempt = 0; attempt <= 3; attempt++) { try { - // Three 10s browser phases plus transport overhead; covers response body too. - let response = await fetchWithTimeout(endpoint, request, 40_000); + let response = await this.browser.quickAction('screenshot', options); if (!response.ok) { let message = await errorMessage(response); diff --git a/src/app/screenshots/cli.js b/src/app/screenshots/cli.js index 3a7581e..ff2f1e7 100644 --- a/src/app/screenshots/cli.js +++ b/src/app/screenshots/cli.js @@ -11,7 +11,6 @@ if (existsSync('.env')) try { let { values } = parseArgs({ options: { - provider: { type: 'string' }, url: { type: 'string' }, hash: { type: 'string' }, output: { type: 'string', default: 'dist/test-screenshots/capture.png' }, @@ -21,10 +20,10 @@ try { if (values.help) { console.log( - 'Usage: npm run screenshot -- [--provider puppeteer|cloudflare] (--url | --hash ) [--output ]', + 'Usage: npm run screenshot -- (--url | --hash ) [--output ]', ); console.log( - 'Uses SCREENSHOT_PROVIDER and SITE_URL from the environment or .env. Without SITE_URL, Puppeteer serves dist/ temporarily.', + 'Uses local Puppeteer and SITE_URL from the environment or .env. Without SITE_URL, Puppeteer serves dist/ temporarily.', ); } else { if (!!values.url === !!values.hash) diff --git a/src/app/screenshots/node.js b/src/app/screenshots/node.js index 9bc07d1..8cb41fc 100644 --- a/src/app/screenshots/node.js +++ b/src/app/screenshots/node.js @@ -1,32 +1,15 @@ import { createServer } from 'node:http'; import { access } from 'node:fs/promises'; import handler from 'serve-handler'; -import BrowserRunClient from './BrowserRunClient.js'; import PuppeteerRenderer from './PuppeteerRenderer.js'; import ScreenshotGenerator from './ScreenshotGenerator.js'; -// Provider selection and the temporary file server belong to the Node command. -// The Worker constructs its BrowserRunClient directly. +// The Node commands use local Chrome; Browser Run is tested through Wrangler. export async function withScreenshots( callback, - { provider = process.env.SCREENSHOT_PROVIDER, siteUrl = process.env.SITE_URL, url } = {}, + { siteUrl = process.env.SITE_URL, url } = {}, ) { - let renderer; - - if (provider === 'cloudflare') { - renderer = new BrowserRunClient({ - accountId: process.env.CLOUDFLARE_ACCOUNT_ID, - apiToken: process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN, - }); - - if (!siteUrl && !url) - throw new Error('SITE_URL or --url is required for Cloudflare screenshots.'); - } else if (provider === 'puppeteer') { - renderer = new PuppeteerRenderer(); - } else { - throw new Error('SCREENSHOT_PROVIDER must be "puppeteer" or "cloudflare" (or pass --provider).'); - } - + let renderer = new PuppeteerRenderer(); let server; try { diff --git a/test/screenshots/node.test.js b/test/screenshots/node.test.js index d89b1e7..ee4d655 100644 --- a/test/screenshots/node.test.js +++ b/test/screenshots/node.test.js @@ -60,46 +60,6 @@ test('local browser uses page readiness and closes on success or failure', async assert.equal(closed, 2); }); -test('Node can select Cloudflare for a direct URL without SITE_URL or social data', async () => { - let requests = []; - - mock.method(globalThis, 'fetch', async (url, init) => { - requests.push(JSON.parse(init.body)); - - return new Response(PNG); - }); - - let previous = { ...process.env }; - - try { - process.env.CLOUDFLARE_ACCOUNT_ID = 'account'; - process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN = 'token'; - - let url = 'https://dev.example.test/screenshots.html#/schedules/3600'; - let result = await withScreenshots(screenshots => screenshots.capture({ url }), { - provider: 'cloudflare', - siteUrl: '', - url, - }); - - assert.deepEqual(result.image, PNG); - assert.equal(requests[0].url, url); - } finally { - process.env = previous; - } -}); - -test('provider selection is explicit and Cloudflare requires a reachable target', async () => { - await assert.rejects( - withScreenshots(() => {}, { provider: 'unknown' }), - /SCREENSHOT_PROVIDER/, - ); - await assert.rejects( - withScreenshots(() => {}, { provider: 'cloudflare', siteUrl: '' }), - /SITE_URL or --url/, - ); -}); - test('temporary dist server is loopback-only and closes when capture fails', async () => { let { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); let { tmpdir } = await import('node:os'); @@ -129,7 +89,7 @@ test('temporary dist server is loopback-only and closes when capture fails', asy return screenshots.capture({ hash: '/schedules/3600' }); }, - { provider: 'puppeteer', siteUrl: '' }, + { siteUrl: '' }, ), /Capture failed/, ); diff --git a/test/screenshots/screenshots.test.js b/test/screenshots/screenshots.test.js index 64e1ab4..f927be4 100644 --- a/test/screenshots/screenshots.test.js +++ b/test/screenshots/screenshots.test.js @@ -1,16 +1,12 @@ import { test, beforeEach, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; -import BrowserRunClient from '../../src/app/screenshots/BrowserRunClient.js'; +import BrowserRunRenderer from '../../src/app/screenshots/BrowserRunRenderer.js'; import ScreenshotGenerator from '../../src/app/screenshots/ScreenshotGenerator.js'; +let browser; + function screenshots() { - return new ScreenshotGenerator( - new BrowserRunClient({ - accountId: process.env.CLOUDFLARE_ACCOUNT_ID, - apiToken: process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN, - }), - process.env.SITE_URL, - ); + return new ScreenshotGenerator(new BrowserRunRenderer(browser), process.env.SITE_URL); } const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); @@ -20,19 +16,19 @@ function fakeBrowserRendering( ) { const requests = []; - mock.method(globalThis, 'fetch', async (input, init) => { - requests.push({ url: new URL(input), headers: new Headers(init.headers), body: JSON.parse(init.body) }); + browser = { + async quickAction(action, body) { + requests.push({ action, body }); - return respond(); - }); + return respond(); + }, + }; return requests; } beforeEach(() => { process.env.SITE_URL = 'https://example.test'; - process.env.CLOUDFLARE_ACCOUNT_ID = 'acct'; - process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN = 'token'; }); afterEach(() => mock.restoreAll()); @@ -42,13 +38,10 @@ test('asks Browser Rendering for the deployed screenshot page at the default vie assert.equal(requests.length, 1); - const [{ url, headers, body }] = requests; + const [{ action, body }] = requests; - assert.equal( - url.href, - 'https://api.cloudflare.com/client/v4/accounts/acct/browser-rendering/screenshot?cacheTTL=0', - ); - assert.equal(headers.get('authorization'), 'Bearer token'); + assert.equal(action, 'screenshot'); + assert.equal(body.cacheTTL, 0); assert.equal(body.url, 'https://example.test/screenshots.html#/schedules/3600'); assert.deepEqual(body.viewport, { width: 1216, height: 684, deviceScaleFactor: 2 }); assert.deepEqual(body.gotoOptions, { waitUntil: 'domcontentloaded', timeout: 10_000 }); @@ -88,15 +81,6 @@ test('reports API errors with Cloudflare\'s message', async () => { ); }); -test('fails clearly when configuration is missing', async () => { - delete process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN; - - await assert.rejects( - screenshots().capture({ hash: '/x' }), - /Missing screenshot configuration: CLOUDFLARE_BROWSER_RUN_API_TOKEN/, - ); -}); - function immediateBackoff() { const delays = []; @@ -152,7 +136,7 @@ test('does not retry authentication, rate limits or non-timeout validation error assert.deepEqual(delays, []); }); -test('retries network and client deadline failures, including while reading the image', async () => { +test('retries network and timeout failures, including while reading the image', async () => { immediateBackoff(); let attempt = 0; diff --git a/test/screenshots/worker.test.js b/test/screenshots/worker.test.js new file mode 100644 index 0000000..5cee0ee --- /dev/null +++ b/test/screenshots/worker.test.js @@ -0,0 +1,34 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import worker from '../../workers/screenshots/index.mjs'; + +test('capture-only Worker returns PNG bytes using the shared renderer', async () => { + let image = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + let url = 'https://example.test/screenshots.html#/schedules/3600'; + let request = new Request(`http://localhost/?${new URLSearchParams({ url })}`); + let env = { + BROWSER: { + async quickAction(action, options) { + assert.equal(action, 'screenshot'); + assert.equal(options.url, url); + assert.equal(options.waitForSelector.selector, '[data-screenshot-ready="true"]'); + + return new Response(image); + }, + }, + }; + + let response = await worker.fetch(request, env); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'image/png'); + assert.deepEqual(new Uint8Array(await response.arrayBuffer()), image); +}); + +test('capture-only Worker rejects missing and invalid targets before using the browser', async () => { + for (let url of ['', '?url=invalid', '?url=file:///tmp/page.html']) { + let response = await worker.fetch(new Request(`http://localhost/${url}`), {}); + + assert.equal(response.status, 400); + } +}); diff --git a/workers/screenshots/index.mjs b/workers/screenshots/index.mjs new file mode 100644 index 0000000..5016761 --- /dev/null +++ b/workers/screenshots/index.mjs @@ -0,0 +1,37 @@ +import BrowserRunRenderer from '../../src/app/screenshots/BrowserRunRenderer.js'; +import ScreenshotGenerator from '../../src/app/screenshots/ScreenshotGenerator.js'; + +// Local capture-only entry point: no scheduler, buckets, or social clients. +export default { + async fetch(request, env) { + let params = new URL(request.url).searchParams; + let url = params.get('url'); + + if (request.method !== 'GET') + return new Response('Use GET with a url query parameter.', { status: 405 }); + + try { + if (!url || !['https:', 'http:'].includes(new URL(url).protocol)) + return new Response('Provide a full HTTP(S) screenshot page URL in ?url=.', { status: 400 }); + } catch { + return new Response('Invalid screenshot page URL.', { status: 400 }); + } + + let screenshots = new ScreenshotGenerator(new BrowserRunRenderer(env.BROWSER)); + + try { + let result = await screenshots.capture({ url }); + + return new Response(result.image, { + headers: { + 'Content-Type': result.type, + 'Cache-Control': 'no-store', + }, + }); + } catch (error) { + console.error(error); + + return new Response(error.message, { status: 502 }); + } + }, +}; diff --git a/workers/screenshots/wrangler.jsonc b/workers/screenshots/wrangler.jsonc new file mode 100644 index 0000000..3fe763e --- /dev/null +++ b/workers/screenshots/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "splatoon2-ink-screenshot-preview", + "main": "index.mjs", + "compatibility_date": "2026-09-03", + "compatibility_flags": ["nodejs_compat"], + "workers_dev": false, + "preview_urls": false, + "browser": { "binding": "BROWSER", "remote": true }, + "dev": { "ip": "127.0.0.1", "port": 8789 } +} diff --git a/workers/updater/README.md b/workers/updater/README.md index 811e0db..aac57f9 100644 --- a/workers/updater/README.md +++ b/workers/updater/README.md @@ -73,7 +73,7 @@ Visually check generated images before cutover. Nintendo, Bluesky, and rendering-site checks have 30-second network deadlines, including body consumption. Browser Rendering uses 10-second navigation, page-ready, and capture limits, -with a 40-second overall request deadline per attempt. It waits for +through the Browser binding. It waits for `data-screenshot-ready="true"` after data, Vue rendering, fonts, images and layout settle, instead of waiting for network idle. Deploy the updated screenshot page before the Worker that requires this marker. @@ -87,11 +87,10 @@ as successful social runs. ## Local screenshots and Browser Run testing -Node commands select `SCREENSHOT_PROVIDER=puppeteer` or `cloudflare`, matching -splat3's setting name. There is no implicit provider fallback. The Worker -constructs its `BrowserRunClient` directly and does not import Puppeteer. -`ScreenshotGenerator` owns the shared routes, viewport, and page-ready selector; -either renderer returns PNG bytes. No new package import conditions are used. +Node commands use Puppeteer; the Worker uses `BrowserRunRenderer` with its +`BROWSER` binding. `ScreenshotGenerator` owns the shared routes, viewport, and +page-ready selector; either renderer returns PNG bytes. No API token or account +ID is needed for screenshots, and no package import conditions select renderers. With Puppeteer, leave `SITE_URL` empty to temporarily serve the built `dist/` on loopback. Run `npm run build` first and provide the usual data/assets in @@ -103,30 +102,38 @@ and uses 10-second navigation, readiness, and browser-protocol timeouts. ```sh # Generate the social test images against the local build. -SCREENSHOT_PROVIDER=puppeteer SITE_URL= npm run social:test +SITE_URL= npm run social:test # Capture one route; replace the timestamp with a rotation in your data. -SCREENSHOT_PROVIDER=puppeteer SITE_URL= npm run screenshot -- \ +SITE_URL= npm run screenshot -- \ --hash '/schedules/1788652800' --output dist/test-screenshots/schedule.png # Capture a running dev server directly. -npm run screenshot -- --provider puppeteer \ +npm run screenshot -- \ --url 'http://127.0.0.1:8080/screenshots.html#/schedules/1788652800' -# Exercise the actual Browser Run client from Node against a reachable site. -npm run screenshot -- --provider cloudflare \ - --url 'https://dev.splatoon2.ink/screenshots.html#/schedules/1788652800' \ - --output dist/test-screenshots/cloudflare.png +# Start the capture-only Worker locally, with a remote Browser binding. +npm run screenshot:cloudflare + +# In another terminal, save a capture from a publicly reachable screenshot page. +curl --fail-with-body --get 'http://127.0.0.1:8789/' \ + --data-urlencode 'url=https://dev.splatoon2.ink/screenshots.html#/schedules/1788652800' \ + --output /tmp/cloudflare.png ``` -`npm run screenshot` is capture-only: it does not read social data, compare -published datasets, post messages, update checkpoints, or convert to JPEG. -It loads `.env`, accepts either `--url` or `--hash`, and saves a PNG. Cloudflare -still requires `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_BROWSER_RUN_API_TOKEN`, -and cannot reach localhost directly. `--url` needs no `SITE_URL`; `--hash` uses -`SITE_URL` (or Puppeteer's temporary server). All screenshot pages must provide -the readiness marker. The Worker's social pipeline retains its published-data -checks; this diagnostic command deliberately does not run that pipeline. +`npm run screenshot` loads `.env`, accepts either `--url` or `--hash`, and saves +a PNG using local Chrome. `--hash` uses `SITE_URL` or the temporary dist server. + +`npm run screenshot:cloudflare` runs the small `workers/screenshots` entry point +through Wrangler. Sign in with `npx wrangler login` if needed. Only the browser +runs remotely; the capture endpoint listens on loopback port 8789. This entry +point is for local development, not deployment. Cloudflare cannot reach localhost: +use a deployed preview or tunnel for an unpublished frontend. Remote captures +use the account's Browser Run allowance. + +Both paths are capture-only: no updates, social sends, checkpoints, dataset +comparisons, or JPEG conversion. All screenshot pages must provide the readiness +marker. The updater's social pipeline retains its published-data checks. ## Shadow testing and cutover @@ -211,14 +218,12 @@ Access. The panel does not expose force-repost, pause, or resume controls. Secrets: `NINTENDO_SESSION_ID_NA`, `NINTENDO_SESSION_ID_EU`, `NINTENDO_SESSION_ID_JP`, optional `SPLATNET_USER_AGENT`, `RUN_TOKEN`, -`CLOUDFLARE_BROWSER_RUN_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, optional `SENTRY_DSN`, and at cutover +optional `SENTRY_DSN`, and at cutover `BLUESKY_SERVICE`, `BLUESKY_IDENTIFIER`, `BLUESKY_PASSWORD`. Use `wrangler secret put NAME --config workers/updater/wrangler.jsonc` for a -secret. `SITE_URL` is a non-secret var in the config. The account ID is stored -as a secret to keep this account identifier out of the public repository; it -is not an authentication credential. Before deploying this change, configure -`CLOUDFLARE_ACCOUNT_ID` with the secret command above. +secret. `SITE_URL` is a non-secret var in the config. Screenshots use the +`BROWSER` binding; no Browser Run API credentials are required. For local development, use gitignored `workers/updater/.dev.vars`. The existing shared code reads these values through Workers' populated `process.env`. Sentry wrappers route shared updater errors to Sentry when `SENTRY_DSN` is set. diff --git a/workers/updater/Scheduler.spec.mjs b/workers/updater/Scheduler.spec.mjs index 615eea8..cf47289 100644 --- a/workers/updater/Scheduler.spec.mjs +++ b/workers/updater/Scheduler.spec.mjs @@ -25,6 +25,14 @@ function network({ down = false, renderFails = false, beforeRequest } = {}) { let splatnet = fakeSplatNet(); let renders = []; + vi.spyOn(env.BROWSER, 'quickAction').mockImplementation(async (action, options) => { + renders.push(options); + + return new Response(renderFails ? 'render failed' : new Uint8Array([1, 2]), { + status: renderFails ? 503 : 200, + }); + }); + vi.stubGlobal('fetch', async (input, init) => { const url = new URL(input); @@ -34,14 +42,6 @@ function network({ down = false, renderFails = false, beforeRequest } = {}) { return object ? new Response(object.body) : new Response('missing', { status: 404 }); } - if (url.hostname === 'api.cloudflare.com') { - renders.push(JSON.parse(init.body)); - - return new Response(renderFails ? 'render failed' : new Uint8Array([1, 2]), { - status: renderFails ? 503 : 200, - }); - } - await beforeRequest?.(); return down ? new Response('down', { status: 503 }) : splatnet(input, init); @@ -53,14 +53,13 @@ function network({ down = false, renderFails = false, beforeRequest } = {}) { beforeEach(() => { setSessionEnvironment(); process.env.SITE_URL = 'https://site.test'; - process.env.CLOUDFLARE_ACCOUNT_ID = 'test'; - process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN = 'test'; for (let name of ['BLUESKY_SERVICE', 'BLUESKY_IDENTIFIER', 'BLUESKY_PASSWORD']) delete process.env[name]; }); afterEach(() => { vi.unstubAllGlobals(); + vi.restoreAllMocks(); vi.useRealTimers(); }); diff --git a/workers/updater/posters.spec.mjs b/workers/updater/posters.spec.mjs index f4b2d9e..10b090a 100644 --- a/workers/updater/posters.spec.mjs +++ b/workers/updater/posters.spec.mjs @@ -5,7 +5,7 @@ import { fakeSplatNet, ROUTES } from './fakeSplatNet.mjs'; import { getTopOfCurrentHour } from '../../src/common/time.js'; // The posters (src/app/social) running inside workerd: data from R2, screenshots from a -// stubbed Browser Rendering endpoint, no social credentials (shadow mode). +// stubbed Browser binding, no social credentials (shadow mode). const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); describe('runPosters', () => { @@ -13,8 +13,6 @@ describe('runPosters', () => { beforeEach(async () => { process.env.SITE_URL = 'https://example.test'; - process.env.CLOUDFLARE_ACCOUNT_ID = 'acct'; - process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN = 'token'; for (let name of ['BLUESKY_SERVICE', 'BLUESKY_IDENTIFIER', 'BLUESKY_PASSWORD']) delete process.env[name]; @@ -48,6 +46,12 @@ describe('runPosters', () => { let splatnet = fakeSplatNet(); + vi.spyOn(env.BROWSER, 'quickAction').mockImplementation(async (action, options) => { + renders.push(options); + + return new Response(PNG, { headers: { 'content-type': 'image/png' } }); + }); + vi.stubGlobal('fetch', async (input, init) => { let url = new URL(input); @@ -57,16 +61,13 @@ describe('runPosters', () => { return object ? new Response(object.body) : new Response('missing', { status: 404 }); } - if (url.hostname === 'api.cloudflare.com') { - renders.push(JSON.parse(init.body)); - - return new Response(PNG, { headers: { 'content-type': 'image/png' } }); - } - return splatnet(input, init); }); }); - afterEach(() => vi.unstubAllGlobals()); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); it('renders the public images for the hour without posting anywhere', async () => { let summary = await runPosters(env); diff --git a/workers/updater/src/posters.mjs b/workers/updater/src/posters.mjs index 54dc74f..cf39dd6 100644 --- a/workers/updater/src/posters.mjs +++ b/workers/updater/src/posters.mjs @@ -1,4 +1,4 @@ -import BrowserRunClient from '../../../src/app/screenshots/BrowserRunClient.js'; +import BrowserRunRenderer from '../../../src/app/screenshots/BrowserRunRenderer.js'; import ScreenshotGenerator from '../../../src/app/screenshots/ScreenshotGenerator.js'; import stringify from 'json-stable-stringify'; import { sendStatuses, createClients } from '../../../src/app/social/index.js'; @@ -50,10 +50,7 @@ export async function runPosters(env) { enabled.push(client.key); let screenshots = new ScreenshotGenerator( - new BrowserRunClient({ - accountId: process.env.CLOUDFLARE_ACCOUNT_ID, - apiToken: process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN, - }), + new BrowserRunRenderer(env.BROWSER), process.env.SITE_URL, ); let result = await sendStatuses(storage, clients, screenshots); diff --git a/workers/updater/vitest.config.mjs b/workers/updater/vitest.config.mjs index ef011dc..fd5103d 100644 --- a/workers/updater/vitest.config.mjs +++ b/workers/updater/vitest.config.mjs @@ -5,6 +5,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [ cloudflareTest({ + // Browser captures are mocked; automated tests never use the remote service. + remoteBindings: false, wrangler: { configPath: fileURLToPath(new URL('./wrangler.jsonc', import.meta.url)), }, diff --git a/workers/updater/wrangler.jsonc b/workers/updater/wrangler.jsonc index 58262fc..3c9ec84 100644 --- a/workers/updater/wrangler.jsonc +++ b/workers/updater/wrangler.jsonc @@ -30,10 +30,10 @@ ], "vars": { // Screenshots for social posts are rendered from the deployed site by Browser Rendering. - // The API token is a secret (CLOUDFLARE_BROWSER_RUN_API_TOKEN). "SITE_URL": "https://splatoon2.ink", "ADMIN_HOSTNAME": "admin.dev.splatoon2.ink" }, + "browser": { "binding": "BROWSER", "remote": true }, "images": { "binding": "IMAGES" }, "r2_buckets": [ {