mirror of
https://github.com/misenhower/splatoon2.ink.git
synced 2026-09-28 04:06:52 -05:00
Improve readability of test setup and assertions
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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()));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']]);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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' } } });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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__');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user