mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-13 14:16:19 +00:00
Pure-function unit tests for chunk-storage (BLOB-grow, orphan-row read, multi-chunk grouping) and cooldown-store (TTL math, INSERT cursor drain, GC sampling, release/refund) cover the bugs Phase 1 fixed. Integration suite boots a local Worker + real DO via wrangler unstable_dev and asserts cookie issuance, cooldown isolation across identities, Content-Length 411/413 guards, WS upgrade semantics, WS broadcast frame shape with monotonic seq, ping/pong, per-identity WS cap, and Origin allowlist. WS uses the ws package directly since unstable_dev's fetch strips CF's webSocket Response field. 156 tests pass in ~3.3s; verified stable across 3 consecutive runs.
136 lines
4.6 KiB
JavaScript
136 lines
4.6 KiB
JavaScript
import { unstable_dev } from 'wrangler';
|
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
import { WebSocket } from 'ws';
|
|
|
|
/**
|
|
* Boot a local Worker + Durable Object via miniflare (`wrangler unstable_dev`).
|
|
*
|
|
* Returns:
|
|
* - worker: the UnstableDevWorker instance
|
|
* - fetch: a bound fetch(url, init) that hits the local worker
|
|
* - close: async () => worker.stop()
|
|
*
|
|
* The Worker uses real DO SQLite storage, so reads/writes/broadcasts behave
|
|
* as in production. State persists across tests within one suite (one worker
|
|
* per setupDO call). Tests should use unique identities (random cookies) to
|
|
* avoid cooldown cross-talk.
|
|
*/
|
|
|
|
function ensureDistPlaceholder() {
|
|
// The wrangler.json `assets.directory` must exist or miniflare warns.
|
|
// Don't fail tests just because the user hasn't run `npm run build`.
|
|
if (!existsSync('dist')) {
|
|
mkdirSync('dist', { recursive: true });
|
|
writeFileSync('dist/index.html', '<!-- test placeholder -->');
|
|
}
|
|
}
|
|
|
|
export async function setupDO(overrides = {}) {
|
|
ensureDistPlaceholder();
|
|
const worker = await unstable_dev('src/worker.js', {
|
|
config: 'wrangler.json',
|
|
experimental: {
|
|
disableExperimentalWarning: true,
|
|
disableDevRegistry: true,
|
|
},
|
|
persist: false,
|
|
ip: '127.0.0.1',
|
|
...overrides,
|
|
});
|
|
return {
|
|
worker,
|
|
fetch: worker.fetch.bind(worker),
|
|
address: `${worker.address}:${worker.port}`,
|
|
close: () => worker.stop(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* POST /api/place helper. Computes Content-Length explicitly because the
|
|
* Worker's edge validation requires it.
|
|
*/
|
|
export async function placePixel(harness, { x, y, color, cookie }) {
|
|
const body = JSON.stringify({ pixels: [{ x, y, color }] });
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': String(new TextEncoder().encode(body).byteLength),
|
|
};
|
|
if (cookie) headers.Cookie = `rplace_id=${cookie}`;
|
|
const res = await harness.fetch('/api/place', { method: 'POST', headers, body });
|
|
const setCookie = res.headers.get('set-cookie');
|
|
return {
|
|
status: res.status,
|
|
setCookie,
|
|
json: res.headers.get('content-type')?.includes('json') ? await res.json() : null,
|
|
};
|
|
}
|
|
|
|
/** GET /api/canvas — returns { status, bytes, setCookie }. */
|
|
export async function fetchCanvas(harness, { cookie } = {}) {
|
|
const headers = {};
|
|
if (cookie) headers.Cookie = `rplace_id=${cookie}`;
|
|
const res = await harness.fetch('/api/canvas', { headers });
|
|
const bytes = res.status === 200 ? new Uint8Array(await res.arrayBuffer()) : null;
|
|
return {
|
|
status: res.status,
|
|
bytes,
|
|
setCookie: res.headers.get('set-cookie'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Open a WS connection against the running worker. `unstable_dev`'s fetch is
|
|
* undici-based and strips the CF-specific `.webSocket` field, so we connect
|
|
* with the `ws` package against the worker's HTTP port instead.
|
|
*
|
|
* Returns either `{ status: 101, ws }` once the open event fires, or
|
|
* `{ status, ws: null }` if the upgrade is rejected with a non-101 response.
|
|
*/
|
|
export function openWs(harness, { cookie, origin } = {}) {
|
|
const url = `ws://${harness.address}/api/ws`;
|
|
const headers = {};
|
|
if (cookie) headers.Cookie = `rplace_id=${cookie}`;
|
|
if (origin) headers.Origin = origin;
|
|
const ws = new WebSocket(url, { headers });
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
function done(value) {
|
|
if (settled) return;
|
|
settled = true;
|
|
ws.removeAllListeners('open');
|
|
ws.removeAllListeners('unexpected-response');
|
|
ws.removeAllListeners('error');
|
|
resolve(value);
|
|
}
|
|
ws.once('open', () => done({ status: 101, ws }));
|
|
ws.once('unexpected-response', (_req, res) => done({ status: res.statusCode, ws: null }));
|
|
ws.once('error', (err) => {
|
|
// Pull HTTP status out of common ws-library errors (e.g. "Unexpected
|
|
// server response: 403"). Fall back to -1 to surface unknown errors.
|
|
const match = String(err?.message || '').match(/(\d{3})/);
|
|
done({ status: match ? Number(match[1]) : -1, ws: null });
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Generate a UUID-shaped cookie value (matches the validator in get-user-id.js). */
|
|
export function randomCookie() {
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
/** Promise wrapper for the next WS message (works with `ws` package). */
|
|
export function nextMessage(ws, { timeoutMs = 5000 } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const t = setTimeout(() => {
|
|
ws.off('message', onMsg);
|
|
reject(new Error(`WS message timeout (${timeoutMs}ms)`));
|
|
}, timeoutMs);
|
|
function onMsg(data) {
|
|
clearTimeout(t);
|
|
ws.off('message', onMsg);
|
|
resolve(typeof data === 'string' ? data : data.toString('utf8'));
|
|
}
|
|
ws.on('message', onMsg);
|
|
});
|
|
}
|