'use strict'; /** * 后台 SPA 构建完整性检查契约。 * * src/Admin/Admin.php::find_missing_admin_chunks() 必须遍历整张模块图, * 而不是只扫 index.js:懒加载的页面 chunk 还会拉自己的依赖 * (shared-api、PageHeader…),只扫入口时这些文件缺失照样"看起来正常", * 直到用户在后台点开对应菜单才 404。CSS 也必须纳入——入口扫描从来不检查 * 样式表,缺一个页面样式只会让后台变裸样式,不会有任何告警。 */ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const root = path.resolve(__dirname, '..', '..'); const adminPhp = fs.readFileSync(path.join(root, 'src/Admin/Admin.php'), 'utf8'); const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); const methodBody = (() => { const start = adminPhp.indexOf('private static function find_missing_admin_chunks'); assert.notEqual(start, -1, 'find_missing_admin_chunks() must exist in src/Admin/Admin.php'); const open = adminPhp.indexOf('{', start); let depth = 0; for (let i = open; i < adminPhp.length; i++) { if (adminPhp[i] === '{') depth++; else if (adminPhp[i] === '}') { depth--; if (depth === 0) return adminPhp.slice(open, i + 1); } } throw new Error('unbalanced braces parsing find_missing_admin_chunks()'); })(); test('the chunk check walks the module graph instead of scanning the entry only', () => { // A work queue + visited set is what makes the walk transitive. assert.match(methodBody, /\$queue/, 'must seed a traversal queue'); assert.match(methodBody, /\$visited/, 'must track visited files to terminate'); assert.match(methodBody, /while\s*\(/, 'must loop until the queue drains'); // The dependency of a chunk must itself be re-read and followed. assert.match(methodBody, /\$queue\[\]\s*=\s*\$dep/, 'discovered deps must be queued for traversal'); }); test('the chunk check covers CSS as well as JS', () => { // A missing page stylesheet used to be invisible; the specifier must match both. assert.match(methodBody, /\.\(\?:js\|css\)/, 'specifier pattern must match .js and .css'); }); test('a missing entry file is reported rather than silently passing', () => { assert.match(methodBody, /!is_file\(\$entry_file\)/, 'must guard an absent index.js'); }); test('the real build graph is complete', () => { // Guard against committing a build/ whose graph does not resolve. const buildDir = path.join(root, 'assets/admin/build'); if (!fs.existsSync(path.join(buildDir, 'index.js'))) { // build/ is gitignored and deploy-only; nothing to assert when absent. return; } const files = new Set(fs.readdirSync(buildDir)); const referenced = new Set(); const walk = (file) => { if (referenced.has(file)) return; referenced.add(file); if (!file.endsWith('.js') || !files.has(file)) return; const src = fs.readFileSync(path.join(buildDir, file), 'utf8'); for (const m of src.matchAll(/\.\/([A-Za-z0-9_.-]+\.(?:js|css))/g)) { walk(m[1]); } }; walk('index.js'); const missing = [...referenced].filter((f) => !files.has(f)); assert.deepEqual(missing, [], `build/ is missing: ${missing.join(', ')}`); });