mirror of
https://github.com/misenhower/splatoon2.ink.git
synced 2026-09-28 04:06:52 -05:00
Use Browser binding for screenshots and local Wrangler testing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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 <page URL> | --hash <screenshot route>) [--output <file.png>]',
|
||||
'Usage: npm run screenshot -- (--url <page URL> | --hash <screenshot route>) [--output <file.png>]',
|
||||
);
|
||||
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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
34
test/screenshots/worker.test.js
Normal file
34
test/screenshots/worker.test.js
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
37
workers/screenshots/index.mjs
Normal file
37
workers/screenshots/index.mjs
Normal file
@@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
11
workers/screenshots/wrangler.jsonc
Normal file
11
workers/screenshots/wrangler.jsonc
Normal file
@@ -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 }
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user