mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-13 06:15:44 +00:00
fix: address ultrareview findings across backend and frontend
Backend: - rate-limiter: retryAfter now in seconds; ms-precision lu preserves fractional regen residue across calls (C1, C2) - redis-client: throw on Upstash 200-with-error envelope; redisRaw returns body.result (NH1) - constants: MAX_BATCH_SIZE = MAX_CREDITS = 256 (was 512 vs 256) - worker: content-length cap, gzip + s-maxage=10 on /api/canvas, broadcast via executionCtx.waitUntil with r.ok check (NC2, H4, H5) - canvas-storage: warn on truncated Upstash read instead of silent zero-pad (NH2) - get-user-id: SHA-256 (16 hex chars) replaces 32-bit string hash; missing cf-connecting-ip routes to anon:dev with warn (H1, H2); function is now async - canvas-room: log unclean WS closes and errors; defensive close on unexpected client message (NH4, N5) Frontend: - pixel buffer capped at MAX_BATCH_SIZE with toast (NC2) - Submit error UX: toast for 429/413/400/5xx/network; honor retryAfter (NC1) - committedColors allocated upfront so WS updates during initial fetch no longer null-deref (NC3) - handleWheel always renders even when zoom is clamped (C1, C2) - canvas-decoder throws on truncated input instead of reading past end with || 0 (C3) - WS reconnect refetches canvas to recover missed pixels (C4) - pixel-buffer Map cache for O(1) getColorAt/pixelCount (NH1) - cancel in-progress stroke on mode switch (NH3) - canvas load error overlay with Retry button (NH5) - DPR-aware canvas sizing (H1) - onMount cleanup is now sync (no leaked resize listener) (H2) Tests: - update for async getUserId, decoder bounds check, redis error format; add Upstash error-envelope coverage
This commit is contained in:
1 parent
8e1f8c4049
commit
a823f8527d
14 files changed
+418
-154
No files matched your search
@@ -1,16 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decodeCanvas, indicesToRgba } from '../../src/lib/canvas-decoder.js';
|
||||
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS, COLORS_RGBA } from '../../src/lib/constants.js';
|
||||
import { CANVAS_WIDTH, CANVAS_HEIGHT, BITS_PER_PIXEL, COLORS, COLORS_RGBA } from '../../src/lib/constants.js';
|
||||
|
||||
/** Encode color indices into 5-bit packed bytes (test helper, mirrors BITFIELD storage) */
|
||||
function encodeIndices(indices) {
|
||||
const totalBits = indices.length * 5;
|
||||
const bytes = new Uint8Array(Math.ceil(totalBits / 8));
|
||||
const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
|
||||
const EXPECTED_BYTES = Math.ceil((TOTAL_PIXELS * BITS_PER_PIXEL) / 8);
|
||||
|
||||
/** Encode color indices into 5-bit packed bytes (test helper, mirrors BITFIELD storage).
|
||||
* Returns a full-canvas-sized buffer (zero-padded tail) so decodeCanvas accepts it. */
|
||||
function encodeIndicesPadded(indices) {
|
||||
const bytes = new Uint8Array(EXPECTED_BYTES);
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
const bitPos = i * 5;
|
||||
const byteIndex = bitPos >> 3;
|
||||
const bitOffset = bitPos & 7;
|
||||
// Write 5-bit value across 1-2 bytes
|
||||
bytes[byteIndex] |= (indices[i] << (11 - bitOffset)) >> 8;
|
||||
if (bitOffset > 3) {
|
||||
bytes[byteIndex + 1] |= (indices[i] << (11 - bitOffset)) & 0xff;
|
||||
@@ -22,17 +24,22 @@ function encodeIndices(indices) {
|
||||
}
|
||||
|
||||
describe('decodeCanvas', () => {
|
||||
it('decodes empty buffer as all zeros', () => {
|
||||
const buffer = new ArrayBuffer(0);
|
||||
it('decodes a full zero-filled buffer as all zeros', () => {
|
||||
const buffer = new ArrayBuffer(EXPECTED_BYTES);
|
||||
const indices = decodeCanvas(buffer);
|
||||
expect(indices.length).toBe(CANVAS_WIDTH * CANVAS_HEIGHT);
|
||||
expect(indices.length).toBe(TOTAL_PIXELS);
|
||||
expect(indices.every((v) => v === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('throws on truncated buffer', () => {
|
||||
expect(() => decodeCanvas(new ArrayBuffer(0))).toThrow(/truncated/);
|
||||
expect(() => decodeCanvas(new ArrayBuffer(EXPECTED_BYTES - 1))).toThrow(/truncated/);
|
||||
});
|
||||
|
||||
it('decodes a single pixel', () => {
|
||||
// Color 15 at pixel 0: binary 01111 in first 5 bits
|
||||
// Byte 0: 0111_1000 = 0x78
|
||||
const bytes = new Uint8Array([0x78, 0]);
|
||||
// Color 15 at pixel 0: binary 01111 in first 5 bits → byte 0 = 0111_1000 = 0x78
|
||||
const bytes = new Uint8Array(EXPECTED_BYTES);
|
||||
bytes[0] = 0x78;
|
||||
const indices = decodeCanvas(bytes.buffer);
|
||||
expect(indices[0]).toBe(15);
|
||||
});
|
||||
@@ -40,7 +47,7 @@ describe('decodeCanvas', () => {
|
||||
it('round-trips all 32 color values', () => {
|
||||
const input = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) input[i] = i;
|
||||
const encoded = encodeIndices(input);
|
||||
const encoded = encodeIndicesPadded(input);
|
||||
const decoded = decodeCanvas(encoded.buffer);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
expect(decoded[i]).toBe(i);
|
||||
@@ -50,7 +57,7 @@ describe('decodeCanvas', () => {
|
||||
it('round-trips repeated color patterns', () => {
|
||||
const input = new Uint8Array(100);
|
||||
for (let i = 0; i < 100; i++) input[i] = i % 32;
|
||||
const encoded = encodeIndices(input);
|
||||
const encoded = encodeIndicesPadded(input);
|
||||
const decoded = decodeCanvas(encoded.buffer);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(decoded[i]).toBe(i % 32);
|
||||
@@ -59,7 +66,7 @@ describe('decodeCanvas', () => {
|
||||
|
||||
it('handles max color value (31) at various offsets', () => {
|
||||
const input = new Uint8Array(8).fill(31);
|
||||
const encoded = encodeIndices(input);
|
||||
const encoded = encodeIndicesPadded(input);
|
||||
const decoded = decodeCanvas(encoded.buffer);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
expect(decoded[i]).toBe(31);
|
||||
|
||||
@@ -9,27 +9,32 @@ function mockRequest(headers = {}) {
|
||||
}
|
||||
|
||||
describe('getUserId', () => {
|
||||
it('returns anon: prefix', () => {
|
||||
const id = getUserId(mockRequest({ 'cf-connecting-ip': '1.2.3.4' }));
|
||||
it('returns anon: prefix', async () => {
|
||||
const id = await getUserId(mockRequest({ 'cf-connecting-ip': '1.2.3.4' }));
|
||||
expect(id).toMatch(/^anon:/);
|
||||
});
|
||||
|
||||
it('returns deterministic ID for same IP', () => {
|
||||
const req1 = mockRequest({ 'cf-connecting-ip': '192.168.1.1' });
|
||||
const req2 = mockRequest({ 'cf-connecting-ip': '192.168.1.1' });
|
||||
expect(getUserId(req1)).toBe(getUserId(req2));
|
||||
it('returns deterministic ID for same IP', async () => {
|
||||
const id1 = await getUserId(mockRequest({ 'cf-connecting-ip': '192.168.1.1' }));
|
||||
const id2 = await getUserId(mockRequest({ 'cf-connecting-ip': '192.168.1.1' }));
|
||||
expect(id1).toBe(id2);
|
||||
});
|
||||
|
||||
it('returns different IDs for different IPs', () => {
|
||||
const id1 = getUserId(mockRequest({ 'cf-connecting-ip': '1.1.1.1' }));
|
||||
const id2 = getUserId(mockRequest({ 'cf-connecting-ip': '2.2.2.2' }));
|
||||
it('returns different IDs for different IPs', async () => {
|
||||
const id1 = await getUserId(mockRequest({ 'cf-connecting-ip': '1.1.1.1' }));
|
||||
const id2 = await getUserId(mockRequest({ 'cf-connecting-ip': '2.2.2.2' }));
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
it('falls back to 127.0.0.1 when header is missing', () => {
|
||||
const id = getUserId(mockRequest({}));
|
||||
expect(id).toMatch(/^anon:/);
|
||||
// Should be deterministic for missing header too
|
||||
expect(getUserId(mockRequest({}))).toBe(id);
|
||||
it('falls back to a shared dev bucket when header is missing', async () => {
|
||||
const id = await getUserId(mockRequest({}));
|
||||
expect(id).toBe('anon:dev');
|
||||
// Deterministic for missing header
|
||||
expect(await getUserId(mockRequest({}))).toBe(id);
|
||||
});
|
||||
|
||||
it('uses 16-hex-char (8-byte) suffix from SHA-256', async () => {
|
||||
const id = await getUserId(mockRequest({ 'cf-connecting-ip': '203.0.113.45' }));
|
||||
expect(id).toMatch(/^anon:[0-9a-f]{16}$/);
|
||||
});
|
||||
});
|
||||
@@ -32,14 +32,30 @@ describe('redisRaw', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
it('returns the response result field', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ result: 'PONG' }),
|
||||
});
|
||||
const result = await redisRaw(env, ['PING']);
|
||||
expect(result).toBe('PONG');
|
||||
});
|
||||
|
||||
it('throws on non-ok HTTP response', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: () => Promise.resolve('Unauthorized'),
|
||||
});
|
||||
await expect(redisRaw(env, ['PING'])).rejects.toThrow('Redis HTTP 401');
|
||||
});
|
||||
|
||||
await expect(redisRaw(env, ['PING'])).rejects.toThrow('Redis command failed: 401');
|
||||
it('throws on Upstash 200 with error envelope', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ error: 'ERR wrong number of arguments' }),
|
||||
});
|
||||
await expect(redisRaw(env, ['BITFIELD'])).rejects.toThrow(/Redis error.*wrong number of arguments/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,13 +98,20 @@ describe('redisRawBinary', () => {
|
||||
expect(result).toBe('AQID');
|
||||
});
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
it('throws on non-ok HTTP response', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve('Internal Error'),
|
||||
});
|
||||
await expect(redisRawBinary(env, ['GET', 'key'])).rejects.toThrow('Redis HTTP 500');
|
||||
});
|
||||
|
||||
await expect(redisRawBinary(env, ['GET', 'key'])).rejects.toThrow('Redis command failed: 500');
|
||||
it('throws on Upstash 200 with error envelope', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ error: 'ERR no such key' }),
|
||||
});
|
||||
await expect(redisRawBinary(env, ['GET', 'missing'])).rejects.toThrow(/Redis error.*no such key/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user