Regression / regression (push) Canceled after 0s
## 品牌标识(本次会话) 起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」, 那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」, 移动端站已是「K」;移动端文档站则完全没有 favicon)。 - 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边; 描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」 - 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark); 顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28)) - 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg) - 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留) - 移动端 7 个模板补 favicon(此前计数 0) 验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/ theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致; 两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。 ## 并行会话成果(本次一并入库) - 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具 + IconPreview 预览页 + ICON-SPEC.md 冻结规格 - 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁 - PC 组件:103 个大后台组件 / 组件11 批次 - uni-app:PC 端试点 + 移动端端实现 + 真实编译验证 ## 工程 - .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库) - CHANGELOG 补品牌标识条目 - ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
234 lines
15 KiB
JavaScript
234 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* verify-versions.mjs — 文档站版本切换的静态门禁(只读,不启动服务)。
|
||
*
|
||
* 覆盖三件事:
|
||
* 1) 清单契约:site/versions.json 的结构、顺序(最新版在首位)、path 形态;
|
||
* 2) 入口契约:index.html 的版本控件标记 + app.js 的加载/渲染/绑定实现;
|
||
* 3) 快照契约:磁盘上每个 site/<x.y.z>/ 目录都必须是「能独立打开」的站点快照。
|
||
*
|
||
* 运行时行为(点一下真的跳过去)由 tools/verify-site-routes.mjs 用真浏览器覆盖。
|
||
*/
|
||
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
|
||
const ROOT = join(import.meta.dirname, '..');
|
||
const SITE = join(ROOT, 'site');
|
||
const failures = [];
|
||
const checks = [];
|
||
function check(name, ok, detail = '') {
|
||
checks.push({ name, ok, detail });
|
||
if (!ok) failures.push(`${name}${detail ? `: ${detail}` : ''}`);
|
||
}
|
||
|
||
const app = readFileSync(join(SITE, 'app.js'), 'utf8');
|
||
const index = readFileSync(join(SITE, 'index.html'), 'utf8');
|
||
const devServer = readFileSync(join(SITE, 'dev-server.js'), 'utf8');
|
||
const nginx = readFileSync(join(ROOT, 'nginx.conf'), 'utf8');
|
||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
||
const manifestPath = join(SITE, 'versions.json');
|
||
|
||
/* ---------- 1. 清单契约 ---------- */
|
||
check('versions.json exists', existsSync(manifestPath), 'run npm run precompute');
|
||
let manifest = null;
|
||
if (existsSync(manifestPath)) {
|
||
try {
|
||
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||
} catch (e) {
|
||
check('versions.json parses', false, String(e.message).slice(0, 120));
|
||
}
|
||
}
|
||
if (manifest) {
|
||
const list = manifest.versions;
|
||
check('versions.json has entries', Array.isArray(list) && list.length > 0);
|
||
check('siteDir is site', manifest.siteDir === 'site',
|
||
`站点根靠 pathname 里第一段 "/site/" 反推,目录名改了会全站 404(实际 ${manifest.siteDir})`);
|
||
check('latest flag points at the first entry', !!(list && list[0] && list[0].latest === true));
|
||
check('latest field matches the first entry', !!(list && list[0] && manifest.latest === list[0].version));
|
||
const badVersion = (list || []).filter((v) => !/^\d+\.\d+\.\d+$/.test(String(v.version || '')));
|
||
check('every version is x.y.z', badVersion.length === 0, badVersion.map((v) => v.version).join(', '));
|
||
const badPath = (list || []).filter((v) => {
|
||
const p = String(v.path || '');
|
||
return p !== '..' && !/^\d+\.\d+\.\d+$/.test(p);
|
||
});
|
||
check('every path is ".." or a snapshot dir', badPath.length === 0, badPath.map((v) => v.path).join(', '));
|
||
check('package.json version is in the manifest', (list || []).some((v) => v.version === pkg.version),
|
||
`package.json=${pkg.version} vs ${(list || []).map((v) => v.version).join('/')}`);
|
||
|
||
/* 归档目录必须真在磁盘上(仓库根 /<x.y.z>/site/)—— 清单里出现幽灵版本就是「点进去 404」 */
|
||
const ghosts = (list || []).filter((v) => v.path !== '..' && !existsSync(join(ROOT, v.path, 'site', 'index.html')));
|
||
check('no ghost versions in the manifest', ghosts.length === 0, ghosts.map((v) => v.version).join(', '));
|
||
|
||
/* 反向:磁盘上有快照却没入清单(precompute 未重跑) */
|
||
const onDisk = readdirSync(ROOT, { withFileTypes: true })
|
||
.filter((e) => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name))
|
||
.filter((e) => existsSync(join(ROOT, e.name, 'site', 'index.html')))
|
||
.map((e) => e.name);
|
||
const listed = new Set((list || []).map((v) => String(v.path)));
|
||
const unlisted = onDisk.filter((v) => !listed.has(v));
|
||
check('every snapshot on disk is listed', unlisted.length === 0,
|
||
unlisted.length ? `${unlisted.join(', ')}(重跑 npm run precompute)` : '');
|
||
|
||
/* 只有当前版本时的形态:单版本必须能干净降级 —— 角标退回纯展示、菜单与窄屏 select 都不出现。
|
||
(这里不禁止磁盘上存在快照:以后归档历史版本是正常操作,只要求清单与磁盘一致。) */
|
||
const single = list && list.length === 1;
|
||
check('single-version manifest is the root site', !single || (list[0].path === '..' && list[0].latest === true),
|
||
single ? JSON.stringify(list[0]) : `${list ? list.length : 0} versions`);
|
||
}
|
||
|
||
/* ---------- 2. 入口契约 ---------- */
|
||
check('version trigger markup', /id="version-trigger"/.test(index) && /aria-haspopup="listbox"/.test(index));
|
||
check('version chip is inside the trigger', /id="version-trigger"[\s\S]{0,400}?id="ver"/.test(index));
|
||
check('version menu markup', /id="version-menu"[\s\S]{0,200}?role="listbox"/.test(index));
|
||
/* 下拉面板是绝对定位:必须与触发角标同处一个定位容器(.ver-picker:position relative),
|
||
否则面板会锚到页面而不是角标下方 —— 实测跑到了左上角。 */
|
||
check('version menu has a positioned anchor', /class="ver-picker"[\s\S]{0,300}?id="version-trigger"[\s\S]{0,600}?id="version-menu"/.test(index)
|
||
&& /\.ver-picker\s*\{\s*position:\s*relative/.test(readFileSync(join(SITE, 'style.css'), 'utf8')));
|
||
check('version menu anchors below the trigger', /\.ver-menu\s*\{[\s\S]{0,300}?top:\s*calc\(100% \+ 8px\)/.test(readFileSync(join(SITE, 'style.css'), 'utf8')));
|
||
check('version menu opts use the dedicated classes', /ver-opt-num/.test(app) && /ver-opt-tag/.test(app) && /ver-opt-check/.test(app));
|
||
check('version menu starts hidden', /id="version-menu"[^>]*class="[^"]*hidden/.test(index));
|
||
check('narrow-screen version select', /id="ver-select-wrap"/.test(index) && /id="ver-select"/.test(index));
|
||
check('narrow-screen select starts hidden', /id="ver-select-wrap"[^>]*hidden/.test(index));
|
||
/* ≤560px 顶栏放不下版本控件(实测溢出),抽屉是这一档的入口:必须有标记、且默认不显示 */
|
||
check('drawer version select markup', /id="drawer-ver"/.test(index) && /id="drawer-ver-select"/.test(index));
|
||
check('drawer version select starts hidden', /id="drawer-ver"[^>]*hidden/.test(index));
|
||
check('drawer version select shares the option list', /\[select, \$\(['"]drawer-ver-select['"]\)\]/.test(app));
|
||
|
||
check('manifest loader exists', /function\s+loadVersionManifest\s*\(/.test(app) && /versionsManifestUrl/.test(app));
|
||
check('manifest fetched with no-store', /cache:\s*'no-store'/.test(app));
|
||
check('menu renderer exists', /function\s+renderVersionMenu\s*\(/.test(app));
|
||
check('interaction binding exists', /function\s+bindVersionSelect\s*\(/.test(app) && /bindVersionSelect\(\);/.test(app));
|
||
check('version switch preserves the route', /function\s+goVersion\s*\([\s\S]{0,400}?routePath\(\)/.test(app));
|
||
/* 路径校验按「行为」验,而不是按写法验。2026-09-20 实测:实现改写成
|
||
「sentinel + 严格 semver 白名单」后,原先匹配某种写法的检查误报失败,
|
||
而实际行为更强(只放行 '..' 与单段 x.y.z;反斜杠、'//'、多段路径一律拒绝)。
|
||
这里把函数体抽出来跑输入矩阵 —— 判据仍是「只有站点根 '..' 与单段版本目录名合法」。 */
|
||
const vpFn = app.match(/function\s+isValidVersionPath\s*\(([^)]*)\)\s*\{([\s\S]*?)\n {2}\}/);
|
||
let vpOk = false;
|
||
let vpDetail = 'isValidVersionPath 未找到';
|
||
if (vpFn) {
|
||
try {
|
||
const sentinel = (app.match(/CURRENT_PATH_SENTINEL\s*=\s*'([^']*)'/) || [])[1] || '..';
|
||
const fn = new Function(vpFn[1], 'CURRENT_PATH_SENTINEL', vpFn[2]);
|
||
const cases = [
|
||
[sentinel, true], ['1.4.1', true], ['10.20.30', true],
|
||
['1.4', false], ['latest', false], ['', false],
|
||
['1.4.1/x', false], ['x/1.4.1', false], ['../../etc', false],
|
||
['1.4.1\\x', false], ['1.4.1//x', false],
|
||
];
|
||
const bad = cases.filter(([input, want]) => {
|
||
let got;
|
||
try { got = !!fn(input, sentinel); } catch (e) { got = 'throw:' + e.message; }
|
||
return got !== want;
|
||
});
|
||
vpOk = bad.length === 0;
|
||
vpDetail = bad.map(([i, w]) => `${JSON.stringify(i)} 期望 ${w}`).join('; ');
|
||
} catch (e) { vpDetail = '抽取函数体失败: ' + e.message; }
|
||
}
|
||
check('path validation keeps the single-".." rule(按行为)', vpOk, vpDetail);
|
||
/* 只有「清单拿不到任何版本」才退化为纯角标(data-single=1 + pointer-events:none);
|
||
清单里有版本(哪怕只有一版)就是正常下拉 —— 用户要求下拉始终存在,里面就一项。 */
|
||
check('drawer/none-list mode degrades to a plain chip',
|
||
/data-single/.test(app) && /versionsState\.interactive\s*=\s*!!\(list\s*&&\s*list\.length\s*>\s*0\)/.test(app)
|
||
&& /if \(!list \|\| !list\.length\)/.test(app));
|
||
/* 「取不到清单就静默降级」按**行为**判:实现允许多候选(站点根一份 + 快照自带一份,按 rank 定优先级),
|
||
但每个 fetch 必须各自吞掉失败、最终以「可用的那份」或 null 回调。
|
||
2026-09-20 实测:实现改成多候选后,原先按 `catch(function () { done(null)` 写法匹配的判据误报失败,
|
||
而行为完全符合本意(`.catch(...)` 里只留注释 + `.then(settle, settle)`)。
|
||
反例(必须仍然失败):删掉那个吞异常的 catch,或把回调改成抛错。 */
|
||
check('fetch failure degrades silently(按行为)',
|
||
/function\s+loadVersionManifest\s*\([\s\S]{0,1600}?\.catch\(function\s*\(\)\s*\{[\s\S]{0,120}?\}\)/.test(app) &&
|
||
/done\((?:best\s*\?\s*best\.list\s*:\s*)?null\)/.test(app));
|
||
|
||
/* 版本号不得硬编码进 app.js:角标文案只能来自构建数据或清单。
|
||
先剥注释再扫:2026-09-20 实测 文档注释里的路由示例(/1.4.1/site/component/button/h5)
|
||
被当成硬编码字面量误报 —— 判据本意是「**代码**里不得出现字面量版本号」。
|
||
反例(必须仍然失败):代码里出现 var v = 'v2.0.0'; */
|
||
const appCode = app.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
|
||
const hardcoded = [...appCode.matchAll(/['"]v?(\d+\.\d+\.\d+)['"]/g)].map((m) => m[1]);
|
||
check('no hardcoded version strings in app.js(已剥注释)', hardcoded.length === 0, hardcoded.join(', '));
|
||
|
||
/* ---------- 3. 服务器契约 ---------- */
|
||
check('dev-server allows snapshot dirs', /VERSION_DIR\s*=\s*\/\^\\d\+\\\.\\d\+\\\.\\d\+\$\//.test(devServer));
|
||
check('dev-server falls back to the versioned shell', /spaMatch\[1\]/.test(devServer));
|
||
check('dev-server redirects version roots', /verRoot/.test(devServer));
|
||
check('nginx has the versioned fallback', /location ~ \^\/\(\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\)\/site\//.test(nginx));
|
||
/* 快照缺失时必须是干净的 404,不能是 500。
|
||
2026-09-20 实测:本轮发布把已清理的 1.4.1 快照一并移除后,`/1.4.1/site/` 返回 500 ——
|
||
容器日志是 "rewrite or internal redirection cycle while internally redirecting to
|
||
/1.4.1/site/index.html":try_files 的回落目标又匹配回同一 location,形成内部重定向环。
|
||
末位带 `=404` 后,前三个参数退化为文件存在性检查,缺快照即 404。 */
|
||
check(
|
||
'nginx versioned fallback terminates with =404(缺快照 → 404 而非 500)',
|
||
/try_files\s+\$uri\s+\$uri\/\s+\/\$1\/site\/index\.html\s+=404\s*;/.test(nginx),
|
||
'期望:try_files $uri $uri/ /$1/site/index.html =404;'
|
||
);
|
||
check('nginx marks versions.json no-store', /versions\\\.json[\s\S]{0,1200}?no-store/.test(nginx));
|
||
|
||
/* Dockerfile 必须**逐版本显式** COPY 快照到自己的子目录。
|
||
2026-09-20 实测踩到的坑:写成「通配源 + 站点根做目标」时(形如 COPY [0-9]x.[0-9]x.[0-9]x
|
||
加斜杠 → /usr/share/nginx/html/),COPY 复制的是目录**内容** → 快照的 site、frameworks、
|
||
sitemap.xml 原地合并进站点根,用快照那份旧构建覆盖掉本次构建(镜像里 data.js 是快照那次的
|
||
generated、根 frameworks 多出改名前的 21 个小写页),且 /1.4.1/ 目录根本没建出来 →
|
||
nginx 版本 location 自循环 → 500。归档新版本时忘了补 COPY 行,同样会 500 ——
|
||
故这里双向卡住:磁盘上的每个快照都要有 COPY 行,Dockerfile 里也不许留已删除版本的 COPY 行。 */
|
||
const dockerfile = existsSync(join(ROOT, 'Dockerfile')) ? readFileSync(join(ROOT, 'Dockerfile'), 'utf8') : '';
|
||
const snapDirs = existsSync(ROOT)
|
||
? readdirSync(ROOT, { withFileTypes: true })
|
||
.filter((e) => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name))
|
||
.map((e) => e.name)
|
||
: [];
|
||
const copyLines = [...dockerfile.matchAll(/^COPY\s+(\d+\.\d+\.\d+)\s+\/usr\/share\/nginx\/html\/\1\s*$/gm)].map((m) => m[1]);
|
||
check('no wildcard snapshot COPY in Dockerfile(通配会把快照内容合并进站点根)',
|
||
!/^COPY\s+\[0-9\][^\n]*\s+\/usr\/share\/nginx\/html\/\s*$/m.test(dockerfile));
|
||
for (const v of snapDirs) {
|
||
check(`Dockerfile copies snapshot ${v} into its own dir`, copyLines.includes(v));
|
||
}
|
||
const staleCopies = copyLines.filter((v) => !snapDirs.includes(v));
|
||
check('no Dockerfile COPY for missing snapshots', staleCopies.length === 0, staleCopies.join(', '));
|
||
|
||
/* ---------- 4. 快照契约 ---------- */
|
||
if (existsSync(manifestPath)) {
|
||
for (const v of (manifest && manifest.versions) || []) {
|
||
if (v.path === '..') continue;
|
||
const base = join(ROOT, v.path);
|
||
for (const rel of ['site/index.html', 'site/data.js', 'frameworks', '.design_library/kole-ui/colors_and_type.css', 'VERSION.json']) {
|
||
check(`snapshot ${v.version} has ${rel}`, existsSync(join(base, rel)));
|
||
}
|
||
/* 快照**不带**自己的 versions.json:那份是归档当时写下的,之后新归档的版本不会进去,
|
||
实测会让快照页误判成单版本、角标变不可点(回不到最新版)。运行时改为读部署根那份。 */
|
||
check(`snapshot ${v.version} carries no stale manifest`, !existsSync(join(base, 'site', 'versions.json')));
|
||
const marker = join(base, 'VERSION.json');
|
||
if (existsSync(marker)) {
|
||
try {
|
||
const info = JSON.parse(readFileSync(marker, 'utf8'));
|
||
check(`snapshot ${v.version} marker matches`, info.version === v.version, `marker=${info.version}`);
|
||
} catch (e) {
|
||
check(`snapshot ${v.version} marker parses`, false, String(e.message).slice(0, 120));
|
||
}
|
||
}
|
||
/* 规范原文与 agent 报告是仓库内部资料,不该进快照(同部署包排除规则) */
|
||
for (const forbidden of ['.design_library/kole-ui/specs', '.design_library/kole-ui/agent-reports']) {
|
||
check(`snapshot ${v.version} excludes ${forbidden}`, !existsSync(join(base, forbidden)));
|
||
}
|
||
const size = (p) => {
|
||
let total = 0;
|
||
for (const e of readdirSync(p, { withFileTypes: true })) {
|
||
const f = join(p, e.name);
|
||
total += e.isDirectory() ? size(f) : statSync(f).size;
|
||
}
|
||
return total;
|
||
};
|
||
const mb = size(base) / 1024 / 1024;
|
||
check(`snapshot ${v.version} is non-trivial`, mb > 1, `${mb.toFixed(1)} MB`);
|
||
}
|
||
}
|
||
|
||
for (const c of checks) console.log(`[versions] ${c.ok ? 'OK' : 'FAIL'} ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
|
||
if (failures.length) {
|
||
console.error(`\n[versions] FAIL (${failures.length})`);
|
||
failures.forEach((f) => console.error('- ' + f));
|
||
process.exit(1);
|
||
}
|
||
console.log(`\n[versions] OK — ${checks.length} checks passed`);
|