0.2.6-dev: merge claude/bw-l01 — host bandwidth layers 0 and 1 (no BGM download while the music is silent, WebSocket permessage-deflate, lossless WebP 玩法说明 pages, traffic counters, content-hashed asset URLs with immutable caching, brotli q5) on top of the 0.2.5 release; its note becomes DESIGN §31 in docs/history/0.2.6.md, CHANGELOG 0.2.6-dev, version 0.2.6-dev; golden: unchanged
This commit is contained in:
commit
3d5d6b380d
47 files changed
+2325
-136
No files matched your search
@@ -0,0 +1,356 @@
|
||||
// Content-hashed asset URLs (host bandwidth, layer 1; docs/ASSETS.md "The served manifests"):
|
||||
// * server/http/assetIndex.js — SHA-256 (16 hex) of every file under public/assets and public/fonts, cached by path +
|
||||
// size + mtime in a JSON file, never trusted for a file whose size or mtime changed; Spine models share one hash;
|
||||
// * server/http/assetViews.js — the served data/assets.json / data/local-assets.json with every art URL carrying its
|
||||
// hash segment, the committed files untouched;
|
||||
// * the ~hash routes of static.js / media.js — the segment stripped, `immutable` only when it names the file's bytes,
|
||||
// the current file with `no-cache` otherwise; content ETags; the plain URLs as before.
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { createAssetIndex, hash16 } from '../server/http/assetIndex.js';
|
||||
import { rewriteManifest, relOf } from '../server/http/assetViews.js';
|
||||
import { startServer } from '../server/index.js';
|
||||
|
||||
const sha16 = (buf) => createHash('sha256').update(buf).digest('hex').slice(0, 16);
|
||||
const IMMUTABLE = 'public, max-age=31536000, immutable';
|
||||
|
||||
function tree(files) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-hash-'));
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const p = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, content);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function get(port, p, headers = {}, method = 'GET') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ host: '127.0.0.1', port, path: p, headers, method, agent: false }, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
const until = async (fn, ms = 3000) => { for (const t0 = Date.now(); Date.now() - t0 < ms;) { if (await fn()) return true; await new Promise((r) => setTimeout(r, 20)); } return false; };
|
||||
|
||||
describe('the asset index', () => {
|
||||
test('hashes every file under assets/ and fonts/ (dot names skipped, a linked folder followed), persists them, rehashes only what changed', async () => {
|
||||
const a = randomBytes(2000);
|
||||
const b = Buffer.from('atlas text\n'.repeat(50));
|
||||
const dir = tree({
|
||||
'public/assets/char/a.png': a, 'public/assets/spine/x/x.atlas': b, 'public/assets/.hidden': 'h', 'public/assets/.git/obj': 'o',
|
||||
'public/fonts/f.woff2': 'font', 'public/js/app.js': 'not indexed', 'elsewhere/linked/l.png': 'linked',
|
||||
});
|
||||
fs.symlinkSync(path.join(dir, 'elsewhere/linked'), path.join(dir, 'public/assets/link'), 'dir');
|
||||
const cacheFile = path.join(dir, '.cache/asset-hashes.json');
|
||||
try {
|
||||
const idx = createAssetIndex({ publicDir: path.join(dir, 'public'), cacheFile, delayMs: 0 });
|
||||
assert.equal(idx.ready, false);
|
||||
idx.start();
|
||||
await idx.whenReady();
|
||||
assert.equal(idx.ready, true);
|
||||
const st = (rel) => fs.statSync(path.join(dir, 'public', rel));
|
||||
assert.equal(idx.hashOf('assets/char/a.png', st('assets/char/a.png')), sha16(a));
|
||||
assert.equal(idx.hashOf('assets/spine/x/x.atlas'), sha16(b));
|
||||
assert.equal(idx.hashOf('fonts/f.woff2'), sha16(Buffer.from('font')));
|
||||
assert.equal(idx.hashOf('assets/link/l.png'), sha16(Buffer.from('linked')), 'a linked folder is followed');
|
||||
assert.equal(idx.hashOf('assets/.hidden'), null, 'dot names are never served, never hashed');
|
||||
assert.equal(idx.hashOf('assets/.git/obj'), null);
|
||||
assert.equal(idx.hashOf('js/app.js'), null, 'only the asset folders');
|
||||
assert.equal(idx.size, 4);
|
||||
assert.equal(idx.stats().last.hashed, 4);
|
||||
assert.equal(idx.hashOf('assets/CHAR/A.PNG'), sha16(a), 'a name that differs only in case finds the file');
|
||||
// a stat that no longer matches: no hash (the caller serves the plain way)
|
||||
assert.equal(idx.hashOf('assets/char/a.png', { size: 1, mtimeMs: st('assets/char/a.png').mtimeMs }), null);
|
||||
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||
assert.equal(cached.version, 1);
|
||||
assert.deepEqual(cached.files['assets/char/a.png'], [a.length, st('assets/char/a.png').mtimeMs, sha16(a)]);
|
||||
idx.close();
|
||||
|
||||
// a restart: the cache answers for the unchanged files; a changed file is hashed again; a removed one is dropped
|
||||
const a2 = randomBytes(2100);
|
||||
fs.writeFileSync(path.join(dir, 'public/assets/char/a.png'), a2);
|
||||
fs.rmSync(path.join(dir, 'public/fonts/f.woff2'));
|
||||
const idx2 = createAssetIndex({ publicDir: path.join(dir, 'public'), cacheFile, delayMs: 0 });
|
||||
idx2.start();
|
||||
await idx2.whenReady();
|
||||
assert.equal(idx2.stats().last.hashed, 1, 'only the changed file was read');
|
||||
assert.equal(idx2.stats().last.reused, 2);
|
||||
assert.equal(idx2.hashOf('assets/char/a.png'), sha16(a2));
|
||||
assert.equal(idx2.hashOf('fonts/f.woff2'), null);
|
||||
assert.equal(JSON.parse(fs.readFileSync(cacheFile, 'utf8')).files['fonts/f.woff2'], undefined);
|
||||
idx2.close();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('models: one hash for a skeleton, its atlas and pages; urlHashOk accepts the own or the model hash, nothing else', async () => {
|
||||
const dir = tree({ 'public/assets/s/x.skel': 'skel', 'public/assets/s/x.atlas': 'atlas', 'public/assets/s/x.png': 'png', 'public/assets/o.png': 'other' });
|
||||
try {
|
||||
const idx = createAssetIndex({ publicDir: path.join(dir, 'public'), delayMs: 0 });
|
||||
idx.start();
|
||||
await idx.whenReady();
|
||||
const trio = ['assets/s/x.skel', 'assets/s/x.atlas', 'assets/s/x.png'];
|
||||
const m = idx.modelHash(trio);
|
||||
assert.equal(m, hash16(['skel', 'atlas', 'png'].map((s) => sha16(Buffer.from(s))).join('\n')));
|
||||
assert.equal(idx.modelHash([...trio, 'assets/missing.png']), null, 'a member the index does not know: no model hash');
|
||||
const st = (rel) => fs.statSync(path.join(dir, 'public', rel));
|
||||
assert.equal(idx.urlHashOk('assets/s/x.atlas', st('assets/s/x.atlas'), m), false, 'not before the lists are known');
|
||||
idx.setModels([trio]);
|
||||
for (const rel of trio) assert.equal(idx.urlHashOk(rel, st(rel), m), true, rel);
|
||||
assert.equal(idx.urlHashOk('assets/s/x.png', st('assets/s/x.png'), sha16(Buffer.from('png'))), true, 'the own hash too');
|
||||
assert.equal(idx.urlHashOk('assets/o.png', st('assets/o.png'), m), false, 'not a member');
|
||||
assert.equal(idx.urlHashOk('assets/s/x.png', st('assets/s/x.png'), '0000000000000000'), false);
|
||||
assert.equal(idx.urlHashOk('assets/s/x.png', { size: 99, mtimeMs: 1 }, m), false, 'changed since: never immutable');
|
||||
idx.close();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a served file the index no longer matches asks for a rescan (rate-limited); the new hash follows', async () => {
|
||||
const dir = tree({ 'public/assets/a.png': 'one' });
|
||||
try {
|
||||
let changes = 0;
|
||||
const idx = createAssetIndex({ publicDir: path.join(dir, 'public'), delayMs: 0, rescanGapMs: 50 });
|
||||
idx.onChange(() => changes++);
|
||||
idx.start();
|
||||
await idx.whenReady();
|
||||
assert.equal(changes, 1);
|
||||
const gen = idx.generation;
|
||||
fs.writeFileSync(path.join(dir, 'public/assets/a.png'), 'two!');
|
||||
assert.equal(idx.hashOf('assets/a.png', fs.statSync(path.join(dir, 'public/assets/a.png'))), null);
|
||||
idx.noteMiss('assets/a.png');
|
||||
idx.noteMiss('assets/a.png');
|
||||
assert.ok(await until(() => idx.generation > gen), 'rescanned');
|
||||
assert.equal(idx.hashOf('assets/a.png'), sha16(Buffer.from('two!')));
|
||||
assert.equal(changes, 2, 'one rescan for both misses');
|
||||
idx.close();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the served manifests (rewriteManifest)', () => {
|
||||
const H = { '/assets/char/a.png': 'aaaaaaaaaaaaaaaa', '/assets/audio/bgm/x.mp3': 'bbbbbbbbbbbbbbbb', '/fonts/f.woff2': 'cccccccccccccccc',
|
||||
'/assets/spine/x/x.skel': '1111111111111111', '/assets/spine/x/x.atlas': '2222222222222222', '/assets/spine/x/x.png': '3333333333333333',
|
||||
'/assets/local/spine/token/t/t.skel': '4444444444444444', '/assets/local/spine/token/t/t.atlas': '5555555555555555',
|
||||
'/assets/local/spine/token/t/t.png': '6666666666666666', '/assets/local/ui/battle/b.png': '7777777777777777' };
|
||||
const index = {
|
||||
hashOf: (rel) => H[`/${rel}`] || null,
|
||||
modelHash: (rels) => (rels.every((r) => H[`/${r}`]) ? hash16(rels.map((r) => H[`/${r}`]).join('\n')) : null),
|
||||
};
|
||||
|
||||
test('data/assets.json: own hashes, one model hash per Spine object, unknown files and names left alone, the input untouched', () => {
|
||||
const doc = {
|
||||
version: 1, hash: 'a1b2c3d4e5f6', generator: 'tools/fetch-assets.mjs',
|
||||
chars: { c: { avatar: '/assets/char/a.png', portrait: '/assets/char/missing.png',
|
||||
spine: { front: { skel: '/assets/spine/x/x.skel', atlas: '/assets/spine/x/x.atlas', textures: ['/assets/spine/x/x.png'], pma: false, anims: { idle: 'Idle' } } } } },
|
||||
enemies: { e: { icon: '/assets/char/a.png', spineLocal: { group: 'spine/enemy/e', skel: 'e.skel', atlas: 'e.atlas', textures: ['e.png'] } } },
|
||||
audio: { bgm: { lobby: { loop: '/assets/audio/bgm/x.mp3' } } },
|
||||
fonts: { css: '/fonts/fonts.css', faces: { f: { woff2: '/fonts/f.woff2', family: 'F' } } },
|
||||
};
|
||||
const copy = JSON.parse(JSON.stringify(doc));
|
||||
const r = rewriteManifest(doc, 'assets.json', index);
|
||||
assert.deepEqual(doc, copy, 'the committed manifest object is not changed');
|
||||
const m = index.modelHash(['assets/spine/x/x.skel', 'assets/spine/x/x.atlas', 'assets/spine/x/x.png']);
|
||||
assert.equal(r.doc.chars.c.avatar, '/assets/~aaaaaaaaaaaaaaaa/char/a.png');
|
||||
assert.equal(r.doc.chars.c.portrait, '/assets/char/missing.png', 'no hash: the plain URL');
|
||||
assert.deepEqual(r.doc.chars.c.spine.front, { skel: `/assets/~${m}/spine/x/x.skel`, atlas: `/assets/~${m}/spine/x/x.atlas`, textures: [`/assets/~${m}/spine/x/x.png`], pma: false, anims: { idle: 'Idle' } });
|
||||
assert.deepEqual(r.doc.enemies.e.spineLocal, copy.enemies.e.spineLocal, 'spineLocal holds names, not URLs');
|
||||
assert.equal(r.doc.audio.bgm.lobby.loop, '/assets/~bbbbbbbbbbbbbbbb/audio/bgm/x.mp3', 'audio keeps /assets/…/audio/….mp3 (media.js makes it /media/~hash/…)');
|
||||
assert.equal(r.doc.fonts.faces.f.woff2, '/fonts/~cccccccccccccccc/f.woff2');
|
||||
assert.equal(r.doc.fonts.css, '/fonts/fonts.css');
|
||||
assert.equal(r.doc.hash, 'a1b2c3d4e5f6');
|
||||
assert.deepEqual(r.models, [['assets/spine/x/x.skel', 'assets/spine/x/x.atlas', 'assets/spine/x/x.png']]);
|
||||
assert.equal(r.hashed, 7);
|
||||
assert.equal(r.plain, 2);
|
||||
});
|
||||
|
||||
test('data/local-assets.json: a spine/ group is one model, other groups own hashes', () => {
|
||||
const doc = { version: 1, source: 'local-client', count: 4, groups: {
|
||||
'spine/token/t': { 't.atlas': { path: '/assets/local/spine/token/t/t.atlas', kind: 'TextAsset' }, 't.png': { path: '/assets/local/spine/token/t/t.png', w: 4, h: 4 }, 't.skel': { path: '/assets/local/spine/token/t/t.skel' } },
|
||||
'ui/battle': { b: { path: '/assets/local/ui/battle/b.png', w: 2, h: 2, kind: 'Sprite' } },
|
||||
} };
|
||||
const r = rewriteManifest(doc, 'local-assets.json', index);
|
||||
const m = index.modelHash(['assets/local/spine/token/t/t.atlas', 'assets/local/spine/token/t/t.png', 'assets/local/spine/token/t/t.skel']);
|
||||
assert.ok(m);
|
||||
for (const [n, e] of Object.entries(r.doc.groups['spine/token/t'])) assert.equal(e.path, doc.groups['spine/token/t'][n].path.replace('/assets/', `/assets/~${m}/`), n);
|
||||
assert.equal(r.doc.groups['spine/token/t']['t.png'].w, 4);
|
||||
assert.deepEqual(r.doc.groups['ui/battle'].b, { path: '/assets/~7777777777777777/local/ui/battle/b.png', w: 2, h: 2, kind: 'Sprite' });
|
||||
assert.deepEqual([r.doc.version, r.doc.source, r.doc.count], [1, 'local-client', 4]);
|
||||
assert.equal(r.models.length, 1);
|
||||
});
|
||||
|
||||
test('relOf: URL path → index path, refusing what the static server refuses', () => {
|
||||
assert.equal(relOf('/assets/a%20b.png'), 'assets/a b.png');
|
||||
assert.equal(relOf('/fonts/f.woff2'), 'fonts/f.woff2');
|
||||
for (const u of ['/data/x.json', '/assets/../x', '/assets//x', '/assets/x?v=1', '/assets/%E0%A4%A', 'assets/x', null]) assert.equal(relOf(u), null, String(u));
|
||||
});
|
||||
});
|
||||
|
||||
describe('content-hashed URLs through a real server', () => {
|
||||
let srv;
|
||||
let dir;
|
||||
const png = randomBytes(3000);
|
||||
const mp3 = randomBytes(6000);
|
||||
const skel = Buffer.from('skel-binary'.repeat(80));
|
||||
const atlas = Buffer.from('x.png\nsize: 64,64\n'.repeat(30));
|
||||
const page = randomBytes(1500);
|
||||
const manifest = {
|
||||
version: 1, hash: 'abcdefabcdef',
|
||||
chars: { c: { avatar: '/assets/char/a.png', spine: { front: { skel: '/assets/spine/x/x.skel', atlas: '/assets/spine/x/x.atlas', textures: ['/assets/spine/x/x.png'], anims: { idle: 'Idle' } } } } },
|
||||
audio: { bgm: { lobby: { loop: '/assets/audio/bgm/lobby.mp3' } } },
|
||||
fonts: { css: '/fonts/fonts.css', faces: { f: { woff2: '/fonts/f.woff2' } } },
|
||||
stats: { note: 'a manifest large enough to be compressed '.repeat(40) },
|
||||
};
|
||||
const manifestText = JSON.stringify(manifest, null, 2);
|
||||
|
||||
before(async () => {
|
||||
dir = tree({
|
||||
'secret.txt': 'TOP-SECRET-CONTENT',
|
||||
'public/index.html': '<p>hi</p>', 'public/assets/char/a.png': png, 'public/assets/audio/bgm/lobby.mp3': mp3,
|
||||
'public/assets/spine/x/x.skel': skel, 'public/assets/spine/x/x.atlas': atlas, 'public/assets/spine/x/x.png': page,
|
||||
'public/assets/spine/x/tiles.json': '{"t":1}', 'public/fonts/f.woff2': randomBytes(500), 'public/fonts/fonts.css': '@font-face{}',
|
||||
'data/assets.json': manifestText, 'data/config.json': '{}', 'shared/x.js': 'export const y = 2;',
|
||||
});
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, publicDir: path.join(dir, 'public'), dataDir: path.join(dir, 'data'),
|
||||
sharedDir: path.join(dir, 'shared'), assetIndex: { delayMs: 0 } });
|
||||
await srv.assetIndex.whenReady();
|
||||
});
|
||||
after(async () => {
|
||||
await srv?.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const view = async () => JSON.parse((await get(srv.port, '/data/assets.json')).body);
|
||||
|
||||
test('/data/assets.json is served with hashed URLs (no-cache, its own ETag, 304, gzip, HEAD); the committed file stays as it was', async () => {
|
||||
const r = await get(srv.port, '/data/assets.json');
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.headers['content-type'], 'application/json; charset=utf-8');
|
||||
assert.equal(r.headers['cache-control'], 'no-cache');
|
||||
assert.match(r.headers.etag, /^"[0-9a-f]{16}"$/);
|
||||
const v = JSON.parse(r.body);
|
||||
assert.equal(v.chars.c.avatar, `/assets/~${sha16(png)}/char/a.png`);
|
||||
assert.equal(v.audio.bgm.lobby.loop, `/assets/~${sha16(mp3)}/audio/bgm/lobby.mp3`);
|
||||
assert.match(v.fonts.faces.f.woff2, /^\/fonts\/~[0-9a-f]{16}\/f\.woff2$/);
|
||||
const m = hash16([skel, atlas, page].map(sha16).join('\n'));
|
||||
assert.deepEqual([v.chars.c.spine.front.skel, v.chars.c.spine.front.atlas, v.chars.c.spine.front.textures[0]],
|
||||
[`/assets/~${m}/spine/x/x.skel`, `/assets/~${m}/spine/x/x.atlas`, `/assets/~${m}/spine/x/x.png`]);
|
||||
assert.equal((await get(srv.port, '/data/assets.json', { 'if-none-match': r.headers.etag })).status, 304);
|
||||
const gz = await get(srv.port, '/data/assets.json', { 'accept-encoding': 'gzip' });
|
||||
assert.equal(gz.headers['content-encoding'], 'gzip');
|
||||
assert.notEqual(gz.headers.etag, r.headers.etag);
|
||||
const head = await get(srv.port, '/data/assets.json', {}, 'HEAD');
|
||||
assert.equal(head.status, 200);
|
||||
assert.equal(head.body.length, 0);
|
||||
assert.equal(fs.readFileSync(path.join(dir, 'data/assets.json'), 'utf8'), manifestText, 'never written');
|
||||
assert.equal((await get(srv.port, '/data/local-assets.json')).status, 200, 'an absent local manifest is still the empty stand-in');
|
||||
assert.deepEqual(JSON.parse((await get(srv.port, '/data/local-assets.json')).body).groups, {});
|
||||
});
|
||||
|
||||
test('a hashed URL: immutable with a content ETag when the hash names the file; the current file with no-cache otherwise; plain URLs as before', async () => {
|
||||
const v = await view();
|
||||
const ok = await get(srv.port, v.chars.c.avatar);
|
||||
assert.equal(ok.status, 200);
|
||||
assert.deepEqual(ok.body, png);
|
||||
assert.equal(ok.headers['cache-control'], IMMUTABLE);
|
||||
assert.equal(ok.headers.etag, `"${sha16(png)}"`);
|
||||
const old = await get(srv.port, '/assets/~0000000000000000/char/a.png');
|
||||
assert.deepEqual(old.body, png, 'an old page still gets the file');
|
||||
assert.equal(old.headers['cache-control'], 'no-cache');
|
||||
const plain = await get(srv.port, '/assets/char/a.png');
|
||||
assert.equal(plain.headers['cache-control'], 'public, max-age=86400');
|
||||
assert.equal(plain.headers.etag, `"${sha16(png)}"`, 'a content ETag on the plain URL too (an mtime change alone is a 304)');
|
||||
assert.equal((await get(srv.port, '/assets/char/a.png', { 'if-none-match': `"${sha16(png)}"` })).status, 304);
|
||||
const font = await get(srv.port, v.fonts.faces.f.woff2);
|
||||
assert.equal(font.headers['cache-control'], IMMUTABLE);
|
||||
const query = await get(srv.port, '/assets/char/a.png?v=1');
|
||||
assert.match(query.headers['cache-control'], /immutable/, 'the old ?v= rule is kept');
|
||||
});
|
||||
|
||||
test('a Spine model: skeleton, atlas, page and the atlas derived from the skeleton URL are immutable under the model hash; a file derived beside them is no-cache', async () => {
|
||||
const sp = (await view()).chars.c.spine.front;
|
||||
for (const u of [sp.skel, sp.atlas, sp.textures[0], sp.skel.replace(/\.skel$/, '.atlas')]) {
|
||||
const r = await get(srv.port, u);
|
||||
assert.equal(r.status, 200, u);
|
||||
assert.equal(r.headers['cache-control'], IMMUTABLE, u);
|
||||
}
|
||||
const sk = await get(srv.port, sp.skel);
|
||||
assert.deepEqual(sk.body, skel);
|
||||
assert.equal(sk.headers.etag, `"${sha16(skel)}"`, 'the ETag is the file\'s own content hash');
|
||||
const tiles = await get(srv.port, sp.skel.replace(/[^/]+$/, 'tiles.json'));
|
||||
assert.equal(tiles.status, 200);
|
||||
assert.equal(tiles.headers['cache-control'], 'no-cache', 'not a member of the model');
|
||||
});
|
||||
|
||||
test('audio: /media/~hash/… resolves like /media/…, immutable when the hash matches, ranges and ETags as before', async () => {
|
||||
const loop = (await view()).audio.bgm.lobby.loop;
|
||||
const media = loop.replace(/^\/assets\/(~[0-9a-f]{16})\/audio\/(.+)\.mp3$/, '/media/$1/$2');
|
||||
assert.match(media, /^\/media\/~[0-9a-f]{16}\/bgm\/lobby$/);
|
||||
const r = await get(srv.port, media);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.headers['content-type'], 'audio/mpeg');
|
||||
assert.equal(r.headers['cache-control'], IMMUTABLE);
|
||||
assert.equal(r.headers.etag, `"${sha16(mp3)}"`);
|
||||
assert.deepEqual(r.body, mp3);
|
||||
const range = await get(srv.port, media, { range: 'bytes=0-9' });
|
||||
assert.equal(range.status, 206);
|
||||
assert.deepEqual(range.body, mp3.subarray(0, 10));
|
||||
assert.equal((await get(srv.port, loop)).headers['cache-control'], IMMUTABLE, 'the .mp3 fallback URL too');
|
||||
assert.equal((await get(srv.port, '/media/~0000000000000000/bgm/lobby')).headers['cache-control'], 'no-cache');
|
||||
assert.equal((await get(srv.port, '/media/bgm/lobby')).headers['cache-control'], 'public, max-age=86400');
|
||||
});
|
||||
|
||||
test('the segment never opens a way out of the served folders', async () => {
|
||||
const h = sha16(png);
|
||||
for (const p of [`/assets/~${h}/../../secret.txt`, `/assets/~${h}/%2e%2e/%2e%2e/secret.txt`, `/assets/~${h}/..%2f..%2fsecret.txt`,
|
||||
`/media/~${h}/..%2f..%2f..%2fsecret.txt`, `/fonts/~${h}/../../secret.txt`, `/assets/~${h}/.hidden`, `/assets/~${h}`, '/assets/~/char/a.png']) {
|
||||
const r = await get(srv.port, p);
|
||||
assert.ok([301, 400, 403, 404].includes(r.status), `${p} → ${r.status}`);
|
||||
assert.ok(!r.body.toString().includes('TOP-SECRET'), `${p} leaked`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a file replaced on disk: its old hashed URL turns no-cache at once, and after the rescan the view names the new bytes', async () => {
|
||||
const before = (await view()).chars.c.avatar;
|
||||
const next = randomBytes(3100);
|
||||
fs.writeFileSync(path.join(dir, 'public/assets/char/a.png'), next);
|
||||
const stale = await get(srv.port, before);
|
||||
assert.deepEqual(stale.body, next, 'the current file');
|
||||
assert.equal(stale.headers['cache-control'], 'no-cache', 'the index no longer vouches for these bytes');
|
||||
// the miss asked for a rescan (RESCAN_GAP_MS after the last build) — trigger it now instead of waiting
|
||||
await srv.assetIndex.refresh();
|
||||
assert.ok(await until(async () => (await view()).chars.c.avatar === `/assets/~${sha16(next)}/char/a.png`), 'the view follows');
|
||||
assert.equal((await get(srv.port, `/assets/~${sha16(next)}/char/a.png`)).headers['cache-control'], IMMUTABLE);
|
||||
});
|
||||
});
|
||||
|
||||
test('without the index (assetIndex: false) the manifest goes out as committed and a hashed URL is served no-cache', async () => {
|
||||
const dir = tree({ 'public/assets/a.png': 'png', 'data/assets.json': JSON.stringify({ chars: { c: { avatar: '/assets/a.png' } } }) });
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, publicDir: path.join(dir, 'public'), dataDir: path.join(dir, 'data'), assetIndex: false });
|
||||
try {
|
||||
assert.equal(srv.assetIndex, null);
|
||||
assert.equal(JSON.parse((await get(srv.port, '/data/assets.json')).body).chars.c.avatar, '/assets/a.png');
|
||||
const r = await get(srv.port, `/assets/~${sha16(Buffer.from('png'))}/a.png`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.headers['cache-control'], 'no-cache');
|
||||
assert.match(r.headers.etag, /^"[0-9a-f]+-[0-9a-f]+"$/, 'size-mtime ETag');
|
||||
} finally {
|
||||
await srv.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// shared/assetPaths.js: the content-hash segment of asset URLs (split / insert / strip) and the traffic categories the
|
||||
// server's counters and the browser's diagnostics share (host bandwidth, layers 0 and 1).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { splitHashedPath, hashedPath, unhashedUrl, trafficCategory, TRAFFIC_CATEGORIES, ASSET_HASH_HEX, HASH_SEGMENT_RE } from '../shared/assetPaths.js';
|
||||
|
||||
const H = '0123456789abcdef';
|
||||
|
||||
test('the hash segment: split off, inserted after the root, removed again', () => {
|
||||
assert.equal(ASSET_HASH_HEX, 16);
|
||||
assert.ok(HASH_SEGMENT_RE.test(`~${H}`));
|
||||
assert.deepEqual(splitHashedPath(`/assets/~${H}/char/avatar/a.png`), { hash: H, path: '/assets/char/avatar/a.png' });
|
||||
assert.deepEqual(splitHashedPath(`/fonts/~${H}/f.woff2`), { hash: H, path: '/fonts/f.woff2' });
|
||||
assert.deepEqual(splitHashedPath(`/media/~${H}/bgm/a`), { hash: H, path: '/media/bgm/a' });
|
||||
for (const p of ['/assets/char/a.png', `/assets/~${H.toUpperCase()}/a.png`, `/assets/~${H}0/a.png`, `/assets/~${H}`, `/data/~${H}/x.json`,
|
||||
`/assets/x/~${H}/a.png`, '/assets/~zz/a.png', '']) {
|
||||
assert.deepEqual(splitHashedPath(p), { hash: null, path: p }, p);
|
||||
}
|
||||
assert.equal(hashedPath('/assets/char/a.png', H), `/assets/~${H}/char/a.png`);
|
||||
assert.equal(hashedPath('/media/bgm/a', H), `/media/~${H}/bgm/a`);
|
||||
assert.equal(hashedPath('/fonts/f.woff2', H), `/fonts/~${H}/f.woff2`);
|
||||
assert.equal(hashedPath('/data/assets.json', H), '/data/assets.json', 'not a hashed root');
|
||||
assert.equal(hashedPath('/assets/a.png', null), '/assets/a.png');
|
||||
assert.equal(hashedPath('/assets/a.png', 'nothex'), '/assets/a.png');
|
||||
assert.equal(hashedPath(`/assets/~${H}/a.png`, 'fedcba9876543210'), `/assets/~${H}/a.png`, 'never two segments');
|
||||
assert.equal(hashedPath('/assets/', H), '/assets/');
|
||||
assert.equal(unhashedUrl(`/assets/~${H}/audio/sfx/b_char_dead.mp3`), '/assets/audio/sfx/b_char_dead.mp3');
|
||||
assert.equal(unhashedUrl(null), null);
|
||||
});
|
||||
|
||||
test('traffic categories: audio, assets, code, data, other — the hash segment does not matter', () => {
|
||||
assert.deepEqual([...TRAFFIC_CATEGORIES], ['assets', 'audio', 'code', 'data', 'other']);
|
||||
const cases = {
|
||||
'/media/bgm/act1': 'audio', [`/media/~${H}/bgm/act1`]: 'audio', '/assets/audio/bgm/a.mp3': 'audio', [`/assets/~${H}/audio/sfx/a.mp3`]: 'audio',
|
||||
'/assets/char/avatar/a.png': 'assets', [`/assets/~${H}/spine/op/x/front/x.skel`]: 'assets', '/fonts/f.woff2': 'assets', '/icons/app.svg': 'assets',
|
||||
'/packs/foo/art/a.png': 'assets', '/packs/index.json': 'data', '/data/assets.json': 'data', '/data/i18n/en.json': 'data', '/i18n/en.json': 'data',
|
||||
'/manifest.json': 'data', '/': 'code', '/index.html': 'code', '/js/main.js': 'code', '/css/theme.css': 'code', '/vendor/pixi.min.js': 'code',
|
||||
'/sim/Battle.js': 'code', '/shared/protocol.js': 'code', '/data.js': 'code', '/healthz': 'other', '/favicon.ico': 'other',
|
||||
};
|
||||
for (const [p, cat] of Object.entries(cases)) assert.equal(trafficCategory(p), cat, p);
|
||||
});
|
||||
@@ -111,6 +111,11 @@ test('#4 audio classes (official ON_UNIT_DEAD/BORN defaults): only operators pla
|
||||
assert.equal(deathSfxUrl(manifest, bomb, { consumed: true }), null, 'used up ⇒ silent');
|
||||
assert.equal(deathSfxUrl(manifest, mapChar), SFX.battle.charDie, 'band map characters are operators');
|
||||
assert.equal(deathSfxUrl(manifest, crate), TOKEN_DEAD, 'act crate trap_1105 ⇒ b_char_tokendead');
|
||||
// a served manifest's charDie carries charDie's content hash (host bandwidth layer 1): the sound derived beside it
|
||||
// names another file, so it goes out without the segment (served plainly, cached a day) instead of a wrong hash
|
||||
const hashed = { ...manifest, audio: { ...manifest.audio, sfx: { ...SFX, battle: { ...SFX.battle, charDie: SFX.battle.charDie.replace(/^\/assets\//, '/assets/~0123456789abcdef/') } } } };
|
||||
assert.equal(deathSfxUrl(hashed, op), hashed.audio.sfx.battle.charDie);
|
||||
assert.equal(deathSfxUrl(hashed, tok), TOKEN_DEAD, 'b_char_tokendead without charDie\'s hash');
|
||||
assert.equal(deathSfxUrl(manifest, enemy), SFX.battle.enemyDie);
|
||||
assert.equal(deathSfxUrl(manifest, boss), SFX.battle.enemyDieHeavy);
|
||||
assert.equal(deathSfxUrl(manifest, op, { reason: 'retreat' }), null, 'a retreat is no knock-down');
|
||||
|
||||
+51
-1
@@ -5,7 +5,7 @@ import { test, describe, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
recordError, errorLog, errorCount, clearErrorLog, messageOf, stackOf, stripHosts, redact, redactValues, secretsOf, buildDiagnostics,
|
||||
setBattleSource, currentBattle, LOG_MAX, STACK_LINES,
|
||||
setBattleSource, currentBattle, LOG_MAX, STACK_LINES, recordResourceEntry, trafficSummary, trafficLine, clearTraffic, installTrafficMeter,
|
||||
} from '../public/js/diag.js';
|
||||
import { APP_VERSION } from '../shared/constants.js';
|
||||
|
||||
@@ -184,3 +184,53 @@ describe('the report', () => {
|
||||
setBattleSource(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the page\'s traffic (host bandwidth layer 0)', () => {
|
||||
beforeEach(() => clearTraffic());
|
||||
const ORIGIN = 'http://192.168.1.5:3000';
|
||||
|
||||
test('Resource Timing entries sum by category: bytes on the wire, a cache hit counted as cached, other sites apart', () => {
|
||||
recordResourceEntry({ name: `${ORIGIN}/assets/~0123456789abcdef/spine/op/x/front/x.skel`, transferSize: 120000, decodedBodySize: 400000 }, ORIGIN);
|
||||
recordResourceEntry({ name: `${ORIGIN}/assets/char/avatar/a.png`, transferSize: 0, decodedBodySize: 50000 }, ORIGIN);
|
||||
recordResourceEntry({ name: `${ORIGIN}/media/~0123456789abcdef/bgm/m_bat`, transferSize: 2000000, decodedBodySize: 2000000 }, ORIGIN);
|
||||
recordResourceEntry({ name: `${ORIGIN}/js/main.js`, transferSize: 9000, decodedBodySize: 30000 }, ORIGIN);
|
||||
recordResourceEntry({ name: `${ORIGIN}/data/assets.json`, transferSize: 300, decodedBodySize: 1300000 }, ORIGIN);
|
||||
recordResourceEntry({ name: 'https://fonts.gstatic.com/s/x.woff2', transferSize: 50000 }, ORIGIN);
|
||||
recordResourceEntry({ name: 'data:image/png;base64,AAAA', transferSize: 0 }, ORIGIN);
|
||||
recordResourceEntry({ name: 'blob:http://192.168.1.5:3000/1234', transferSize: 0 }, ORIGIN);
|
||||
recordResourceEntry(null, ORIGIN);
|
||||
const t = trafficSummary();
|
||||
assert.deepEqual(t.assets, { requests: 2, bytes: 120000, cached: 1 });
|
||||
assert.deepEqual(t.audio, { requests: 1, bytes: 2000000, cached: 0 });
|
||||
assert.deepEqual(t.code, { requests: 1, bytes: 9000, cached: 0 });
|
||||
assert.deepEqual(t.data, { requests: 1, bytes: 300, cached: 0 }, 'a 304 costs its headers only');
|
||||
assert.deepEqual(t.external, { requests: 1, bytes: 50000, cached: 0 });
|
||||
assert.deepEqual(t.total, { requests: 5, bytes: 2129300, cached: 1 }, 'the host total leaves other sites out');
|
||||
assert.deepEqual(Object.keys(t), ['assets', 'audio', 'code', 'data', 'external', 'total']);
|
||||
assert.equal(trafficLine(t), 'assets 120 KB (2, 1 cached) | audio 2.0 MB (1) | code 9 KB (1) | data 300 B (1) | external 50 KB (1) | host total 2.1 MB (5)');
|
||||
});
|
||||
|
||||
test('the report carries the traffic line; nothing recorded → no line', () => {
|
||||
recordResourceEntry({ name: `${ORIGIN}/js/main.js`, transferSize: 1500 }, ORIGIN);
|
||||
const text = buildDiagnostics({ state: {}, now: 0, pageStart: 0, traffic: trafficSummary() });
|
||||
assert.match(text, /\ntraffic: code 2 KB \(1\) \| host total 2 KB \(1\)\n/);
|
||||
clearTraffic();
|
||||
assert.ok(!/traffic:/.test(buildDiagnostics({ state: {}, now: 0, pageStart: 0, traffic: trafficSummary() })));
|
||||
assert.ok(!/traffic:/.test(buildDiagnostics({ state: {}, now: 0, pageStart: 0 })));
|
||||
});
|
||||
|
||||
test('installTrafficMeter reads the navigation entry and observes the resources (buffered), once; false without the API', () => {
|
||||
let observed = null;
|
||||
class PO {
|
||||
constructor(fn) { this.fn = fn; }
|
||||
observe(o) { observed = o; this.fn({ getEntries: () => [{ name: `${ORIGIN}/vendor/pixi.min.js`, transferSize: 140000 }] }); }
|
||||
}
|
||||
const win = { PerformanceObserver: PO, location: { origin: ORIGIN }, performance: { getEntriesByType: (t) => (t === 'navigation' ? [{ name: `${ORIGIN}/`, transferSize: 7000 }] : []) } };
|
||||
assert.equal(installTrafficMeter({}), false);
|
||||
assert.equal(installTrafficMeter(win), true);
|
||||
assert.deepEqual(observed, { type: 'resource', buffered: true });
|
||||
assert.deepEqual(trafficSummary().code, { requests: 2, bytes: 147000, cached: 0 });
|
||||
assert.equal(installTrafficMeter(win), true, 'already installed');
|
||||
assert.equal(trafficSummary().code.requests, 2, 'not twice');
|
||||
});
|
||||
});
|
||||
@@ -109,6 +109,13 @@ export async function startRealServer(opts = {}) {
|
||||
if (Date.now() - t0 > 20000) { child.kill('SIGKILL'); throw new Error(`server did not start:\n${logs.join('\n')}`); }
|
||||
await sleep(150);
|
||||
}
|
||||
// the content-hash index (server/http/assetIndex.js, built ~1 s after the start): wait for it, so a page loads the
|
||||
// served manifest's /assets/~<hash>/… URLs like a player's would (none: SP_ASSET_INDEX=0); bounded, never fatal
|
||||
for (const t1 = Date.now(); Date.now() - t1 < 20000;) {
|
||||
const h = await fetch(`${base}/healthz?traffic=1`).then((r) => r.json(), () => null);
|
||||
if (!h || !h.assetHashes || h.assetHashes.ready) break;
|
||||
await sleep(150);
|
||||
}
|
||||
return {
|
||||
base, port, logs,
|
||||
// `{ hard: true }`: SIGKILL at once — a crash: no room.closed 'shutdown' reaches the clients
|
||||
|
||||
+59
-12
@@ -11,7 +11,7 @@ import path from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { startServer, parseRange, acceptsGzip, parseTrustProxy } from '../server/index.js';
|
||||
import { startServer, parseRange, acceptsGzip, pickEncoding, parseTrustProxy } from '../server/index.js';
|
||||
import { loadData, lookup, getChess, getBond, getBand, getMode, getConfig, INDEXED_FILES } from '../server/data.js';
|
||||
import * as dataModule from '../server/data.js';
|
||||
import { CODE_ALPHABET, BOT_NAMES } from '../server/lobby.js';
|
||||
@@ -109,6 +109,8 @@ describe('static http server', () => {
|
||||
const js = `// app\n${'export const x = 1;\n'.repeat(200)}`;
|
||||
const mp3 = randomBytes(5000);
|
||||
const skel = Buffer.from('skel'.repeat(400));
|
||||
// over the 8 MB compression cache limit: compressed as a stream
|
||||
const big = `// big\n${Array.from({ length: 360000 }, (_, i) => `export const v${i} = ${i % 97};`).join('\n')}\n`;
|
||||
|
||||
before(async () => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-static-'));
|
||||
@@ -127,6 +129,7 @@ describe('static http server', () => {
|
||||
w('public/assets/spine/x.skel', skel);
|
||||
w('public/assets/spine/x.atlas', 'x.png\nsize: 64,64\n'.repeat(40));
|
||||
w('public/assets/img/a.png', randomBytes(32));
|
||||
w('public/js/huge.js', big);
|
||||
w('public/assets/img/a.webp', randomBytes(32));
|
||||
w('public/fonts/f.woff2', randomBytes(32));
|
||||
w('public/fonts/f.ttf', randomBytes(32));
|
||||
@@ -138,7 +141,10 @@ describe('static http server', () => {
|
||||
srv = await startServer({
|
||||
port: 0, host: '127.0.0.1', quiet: true,
|
||||
publicDir: path.join(tmp, 'public'), dataDir: path.join(tmp, 'data'), sharedDir: path.join(tmp, 'shared'),
|
||||
assetIndex: { delayMs: 0 },
|
||||
});
|
||||
// the content hashes of public/assets + public/fonts (the ETags of those files): ready before the first request
|
||||
await srv.assetIndex.whenReady();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
@@ -161,17 +167,20 @@ describe('static http server', () => {
|
||||
assert.equal(redirect.headers.location, '/sub/?x=1');
|
||||
});
|
||||
|
||||
test('javascript: correct MIME, gzip when accepted, identity otherwise', async () => {
|
||||
const gz = await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip, deflate, br' } });
|
||||
assert.equal(gz.status, 200);
|
||||
assert.equal(gz.headers['content-type'], 'text/javascript; charset=utf-8');
|
||||
assert.equal(gz.headers['content-encoding'], 'gzip');
|
||||
assert.equal(gz.headers.vary, 'Accept-Encoding');
|
||||
assert.equal(Number(gz.headers['content-length']), gz.body.length);
|
||||
test('javascript: correct MIME, brotli when named, gzip when accepted, identity otherwise', async () => {
|
||||
const br = await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip, deflate, br' } });
|
||||
assert.equal(br.status, 200);
|
||||
assert.equal(br.headers['content-type'], 'text/javascript; charset=utf-8');
|
||||
assert.equal(br.headers['content-encoding'], 'br', 'a browser on an HTTPS page (or localhost) names br');
|
||||
assert.equal(br.headers.vary, 'Accept-Encoding');
|
||||
assert.equal(Number(br.headers['content-length']), br.body.length);
|
||||
assert.equal(zlib.brotliDecompressSync(br.body).toString(), js);
|
||||
const gz = await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip, deflate' } });
|
||||
assert.equal(gz.headers['content-encoding'], 'gzip', 'a LAN http:// page: gzip');
|
||||
assert.equal(zlib.gunzipSync(gz.body).toString(), js);
|
||||
// cached second response is identical
|
||||
const gz2 = await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip' } });
|
||||
assert.deepEqual(gz2.body, gz.body);
|
||||
// cached second responses are identical
|
||||
assert.deepEqual((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip' } })).body, gz.body);
|
||||
assert.deepEqual((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'br' } })).body, br.body);
|
||||
|
||||
const plain = await httpReq(srv.port, '/js/app.js');
|
||||
assert.equal(plain.headers['content-encoding'], undefined);
|
||||
@@ -206,6 +215,27 @@ describe('static http server', () => {
|
||||
}
|
||||
const skelGz = await httpReq(srv.port, '/assets/spine/x.skel', { headers: { 'accept-encoding': 'gzip' } });
|
||||
assert.deepEqual(zlib.gunzipSync(skelGz.body), skel);
|
||||
const skelBr = await httpReq(srv.port, '/assets/spine/x.skel', { headers: { 'accept-encoding': 'gzip, br' } });
|
||||
assert.equal(skelBr.headers['content-encoding'], 'br');
|
||||
assert.deepEqual(zlib.brotliDecompressSync(skelBr.body), skel);
|
||||
// media is never compressed, whatever the client names
|
||||
assert.equal((await httpReq(srv.port, '/assets/audio/bgm.mp3', { headers: { 'accept-encoding': 'br, gzip' } })).headers['content-encoding'], undefined);
|
||||
assert.equal((await httpReq(srv.port, '/assets/img/a.png', { headers: { 'accept-encoding': 'br' } })).headers['content-encoding'], undefined);
|
||||
});
|
||||
|
||||
test('a file over the compression cache limit is compressed as a stream (brotli and gzip); HEAD sends no body', async () => {
|
||||
const br = await httpReq(srv.port, '/js/huge.js', { headers: { 'accept-encoding': 'br' } });
|
||||
assert.equal(br.headers['content-encoding'], 'br');
|
||||
assert.equal(br.headers['content-length'], undefined, 'streamed: no length up front');
|
||||
assert.equal(zlib.brotliDecompressSync(br.body).toString(), big);
|
||||
assert.ok(br.body.length < big.length / 4);
|
||||
const gz = await httpReq(srv.port, '/js/huge.js', { headers: { 'accept-encoding': 'gzip' } });
|
||||
assert.equal(gz.headers['content-encoding'], 'gzip');
|
||||
assert.equal(zlib.gunzipSync(gz.body).toString(), big);
|
||||
const head = await httpReq(srv.port, '/js/huge.js', { method: 'HEAD', headers: { 'accept-encoding': 'br' } });
|
||||
assert.equal(head.status, 200);
|
||||
assert.equal(head.body.length, 0);
|
||||
assert.equal(head.headers['content-encoding'], 'br');
|
||||
});
|
||||
|
||||
test('ETag / Last-Modified revalidation and Cache-Control policy', async () => {
|
||||
@@ -225,10 +255,16 @@ describe('static http server', () => {
|
||||
assert.equal(stale.status, 200);
|
||||
const gzEtag = (await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip' } })).headers.etag;
|
||||
assert.notEqual(gzEtag, etag, 'gzip variant has its own ETag');
|
||||
const brEtag = (await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'br' } })).headers.etag;
|
||||
assert.equal(brEtag, etag.replace(/"$/, '-br"'), 'so has the brotli variant');
|
||||
assert.equal((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'br', 'if-none-match': brEtag } })).status, 304);
|
||||
assert.equal((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip', 'if-none-match': brEtag } })).status, 200, 'not the gzip variant');
|
||||
|
||||
// unversioned art/fonts/vendor: cached for a day (files can be rewritten in place by the asset tools)
|
||||
// unversioned art/fonts/vendor: cached for a day (files can be rewritten in place by the asset tools); the art and
|
||||
// fonts carry their content hash as ETag (server/http/assetIndex.js), so a rewrite with the same bytes is a 304
|
||||
const asset = await httpReq(srv.port, '/assets/img/a.png');
|
||||
assert.equal(asset.headers['cache-control'], 'public, max-age=86400');
|
||||
assert.match(asset.headers.etag, /^"[0-9a-f]{16}"$/);
|
||||
const font = await httpReq(srv.port, '/fonts/f.woff2');
|
||||
assert.equal(font.headers['cache-control'], 'public, max-age=86400');
|
||||
const assetV = await httpReq(srv.port, '/assets/img/a.png?v=abc');
|
||||
@@ -391,6 +427,17 @@ describe('static http server', () => {
|
||||
assert.equal(acceptsGzip('*;q=0'), false);
|
||||
assert.equal(acceptsGzip('br'), false);
|
||||
assert.equal(acceptsGzip(undefined), false);
|
||||
// brotli when named at least as high as gzip; `*` alone never picks it
|
||||
assert.equal(pickEncoding('gzip, deflate, br, zstd'), 'br');
|
||||
assert.equal(pickEncoding('gzip, deflate'), 'gzip');
|
||||
assert.equal(pickEncoding('br;q=0, gzip'), 'gzip');
|
||||
assert.equal(pickEncoding('br;q=0.5, gzip'), 'gzip');
|
||||
assert.equal(pickEncoding('gzip;q=0.5, br'), 'br');
|
||||
assert.equal(pickEncoding('br'), 'br');
|
||||
assert.equal(pickEncoding('*'), 'gzip');
|
||||
assert.equal(pickEncoding('*;q=0'), null);
|
||||
assert.equal(pickEncoding('identity'), null);
|
||||
assert.equal(pickEncoding(undefined), null);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -209,6 +209,47 @@ print(json.dumps(e.prefab_node(go)))`);
|
||||
const raw = spawnSync(PY, ['-c', `import sys; sys.path.insert(0, ${JSON.stringify(TOOL)}); import extract as e, json; print(json.dumps(e.vec(type('V', (), {'x': -0.0, 'y': float('inf'), 'z': 1e-9})())))`], { encoding: 'utf8', env: ENV });
|
||||
assert.equal(raw.stdout.trim(), '[0.0, 0, 0.0]');
|
||||
});
|
||||
|
||||
test('guide pages (host bandwidth layer 0): --webp writes a lossless WebP copy of every page PNG, the manifest lists it, the PNG stays',
|
||||
{ skip: spawnSync(PY, ['-c', "import sys; from PIL import features; sys.exit(0 if features.check('webp') else 1)"], { env: ENV }).status !== 0 && 'no Pillow with WebP' }, () => {
|
||||
const jobs = JSON.parse(spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV }).stdout);
|
||||
assert.deepEqual(jobs.guideWebp.sub, 'guide');
|
||||
assert.equal(jobs.guideWebp.mode, 'lossless', 'lossy pages wait for the maintainer\'s comparison');
|
||||
assert.ok(!jobs.webp.some((w) => w.sub === 'guide'), 'the board texture table is unchanged');
|
||||
const out = py(`
|
||||
import tempfile, pathlib
|
||||
from PIL import Image
|
||||
root = pathlib.Path(tempfile.mkdtemp())
|
||||
sub = root / 'guide'
|
||||
sub.mkdir()
|
||||
for i, name in enumerate(['autochess_home_1', 'autochess_shop_2']):
|
||||
img = Image.new('RGBA', (48, 32), (10, 20, 30, 255))
|
||||
for x in range(48):
|
||||
for y in range(32): img.putpixel((x, y), ((x * 5 + i * 40) % 256, (y * 7) % 256, (x * y) % 256, 255 if x % 7 else 254))
|
||||
img.save(sub / f'{name}.png')
|
||||
groups = {'guide': {n: {'path': f'/assets/local/guide/{n}.png', 'w': 48, 'h': 32, 'kind': 'Sprite'} for n in ['autochess_home_1', 'autochess_shop_2']},
|
||||
'ui/battle': {'x': {'path': '/assets/local/ui/battle/x.png', 'w': 2, 'h': 2, 'kind': 'Sprite'}}}
|
||||
groups['guide']['autochess_handbook_9'] = {'path': '/assets/local/guide/autochess_handbook_9.png', 'kind': 'Sprite'} # no PNG on disk
|
||||
mf = root / 'local-assets.json'
|
||||
mf.write_text(json.dumps({'version': 1, 'source': 'local-client', 'count': 4, 'groups': groups}))
|
||||
code = e.webp_only(root, mf, lambda *a: None)
|
||||
doc = json.loads(mf.read_text())
|
||||
g = doc['groups']['guide']
|
||||
px = lambda p: list(Image.open(p).convert('RGBA').getdata())
|
||||
print(json.dumps({'code': code, 'paths': {k: v['path'] for k, v in g.items()}, 'modes': {k: v.get('webp') for k, v in g.items()},
|
||||
'other': doc['groups']['ui/battle']['x']['path'],
|
||||
'same': all(px(sub / f'{n}.png') == px(sub / f'{n}.webp') for n in ['autochess_home_1', 'autochess_shop_2']),
|
||||
'png': all((sub / f'{n}.png').exists() for n in ['autochess_home_1', 'autochess_shop_2']),
|
||||
'again': e.run_webp(root, 'guide', doc['groups'], lambda *a: None)}))`);
|
||||
assert.equal(out.code, 0);
|
||||
const at = (n, ext) => `/assets/local/guide/${n}.${ext}`;
|
||||
assert.deepEqual(out.paths, { autochess_home_1: at('autochess_home_1', 'webp'), autochess_shop_2: at('autochess_shop_2', 'webp'), autochess_handbook_9: at('autochess_handbook_9', 'png') });
|
||||
assert.deepEqual(out.modes, { autochess_home_1: 'lossless', autochess_shop_2: 'lossless', autochess_handbook_9: null });
|
||||
assert.equal(out.other, '/assets/local/ui/battle/x.png', 'other groups keep their PNGs');
|
||||
assert.ok(out.same, 'lossless: the same pixels, alpha included');
|
||||
assert.ok(out.png, 'the PNG stays beside the copy');
|
||||
assert.equal(out.again, 2, 'a second run (another GUIDE_WEBP_MODE) rewrites the copies the manifest already lists');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extracted board-scene files (when a local client was extracted)', { skip: !manifest && 'no data/local-assets.json' }, () => {
|
||||
|
||||
@@ -23,6 +23,16 @@ describe('mediaUrl: 音频地址去掉扩展名(躲开下载器嗅探)', ()
|
||||
assert.equal(mediaUrl('/assets/audio/x/A.MP3', ORIGIN), '/media/x/A', '大小写不敏感');
|
||||
});
|
||||
|
||||
test('服务器下发的清单带内容哈希段(/assets/~<hash>/audio/…):改写后保留这一段', () => {
|
||||
const H = '0123456789abcdef';
|
||||
assert.equal(mediaUrl(`/assets/~${H}/audio/bgm/m_sys_act1autochess_loop.mp3`, ORIGIN), `/media/~${H}/bgm/m_sys_act1autochess_loop`);
|
||||
assert.equal(mediaUrl(`${ORIGIN}/assets/~${H}/audio/sfx/a.mp3`, ORIGIN), `/media/~${H}/sfx/a`);
|
||||
assert.doesNotMatch(mediaUrl(`/assets/~${H}/audio/bgm/a.mp3`, ORIGIN), /\.(mp3|m4a|aac|ogg|oga|opus|wav)(\?|$)/i);
|
||||
// 不是合法的哈希段(大写、长度不对)就不是我们的音频地址,原样返回
|
||||
assert.equal(mediaUrl(`/assets/~${H.toUpperCase()}/audio/a.mp3`, ORIGIN), `/assets/~${H.toUpperCase()}/audio/a.mp3`);
|
||||
assert.equal(mediaUrl(`/assets/~${H}/char/a.png`, ORIGIN), `/assets/~${H}/char/a.png`, '图片不改写');
|
||||
});
|
||||
|
||||
test('改写后的地址里不再有任何媒体扩展名(下载器就是按这个嗅探的)', () => {
|
||||
const out = mediaUrl('/assets/audio/bgm/m_bat_vtlionk_loop.mp3', ORIGIN);
|
||||
assert.equal(out.startsWith(MEDIA_PREFIX), true);
|
||||
|
||||
@@ -23,6 +23,8 @@ describe('render engine in headless Chrome', { skip }, () => {
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
// the content-hash index first: every page then loads the served manifest's /assets/~<hash>/… URLs (ASSETS.md)
|
||||
await srv.assetIndex?.whenReady();
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
@@ -73,6 +75,39 @@ describe('render engine in headless Chrome', { skip }, () => {
|
||||
});
|
||||
}
|
||||
|
||||
test('Spine models load through the served manifest\'s content-hashed URLs, cached immutably (host bandwidth layer 1)', async (t) => {
|
||||
if (!srv.assetIndex) { t.skip('SP_ASSET_INDEX=0: no content hashes'); return; }
|
||||
const page = await browser.newPage();
|
||||
const spine = [];
|
||||
const problems = [];
|
||||
page.on('response', (r) => {
|
||||
const u = new URL(r.url());
|
||||
if (/\.(skel|atlas)$/.test(u.pathname) || (/\/spine\//.test(u.pathname) && /\.png$/.test(u.pathname))) spine.push({ path: u.pathname, status: r.status(), cache: r.headers()['cache-control'] });
|
||||
if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`);
|
||||
});
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
try {
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&t=18&panel=0`);
|
||||
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
|
||||
await wait(3500);
|
||||
const st = await page.evaluate(() => window.__demo.stats());
|
||||
assert.ok(st.units > 0);
|
||||
assert.equal(st.spine.failed, 0, 'no Spine load failures');
|
||||
assert.ok(spine.length >= 6, `Spine files requested: ${spine.length}`);
|
||||
for (const s of spine) {
|
||||
assert.match(s.path, /^\/assets\/~[0-9a-f]{16}\//, `${s.path}: a content-hashed URL`);
|
||||
assert.equal(s.status, 200, s.path);
|
||||
assert.equal(s.cache, 'public, max-age=31536000, immutable', `${s.path}: skeleton, atlas and pages share the model hash`);
|
||||
}
|
||||
// the atlas pixi-spine derived from each skeleton came under the skeleton's segment
|
||||
for (const sk of spine.filter((s) => s.path.endsWith('.skel'))) {
|
||||
assert.ok(spine.some((s) => s.path === sk.path.replace(/\.skel$/, '.atlas')), `${sk.path}: its atlas`);
|
||||
}
|
||||
assert.deepEqual(problems, []);
|
||||
} finally { await page.close(); }
|
||||
});
|
||||
|
||||
test('Swire coins from a real snapshot remain separate from the elemental gauge', async () => {
|
||||
const { makeBattle } = await import('../helpers/battleHarness.js');
|
||||
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_3_04_a', row: 10, col: 4, skillIndex: 1 }] });
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// The server's traffic counters (server/http/traffic.js, GET /healthz?traffic=1; host bandwidth layer 0): the body
|
||||
// bytes every HTTP response wrote, by category, 304s apart, and the WebSocket's wire and payload bytes both ways.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { startServer } from '../server/index.js';
|
||||
import { createTrafficCounter } from '../server/http/traffic.js';
|
||||
import { TestClient } from './helpers/wsClient.js';
|
||||
|
||||
function get(port, p, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ host: '127.0.0.1', port, path: p, headers, agent: false }, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
test('HTTP: body bytes by category as written (compressed), 304s counted, /healthz reports them only when asked', async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-traffic-'));
|
||||
const w = (rel, content) => { fs.mkdirSync(path.dirname(path.join(tmp, rel)), { recursive: true }); fs.writeFileSync(path.join(tmp, rel), content); };
|
||||
const js = `// app\n${'export const x = 1;\n'.repeat(400)}`;
|
||||
const png = randomBytes(3000);
|
||||
const mp3 = randomBytes(5000);
|
||||
w('public/index.html', '<!doctype html><p>hi</p>');
|
||||
w('public/js/app.js', js);
|
||||
w('public/assets/img/a.png', png);
|
||||
w('public/assets/audio/bgm/a.mp3', mp3);
|
||||
w('data/config.json', JSON.stringify({ a: 1 }));
|
||||
w('shared/x.js', 'export const y = 2;');
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, publicDir: path.join(tmp, 'public'), dataDir: path.join(tmp, 'data'), sharedDir: path.join(tmp, 'shared') });
|
||||
try {
|
||||
const plain = await get(srv.port, '/health');
|
||||
assert.equal(plain.status, 404);
|
||||
const gz = await get(srv.port, '/js/app.js', { 'accept-encoding': 'gzip' });
|
||||
assert.ok(gz.body.length < js.length, 'compressed');
|
||||
await get(srv.port, '/js/app.js', { 'if-none-match': (await get(srv.port, '/js/app.js')).headers.etag });
|
||||
await get(srv.port, '/assets/img/a.png');
|
||||
await get(srv.port, '/media/bgm/a');
|
||||
await get(srv.port, '/data/config.json');
|
||||
const before = await get(srv.port, '/healthz');
|
||||
assert.equal(JSON.parse(before.body).traffic, undefined, 'plain /healthz stays as it was');
|
||||
const t = JSON.parse((await get(srv.port, '/healthz?traffic=1')).body).traffic;
|
||||
assert.equal(t.http.code.requests, 3);
|
||||
assert.equal(t.http.code.bytes, gz.body.length + js.length, 'what was written: the gzip body, the plain body, nothing for the 304');
|
||||
assert.equal(t.http.code.notModified, 1);
|
||||
assert.deepEqual(t.http.assets, { requests: 1, bytes: png.length, notModified: 0 });
|
||||
assert.deepEqual(t.http.audio, { requests: 1, bytes: mp3.length, notModified: 0 });
|
||||
assert.equal(t.http.data.requests, 1);
|
||||
assert.equal(t.http.other.requests, 2, 'the 404 page and the first /healthz');
|
||||
assert.ok(t.http.other.bytes > 0);
|
||||
assert.equal(t.http.total.requests, 8);
|
||||
assert.equal(typeof t.since, 'string');
|
||||
assert.equal(srv.traffic.snapshot().http.total.requests, 9, 'the traffic request itself once it has finished');
|
||||
} finally {
|
||||
await srv.close();
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('WebSocket: wire bytes both ways (after permessage-deflate) and the payloads, live and after the close', async () => {
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
try {
|
||||
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
await c.hello('流量计数');
|
||||
const live = srv.traffic.snapshot().ws;
|
||||
assert.equal(live.connections, 1);
|
||||
assert.equal(live.open, 1);
|
||||
assert.ok(live.messagesOut >= 1 && live.payloadOut > 0, JSON.stringify(live));
|
||||
assert.ok(live.messagesIn >= 1 && live.payloadIn > 0);
|
||||
assert.ok(live.wireOut > 0 && live.wireIn > 0);
|
||||
await c.close();
|
||||
for (let i = 0; i < 50 && srv.traffic.snapshot().ws.open; i++) await new Promise((r) => setTimeout(r, 20));
|
||||
const done = srv.traffic.snapshot().ws;
|
||||
assert.equal(done.open, 0);
|
||||
assert.ok(done.wireOut >= live.wireOut, 'the closed socket\'s bytes stay in the totals');
|
||||
} finally {
|
||||
await srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('the counter on its own: a string body in another encoding, an aborted response, the payload of a fragmented message', () => {
|
||||
const tc = createTrafficCounter({ now: () => 1000 });
|
||||
const listeners = {};
|
||||
const res = { statusCode: 200, write() { return true; }, end() {}, once(ev, fn) { listeners[ev] = fn; } };
|
||||
tc.meter(res, '/data/x.json');
|
||||
res.write('aGk=', 'base64');
|
||||
res.end('é');
|
||||
listeners.close();
|
||||
const s = tc.snapshot();
|
||||
assert.deepEqual(s.http.data, { requests: 1, bytes: 2 + 2, notModified: 0 });
|
||||
tc.wsSent('{"t":"pong"}');
|
||||
tc.wsReceived([Buffer.alloc(3), Buffer.alloc(4)]);
|
||||
const ws = tc.snapshot().ws;
|
||||
assert.equal(ws.payloadOut, 12);
|
||||
assert.equal(ws.payloadIn, 7);
|
||||
assert.equal(tc.snapshot().seconds, 0);
|
||||
});
|
||||
@@ -577,6 +577,63 @@ describe('AudioManager', () => {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
test('BGM is not downloaded while the music is muted or at volume 0; the wanted track starts once it is audible', async () => {
|
||||
const fw = fakeWindow();
|
||||
const origFetch = globalThis.fetch;
|
||||
const urls = [];
|
||||
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
|
||||
const tick = () => new Promise((r) => setTimeout(r, 10));
|
||||
const lobby = manifest.audio.bgm.lobby.loop;
|
||||
const prep = manifest.audio.bgm.prep.loop;
|
||||
assert.notEqual(lobby, prep, 'premise: two different loops');
|
||||
try {
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
|
||||
a.setVolumes({ muted: true });
|
||||
a.install();
|
||||
a.playBgm('lobby');
|
||||
fw.fire('pointerdown');
|
||||
await tick();
|
||||
assert.equal(a.unlocked, true);
|
||||
assert.equal(a.bgmSilent, true);
|
||||
assert.ok(!asked(urls, lobby) && !asked(urls, manifest.audio.bgm.lobby.intro), 'muted: nothing fetched at unlock');
|
||||
a.playBgm('prep');
|
||||
await tick();
|
||||
assert.ok(!asked(urls, prep), 'muted: a phase change fetches nothing');
|
||||
assert.equal(a.wantBgm, 'prep', 'the wanted track is remembered');
|
||||
a.setVolumes({ muted: false });
|
||||
await tick();
|
||||
assert.ok(asked(urls, prep), 'unmuted: the track the screen wants now is fetched');
|
||||
assert.ok(!asked(urls, lobby), 'never the track wanted before');
|
||||
assert.equal(a.bgm?.loopUrl, prep);
|
||||
|
||||
// BGM volume 0 is silent too: another track stops and is not fetched; SFX keep playing
|
||||
a.setVolumes({ bgm: 0 });
|
||||
a.playBgm('lobby');
|
||||
await tick();
|
||||
assert.ok(!asked(urls, lobby), 'BGM volume 0: no download');
|
||||
assert.equal(a.bgm, null, 'the other track stopped');
|
||||
a.sfx('buy');
|
||||
await tick();
|
||||
assert.ok(asked(urls, manifest.audio.sfx.ui.buy), 'SFX are not gated by the BGM volume');
|
||||
a.setVolumes({ bgm: 0.5 });
|
||||
await tick();
|
||||
assert.ok(asked(urls, lobby), 'audible again: fetched');
|
||||
assert.equal(a.bgm?.loopUrl, lobby);
|
||||
|
||||
// the loop that already plays keeps playing through a silent phase change, and is not fetched again
|
||||
a.setVolumes({ muted: true });
|
||||
const n = urls.length;
|
||||
const started = fw.made.started;
|
||||
a.playBgm('lobby');
|
||||
a.setVolumes({ muted: false });
|
||||
await tick();
|
||||
assert.equal(a.bgm?.loopUrl, lobby);
|
||||
assert.equal(urls.length, n, 'no new request');
|
||||
assert.equal(fw.made.started, started, 'no restart');
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
test('fetch failures are swallowed', async () => {
|
||||
const fw = fakeWindow();
|
||||
const origFetch = globalThis.fetch;
|
||||
@@ -598,6 +655,27 @@ describe('AudioManager', () => {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
test('a served manifest\'s content-hashed audio URL is fetched as /media/~<hash>/…, falling back to the hashed .mp3 URL', async () => {
|
||||
const H = '0123456789abcdef';
|
||||
const raw = manifest.audio.bgm.prep.loop.replace(/^\/assets\//, `/assets/~${H}/`);
|
||||
const served = { ...manifest, audio: { ...manifest.audio, bgm: { ...manifest.audio.bgm, prep: { loop: raw } } } };
|
||||
const fw = fakeWindow();
|
||||
const urls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (u) => { urls.push(u); return u.startsWith('/media/') ? { ok: false, status: 404 } : { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
|
||||
try {
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => served });
|
||||
a.install();
|
||||
fw.fire('pointerdown');
|
||||
a.playBgm('prep');
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.deepEqual(urls, [raw.replace(/^\/assets\/(~[0-9a-f]{16})\/audio\/(.+)\.mp3$/, '/media/$1/$2'), raw]);
|
||||
assert.match(urls[0], new RegExp(`^/media/~${H}/bgm/`));
|
||||
assert.equal(a.bgm?.loopUrl, raw);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
test('音频先走无扩展名的 /media/ 路由;只有它 404 才回退到带扩展名的原地址', async () => {
|
||||
const raw = manifest.audio.bgm.prep.loop;
|
||||
const media = mediaUrl(raw);
|
||||
|
||||
@@ -11,7 +11,10 @@ test('buying Matterhorn sends two live layer cues through the real client; resyn
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.1, startRound: 2, kit: 0, shop: ['chess_char_1_02_a'] } });
|
||||
const c = new Client(P, srv.base, 'bond-audio', { prefix: 'bond-audio' });
|
||||
try {
|
||||
await c.open(); await c.enter('叠层音效测试');
|
||||
await c.open();
|
||||
const mediaHits = [];
|
||||
c.page.on('response', (r) => { const p = new URL(r.url()).pathname; if (p.startsWith('/media/')) mediaHits.push({ p, status: r.status(), cache: r.headers()['cache-control'] }); });
|
||||
await c.enter('叠层音效测试');
|
||||
await c.click('.mode-card', '独立模拟'); await c.click('.diff-card', '险境');
|
||||
await c.click('.create-box button', '开始独立模拟');
|
||||
await c.waitFor(s => !!s.room, 'solo room');
|
||||
@@ -41,6 +44,15 @@ test('buying Matterhorn sends two live layer cues through the real client; resyn
|
||||
assert.deepEqual(result.frames.map(m => [m.bondId, m.add]).sort(), [['kjeragShip', 2], ['steadShip', 2]]);
|
||||
assert.equal(result.cues.length, 2); assert.equal(result.playing, true); assert.equal(result.state, 'running');
|
||||
assert.ok(result.cues.every(x => /act1autochess_b_ui_buffup/.test(x.url)));
|
||||
// host bandwidth layer 1: the served manifest names the cue with its content hash and the client fetches it through
|
||||
// the extension-less /media/~<hash>/… route, cached immutably (skipped with SP_ASSET_INDEX=0: plain URLs)
|
||||
if (result.cues[0].url.includes('/~')) {
|
||||
assert.ok(result.cues.every(x => /^\/assets\/~[0-9a-f]{16}\/audio\//.test(x.url)), JSON.stringify(result.cues));
|
||||
const hit = mediaHits.find(h => /^\/media\/~[0-9a-f]{16}\/.*act1autochess_b_ui_buffup$/.test(h.p));
|
||||
assert.ok(hit, mediaHits.map(h => h.p).join(' '));
|
||||
assert.equal(hit.status, 200);
|
||||
assert.equal(hit.cache, 'public, max-age=31536000, immutable');
|
||||
}
|
||||
const count = result.cues.length;
|
||||
// g.watch on own prep asks the real server for state again, without a new gain.
|
||||
await c.page.evaluate(() => globalThis.__SP__.net.request('g.watch', { fieldId: `n:${globalThis.__SP__.store.get().me.playerId}` }));
|
||||
|
||||
@@ -163,6 +163,19 @@ describe('emotes and 玩法说明 pages: local art first, then the mirror copy (
|
||||
assert.ok(!src.includes('该页面暂时无法显示'), 'no blank notice left');
|
||||
});
|
||||
|
||||
test('the guide preloads only the next page (host bandwidth layer 0): wraps around, skips a failed copy, never the previous page', async () => {
|
||||
const { guidePreloadUrl } = await import('../../public/js/ui/guide.js');
|
||||
const page = (k) => ({ urls: [`/assets/local/guide/${k}.webp`, `/assets/ui/guide/${k}.png`] });
|
||||
const pages = ['autochess_home_1', 'autochess_home_2', 'autochess_home_3'].map(page);
|
||||
assert.equal(guidePreloadUrl(pages, 0), '/assets/local/guide/autochess_home_2.webp');
|
||||
assert.equal(guidePreloadUrl(pages, 2), '/assets/local/guide/autochess_home_1.webp', 'wraps to the first page');
|
||||
assert.equal(guidePreloadUrl(pages, 1, new Set(['/assets/local/guide/autochess_home_3.webp'])), '/assets/ui/guide/autochess_home_3.png', 'the copy that has not failed');
|
||||
assert.equal(guidePreloadUrl([page('autochess_home_1')], 0), null, 'a single page needs no preload');
|
||||
assert.equal(guidePreloadUrl([], 0), null);
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/ui/guide.js'), 'utf8');
|
||||
assert.deepEqual(src.match(/^\s+preload\(.*$/gm).map((l) => l.trim()), ['preload(guidePreloadUrl(pages, i, failed));'], 'one preload call in the viewer');
|
||||
});
|
||||
|
||||
test('tools/assets/plan.mjs GUIDE_PAGES is the viewer\'s page list (GUIDE_CHAPTERS), in reading order', async () => {
|
||||
const { GUIDE_CHAPTERS } = await import('../../public/js/ui/guide.js');
|
||||
assert.deepEqual([...GUIDE_PAGES], GUIDE_CHAPTERS.flatMap((c) => c.pages.map(([key]) => key)));
|
||||
|
||||
@@ -38,7 +38,7 @@ const CHROME_ARGS = ['--no-sandbox', '--no-first-run', '--autoplay-policy=no-use
|
||||
*/
|
||||
class Client {
|
||||
constructor(puppeteer, base, label, { w = 1920, h = 1080 } = {}) {
|
||||
Object.assign(this, { puppeteer, base, label, w, h, problems: [], log: [], shots: 0 });
|
||||
Object.assign(this, { puppeteer, base, label, w, h, problems: [], log: [], shots: 0, hashed: { spine: 0, media: 0, other: 0 } });
|
||||
}
|
||||
|
||||
async open(query = '') {
|
||||
@@ -50,7 +50,14 @@ class Client {
|
||||
page.on('console', (m) => { if (m.type() === 'error') this.problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => this.problems.push(`pageerror: ${e.message}`));
|
||||
page.on('requestfailed', (r) => this.problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
|
||||
page.on('response', (r) => { if (r.status() >= 400) this.problems.push(`http ${r.status()}: ${r.url()}`); });
|
||||
page.on('response', (r) => {
|
||||
if (r.status() >= 400) this.problems.push(`http ${r.status()}: ${r.url()}`);
|
||||
// the served manifest's content-hashed URLs (docs/ASSETS.md): Spine files, audio through /media/~<hash>/, the rest
|
||||
const p = new URL(r.url()).pathname;
|
||||
if (r.status() === 200 && /^\/(?:assets|media)\/~[0-9a-f]{16}\//.test(p)) {
|
||||
this.hashed[/\.(?:skel|atlas)$|\/spine\//.test(p) ? 'spine' : p.startsWith('/media/') ? 'media' : 'other']++;
|
||||
}
|
||||
});
|
||||
page.on('dialog', (d) => d.dismiss().catch(() => {}));
|
||||
await page.goto(`${this.base}/${query}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!globalThis.__SP__ && !!document.querySelector('.screen'), { timeout: 30000 });
|
||||
@@ -443,6 +450,8 @@ describe('real server + real browsers', { skip: !ENABLED && 'set SP_REAL_E2E=1 (
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
// the content-hash index first, so the pages load the served manifests' /assets/~<hash>/… URLs like a player's
|
||||
await srv.assetIndex?.whenReady();
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
@@ -628,6 +637,10 @@ describe('real server + real browsers', { skip: !ENABLED && 'set SP_REAL_E2E=1 (
|
||||
assert.ok(did.inspected && did.bond, 'detail panel + bond popup');
|
||||
assert.ok(did.emote, 'emote bubble reached the teammate');
|
||||
assert.ok(did.enemies >= 0, 'enemy drawer opened');
|
||||
if (srv.assetIndex) {
|
||||
host.note(`content-hashed responses: ${JSON.stringify(host.hashed)}`);
|
||||
assert.ok(host.hashed.spine > 0 && host.hashed.media > 0 && host.hashed.other > 0, `Spine, audio and art through hashed URLs: ${JSON.stringify(host.hashed)}`);
|
||||
}
|
||||
await assertClean(both);
|
||||
} finally {
|
||||
console.log([...host.log, ...guest.log].join('\n'));
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// WebSocket permessage-deflate (server/http/websocket.js WS_DEFLATE, host bandwidth layer 0): a browser-like client
|
||||
// negotiates it with no server context takeover and a 2 KB server window, a large JSON frame goes out compressed, a
|
||||
// frame under the 1 KB threshold and a client without the extension get plain frames, and an offer that caps the server
|
||||
// window below ours is answered without compression instead of a refused handshake.
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import WebSocket from 'ws';
|
||||
import { attachWebSocket, narrowDeflateOffer, WS_DEFLATE } from '../server/http/websocket.js';
|
||||
import { startServer } from '../server/index.js';
|
||||
|
||||
const BIG = JSON.stringify({ t: 'm.public', players: Array.from({ length: 200 }, (_, i) => ({ playerId: `p_${i % 4}`, name: '阿米娅', lp: 30, phase: 'PREP' })) });
|
||||
const SMALL = JSON.stringify({ t: 'pong', at: 12345 });
|
||||
|
||||
/** A bare http server whose /ws sends BIG then SMALL to every socket (a stand-in for net.js Network). */
|
||||
async function fakeServer() {
|
||||
const server = http.createServer((req, res) => { res.writeHead(404); res.end(); });
|
||||
const network = {
|
||||
admission: () => null,
|
||||
handleConnection: (ws) => { ws.send(BIG); ws.send(SMALL); },
|
||||
};
|
||||
const wss = attachWebSocket(server, { network, log: { error() {} } });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
return { url: `ws://127.0.0.1:${server.address().port}/ws`, close: () => new Promise((r) => { wss.close(); server.close(() => r()); }) };
|
||||
}
|
||||
|
||||
/** Connect, collect two messages, report the server's Sec-WebSocket-Extensions answer and the bytes the socket read. */
|
||||
function receive(url, opts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url, opts);
|
||||
const msgs = [];
|
||||
let negotiated = '';
|
||||
ws.on('upgrade', (res) => { negotiated = res.headers['sec-websocket-extensions'] || ''; });
|
||||
ws.on('error', reject);
|
||||
ws.on('message', (data) => {
|
||||
msgs.push(String(data));
|
||||
if (msgs.length === 2) {
|
||||
const read = ws._socket.bytesRead;
|
||||
ws.close();
|
||||
resolve({ msgs, read, extensions: negotiated });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('WebSocket permessage-deflate', () => {
|
||||
let srv;
|
||||
before(async () => { srv = await fakeServer(); });
|
||||
after(async () => { await srv.close(); });
|
||||
|
||||
test('negotiated with no server context takeover and a small server window; a large frame is compressed', async () => {
|
||||
const r = await receive(srv.url);
|
||||
assert.match(r.extensions, /permessage-deflate/);
|
||||
assert.match(r.extensions, /server_no_context_takeover/);
|
||||
assert.match(r.extensions, new RegExp(`server_max_window_bits=${WS_DEFLATE.serverMaxWindowBits}`));
|
||||
assert.deepEqual(r.msgs, [BIG, SMALL], 'the payloads arrive intact');
|
||||
assert.ok(BIG.length > 10000);
|
||||
assert.ok(r.read < BIG.length / 4, `compressed on the wire: ${r.read} bytes read for ${BIG.length} of payload`);
|
||||
assert.equal(WS_DEFLATE.threshold, 1024);
|
||||
});
|
||||
|
||||
test('a client without the extension gets the same messages uncompressed', async () => {
|
||||
const r = await receive(srv.url, { perMessageDeflate: false });
|
||||
assert.equal(r.extensions, '');
|
||||
assert.deepEqual(r.msgs, [BIG, SMALL]);
|
||||
assert.ok(r.read > BIG.length + SMALL.length, `plain frames: ${r.read}`);
|
||||
});
|
||||
|
||||
test('an offer that caps the server window below ours connects uncompressed instead of a refused handshake', async () => {
|
||||
const r = await receive(srv.url, { perMessageDeflate: { serverMaxWindowBits: 9 } });
|
||||
assert.equal(r.extensions, '');
|
||||
assert.deepEqual(r.msgs, [BIG, SMALL]);
|
||||
assert.equal(narrowDeflateOffer('permessage-deflate; server_max_window_bits=9'), true);
|
||||
assert.equal(narrowDeflateOffer('permessage-deflate; server_max_window_bits=12; client_max_window_bits'), false);
|
||||
assert.equal(narrowDeflateOffer('permessage-deflate; server_max_window_bits=9, permessage-deflate'), false, 'another offer is acceptable');
|
||||
assert.equal(narrowDeflateOffer('permessage-deflate; client_max_window_bits'), false);
|
||||
assert.equal(narrowDeflateOffer(undefined), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('the real server negotiates compression at /ws and keeps the 64 KB inflated frame limit', async () => {
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
try {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); });
|
||||
assert.match(ws.extensions, /permessage-deflate/);
|
||||
// a compressed client frame whose inflated size is over the limit closes the socket (1009), like a plain one
|
||||
const closed = new Promise((resolve) => ws.once('close', (code) => resolve(code)));
|
||||
ws.send(JSON.stringify({ t: 'hello', name: 'x'.repeat(70 * 1024) }));
|
||||
assert.equal(await closed, 1009);
|
||||
} finally {
|
||||
await srv.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user