Files
danyang-match/ui-tests/ui-document-baseline-contracts.mjs
WorkBuddy 3c2b967274
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
release: freeze 2026-10-15 validated release baseline
2026-10-15 09:47:31 +08:00

231 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const read = (path) => readFileSync(resolve(root, path), 'utf8')
const spec = 'docs/superpowers/specs/2026-09-13-reference-driven-dual-client-ui-redesign-design.md'
const obsolete = [
'docs/superpowers/specs/2026-09-10-ui-v11-fidelity-design.md',
'docs/superpowers/specs/2026-09-11-codex-baseline-cleanup-design.md',
'docs/superpowers/specs/2026-09-11-dual-client-full-ui-redesign-design.md',
'docs/superpowers/plans/2026-09-10-ui-v11-fidelity.md',
'docs/superpowers/plans/2026-09-11-codex-baseline-cleanup.md',
'docs/superpowers/plans/2026-09-11-admin-full-ui-redesign.md',
'docs/superpowers/plans/2026-09-11-miniapp-full-ui-redesign.md',
'docs/superpowers/plans/2026-09-11-ui-foundation-and-pilot.md',
'docs/UI-V11-Visual-Acceptance-2026-09-10.md',
'docs/善缘丹阳 V1.1 前端 UI-UX 高保真原型修订说明.md',
'design-qa.md',
'artifacts/phase07-miniapp-home.png',
'artifacts/phase07-miniapp-discover.png',
'artifacts/phase07-home-comparison.png',
'artifacts/phase07-discover-comparison.png',
'artifacts/ui-v11/admin-counselor-1366.png',
'artifacts/ui-v11/admin-dashboard-1440.png',
'artifacts/ui-v11/admin-station-1366.png',
'artifacts/ui-v11/miniapp-home-320.png',
'artifacts/ui-v11/miniapp-home-375.png',
'artifacts/ui-v11/miniapp-home-390.png',
'artifacts/ui-v11/miniapp-home-430.png',
'artifacts/ui-v11/miniapp-home-preview.html',
'artifacts/tabbar-icons-preview.png',
'ui-tests/admin-redesign-contracts.mjs',
'ui-tests/miniapp-redesign-contracts.mjs',
]
const agents = read('AGENTS.md')
assert.ok(agents.includes(spec), 'AGENTS.md 未指向唯一视觉规格')
assert.doesNotMatch(agents, /善缘丹阳 V1\.1 前端 UI-UX 高保真原型修订说明\.md/, 'AGENTS.md 仍引用旧高保真规范')
for (const file of obsolete) {
assert.equal(existsSync(resolve(root, file)), false, `旧视觉入口仍存在: ${file}`)
}
const status = read('docs/STATUS.md')
const backlog = read('docs/JIRA_BACKLOG.md')
const phase07 = read('docs/Phase-07-V11-Recovery-Completion-Report-2026-09-13.md')
const matrix = read('docs/UI_REDESIGN_PAGE_MATRIX_V1.1.md')
// —— 文档基线口径 ——
// UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)。
// 四份文档必须一致记录该结论。若有人把口径改回"视觉否决 / 待重新实现 / REOPENED",
// 本门禁必须变红 —— 这是防止验收状态被静默回退的哨兵。
assert.match(status, /UI REDESIGN ACCEPTED BY USER/, 'STATUS 头部未记录 UI 已通过用户验收')
assert.match(status, /Phase 07[^\n]*已通过用户验收/, 'STATUS 未标记 Phase 07 UI 验收通过')
// JIRA 表格行必须已关闭;主表中的 REOPENED 不得复活(历史校正段落中的引用不算)。
assert.match(backlog, /^\|\s*Phase 07[^\n]*已通过用户验收/m, 'JIRA 未关闭 Phase 07 UI 条目')
assert.doesNotMatch(backlog, /^\|\s*Phase 07[^\n]*REOPENED/m, 'JIRA 主表仍将 Phase 07 UI 标记为 REOPENED')
// Phase 07 报告是历史文档:既要保留 2026-09-13 的失效判定,也要记录重新实现后的验收结论。
assert.match(phase07, /视觉结论已失效/, 'Phase 07 报告未保留 2026-09-13 历史判定')
assert.match(phase07, /首批 11 页于 2026-09-16 通过用户验收/, 'Phase 07 报告未记录重新实现后的验收结论')
assert.match(matrix, /小程序首批 11 个核心页面/, '页面矩阵未切换到当前首批范围')
assert.match(matrix, /2026-09-16 验收通过/, '页面矩阵未记录 11 页验收结论')
assert.doesNotMatch(matrix, /待重新实现|待开始/, '页面矩阵仍残留未完成状态')
assert.doesNotMatch(matrix, /63 个页面.*(全部|全量).*重构/, '页面矩阵仍要求本批全量重构')
// —— 管理端端口一致性(本地开发 / 文档 / 生产发布) ——
// 同一个端口写在三处:admin-web/vite.config.ts 是唯一权威,
// docs/STATUS.md 的「当前运行状态」是给人照做的,
// infrastructure/docker-compose.prod.yml 是生产 admin 容器的发布端口。
// 只改一处不会让任何测试变红,但会让照文档操作的人打不开页面,
// 或让本机直连排查打到错的端口 —— 属于本项目反复出现的「声明与兑现失联」同族问题,
// 因此用门控钉住。注意断言的是「几处一致」而非写死 13520:改端口是允许的,
// 允许的是「一起改」,不是「只改一处」。
// 前两处(vite / STATUS)是版本控制内的硬断言;第三处(生产 compose)只能
// 「存在才断言」,因为它被刻意排除在版本控制之外 —— 理由见下。
//
// 刻意不纳入本组守卫的另一处「管理端入口」:
// - infrastructure/docker-compose.yml:admin 容器不发布端口,管理端经 nginx 的 6688
// 与 /api 一起暴露(6688 是 nginx 入口端口,不是纯管理端端口),故不参与"等于 vite 端口"。
// staging(docker-compose.staging.yml)原也列在"不纳入"——它按「生产 + 1」错开,端口本就不
// 等于 vite 端口。但它与 tests/uat/run-uat.sh 默认值之间的一致性是可断言的(两者都在版本
// 控制内),而两者不一致正是"照脚本去跑、管理端却打不开"的直接原因,故改为单独一组断言:
// 见下方「staging 管理端入口一致性」。
const viteConfig = read('admin-web/vite.config.ts')
const vitePort = viteConfig.match(/^\s*port:\s*(\d+),/m)?.[1]
// 解析失败即失败:正则匹配不到时捕获组为 undefined,断言会退化成"与 undefined 比较"
// 而失去意义 —— 守卫静默失效是本项目踩过的坑。
assert.ok(vitePort, '未能从 admin-web/vite.config.ts 解析出 server.port(守卫自身失效)')
// 用 equal 而不是 match:match 失败会把整份文档打进报错里,
// 3000 行的噪声会盖掉真正的不一致信息。
const docPort = status.match(/管理后台:\s*http:\/\/localhost:(\d+)\//)?.[1]
assert.ok(docPort, 'docs/STATUS.md「当前运行状态」未声明管理后台地址(守卫自身失效)')
assert.equal(docPort, vitePort, `管理后台端口不一致:STATUS.md=${docPort},vite.config.ts=${vitePort}`)
// 生产 compose 的 admin 发布端口。
// ⚠️ infrastructure/docker-compose.prod.yml 位于 .gitignore 的「Secrets and certificates」
// 段,刻意不进版本控制 —— 因此 CI(全新克隆)上这个文件根本不存在。若在这里硬 read(),
// 门控会在 CI 上抛 ENOENT 变成假红。故取「存在即断言、不存在则显式打印跳过」:
// 本机有它,本机就能拦住漂移;跳过时打印原因,不让它被误读成「已校验通过」。
const prodComposePath = 'infrastructure/docker-compose.prod.yml'
if (!existsSync(resolve(root, prodComposePath))) {
console.log(`skip: ${prodComposePath} 不在版本控制内(CI 上不存在),跳过生产管理端端口校验`)
} else {
// 先切出 admin-web 服务块(到下一个 2 空格缩进的服务名为止),
// 再取块内唯一的带引号端口映射(形如 "127.0.0.1:13520:80")。
// 不直接写 /ports:\s*\n\s*-\s*"/ —— 端口上方允许有注释行,那样会匹配不到并让守卫误判为失效。
const prodCompose = read(prodComposePath)
const prodAdminBlock = prodCompose.match(
/container_name:\s*danyang-match-admin\r?\n([\s\S]*?)(?=\r?\n {2}[a-z]|$)/,
)?.[1]
assert.ok(prodAdminBlock, '未能从 docker-compose.prod.yml 切出 admin-web 服务块(守卫自身失效)')
const prodAdminMapping = prodAdminBlock.match(/-\s*"([^"]+)"/)?.[1]
assert.ok(prodAdminMapping, '未能从 docker-compose.prod.yml 的 admin-web 解析出端口映射(守卫自身失效)')
// "127.0.0.1:13520:80" → 主机端口取倒数第二段(兼容不带绑定地址的 "13520:80")。
const prodAdminPort = prodAdminMapping.split(':').at(-2)
assert.equal(
prodAdminPort,
vitePort,
`管理端端口不一致:docker-compose.prod.yml=${prodAdminPort},vite.config.ts=${vitePort}`,
)
}
// —— staging 管理端入口一致性(发布端口 / UAT 脚本默认值 / 子路径) ——
// 2026-09-18 之前,tests/uat/run-uat.sh 里的 ADMIN_URL 是个「定义了却无人消费」的常量:
// 端口改了、脚本没同步,不会有任何检查变红。现该变量已接到实测的管理端可达性检查上,
// 这里再用门控保证它与 staging compose 的发布端口一致 —— 否则实测会稳定地测错对象。
const stagingCompose = read('infrastructure/docker-compose.staging.yml')
// 行尾必须容忍 CRLF —— 本仓库同时存在两种行尾(staging.yml / run-uat.sh 是 CRLF,
// prod.yml 是 LF),而 prod 那段在 CI 上因文件被 gitignore 根本不执行,
// 所以只有 staging 这段会在 Windows 工作区暴雷。若写死 \n:
// 窗口内夹了 \r 后解析直接失败,守卫会报「自身失效」——
// 那是**假红**(真实端口 13521 与 run-uat.sh 一致),比不检查更坏。
const stagingAdminMapping = stagingCompose
.match(/container_name:\s*danyang-match-staging-admin\r?\n([\s\S]*?)(?=\r?\n {2}[a-z]|$)/)?.[1]
?.match(/-\s*"([^"]+)"/)?.[1]
assert.ok(stagingAdminMapping, '未能从 docker-compose.staging.yml 的 admin-web 解析出端口映射(守卫自身失效)')
const stagingAdminPort = stagingAdminMapping.split(':').at(-2)
const uatAdminUrlMatch = read('tests/uat/run-uat.sh')
.match(/^ADMIN_URL="\$\{UAT_ADMIN_URL:-http:\/\/localhost:(\d+)(\/[^}"]*)?\}"/m)
assert.ok(uatAdminUrlMatch, '未能从 tests/uat/run-uat.sh 解析出 ADMIN_URL 默认值(守卫自身失效)')
assert.equal(
uatAdminUrlMatch[1],
stagingAdminPort,
`管理端 UAT 入口端口不一致:run-uat.sh=${uatAdminUrlMatch[1]},docker-compose.staging.yml=${stagingAdminPort}`,
)
// 子路径:产物部署在 /admin/ 下,UAT 入口必须带该前缀。缺了它测的是根路径(301 或 404)——
// "检查了但检查错了对象"比不检查更坏,因为它给出的是通过。
assert.equal(uatAdminUrlMatch[2], '/admin/', `run-uat.sh 的管理端入口未指向 /admin/ 子路径:${uatAdminUrlMatch[0]}`)
// staging 与生产同机共存,端口必须错开。生产端口在 CI 上不可知(prod compose 不在版本
// 控制内),故只能断言"不撞车",不断言具体的「生产 + 1」。
assert.notEqual(stagingAdminPort, vitePort, `staging 管理端端口与主管理端端口撞车(都是 ${stagingAdminPort}),同机部署会冲突`)
// —— 管理端子路径部署一致性 ——
// 产物部署在 /admin/ 子路径下(vite base 按 command 分叉)。该形态依赖五处同时成立:
// vite base、router history base、生产 nginx、本地整套栈 nginx、admin-web 容器自身 nginx。
// 任一处退回根路径假设,都会造成"页面能打开但白屏 / 裂图 / 指到别的东西"。
// 历史缺陷:生产 nginx 的 /admin/ 曾写 `alias /usr/share/nginx/html/` —— 那是 nginx:alpine
// 镜像自带的默认页目录(本容器内并没有管理端产物),于是 /admin/ 返回 200 + nginx 欢迎页:
// 假成功,比 404 更难发现。故下面既断言"代理到 upstream",也反向断言"不得再退回 alias"。
assert.match(
viteConfig,
/base:\s*command\s*===\s*'build'\s*\?\s*'\/admin\/'\s*:\s*'\/'/,
'vite 未把构建 base 设为 /admin/(产物会引用站点根路径的资源)',
)
assert.match(
read('admin-web/src/router/index.ts'),
/createWebHistory\(import\.meta\.env\.BASE_URL\)/,
'router 的 history base 未跟随 vite base(子路径部署下刷新或直达深层路由会白屏)',
)
const prodNginx = read('infrastructure/nginx-production.conf')
assert.match(prodNginx, /upstream\s+admin_web\s*\{[\s\S]*?server\s+admin-web:80;/, '生产 nginx 未声明 admin_web upstream')
assert.match(
prodNginx,
/location\s+\/admin\/\s*\{[^}]*proxy_pass\s+http:\/\/admin_web;/,
'生产 nginx 的 /admin/ 未代理到 admin_web upstream',
)
assert.doesNotMatch(
prodNginx,
/location\s+\/admin\/\s*\{[^}]*alias\s/,
'生产 nginx 的 /admin/ 又退回 alias 本容器目录(会返回镜像默认页,属假成功)',
)
assert.match(
read('infrastructure/nginx.conf'),
/location\s+\/admin\/\s*\{[^}]*proxy_pass\s+(?:http:\/\/admin_web|\$admin_upstream);/,
'本地整套栈 nginx 的 /admin/ 未代理到 admin_web(6688 上取不到管理端)',
)
const adminContainerNginx = read('admin-web/nginx-admin.conf')
assert.match(
adminContainerNginx,
/location\s+\/admin\/\s*\{[\s\S]*?alias\s+\/usr\/share\/nginx\/html\/;/,
'admin-web 容器 nginx 未服务 /admin/ 子路径',
)
assert.match(
adminContainerNginx,
/try_files\s+\$uri\s+\$uri\/\s+\/admin\/index\.html;/,
'admin-web 容器 nginx 的 SPA 兜底未指向 /admin/index.html(alias 配 try_files 写成文件路径会失效)',
)
// public/ 下的运行时资源不受 vite base 影响(原样复制到产物根),因此写死 `src="/brand/...`
// 或 `url('/brand/...')` 在子路径部署下会 404(logo / 插画 / 背景图裂)。统一走 src/brand.ts。
for (const file of [
'admin-web/src/layouts/MainLayout.vue',
'admin-web/src/views/LoginView.vue',
'admin-web/src/views/DashboardView.vue',
]) {
const src = read(file)
assert.doesNotMatch(src, /src="\/brand\//, `${file} 仍写死 /brand/ 根路径引用(子路径部署下会裂图)`)
assert.doesNotMatch(src, /url\('\/brand\//, `${file} 仍写死 url('/brand/...')(子路径部署下会裂图)`)
}
assert.match(
read('admin-web/index.html'),
/href="\/brand\/logo-mark\.png"/,
'index.html 的 favicon 未指向存在的品牌资源(vite 会为它补上 base 前缀)',
)
assert.doesNotMatch(
read('admin-web/index.html'),
/favicon\.svg/,
'index.html 仍引用不存在的 /favicon.svg(仓库内无此文件,且绝对路径不随 base 变化)',
)
console.log('UI document baseline contracts passed')