337 lines
20 KiB
JavaScript
337 lines
20 KiB
JavaScript
// test/update-boot.test.js — the update package on the player's machine (server/update.js, docs/DEPLOY.md §1.5): the
|
||
// paths UPDATE.json may name, the boot step that finishes an update (deletes, renames, refuses a mismatched install,
|
||
// skips a malformed file), and the MANIFEST.json check `npm run doctor` shows. Temporary folders only.
|
||
// Run: node --test test/update-boot.test.js (the packaging side: test/update-package.test.js)
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import crypto from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import {
|
||
APPLIED_FILE, MANIFEST_FILE, UPDATE_FILE, affectsRuntime, applyPendingUpdate, checkInstall, deleteRemoved, digestFile,
|
||
inManifest, isSetupArt, parseManifest, parseUpdate, pathProblem, removalProblem, verifyFiles,
|
||
} from '../server/update.js';
|
||
|
||
const sha = (body) => crypto.createHash('sha256').update(body).digest('hex');
|
||
const dg = (body) => ({ size: Buffer.byteLength(body), sha256: sha(body) });
|
||
const tmp = (tag) => fs.mkdtempSync(path.join(os.tmpdir(), `sp-update-${tag}-`));
|
||
const put = (dir, rel, body = `// ${rel}\n`) => {
|
||
fs.mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true });
|
||
fs.writeFileSync(path.join(dir, rel), body);
|
||
};
|
||
const has = (dir, rel) => fs.existsSync(path.join(dir, rel));
|
||
const read = (dir, rel) => fs.readFileSync(path.join(dir, rel), 'utf8');
|
||
const quiet = () => {
|
||
const lines = [];
|
||
return { lines, log: { log: (m) => lines.push(['log', m]), warn: (m) => lines.push(['warn', m]), error: (m) => lines.push(['error', m]) } };
|
||
};
|
||
/** MANIFEST.json / UPDATE.json as tools/package-update.mjs writes them (the same keys; its line layout is not needed here). */
|
||
const manifestText = ({ app, files }) => JSON.stringify({ format: 1, app, files: Object.fromEntries(files) });
|
||
const updateText = ({ app, from, files, removed }) => JSON.stringify({ format: 1, app, from, count: files.size,
|
||
bytes: [...files.values()].reduce((n, d) => n + d.size, 0), files: Object.fromEntries(files), removed });
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// The paths an update may name
|
||
|
||
test('refusal rules: traversal, absolute and drive paths, backslashes, per-machine folders and the update files are never removed', () => {
|
||
for (const p of ['../x', 'a/../../x', 'server/../../etc/passwd', 'a/./b', 'a//b', 'a/', '', '..', '.']) assert.ok(pathProblem(p), `traversal: ${JSON.stringify(p)}`);
|
||
for (const p of ['/etc/passwd', 'C:/Windows/x', 'C:\\Windows\\x', 'c:x', '\\\\server\\share\\x', 'server\\index.js', 'a\0b', 'a/b.', 'a/b ']) {
|
||
assert.ok(pathProblem(p), `absolute / not plain: ${JSON.stringify(p)}`);
|
||
}
|
||
for (const p of [null, undefined, 3, {}]) assert.ok(pathProblem(p) && removalProblem(p), String(p));
|
||
for (const p of ['.git/config', '.cache/assets-report.json', 'logs/server.log', '.venv-extract/bin/python', 'tools/local-extract/.venv/x',
|
||
'data/.env', '.env.local', 'scripts/service.env.cmd', 'node_modules/.cache/x', 'handoff/HANDOFF.md', '.claude/settings.json',
|
||
MANIFEST_FILE, UPDATE_FILE, APPLIED_FILE]) {
|
||
assert.ok(removalProblem(p), `never removed: ${p}`);
|
||
assert.equal(pathProblem(p), null, `${p} may still be listed (a manifest path is only read)`);
|
||
}
|
||
for (const p of ['../x', '/etc/passwd', 'C:/x']) assert.ok(removalProblem(p), p);
|
||
for (const p of ['server/old.js', 'packs/qab/pack.json', 'node_modules/three/build/three.cjs', 'public/assets/ui/b c.png', 'data/i18n/zh-TW.json',
|
||
'docs/research/07-assets.json', 'README.md']) {
|
||
assert.equal(removalProblem(p), null, p);
|
||
}
|
||
// a dependency's own .env.example can be verified (MANIFEST.json) but an update never deletes it
|
||
const m = parseManifest(JSON.stringify({ format: 1, app: '0.2.1', files: { 'node_modules/x/.env.example': dg('x') } }));
|
||
assert.deepEqual([...m.files.keys()], ['node_modules/x/.env.example']);
|
||
});
|
||
|
||
const H = sha('x');
|
||
const goodUpdate = (patch = {}) => JSON.stringify({ format: 1, app: '0.2.1', from: ['0.2.0'], count: 1, bytes: 1,
|
||
files: { 'server/a.js': { size: 1, sha256: H } }, removed: [{ path: 'server/old.js', sha256: [H] }], ...patch });
|
||
|
||
test('UPDATE.json is read strictly: one bad entry and the whole file is refused', () => {
|
||
const u = parseUpdate(goodUpdate());
|
||
assert.equal(u.app, '0.2.1');
|
||
assert.deepEqual(u.removed, [{ path: 'server/old.js', sha256: [H] }]);
|
||
assert.deepEqual([...u.files.keys()], ['server/a.js']);
|
||
const bad = {
|
||
'not JSON': '{"format": 1,',
|
||
'not an object': '[]',
|
||
'another format': goodUpdate({ format: 2 }),
|
||
'no version': goodUpdate({ app: 'latest' }),
|
||
'no base': goodUpdate({ from: [] }),
|
||
'no count': goodUpdate({ count: -1 }),
|
||
'files not an object': goodUpdate({ files: [] }),
|
||
'a file without a hash': goodUpdate({ files: { 'server/a.js': { size: 1 } } }),
|
||
'a file outside the install': goodUpdate({ files: { '../a.js': { size: 1, sha256: H } } }),
|
||
'removed not a list': goodUpdate({ removed: {} }),
|
||
'traversal': goodUpdate({ removed: [{ path: '../../outside.txt', sha256: [H] }] }),
|
||
'absolute': goodUpdate({ removed: [{ path: '/etc/hosts', sha256: [H] }] }),
|
||
'a user folder': goodUpdate({ removed: [{ path: 'logs/server.log', sha256: [H] }] }),
|
||
'twice': goodUpdate({ removed: [{ path: 'server/old.js', sha256: [H] }, { path: 'server/old.js', sha256: [H] }] }),
|
||
'shipped and removed': goodUpdate({ removed: [{ path: 'server/a.js', sha256: [H] }] }),
|
||
'no hash': goodUpdate({ removed: [{ path: 'server/old.js', sha256: [] }] }),
|
||
'a bad hash': goodUpdate({ removed: [{ path: 'server/old.js', sha256: ['abc'] }] }),
|
||
'a bare path': goodUpdate({ removed: ['server/old.js'] }),
|
||
};
|
||
for (const [what, text] of Object.entries(bad)) assert.throws(() => parseUpdate(text), undefined, what);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// The boot step
|
||
|
||
/** An install of the new version: `files` on disk, MANIFEST.json over the non-art ones, UPDATE.json as given. */
|
||
function newInstall(onDisk, { app = '0.2.1', from = ['0.2.0'], shipped = [], removed = [], manifest = null } = {}) {
|
||
const dir = tmp('inst');
|
||
for (const [rel, body] of Object.entries(onDisk)) put(dir, rel, body);
|
||
const listed = new Map();
|
||
for (const rel of manifest ?? Object.keys(onDisk).filter(inManifest)) listed.set(rel, digestFile(path.join(dir, rel)));
|
||
fs.writeFileSync(path.join(dir, MANIFEST_FILE), manifestText({ app, files: listed }));
|
||
const ship = new Map(shipped.map((rel) => [rel, digestFile(path.join(dir, rel))]));
|
||
fs.writeFileSync(path.join(dir, UPDATE_FILE), updateText({ app, from, files: ship, removed }));
|
||
return dir;
|
||
}
|
||
|
||
test('boot apply: deletes the removed files that hold a base\'s bytes, keeps changed ones and players\' packs, renames UPDATE.json, then does nothing', () => {
|
||
const dir = newInstall({
|
||
'server/index.js': 'new', 'shared/constants.js': "APP_VERSION='0.2.1'", 'README.md': 'readme',
|
||
// left over from 0.2.0
|
||
'server/old.js': 'old', 'server/lib/only/gone.js': 'gone', 'data/old.json': 'edited here', 'packs/qab/pack.json': 'shipped pack',
|
||
'packs/mine/pack.json': 'a player\'s own pack',
|
||
}, {
|
||
shipped: ['server/index.js', 'shared/constants.js'],
|
||
manifest: ['server/index.js', 'shared/constants.js', 'README.md'],
|
||
removed: [
|
||
{ path: 'server/old.js', sha256: [sha('old')] },
|
||
{ path: 'server/lib/only/gone.js', sha256: [sha('gone')] },
|
||
{ path: 'data/old.json', sha256: [sha('as shipped')] },
|
||
{ path: 'packs/qab/pack.json', sha256: [sha('shipped pack')] },
|
||
{ path: 'packs/mine/pack.json', sha256: [sha('the 0.2.0 pack of that name')] },
|
||
{ path: 'server/never-here.js', sha256: [sha('x')] },
|
||
],
|
||
});
|
||
try {
|
||
const { lines, log } = quiet();
|
||
const r = applyPendingUpdate(dir, { log });
|
||
assert.equal(r.state, 'applied', JSON.stringify(lines));
|
||
assert.deepEqual(r.deleted.sort(), ['packs/qab/pack.json', 'server/lib/only/gone.js', 'server/old.js']);
|
||
assert.deepEqual(r.kept.sort(), ['data/old.json', 'packs/mine/pack.json'], 'other bytes than any base shipped: left alone');
|
||
assert.equal(r.absent, 1);
|
||
assert.ok(!has(dir, 'server/old.js') && !has(dir, 'server/lib'), 'the folders it leaves empty go too');
|
||
assert.ok(has(dir, 'server/index.js') && has(dir, 'packs/mine/pack.json') && has(dir, 'data/old.json'));
|
||
assert.ok(!has(dir, 'packs/qab') && has(dir, 'packs'), 'an emptied pack folder goes, the packs folder with another pack stays');
|
||
assert.ok(!has(dir, UPDATE_FILE) && has(dir, APPLIED_FILE), 'UPDATE.json → .update-applied.json');
|
||
assert.equal(parseUpdate(read(dir, APPLIED_FILE)).app, '0.2.1');
|
||
assert.match(lines.map((l) => l[1]).join('\n'), /已更新到 v0\.2\.1:3 个文件校验通过,删除 3 个旧文件 · updated to v0\.2\.1/);
|
||
assert.ok(lines.some(([k, m]) => k === 'warn' && /保留了 2 个/.test(m)), 'the kept files are named');
|
||
// idempotent: nothing pending, nothing changes
|
||
const before = fs.readdirSync(dir, { recursive: true }).sort();
|
||
assert.equal(applyPendingUpdate(dir, { log }).state, 'none');
|
||
assert.deepEqual(fs.readdirSync(dir, { recursive: true }).sort(), before);
|
||
} finally {
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('boot apply: a malformed UPDATE.json is logged and skipped — nothing deleted, the file kept, never a throw', () => {
|
||
const outside = tmp('outside');
|
||
put(outside, 'victim.txt', 'keep me');
|
||
const dir = newInstall({ 'server/index.js': 'new', 'server/old.js': 'old' });
|
||
try {
|
||
const cases = [
|
||
'{"format": 1, "app": "0.2.1"', // truncated
|
||
JSON.stringify({ format: 1, app: '0.2.1', from: ['0.2.0'], count: 0, bytes: 0, files: {},
|
||
removed: [{ path: 'server/old.js', sha256: [sha('old')] }, { path: `../${path.basename(outside)}/victim.txt`, sha256: [sha('keep me')] }] }),
|
||
JSON.stringify({ format: 1, app: '0.2.1', from: ['0.2.0'], count: 0, bytes: 0, files: {}, removed: [{ path: path.join(outside, 'victim.txt'), sha256: [sha('keep me')] }] }),
|
||
JSON.stringify({ format: 99 }),
|
||
'null',
|
||
];
|
||
for (const text of cases) {
|
||
fs.writeFileSync(path.join(dir, UPDATE_FILE), text);
|
||
const { lines, log } = quiet();
|
||
const r = applyPendingUpdate(dir, { log });
|
||
assert.equal(r.state, 'malformed', text);
|
||
assert.ok(has(dir, UPDATE_FILE) && !has(dir, APPLIED_FILE), 'left in place');
|
||
assert.ok(has(dir, 'server/old.js'), 'one bad entry: not even the good ones are deleted');
|
||
assert.equal(read(outside, 'victim.txt'), 'keep me');
|
||
assert.match(lines[0][1], /UPDATE\.json 格式不对,已跳过,服务器按现有文件启动/);
|
||
}
|
||
fs.rmSync(path.join(dir, UPDATE_FILE));
|
||
fs.mkdirSync(path.join(dir, UPDATE_FILE)); // unreadable (a folder)
|
||
assert.equal(applyPendingUpdate(dir, quiet()).state, 'malformed');
|
||
} finally {
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
fs.rmSync(outside, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('boot apply: a file the server runs that does not match MANIFEST.json stops the start; nothing is deleted, UPDATE.json stays', () => {
|
||
const dir = newInstall({ 'server/index.js': 'new', 'data/chess.json': '{}', 'server/old.js': 'old' }, {
|
||
manifest: ['server/index.js', 'data/chess.json'],
|
||
removed: [{ path: 'server/old.js', sha256: [sha('old')] }],
|
||
});
|
||
try {
|
||
put(dir, 'data/chess.json', '{"old": 1}'); // the base's copy: the update was extracted over another version
|
||
fs.rmSync(path.join(dir, 'server/index.js'));
|
||
const { lines, log } = quiet();
|
||
const r = applyPendingUpdate(dir, { log });
|
||
assert.equal(r.state, 'failed');
|
||
assert.deepEqual(r.fatal, ['data/chess.json', 'server/index.js']);
|
||
assert.ok(has(dir, 'server/old.js') && has(dir, UPDATE_FILE) && !has(dir, APPLIED_FILE));
|
||
const text = lines.filter(([k]) => k === 'error').map((l) => l[1]).join('\n');
|
||
assert.match(text, /这个更新包只能覆盖在 v0\.2\.0 的整合包安装上(检测到 2 个文件与 v0\.2\.1 不一致或缺失,例:data\/chess\.json、server\/index\.js)。服务器没有启动:请下载 v0\.2\.1 的完整包/);
|
||
assert.match(text, /This update package only applies over a v0\.2\.0 install \(2 files differ from v0\.2\.1 or are missing/);
|
||
// fixed (the right files extracted): the next start applies it
|
||
put(dir, 'data/chess.json', '{}');
|
||
put(dir, 'server/index.js', 'new');
|
||
assert.equal(applyPendingUpdate(dir, quiet()).state, 'applied');
|
||
assert.ok(!has(dir, 'server/old.js'));
|
||
} finally {
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('boot apply: a missing or foreign MANIFEST.json stops the start; docs-only differences and missing art are warnings', () => {
|
||
const a = newInstall({ 'server/index.js': 'new' });
|
||
const b = newInstall({ 'server/index.js': 'new' });
|
||
const c = newInstall({ 'server/index.js': 'new', 'README.md': 'readme', 'scripts/start.sh': 'sh', 'node_modules/ws/README.md': 'r', 'public/assets/x.png': 'x' },
|
||
{ shipped: ['public/assets/x.png'] });
|
||
try {
|
||
fs.rmSync(path.join(a, MANIFEST_FILE));
|
||
let q = quiet();
|
||
assert.equal(applyPendingUpdate(a, { log: q.log }).state, 'failed');
|
||
assert.match(q.lines.map((l) => l[1]).join('\n'), /没有完整解压(MANIFEST\.json is missing)/);
|
||
fs.writeFileSync(path.join(b, MANIFEST_FILE), manifestText({ app: '0.2.2', files: new Map() }));
|
||
q = quiet();
|
||
assert.equal(applyPendingUpdate(b, { log: q.log }).state, 'failed');
|
||
assert.match(q.lines.map((l) => l[1]).join('\n'), /MANIFEST\.json belongs to v0\.2\.2/);
|
||
put(c, 'README.md', 'edited');
|
||
put(c, 'scripts/start.sh', 'edited');
|
||
put(c, 'node_modules/ws/README.md', 'edited');
|
||
fs.rmSync(path.join(c, 'public/assets/x.png'));
|
||
q = quiet();
|
||
const r = applyPendingUpdate(c, { log: q.log });
|
||
assert.equal(r.state, 'applied');
|
||
assert.deepEqual(r.notes, ['README.md', 'node_modules/ws/README.md', 'scripts/start.sh']);
|
||
assert.deepEqual(r.art, ['public/assets/x.png']);
|
||
const warned = q.lines.filter(([k]) => k === 'warn').map((l) => l[1]).join('\n');
|
||
assert.match(warned, /3 个说明 \/ 脚本文件与 v0\.2\.1 不一致(不影响运行/);
|
||
assert.match(warned, /1 个素材文件没有完整解压/);
|
||
} finally {
|
||
for (const d of [a, b, c]) fs.rmSync(d, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('boot apply: a local-art manifest the update brought to an install without that art is named (a lite install)', () => {
|
||
const local = JSON.stringify({ groups: { g: { x: { path: '/assets/local/g/x.webp' }, y: { path: '/assets/local/g/y.webp' } } } });
|
||
const dir = newInstall({ 'server/index.js': 'new', 'data/local-assets.json': local, 'public/assets/local/g/y.webp': 'y' },
|
||
{ shipped: ['data/local-assets.json', 'public/assets/local/g/y.webp'] });
|
||
try {
|
||
const q = quiet();
|
||
const r = applyPendingUpdate(dir, { log: q.log });
|
||
assert.equal(r.state, 'applied');
|
||
assert.deepEqual(r.localGaps, ['public/assets/local/g/x.webp']);
|
||
assert.match(q.lines.map((l) => l[1]).join('\n'), /本地客户端素材不完整:data\/local-assets\.json 列出的 1 个文件不在这台电脑上/);
|
||
} finally {
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('deleting removed files: never through a symlink, never a folder, never a file of the new version that differs only in case', () => {
|
||
const dir = tmp('del');
|
||
const outside = tmp('outside');
|
||
try {
|
||
put(outside, 'x.js', 'old');
|
||
put(dir, 'server/dir.js/inner.js', 'old');
|
||
put(dir, 'public/assets/X.png', 'png');
|
||
let links = true;
|
||
try {
|
||
fs.symlinkSync(path.join(outside, 'x.js'), path.join(dir, 'server', 'link.js'));
|
||
fs.symlinkSync(outside, path.join(dir, 'linked'), 'dir');
|
||
} catch { links = false; } // Windows without the symlink privilege
|
||
const r = deleteRemoved(dir, [
|
||
{ path: 'server/link.js', sha256: [sha('old')] },
|
||
{ path: 'linked/x.js', sha256: [sha('old')] },
|
||
{ path: 'server/dir.js', sha256: [sha('old')] },
|
||
{ path: 'public/assets/X.png', sha256: [sha('png')] },
|
||
{ path: '../escape.js', sha256: [sha('old')] },
|
||
], new Set(['public/assets/x.png']));
|
||
assert.deepEqual(r.deleted, []);
|
||
assert.equal(read(outside, 'x.js'), 'old', 'the symlink targets are untouched');
|
||
assert.ok(has(dir, 'public/assets/X.png') && has(dir, 'server/dir.js/inner.js'));
|
||
const why = r.skipped.join('\n');
|
||
if (links) {
|
||
assert.match(why, /server\/link\.js \(not a regular file\)/);
|
||
assert.match(why, /linked\/x\.js \(outside the install\)/);
|
||
}
|
||
assert.match(why, /server\/dir\.js \(not a regular file\)/);
|
||
assert.match(why, /public\/assets\/X\.png \(a file of the new version\)/);
|
||
assert.match(why, /\.\.\/escape\.js/);
|
||
} finally {
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
fs.rmSync(outside, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// The manifest check
|
||
|
||
test('manifest verification: missing files, other sizes, same size other bytes, a folder in place of a file; doctor\'s states', () => {
|
||
const dir = tmp('verify');
|
||
try {
|
||
put(dir, 'a.js', 'aaaa');
|
||
put(dir, 'b.js', 'bbbb');
|
||
put(dir, 'c.js', 'cccc');
|
||
fs.mkdirSync(path.join(dir, 'd.js'));
|
||
const v = verifyFiles(dir, new Map([['a.js', dg('aaaa')], ['b.js', dg('bbbbb')], ['c.js', dg('CCCC')], ['d.js', dg('d')], ['e.js', dg('e')]]));
|
||
assert.deepEqual(v, { checked: 5, missing: ['d.js', 'e.js'], mismatched: ['b.js', 'c.js'] });
|
||
|
||
assert.deepEqual(checkInstall(dir), { state: 'none', pending: false }, 'no MANIFEST.json: a source checkout');
|
||
fs.writeFileSync(path.join(dir, MANIFEST_FILE), '{');
|
||
assert.equal(checkInstall(dir).state, 'broken');
|
||
const listed = new Map([['a.js', dg('aaaa')], ['README.md', dg('readme')]]);
|
||
put(dir, 'README.md', 'readme');
|
||
fs.writeFileSync(path.join(dir, MANIFEST_FILE), manifestText({ app: '0.2.1', files: listed }));
|
||
assert.deepEqual(checkInstall(dir), { state: 'ok', pending: false, app: '0.2.1', checked: 2, missing: [], mismatched: [], runtime: [], other: [] });
|
||
put(dir, 'README.md', 'edited');
|
||
put(dir, 'a.js', 'AAAA');
|
||
put(dir, UPDATE_FILE, '{}');
|
||
const c = checkInstall(dir);
|
||
assert.equal(c.state, 'mismatch');
|
||
assert.deepEqual([c.runtime, c.other, c.pending], [['a.js'], ['README.md'], true]);
|
||
} finally {
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('what MANIFEST.json lists and what stops an update', () => {
|
||
for (const f of ['public/assets/char/a.png', 'public/fonts/fonts.css', 'data/assets.json', 'data/local-assets.json']) {
|
||
assert.ok(isSetupArt(f) && !inManifest(f), f);
|
||
}
|
||
for (const f of [MANIFEST_FILE, UPDATE_FILE, APPLIED_FILE]) assert.ok(!inManifest(f), f);
|
||
for (const f of ['server/index.js', 'data/chess.json', 'node_modules/ws/index.js', 'public/vendor/pixi.min.js', 'README.md', 'packs/index.json']) {
|
||
assert.ok(inManifest(f), f);
|
||
}
|
||
for (const f of ['server/index.js', 'shared/constants.js', 'data/chess.json', 'public/js/main.js', 'public/vendor/pixi.min.js', 'packs/index.json',
|
||
'node_modules/ws/package.json', 'node_modules/ws/lib/websocket.js', 'node_modules/x/license.js', 'docs/research/03-operators.json', 'package.json']) {
|
||
assert.ok(affectsRuntime(f), `${f} stops an update`);
|
||
}
|
||
for (const f of ['README.md', 'CHANGELOG.md', 'LICENSE', 'NOTICE.md', 'docs/DEPLOY.md', 'node_modules/ws/README.md', 'node_modules/three/LICENSE',
|
||
'node_modules/preact/src/index.d.ts', 'node_modules/x/dist/x.js.map', 'scripts/start-windows.bat', 'tools/setup.mjs',
|
||
'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'package-lock.json', 'node_modules/.package-lock.json']) {
|
||
assert.ok(!affectsRuntime(f), `${f} only warns`);
|
||
}
|
||
});
|