Improve readability of test setup and assertions

This commit is contained in:
Matt Isenhower
2026-09-07 17:52:50 -07:00
parent 9971afe2e8
commit a1e934d0bd
27 changed files with 1223 additions and 245 deletions

View File

@@ -9,6 +9,7 @@ const env = {
ACCESS_TEAM_DOMAIN: 'example.cloudflareaccess.com',
ACCESS_AUD: 'admin-audience',
};
async function token({
audience = env.ACCESS_AUD,
issuer = 'https://' + env.ACCESS_TEAM_DOMAIN,
@@ -22,6 +23,7 @@ async function token({
.setExpirationTime(expires)
.sign(privateKey);
}
function request(jwt, host = env.ADMIN_HOSTNAME) {
return new Request(`https://${host}/admin/`, {
headers: jwt ? { 'Cf-Access-Jwt-Assertion': jwt } : {},
@@ -32,17 +34,18 @@ test('validates Access signature, issuer, audience, expiration and configured ho
assert.deepEqual(await verifyAccess(request(await token()), env, publicKey), {
email: 'admin@example.test',
});
for (const options of [
{ audience: 'different-app' },
{ issuer: 'https://other.cloudflareaccess.com' },
{ expires: '0s' },
])
assert.equal(await verifyAccess(request(await token(options)), env, publicKey), null);
assert.equal(
await verifyAccess(request(await token(), 'other.workers.dev'), env, publicKey),
null,
);
assert.equal(await verifyAccess(request(await token(), 'other.workers.dev'), env, publicKey), null);
const wrong = await generateKeyPair('RS256');
assert.equal(await verifyAccess(request(await token()), env, wrong.publicKey), null);
});

View File

@@ -4,12 +4,16 @@ import { RunLog, logMessage } from '../../src/app/log.js';
test('bounds retained logs, redacts secrets, and preserves normal console output', async () => {
const consoleLog = mock.method(console, 'info', () => {});
try {
const capture = new RunLog(['secret-value']);
await capture.run(async () => {
for (let i = 0; i < 205; i++) logMessage('info', `line ${i}`);
for (let i = 0; i < 205; i++)
logMessage('info', `line ${i}`);
logMessage('info', 'secret-value Bearer sensitive-token');
});
assert.equal(capture.snapshot.lines.length, 200);
assert.equal(capture.snapshot.omitted, 6);
assert.equal(capture.snapshot.lines.at(-1).text, '[redacted] Bearer [redacted]');
@@ -20,9 +24,11 @@ test('bounds retained logs, redacts secrets, and preserves normal console output
});
test('concurrent run contexts do not capture each other or unrelated messages', async () => {
mock.method(console, 'info', () => {});
try {
const a = new RunLog(),
b = new RunLog();
await Promise.all([
a.run(async () => {
await Promise.resolve();
@@ -33,12 +39,13 @@ test('concurrent run contexts do not capture each other or unrelated messages',
}),
]);
logMessage('info', 'outside');
assert.deepEqual(
a.snapshot.lines.map((l) => l.text),
a.snapshot.lines.map(l => l.text),
['a'],
);
assert.deepEqual(
b.snapshot.lines.map((l) => l.text),
b.snapshot.lines.map(l => l.text),
['b'],
);
} finally {

View File

@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { nextDataRefreshAt } from '../src/common/dataRefresh.js';
const at = time => Date.parse(`2026-09-07T${time}Z`);
test('a late hourly publication is picked up again without waiting another hour', () => {
assert.equal(nextDataRefreshAt(at('10:00:10'), 0), at('10:00:25'));
assert.equal(nextDataRefreshAt(at('10:00:40'), 0), at('10:01:25'));

View File

@@ -4,9 +4,15 @@ import { setTimeout as sleep } from 'node:timers/promises';
import { fetchWithTimeout } from '../src/common/fetch.js';
test('aborts a stalled request at its deadline', async () => {
mock.method(globalThis, 'fetch', (input, { signal }) => new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
}));
mock.method(
globalThis,
'fetch',
(input, { signal }) =>
new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
}),
);
try {
await Promise.all([
assert.rejects(fetchWithTimeout('https://example.test', {}, 5), { name: 'TimeoutError' }),
@@ -18,13 +24,24 @@ test('aborts a stalled request at its deadline', async () => {
});
test('keeps the deadline active while reading the response body', async () => {
mock.method(globalThis, 'fetch', async (input, { signal }) => new Response(new ReadableStream({
start(controller) {
signal.addEventListener('abort', () => controller.error(signal.reason), { once: true });
},
})));
mock.method(
globalThis,
'fetch',
async (input, { signal }) =>
new Response(
new ReadableStream({
start(controller) {
signal.addEventListener('abort', () => controller.error(signal.reason), {
once: true,
});
},
}),
),
);
try {
const response = await fetchWithTimeout('https://example.test', {}, 5);
await Promise.all([assert.rejects(response.text(), { name: 'TimeoutError' }), sleep(20)]);
} finally {
mock.restoreAll();
@@ -33,11 +50,19 @@ test('keeps the deadline active while reading the response body', async () => {
test('preserves caller cancellation', async () => {
let signal;
mock.method(globalThis, 'fetch', async (input, init) => { signal = init.signal; return new Response('ok'); });
mock.method(globalThis, 'fetch', async (input, init) => {
signal = init.signal;
return new Response('ok');
});
try {
const controller = new AbortController;
const controller = new AbortController();
await fetchWithTimeout('https://example.test', { signal: controller.signal });
controller.abort();
assert.equal(signal.aborted, true);
} finally {
mock.restoreAll();

View File

@@ -5,6 +5,7 @@ import PuppeteerRenderer from '../../src/app/screenshots/PuppeteerRenderer.js';
import { withScreenshots } from '../../src/app/screenshots/node.js';
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
afterEach(() => mock.restoreAll());
test('local browser uses page readiness and closes on success or failure', async () => {
@@ -12,51 +13,75 @@ test('local browser uses page readiness and closes on success or failure', async
let fail = false;
let ready;
let page = {
setDefaultTimeout(timeout) { assert.equal(timeout, 10_000); },
setDefaultTimeout(timeout) {
assert.equal(timeout, 10_000);
},
async setViewport() {},
async setCacheEnabled() {},
async goto(url, options) {
assert.equal(url, 'http://localhost:8080/screenshots.html');
assert.equal(options.waitUntil, 'domcontentloaded');
return { ok: () => true };
},
async waitForSelector(selector) {
ready = selector;
if (fail)
throw new Error('Page not ready');
},
async screenshot(options) {
assert.equal(options.type, 'png');
return PNG;
},
};
mock.method(puppeteer, 'launch', async () => ({
newPage: async () => page,
close: async () => { closed++; },
close: async () => {
closed++;
},
}));
let renderer = new PuppeteerRenderer;
let options = { url: 'http://localhost:8080/screenshots.html', viewport: {}, readySelector: '[data-screenshot-ready="true"]' };
let renderer = new PuppeteerRenderer();
let options = {
url: 'http://localhost:8080/screenshots.html',
viewport: {},
readySelector: '[data-screenshot-ready="true"]',
};
assert.deepEqual(await renderer.capture(options), PNG);
assert.equal(ready, options.readySelector);
fail = true;
await assert.rejects(renderer.capture(options), /Page not ready/);
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,
provider: 'cloudflare',
siteUrl: '',
url,
});
assert.deepEqual(result.image, PNG);
assert.equal(requests[0].url, url);
} finally {
@@ -65,8 +90,14 @@ test('Node can select Cloudflare for a direct URL without SITE_URL or social dat
});
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/);
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 () => {
@@ -76,18 +107,32 @@ test('temporary dist server is loopback-only and closes when capture fails', asy
let directory = await mkdtemp(join(tmpdir(), 'screenshot-server-'));
let originalDirectory = process.cwd();
let siteUrl;
try {
await mkdir(join(directory, 'dist'));
await writeFile(join(directory, 'dist/screenshots.html'), '<html>local fixture</html>');
process.chdir(directory);
mock.method(PuppeteerRenderer.prototype, 'capture', async () => { throw new Error('Capture failed'); });
await assert.rejects(withScreenshots(async screenshots => {
siteUrl = screenshots.siteUrl;
assert.equal(new URL(siteUrl).hostname, '127.0.0.1');
let response = await fetch(new URL('/screenshots.html', siteUrl));
assert.match(await response.text(), /local fixture/);
return screenshots.capture({ hash: '/schedules/3600' });
}, { provider: 'puppeteer', siteUrl: '' }), /Capture failed/);
mock.method(PuppeteerRenderer.prototype, 'capture', async () => {
throw new Error('Capture failed');
});
await assert.rejects(
withScreenshots(
async screenshots => {
siteUrl = screenshots.siteUrl;
assert.equal(new URL(siteUrl).hostname, '127.0.0.1');
let response = await fetch(new URL('/screenshots.html', siteUrl));
assert.match(await response.text(), /local fixture/);
return screenshots.capture({ hash: '/schedules/3600' });
},
{ provider: 'puppeteer', siteUrl: '' },
),
/Capture failed/,
);
await assert.rejects(fetch(siteUrl));
} finally {
process.chdir(originalDirectory);

View File

@@ -4,45 +4,90 @@ import { markScreenshotReady } from '../../src/common/screenshot.js';
function deferred() {
let resolve;
const promise = new Promise(done => { resolve = done; });
const promise = new Promise(done => {
resolve = done;
});
return { promise, resolve };
}
test('waits for data, Vue, fonts, images and two layout frames before signaling readiness', async () => {
const data = deferred(), vue = deferred(), fonts = deferred(), image = deferred();
const data = deferred(),
vue = deferred(),
fonts = deferred(),
image = deferred();
const steps = [];
const document = {
documentElement: { removeAttribute: () => steps.push('reset'), setAttribute: (name, value) => steps.push([name, value]) },
documentElement: {
removeAttribute: () => steps.push('reset'),
setAttribute: (name, value) => steps.push([name, value]),
},
fonts: { ready: fonts.promise },
images: [{ decode: () => { steps.push('image'); return image.promise; } }],
images: [
{
decode: () => {
steps.push('image');
return image.promise;
},
},
],
};
const ready = markScreenshotReady({
document, loadData: () => data.promise,
nextTick: () => { steps.push('vue'); return vue.promise; },
requestAnimationFrame: callback => { steps.push('frame'); callback(); },
document,
loadData: () => data.promise,
nextTick: () => {
steps.push('vue');
return vue.promise;
},
requestAnimationFrame: callback => {
steps.push('frame');
callback();
},
});
assert.deepEqual(steps, ['reset']);
data.resolve([{ status: 'fulfilled' }]);
await Promise.resolve();
assert.deepEqual(steps, ['reset', 'vue']);
vue.resolve();
fonts.resolve();
await Promise.resolve(); await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(steps, ['reset', 'vue', 'image']);
image.resolve();
await ready;
assert.deepEqual(steps.slice(-3), ['frame', 'frame', ['data-screenshot-ready', 'true']]);
});
test('failed data and superseded routes never mark a page ready', async () => {
let marked = false;
const document = {
documentElement: { removeAttribute() {}, setAttribute() { marked = true; } },
fonts: { ready: Promise.resolve() }, images: [],
documentElement: {
removeAttribute() {},
setAttribute() {
marked = true;
},
},
fonts: { ready: Promise.resolve() },
images: [],
};
const options = { document, nextTick: async () => {}, requestAnimationFrame: callback => callback() };
await assert.rejects(markScreenshotReady({ ...options, loadData: async () => [{ status: 'rejected' }] }), /data failed/);
await assert.rejects(
markScreenshotReady({ ...options, loadData: async () => [{ status: 'rejected' }] }),
/data failed/,
);
assert.equal(marked, false);
await markScreenshotReady({ ...options, loadData: async () => [], isCurrent: () => false });
assert.equal(marked, false);
});

View File

@@ -4,20 +4,28 @@ import BrowserRunClient from '../../src/app/screenshots/BrowserRunClient.js';
import ScreenshotGenerator from '../../src/app/screenshots/ScreenshotGenerator.js';
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 BrowserRunClient({
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
apiToken: process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN,
}),
process.env.SITE_URL,
);
}
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
function fakeBrowserRendering(respond = () => new Response(PNG, { headers: { 'content-type': 'image/png' } })) {
function fakeBrowserRendering(
respond = () => new Response(PNG, { headers: { 'content-type': 'image/png' } }),
) {
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) });
return respond();
});
return requests;
}
@@ -33,8 +41,13 @@ test('asks Browser Rendering for the deployed screenshot page at the default vie
const result = await screenshots().capture({ hash: '/schedules/3600' });
assert.equal(requests.length, 1);
const [{ url, headers, body }] = requests;
assert.equal(url.href, 'https://api.cloudflare.com/client/v4/accounts/acct/browser-rendering/screenshot?cacheTTL=0');
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(body.url, 'https://example.test/screenshots.html#/schedules/3600');
assert.deepEqual(body.viewport, { width: 1216, height: 684, deviceScaleFactor: 2 });
@@ -49,42 +62,62 @@ test('asks Browser Rendering for the deployed screenshot page at the default vie
test('the new-weapon screenshot grows with the number of weapons, and splatfest passes its regions', async () => {
const requests = fakeBrowserRendering();
const tall = await screenshots().captureNewWeaponScreenshot(3600, 9);
assert.equal(requests[0].body.viewport.height, 3 * 320 + 60);
assert.equal(tall.height, (3 * 320 + 60) * 2);
const short = await screenshots().captureNewWeaponScreenshot(3600, 1);
assert.equal(requests[1].body.viewport.height, 700);
assert.equal(short.width, 2432);
await screenshots().captureSplatfestScreenshot('na', 3600, ['na', 'eu']);
assert.equal(requests[2].body.url, 'https://example.test/screenshots.html#/splatfest/na/3600?regions=na,eu');
assert.equal(
requests[2].body.url,
'https://example.test/screenshots.html#/splatfest/na/3600?regions=na,eu',
);
});
test('reports API errors with Cloudflare\'s message', async () => {
fakeBrowserRendering(() => Response.json({ errors: [{ message: 'Invalid token' }] }, { status: 401 }));
await assert.rejects(screenshots().capture({ hash: '/x' }), /Browser Rendering screenshot failed \(401\): Invalid token/);
await assert.rejects(
screenshots().capture({ hash: '/x' }),
/Browser Rendering screenshot failed \(401\): Invalid token/,
);
});
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/);
await assert.rejects(
screenshots().capture({ hash: '/x' }),
/Missing screenshot configuration: CLOUDFLARE_BROWSER_RUN_API_TOKEN/,
);
});
function immediateBackoff() {
const delays = [];
mock.method(globalThis, 'setTimeout', (callback, delay) => {
delays.push(delay);
queueMicrotask(callback);
});
return delays;
}
test('retries timeout responses and succeeds with the same screenshot request', async () => {
const delays = immediateBackoff();
let attempt = 0;
const requests = fakeBrowserRendering(() => ++attempt < 3
? Response.json({ errors: [{ message: 'Navigation timeout of 10000 ms exceeded' }] }, { status: 422 })
: new Response(PNG));
const requests = fakeBrowserRendering(() =>
++attempt < 3
? Response.json({ errors: [{ message: 'Navigation timeout of 10000 ms exceeded' }] }, { status: 422 })
: new Response(PNG),
);
const result = await screenshots().capture({ hash: '/schedules/3600' });
assert.deepEqual(result.image, PNG);
assert.equal(requests.length, 3);
assert.deepEqual(requests[0].body, requests[2].body);
@@ -94,6 +127,7 @@ test('retries timeout responses and succeeds with the same screenshot request',
test('stops after three retries and preserves the final error', async () => {
const delays = immediateBackoff();
const requests = fakeBrowserRendering(() => new Response('upstream unavailable', { status: 503 }));
await assert.rejects(screenshots().capture({ hash: '/x' }), /503.*upstream unavailable/);
assert.equal(requests.length, 4);
assert.deepEqual(delays, [500, 1000, 2000]);
@@ -101,26 +135,48 @@ test('stops after three retries and preserves the final error', async () => {
test('does not retry authentication, rate limits or non-timeout validation errors', async () => {
const delays = immediateBackoff();
for (const status of [401, 403, 429, 422]) {
let count = 0;
const requests = fakeBrowserRendering(() => { count++; return new Response('invalid request', { status }); });
const requests = fakeBrowserRendering(() => {
count++;
return new Response('invalid request', { status });
});
await assert.rejects(screenshots().capture({ hash: '/x' }), new RegExp(String(status)));
assert.equal(count, 1);
assert.equal(requests.length, 1);
}
assert.deepEqual(delays, []);
});
test('retries network and client deadline failures, including while reading the image', async () => {
immediateBackoff();
let attempt = 0;
const requests = fakeBrowserRendering(() => {
attempt++;
if (attempt === 1) throw new TypeError('fetch failed');
if (attempt === 2) throw new DOMException('request timed out', 'TimeoutError');
if (attempt === 3) return new Response(new ReadableStream({ start(controller) { controller.error(new TypeError('connection reset')); } }));
if (attempt === 1)
throw new TypeError('fetch failed');
if (attempt === 2)
throw new DOMException('request timed out', 'TimeoutError');
if (attempt === 3)
return new Response(
new ReadableStream({
start(controller) {
controller.error(new TypeError('connection reset'));
},
}),
);
return new Response(PNG);
});
assert.deepEqual((await screenshots().capture({ hash: '/x' })).image, PNG);
assert.equal(requests.length, 4);
});

View File

@@ -8,9 +8,14 @@ import { captureException } from '@sentry/core';
// core: Sentry keys its global carrier by version, so a version split silently drops events.
test('captureException from @sentry/core reaches the client @sentry/node initialized', async () => {
const seen = [];
SentryNode.init({
dsn: 'https://public@example.ingest.sentry.io/1',
beforeSend: event => { seen.push(event.exception.values[0].value); return null; },
beforeSend: event => {
seen.push(event.exception.values[0].value);
return null;
},
});
captureException(new Error('routed through core'));

View File

@@ -4,10 +4,17 @@ import BlueskyClient from '../../src/app/social/clients/BlueskyClient.js';
function fakeAgent() {
const calls = { uploads: [], posts: [] };
return {
calls,
uploadBlob: async (bytes, options) => { calls.uploads.push({ bytes, options }); return { data: { blob: { ref: `blob-${calls.uploads.length}` } } }; },
post: async record => { calls.posts.push(record); },
uploadBlob: async (bytes, options) => {
calls.uploads.push({ bytes, options });
return { data: { blob: { ref: `blob-${calls.uploads.length}` } } };
},
post: async record => {
calls.posts.push(record);
},
// RichText.detectFacets resolves handles through the agent; none in our text
resolveHandle: async () => ({ data: { did: 'did:example' } }),
};
@@ -18,23 +25,38 @@ test('uploads the media as given and posts with an image embed and aspect ratio'
const client = new BlueskyClient({ agent });
const jpeg = new Uint8Array([0xff, 0xd8, 0xff]);
await client.send({ status: 'Hello #splatoon2', media: [{ file: jpeg, type: 'image/jpeg', width: 2432, height: 1368 }] });
await client.send({
status: 'Hello #splatoon2',
media: [{ file: jpeg, type: 'image/jpeg', width: 2432, height: 1368 }],
});
assert.equal(client.mediaType, 'image/jpeg');
assert.deepEqual(agent.calls.uploads, [{ bytes: jpeg, options: { encoding: 'image/jpeg' } }]);
const [post] = agent.calls.posts;
assert.equal(post.text, 'Hello #splatoon2');
assert.ok(post.facets.some(f => f.features[0].$type === 'app.bsky.richtext.facet#tag'));
assert.deepEqual(post.embed, { $type: 'app.bsky.embed.images', images: [{ image: { ref: 'blob-1' }, alt: '', aspectRatio: { width: 2432, height: 1368 } }] });
assert.deepEqual(post.embed, {
$type: 'app.bsky.embed.images',
images: [{ image: { ref: 'blob-1' }, alt: '', aspectRatio: { width: 2432, height: 1368 } }],
});
});
test('omits the aspect ratio when the size is unknown', async () => {
const agent = fakeAgent();
await new BlueskyClient({ agent }).send({ status: 'x', media: [{ file: new Uint8Array([1]), type: 'image/png' }] });
await new BlueskyClient({ agent }).send({
status: 'x',
media: [{ file: new Uint8Array([1]), type: 'image/png' }],
});
assert.deepEqual(Object.keys(agent.calls.posts[0].embed.images[0]), ['image', 'alt']);
});
test('cannot send without credentials', async () => {
for (const name of ['BLUESKY_SERVICE', 'BLUESKY_IDENTIFIER', 'BLUESKY_PASSWORD']) delete process.env[name];
assert.ok(!await new BlueskyClient().canSend());
for (const name of ['BLUESKY_SERVICE', 'BLUESKY_IDENTIFIER', 'BLUESKY_PASSWORD'])
delete process.env[name];
assert.ok(!(await new BlueskyClient().canSend()));
});

View File

@@ -9,41 +9,72 @@ const IMAGE = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
class HourlyPost extends SocialPostBase {
constructor(s, clients, { data = { id: 1 }, time = 3600, publicImage = 'hourly.png' } = {}) {
super(s, clients);
this.data = data; this.time = time; this.publicImage = publicImage; this.images = 0;
this.data = data;
this.time = time;
this.publicImage = publicImage;
this.images = 0;
}
getKey() {
return 'hourly';
}
getName() {
return 'Hourly';
}
async getDataTime() {
return this.time;
}
async getData() {
return this.data;
}
async getImage() {
this.images++;
return IMAGE;
}
getPublicImageFilename() {
return this.publicImage;
}
async getText(data) {
return `Post ${data.id}`;
}
getKey() { return 'hourly'; }
getName() { return 'Hourly'; }
async getDataTime() { return this.time; }
async getData() { return this.data; }
async getImage() { this.images++; return IMAGE; }
getPublicImageFilename() { return this.publicImage; }
async getText(data) { return `Post ${data.id}`; }
}
let s, bluesky, other;
beforeEach(() => { s = storage(); bluesky = fakeClient('bluesky'); other = fakeClient('other'); });
beforeEach(() => {
s = storage();
bluesky = fakeClient('bluesky');
other = fakeClient('other');
});
test('posts to every client, saves the public image, and records the time per client', async () => {
const post = new HourlyPost(s, [bluesky, other]);
assert.equal((await post.maybePost()).ok, true);
assert.deepEqual(bluesky.sent, [{ status: 'Post 1', media: [{ file: IMAGE, type: 'image/png' }] }]);
assert.deepEqual(other.sent, bluesky.sent);
assert.equal(post.images, 1);
assert.deepEqual(new Uint8Array(await (await s.publicBucket.get('twitter-images/hourly.png')).arrayBuffer()), IMAGE);
assert.deepEqual(
new Uint8Array(await (await s.publicBucket.get('twitter-images/hourly.png')).arrayBuffer()),
IMAGE,
);
assert.deepEqual(await json(s.privateBucket, 'bluesky-lastPostTimes.json'), { hourly: 3600 });
assert.deepEqual(await json(s.privateBucket, 'other-lastPostTimes.json'), { hourly: 3600 });
});
test('does not post the same data time twice, and posts again when it moves on', async () => {
await seed(s.privateBucket, 'bluesky-lastPostTimes.json', { hourly: 3600, other: 99 });
const post = new HourlyPost(s, [bluesky]);
assert.equal(await post.maybePost(), false);
assert.deepEqual(bluesky.sent, []);
assert.equal(post.images, 0);
post.time = 7200;
await post.maybePost();
assert.equal(bluesky.sent.length, 1);
assert.deepEqual(await json(s.privateBucket, 'bluesky-lastPostTimes.json'), { hourly: 7200, other: 99 });
});
@@ -51,6 +82,7 @@ test('does not post the same data time twice, and posts again when it moves on',
test('a client that already posted is skipped while another still posts', async () => {
await seed(s.privateBucket, 'bluesky-lastPostTimes.json', { hourly: 3600 });
await new HourlyPost(s, [bluesky, other]).maybePost();
assert.deepEqual(bluesky.sent, []);
assert.equal(other.sent.length, 1);
});
@@ -58,44 +90,67 @@ test('a client that already posted is skipped while another still posts', async
test('a failing client does not record a post time and does not block the other client', async () => {
const broken = fakeClient('bluesky', { fail: true });
const result = await new HourlyPost(s, [broken, other]).maybePost();
assert.equal(result.ok, false);
assert.deepEqual(result.clients.map(c => c.ok), [false, true]);
assert.deepEqual(
result.clients.map(c => c.ok),
[false, true],
);
assert.equal(other.sent.length, 1);
assert.equal(await json(s.privateBucket, 'bluesky-lastPostTimes.json'), null);
assert.deepEqual(await json(s.privateBucket, 'other-lastPostTimes.json'), { hourly: 3600 });
});
test('captures one PNG and converts it once for JPEG clients, preserving dimensions', async () => {
const png = await sharp({ create: { width: 2, height: 1, channels: 3, background: '#ff0000' } }).png().toBuffer();
const png = await sharp({ create: { width: 2, height: 1, channels: 3, background: '#ff0000' } })
.png()
.toBuffer();
class ScreenshotPost extends HourlyPost {
async getImage() {
this.images++;
return { image: png, type: 'image/png', width: 2, height: 1 };
}
}
bluesky.mediaType = 'image/jpeg';
const secondJpeg = fakeClient('second'); secondJpeg.mediaType = 'image/jpeg';
const secondJpeg = fakeClient('second');
secondJpeg.mediaType = 'image/jpeg';
const post = new ScreenshotPost(s, [bluesky, other, secondJpeg]);
assert.equal((await post.maybePost()).ok, true);
assert.equal(post.images, 1);
const jpeg = bluesky.sent[0].media[0];
assert.equal(jpeg.type, 'image/jpeg');
assert.equal(jpeg.width, 2);
assert.equal(jpeg.height, 1);
const decoded = await sharp(jpeg.file).metadata();
assert.equal(decoded.format, 'jpeg');
assert.equal(decoded.width, 2);
assert.equal(decoded.height, 1);
assert.equal(secondJpeg.sent[0].media[0], jpeg);
assert.deepEqual(other.sent[0].media[0].file, png);
assert.deepEqual(new Uint8Array(await (await s.publicBucket.get('twitter-images/hourly.png')).arrayBuffer()), new Uint8Array(png));
assert.deepEqual(
new Uint8Array(await (await s.publicBucket.get('twitter-images/hourly.png')).arrayBuffer()),
new Uint8Array(png),
);
});
test('conversion failure preserves the PNG and does not checkpoint or send the JPEG post', async () => {
bluesky.mediaType = 'image/jpeg';
const post = new HourlyPost(s, [bluesky, other]);
post.convertMedia = async () => { throw new Error('Images quota exceeded'); };
post.convertMedia = async () => {
throw new Error('Images quota exceeded');
};
const result = await post.maybePost();
assert.equal(result.ok, false);
assert.match(result.clients[0].error, /Images quota exceeded/);
assert.equal(bluesky.sent.length, 0);
@@ -107,13 +162,16 @@ test('conversion failure preserves the PNG and does not checkpoint or send the J
test('with no data there is nothing to post', async () => {
const post = new HourlyPost(s, [bluesky], { data: null });
assert.equal(await post.maybePost(), false);
assert.equal(post.images, 0);
});
test('with no client able to send, the public image is still saved', async () => {
const offline = fakeClient('bluesky', { canSend: false });
await new HourlyPost(s, [offline]).maybePost();
assert.deepEqual(offline.sent, []);
assert.ok(await s.publicBucket.get('twitter-images/hourly.png'));
});
@@ -121,19 +179,28 @@ test('with no client able to send, the public image is still saved', async () =>
test('with no client able to send and no public image, it gives up', async () => {
const offline = fakeClient('bluesky', { canSend: false });
const post = new HourlyPost(s, [offline], { publicImage: null });
assert.equal(await post.maybePost(), false);
assert.equal(post.images, 0);
});
test('test screenshots go to public storage', async () => {
await new HourlyPost(s, []).saveTestScreenshot();
assert.deepEqual(new Uint8Array(await (await s.publicBucket.get('test-screenshots/hourly.png')).arrayBuffer()), IMAGE);
assert.deepEqual(
new Uint8Array(await (await s.publicBucket.get('test-screenshots/hourly.png')).arrayBuffer()),
IMAGE,
);
});
test('screenshot failure is reported and does not advance post state', async () => {
const post = new HourlyPost(s, [bluesky]);
post.getImage = async () => { throw new Error('render failed'); };
post.getImage = async () => {
throw new Error('render failed');
};
const result = await post.maybePost();
assert.equal(result.ok, false);
assert.match(result.error, /render failed/);
assert.equal(await json(s.privateBucket, 'bluesky-lastPostTimes.json'), null);
@@ -142,10 +209,13 @@ test('screenshot failure is reported and does not advance post state', async ()
test('retry only sends the previously failed client, using a fresh storage view', async () => {
const broken = fakeClient('other', { fail: true });
const first = await new HourlyPost(s, [bluesky, broken]).maybePost();
assert.equal(first.ok, false);
const recovered = fakeClient('other');
const fresh = { ...s, privateStorage: new s.privateStorage.constructor(s.privateBucket) };
const second = await new HourlyPost(fresh, [bluesky, recovered]).maybePost();
assert.equal(second.ok, true);
assert.equal(bluesky.sent.length, 1);
assert.equal(recovered.sent.length, 1);

View File

@@ -9,92 +9,173 @@ import { storage, nextRun, fakeClient, json, seed } from './support.js';
const NOW = 3600 * 1000; // an hour boundary, as getTopOfCurrentHour returns
const stage = (id, name) => ({ id, name, image: `/images/stage/${id}.png` });
const rotation = (start, a, b, rule) => ({ start_time: start, end_time: start + 7200, stage_a: a, stage_b: b, rule: { key: rule, name: rule } });
const rotation = (start, a, b, rule) => ({
start_time: start,
end_time: start + 7200,
stage_a: a,
stage_b: b,
rule: { key: rule, name: rule },
});
let s, client;
beforeEach(async () => {
s = storage();
client = fakeClient('bluesky');
await seed(s.publicBucket, 'data/festivals.json', { na: { festivals: [], results: [] }, eu: { festivals: [], results: [] }, jp: { festivals: [], results: [] } });
await seed(s.publicBucket, 'data/festivals.json', {
na: { festivals: [], results: [] },
eu: { festivals: [], results: [] },
jp: { festivals: [], results: [] },
});
});
function pinTime(post, time) {
post.getDataTime = async () => time;
post.getImage = async () => new Uint8Array([1]);
return post;
}
test('createPosts builds every post against the same storage and clients', () => {
const screenshots = {};
const posts = createPosts(s, [client], screenshots);
assert.deepEqual(posts.map(t => t.getKey()), ['schedule', 'gear', 'salmonrun', 'weapon', 'newstage', 'splatfest-na', 'splatfest-eu', 'splatfest-jp']);
assert.ok(posts.every(t => t.publicStorage === s.publicStorage && t.clients[0] === client && t.screenshots === screenshots));
assert.deepEqual(
posts.map(t => t.getKey()),
['schedule', 'gear', 'salmonrun', 'weapon', 'newstage', 'splatfest-na', 'splatfest-eu', 'splatfest-jp'],
);
assert.ok(
posts.every(
t => t.publicStorage === s.publicStorage && t.clients[0] === client && t.screenshots === screenshots,
),
);
});
test('schedule: posts the rotation for the current hour, with new-stage wording when the stage is new', async () => {
const reef = stage('0', 'The Reef'), skipper = stage('22', 'Skipper Pavilion');
const reef = stage('0', 'The Reef'),
skipper = stage('22', 'Skipper Pavilion');
await seed(s.publicBucket, 'data/schedules.json', {
regular: [rotation(NOW, reef, skipper, 'Turf War')], gachi: [rotation(NOW, reef, skipper, 'Splat Zones')], league: [rotation(NOW, reef, skipper, 'Rainmaker')],
regular: [rotation(NOW, reef, skipper, 'Turf War')],
gachi: [rotation(NOW, reef, skipper, 'Splat Zones')],
league: [rotation(NOW, reef, skipper, 'Rainmaker')],
});
await seed(s.privateBucket, 'stages.json', [{ ...reef, first_seen: -1, first_available: -1 }, { ...skipper, first_seen: NOW - 7200, first_available: NOW }]);
await seed(s.privateBucket, 'stages.json', [
{ ...reef, first_seen: -1, first_available: -1 },
{ ...skipper, first_seen: NOW - 7200, first_available: NOW },
]);
const post = pinTime(new SchedulePost(s, [client]), NOW);
await post.maybePost();
assert.equal(client.sent[0].status, 'NEW STAGE: Skipper Pavilion is now open! #maprotation #splatoon2');
assert.ok(await s.publicBucket.get('twitter-images/schedule.png'));
const later = pinTime(new SchedulePost(nextRun(s), [client]), NOW + 3600);
assert.equal(await later.maybePost(), false); // no rotation starts at that hour
});
test('schedule: the plain rotation text names the ranked and league rules', async () => {
const reef = stage('0', 'The Reef'), fitness = stage('1', 'Musselforge Fitness');
const reef = stage('0', 'The Reef'),
fitness = stage('1', 'Musselforge Fitness');
await seed(s.publicBucket, 'data/schedules.json', {
regular: [rotation(NOW, reef, fitness, 'Turf War')], gachi: [rotation(NOW, reef, fitness, 'Splat Zones')], league: [rotation(NOW, reef, fitness, 'Rainmaker')],
regular: [rotation(NOW, reef, fitness, 'Turf War')],
gachi: [rotation(NOW, reef, fitness, 'Splat Zones')],
league: [rotation(NOW, reef, fitness, 'Rainmaker')],
});
const post = pinTime(new SchedulePost(s, [client]), NOW);
assert.equal(await post.getText(await post.getData()), 'Splatoon 2 map rotation: Ranked game mode: Splat Zones, League game mode: Rainmaker #maprotation');
assert.equal(
await post.getText(await post.getData()),
'Splatoon 2 map rotation: Ranked game mode: Splat Zones, League game mode: Rainmaker #maprotation',
);
});
test('salmon run: posts when a shift opens, remembers it, and posts again when it closes', async () => {
const shift = { start_time: NOW, end_time: NOW + 3600 * 36, stage: { name: 'Spawning Grounds' }, weapons: [{ id: '0', weapon: { name: 'Splattershot' } }] };
const shift = {
start_time: NOW,
end_time: NOW + 3600 * 36,
stage: { name: 'Spawning Grounds' },
weapons: [{ id: '0', weapon: { name: 'Splattershot' } }],
};
await seed(s.publicBucket, 'data/coop-schedules.json', { schedules: [shift], details: [shift] });
await seed(s.publicBucket, 'data/timeline.json', { coop: { reward_gear: { gear: { name: 'Cap' } } } });
await pinTime(new SalmonRunPost(nextRun(s), [client]), NOW).maybePost();
assert.equal(client.sent[0].status, 'Salmon Run is now open on Spawning Grounds! Current reward gear is the Cap. #salmonrun #splatoon2');
assert.equal(
client.sent[0].status,
'Salmon Run is now open on Spawning Grounds! Current reward gear is the Cap. #salmonrun #splatoon2',
);
assert.deepEqual(await json(s.privateBucket, 'salmonrun-previousSchedule.json'), shift);
assert.equal(await pinTime(new SalmonRunPost(nextRun(s), [client]), NOW + 3600).maybePost(), false); // mid-shift, not a 12-hour mark
await pinTime(new SalmonRunPost(nextRun(s), [client]), NOW + 3600 * 12).maybePost();
assert.match(client.sent[1].status, /is still open on Spawning Grounds/);
const next = { start_time: NOW + 3600 * 40, end_time: NOW + 3600 * 76 };
await seed(s.publicBucket, 'data/coop-schedules.json', { schedules: [next], details: [] });
await pinTime(new SalmonRunPost(nextRun(s), [client]), NOW + 3600 * 36).maybePost();
assert.equal(client.sent[2].status, 'Salmon Run is now closed. The next shift starts in 4 hours! #salmonrun #splatoon2');
assert.equal(
client.sent[2].status,
'Salmon Run is now closed. The next shift starts in 4 hours! #salmonrun #splatoon2',
);
});
test('new stage: posts the mirrored stage image the hour the stage was first seen', async () => {
await seed(s.privateBucket, 'stages.json', [{ id: '22', name: 'Skipper Pavilion', image: '/images/stage/22.png', first_seen: NOW, first_available: NOW + 7200 }]);
await seed(s.privateBucket, 'stages.json', [
{
id: '22',
name: 'Skipper Pavilion',
image: '/images/stage/22.png',
first_seen: NOW,
first_available: NOW + 7200,
},
]);
await s.publicBucket.put('assets/splatnet/images/stage/22.png', new Uint8Array([7]));
const post = new NewStagePost(s, [client]);
post.getDataTime = async () => NOW;
await post.maybePost();
assert.equal(client.sent[0].status, 'NEW STAGE: The first schedules for Skipper Pavilion have been posted! Start playing the new stage when this post is 2 hours old. #splatoon2');
assert.equal(
client.sent[0].status,
'NEW STAGE: The first schedules for Skipper Pavilion have been posted! Start playing the new stage when this post is 2 hours old. #splatoon2',
);
assert.deepEqual(client.sent[0].media[0].file, new Uint8Array([7]));
});
test('splatfest: a global fest posts once, from the first region', async () => {
const fest = { festival_id: 5, names: { alpha_short: 'A', bravo_short: 'B' }, times: { announce: 0, start: NOW, end: NOW + 3600, result: NOW + 7200 } };
await seed(s.publicBucket, 'data/festivals.json', { na: { festivals: [fest], results: [] }, eu: { festivals: [fest], results: [] }, jp: { festivals: [fest], results: [] } });
const fest = {
festival_id: 5,
names: { alpha_short: 'A', bravo_short: 'B' },
times: { announce: 0, start: NOW, end: NOW + 3600, result: NOW + 7200 },
};
await seed(s.publicBucket, 'data/festivals.json', {
na: { festivals: [fest], results: [] },
eu: { festivals: [fest], results: [] },
jp: { festivals: [fest], results: [] },
});
const sentBy = [];
for (const region of ['na', 'eu', 'jp']) {
const c = fakeClient('bluesky');
await pinTime(new SplatfestPost(region, s, [c]), NOW).maybePost();
if (c.sent.length) sentBy.push([region, c.sent[0].status]);
if (c.sent.length)
sentBy.push([region, c.sent[0].status]);
}
assert.deepEqual(sentBy, [['na', 'The global Splatfest is now open! #splatfest #splatoon2']]);
});

View File

@@ -1,8 +1,9 @@
import { MemoryBucket, BucketStorage } from '../../src/common/storage/index.js';
export function storage() {
const publicBucket = new MemoryBucket;
const privateBucket = new MemoryBucket;
const publicBucket = new MemoryBucket();
const privateBucket = new MemoryBucket();
return {
publicBucket,
privateBucket,
@@ -13,12 +14,16 @@ export function storage() {
/** A fresh per-run view over the same buckets, as each real run gets (the cache is per run). */
export function nextRun(s) {
return { publicStorage: new BucketStorage(s.publicBucket), privateStorage: new BucketStorage(s.privateBucket) };
return {
publicStorage: new BucketStorage(s.publicBucket),
privateStorage: new BucketStorage(s.privateBucket),
};
}
/** A social client that records what it is asked to send. */
export function fakeClient(key, { canSend = true, fail = false } = {}) {
const sent = [];
return {
key,
name: key[0].toUpperCase() + key.slice(1),
@@ -27,6 +32,7 @@ export function fakeClient(key, { canSend = true, fail = false } = {}) {
send: async status => {
if (fail)
throw new Error(`${key} is down`);
sent.push(status);
},
};
@@ -34,6 +40,8 @@ export function fakeClient(key, { canSend = true, fail = false } = {}) {
export async function json(bucket, key) {
const object = await bucket.get(key);
return object ? object.json() : null;
}
export const seed = (bucket, key, value) => bucket.put(key, JSON.stringify(value));

View File

@@ -3,9 +3,10 @@ import assert from 'node:assert/strict';
import { MemoryBucket, BucketStorage } from '../../src/common/storage/index.js';
test('exists lists a directory once and remembers what it wrote', async () => {
const bucket = new MemoryBucket;
const bucket = new MemoryBucket();
await bucket.put('assets/splatnet/images/stage/a.png', 'a');
await bucket.put('assets/splatnet/images/weapon/w.png', 'w');
const list = mock.method(bucket, 'list');
const head = mock.method(bucket, 'head');
const storage = new BucketStorage(bucket);
@@ -13,22 +14,28 @@ test('exists lists a directory once and remembers what it wrote', async () => {
assert.equal(await storage.exists('assets/splatnet/images/stage/a.png'), true);
assert.equal(await storage.exists('assets/splatnet/images/stage/b.png'), false);
assert.equal(await storage.exists('assets/splatnet/images/weapon/w.png'), true);
assert.deepEqual(list.mock.calls.map(c => c.arguments[0].prefix), ['assets/splatnet/images/stage/', 'assets/splatnet/images/weapon/']);
assert.deepEqual(
list.mock.calls.map(c => c.arguments[0].prefix),
['assets/splatnet/images/stage/', 'assets/splatnet/images/weapon/'],
);
assert.equal(head.mock.calls.length, 0);
await storage.writeBytes('assets/splatnet/images/stage/b.png', new Uint8Array([1]));
assert.equal(await storage.exists('assets/splatnet/images/stage/b.png'), true);
assert.equal(list.mock.calls.length, 2);
});
test('readJson parses once and returns the same object, null for missing keys', async () => {
const bucket = new MemoryBucket;
const bucket = new MemoryBucket();
await bucket.put('data/x.json', JSON.stringify({ a: 1 }));
const get = mock.method(bucket, 'get');
const storage = new BucketStorage(bucket);
const first = await storage.readJson('data/x.json');
const second = await storage.readJson('data/x.json');
assert.deepEqual(first, { a: 1 });
assert.equal(first, second);
assert.equal(await storage.readJson('data/missing.json'), null);
@@ -37,19 +44,24 @@ test('readJson parses once and returns the same object, null for missing keys',
});
test('writeJson skips unchanged content, writes changed content with metadata, and updates the cache', async () => {
const bucket = new MemoryBucket;
const bucket = new MemoryBucket();
await bucket.put('data/x.json', JSON.stringify({ a: 1 }));
const put = mock.method(bucket, 'put');
const storage = new BucketStorage(bucket);
const doc = await storage.readJson('data/x.json');
assert.equal(await storage.writeJson('data/x.json', { a: 1 }), false);
assert.equal(put.mock.calls.length, 0);
doc.a = 2; // mutate the shared object, as the localization processor does
assert.equal(await storage.writeJson('data/x.json', doc, { cacheControl: 'no-cache' }), true);
assert.equal(put.mock.calls.length, 1);
assert.deepEqual((await bucket.get('data/x.json')).httpMetadata, { contentType: 'application/json', cacheControl: 'no-cache' });
assert.deepEqual((await bucket.get('data/x.json')).httpMetadata, {
contentType: 'application/json',
cacheControl: 'no-cache',
});
assert.deepEqual(await (await bucket.get('data/x.json')).json(), { a: 2 });
assert.equal(await storage.writeJson('data/x.json', { a: 2 }), false);
@@ -59,11 +71,17 @@ test('writeJson skips unchanged content, writes changed content with metadata, a
});
test('writeText and readBytes go straight through with content types by extension', async () => {
const bucket = new MemoryBucket;
const bucket = new MemoryBucket();
const storage = new BucketStorage(bucket);
await storage.writeText('data/cal.ics', 'BEGIN:VCALENDAR', { cacheControl: 'no-cache' });
assert.deepEqual((await bucket.get('data/cal.ics')).httpMetadata, { contentType: 'text/calendar', cacheControl: 'no-cache' });
assert.deepEqual((await bucket.get('data/cal.ics')).httpMetadata, {
contentType: 'text/calendar',
cacheControl: 'no-cache',
});
await storage.writeBytes('assets/a.png', new Uint8Array([9, 9]));
assert.equal((await bucket.get('assets/a.png')).httpMetadata.contentType, 'image/png');
assert.deepEqual(await storage.readBytes('assets/a.png'), new Uint8Array([9, 9]));
assert.equal(await storage.readBytes('assets/none.png'), null);

View File

@@ -4,23 +4,37 @@ import { MemoryBucket } from '../../src/common/storage/index.js';
// MemoryBucket stands in for the R2 binding in tests, so its list() must paginate like R2's.
test('list filters by prefix, sorts, and paginates with cursors', async () => {
const bucket = new MemoryBucket;
for (const key of ['assets/images/stage/b.png', 'assets/images/stage/a.png', 'assets/images/stage/deep/c.png', 'assets/images/weapon/w.png', 'data/x.json'])
const bucket = new MemoryBucket();
for (const key of [
'assets/images/stage/b.png',
'assets/images/stage/a.png',
'assets/images/stage/deep/c.png',
'assets/images/weapon/w.png',
'data/x.json',
])
await bucket.put(key, 'x');
const all = await bucket.list({ prefix: 'assets/images/stage/' });
assert.deepEqual(all.objects.map(o => o.key), ['assets/images/stage/a.png', 'assets/images/stage/b.png', 'assets/images/stage/deep/c.png']);
assert.deepEqual(
all.objects.map(o => o.key),
['assets/images/stage/a.png', 'assets/images/stage/b.png', 'assets/images/stage/deep/c.png'],
);
assert.equal(all.truncated, false);
const first = await bucket.list({ prefix: 'assets/', limit: 2 });
assert.equal(first.truncated, true);
const second = await bucket.list({ prefix: 'assets/', limit: 2, cursor: first.cursor });
assert.deepEqual([...first.objects, ...second.objects].map(o => o.key).length, 4);
assert.equal(second.truncated, false);
});
test('put and get round-trip strings, bytes, and streams with metadata', async () => {
const bucket = new MemoryBucket;
const bucket = new MemoryBucket();
await bucket.put('a.json', '{"a":1}', { httpMetadata: { contentType: 'application/json' } });
await bucket.put('b.png', new Uint8Array([1, 2]));
await bucket.put('c.png', new Response(new Uint8Array([3])).body);
@@ -30,6 +44,8 @@ test('put and get round-trip strings, bytes, and streams with metadata', async (
assert.deepEqual(new Uint8Array(await (await bucket.get('b.png')).arrayBuffer()), new Uint8Array([1, 2]));
assert.deepEqual(new Uint8Array(await (await bucket.get('c.png')).arrayBuffer()), new Uint8Array([3]));
assert.equal(await bucket.get('missing'), null);
await bucket.delete('a.json');
assert.equal(await bucket.head('a.json'), null);
});

View File

@@ -9,7 +9,7 @@ import FilesystemStorage from '../../src/common/storage/FilesystemStorage.js';
// One contract for the storage interface the updaters use, run against every implementation.
const implementations = {
BucketStorage: {
create: async () => new BucketStorage(new MemoryBucket),
create: async () => new BucketStorage(new MemoryBucket()),
destroy: async () => {},
},
FilesystemStorage: {
@@ -21,7 +21,10 @@ const implementations = {
for (const [name, { create, destroy }] of Object.entries(implementations)) {
describe(name, () => {
let storage;
beforeEach(async () => { storage = await create(); });
beforeEach(async () => {
storage = await create();
});
afterEach(() => destroy(storage));
test('missing keys do not exist and read as null', async () => {
@@ -32,19 +35,23 @@ for (const [name, { create, destroy }] of Object.entries(implementations)) {
test('json round-trips and becomes visible to exists', async () => {
await storage.writeJson('data/nested/a.json', { hello: 'world' });
assert.equal(await storage.exists('data/nested/a.json'), true);
assert.deepEqual(await storage.readJson('data/nested/a.json'), { hello: 'world' });
await storage.writeJson('data/nested/a.json', { hello: 'again' });
assert.deepEqual(await storage.readJson('data/nested/a.json'), { hello: 'again' });
});
test('bytes and text round-trip', async () => {
await storage.writeBytes('assets/x.png', new Uint8Array([1, 2, 3]));
assert.deepEqual(await storage.readBytes('assets/x.png'), new Uint8Array([1, 2, 3]));
assert.equal(await storage.exists('assets/x.png'), true);
await storage.writeText('data/cal.ics', 'BEGIN:VCALENDAR');
assert.equal(await storage.exists('data/cal.ics'), true);
assert.equal(new TextDecoder().decode(await storage.readBytes('data/cal.ics')), 'BEGIN:VCALENDAR');
});

View File

@@ -10,44 +10,68 @@ const rules = { name: 'rules', entities: '$..rule', id: 'key', values: ['name',
const gear = { name: 'gear', entities: '$..gear', id: ['kind', 'id'], values: 'name' };
const data = {
regular: [{ stage_a: { id: '0', name: 'The Reef' }, stage_b: { id: '22', name: 'Skipper Pavilion' }, rule: { key: 'turf_war', name: 'Turf War', multiline_name: 'Turf\nWar' } }],
regular: [
{
stage_a: { id: '0', name: 'The Reef' },
stage_b: { id: '22', name: 'Skipper Pavilion' },
rule: { key: 'turf_war', name: 'Turf War', multiline_name: 'Turf\nWar' },
},
],
};
const json = async (bucket, key) => (await bucket.get(key)).json();
test('records localized values under name/ids/value and reports what is missing', async () => {
const bucket = new MemoryBucket;
const bucket = new MemoryBucket();
await bucket.put('data/locale/en.json', JSON.stringify({ stages: { 0: { name: 'The Reef' } } }));
const storage = new BucketStorage(bucket);
const stageProcessor = new LocalizationProcessor(stages, en, storage);
assert.equal(await stageProcessor.hasLocalizations(data), false); // stage 22 unknown
await stageProcessor.updateLocalizations(data);
assert.equal(await stageProcessor.hasLocalizations(data), true);
const ruleProcessor = new LocalizationProcessor(rules, en, storage);
assert.equal(await ruleProcessor.hasLocalizations(data), false);
await ruleProcessor.updateLocalizations(data);
assert.deepEqual(await json(bucket, 'data/locale/en.json'), {
stages: { 0: { name: 'The Reef' }, 22: { name: 'Skipper Pavilion' } },
rules: { turf_war: { name: 'Turf War', multiline_name: 'Turf\nWar' } },
});
assert.equal((await bucket.head('data/locale/en.json')).httpMetadata.cacheControl, 'no-cache, stale-while-revalidate=5, stale-if-error=86400');
assert.equal(
(await bucket.head('data/locale/en.json')).httpMetadata.cacheControl,
'no-cache, stale-while-revalidate=5, stale-if-error=86400',
);
});
test('supports multi-part ids and creates a missing document', async () => {
const bucket = new MemoryBucket;
const bucket = new MemoryBucket();
const processor = new LocalizationProcessor(gear, en, new BucketStorage(bucket));
await processor.updateLocalizations({ merchandises: [{ gear: { kind: 'head', id: '5', name: 'Fake Hat' } }] });
assert.deepEqual(await json(bucket, 'data/locale/en.json'), { gear: { head: { 5: { name: 'Fake Hat' } } } });
await processor.updateLocalizations({
merchandises: [{ gear: { kind: 'head', id: '5', name: 'Fake Hat' } }],
});
assert.deepEqual(await json(bucket, 'data/locale/en.json'), {
gear: { head: { 5: { name: 'Fake Hat' } } },
});
});
test('does not rewrite a document that already has every string', async () => {
const bucket = new MemoryBucket;
await bucket.put('data/locale/en.json', JSON.stringify({ stages: { 0: { name: 'The Reef' }, 22: { name: 'Skipper Pavilion' } } }));
const bucket = new MemoryBucket();
await bucket.put(
'data/locale/en.json',
JSON.stringify({ stages: { 0: { name: 'The Reef' }, 22: { name: 'Skipper Pavilion' } } }),
);
const puts = mock.method(bucket, 'put');
await new LocalizationProcessor(stages, en, new BucketStorage(bucket)).updateLocalizations(data);
assert.equal(puts.mock.calls.length, 0);
});

View File

@@ -12,33 +12,51 @@ const NAMES = {
function thingsFor(language) {
const names = NAMES[language] ?? NAMES.en;
return { things: [{ id: '1', name: names.thing, image: '/images/things/one.png' }, { id: '2', name: names.other, image: '/images/things/two.png' }] };
return {
things: [
{ id: '1', name: names.thing, image: '/images/things/one.png' },
{ id: '2', name: names.other, image: '/images/things/two.png' },
],
};
}
class ThingsUpdater extends Updater {
constructor(buckets, options = {}) {
super({
name: 'Things',
filename: 'things.json',
calendarFilename: 'things.ics',
request: splatnet => splatnet.getResponse('things'),
imagePaths: ['$..image'],
localization: [{ name: 'things', entities: '$.things[*]', id: 'id', values: 'name' }],
...options,
}, buckets);
super(
{
name: 'Things',
filename: 'things.json',
calendarFilename: 'things.ics',
request: splatnet => splatnet.getResponse('things'),
imagePaths: ['$..image'],
localization: [{ name: 'things', entities: '$.things[*]', id: 'id', values: 'name' }],
...options,
},
buckets,
);
}
getCalendarEntries(data) {
return data.things.map(thing => ({ id: `thing-${thing.id}`, title: thing.name, start_time: 3600, end_time: 7200 }));
return data.things.map(thing => ({
id: `thing-${thing.id}`,
title: thing.name,
start_time: 3600,
end_time: 7200,
}));
}
}
let b;
beforeEach(() => { b = buckets(); });
beforeEach(() => {
b = buckets();
});
afterEach(() => mock.restoreAll());
test('publishes data, locale document, and calendar with the right metadata', async () => {
const splatnet = fakeSplatNet({ '/api/things': ({ language }) => thingsFor(language) });
await new ThingsUpdater(b).update();
assert.deepEqual(await json(b.publicBucket, 'data/things.json'), thingsFor('en'));
@@ -47,10 +65,15 @@ test('publishes data, locale document, and calendar with the right metadata', as
cacheControl: 'no-cache, stale-while-revalidate=5, stale-if-error=86400',
});
assert.deepEqual(await json(b.publicBucket, 'data/locale/en.json'), { things: { 1: { name: 'Widget' }, 2: { name: 'Gadget' } } });
assert.deepEqual(await json(b.publicBucket, 'data/locale/ja.json'), { things: { 1: { name: 'ウィジェット' }, 2: { name: 'ガジェット' } } });
assert.deepEqual(await json(b.publicBucket, 'data/locale/en.json'), {
things: { 1: { name: 'Widget' }, 2: { name: 'Gadget' } },
});
assert.deepEqual(await json(b.publicBucket, 'data/locale/ja.json'), {
things: { 1: { name: 'ウィジェット' }, 2: { name: 'ガジェット' } },
});
const ics = await (await b.publicBucket.get('data/things.ics')).text();
assert.match(ics, /SUMMARY:Widget/);
assert.match(ics, /X-WR-CALNAME:Things/);
assert.equal((await b.publicBucket.head('data/things.ics')).httpMetadata.contentType, 'text/calendar');
@@ -61,55 +84,94 @@ test('publishes data, locale document, and calendar with the right metadata', as
test('fetches each language with missing strings using that region\'s session, then stops needing to', async () => {
const splatnet = fakeSplatNet({ '/api/things': ({ language }) => thingsFor(language) });
await new ThingsUpdater(b).update();
assert.deepEqual(splatnet.api.map(r => `${r.region}/${r.language}`), [
'NA/en', 'EU/es', 'NA/es-MX', 'EU/fr', 'NA/fr-CA', 'EU/de', 'EU/nl', 'EU/it', 'EU/ru', 'JP/ja',
]);
assert.deepEqual(
splatnet.api.map(r => `${r.region}/${r.language}`),
['NA/en', 'EU/es', 'NA/es-MX', 'EU/fr', 'NA/fr-CA', 'EU/de', 'EU/nl', 'EU/it', 'EU/ru', 'JP/ja'],
);
const second = fakeSplatNet({ '/api/things': ({ language }) => thingsFor(language) });
const puts = mock.method(b.publicBucket, 'put');
await new ThingsUpdater(b).update();
assert.deepEqual(second.api.map(r => r.language), ['en']);
assert.deepEqual(puts.mock.calls.map(c => c.arguments[0]).filter(k => k.startsWith('data/locale/')), []);
assert.deepEqual(
second.api.map(r => r.language),
['en'],
);
assert.deepEqual(
puts.mock.calls.map(c => c.arguments[0]).filter(k => k.startsWith('data/locale/')),
[],
);
});
test('mirrors only images that are missing, using one list per directory', async () => {
await b.publicBucket.put('assets/splatnet/images/things/one.png', 'existing');
const splatnet = fakeSplatNet({ '/api/things': ({ language }) => thingsFor(language) });
const list = mock.method(b.publicBucket, 'list');
const head = mock.method(b.publicBucket, 'head');
await new ThingsUpdater(b).update();
assert.deepEqual(splatnet.images.map(r => r.path), ['/images/things/two.png']);
assert.deepEqual(
splatnet.images.map(r => r.path),
['/images/things/two.png'],
);
assert.equal(splatnet.images[0].cookie, null);
assert.equal(await (await b.publicBucket.get('assets/splatnet/images/things/one.png')).text(), 'existing');
assert.deepEqual(list.mock.calls.map(c => c.arguments[0].prefix), ['assets/splatnet/images/things/']);
assert.deepEqual(
list.mock.calls.map(c => c.arguments[0].prefix),
['assets/splatnet/images/things/'],
);
assert.equal(head.mock.calls.length, 0);
assert.equal((await b.publicBucket.get('assets/splatnet/images/things/two.png')).httpMetadata.contentType, 'image/png');
assert.equal(
(await b.publicBucket.get('assets/splatnet/images/things/two.png')).httpMetadata.contentType,
'image/png',
);
});
test('uses the embedded backup for the image SplatNet no longer serves', async () => {
const { cdnBackup } = await import('#cdn-images');
const missing = '/images/skill/53c62995f9d2dc4a60f3850c5dbdd2323f1eef87.png';
const splatnet = fakeSplatNet({ '/api/things': () => ({ things: [{ id: '1', name: 'Widget', image: missing }] }) });
const splatnet = fakeSplatNet({
'/api/things': () => ({ things: [{ id: '1', name: 'Widget', image: missing }] }),
});
await new ThingsUpdater(b).update();
assert.deepEqual(splatnet.images, []);
const written = new Uint8Array(await (await b.publicBucket.get(`assets/splatnet${missing}`)).arrayBuffer());
assert.deepEqual(written, await cdnBackup(missing));
assert.deepEqual(written.slice(0, 4), new Uint8Array([0x89, 0x50, 0x4e, 0x47])); // a real PNG
assert.equal(written.byteLength, 8440);
});
test('filters root keys, dropping hidden timeline items', async () => {
fakeSplatNet({ '/api/things': () => ({ keep: { importance: 1, x: 1 }, hidden: { importance: -1 }, extra: 'dropped' }) });
await new ThingsUpdater(b, { rootKeys: ['keep', 'hidden', 'absent'], localization: undefined, imagePaths: undefined, calendarFilename: undefined }).update();
assert.deepEqual(await json(b.publicBucket, 'data/things.json'), { keep: { importance: 1, x: 1 }, hidden: null, absent: null });
fakeSplatNet({
'/api/things': () => ({ keep: { importance: 1, x: 1 }, hidden: { importance: -1 }, extra: 'dropped' }),
});
await new ThingsUpdater(b, {
rootKeys: ['keep', 'hidden', 'absent'],
localization: undefined,
imagePaths: undefined,
calendarFilename: undefined,
}).update();
assert.deepEqual(await json(b.publicBucket, 'data/things.json'), {
keep: { importance: 1, x: 1 },
hidden: null,
absent: null,
});
});
test('fails the run and writes nothing when SplatNet rejects the session', async () => {
fakeSplatNet({ '/api/things': () => new Response('forbidden', { status: 403 }) });
await assert.rejects(new ThingsUpdater(b).update(), /status 403/);
assert.deepEqual(keys(b.publicBucket), []);
});
@@ -117,6 +179,7 @@ test('fails the run and writes nothing when SplatNet rejects the session', async
test('leaves the existing locale document untouched when the update fails', async () => {
await b.publicBucket.put('data/locale/en.json', JSON.stringify({ things: { 9: { name: 'Old' } } }));
fakeSplatNet({ '/api/things': () => new Response('down', { status: 503 }) });
await assert.rejects(new ThingsUpdater(b).update());
assert.deepEqual(await json(b.publicBucket, 'data/locale/en.json'), { things: { 9: { name: 'Old' } } });
});

View File

@@ -17,26 +17,39 @@ export function setSessionEnvironment() {
*/
export function fakeSplatNet(routes = {}) {
const requests = [];
mock.method(globalThis, 'fetch', async (input, init = {}) => {
const url = new URL(input);
const headers = new Headers(init.headers);
const cookie = headers.get('Cookie');
const region = Object.entries(SESSIONS).find(([, id]) => cookie === `iksm_session=${id}`)?.[0] ?? null;
const request = { path: url.pathname, language: headers.get('Accept-Language'), cookie, region };
requests.push(request);
if (url.pathname.startsWith('/images/'))
return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { headers: { 'content-type': 'image/png' } });
return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
headers: { 'content-type': 'image/png' },
});
const route = routes[url.pathname];
if (!route)
return new Response('not found', { status: 404 });
const result = await route(request);
return result instanceof Response ? result : Response.json(result);
});
return {
requests,
get api() { return requests.filter(r => r.path.startsWith('/api/')); },
get images() { return requests.filter(r => r.path.startsWith('/images/')); },
get api() {
return requests.filter(r => r.path.startsWith('/api/'));
},
get images() {
return requests.filter(r => r.path.startsWith('/images/'));
},
};
}
@@ -45,12 +58,20 @@ export function fakeSplatNet(routes = {}) {
* updaters' storage argument and as a handle on the raw buckets for assertions.
*/
export function buckets() {
const publicBucket = new MemoryBucket, privateBucket = new MemoryBucket;
return { publicBucket, privateBucket, publicStorage: new BucketStorage(publicBucket), privateStorage: new BucketStorage(privateBucket) };
const publicBucket = new MemoryBucket(),
privateBucket = new MemoryBucket();
return {
publicBucket,
privateBucket,
publicStorage: new BucketStorage(publicBucket),
privateStorage: new BucketStorage(privateBucket),
};
}
export async function json(bucket, key) {
const object = await bucket.get(key);
return object ? object.json() : null;
}

View File

@@ -15,32 +15,58 @@ import { fakeSplatNet, buckets, json, keys, setSessionEnvironment } from './supp
setSessionEnvironment();
let b;
beforeEach(() => { b = buckets(); });
beforeEach(() => {
b = buckets();
});
afterEach(() => mock.restoreAll());
const stage = (id, name) => ({ id, name, image: `/images/stage/${id}.png` });
const rotation = (start, a, c, rule = 'turf_war') => ({
id: start, start_time: start, end_time: start + 7200,
stage_a: a, stage_b: c,
id: start,
start_time: start,
end_time: start + 7200,
stage_a: a,
stage_b: c,
game_mode: { key: 'regular', name: 'Regular Battle' },
rule: { key: rule, name: 'Turf War', multiline_name: 'Turf\nWar' },
});
test('createUpdaters builds every updater against the given buckets', () => {
const updaters = createUpdaters(b);
assert.deepEqual(updaters.map(u => u.options.name), [
'Original Gear', 'Schedules', 'Co-op Schedules', 'Timeline', 'Festivals NA', 'Festivals EU', 'Festivals JP', 'Merchandises',
]);
assert.ok(updaters.every(u => u.publicStorage === b.publicStorage && u.privateStorage === b.privateStorage));
assert.deepEqual(
updaters.map(u => u.options.name),
[
'Original Gear',
'Schedules',
'Co-op Schedules',
'Timeline',
'Festivals NA',
'Festivals EU',
'Festivals JP',
'Merchandises',
],
);
assert.ok(
updaters.every(u => u.publicStorage === b.publicStorage && u.privateStorage === b.privateStorage),
);
});
test('schedules: seeds the known stages from SplatNet and records new stages once', async () => {
const reef = stage('0', 'The Reef'), fitness = stage('1', 'Musselforge Fitness'), skipper = stage('22', 'Skipper Pavilion');
const schedules = { regular: [rotation(7200, skipper, reef), rotation(3600, reef, fitness)], gachi: [rotation(3600, reef, fitness)], league: [rotation(3600, fitness, reef)] };
const reef = stage('0', 'The Reef'),
fitness = stage('1', 'Musselforge Fitness'),
skipper = stage('22', 'Skipper Pavilion');
const schedules = {
regular: [rotation(7200, skipper, reef), rotation(3600, reef, fitness)],
gachi: [rotation(3600, reef, fitness)],
league: [rotation(3600, fitness, reef)],
};
const splatnet = fakeSplatNet({
'/api/schedules': () => schedules,
'/api/data/stages': () => ({ stages: [reef, fitness] }),
});
await new SchedulesUpdater(b).update();
assert.deepEqual(await json(b.publicBucket, 'data/schedules.json'), schedules);
@@ -53,20 +79,46 @@ test('schedules: seeds the known stages from SplatNet and records new stages onc
const second = fakeSplatNet({ '/api/schedules': () => schedules });
const puts = mock.method(b.privateBucket, 'put');
await new SchedulesUpdater(b).update();
assert.equal(second.api.filter(r => r.path === '/api/data/stages').length, 0);
assert.equal(puts.mock.calls.length, 0);
});
test('coop: publishes the calendar with stage and weapon details', async () => {
const coop = {
schedules: [{ start_time: 3600, end_time: 7200 }, { start_time: 90000, end_time: 93600 }],
details: [{ start_time: 3600, end_time: 7200, stage: { name: 'Spawning Grounds', image: '/images/coop_stage/a.png' }, weapons: [{ id: '0', weapon: { id: '0', name: 'Splattershot', image: '/images/weapon/0.png', sub: { id: '1', name: 'Bomb' }, special: { id: '2', name: 'Special' } } }, null] }],
schedules: [
{ start_time: 3600, end_time: 7200 },
{ start_time: 90000, end_time: 93600 },
],
details: [
{
start_time: 3600,
end_time: 7200,
stage: { name: 'Spawning Grounds', image: '/images/coop_stage/a.png' },
weapons: [
{
id: '0',
weapon: {
id: '0',
name: 'Splattershot',
image: '/images/weapon/0.png',
sub: { id: '1', name: 'Bomb' },
special: { id: '2', name: 'Special' },
},
},
null,
],
},
],
};
fakeSplatNet({ '/api/coop_schedules': () => coop });
await new CoopSchedulesUpdater(b).update();
const ics = await (await b.publicBucket.get('data/coop-schedules.ics')).text();
assert.match(ics, /SUMMARY:Salmon Run on Spawning Grounds/);
assert.match(ics, /Splattershot/);
assert.match(ics, /Random/);
@@ -75,25 +127,58 @@ test('coop: publishes the calendar with stage and weapon details', async () => {
});
test('timeline: keeps only coop and weapon availability, dropping hidden items', async () => {
fakeSplatNet({ '/api/timeline': () => ({ coop: { importance: -1 }, weapon_availability: { importance: 1, availabilities: [] }, stats: { ignored: true } }) });
fakeSplatNet({
'/api/timeline': () => ({
coop: { importance: -1 },
weapon_availability: { importance: 1, availabilities: [] },
stats: { ignored: true },
}),
});
await new TimelineUpdater(b).update();
assert.deepEqual(await json(b.publicBucket, 'data/timeline.json'), { coop: null, weapon_availability: { importance: 1, availabilities: [] } });
assert.deepEqual(await json(b.publicBucket, 'data/timeline.json'), {
coop: null,
weapon_availability: { importance: 1, availabilities: [] },
});
});
test('merchandises: attaches original gear from the bundled data without its brand', async () => {
const original = gearData.head[0];
const brand = { id: '0', name: 'Brand', image: '/images/brand/0.png', frequent_skill: { id: '0', name: 'Skill', image: '/images/skill/0.png' } };
const brand = {
id: '0',
name: 'Brand',
image: '/images/brand/0.png',
frequent_skill: { id: '0', name: 'Skill', image: '/images/skill/0.png' },
};
const skill = { id: '0', name: 'Skill', image: '/images/skill/0.png' };
fakeSplatNet({ '/api/onlineshop/merchandises': () => ({
merchandises: [
{ end_time: 7200, gear: { kind: 'head', id: '1', name: original.name.toUpperCase(), image: '/images/gear/1.png', brand }, skill },
{ end_time: 7200, gear: { kind: 'head', id: '2', name: 'No Such Gear', image: '/images/gear/2.png', brand }, skill },
],
extra: 'dropped',
}) });
fakeSplatNet({
'/api/onlineshop/merchandises': () => ({
merchandises: [
{
end_time: 7200,
gear: {
kind: 'head',
id: '1',
name: original.name.toUpperCase(),
image: '/images/gear/1.png',
brand,
},
skill,
},
{
end_time: 7200,
gear: { kind: 'head', id: '2', name: 'No Such Gear', image: '/images/gear/2.png', brand },
skill,
},
],
extra: 'dropped',
}),
});
await new MerchandisesUpdater(b).update();
const data = await json(b.publicBucket, 'data/merchandises.json');
assert.deepEqual(Object.keys(data), ['merchandises']);
assert.equal(data.merchandises[0].original_gear.name, original.name);
assert.equal('brand' in data.merchandises[0].original_gear, false);
@@ -104,34 +189,66 @@ test('merchandises: attaches original gear from the bundled data without its bra
test('festivals: merges one region into the shared file, fetches missing rankings, uses regional languages', async () => {
await b.publicBucket.put('data/festivals.json', JSON.stringify({ eu: { festivals: [], results: [] } }));
await b.publicBucket.put('data/festivals/na-1-rankings.json', JSON.stringify({ existing: true }));
const festival = id => ({ festival_id: id, names: { alpha_short: 'A', bravo_short: 'B' }, times: { start: 3600, end: 7200 }, images: { alpha: '/images/festival/a.png', bravo: '/images/festival/b.png', panel: '/images/festival/p.png' }, special_stage: { id: '100', name: 'Shifty Station', image: '/images/stage/100.png' } });
const festival = id => ({
festival_id: id,
names: { alpha_short: 'A', bravo_short: 'B' },
times: { start: 3600, end: 7200 },
images: {
alpha: '/images/festival/a.png',
bravo: '/images/festival/b.png',
panel: '/images/festival/p.png',
},
special_stage: { id: '100', name: 'Shifty Station', image: '/images/stage/100.png' },
});
const splatnet = fakeSplatNet({
'/api/festivals/active': () => ({ festivals: [festival(2)] }),
'/api/festivals/pasts': () => ({ festivals: [festival(1)], results: [{ festival_id: 1 }, { festival_id: 2 }] }),
'/api/festivals/pasts': () => ({
festivals: [festival(1)],
results: [{ festival_id: 1 }, { festival_id: 2 }],
}),
'/api/festivals/2/rankings': () => ({ rankings: 'two' }),
});
await new FestivalsUpdater('NA', b).update();
const data = await json(b.publicBucket, 'data/festivals.json');
assert.deepEqual(Object.keys(data).sort(), ['eu', 'na']);
assert.deepEqual(data.na.festivals.map(f => f.festival_id), [2, 1]);
assert.deepEqual(
data.na.festivals.map(f => f.festival_id),
[2, 1],
);
assert.deepEqual(await json(b.publicBucket, 'data/festivals/na-1-rankings.json'), { existing: true });
assert.deepEqual(await json(b.publicBucket, 'data/festivals/na-2-rankings.json'), { rankings: 'two' });
assert.deepEqual(splatnet.api.filter(r => r.path.endsWith('/rankings')).map(r => r.path), ['/api/festivals/2/rankings']);
assert.deepEqual(
splatnet.api.filter(r => r.path.endsWith('/rankings')).map(r => r.path),
['/api/festivals/2/rankings'],
);
// Only this region's languages are localized (NA: en, es-MX, fr-CA), all with the NA session
const localized = splatnet.api.filter(r => r.path === '/api/festivals/active');
assert.deepEqual(localized.map(r => `${r.region}/${r.language}`), ['NA/en', 'NA/es-MX', 'NA/fr-CA']);
assert.deepEqual(
localized.map(r => `${r.region}/${r.language}`),
['NA/en', 'NA/es-MX', 'NA/fr-CA'],
);
const ics = await (await b.publicBucket.get('data/festivals-na.ics')).text();
assert.match(ics, /X-WR-CALNAME:Splatfests \(NA\)/);
assert.match(ics, /SUMMARY:NA Splatfest: A vs. B/);
});
test('original gear: mirrors skill images from the bundled skills data', async () => {
const images = Object.values(skills).map(s => s.image).filter(Boolean);
const images = Object.values(skills)
.map(s => s.image)
.filter(Boolean);
await b.publicBucket.put(`assets/splatnet${images[0]}`, 'existing');
const splatnet = fakeSplatNet();
await new OriginalGearImageUpdater(b).update();
assert.equal(splatnet.images.length, images.length - 2); // one already present, one from the embedded backup

View File

@@ -9,24 +9,35 @@ let page;
let originalDocument;
const context = {
rootGetters: { 'splatoon/languages/selectedLanguage': null },
dispatch(name) { return actions[name](context); },
commit(name, value) { commits.push({ name, value }); },
dispatch(name) {
return actions[name](context);
},
commit(name, value) {
commits.push({ name, value });
},
};
beforeEach(() => {
originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
page = new EventTarget;
page = new EventTarget();
page.visibilityState = 'visible';
Object.defineProperty(globalThis, 'document', { configurable: true, value: page });
timers = new Map;
timers = new Map();
commits = [];
let id = 0;
mock.method(globalThis, 'setTimeout', (callback, ms) => { timers.set(++id, { callback, ms }); return id; });
mock.method(globalThis, 'setTimeout', (callback, ms) => {
timers.set(++id, { callback, ms });
return id;
});
mock.method(globalThis, 'clearTimeout', id => timers.delete(id));
});
afterEach(() => {
actions.stopUpdatingData();
mock.restoreAll();
if (originalDocument)
Object.defineProperty(globalThis, 'document', originalDocument);
else
@@ -34,18 +45,33 @@ afterEach(() => {
});
test('a stalled body times out and leaves a future refresh scheduled', async () => {
mock.method(globalThis, 'fetch', async (url, { signal }) => new Response(new ReadableStream({
start(controller) {
signal.addEventListener('abort', () => controller.error(new Error('aborted')), { once: true });
},
})));
mock.method(
globalThis,
'fetch',
async (url, { signal }) =>
new Response(
new ReadableStream({
start(controller) {
signal.addEventListener('abort', () => controller.error(new Error('aborted')), {
once: true,
});
},
}),
),
);
let refreshing = actions.startUpdatingData(context);
assert.equal(timers.size, 5);
for (let timer of [...timers.values()]) {
assert.equal(timer.ms, 30_000);
timer.callback();
}
await refreshing;
assert.equal(commits.length, 0);
assert.equal(timers.size, 1);
assert.ok([...timers.values()][0].ms > 0);
@@ -54,22 +80,38 @@ test('a stalled body times out and leaves a future refresh scheduled', async ()
test('a failed fetch retains old data and does not stop the refresh loop', async () => {
mock.method(globalThis, 'fetch', async () => new Response('down', { status: 503 }));
await actions.startUpdatingData(context);
assert.equal(commits.length, 0);
assert.equal(timers.size, 1);
actions.stopUpdatingData();
assert.equal(timers.size, 0);
});
test('stop and restart while an old refresh finishes creates only one timer', async () => {
let release;
let pending = new Promise(resolve => { release = resolve; });
mock.method(globalThis, 'fetch', async () => { await pending; return Response.json({ current: true }); });
let pending = new Promise(resolve => {
release = resolve;
});
mock.method(globalThis, 'fetch', async () => {
await pending;
return Response.json({ current: true });
});
let first = actions.startUpdatingData(context);
actions.stopUpdatingData();
let second = actions.startUpdatingData(context);
release();
await Promise.all([first, second]);
assert.equal(timers.size, 1);
await actions.startUpdatingData(context); // Already running: no extra fetch or timer
assert.equal(timers.size, 1);
});
@@ -81,55 +123,85 @@ function visibility(state) {
test('returning to an old tab refreshes immediately, with a cooldown and one timer', async () => {
let now = Date.parse('2026-09-07T10:10:00Z');
mock.method(Date, 'now', () => now);
const fetching = mock.method(globalThis, 'fetch', async () => Response.json({ current: true }));
await actions.startUpdatingData(context);
assert.equal(fetching.mock.callCount(), 5);
now += 60_000;
visibility('hidden');
await setImmediate();
assert.equal(fetching.mock.callCount(), 5);
visibility('visible');
await setImmediate();
assert.equal(fetching.mock.callCount(), 10);
assert.equal(timers.size, 1);
visibility('hidden');
visibility('visible');
await setImmediate();
assert.equal(fetching.mock.callCount(), 10);
actions.stopUpdatingData();
now += 60_000;
visibility('visible');
await setImmediate();
assert.equal(fetching.mock.callCount(), 10);
assert.equal(timers.size, 0);
});
test('activation does not overlap an in-flight refresh or leave a timer after stopping', async () => {
let now = Date.now();
mock.method(Date, 'now', () => now);
let release;
let pending = new Promise(resolve => { release = resolve; });
const fetching = mock.method(globalThis, 'fetch', async () => { await pending; return Response.json({ current: true }); });
let pending = new Promise(resolve => {
release = resolve;
});
const fetching = mock.method(globalThis, 'fetch', async () => {
await pending;
return Response.json({ current: true });
});
let refreshing = actions.startUpdatingData(context);
now += 60_000;
visibility('visible');
assert.equal(fetching.mock.callCount(), 5);
actions.stopUpdatingData();
release();
await refreshing;
assert.equal(timers.size, 0);
});
test('activation cooldown does not suppress the scheduled top-of-hour refresh', async () => {
let now = Date.parse('2026-09-07T10:59:50Z');
mock.method(Date, 'now', () => now);
const fetching = mock.method(globalThis, 'fetch', async () => Response.json({ current: true }));
await actions.startUpdatingData(context);
const [id, scheduled] = [...timers.entries()][0];
timers.delete(id); // A real timeout is removed before its callback runs.
now += 40_000;
await scheduled.callback();
assert.equal(fetching.mock.callCount(), 10);
assert.equal(timers.size, 1);
});

View File

@@ -1,5 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createExecutionContext, createScheduledController, env, runDurableObjectAlarm, runInDurableObject } from 'cloudflare:test';
import {
createExecutionContext,
createScheduledController,
env,
runDurableObjectAlarm,
runInDurableObject,
} from 'cloudflare:test';
import worker from './src/index.mjs';
import { nextRunAt, GRACE_MS, HOUR_MS } from './src/schedule.mjs';
import { fakeSplatNet, setSessionEnvironment } from './fakeSplatNet.mjs';
@@ -11,25 +17,36 @@ function stub() {
async function runAlarmUntil(scheduler, done) {
await runDurableObjectAlarm(scheduler);
await vi.waitFor(async () => expect(done(await scheduler.status())).toBe(true), { timeout: 5000 });
return scheduler.status();
}
function network({ down = false, renderFails = false, beforeRequest } = {}) {
let splatnet = fakeSplatNet();
let renders = [];
vi.stubGlobal('fetch', async (input, init) => {
const url = new URL(input);
if (url.hostname === 'site.test') {
let object = await env.ASSETS.get(url.pathname.slice(1));
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 });
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);
});
return renders;
}
@@ -38,6 +55,7 @@ beforeEach(() => {
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];
});
@@ -51,6 +69,7 @@ describe('Scheduler', () => {
let scheduler = stub();
let before = Date.now();
let first = await scheduler.ensureArmed();
expect(first.armed).toBe(true);
expect(first.hourlyAt).toBeGreaterThanOrEqual(nextRunAt(before));
expect((first.hourlyAt - GRACE_MS) % HOUR_MS).toBe(0);
@@ -59,13 +78,17 @@ describe('Scheduler', () => {
it('persists a pause through watchdog calls and only resumes explicitly', async () => {
network();
let scheduler = stub();
await scheduler.ensureArmed();
expect(await scheduler.pause()).toEqual({ ok: true, paused: true });
expect(await scheduler.ensureArmed()).toEqual({ armed: false, paused: true });
expect((await scheduler.status()).alarmAt).toBeNull();
expect(await scheduler.run()).toMatchObject({ ok: false, paused: true });
await scheduler.resume();
expect((await scheduler.status()).alarmAt).not.toBeNull();
expect((await scheduler.status()).paused).toBe(false);
});
@@ -73,19 +96,25 @@ describe('Scheduler', () => {
it('repairs a missing alarm without moving its due time', async () => {
let scheduler = stub();
let initial = await scheduler.ensureArmed();
await runInDurableObject(scheduler, async (instance, state) => state.storage.deleteAlarm());
expect(await scheduler.ensureArmed()).toEqual({ ...initial, armed: true });
});
it('converts pending requests from the previous deployment into a full run', async () => {
network();
let scheduler = stub();
let hourlyAt = nextRunAt();
await runInDurableObject(scheduler, async (instance, state) => {
await state.storage.put('state', { hourlyAt, pending: ['updaters', 'posters'] });
});
await scheduler.ensureArmed();
let status = await runAlarmUntil(scheduler, s => s.lastRun !== null);
expect(status.lastRun.ok).toBe(true);
expect(status.hourlyAt).toBe(hourlyAt);
expect(status.retryAt).toBeNull();
@@ -93,11 +122,15 @@ describe('Scheduler', () => {
it('runs a full hourly pipeline, records completion timing, and schedules the next hour', async () => {
network();
let scheduler = stub();
let { hourlyAt } = await scheduler.ensureArmed();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(hourlyAt + 1);
let status = await runAlarmUntil(scheduler, s => s.hourlyAt !== hourlyAt);
expect(status.lastRun.ok).toBe(true);
expect(status.lastRun.updaters.updaters).toHaveLength(8);
expect(status.lastRun.social.ok).toBe(true);
@@ -109,29 +142,39 @@ describe('Scheduler', () => {
let renders = network({ down: true });
let scheduler = stub();
let { hourlyAt } = await scheduler.ensureArmed();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(hourlyAt + 5);
let status = await runAlarmUntil(scheduler, s => s.retries === 1);
expect(status.lastRun.updaters.ok).toBe(false);
expect(status.lastRun.social).toMatchObject({ skipped: true, reason: 'updater-failed' });
expect(renders).toEqual([]);
for (let attempt = 2; attempt <= 3; attempt++) {
vi.setSystemTime(status.retryAt);
status = await runAlarmUntil(scheduler, s => s.retries === attempt);
}
vi.setSystemTime(status.retryAt);
status = await runAlarmUntil(scheduler, s => s.retryAt === null);
expect(status.retries).toBe(0);
expect(status.alarmAt).toBe(hourlyAt + HOUR_MS);
});
it('retries screenshot failures instead of reporting a successful social run', async () => {
network({ renderFails: true });
let scheduler = stub();
let { hourlyAt } = await scheduler.ensureArmed();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(hourlyAt + 1);
let status = await runAlarmUntil(scheduler, s => s.retries === 1);
expect(status.lastRun.updaters.ok).toBe(true);
expect(status.lastRun.social.ok).toBe(false);
expect(status.lastRun.social.posts.some(post => post.ok === false)).toBe(true);
@@ -142,6 +185,7 @@ describe('Scheduler', () => {
let scheduler = stub();
let { hourlyAt } = await scheduler.ensureArmed();
let result = await scheduler.run({ only: ['Schedules'] });
expect(result.ok).toBe(true);
expect(result.updaters.updaters.map(u => u.name)).toEqual(['Schedules']);
expect(result.social.reason).toBe('targeted-update');
@@ -153,29 +197,48 @@ describe('Scheduler', () => {
it('rejects overlapping manual work explicitly and preserves an hourly alarm due during it', async () => {
let release;
let gate = new Promise(resolve => { release = resolve; });
let gate = new Promise(resolve => {
release = resolve;
});
let entered = false;
network({ beforeRequest: async () => { entered = true; await gate; } });
network({
beforeRequest: async () => {
entered = true;
await gate;
},
});
let scheduler = stub();
let { hourlyAt } = await scheduler.ensureArmed();
let first = scheduler.run({ only: ['Schedules'] });
try {
await vi.waitFor(() => expect(entered).toBe(true));
expect((await scheduler.status()).activeRun.logs.lines.some(line => line.text.includes('Updating data'))).toBe(true);
expect(
(await scheduler.status()).activeRun.logs.lines.some(line => line.text.includes('Updating data')),
).toBe(true);
expect(await scheduler.run()).toMatchObject({ ok: false, busy: true });
expect(await scheduler.ensureArmed()).toMatchObject({ busy: true });
expect(await scheduler.pause()).toMatchObject({ ok: false, busy: true });
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(hourlyAt + 1);
await runDurableObjectAlarm(scheduler);
let status = await scheduler.status();
expect(status.hourlyAt).toBe(hourlyAt);
expect(status.alarmAt).not.toBeNull();
} finally {
release();
}
await first;
let status = await runAlarmUntil(scheduler, s => s.lastRun !== null);
expect(status.lastRun.updaters.ok).toBe(true);
expect(status.hourlyAt).toBe(hourlyAt + HOUR_MS);
});
@@ -184,20 +247,33 @@ describe('Scheduler', () => {
describe('Background manual runs', () => {
it('persists a request, rejects overlap, and runs data without posting', async () => {
let release;
const gate = new Promise(resolve => { release = resolve; });
const gate = new Promise(resolve => {
release = resolve;
});
const renders = network({ beforeRequest: () => gate });
const scheduler = stub();
const { hourlyAt } = await scheduler.ensureArmed();
const result = await scheduler.startManual('data');
expect(result).toMatchObject({ ok: true, run: { mode: 'data', status: 'queued' } });
try {
expect((await scheduler.status()).pendingManual.id).toBe(result.run.id);
expect(await scheduler.startManual('social')).toMatchObject({ ok: false, busy: true });
expect(await scheduler.run()).toMatchObject({ ok: false, busy: true });
expect(await scheduler.pause()).toMatchObject({ ok: false, busy: true });
} finally { release(); }
} finally {
release();
}
const status = await runAlarmUntil(scheduler, s => !!s.lastManualRun);
expect(status.lastManualRun).toMatchObject({ id: result.run.id, ok: true, status: 'succeeded', social: { skipped: true } });
expect(status.lastManualRun).toMatchObject({
id: result.run.id,
ok: true,
status: 'succeeded',
social: { skipped: true },
});
expect(status.pendingManual).toBeNull();
expect(status.activeRun).toBeNull();
expect(status.lastManualRun.logs.lines.some(line => line.text.includes('Done.'))).toBe(true);
@@ -206,26 +282,41 @@ describe('Background manual runs', () => {
});
it('runs social from the published data without invoking SplatNet again', async () => {
network();
const scheduler = stub();
await scheduler.startManual('data');
await runAlarmUntil(scheduler, s => !!s.lastManualRun);
const requests = vi.fn();
const renders = network({ beforeRequest: requests });
const { run } = await scheduler.startManual('social');
const status = await runAlarmUntil(scheduler, s => s.lastManualRun?.id === run.id);
expect(status.lastManualRun).toMatchObject({ ok: true, updaters: { skipped: true }, social: { ok: true } });
expect(status.lastManualRun).toMatchObject({
ok: true,
updaters: { skipped: true },
social: { ok: true },
});
expect(requests).not.toHaveBeenCalled();
expect(renders.length).toBeGreaterThan(0);
});
it('reports an interrupted run instead of automatically replaying an uncertain post', async () => {
const scheduler = stub();
const renders = network();
await scheduler.ensureArmed();
await runInDurableObject(scheduler, async (instance, state) => {
await state.storage.put('pendingManual', { id: 'interrupted', mode: 'social', status: 'running', startedAt: Date.now() - 1000 });
await state.storage.put('pendingManual', {
id: 'interrupted',
mode: 'social',
status: 'running',
startedAt: Date.now() - 1000,
});
});
await scheduler.ensureArmed();
const status = await runAlarmUntil(scheduler, s => !!s.lastManualRun);
expect(status.lastManualRun).toMatchObject({ id: 'interrupted', ok: false, status: 'failed' });
expect(status.lastManualRun.error).toContain('interrupted');
expect(renders).toHaveLength(0);
@@ -233,22 +324,34 @@ describe('Background manual runs', () => {
});
it('still performs an hourly cycle that became due alongside a manual request', async () => {
network();
const scheduler = stub();
const { hourlyAt } = await scheduler.ensureArmed();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(hourlyAt + 1);
await runInDurableObject(scheduler, async (instance, state) => {
await state.storage.put('pendingManual', { id: 'due', mode: 'data', status: 'queued', requestedAt: Date.now() });
await state.storage.put('pendingManual', {
id: 'due',
mode: 'data',
status: 'queued',
requestedAt: Date.now(),
});
});
const status = await runAlarmUntil(scheduler, s => !!s.lastRun);
expect(status.lastManualRun).toMatchObject({ id: 'due', ok: true, mode: 'data' });
expect(status.lastRun).toMatchObject({ ok: true, mode: 'both', social: { ok: true } });
expect(status.hourlyAt).toBe(hourlyAt + HOUR_MS);
});
it('rejects invalid modes and paused scheduling', async () => {
const scheduler = stub();
expect(await scheduler.startManual('anything')).toMatchObject({ ok: false });
await scheduler.pause();
expect(await scheduler.startManual('both')).toMatchObject({ ok: false, paused: true });
});
});
@@ -257,22 +360,36 @@ describe('Worker routing', () => {
it('cron only calls the watchdog', async () => {
const ensureArmed = vi.fn(async () => ({ armed: true }));
const fakeEnv = { SCHEDULER: { idFromName: () => 'id', get: () => ({ ensureArmed }) } };
await worker.scheduled(createScheduledController({ cron: '30 * * * *' }), fakeEnv, createExecutionContext());
await worker.scheduled(
createScheduledController({ cron: '30 * * * *' }),
fakeEnv,
createExecutionContext(),
);
expect(ensureArmed).toHaveBeenCalledOnce();
});
it('authenticates manual runs and sends them through the DO with busy/failure HTTP statuses', async () => {
const run = vi.fn(async () => ({ ok: false, busy: true }));
const fakeEnv = { RUN_TOKEN: 'test-token', SCHEDULER: { idFromName: () => 'id', get: () => ({ run }) } };
const request = headers => new Request('https://worker.test/run?only=Schedules,Timeline', { method: 'POST', headers });
const request = headers =>
new Request('https://worker.test/run?only=Schedules,Timeline', { method: 'POST', headers });
expect((await worker.fetch(request(), fakeEnv, createExecutionContext())).status).toBe(401);
expect(run).not.toHaveBeenCalled();
const headers = { Authorization: 'Bearer test-token' };
expect((await worker.fetch(request(headers), fakeEnv, createExecutionContext())).status).toBe(409);
expect(run).toHaveBeenCalledWith({ only: ['Schedules', 'Timeline'] });
run.mockResolvedValue({ ok: false });
expect((await worker.fetch(request(headers), fakeEnv, createExecutionContext())).status).toBe(502);
run.mockResolvedValue({ ok: true });
expect((await worker.fetch(request(headers), fakeEnv, createExecutionContext())).status).toBe(200);
});
});

View File

@@ -3,6 +3,7 @@ import { adminRequest } from './src/admin/routes.mjs';
import { verifyAccess } from './src/admin/access.mjs';
vi.mock('./src/admin/access.mjs', () => ({ verifyAccess: vi.fn() }));
afterEach(() => vi.resetAllMocks());
const url = 'https://admin.example.test';
const post = (mode = 'both', origin = url) =>
new Request(url + '/admin/api/run', {
@@ -13,7 +14,9 @@ const post = (mode = 'both', origin = url) =>
it('requires Access before exposing the panel, status or actions', async () => {
verifyAccess.mockResolvedValue(null);
const get = vi.fn();
for (const request of [
new Request(url + '/'),
new Request(url + '/admin/'),
@@ -21,30 +24,42 @@ it('requires Access before exposing the panel, status or actions', async () => {
post(),
])
expect((await adminRequest(request, {}, get)).status).toBe(401);
expect(get).not.toHaveBeenCalled();
});
it('rejects cross-origin requests and unknown modes before scheduling work', async () => {
verifyAccess.mockResolvedValue({ email: 'admin@example.test' });
const get = vi.fn();
expect((await adminRequest(post('both', 'https://other.test'), {}, get)).status).toBe(403);
expect((await adminRequest(post('force'), {}, get)).status).toBe(400);
expect(get).not.toHaveBeenCalled();
});
it('returns an accepted run immediately and reports overlap without starting another', async () => {
verifyAccess.mockResolvedValue({ email: 'admin@example.test' });
const startManual = vi.fn(async (mode) => ({
const startManual = vi.fn(async mode => ({
ok: true,
run: { id: 'one', mode, status: 'queued' },
}));
expect((await adminRequest(post('social'), {}, () => ({ startManual }))).status).toBe(202);
expect(startManual).toHaveBeenCalledWith('social');
startManual.mockResolvedValue({ ok: false, busy: true });
expect((await adminRequest(post(), {}, () => ({ startManual }))).status).toBe(409);
});
it.each(['/', '/admin/'])('serves the mobile panel at %s with no-store and a nonce-based content policy', async (path) => {
verifyAccess.mockResolvedValue({ email: 'admin@example.test' });
const response = await adminRequest(new Request(url + path), {}, vi.fn());
expect(response.headers.get('cache-control')).toBe('no-store');
expect(response.headers.get('content-security-policy')).toContain('frame-ancestors \'none\'');
expect(await response.text()).not.toContain('__NONCE__');
});
it.each(['/', '/admin/'])(
'serves the mobile panel at %s with no-store and a nonce-based content policy',
async path => {
verifyAccess.mockResolvedValue({ email: 'admin@example.test' });
const response = await adminRequest(new Request(url + path), {}, vi.fn());
expect(response.headers.get('cache-control')).toBe('no-store');
expect(response.headers.get('content-security-policy')).toContain('frame-ancestors \'none\'');
expect(await response.text()).not.toContain('__NONCE__');
},
);

View File

@@ -3,41 +3,94 @@ export const SESSIONS = { NA: 'na-session', EU: 'eu-session', JP: 'jp-session' }
const stage = (id, name) => ({ id, name, image: `/images/stage/${id}.png` });
const rotation = start => ({
id: start, start_time: start, end_time: start + 7200,
stage_a: stage('0', 'The Reef'), stage_b: stage('1', 'Musselforge Fitness'),
id: start,
start_time: start,
end_time: start + 7200,
stage_a: stage('0', 'The Reef'),
stage_b: stage('1', 'Musselforge Fitness'),
game_mode: { key: 'regular', name: 'Regular Battle' },
rule: { key: 'turf_war', name: 'Turf War', multiline_name: 'Turf\nWar' },
});
const gear = { kind: 'head', id: '1', name: 'Fake Hat', image: '/images/gear/1.png', brand: { id: '0', name: 'Brand', image: '/images/brand/0.png', frequent_skill: { id: '0', name: 'Skill', image: '/images/skill/0.png' } } };
const festival = id => ({ festival_id: id, names: { alpha_short: 'A', bravo_short: 'B' }, times: { start: 3600, end: 7200 }, images: { alpha: '/images/festival/a.png', bravo: '/images/festival/b.png', panel: '/images/festival/p.png' }, special_stage: stage('100', 'Shifty Station') });
const gear = {
kind: 'head',
id: '1',
name: 'Fake Hat',
image: '/images/gear/1.png',
brand: {
id: '0',
name: 'Brand',
image: '/images/brand/0.png',
frequent_skill: { id: '0', name: 'Skill', image: '/images/skill/0.png' },
},
};
const festival = id => ({
festival_id: id,
names: { alpha_short: 'A', bravo_short: 'B' },
times: { start: 3600, end: 7200 },
images: {
alpha: '/images/festival/a.png',
bravo: '/images/festival/b.png',
panel: '/images/festival/p.png',
},
special_stage: stage('100', 'Shifty Station'),
});
export const ROUTES = {
'/api/schedules': () => ({ regular: [rotation(3600)], gachi: [rotation(3600)], league: [rotation(3600)] }),
'/api/data/stages': () => ({ stages: [stage('0', 'The Reef'), stage('1', 'Musselforge Fitness')] }),
'/api/coop_schedules': () => ({ schedules: [{ start_time: 3600, end_time: 7200 }], details: [{ start_time: 3600, end_time: 7200, stage: { name: 'Spawning Grounds', image: '/images/coop_stage/a.png' }, weapons: [null] }] }),
'/api/timeline': () => ({ coop: { importance: 1, reward_gear: { available_time: 0, gear } }, weapon_availability: null }),
'/api/coop_schedules': () => ({
schedules: [{ start_time: 3600, end_time: 7200 }],
details: [
{
start_time: 3600,
end_time: 7200,
stage: { name: 'Spawning Grounds', image: '/images/coop_stage/a.png' },
weapons: [null],
},
],
}),
'/api/timeline': () => ({
coop: { importance: 1, reward_gear: { available_time: 0, gear } },
weapon_availability: null,
}),
'/api/festivals/active': () => ({ festivals: [festival(2)] }),
'/api/festivals/pasts': () => ({ festivals: [festival(1)], results: [{ festival_id: 1 }] }),
'/api/festivals/1/rankings': () => ({ rankings: [] }),
'/api/onlineshop/merchandises': () => ({ merchandises: [{ end_time: 7200, gear, skill: { id: '0', name: 'Skill', image: '/images/skill/0.png' } }] }),
'/api/onlineshop/merchandises': () => ({
merchandises: [{ end_time: 7200, gear, skill: { id: '0', name: 'Skill', image: '/images/skill/0.png' } }],
}),
};
/** Install on global fetch (e.g. vi.stubGlobal('fetch', fakeSplatNet())). Records every request. */
export function fakeSplatNet(routes = ROUTES) {
const requests = [];
const fetch = async (input, init = {}) => {
const url = new URL(input);
const headers = new Headers(init.headers);
requests.push({ path: url.pathname, language: headers.get('Accept-Language'), cookie: headers.get('Cookie') });
requests.push({
path: url.pathname,
language: headers.get('Accept-Language'),
cookie: headers.get('Cookie'),
});
if (url.pathname.startsWith('/images/'))
return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { headers: { 'content-type': 'image/png' } });
return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
headers: { 'content-type': 'image/png' },
});
const route = routes[url.pathname];
if (!route)
return new Response('not found', { status: 404 });
const result = await route();
return result instanceof Response ? result : Response.json(result);
};
fetch.requests = requests;
return fetch;
}

View File

@@ -3,10 +3,18 @@ import { env } from 'cloudflare:test';
import { convertToJpeg } from '../../src/app/screenshots/convert.worker.js';
it('converts a PNG through the local Images binding without changing its dimensions', async () => {
const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAD0lEQVQImWP4z8Dwn4EBAAj+Af/KOtJRAAAAAElFTkSuQmCC'), c => c.charCodeAt(0));
const png = Uint8Array.from(
atob(
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAD0lEQVQImWP4z8Dwn4EBAAj+Af/KOtJRAAAAAElFTkSuQmCC',
),
c => c.charCodeAt(0),
);
const jpeg = await convertToJpeg(png);
expect([...jpeg.slice(0, 3)]).toEqual([255, 216, 255]);
const info = await env.IMAGES.info(new Response(jpeg).body);
expect(info.width).toBe(2);
expect(info.height).toBe(1);
});
@@ -17,7 +25,14 @@ it('passes the original PNG bytes to Images and returns JPEG bytes', async () =>
let source;
const output = vi.fn(async () => ({ response: () => new Response(jpeg) }));
const transform = vi.fn(() => ({ output }));
const images = { input: stream => { source = stream; return { transform }; } };
const images = {
input: stream => {
source = stream;
return { transform };
},
};
expect(await convertToJpeg(png, images)).toEqual(jpeg);
expect(new Uint8Array(await new Response(source).arrayBuffer())).toEqual(png);
expect(transform).toHaveBeenCalledWith({ background: '#ffffff' });
@@ -25,6 +40,15 @@ it('passes the original PNG bytes to Images and returns JPEG bytes', async () =>
});
it('propagates Images failures to the posting pipeline', async () => {
const images = { input: () => ({ transform: () => ({ output: async () => { throw new Error('Images quota exceeded'); } }) }) };
const images = {
input: () => ({
transform: () => ({
output: async () => {
throw new Error('Images quota exceeded');
},
}),
}),
};
await expect(convertToJpeg(new Uint8Array([1]), images)).rejects.toThrow('Images quota exceeded');
});

View File

@@ -10,34 +10,59 @@ const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
describe('runPosters', () => {
let renders;
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];
let now = getTopOfCurrentHour();
let rotation = { ...ROUTES['/api/schedules']().regular[0], start_time: now, end_time: now + 7200 };
await env.ASSETS.put('data/schedules.json', JSON.stringify({ regular: [rotation], gachi: [rotation], league: [rotation] }));
await env.ASSETS.put('data/festivals.json', JSON.stringify({ na: { festivals: [], results: [] }, eu: { festivals: [], results: [] }, jp: { festivals: [], results: [] } }));
await env.ASSETS.put(
'data/schedules.json',
JSON.stringify({ regular: [rotation], gachi: [rotation], league: [rotation] }),
);
await env.ASSETS.put(
'data/festivals.json',
JSON.stringify({
na: { festivals: [], results: [] },
eu: { festivals: [], results: [] },
jp: { festivals: [], results: [] },
}),
);
await env.ASSETS.put('data/coop-schedules.json', JSON.stringify({ schedules: [], details: [] }));
await env.ASSETS.put('data/timeline.json', JSON.stringify({ coop: null, weapon_availability: null }));
await env.ASSETS.put('data/merchandises.json', JSON.stringify({ merchandises: [{ end_time: now + 3600, gear: { name: 'Hat' }, skill: { name: 'Skill' } }] }));
await env.ASSETS.put(
'data/merchandises.json',
JSON.stringify({
merchandises: [{ end_time: now + 3600, gear: { name: 'Hat' }, skill: { name: 'Skill' } }],
}),
);
await env.ASSETS.put('data/locale/en.json', JSON.stringify({}));
renders = [];
let splatnet = fakeSplatNet();
vi.stubGlobal('fetch', async (input, init) => {
let url = new URL(input);
if (url.hostname === 'example.test') {
let object = await env.ASSETS.get(url.pathname.slice(1));
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);
});
});
@@ -61,6 +86,7 @@ describe('runPosters', () => {
it('rejects a rendering site serving different data before making screenshots', async () => {
vi.stubGlobal('fetch', async () => Response.json({ stale: true }));
await expect(runPosters(env)).rejects.toThrow('Rendering site data does not match');
expect(renders).toEqual([]);
});

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { nextRunAt, HOUR_MS, GRACE_MS } from './src/schedule.mjs';
const HOUR = Date.UTC(2026, 8, 4, 14, 0, 0); // 14:00:00 UTC
const t = (seconds) => HOUR + seconds * 1000;
const t = seconds => HOUR + seconds * 1000;
describe('nextRunAt', () => {
it('targets :00:10 of the current hour when that is still ahead', () => {

View File

@@ -12,7 +12,11 @@ async function keys(bucket, prefix = '') {
describe('runUpdaters', () => {
let fetch;
beforeEach(() => { fetch = fakeSplatNet(); vi.stubGlobal('fetch', fetch); });
beforeEach(() => {
fetch = fakeSplatNet();
vi.stubGlobal('fetch', fetch);
});
afterEach(() => vi.unstubAllGlobals());
it('runs every updater against the R2 bindings and publishes the site data', async () => {
@@ -20,28 +24,61 @@ describe('runUpdaters', () => {
expect(summary.ok).toBe(true);
expect(summary.updaters.map(u => u.name)).toEqual([
'Original Gear', 'Schedules', 'Co-op Schedules', 'Timeline', 'Festivals NA', 'Festivals EU', 'Festivals JP', 'Merchandises',
'Original Gear',
'Schedules',
'Co-op Schedules',
'Timeline',
'Festivals NA',
'Festivals EU',
'Festivals JP',
'Merchandises',
]);
expect(await keys(env.ASSETS, 'data/')).toEqual(expect.arrayContaining([
'data/schedules.json', 'data/coop-schedules.json', 'data/coop-schedules.ics', 'data/timeline.json',
'data/festivals.json', 'data/festivals-na.ics', 'data/festivals-eu.ics', 'data/festivals-jp.ics',
'data/festivals/na-1-rankings.json', 'data/merchandises.json', 'data/locale/en.json', 'data/locale/ja.json',
]));
expect(await keys(env.ASSETS, 'data/')).toEqual(
expect.arrayContaining([
'data/schedules.json',
'data/coop-schedules.json',
'data/coop-schedules.ics',
'data/timeline.json',
'data/festivals.json',
'data/festivals-na.ics',
'data/festivals-eu.ics',
'data/festivals-jp.ics',
'data/festivals/na-1-rankings.json',
'data/merchandises.json',
'data/locale/en.json',
'data/locale/ja.json',
]),
);
expect(await keys(env.PRIVATE)).toEqual(['stages.json']);
let schedules = await (await env.ASSETS.get('data/schedules.json')).json();
expect(schedules.regular[0].stage_a.name).toBe('The Reef');
expect((await env.ASSETS.head('data/schedules.json')).httpMetadata.cacheControl).toContain('stale-while-revalidate');
expect(await keys(env.ASSETS, 'assets/splatnet/images/stage/')).toEqual(['assets/splatnet/images/stage/0.png', 'assets/splatnet/images/stage/1.png', 'assets/splatnet/images/stage/100.png']);
expect((await env.ASSETS.head('data/schedules.json')).httpMetadata.cacheControl).toContain(
'stale-while-revalidate',
);
expect(await keys(env.ASSETS, 'assets/splatnet/images/stage/')).toEqual([
'assets/splatnet/images/stage/0.png',
'assets/splatnet/images/stage/1.png',
'assets/splatnet/images/stage/100.png',
]);
let festivals = await (await env.ASSETS.get('data/festivals.json')).json();
expect(Object.keys(festivals).sort()).toEqual(['eu', 'jp', 'na']);
expect(fetch.requests.find(r => r.path === '/api/schedules').cookie).toBe('iksm_session=na-session');
});
it('can run a subset of updaters, and reports a failing one without stopping the rest', async () => {
await env.ASSETS.delete(['data/timeline.json', 'data/merchandises.json']);
vi.stubGlobal('fetch', fakeSplatNet({ '/api/timeline': () => new Response('down', { status: 503 }), '/api/onlineshop/merchandises': () => ({ merchandises: [] }) }));
vi.stubGlobal(
'fetch',
fakeSplatNet({
'/api/timeline': () => new Response('down', { status: 503 }),
'/api/onlineshop/merchandises': () => ({ merchandises: [] }),
}),
);
let summary = await runUpdaters(env, { only: ['Timeline', 'Merchandises'] });
expect(summary.ok).toBe(false);