Files
tiennm99 90cd679639 feat(web): state the rules, reach the chat from the top of the board, lint the tree
A /rules page says, in one place, what nothing in the app said before:
the chain rule, the clock, what a dead end costs, elimination and the last
player standing, how a word is scored and the reconnect window. Linked
from the landing page and from the board and lobby headers.

The chat control moves above the chain as a pill with the unread count,
where it can be reached on a phone mid-game, and the lobby's folded chat
now carries an unread badge too.

ESLint with the Svelte and JSDoc plugins, run in CI; the real findings
it turned up (missing each keys, untyped timer handles) are fixed.
2026-09-21 01:17:52 +07:00

108 lines
3.6 KiB
JavaScript

// The wordlist must never reach the browser. If it did, the dictionary would
// stop being the server's secret and every rule it enforces would become
// advisory — a player could read the valid answers out of their own bundle.
//
// npm test builds before running, so this check always reads a bundle produced
// from the current source rather than whatever was left in build/.
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const buildDir = fileURLToPath(new URL('../build', import.meta.url));
const srcDir = fileURLToPath(new URL('../src', import.meta.url));
/**
* Every file in the built output, recursively.
* @param {string} dir
* @returns {string[]}
*/
function walk(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = join(dir, entry.name);
return entry.isDirectory() ? walk(full) : [full];
});
}
/**
* Turns `\uXXXX` sequences back into the characters they stand for.
*
* A minifier configured to emit ASCII writes Vietnamese as escapes, and a
* substring search over the raw bytes would then find nothing while the words
* are plainly there in the shipped file.
* @param {string} text
* @returns {string}
*/
function decodeUnicodeEscapes(text) {
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
}
/**
* Words that belong to the dictionary and to nothing else in this project.
* None of them is UI copy, a test fixture, or a placeholder, so any hit is the
* wordlist leaking rather than a coincidence.
*/
const DICTIONARY_WORDS = [
'học sinh',
'sinh viên',
'bàn ghế',
'quốc gia',
'con mèo',
'giáo viên',
'thành phố',
'nhà cửa'
];
/**
* A budget that binds. The bundle is around 190 KB, so this leaves room for
* more screens without leaving room for a wordlist: the derived dictionary is
* several MB, and even a fraction of it would not fit here.
*/
const MAX_TOTAL_BYTES = 400_000;
const files = walk(buildDir);
describe('built bundle', () => {
it('is newer than the source it was built from', () => {
// `npm test` builds first, so this should always hold. It is asserted
// anyway because running vitest directly would otherwise search whatever
// happened to be left in build/ — passing over code that no longer
// exists, which is the one way this whole file could be vacuous.
expect(files.length).toBeGreaterThan(0);
expect(files.some((f) => f.endsWith('index.html'))).toBe(true);
const newestBuild = Math.max(...files.map((f) => statSync(f).mtimeMs));
const newestSource = Math.max(...walk(srcDir).map((f) => statSync(f).mtimeMs));
expect(
newestBuild,
'build/ is older than src/ — run `npm test`, which builds first'
).toBeGreaterThan(newestSource);
});
it('carries no dictionary words', () => {
/** @type {string[]} */
const hits = [];
for (const file of files) {
const text = decodeUnicodeEscapes(readFileSync(file, 'utf8'));
for (const word of DICTIONARY_WORDS) {
if (text.includes(word)) hits.push(`${file}: ${word}`);
}
}
expect(hits).toEqual([]);
});
it('ships no database or wordlist file', () => {
const dataFiles = files.filter((f) =>
['.db', '.sqlite', '.sqlite3', '.csv', '.tsv'].includes(extname(f))
);
expect(dataFiles).toEqual([]);
});
it('stays within the size budget that separates code from data', () => {
const total = files.reduce((sum, f) => sum + statSync(f).size, 0);
expect(total).toBeLessThan(MAX_TOTAL_BYTES);
});
});