1 parent
e386975056
commit
65c175c8fa
34 files changed
+8
-11011
No files matched your search
@@ -26,16 +26,10 @@ jobs:
|
||||
run: |
|
||||
npm run lint
|
||||
npm run format:check
|
||||
- name: 安装测试浏览器
|
||||
run: npx playwright install --with-deps chromium firefox
|
||||
- name: 检查源码并构建
|
||||
run: |
|
||||
npm run check
|
||||
npm run build
|
||||
- name: 验收真实生产构建
|
||||
run: |
|
||||
task_chrome_path="$(node --input-type=module -e 'import { chromium } from "playwright"; process.stdout.write(chromium.executablePath())')"
|
||||
CHROME_PATH="$task_chrome_path" npm test
|
||||
# deepmerge 尚无上游修复版本;低危传播项保留在审计输出,不通过降级框架隐藏。
|
||||
- name: 阻止中危及以上依赖风险
|
||||
run: npm audit --audit-level=moderate
|
||||
+6
-2
@@ -7,8 +7,12 @@ test-results/
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 本地交接、审计报告和验收截图不随源码交付;README 保留项目使用说明。
|
||||
docs/
|
||||
# 本地说明、测试和第三方声明不随源码仓库公开。
|
||||
/AGENTS.md
|
||||
/THIRD_PARTY_NOTICES.md
|
||||
/docs/
|
||||
/test/
|
||||
/tests/
|
||||
|
||||
# 本地运维 skill 含生产服务器结构说明,只留在操作者本机。
|
||||
.claude/skills/crashwork-ops/
|
||||
@@ -1,26 +0,0 @@
|
||||
# V0 项目边界
|
||||
|
||||
本仓库属于 V0:Nuxt、Vue、OpenVue 和 Deni 会员前端。V0 只保留前端,不承载后端项目。
|
||||
|
||||
## 仓库隔离
|
||||
|
||||
- 本仓库远端为 `https://git.crash.work/cieanyau/console-dev.git`。
|
||||
- V1 位于 `/Users/cieanyau/Documents/ChatGPT/V1`;其中 Next.js、React 和 Shadcn 前端使用 `https://git.crash.work/cieanyau/console-new.git`,PHP 后端在其 `backend/` 子目录保持独立 Git 仓库。
|
||||
- V0 任务只修改本前端;V1 的前端、后端、依赖、分支和构建产物需要在各自任务范围处理,不互相合并或作为彼此的发布源。
|
||||
- 推送前核对工作树、分支和远端;只暂存本任务明确涉及的文件。
|
||||
- 提交说明使用中文,新增解释性代码注释使用中文。
|
||||
|
||||
## 发布隔离
|
||||
|
||||
- V0 正式站为 `console.crash.work`,发布根目录为 `/www/wwwroot/crashwork-console`,会员前端进程为 PM2 的 `crashwork-console`。
|
||||
- V1 开发站为 `console-dev.crash.work`,发布根目录为 `/www/wwwroot/console-dev.crash.work`,使用独立的 `console-dev.service`;这些目标不属于 V0 发布范围。
|
||||
- V0 发布只操作已经核实的 V0 目标,保留旧版本及回滚点;不得重启全部 PM2 进程或 V1 服务。
|
||||
- 构建、依赖目录、发布归档、运行配置及验收记录分别归属各自项目,不跨项目复用。
|
||||
|
||||
## 共享后端边界
|
||||
|
||||
- `/console/v1` 是 V10 业务系统的 API 前缀,与名为 V1 的前端项目不是同一个概念。
|
||||
- 两个前端目前共用该后端;前端仓库独立不代表 API 或数据库独立。
|
||||
- V0 前端发布不自动授权修改共享后端契约、执行数据库迁移或复制生产数据。影响共享后端的变更必须另行核对全部使用方,并按用户确认的后端范围发布。
|
||||
- 后端主仓库已经迁至 `/Users/cieanyau/Documents/ChatGPT/V1/backend`;关联工作树在 `V1/backend-worktrees/`,交付包在 `V1/backend-deliveries/`。不得在 V0 重建后端目录,也不得将完整后端源码或配置推送至本仓库。
|
||||
- 目录迁移不改变生产 API 或数据库。共享后端契约尚未配套发布时,不发布依赖该新契约的 V0 前端产物。
|
||||
@@ -1,193 +1,5 @@
|
||||
# 破碎工坊云 · 会员中心前端
|
||||
|
||||
面向云服务和 IDC 业务的用户控制台,提供账号管理、商品选购、服务器管理、订单支付与售后服务等页面。
|
||||
本仓库是可独立安装、开发和构建的前端工程,不包含后端服务、数据库或生产环境凭证。
|
||||
破碎工坊云会员中心是面向云服务与 IDC 业务的用户控制台,覆盖账号与身份管理、云服务器和独立服务器管理、商品选购、订单支付、财务、安全、工单、消息与售后服务等主要业务场景。
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 账号与身份:密码 / 短信登录、注册、找回密码、第三方登录与绑定、个人资料、实名认证和 API 密钥。
|
||||
- 控制台与资源:会员首页、资源总览、云服务器和独立服务器列表、实例详情。
|
||||
- 商品与购买:商品分类、配置选购、购物车、订单创建、续费和升级入口。
|
||||
- 实例管理:监控、网络、磁盘、流量、访问凭据与远程控制;具体能力取决于后端接入的产品模块。
|
||||
- 账务与支付:订单、账单、余额、支付、发票、优惠券、转账和推广相关页面。
|
||||
- 安全与服务:SSH 密钥、安全组、域名、操作日志、工单、消息、公告和帮助内容。
|
||||
|
||||
当前包含 52 个页面、106 个业务组件和 4 套布局。上述列表说明前端覆盖范围,不代表所有业务已完成真实后端验收。
|
||||
|
||||
新输入手机号的入口统一使用中国大陆 `+86`,包括登录、注册、找回密码、第三方绑定及账户手机号变更;
|
||||
不提供国际区号选择。已保存号码的真实区号展示和旧号码安全验证保持后端原有数据,不伪装成 `+86`。
|
||||
物理服务器目录与配置器共用有效库存约束,网络档位取值在配置器和摘要间统一;无确定库存时不宣传具体余量。
|
||||
官网选购入口允许匿名浏览商品,机柜托管通过登录后的工单咨询;可配置图片加载失败时显示明确替代内容。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层次 | 技术 |
|
||||
| ---------- | --------------------------------------------------------------- |
|
||||
| 应用框架 | Nuxt 4、Vue 3、Vue Router,客户端 SPA(`ssr: false`) |
|
||||
| 状态与界面 | Pinia、OpenVue、OpenVue Aura 主题、本地 Lucide 图标 |
|
||||
| 图表与远控 | ECharts、vue-echarts、noVNC |
|
||||
| 源码 | JavaScript、Vue 单文件组件、CSS,Nuxt 配置使用 TypeScript |
|
||||
| 验证工具 | Node.js `node:test`、Playwright、源码检查脚本、ESLint、Prettier |
|
||||
|
||||
界面基础统一为 `openvue`、`@openvue/forms`、`@openvue/themes` 1.1.1,不使用 npm alias 或并行 PrimeVue 依赖。
|
||||
后台布局移植自 Deni,保留本项目 Nuxt 路由、鉴权、权限菜单、维护条和三态主题;
|
||||
不导入模板的演示业务、分析上报或额外图表/图标库。来源与 MIT 许可见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
|
||||
主题入口为 `app/config/openvue.js`;OpenVue 上游保留的 `.p-*` 类和 `--p-*` 令牌不是遗留包依赖。
|
||||
|
||||
依赖的精确版本以 `package-lock.json` 为准。
|
||||
|
||||
## 快速开始
|
||||
|
||||
需要 npm 及满足以下任一范围的 Node.js:**22.22.3–22.x、24.15.0–24.x 或 26.0.0 及以上**。
|
||||
该范围与锁定依赖的引擎要求一致,团队开发应统一使用其中一个受支持版本。
|
||||
|
||||
```sh
|
||||
git clone https://git.crash.work/cieanyau/console-dev.git
|
||||
cd console-dev
|
||||
npm ci
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
在 `.env` 中配置后端地址:
|
||||
|
||||
```dotenv
|
||||
NUXT_PUBLIC_API_BASE=http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
开发服务仅监听 `127.0.0.1`,实际访问地址以启动日志为准。
|
||||
|
||||
### 接口与反向代理
|
||||
|
||||
`NUXT_PUBLIC_API_BASE` 只填写后端 origin,不要附加 `/console/v1`,请求层会自动追加该前缀。
|
||||
留空表示使用前端同源地址,需要在开发或部署环境配置接口反向代理。
|
||||
|
||||
以下地址始终走前端同源,必须单独转发到对应服务:
|
||||
|
||||
- `/captcha/`:图形验证码。
|
||||
- `/remote-console/sessions`:远控会话及 WebSocket 连接。
|
||||
|
||||
仅设置 API 地址不会自动代理这两类请求,也不会启动后端、验证码、短信邮件、支付渠道或远控网关。
|
||||
所有 `NUXT_PUBLIC_*` 配置都会暴露给浏览器,不能填写密码、访问令牌或数据库凭证。
|
||||
|
||||
## 常用命令
|
||||
|
||||
| 命令 | 用途 |
|
||||
| ---------------------- | ----------------------------------------- |
|
||||
| `npm run dev` | 启动本地开发服务 |
|
||||
| `npm run check` | 检查应用源码语法、导入和历史部署路径依赖 |
|
||||
| `npm run lint` | 检查应用、配置、脚本和测试的代码问题 |
|
||||
| `npm run lint:fix` | 自动修复可安全修正的 ESLint 问题 |
|
||||
| `npm run build` | 构建生产版本,输出到项目根目录 `.output/` |
|
||||
| `npm run preview` | 运行已构建的 Node.js 服务 |
|
||||
| `npm test` | 对生产构建运行浏览器端到端测试 |
|
||||
| `npm run format` | 统一格式化应用、配置、脚本、测试和文档 |
|
||||
| `npm run format:check` | 只检查格式,不改写文件 |
|
||||
|
||||
格式规则在 `.prettierrc.json`,扫描范围由 `.prettierignore` 排除生成物、本地报告、
|
||||
静态资源和 npm 管理的锁文件;不会将 `docs/` 或测试产物重新纳入提交。
|
||||
|
||||
### 构建与本地预览
|
||||
|
||||
```sh
|
||||
npm run check
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run build
|
||||
NUXT_PUBLIC_API_BASE=http://127.0.0.1:8080 HOST=127.0.0.1 PORT=3000 npm run preview
|
||||
```
|
||||
|
||||
预览命令直接运行 `.output/server/index.mjs`,不会自动读取项目 `.env`,请通过运行环境传入配置。
|
||||
正式部署应使用独立版本目录和进程管理,并配置 HTTPS、接口 / WebSocket 反向代理及回滚流程。
|
||||
本仓库的提交和构建不会自动修改生产服务器。
|
||||
|
||||
### 浏览器安全边界
|
||||
|
||||
应用响应统一提供 `nosniff`、`no-referrer`、同源嵌入限制,以及约束嵌入祖先、对象和 base 地址的部分 CSP。
|
||||
这不是完整的脚本来源策略或 XSS 防护;HTTPS、后端鉴权与限流仍由部署环境和服务端负责。
|
||||
若反向代理直接返回静态页面而不经过 Nuxt,必须在代理层提供同等响应头,并在部署后实际检查。
|
||||
|
||||
实名认证插件 HTML 在只允许脚本的隔离帧内显示,不能继承会员中心的同源权限。
|
||||
本地测试验证动态二维码与父页面隔离;真实渠道的跨域请求、Cookie、表单和跳转兼容性仍须联调,
|
||||
不能为了恢复未知插件行为而直接授予 `allow-same-origin`。
|
||||
|
||||
## 源码目录
|
||||
|
||||
```text
|
||||
app/
|
||||
app.vue 应用入口、布局选择与会话隔离
|
||||
pages/ 文件路由与业务页面
|
||||
layouts/ 控制台、认证、公开购买及站点布局
|
||||
components/ 业务表单、实例管理、支付和公共界面
|
||||
lib/ 接口、会话、商品配置及业务工具
|
||||
middleware/ 路由鉴权
|
||||
plugins/ 界面注册、启动初始化、跨标签页会话同步
|
||||
config/ 中文文案和主题配置
|
||||
utils/ 访问规则及基础适配
|
||||
assets/styles/ 全局与业务样式
|
||||
public/ 图片、图标、字体等静态资源
|
||||
scripts/ 源码检查及一次性重建留档脚本
|
||||
tests/ 浏览器场景与独立 HTTP 测试后端
|
||||
nuxt.config.ts 应用与公开运行时配置
|
||||
.env.example 无凭证的环境配置示例
|
||||
```
|
||||
|
||||
`docs/` 仅保留在本地,已加入 Git 忽略规则,不随后续源码版本上传;历史提交中的文档不会因此消失。
|
||||
不要使用 `git add -f docs` 将本地报告重新加入仓库。
|
||||
|
||||
## 测试
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
测试使用真实 Chrome / Chromium 操作生产构建,自动启动本地 HTTP 测试后端,禁止访问外网,
|
||||
不替换项目内部模块,也不预置登录令牌。默认使用 macOS 已安装的 Chrome;其他环境可指定:
|
||||
|
||||
```sh
|
||||
CHROME_PATH=/absolute/path/to/chrome npm test
|
||||
```
|
||||
|
||||
离线场景覆盖登录协议、短信倒计时、路由保护、会话失效、双标签页退出、SSH 密钥管理、
|
||||
请求超时、支付期限及旧登录入口;还覆盖安全组批量规则、旧工单切换、镜像订单重试、
|
||||
级联配置、商品字段校验、安全随机密码和三态主题;补充维护布局、深色样式隔离、
|
||||
上传控件名称、导航键盘焦点及监控图表减动回归。还覆盖固定手机号区号、密码自动填充与键盘显隐、
|
||||
按钮文字对比度、库存与网络规格一致性、商品编号边界,以及官网选购咨询和图片失败降级。
|
||||
专项回归还检查离页/切换会话后的派生操作、新旧编辑草稿、通知失败重试、微信二维码解码失败、
|
||||
认证插件父页面隔离,以及实际页面响应的浏览器安全策略。
|
||||
还检查监控缺测与真实零值、跨日采样日期、磁盘容量字段名称、首页认证状态一致性,
|
||||
以及九个列表页面及新闻/公告嵌入入口的桌面/手机、深浅色密度和可见数据。
|
||||
测试产物保存在被忽略的 `tests/fixtures/runs/`。
|
||||
详细使用方式见 [测试说明](tests/README.md)。
|
||||
|
||||
仓库包含 `.gitea/workflows/frontend.yml`:安装锁定依赖后先运行 ESLint 和格式检查,再安装测试浏览器、运行源码检查、生产构建、浏览器测试和
|
||||
`npm audit --audit-level=moderate`,不执行部署。Git 服务需启用 Actions 并配置兼容的 Linux runner;
|
||||
仅提交工作流不代表远端任务已经运行通过。
|
||||
|
||||
传递依赖 `glob` 与 `minimatch` 通过精确 overrides 使用修复版本。
|
||||
截至 2026-11-06,`deepmerge` 的 GHSA-gcqx-w48m-w2h4 尚无已发布修复版本,低危传播告警仍保留,
|
||||
未用强制降级 Nuxt 或关闭审计来隐藏。禁止把通过中危及以上门禁解读为依赖零风险。
|
||||
|
||||
真实支付、第三方登录 / CAPTCHA、安全挑战、短信邮件投递、供应商接口和 VNC 网关仍需在测试环境联调。
|
||||
|
||||
## 维护与交付说明
|
||||
|
||||
本工程根据 `20261025-kky-traffic-display` 发布产物重建,不是原始源码的逐字恢复。
|
||||
多数页面和组件保留恢复后的 Vue 渲染函数及部分短变量名;原始模板、注释和完整变量名无法从发布包完整找回。
|
||||
当前新增注释用于解释已核实的职责、流程和接口边界,不代表找回了原作者的注释,也不代表全站重新手写。
|
||||
|
||||
项目自有代码的说明性注释统一使用中文,提交信息也统一使用中文。
|
||||
变量名、接口字段、工具指令和第三方版权声明保留原样;注释应解释意图与约束,避免逐行复述代码。
|
||||
|
||||
维护时直接修改当前源码并正常构建,不要编辑生成的哈希产物。
|
||||
`scripts/reconstruct.mjs` 仅是一次性导入留档,不是开发或发布步骤,且会拒绝覆盖现有页面;
|
||||
不要使用不可信的第三方发布产物运行该脚本。
|
||||
登录所用的 CryptoJS AES 参数属于现有后端协议,调整前必须与后端协商,不能将其视为替代 HTTPS 的安全措施。
|
||||
|
||||
向员工交付仓库和对应分支即可;每位开发者自行安装依赖、配置后端并执行验证。
|
||||
不要提交 `.env`、账号密码、令牌、数据库备份、`node_modules/`、`.output/` 或本地验收报告。
|
||||
依赖包与保留静态资源应遵守各自许可证及资产授权;本仓库未另行授予整体开源许可证。
|
||||
项目基于 Nuxt 4、Vue 3、OpenVue 和 Pinia 构建,采用客户端单页应用架构,并集成图表展示与远程控制能力。本仓库仅包含会员中心前端源码,不包含后端服务、数据库、生产配置或用户数据。
|
||||
@@ -1,42 +0,0 @@
|
||||
# Third-party source attribution
|
||||
|
||||
## Deni
|
||||
|
||||
The console layout and shared presentation styles are adapted from Deni,
|
||||
revision `72156473d5f73a26f97d35aa5fed3a96059779ef`, maintained by the
|
||||
OpenVue Foundation. Upstream: `https://github.com/openvi-foundation/deni`.
|
||||
|
||||
Adapted sources: `src/layout/AppLayout.vue`, `AppSidebar.vue`, `AppTopbar.vue`,
|
||||
`AppMenuItem.vue`, and `src/assets/styles/_base.scss`, `_layout.scss`,
|
||||
`_sidebar.scss`, `_topbar.scss`, `_page.scss`. The adaptation uses Nuxt slots,
|
||||
the console's existing authentication and authorised menu, local Lucide icons,
|
||||
Chinese labels, measured maintenance offsets and three-mode theme handling.
|
||||
The upstream demo router, data, analytics and configurator are not included.
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2026 OpenVue Foundation and contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
## OpenVue packages
|
||||
|
||||
`openvue`, `@openvue/forms` and `@openvue/themes` are pinned to 1.1.1.
|
||||
Their upstream licences and notices are distributed in the installed npm
|
||||
packages. Upstream: `https://github.com/openvi-foundation/openvue`.
|
||||
@@ -1,26 +0,0 @@
|
||||
# V0 发布协调与线上复核
|
||||
|
||||
日期:2026 年 11 月 7 日,时间为北京时间。本记录只属于 V0 前端,不包含后端源码、运行配置、生产数据或凭据。
|
||||
|
||||
## 发布归属
|
||||
|
||||
- V0 正式站:`console.crash.work`;发布根目录:`/www/wwwroot/crashwork-console`;PM2 应用:`crashwork-console`。
|
||||
- 另一用户授权的 V0 部署任务已将主线提交 `d72a7e03d8cef75580b11e24c1faeaa06c907028` 对应产物上线;产品源码提交为 `b788f890f848980504dab6073a09af60bc298373`,之后的说明与浏览器测试改动不改变产品字节。
|
||||
- 当前输出:`releases/20261107-b788f89-contract/.output`;构建 ID:`691b6334-355d-4826-bb80-95d0fc61f984`。
|
||||
- 原版本 `releases/20261107-90989f8/.output` 保留为回滚点,不清理旧不可变资源。
|
||||
|
||||
## 本轮协调
|
||||
|
||||
发现并行发布后,本任务停止使用旧的 V0 待发布基线,不重复激活 V0、不把它回退到本轮开始前的旧版本。共享 API 协调只在其独立项目范围执行;V0 仓库仍只保留前端。
|
||||
|
||||
2026 年 11 月 7 日 10:50:27 至 10:50:41,共享 API 进入短维护并完成契约协调。V0 在共同发布锁保护下只读核验当前链接、固定构建及 6868 个产物的文件/目录闭集、内容与权限;本轮没有重启 V0 PM2。维护配置已恢复,V0 的两个既有 worker 保持不变,远控进程未重启。
|
||||
|
||||
## 验收证据与限制
|
||||
|
||||
- V0 部署任务报告:同一服务器归档完整 E2E 262/262;六个匿名页面、308 个引用资源逐字节一致;桌面浅色与移动深色 Chrome 验收通过。该证据归属原部署任务,不冒充本任务重新运行 262 项。
|
||||
- 本任务独立线上只读复核:六个匿名页面、59 个静态资源、6152 个旧资源的本地保留以及三个旧资源公网采样通过。
|
||||
- 本任务真实 Chrome:1440 浅色与 390 深色两个视口均验证启动遮罩、初始化前隐藏页面根、加载后盒模型、刷新及无横向溢出;没有提交登录或业务写请求。
|
||||
- 协调后重新核验正式站登录页构建和同源 `/console/v1/common`,均返回 200;这只证明公开入口与资源可用。
|
||||
- 支付、短信、实例控制、提现、开票及真实微信/OAuth 渠道仍待联调;验证码请求和非必要外部请求在只读浏览器中受到限制,不能称这些渠道验收通过。登录后各业务的真实数据写入未由本轮执行。
|
||||
|
||||
本记录不宣称全站所有页面、每个按钮和全部代码完成真实生产功能验收。
|
||||
@@ -1 +0,0 @@
|
||||
fixtures/runs/
|
||||
@@ -1,82 +0,0 @@
|
||||
# 浏览器端到端验收
|
||||
|
||||
先在项目根目录执行 `npm ci` 安装锁定依赖,执行 `npm run check`、`npm run lint`、
|
||||
`npm run format:check` 和 `npm run build`,再执行 `npm test`。
|
||||
测试使用 Node 自带的 `node:test`。浏览器场景直接操作实际构建后的页面,不替换项目内部模块;
|
||||
规则适配与随机密码另有公开模块入口的隔离失败模式测试,不能代替浏览器验收。
|
||||
|
||||
每个浏览器测试文件会自动启动两个仅监听 `127.0.0.1` 的独立进程:构建后的 Nuxt 预览服务和 HTTP 假后端。
|
||||
后端地址仅通过生产已有的 `NUXT_PUBLIC_API_BASE` 注入。不会读取 `.env`,不会请求生产服务;
|
||||
浏览器访问两个本地 origin 之外的任何 HTTP 地址都会被阻断并让场景失败。
|
||||
共享启动、登录、清单和清理逻辑位于 `tests/helpers/console-browser.mjs`;专项场景仅在已配置的
|
||||
后端 HTTP 边界提供响应,不替换应用内部函数或预置 JWT。
|
||||
|
||||
默认 Chrome 路径是 macOS 的 `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`。
|
||||
其他平台通过 `CHROME_PATH` 指定 Chromium/Chrome 可执行文件,或指定已安装的 Playwright Chromium:
|
||||
|
||||
```sh
|
||||
CHROME_PATH=/absolute/path/to/chrome npm test
|
||||
```
|
||||
|
||||
默认测试当前项目的 `.output`。如需验证其他工作树中已经构建的前端,可设置
|
||||
`E2E_APP_ROOT=/absolute/path/to/project`;测试依赖仍从当前工作树解析。
|
||||
|
||||
每次运行在 `tests/fixtures/runs/run-*` 下生成截图和 `manifest.json`。
|
||||
清单只包含稳定的场景结果、行为观察、请求形状以及浏览器错误/网络越界;不包含随机端口、
|
||||
时间戳、请求顺序、密码或令牌。该目录被忽略,不会提交生成物。
|
||||
另有各场景的 `.requests.json` 诊断附件,保留请求先后顺序和是否携带认证,不保存凭证值。
|
||||
|
||||
覆盖登录协议、未登录/失效会话保护、空菜单权威性、SSH 搜索的 10/10/5 分页与重置、
|
||||
挂起退出接口时立即清理本地登录、危险返回地址、支付成功及挂起状态请求下的独立支付期限。
|
||||
还覆盖临时登录凭证的首次验证、已有会话再验证、失败时地址脱敏及原页面重试,打开支付页时退出/失效不重新初始化,
|
||||
以及使用同一浏览器真实存储事件的双标签页退出同步;所有会话都来自实际登录或临时凭证入口,不预置 JWT。
|
||||
模板改写的回归场景还覆盖短信发送协议、60 秒倒计时、登录方式禁用配置及 SSH 新增/编辑保存/删除末页恢复。
|
||||
支付超时使用浏览器的外部时钟推进,不添加测试专用产品配置。假后端数据契约来自现有部署产物。
|
||||
|
||||
专项回归按行为分组:
|
||||
|
||||
- `review-routes.e2e.test.mjs`:安全组方向大小写、非法枚举整批拒绝;旧工单 query 切换后草稿重置且只操作新工单。
|
||||
- `review-purchase.e2e.test.mjs`:镜像订单未付复用、取消后重建、已付授权延迟不重复收费;级联候选与默认、结算、购物车恢复一致。
|
||||
- `review-fields.e2e.test.mjs`:常见 ASCII 规则本地拦截、PCRE 专有规则交后端校验;实例随机密码实际提交及安全源失效、无偏采样边界。
|
||||
- `review-theme.e2e.test.mjs`:认证页三态主题文案、持久化与跟随系统变化。
|
||||
- `review-startup.e2e.test.mjs`:冷刷新在入口脚本、公共配置和页面样式延迟时保留加载占位,业务启动前恢复主题;字体延迟不阻塞交互,配置失败可重试,会员深链接刷新及站内导航正常交接。
|
||||
- `review-ui-foundation.e2e.test.mjs`:OpenVue/Deni 的手机导航焦点约束、Escape/离页关闭、1024/1025 断点与桌面恢复、手机表格字段标签和可操作的窄屏编辑弹窗。
|
||||
- 手机表格额外检查首次插入和深链接刷新时标签已完整,无需等下一帧补写;动态等级表头同时验证手机字段名、桌面单一标题与原有颜色。
|
||||
- `review-content-geometry.e2e.test.mjs`:延迟首页工单和公告响应,验证桌面/手机在同样五行数据替换骨架后,卡片高度与相邻位置不变;不代表任意列表长度或外部图片都零位移。
|
||||
- `review-style-layout.e2e.test.mjs`:维护条随内容与视口变化、超长通知内部滚动、帮助/购物车/机型/配置摘要吸顶避让,退出布局后的偏移清理与导航单一键盘焦点;首页用户 ID 复制按钮在各宽度下不拆行(该问题只在 Firefox 出现,场景额外启动 Playwright Firefox,需先执行 `npx playwright install firefox`)。
|
||||
- `review-style-fields.e2e.test.mjs`:深色商品说明对比度、提现样式隔离、实名认证与转账凭证上传名称;实际升级弹窗的窄屏网格。
|
||||
- `review-style-motion.e2e.test.mjs`:实例监控 Canvas 初始/刷新/加载反馈跟随系统减动偏好,并验证偏好切换及离页后无运行时错误。
|
||||
- `review-online-auth.e2e.test.mjs`:真实密码输入的自动填充语义、键盘显隐且不误提交、浅暗主题按钮对比度;注册、找回、第三方绑定与账户新绑/改绑手机号固定 `+86`,保留旧号码真实区号和地址国家选项。登录发码与登录协议由原有认证场景共同覆盖。
|
||||
- `review-online-catalog.e2e.test.mjs`:受控与未受控库存、商品和型号的共同约束、目录与配置器数量上限;radio/step/total 网络规格与实际报价参数一致;4 个公开入口在商品请求前拒绝非法编号,保留合法上边界。
|
||||
- `review-online-official.e2e.test.mjs`:默认及既有 CMS 的选购按钮匿名进入公开商品页、机柜咨询经真实本地登录到工单表单;不可解码图片的品牌/头像/二维码降级、可用原图保留和手机宽度。未向线上写入 CMS 或提交工单。
|
||||
|
||||
可针对单组执行,例如 `node --test tests/review-routes.e2e.test.mjs`;仍需先构建当前源码。
|
||||
随机密码场景只记录性质与是否一致,不把密码或安全随机值写入验收附件。
|
||||
真实服务器联调、真实支付渠道、短信/邮件投递、第三方 CAPTCHA 及完整安全挑战流程不在离线验收范围内。
|
||||
|
||||
### 布局标签修复边界
|
||||
|
||||
几何布局所需的字段应与内容同次渲染,不能由全页观察器在下一帧补写。
|
||||
本次使用 OpenVue 原生单元格配置生成 `data-label`,删除后补插件;不增加等待、遮罩或 DOM 回退。
|
||||
可用 `rg -n 'data-label|dataset\.label|table-cell-labels' app` 复核唯一生产者及 CSS 消费者:
|
||||
产品、工单、订单、日志、SSH 的卡片样式与全局手机标签规则保留,动态等级列改用单一标题来源。
|
||||
另核对了 24 个 DataTable 组件、211 个 Column 调用点;复选框、骨架及空标题操作列继续生成空标签。
|
||||
旧版在首次插入时缺标签的断言失败,修复后通过;这证明后补依赖已移除,不等于已证实或消除全站所有可见位移。
|
||||
|
||||
### 表格刷新布局修复边界
|
||||
|
||||
加载、空表和有数据不能切换表格布局模型;列宽只由列声明决定,不能由本次返回的内容重新分配。
|
||||
OpenVue 全局 PT 统一使用固定布局,合计现有列宽作为内部横滚的最小宽度,保留手机卡片与 `.no-stack`。
|
||||
原百分比列宽用于自动伸缩,现在采用该列已有最小宽度或统一 `12rem`,因此部分宽表的列比例和横滚距离会改变。
|
||||
HostList 的列集合随接口响应确定:首次响应前显示可见加载反馈,不先猜全列;刷新已有结果时保留当前表格。
|
||||
仅有缓存行而没有列定义时,失败必须可重试,不能永久加载;滚动快照在真实表格渲染后恢复。
|
||||
|
||||
本批次缺陷与覆盖清单:
|
||||
|
||||
- 已修复:加载空态的 `100dvh` 高度、空态专用表格模型、自动列宽分配、动态列先猜后改,以及加载态提前消费滚动快照。
|
||||
- `review-content-geometry.e2e.test.mjs`:390/640/768/1440 四种宽度的 SSH 和订单深链接刷新、延迟响应、搜索完成、列位置/宽度与文档横向溢出;390/1440 的 HostList 动态列、空列表、旧接口缺少字段配置、失败重试、同表刷新和站内返回恢复 500px 滚动位置。
|
||||
- `review-list-density.e2e.test.mjs`:既有 44 种手机/桌面与浅暗主题场景,增加空表到有数据的列几何与单元格内按钮不溢出检查;按钮断言不涵盖所有链接或输入。
|
||||
- 只读审查覆盖本批次五个源码/测试文件全部变更、OpenVue 配置全文及 HostList、共享 CSS、列表缓存和实际 OpenVue PT 的相邻接缝;不是全仓代码审计完成声明。
|
||||
- 可复核扫描:`rg -n 'DataTable|createBlock\(.*DataTable' app`、`rg -n 'p-datatable-empty-message|p-datatable-mask|table-layout|row-actions' app/assets/styles`、`rg -n 'columnWidth|schemaReady|Tn' app/config/openvue.js app/components/HostList.vue`;24 个 Vue 文件中的 39 张表复用共享配置,空态布局分支已移除,手机卡片分支属于保留的响应式约定。
|
||||
- 有界待验:手机端既有隐藏字段、任意数据长度、第三方接口与真实支付/短信/实例控制,不由这些离线场景证明。
|
||||
- 本轮本地产物全套结果为 226 项中的 225 项通过;唯一未完成项为 Firefox 在启动空白页时即报 `Could not find profile folder`,不是业务断言失败。未跳过此项,也不能据此宣称全套通过;发布产物另行记录实际回归结果。
|
||||
File diff suppressed because it is too large.
Load diff
Vendored
-289
@@ -1,289 +0,0 @@
|
||||
/**
|
||||
* 独立进程的 HTTP 假后端,仅监听回环地址,按既有 /console/v1 信封响应。
|
||||
* 只模拟测试需要的外部接口,不是可部署的业务后端,也不执行真实付款或短信投递。
|
||||
* 控制口允许场景重置数据/制造挂起和失效;它不进入产品接口实现。
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
|
||||
const token = "e2e-session-token";
|
||||
const account = {
|
||||
id: 1,
|
||||
username: "audit-user",
|
||||
email: "audit@example.invalid",
|
||||
credit: "100.00",
|
||||
};
|
||||
const initialKeys = Array.from({ length: 28 }, (_, index) => ({
|
||||
// 25个audit匹配项 + 3个other项,覆盖搜索结果10/10/5分页与切换过滤条件。
|
||||
id: index + 1,
|
||||
name:
|
||||
index < 25
|
||||
? `audit-${String(index + 1).padStart(2, "0")}`
|
||||
: `other-${index + 1}`,
|
||||
public_key: `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey${index + 1}`,
|
||||
finger_print: `SHA256:fixture-${index + 1}`,
|
||||
}));
|
||||
|
||||
let options = {};
|
||||
let requests = [];
|
||||
let paid = false;
|
||||
let keys = initialKeys.map((key) => ({ ...key }));
|
||||
|
||||
function reply(response, data = {}, extra = {}, httpStatus = 200) {
|
||||
// HTTP状态与业务信封可以独立覆盖,确保前端同时处理传输401和信封401。
|
||||
response.writeHead(httpStatus, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ status: httpStatus, msg: "", data, ...extra }));
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
// 预览服务和假后端端口不同,需要允许测试浏览器发送JSON与Bearer请求。
|
||||
response.setHeader("Access-Control-Allow-Origin", "*");
|
||||
response.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Authorization, Content-Type, Accept",
|
||||
);
|
||||
response.setHeader(
|
||||
"Access-Control-Allow-Methods",
|
||||
"GET, POST, PUT, DELETE, OPTIONS",
|
||||
);
|
||||
if (request.method === "OPTIONS") return response.writeHead(204).end();
|
||||
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
let body;
|
||||
try {
|
||||
const chunks = [];
|
||||
for await (const chunk of request) chunks.push(chunk);
|
||||
const raw = Buffer.concat(chunks).toString();
|
||||
body = raw ? JSON.parse(raw) : undefined;
|
||||
} catch {
|
||||
return reply(response, {}, { msg: "Invalid fixture request body" }, 400);
|
||||
}
|
||||
|
||||
if (url.pathname === "/__control/reset" && request.method === "POST") {
|
||||
// 场景从全新列表和未付款状态开始;11行变体用于删除最后一页唯一记录。
|
||||
options = body ?? {};
|
||||
requests = [];
|
||||
paid = false;
|
||||
keys = initialKeys
|
||||
.slice(0, options.sshLastPageSingleRow ? 11 : initialKeys.length)
|
||||
.map((key) => ({ ...key }));
|
||||
return reply(response);
|
||||
}
|
||||
if (url.pathname === "/__control/options" && request.method === "POST") {
|
||||
options = { ...options, ...body };
|
||||
return reply(response);
|
||||
}
|
||||
if (url.pathname === "/__control/requests") return reply(response, requests);
|
||||
|
||||
const path = url.pathname.replace(/^\/console\/v1/, "");
|
||||
const record = {
|
||||
// 请求原文仅在测试进程内存供断言使用,测试输出端另做脱敏,不能直接导出此对象。
|
||||
method: request.method,
|
||||
path,
|
||||
query: Object.fromEntries(url.searchParams),
|
||||
body,
|
||||
authorization: request.headers.authorization ?? null,
|
||||
referrer: request.headers.referer ?? null,
|
||||
handled: true,
|
||||
};
|
||||
requests.push(record);
|
||||
|
||||
if (path === "/common" && request.method === "GET") {
|
||||
// 模拟站点配置决定允许的登录方式;离线场景关闭外部CAPTCHA与OAuth。
|
||||
return reply(response, {
|
||||
website_name: "本地前端验收",
|
||||
login_phone_password: options.loginMode === "sms-only" ? 0 : 1,
|
||||
login_email_password: options.loginMode === "sms-only" ? 0 : 1,
|
||||
login_phone_verify: options.loginMode === "password-only" ? 0 : 1,
|
||||
first_login_method:
|
||||
options.loginMode === "password-only" ? "code" : "password",
|
||||
captcha_client_login: 0,
|
||||
captcha_client_login_error: 0,
|
||||
captcha_client_login_error_3_times: 0,
|
||||
register_phone: 1,
|
||||
register_email: 1,
|
||||
plugin_configuration: { mp_weixin_notice: { scan_login: 0 } },
|
||||
oauth: [],
|
||||
});
|
||||
}
|
||||
if (path === "/login" && request.method === "POST")
|
||||
return reply(response, { jwt: token });
|
||||
if (path === "/phone/code" && request.method === "POST")
|
||||
return reply(response);
|
||||
|
||||
// 公共配置和登录之后的接口必须携带实际登录得到的测试令牌。
|
||||
if (request.headers.authorization !== `Bearer ${token}`) {
|
||||
record.invalidAuthorization = true;
|
||||
return reply(response, {}, { msg: "Missing fixture session" }, 401);
|
||||
}
|
||||
|
||||
if (path === "/index" && request.method === "GET" && options.expiredLegacy)
|
||||
return reply(
|
||||
response,
|
||||
{},
|
||||
{ status: 401, msg: "Expired legacy fixture session" },
|
||||
);
|
||||
if (path === "/index" && request.method === "GET")
|
||||
return reply(response, { account });
|
||||
if (path === "/account" && request.method === "GET")
|
||||
return reply(response, { account });
|
||||
if (path === "/menu" && request.method === "GET") {
|
||||
return reply(response, {
|
||||
menu: options.emptyMenu
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: 1,
|
||||
name: "SSH 密钥",
|
||||
menu_type: "custom",
|
||||
url: "/security/ssh-keys",
|
||||
},
|
||||
{ id: 2, name: "订单", menu_type: "custom", url: "/orders" },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (path === "/client_care/mail" && request.method === "GET") {
|
||||
return reply(response, { credit_limit: { count: 0, list: [], type: [] } });
|
||||
}
|
||||
if (path === "/logout" && request.method === "POST") {
|
||||
if (options.hangLogout) return;
|
||||
return reply(response);
|
||||
}
|
||||
if (path === "/security_group" && request.method === "GET")
|
||||
return reply(response, { list: [], count: 0 });
|
||||
if (path === "/ssh_key" && request.method === "GET") {
|
||||
if (options.hangSshBody) {
|
||||
// 只发送响应头和不完整JSON,保持连接以复现“响应体解码永不结束”。
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.write('{"status":200,"msg":"","data":');
|
||||
return;
|
||||
}
|
||||
if (options.expiredSession)
|
||||
return reply(
|
||||
response,
|
||||
{},
|
||||
{ status: 401, msg: "Expired fixture session" },
|
||||
);
|
||||
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
|
||||
// 列表接口仍走后端分页;搜索聚合/切片由真实前端路径负责。
|
||||
const limit = Math.max(1, Number(url.searchParams.get("limit")) || 10);
|
||||
return reply(response, {
|
||||
list: keys.slice((page - 1) * limit, page * limit),
|
||||
count: keys.length,
|
||||
});
|
||||
}
|
||||
if (path === "/ssh_key" && request.method === "POST") {
|
||||
const id = Math.max(0, ...keys.map((key) => key.id)) + 1;
|
||||
keys.push({ id, ...body, finger_print: `SHA256:fixture-${id}` });
|
||||
return reply(response, { id });
|
||||
}
|
||||
const keyId = /^\/ssh_key\/(\d+)$/.exec(path);
|
||||
if (keyId && request.method === "PUT") {
|
||||
const key = keys.find((entry) => entry.id === Number(keyId[1]));
|
||||
if (!key) return reply(response, {}, { msg: "Fixture key not found" }, 404);
|
||||
Object.assign(key, body);
|
||||
return reply(response);
|
||||
}
|
||||
if (keyId && request.method === "DELETE") {
|
||||
keys = keys.filter((entry) => entry.id !== Number(keyId[1]));
|
||||
return reply(response);
|
||||
}
|
||||
if (path === "/order/42" && request.method === "GET") {
|
||||
// 订单未付金额、流水与支付状态共享 paid 标记,支付成功后回读反映已付金额。
|
||||
if (options.expiredPayment)
|
||||
return reply(
|
||||
response,
|
||||
{},
|
||||
{ status: 401, msg: "Expired payment fixture session" },
|
||||
);
|
||||
return reply(response, {
|
||||
order: {
|
||||
id: 42,
|
||||
amount: "20.00",
|
||||
credit: "0.00",
|
||||
amount_unpaid: paid ? "0.00" : "20.00",
|
||||
type: "new",
|
||||
status: paid ? "Paid" : "Unpaid",
|
||||
create_time: 1700000000,
|
||||
pay_time: paid ? 1700000060 : 0,
|
||||
gateway: "alipay",
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
product_name: "本地测试实例",
|
||||
description: "前端支付验收",
|
||||
amount: "20.00",
|
||||
},
|
||||
],
|
||||
},
|
||||
customfields: { is_pre_invoice: 0 },
|
||||
});
|
||||
}
|
||||
if (path === "/order/42/transaction_record" && request.method === "GET") {
|
||||
return reply(response, {
|
||||
list: paid
|
||||
? [{ amount: "20.00", create_time: 1700000060, description: "alipay" }]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
if (path === "/gateway" && request.method === "GET") {
|
||||
return reply(response, {
|
||||
list: [{ id: 1, name: "alipay", title: "本地测试支付" }],
|
||||
});
|
||||
}
|
||||
if (path === "/voucher/pay" && request.method === "GET")
|
||||
return reply(response, { list: [] });
|
||||
if (path === "/pay" && request.method === "POST") {
|
||||
// Paid 是顶层 code 字段的支付状态值;挂起变体返回渠道 HTML,随后状态查询不响应。
|
||||
if (options.hangPaymentStatus)
|
||||
return reply(response, { html: '<div role="status">本地支付渠道</div>' });
|
||||
paid = true;
|
||||
return reply(response, {}, { code: "Paid" });
|
||||
}
|
||||
if (path === "/pay/42/status" && request.method === "GET") {
|
||||
if (options.hangPaymentStatus) return;
|
||||
return reply(response, {}, { code: paid ? "Paid" : "Unpaid" });
|
||||
}
|
||||
if (path === "/certification/info" && request.method === "GET") {
|
||||
return reply(response, {
|
||||
certification_open: 0,
|
||||
certification_company_open: 0,
|
||||
is_certification: 0,
|
||||
});
|
||||
}
|
||||
if (
|
||||
["/ticket", "/announcement", "/host"].includes(path) &&
|
||||
request.method === "GET"
|
||||
) {
|
||||
return reply(response, {
|
||||
list: [],
|
||||
count: 0,
|
||||
...(path === "/host" ? { server_page: "classified-v1" } : {}),
|
||||
});
|
||||
}
|
||||
if (path === "/withdraw/rule/credit" && request.method === "GET")
|
||||
return reply(response, {});
|
||||
if (path === "/recommend/promoter" && request.method === "GET")
|
||||
return reply(response, { promoter: { permission: 0 } });
|
||||
|
||||
// 未实现接口显式失败并留标记,防止新增产品请求被“统一成功”假后端掩盖。
|
||||
record.handled = false;
|
||||
reply(
|
||||
response,
|
||||
{},
|
||||
{ msg: `Unhandled fixture endpoint: ${record.method} ${path}` },
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
// 由系统选择空闲端口,首行JSON作为父测试进程的启动握手。
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ origin: `http://127.0.0.1:${server.address().port}` })}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
// 先关闭挂起连接,确保超时测试也能正常清理进程。
|
||||
server.closeAllConnections();
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
@@ -1,375 +0,0 @@
|
||||
/**
|
||||
* 通过浏览器验证实际生产构建,不替换内部模块、不预置 JWT。
|
||||
* 测试只连接独立本地 HTTP 假后端,覆盖页面行为和接口契约,不代表真实支付/供应商已验收。
|
||||
* 每个场景保存截图、脱敏请求形状及稳定结果,方便失败后复核。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { access, mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { after, before } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const testDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const appRoot = resolve(process.env.E2E_APP_ROOT || join(testDirectory, ".."));
|
||||
// 默认复用系统 Chrome;CI/其他平台可指定可执行文件,不自动下载或修改用户浏览器。
|
||||
const chromePath =
|
||||
process.env.CHROME_PATH ||
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
||||
export const loginAccount = "audit@example.invalid";
|
||||
export const loginPassword = "test-only-password";
|
||||
const manifest = { schemaVersion: 1, scenarios: [] };
|
||||
const children = [];
|
||||
export let backendOrigin;
|
||||
export let appOrigin;
|
||||
let browser;
|
||||
let runDirectory;
|
||||
|
||||
export async function eventually(read, accept, message, timeout = 10000) {
|
||||
// 等待可观察结果稳定,而不是假设渲染/网络会在固定休眠后完成;失败信息过滤敏感字段。
|
||||
const deadline = Date.now() + timeout;
|
||||
let value;
|
||||
do {
|
||||
value = await read();
|
||||
if (accept(value)) return value;
|
||||
await delay(50);
|
||||
} while (Date.now() < deadline);
|
||||
const lastValue = JSON.stringify(value, (key, entry) =>
|
||||
["authorization", "password", "jwt", "token"].includes(key)
|
||||
? "<redacted>"
|
||||
: entry,
|
||||
);
|
||||
assert.fail(`${message}; last value: ${lastValue}`);
|
||||
}
|
||||
|
||||
function startProcess(arguments_, options = {}) {
|
||||
// 统一收集子进程日志和句柄,异常退出可诊断,after 钩子负责结束所有本地服务。
|
||||
const child = spawn(process.execPath, arguments_, {
|
||||
...options,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.log = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
child.log += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
child.log += chunk.toString();
|
||||
});
|
||||
children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
export async function fixtureRequest(path, body) {
|
||||
// 控制口只属于本地测试后端,调整外部响应,不给产品源码增加测试专用分支。
|
||||
const response = await fetch(`${backendOrigin}/__control/${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
return (await response.json()).data;
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
// 让系统分配回环端口,降低并行运行多个验收进程时的固定端口冲突风险。
|
||||
const server = createServer();
|
||||
await new Promise((resolve_, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve_);
|
||||
});
|
||||
const port = server.address().port;
|
||||
await new Promise((resolve_) => server.close(resolve_));
|
||||
return port;
|
||||
}
|
||||
|
||||
// node --test 并行运行各验收文件:freePort 释放端口到预览进程绑定之间,其他文件的进程可能占用同一端口。
|
||||
// Nitro 绑定失败时只记录 EADDRINUSE 而不退出,HTTP 探测还会命中别的文件的预览服务,
|
||||
// 因此只以本进程输出的监听地址为准,冲突时换端口重试。
|
||||
async function startApp(attempts = 5) {
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
const port = await freePort();
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const app = startProcess(
|
||||
[join(appRoot, ".output", "server", "index.mjs")],
|
||||
{
|
||||
cwd: appRoot,
|
||||
env: {
|
||||
// 使用生产已有的公开 API 配置接入外部边界,不读取或修改项目 .env。
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
HOST: "127.0.0.1",
|
||||
PORT: String(port),
|
||||
NITRO_HOST: "127.0.0.1",
|
||||
NITRO_PORT: String(port),
|
||||
NUXT_PUBLIC_API_BASE: backendOrigin,
|
||||
},
|
||||
},
|
||||
);
|
||||
const state = await eventually(
|
||||
async () => {
|
||||
if (app.exitCode !== null || app.signalCode !== null) return "exited";
|
||||
if (app.log.includes("EADDRINUSE")) return "collision";
|
||||
if (!app.log.includes(`Listening on ${origin}`)) return false;
|
||||
try {
|
||||
const response = await fetch(`${origin}/login`, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
return response.ok && "ready";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
Boolean,
|
||||
"Built app did not start",
|
||||
30000,
|
||||
);
|
||||
if (state === "ready") return origin;
|
||||
if (state === "exited") throw new Error(`Preview exited: ${app.log}`);
|
||||
children.splice(children.indexOf(app), 1);
|
||||
const exited = new Promise((resolve_) => app.once("exit", resolve_));
|
||||
app.kill("SIGKILL");
|
||||
await exited;
|
||||
}
|
||||
throw new Error(`Built app could not bind a free port in ${attempts} tries`);
|
||||
}
|
||||
|
||||
before(
|
||||
async () => {
|
||||
// 必须先构建;此处运行 .output,而非开发服务器,确保测试走实际交付入口。
|
||||
const runs = join(testDirectory, "fixtures", "runs");
|
||||
await mkdir(runs, { recursive: true });
|
||||
runDirectory = await mkdtemp(join(runs, "run-"));
|
||||
await access(chromePath);
|
||||
await access(join(appRoot, ".output", "server", "index.mjs"));
|
||||
|
||||
const api = startProcess([
|
||||
join(testDirectory, "fixtures", "api-server.mjs"),
|
||||
]);
|
||||
// 假后端首行只报告监听地址,避免在启动协议中输出账号密码或请求内容。
|
||||
backendOrigin = await new Promise((resolve_, reject) => {
|
||||
const lines = createInterface({ input: api.stdout });
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error("Fixture API did not start")),
|
||||
10000,
|
||||
);
|
||||
lines.once("line", (line) => {
|
||||
clearTimeout(timer);
|
||||
lines.close();
|
||||
try {
|
||||
resolve_(JSON.parse(line).origin);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
api.once("error", reject);
|
||||
api.once("exit", (code) =>
|
||||
reject(new Error(`Fixture API exited: ${code}`)),
|
||||
);
|
||||
});
|
||||
|
||||
appOrigin = await startApp();
|
||||
|
||||
browser = await chromium.launch({
|
||||
executablePath: chromePath,
|
||||
headless: true,
|
||||
});
|
||||
},
|
||||
{ timeout: 45000 },
|
||||
);
|
||||
|
||||
after(async () => {
|
||||
// 先关闭浏览器,再逆序结束服务;挂起接口也不能让验收进程永久留在后台。
|
||||
await browser?.close();
|
||||
for (const child of children.reverse()) {
|
||||
if (child.exitCode !== null) continue;
|
||||
child.kill("SIGTERM");
|
||||
await Promise.race([
|
||||
new Promise((resolve_) => child.once("exit", resolve_)),
|
||||
delay(1500),
|
||||
]);
|
||||
if (child.exitCode === null) child.kill("SIGKILL");
|
||||
}
|
||||
if (runDirectory) {
|
||||
manifest.scenarios.sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
await writeFile(
|
||||
join(runDirectory, "manifest.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
console.log(`E2E artifacts: ${join(runDirectory, "manifest.json")}`);
|
||||
}
|
||||
});
|
||||
|
||||
function normalizeRequests(requests) {
|
||||
// 只记录字段名和是否认证;去重、排序移除并发请求顺序等非业务差异,不保存凭证值。
|
||||
const distinct = requests.map(
|
||||
({ method, path, query, body, authorization }) => ({
|
||||
method,
|
||||
path,
|
||||
queryKeys: Object.keys(query).sort(),
|
||||
bodyKeys: Object.keys(body ?? {}).sort(),
|
||||
authenticated: authorization !== null,
|
||||
}),
|
||||
);
|
||||
return [
|
||||
...new Map(
|
||||
distinct.map((entry) => [JSON.stringify(entry), entry]),
|
||||
).values(),
|
||||
].sort((left, right) =>
|
||||
JSON.stringify(left).localeCompare(JSON.stringify(right)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function scenario(t, options, exercise) {
|
||||
// 每个场景重新初始化后端状态和浏览器上下文,账号与列表缓存不能污染下一场景。
|
||||
await fixtureRequest("reset", options);
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1680, height: 1050 },
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
const errors = [];
|
||||
const networkViolations = [];
|
||||
const allowedOrigins = new Set([appOrigin, backendOrigin]);
|
||||
await context.route("**/*", async (route) => {
|
||||
// 只允许两个本地 origin;意外的生产/CDN访问会被阻断,并在场景结束时作为失败报告。
|
||||
const url = new URL(route.request().url());
|
||||
if (
|
||||
!["http:", "https:"].includes(url.protocol) ||
|
||||
allowedOrigins.has(url.origin)
|
||||
)
|
||||
return route.continue();
|
||||
networkViolations.push(
|
||||
`${route.request().method()} ${url.origin}${url.pathname}`,
|
||||
);
|
||||
await route.abort("blockedbyclient");
|
||||
});
|
||||
context.on("page", (openedPage) => {
|
||||
openedPage.setDefaultTimeout(10000);
|
||||
openedPage.on("pageerror", (error) => errors.push(error.message));
|
||||
openedPage.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const screenshot = `${t.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.png`;
|
||||
let failure;
|
||||
let observed;
|
||||
let requests;
|
||||
try {
|
||||
observed = await exercise(page);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
// 即使业务断言失败,也尽量保存截图与请求诊断,不能只留下“超时”这一条错误。
|
||||
try {
|
||||
assert.equal(
|
||||
await page
|
||||
.getByText("Invalid PrimeUI License", { exact: true })
|
||||
.isVisible(),
|
||||
false,
|
||||
"The delivered UI must not display an invalid vendor license warning",
|
||||
);
|
||||
} catch (error) {
|
||||
failure ??= error;
|
||||
}
|
||||
try {
|
||||
await page.screenshot({
|
||||
path: join(runDirectory, screenshot),
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
failure ??= error;
|
||||
}
|
||||
await context.close();
|
||||
requests = await fixtureRequest("requests");
|
||||
// 诊断附件保留先后顺序,但不落盘 body、Authorization 或 referrer 原文。
|
||||
await writeFile(
|
||||
join(runDirectory, screenshot.replace(/\.png$/, ".requests.json")),
|
||||
`${JSON.stringify(
|
||||
requests.map(
|
||||
(
|
||||
{ method, path, authorization, handled, invalidAuthorization },
|
||||
index,
|
||||
) => ({
|
||||
sequence: index + 1,
|
||||
method,
|
||||
path,
|
||||
authenticated: authorization !== null,
|
||||
handled,
|
||||
invalidAuthorization: invalidAuthorization === true,
|
||||
}),
|
||||
),
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
try {
|
||||
assert.deepEqual(
|
||||
networkViolations,
|
||||
[],
|
||||
"Browser attempted a non-local network request",
|
||||
);
|
||||
assert.deepEqual(
|
||||
errors,
|
||||
[],
|
||||
"Browser reported runtime or console errors",
|
||||
);
|
||||
assert.deepEqual(
|
||||
requests.filter((request) => !request.handled),
|
||||
[],
|
||||
"Fixture is missing a production endpoint",
|
||||
);
|
||||
assert.deepEqual(
|
||||
requests.filter((request) => request.invalidAuthorization),
|
||||
[],
|
||||
"API calls lost the session token",
|
||||
);
|
||||
} catch (error) {
|
||||
failure ??= error;
|
||||
}
|
||||
// 主清单保留稳定观察结果;失败文本另外替换随机 origin、测试密码和令牌。
|
||||
manifest.scenarios.push({
|
||||
name: t.name,
|
||||
result: failure ? "failed" : "passed",
|
||||
...(observed ? { observed } : {}),
|
||||
screenshot,
|
||||
requests: normalizeRequests(requests),
|
||||
browserErrors: [...new Set(errors)].sort(),
|
||||
networkViolations: [...new Set(networkViolations)].sort(),
|
||||
...(failure
|
||||
? {
|
||||
failure: {
|
||||
name: failure.name,
|
||||
message: failure.message
|
||||
.replaceAll(appOrigin, "<app>")
|
||||
.replaceAll(backendOrigin, "<api>")
|
||||
.replaceAll(loginPassword, "<password>")
|
||||
.replaceAll("e2e-session-token", "<token>"),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
if (failure) throw failure;
|
||||
}
|
||||
|
||||
export async function login(page, redirect = "/security/ssh-keys") {
|
||||
// 所有普通场景经真实登录表单建立会话,顺带验证密码输入框没有退化成明文框。
|
||||
await page.goto(
|
||||
`${appOrigin}/login?redirect=${encodeURIComponent(redirect)}`,
|
||||
);
|
||||
const password = page.getByLabel("密码", { exact: true });
|
||||
assert.equal(await password.getAttribute("type"), "password");
|
||||
await page.getByLabel("账号", { exact: true }).fill(loginAccount);
|
||||
await password.fill(loginPassword);
|
||||
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||
await page.waitForURL((url) => url.pathname !== "/login");
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
/**
|
||||
* 修复前记录的失败场景:
|
||||
* - 实名认证 HTML 应保留二维码刷新脚本,但不能读写控制台父页面。
|
||||
* - 收到二维码地址不等于图片已加载;图片等待或损坏时不得开始登录轮询。
|
||||
* - 图片损坏时应提供可访问的错误提示和重试入口;新图片加载成功后仍应允许正常登录。
|
||||
* 所有场景均进入实际构建的页面,仅替换外部 HTTP 服务或浏览器时钟。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
eventually,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
const qrImage = "R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==";
|
||||
|
||||
function reply(route, data) {
|
||||
return route.fulfill({
|
||||
json: { status: 200, msg: "", data },
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
"certification QR refresh scripts run without console-parent privileges",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.addInitScript(() => {
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
() => {
|
||||
document.documentElement.dataset.auditIsolationSeed =
|
||||
"synthetic-local-marker";
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/certification/**`, (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path.endsWith("/info"))
|
||||
return reply(route, {
|
||||
certification_open: 1,
|
||||
certification_company_open: 0,
|
||||
certification_upload: 0,
|
||||
is_certification: 0,
|
||||
person: { status: 4 },
|
||||
});
|
||||
if (path.endsWith("/auth"))
|
||||
return reply(route, {
|
||||
html: `<!doctype html><html><body><div id="qrcode"><img src="data:image/gif;base64,${qrImage}"></div><script>
|
||||
document.documentElement.dataset.auditPluginScript = "ran";
|
||||
try {
|
||||
parent.document.documentElement.dataset.auditFromCertification =
|
||||
parent.document.documentElement.dataset.auditIsolationSeed;
|
||||
document.documentElement.dataset.auditParentAccess = "allowed";
|
||||
} catch {
|
||||
document.documentElement.dataset.auditParentAccess = "blocked";
|
||||
}
|
||||
setTimeout(() => {
|
||||
const replacement = document.createElement("img");
|
||||
replacement.src = "data:image/gif;base64,${qrImage}";
|
||||
replacement.dataset.auditDynamicQr = "updated";
|
||||
document.querySelector("#qrcode").replaceChildren(replacement);
|
||||
}, 30);
|
||||
</script></body></html>`,
|
||||
});
|
||||
if (path.endsWith("/status"))
|
||||
return reply(route, { code: 2, refresh: 0 });
|
||||
return route.fallback();
|
||||
});
|
||||
await login(page, "/account/realname");
|
||||
await page
|
||||
.getByRole("button", { name: "继续扫码核验", exact: true })
|
||||
.click();
|
||||
const element = await page.locator("iframe.auth-frame").elementHandle();
|
||||
const frame = await element.contentFrame();
|
||||
await frame
|
||||
.locator('[data-certification-qr][data-audit-dynamic-qr="updated"]')
|
||||
.waitFor();
|
||||
const observed = {
|
||||
refreshedQrVisible: await frame
|
||||
.locator("[data-certification-qr]")
|
||||
.isVisible(),
|
||||
pluginScriptRan: await frame.evaluate(
|
||||
() => document.documentElement.dataset.auditPluginScript === "ran",
|
||||
),
|
||||
parentAccessBlocked: await frame.evaluate(
|
||||
() =>
|
||||
document.documentElement.dataset.auditParentAccess === "blocked",
|
||||
),
|
||||
parentMarkerUntouched: await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.dataset.auditFromCertification ===
|
||||
undefined,
|
||||
),
|
||||
};
|
||||
assert.equal(observed.refreshedQrVisible, true);
|
||||
assert.equal(observed.pluginScriptRan, true);
|
||||
assert.equal(observed.parentAccessBlocked, true);
|
||||
assert.equal(observed.parentMarkerUntouched, true);
|
||||
return observed;
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"a broken WeChat image stops its scan and a fresh image recovers login",
|
||||
{ timeout: 35000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.clock.install();
|
||||
let acquisitions = 0;
|
||||
let firstScanPolls = 0;
|
||||
let secondScanPolls = 0;
|
||||
let releaseImage;
|
||||
const imageRelease = new Promise((resolve) => {
|
||||
releaseImage = resolve;
|
||||
});
|
||||
let imageRequested;
|
||||
const imageStarted = new Promise((resolve) => {
|
||||
imageRequested = resolve;
|
||||
});
|
||||
await page.context().route(`${backendOrigin}/**`, async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === "/audit-qr/1.gif") {
|
||||
imageRequested();
|
||||
await imageRelease;
|
||||
return route.fulfill({
|
||||
contentType: "image/gif",
|
||||
body: "invalid-image",
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/audit-qr/2.gif")
|
||||
return route.fulfill({
|
||||
contentType: "image/gif",
|
||||
body: Buffer.from(qrImage, "base64"),
|
||||
});
|
||||
if (url.pathname === "/console/v1/common")
|
||||
return reply(route, {
|
||||
website_name: "本地扫码验收",
|
||||
login_phone_password: 1,
|
||||
login_email_password: 1,
|
||||
plugin_configuration: { mp_weixin_notice: { scan_login: 1 } },
|
||||
});
|
||||
if (url.pathname === "/console/v1/mp_weixin_notice/qrcode") {
|
||||
acquisitions++;
|
||||
return reply(route, {
|
||||
img_url: `${backendOrigin}/audit-qr/${acquisitions}.gif`,
|
||||
ticket: `local-qr-ticket-${acquisitions}`,
|
||||
token: "local-qr-token",
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/console/v1/mp_weixin_notice/qrcode/status") {
|
||||
if (url.searchParams.get("ticket") === "local-qr-ticket-1") {
|
||||
firstScanPolls++;
|
||||
return reply(route, { status: "Wait" });
|
||||
}
|
||||
secondScanPolls++;
|
||||
return reply(route, { status: "Success", jwt: "e2e-session-token" });
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
try {
|
||||
await page.goto(`${appOrigin}/login?redirect=/security/ssh-keys`);
|
||||
await page
|
||||
.getByRole("button", { name: "获取二维码", exact: true })
|
||||
.click();
|
||||
await imageStarted;
|
||||
await page.clock.runFor(5000);
|
||||
const pollsBeforeImage = firstScanPolls;
|
||||
releaseImage();
|
||||
await eventually(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const image = document.querySelector(".qr-img");
|
||||
return !image || image.complete;
|
||||
}),
|
||||
Boolean,
|
||||
"The invalid image must finish decoding",
|
||||
);
|
||||
await page.clock.runFor(5000);
|
||||
const failureState = {
|
||||
accessibleError: await page
|
||||
.locator(".qr-status")
|
||||
.getByRole("alert")
|
||||
.isVisible(),
|
||||
brokenImageRemoved: (await page.locator(".qr-img").count()) === 0,
|
||||
pollsBeforeImage,
|
||||
pollsAfterImageFailure: firstScanPolls,
|
||||
};
|
||||
await page
|
||||
.getByRole("button", { name: "刷新二维码", exact: true })
|
||||
.click();
|
||||
await eventually(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const image = document.querySelector(".qr-img");
|
||||
return Boolean(image?.complete && image.naturalWidth > 0);
|
||||
}),
|
||||
Boolean,
|
||||
"Retry must display a real decoded QR image",
|
||||
);
|
||||
await page.clock.runFor(2100);
|
||||
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
||||
await page.getByText("audit-01", { exact: true }).waitFor();
|
||||
assert.equal(failureState.accessibleError, true);
|
||||
assert.equal(failureState.brokenImageRemoved, true);
|
||||
assert.equal(failureState.pollsBeforeImage, 0);
|
||||
assert.equal(failureState.pollsAfterImageFailure, 0);
|
||||
assert.equal(secondScanPolls, 1);
|
||||
return { ...failureState, retryCompletedNormalLogin: true };
|
||||
} finally {
|
||||
releaseImage();
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* 认证摘要必须保留接口状态,不能把所有非零状态都视为认证通过。
|
||||
* 原有回归缺口:首页与实名认证页面此前未使用相同的失败、已提交、审核中响应进行对照验收。
|
||||
* 仅替换外部 HTTP 后端;登录、首页卡片、实名认证路由与认证表单均使用实际实现。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
eventually,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
const states = [
|
||||
{ name: "failed", status: "2", summary: /未通过|失败/, application: true },
|
||||
{ name: "submitted", status: "4", summary: /待核验/, continuation: true },
|
||||
{ name: "under review", status: "3", summary: /待审核|审核中/ },
|
||||
{ name: "approved", status: "1", approved: true },
|
||||
{ name: "not applied", status: "0", application: true },
|
||||
{ name: "without records", application: true },
|
||||
];
|
||||
|
||||
for (const state of states)
|
||||
test(
|
||||
`home and real-name actions agree when certification is ${state.name}`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const requests = [];
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/certification/**`, (route) => {
|
||||
const request = route.request();
|
||||
requests.push({
|
||||
method: request.method(),
|
||||
path: new URL(request.url()).pathname,
|
||||
authenticated: Boolean(request.headers().authorization),
|
||||
});
|
||||
if (!request.url().endsWith("/info")) return route.fallback();
|
||||
return route.fulfill({
|
||||
json: {
|
||||
status: 200,
|
||||
msg: "",
|
||||
data: {
|
||||
certification_open: 1,
|
||||
certification_company_open: 1,
|
||||
certification_company_need_person: 0,
|
||||
certification_upload: 0,
|
||||
is_certification: state.approved ? 1 : 0,
|
||||
...(state.status === undefined
|
||||
? {}
|
||||
: {
|
||||
person: {
|
||||
status: state.status,
|
||||
card_name: "本地认证用户",
|
||||
auth_fail: state.status === "2" ? "本地驳回原因" : "",
|
||||
},
|
||||
company: {
|
||||
status: state.status,
|
||||
company: "本地认证企业",
|
||||
auth_fail: state.status === "2" ? "本地驳回原因" : "",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
});
|
||||
await login(page, "/");
|
||||
const tile = (label) =>
|
||||
page.locator(".tile").filter({
|
||||
has: page.locator("dt").getByText(label, { exact: true }),
|
||||
});
|
||||
const person = tile("实名认证");
|
||||
const company = tile("组织机构认证");
|
||||
await eventually(
|
||||
() => person.locator("dd").innerText(),
|
||||
(text) => text.trim().length > 0,
|
||||
"The certification summary must finish loading",
|
||||
);
|
||||
const home = {
|
||||
person: await person.locator("dd").innerText(),
|
||||
company: await company.locator("dd").innerText(),
|
||||
};
|
||||
if (state.summary) {
|
||||
assert.match(home.person, state.summary);
|
||||
assert.match(home.company, state.summary);
|
||||
} else if (state.approved) {
|
||||
assert.equal(await person.getByRole("button").count(), 1);
|
||||
assert.equal(await company.getByRole("button").count(), 1);
|
||||
assert.doesNotMatch(home.company, /去认证|未通过|待审核|待核验/);
|
||||
} else {
|
||||
assert.equal(
|
||||
await person.getByRole("link", { name: "去认证" }).count(),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
await company.getByRole("link", { name: "去认证" }).count(),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
if (state.application && state.status !== "2")
|
||||
await person.getByRole("link", { name: "去认证" }).click();
|
||||
else await page.goto(`${appOrigin}/account/realname`);
|
||||
const personApplication = page.getByPlaceholder("请输入真实姓名", {
|
||||
exact: true,
|
||||
});
|
||||
const continuePerson = page.getByRole("button", {
|
||||
name: "继续扫码核验",
|
||||
exact: true,
|
||||
});
|
||||
const continueCompany = page.getByRole("button", {
|
||||
name: "继续企业核验",
|
||||
exact: true,
|
||||
});
|
||||
if (state.application) {
|
||||
await personApplication.waitFor();
|
||||
await page
|
||||
.getByRole("tab", { name: "企业认证", exact: true })
|
||||
.click();
|
||||
await page.getByPlaceholder("营业执照上的企业全称").waitFor();
|
||||
} else if (state.continuation) {
|
||||
await continuePerson.waitFor();
|
||||
assert.equal(await continuePerson.isEnabled(), true);
|
||||
assert.equal(await continueCompany.isEnabled(), true);
|
||||
assert.equal(await personApplication.count(), 0);
|
||||
} else {
|
||||
await page
|
||||
.getByText(
|
||||
state.approved
|
||||
? "已完成实名认证"
|
||||
: "资料正在审核,请等待审核结果。",
|
||||
{
|
||||
exact: true,
|
||||
},
|
||||
)
|
||||
.waitFor();
|
||||
assert.equal(await personApplication.count(), 0);
|
||||
assert.equal(await continuePerson.count(), 0);
|
||||
assert.equal(await continueCompany.count(), 0);
|
||||
}
|
||||
assert.equal(requests.length, 2);
|
||||
assert.ok(requests.every((request) => request.authenticated));
|
||||
assert.ok(
|
||||
requests.every(
|
||||
(request) =>
|
||||
request.method === "GET" && request.path.endsWith("/info"),
|
||||
),
|
||||
);
|
||||
return {
|
||||
home,
|
||||
applicationAvailable: Boolean(state.application),
|
||||
verificationContinuationAvailable: Boolean(state.continuation),
|
||||
authenticatedReadOnlyCertificationRequests: requests,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -1,426 +0,0 @@
|
||||
/** 回归缺口:刷新时列宽、响应式结构和动态列不能猜测;同样五行内容替换骨架不应改变相邻卡片位置。 */
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
backendOrigin,
|
||||
eventually,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
// 通过外部 HTTP 延迟实际列表,首屏不得先猜测表头;成功后的刷新则保留原表与数据。
|
||||
for (const width of [390, 1440]) {
|
||||
test(
|
||||
`host columns wait for their schema and survive refresh at ${width}px`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
let release;
|
||||
let held = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let reads = 0;
|
||||
let fail = false;
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === backendOrigin &&
|
||||
/^\/console\/v1\/mf_cloud\/7[0-9]{2}(?:\/flow)?$/.test(
|
||||
url.pathname,
|
||||
),
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
status: 200,
|
||||
msg: "",
|
||||
data: {
|
||||
image: { name: "Ubuntu 24.04" },
|
||||
total: 100,
|
||||
used: 20,
|
||||
leave: 80,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route(
|
||||
`${backendOrigin}/console/v1/home/host?*`,
|
||||
async (route) => {
|
||||
reads++;
|
||||
await held;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
status: fail ? 400 : 200,
|
||||
msg: fail ? "本地列配置读取失败" : "",
|
||||
data: {
|
||||
count: 20,
|
||||
list: Array.from({ length: 20 }, (_, index) => ({
|
||||
id: 701 + index,
|
||||
name: `audit-host-${701 + index}`,
|
||||
product_name: "本地验收云服务器",
|
||||
module: "mf_cloud",
|
||||
status: "Unpaid",
|
||||
self_defined_field: { 101: "青岛" },
|
||||
})),
|
||||
select_field: ["name", "product_name", "status"],
|
||||
self_defined_field: [
|
||||
{ id: 101, field_name: "部署区域", field_type: "text" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
try {
|
||||
await login(page, "/cloud-servers?m=73");
|
||||
await page.locator(".host-list-page").waitFor();
|
||||
await page.locator("#__nuxt-loader").waitFor({ state: "detached" });
|
||||
assert.equal(await page.locator(".list-table table").count(), 0);
|
||||
await page.locator(".list-table .list-empty-title").waitFor();
|
||||
assert.equal(
|
||||
await page.locator(".list-table").getAttribute("aria-busy"),
|
||||
"true",
|
||||
);
|
||||
release();
|
||||
await page.getByText("audit-host-701", { exact: true }).waitFor();
|
||||
const table = page.locator(".list-table table");
|
||||
const headers = () => table.locator("th").allTextContents();
|
||||
const accepted = await headers();
|
||||
assert.ok(accepted.includes("部署区域"));
|
||||
assert.ok(accepted.includes("名称"));
|
||||
for (const absent of [
|
||||
"IP",
|
||||
"地域",
|
||||
"计费方式",
|
||||
"自动续费",
|
||||
"基础信息",
|
||||
])
|
||||
assert.equal(accepted.includes(absent), false);
|
||||
const identity = await table.elementHandle();
|
||||
held = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.getByRole("button", { name: "搜索", exact: true }).click();
|
||||
await eventually(
|
||||
() => reads,
|
||||
(count) => count === 2,
|
||||
"Refresh reaches HTTP boundary",
|
||||
);
|
||||
assert.equal(
|
||||
await identity.evaluate(
|
||||
(element) =>
|
||||
element === document.querySelector(".list-table table"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(await headers(), accepted);
|
||||
assert.equal(
|
||||
await page.getByText("audit-host-701", { exact: true }).isVisible(),
|
||||
true,
|
||||
);
|
||||
release();
|
||||
await page.locator(".p-datatable-mask").waitFor({ state: "hidden" });
|
||||
await page.evaluate(() =>
|
||||
window.scrollTo({ top: 500, behavior: "instant" }),
|
||||
);
|
||||
const savedScroll = await page.evaluate(() => scrollY);
|
||||
assert.ok(savedScroll > 300);
|
||||
// 真实站内链接离页再返回,复用行缓存但重新取得列元数据;失败不能永久停留在加载态。
|
||||
await page
|
||||
.locator('nav a[href="/security/ssh-keys"]')
|
||||
.evaluate((element) => element.click());
|
||||
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
||||
fail = true;
|
||||
await page.goBack();
|
||||
await page
|
||||
.getByRole("button", { name: "重新加载", exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await page.locator(".list-table table").count(), 0);
|
||||
assert.equal(
|
||||
await page.locator(".list-table").getAttribute("aria-busy"),
|
||||
"false",
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator(".host-list-foot").getAttribute("inert"),
|
||||
"",
|
||||
);
|
||||
fail = false;
|
||||
await page
|
||||
.getByRole("button", { name: "重新加载", exact: true })
|
||||
.click();
|
||||
await page.getByText("audit-host-701", { exact: true }).waitFor();
|
||||
assert.deepEqual(await headers(), accepted);
|
||||
await eventually(
|
||||
() => page.evaluate(() => scrollY),
|
||||
(position) => Math.abs(position - savedScroll) <= 1,
|
||||
"Returning after metadata retry restores the saved list position",
|
||||
);
|
||||
return {
|
||||
width,
|
||||
speculativeTable: false,
|
||||
refreshRetainsTable: true,
|
||||
metadataFailureCanRetry: true,
|
||||
};
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const routePath of ["/cloud-servers", "/cloud-servers?m=74"]) {
|
||||
test(
|
||||
`empty host response without select_field resolves columns at ${routePath}`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.route(
|
||||
`${backendOrigin}/console/v1/${routePath.includes("?") ? "home/host" : "host"}?*`,
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
status: 200,
|
||||
msg: "",
|
||||
data: { list: [], count: 0, server_page: "classified-v1" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await login(page, routePath);
|
||||
await page.locator(".list-table table").waitFor();
|
||||
await page.locator(".list-table .list-empty-title").waitFor();
|
||||
assert.equal(
|
||||
await page.locator(".list-table").getAttribute("aria-busy"),
|
||||
"false",
|
||||
);
|
||||
const headers = await page.locator(".list-table th").allTextContents();
|
||||
assert.ok(headers.includes("IP"));
|
||||
assert.equal(headers.includes("计费方式"), routePath.includes("?"));
|
||||
return {
|
||||
routePath,
|
||||
emptySchemaResolved: true,
|
||||
legacyColumnsRetained: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const [routePath, endpoint, loadedText] of [
|
||||
["/security/ssh-keys", "ssh_key", "audit-01"],
|
||||
["/orders", "order", "本地订单布局验收"],
|
||||
])
|
||||
for (const width of [390, 640, 768, 1440]) {
|
||||
test(
|
||||
`${routePath} refresh preserves its responsive layout while data loads at ${width}px`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
if (endpoint === "order")
|
||||
await page.route(`${backendOrigin}/console/v1/order?*`, (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
status: 200,
|
||||
msg: "",
|
||||
data: {
|
||||
count: 1,
|
||||
list: [
|
||||
{
|
||||
id: 7,
|
||||
create_time: 1730000000,
|
||||
type: "new",
|
||||
name: loadedText,
|
||||
gateway_name: "本地支付方式",
|
||||
status: "Unpaid",
|
||||
amount: "20.00",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await login(page, routePath);
|
||||
await page.getByText(loadedText, { exact: true }).waitFor();
|
||||
let release;
|
||||
const held = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route(
|
||||
`${backendOrigin}/console/v1/${endpoint}?*`,
|
||||
async (route) => {
|
||||
await held;
|
||||
return route.fallback();
|
||||
},
|
||||
);
|
||||
const geometry = () =>
|
||||
page.locator(".list-table").evaluate((panel) => {
|
||||
const table = panel.querySelector("table");
|
||||
const header = table.querySelector("thead");
|
||||
return {
|
||||
panelMinHeight: getComputedStyle(panel).minHeight,
|
||||
panelHeight: panel.getBoundingClientRect().height,
|
||||
tableWidth: table.getBoundingClientRect().width,
|
||||
tableMinWidth: getComputedStyle(table).minWidth,
|
||||
tableDisplay: getComputedStyle(table).display,
|
||||
headerDisplay: getComputedStyle(header).display,
|
||||
columns: [...header.querySelectorAll("th")].map((cell) => ({
|
||||
title: cell.textContent.trim(),
|
||||
left: cell.getBoundingClientRect().left,
|
||||
width: cell.getBoundingClientRect().width,
|
||||
})),
|
||||
viewport: innerWidth,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
let loading;
|
||||
try {
|
||||
// 真正刷新已登录的深链接;响应挂起时读取用户能看见的加载布局。
|
||||
await page.reload();
|
||||
await page.locator(".p-datatable-mask").waitFor();
|
||||
await page.locator("#__nuxt-loader").waitFor({ state: "detached" });
|
||||
loading = await geometry();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
await page.getByText(loadedText, { exact: true }).waitFor();
|
||||
await page
|
||||
.locator(".p-datatable-mask")
|
||||
.waitFor({ state: "detached" });
|
||||
const ready = await geometry();
|
||||
t.diagnostic(JSON.stringify({ width, loading, ready }));
|
||||
for (const dimension of [
|
||||
"panelMinHeight",
|
||||
"tableDisplay",
|
||||
"headerDisplay",
|
||||
])
|
||||
assert.equal(
|
||||
loading[dimension],
|
||||
ready[dimension],
|
||||
`Loading must not switch ${dimension} before the same table receives data`,
|
||||
);
|
||||
if (width < 640) {
|
||||
assert.ok(
|
||||
loading.tableWidth <= width,
|
||||
"The loading card must fit the phone",
|
||||
);
|
||||
assert.equal(loading.headerDisplay, "none");
|
||||
} else {
|
||||
assert.equal(loading.columns.length, ready.columns.length);
|
||||
for (let index = 0; index < loading.columns.length; index++) {
|
||||
const before = loading.columns[index];
|
||||
const after = ready.columns[index];
|
||||
assert.equal(before.title, after.title);
|
||||
for (const dimension of ["left", "width"])
|
||||
assert.ok(
|
||||
Math.abs(before[dimension] - after[dimension]) <= 1,
|
||||
`${before.title} ${dimension} changed: ${before[dimension]} -> ${after[dimension]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.equal(loading.documentWidth, loading.viewport);
|
||||
assert.equal(ready.documentWidth, ready.viewport);
|
||||
// 再次查询复用已显示的数据,不能恢复视口高度占位或另一套表格模式。
|
||||
if (endpoint === "ssh_key")
|
||||
await page
|
||||
.getByRole("button", { name: "搜索", exact: true })
|
||||
.click();
|
||||
else
|
||||
await page
|
||||
.getByRole("textbox", { name: "搜索订单", exact: true })
|
||||
.press("Enter");
|
||||
await eventually(
|
||||
() => page.locator(".p-datatable-mask").count(),
|
||||
(count) => count === 0,
|
||||
"Refreshing a populated table completes",
|
||||
);
|
||||
return {
|
||||
routePath,
|
||||
width,
|
||||
loadingLayoutMatchesReady: true,
|
||||
phoneFits: width >= 640 || loading.tableWidth <= width,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
test(
|
||||
`home list reserves its rendered row geometry at ${width}px`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
let release;
|
||||
const held = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (![/\/ticket$/, /\/announcement$/].some((end) => end.test(path)))
|
||||
return route.fallback();
|
||||
await held;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
status: 200,
|
||||
msg: "",
|
||||
data: {
|
||||
count: 5,
|
||||
list: Array.from({ length: 5 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
title: `本地布局验收 ${index + 1}`,
|
||||
create_time: 1730000000,
|
||||
post_time: 1730000000,
|
||||
status: "待回复",
|
||||
color: "#0284c7",
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
const geometry = () =>
|
||||
page.locator(".home-tickets, .home-notices").evaluateAll((cards) =>
|
||||
cards.map((card) => {
|
||||
const box = card.getBoundingClientRect();
|
||||
return {
|
||||
card: card.classList.contains("home-tickets")
|
||||
? "tickets"
|
||||
: "notices",
|
||||
top: box.top + scrollY,
|
||||
height: box.height,
|
||||
};
|
||||
}),
|
||||
);
|
||||
let before;
|
||||
try {
|
||||
await login(page, "/");
|
||||
await page
|
||||
.locator(".home-tickets .home-row-skeleton")
|
||||
.first()
|
||||
.waitFor();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
// 只比较本次控制的两个响应,避免混入其他独立面板的正常数据替换。
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!document.querySelector(
|
||||
".home-servers .home-row-skeleton, .home-expiring .home-row-skeleton, .home-referral .p-skeleton",
|
||||
),
|
||||
);
|
||||
before = await geometry();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
await page.locator(".home-notices .notice-row").last().waitFor();
|
||||
await page.locator(".home-tickets .home-row").last().waitFor();
|
||||
const after = await geometry();
|
||||
for (let index = 0; index < before.length; index++) {
|
||||
for (const dimension of ["height", "top"])
|
||||
assert.ok(
|
||||
Math.abs(before[index][dimension] - after[index][dimension]) <= 1,
|
||||
`${before[index].card} ${dimension} changed: ${before[index][dimension]} -> ${after[index][dimension]}`,
|
||||
);
|
||||
}
|
||||
return { width, sameRowGeometryAfterData: true };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
/**
|
||||
* 失败模式(先于实现记录):
|
||||
* 规则适配:POSIX、引用块、未知 escape、Unicode/换行模式、补集和高级分组即使能被 JS
|
||||
* 编译也未必等价;不确定时必须交后端,常见 ASCII 规则仍须拦截无效输入。
|
||||
* 密码算法:缺少/故障的安全随机源不能降级;字符选择和洗牌均不能有模偏差;
|
||||
* 默认、短于三位和较长长度必须保留原字符集及三类字符,不能依赖 Math.random。
|
||||
* 浏览器场景走真实登录、商品配置/结算与实例重置密码,只替换外部 HTTP/随机源边界。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { t as password } from "../app/lib/password-2.js";
|
||||
import { n as fieldPattern } from "../app/lib/product-fields.js";
|
||||
import {
|
||||
backendOrigin,
|
||||
eventually,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
const alphabet = /^[abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789]+$/;
|
||||
function assertPassword(value, length) {
|
||||
// 不把生成密码写入断言失败或验收附件;清单只保存这些不含秘密的性质。
|
||||
assert.equal(value.length, length, "Password length must match its contract");
|
||||
assert.equal(
|
||||
alphabet.test(value),
|
||||
true,
|
||||
"Password alphabet must remain unchanged",
|
||||
);
|
||||
assert.equal(/[a-z]/.test(value), true, "Password needs a lowercase letter");
|
||||
assert.equal(/[A-Z]/.test(value), true, "Password needs an uppercase letter");
|
||||
assert.equal(/[2-9]/.test(value), true, "Password needs a digit");
|
||||
}
|
||||
|
||||
async function boundary(page, fields = []) {
|
||||
const state = { settlements: [], resets: [], rejectFields: false };
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace(
|
||||
/^\/console\/v1/,
|
||||
"",
|
||||
);
|
||||
const reply = (data, extra = {}) => {
|
||||
assert.ok(
|
||||
request.headers().authorization,
|
||||
"Scenario APIs require the real login session",
|
||||
);
|
||||
return route.fulfill({
|
||||
json: { status: 200, msg: "", data, ...extra },
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
};
|
||||
if (path === "/product/123")
|
||||
return reply({
|
||||
product: {
|
||||
id: 123,
|
||||
name: "本地字段验收商品",
|
||||
module: "idcsmart_common",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
if (path === "/idcsmart_common/product/123/configoption")
|
||||
return reply({
|
||||
common_product: { pay_type: "free" },
|
||||
configoptions: [],
|
||||
});
|
||||
if (path === "/product/123/self_defined_field/order_page")
|
||||
return reply({ data: fields });
|
||||
if (path === "/product/123/config_option")
|
||||
return reply({ price_total: "0.00" });
|
||||
if (path === "/purchase_limit/check")
|
||||
return reply({ allowed: true, message: "" });
|
||||
if (path === "/product/settle") {
|
||||
state.settlements.push(request.postDataJSON());
|
||||
if (state.rejectFields)
|
||||
return reply({}, { status: 400, msg: "字段内容未通过服务端校验" });
|
||||
return reply({ order_id: 42, amount: "0.00" });
|
||||
}
|
||||
if (path === "/host/5")
|
||||
return reply({
|
||||
host: {
|
||||
id: 5,
|
||||
product_id: 123,
|
||||
module: "mf_cloud",
|
||||
name: "本地密码验收实例",
|
||||
status: "Active",
|
||||
billing_cycle: "monthly",
|
||||
upgrade_allowed: false,
|
||||
},
|
||||
});
|
||||
if (path === "/host/5/view") return reply({ module: "mf_cloud" });
|
||||
if (path === "/mf_cloud/5")
|
||||
return reply({
|
||||
image: { id: 6, name: "Ubuntu 24.04" },
|
||||
port: 22,
|
||||
username: "root",
|
||||
cpu: 2,
|
||||
memory: 4,
|
||||
config: { reinstall_sms_verify: 0, reset_password_sms_verify: 0 },
|
||||
});
|
||||
if (path === "/mf_cloud/5/status")
|
||||
return reply({ status: "on", rescue: false });
|
||||
if (path === "/mf_cloud/5/disk") return reply({ list: [] });
|
||||
if (path === "/mf_cloud/5/flow")
|
||||
return reply({ total: 100, used: 0, leave: 100 });
|
||||
if (path === "/mf_cloud/5/real_data")
|
||||
return reply({ cpu: 0, memory: 0, disk: 0 });
|
||||
if (path === "/mf_cloud/5/chart") return reply({ list: [] });
|
||||
if (path === "/product/123/mf_cloud/image") return reply({ list: [] });
|
||||
if (path === "/mf_cloud/5/reset_password") {
|
||||
state.resets.push(request.postDataJSON());
|
||||
return reply({});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
test(
|
||||
"product fields preserve PCRE-only values through the real checkout",
|
||||
{ timeout: 45000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const cases = [
|
||||
["ASCII编号", "/^[A-Z]+$/", "ABC"],
|
||||
["POSIX字母", "/^[[:alpha:]]+$/", "ABC"],
|
||||
["引用块", "/^\\Qa+b\\E$/", "a+b"],
|
||||
["水平空白", "/^a\\hb$/", "a b"],
|
||||
["垂直空白", "/^a\\vb$/", "a\nb"],
|
||||
["换行约定", "/^a\\Rb$/", "a\nb"],
|
||||
["八进制字符", "/^\\o{101}$/", "A"],
|
||||
["十六进制字符", "/^\\x{41}$/", "A"],
|
||||
["PCRE空白", "/^a\\sb$/", "a b"],
|
||||
];
|
||||
const fields = cases.map(([field_name, regexpr], index) => ({
|
||||
id: index + 1,
|
||||
field_type: "textarea",
|
||||
field_name,
|
||||
regexpr,
|
||||
is_required: 1,
|
||||
}));
|
||||
const state = await boundary(page, fields);
|
||||
await login(page, "/products/123");
|
||||
for (const [index, [, , value]] of cases.entries())
|
||||
await page.locator(`#product-field-${index + 1}`).fill(value);
|
||||
const buy = page.getByRole("button", { name: "立即购买", exact: true });
|
||||
const reprice = page.getByRole("button", {
|
||||
name: /^(计算报价|重新获取报价)$/,
|
||||
});
|
||||
if (await reprice.isVisible()) await reprice.click();
|
||||
// 单纯 ASCII 规则仍在浏览器拒绝,不能为了解决 PCRE 误判而关闭所有验证。
|
||||
await page.locator("#product-field-1").fill("abc");
|
||||
if (await reprice.isVisible()) await reprice.click();
|
||||
await buy.click();
|
||||
await page.getByText("ASCII编号格式不正确", { exact: true }).waitFor();
|
||||
assert.equal(state.settlements.length, 0);
|
||||
await page.locator("#product-field-1").fill("ABC");
|
||||
if (await reprice.isVisible()) await reprice.click();
|
||||
await buy.click();
|
||||
await eventually(
|
||||
() => state.settlements.length,
|
||||
(n) => n === 1,
|
||||
"Valid PCRE values must reach the real settlement request",
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.settlements[0].self_defined_field,
|
||||
Object.fromEntries(
|
||||
cases.map(([, , value], index) => [String(index + 1), value]),
|
||||
),
|
||||
);
|
||||
return {
|
||||
asciiInvalidBlocked: true,
|
||||
pcreFieldValuesPreserved: true,
|
||||
fieldCount: cases.length,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"server-only field rules preserve the backend validation result",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const state = await boundary(page, [
|
||||
{
|
||||
id: 1,
|
||||
field_type: "text",
|
||||
field_name: "水平空白",
|
||||
regexpr: "/^\\h+$/",
|
||||
is_required: 1,
|
||||
},
|
||||
]);
|
||||
state.rejectFields = true;
|
||||
await login(page, "/products/123");
|
||||
await page.locator("#product-field-1").fill("h");
|
||||
const reprice = page.getByRole("button", {
|
||||
name: /^(计算报价|重新获取报价)$/,
|
||||
});
|
||||
if (await reprice.isVisible()) await reprice.click();
|
||||
await page.getByRole("button", { name: "立即购买", exact: true }).click();
|
||||
await page
|
||||
.getByText("字段内容未通过服务端校验", { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(state.settlements.length, 1);
|
||||
assert.equal(state.settlements[0].self_defined_field["1"], "h");
|
||||
return { serverValidationRequired: true, backendRejectionVisible: true };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"instance password generation uses browser CSPRNG and reaches reset HTTP",
|
||||
{ timeout: 35000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const state = await boundary(page);
|
||||
// 系统随机源是外部边界。只计数真实 WebCrypto,不替换产品内部函数,也不保存密码。
|
||||
await page.addInitScript(() => {
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
window.randomSourceCalls = 0;
|
||||
crypto.getRandomValues = (array) => {
|
||||
window.randomSourceCalls++;
|
||||
return original(array);
|
||||
};
|
||||
});
|
||||
await login(page, "/servers/server?id=5");
|
||||
await page.getByRole("tab", { name: "访问控制", exact: true }).click();
|
||||
const before = await page.evaluate(() => window.randomSourceCalls);
|
||||
await page.getByRole("button", { name: "重置密码", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", {
|
||||
name: "重置密码",
|
||||
exact: true,
|
||||
});
|
||||
const input = dialog.getByLabel("新密码", { exact: true });
|
||||
assertPassword(await input.inputValue(), 12);
|
||||
const afterOpen = await page.evaluate(() => window.randomSourceCalls);
|
||||
assert.equal(
|
||||
afterOpen > before,
|
||||
true,
|
||||
"Opening reset must use WebCrypto",
|
||||
);
|
||||
await dialog.getByRole("button", { name: "随机", exact: true }).click();
|
||||
const generated = await input.inputValue();
|
||||
assertPassword(generated, 12);
|
||||
assert.equal(
|
||||
(await page.evaluate(() => window.randomSourceCalls)) > afterOpen,
|
||||
true,
|
||||
"Regenerating the password must use WebCrypto",
|
||||
);
|
||||
await dialog.getByRole("button", { name: "确定", exact: true }).click();
|
||||
await eventually(
|
||||
() => state.resets.length,
|
||||
(n) => n === 1,
|
||||
"Generated password must reach reset HTTP",
|
||||
);
|
||||
assert.equal(
|
||||
state.resets[0].password === generated,
|
||||
true,
|
||||
"Reset must use the displayed password",
|
||||
);
|
||||
return {
|
||||
length: 12,
|
||||
originalAlphabet: true,
|
||||
allCharacterClasses: true,
|
||||
secureRandomUsed: true,
|
||||
resetMatchesInput: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test("password algorithm rejects biased draws and fails closed without secure randomness", () => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto");
|
||||
const originalRandom = Math.random;
|
||||
const insecureSource = new Error(
|
||||
"Non-cryptographic randomness must not be used",
|
||||
);
|
||||
try {
|
||||
Math.random = () => {
|
||||
throw insecureSource;
|
||||
};
|
||||
// UINT32_MAX 不属于 23 字符集和三位洗牌的等分区间;8/2 等整除区间无需拒绝。
|
||||
let draws = [
|
||||
0xffffffff, 0, 0xffffffff, 0, 0xffffffff, 0xffffffff, 1, 0xffffffff,
|
||||
];
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getRandomValues(array) {
|
||||
for (let index = 0; index < array.length; index++)
|
||||
array[index] = draws.shift() ?? 0;
|
||||
return array;
|
||||
},
|
||||
},
|
||||
});
|
||||
const short = password(3);
|
||||
assertPassword(short, 3);
|
||||
assert.equal(
|
||||
short === "a9A",
|
||||
true,
|
||||
"Biased character/shuffle draws must be retried",
|
||||
);
|
||||
draws = [0, 0, 0, 0xffffffff, 0, 0xffffffff, 0xffffffff, 0, 0xffffffff];
|
||||
const longer = password(4);
|
||||
assertPassword(longer, 4);
|
||||
assert.equal(
|
||||
longer === "2Aaa",
|
||||
true,
|
||||
"The combined alphabet must also reject biased draws",
|
||||
);
|
||||
for (const length of [undefined, 0, 2, 64]) {
|
||||
const expected = length === undefined ? 12 : Math.max(3, length);
|
||||
assertPassword(password(length), expected);
|
||||
}
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
assert.throws(
|
||||
() => password(),
|
||||
(error) => error instanceof Error && error !== insecureSource,
|
||||
);
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
assert.throws(
|
||||
() => password(),
|
||||
(error) => error instanceof Error && error !== insecureSource,
|
||||
);
|
||||
const unavailable = new Error("Secure source unavailable");
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getRandomValues() {
|
||||
throw unavailable;
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() => password(),
|
||||
(error) => error === unavailable,
|
||||
);
|
||||
} finally {
|
||||
Math.random = originalRandom;
|
||||
if (descriptor) Object.defineProperty(globalThis, "crypto", descriptor);
|
||||
else delete globalThis.crypto;
|
||||
}
|
||||
});
|
||||
|
||||
test("field adapter delegates unproven PCRE semantics and retains equivalent ASCII subset", () => {
|
||||
for (const rule of [
|
||||
"/^[[:alpha:]]+$/",
|
||||
"/^\\Qa+b\\E$/",
|
||||
"/^\\h+$/",
|
||||
"/^\\H+$/",
|
||||
"/^\\v+$/",
|
||||
"/^\\V+$/",
|
||||
"/^\\R+$/",
|
||||
"/^\\X+$/",
|
||||
"/^\\s+$/",
|
||||
"/^\\S+$/",
|
||||
"/^\\w+$/",
|
||||
"/^\\W+$/",
|
||||
"/^\\D+$/",
|
||||
"/^\\q+$/",
|
||||
"/^\\o{101}$/",
|
||||
"/^\\x{41}$/",
|
||||
"/^[^a]+$/",
|
||||
"/^.+$/",
|
||||
"/^a$/m",
|
||||
"/^a$/u",
|
||||
"/^a$/x",
|
||||
"/^(?i)a$/",
|
||||
"/^(?>a)$/",
|
||||
"/^(a)\\1$/",
|
||||
"/^\\p{L}+$/u",
|
||||
"/^é+$/",
|
||||
"/^(?=a)a$/",
|
||||
])
|
||||
assert.equal(fieldPattern(rule).patternServerOnly, true, rule);
|
||||
for (const [rule, valid, invalid] of [
|
||||
["/^[A-Z][A-Z0-9_-]{1,8}$/i", "aB_2", "éé"],
|
||||
["/^\\d{2}$/", "23", "ab"],
|
||||
["/\\A(?:abc|def)\\z/", "abc", "abc\n"],
|
||||
["/^[abc]+$/", "abc\n", "abc\r"],
|
||||
]) {
|
||||
const normalized = fieldPattern(rule);
|
||||
assert.notEqual(normalized.patternServerOnly, true, rule);
|
||||
const regex = new RegExp(normalized.pattern, normalized.patternFlags);
|
||||
assert.equal(regex.test(valid), true, rule);
|
||||
assert.equal(regex.test(invalid), false, rule);
|
||||
}
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
/** 冻结记录的响应乱序回归:只控制本地 HTTP 边界,页面与请求层使用生产实现。 */
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
eventually,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
for (const outcome of ["late success", "late failure", "newer read pending"]) {
|
||||
test(
|
||||
`finance freeze records ignore an older response with ${outcome}`,
|
||||
{ timeout: 35000 },
|
||||
(t) =>
|
||||
scenario(t, { loginMode: "password-only" }, async (page) => {
|
||||
let freezeReads = 0;
|
||||
let releaseOlder;
|
||||
let releaseNewer;
|
||||
const olderHeld = new Promise((resolve) => {
|
||||
releaseOlder = resolve;
|
||||
});
|
||||
const newerHeld = new Promise((resolve) => {
|
||||
releaseNewer = resolve;
|
||||
});
|
||||
const freezePath = "/console/v1/account/credit/freeze";
|
||||
const emptyPaths = [
|
||||
"/console/v1/transaction",
|
||||
"/console/v1/credit",
|
||||
"/console/v1/withdraw",
|
||||
"/console/v1/order",
|
||||
"/console/v1/refund/pending/amount",
|
||||
];
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === backendOrigin &&
|
||||
(url.pathname === freezePath || emptyPaths.includes(url.pathname)),
|
||||
async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
const fulfill = (data, status = 200) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "Access-Control-Allow-Origin": appOrigin },
|
||||
body: JSON.stringify({
|
||||
status,
|
||||
msg: status === 200 ? "" : "旧冻结请求失败",
|
||||
data,
|
||||
}),
|
||||
});
|
||||
if (path === "/console/v1/refund/pending/amount")
|
||||
return fulfill({ amount: "0.00" });
|
||||
if (path !== freezePath)
|
||||
return fulfill({ list: [], count: 0, total_amount: "0.00" });
|
||||
const sequence = ++freezeReads;
|
||||
if (sequence === 2) await olderHeld;
|
||||
if (sequence === 3 && outcome === "newer read pending")
|
||||
await newerHeld;
|
||||
if (sequence === 2 && outcome === "late failure")
|
||||
return fulfill({}, 400);
|
||||
return fulfill({
|
||||
list: [
|
||||
{
|
||||
id: 9,
|
||||
amount: sequence === 2 ? "110.00" : "220.00",
|
||||
client_notes:
|
||||
sequence === 1
|
||||
? "初始冻结快照"
|
||||
: sequence === 2
|
||||
? "较早冻结快照"
|
||||
: "最新冻结快照",
|
||||
nickname: "audit-user",
|
||||
create_time: 1784619935,
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
try {
|
||||
await login(page, "/finance?tab=freeze");
|
||||
await page.getByText("初始冻结快照", { exact: true }).waitFor();
|
||||
const transaction = page.getByRole("tab", {
|
||||
name: "交易记录",
|
||||
exact: true,
|
||||
});
|
||||
const freeze = page.getByRole("tab", {
|
||||
name: "冻结记录",
|
||||
exact: true,
|
||||
});
|
||||
await transaction.click();
|
||||
await freeze.click();
|
||||
await eventually(
|
||||
() => freezeReads,
|
||||
(value) => value === 2,
|
||||
"Older freeze read is held at the HTTP boundary",
|
||||
);
|
||||
await transaction.click();
|
||||
await freeze.click();
|
||||
await eventually(
|
||||
() => freezeReads,
|
||||
(value) => value === 3,
|
||||
"Newer freeze read has started",
|
||||
);
|
||||
const panel = page.getByRole("tabpanel").filter({ visible: true });
|
||||
const loading = panel.locator(".p-datatable-mask");
|
||||
if (outcome === "newer read pending")
|
||||
await loading.waitFor({ state: "visible" });
|
||||
else await page.getByText("最新冻结快照", { exact: true }).waitFor();
|
||||
const olderResponse = page.waitForResponse(
|
||||
(response) => new URL(response.url()).pathname === freezePath,
|
||||
);
|
||||
releaseOlder();
|
||||
await (await olderResponse).finished();
|
||||
// 等浏览器完成响应处理与绘制后再核对当前列表,不直接读取组件内部状态。
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
),
|
||||
);
|
||||
if (outcome === "newer read pending") {
|
||||
// 已开始淡出的遮罩仍暂时可见;等待它自身的过渡结束再判断是否仍在加载。
|
||||
await loading.evaluateAll((nodes) =>
|
||||
Promise.all(
|
||||
nodes.flatMap((node) =>
|
||||
node
|
||||
.getAnimations()
|
||||
.map((animation) => animation.finished.catch(() => {})),
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
await loading.isVisible(),
|
||||
true,
|
||||
"An older request must not finish the newer request's loading state",
|
||||
);
|
||||
releaseNewer();
|
||||
await page.getByText("最新冻结快照", { exact: true }).waitFor();
|
||||
}
|
||||
assert.equal(
|
||||
await page.getByText("最新冻结快照", { exact: true }).isVisible(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByText("较早冻结快照", { exact: true }).isVisible(),
|
||||
false,
|
||||
);
|
||||
assert.equal(await panel.locator(".p-message-error").count(), 0);
|
||||
await loading.waitFor({ state: "hidden" });
|
||||
assert.equal(new URL(page.url()).searchParams.get("tab"), "freeze");
|
||||
return {
|
||||
olderResponse: outcome,
|
||||
freezeReads,
|
||||
finalSnapshot: "latest",
|
||||
staleErrorVisible: false,
|
||||
loadingBelongsToLatestRead: true,
|
||||
};
|
||||
} finally {
|
||||
releaseOlder();
|
||||
releaseNewer();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,671 +0,0 @@
|
||||
/**
|
||||
* 回归缺口:缺测值不能伪造为0,多日采样不能丢掉日期,磁盘输入的可见标签必须命名原生控件。
|
||||
* 只替换已配置的外部HTTP边界;经真实登录、实例标签、图表指针与磁盘表单完成验收。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
backendOrigin,
|
||||
eventually,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
async function instanceBoundary(
|
||||
page,
|
||||
{ module = "mf_cloud", value = 50, time } = {},
|
||||
) {
|
||||
const state = { value, quotes: [], chartReads: 0 };
|
||||
const times = [
|
||||
"2026-11-05 08:00:00",
|
||||
"2026-11-06 08:00:00",
|
||||
"2026-11-07 08:00:00",
|
||||
];
|
||||
// 秒级接口按浏览器本地08:00造样本,与字符串时间接口一致,不依赖执行机时区。
|
||||
const epoch =
|
||||
time === undefined
|
||||
? await page.evaluate(() => new Date(2026, 10, 5, 8).getTime() / 1000)
|
||||
: time;
|
||||
await page.context().route(`${backendOrigin}/console/v1/**`, (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname.replace(/^\/console\/v1/, "");
|
||||
const reply = (data) =>
|
||||
route.fulfill({ json: { status: 200, msg: "", data } });
|
||||
if (path === "/host/5")
|
||||
return reply({
|
||||
host: {
|
||||
id: 5,
|
||||
product_id: 123,
|
||||
module,
|
||||
name: "本地指标验收实例",
|
||||
status: "Active",
|
||||
billing_cycle: "monthly",
|
||||
upgrade_allowed: true,
|
||||
},
|
||||
});
|
||||
if (path === "/host/5/view") return reply({ module });
|
||||
if (path === "/host/5/ip") return reply({ list: [] });
|
||||
if (path === "/idcsmart_common/host/5/configoption")
|
||||
return reply({
|
||||
configoptions: [],
|
||||
client_button: [],
|
||||
chart: [{ type: "bandwidth", title: "通用带宽监控", select: [] }],
|
||||
});
|
||||
if (path === "/idcsmart_common/host/5/provision/status")
|
||||
return reply({ status: "on" });
|
||||
if (path === "/idcsmart_common/host/5/configoption/chart") {
|
||||
state.chartReads++;
|
||||
return reply({
|
||||
unit: "Mbps",
|
||||
label: ["入带宽", "出带宽"],
|
||||
list: [
|
||||
times.map((time) => ({ time, value: state.value })),
|
||||
times.map((time) => ({ time, value: state.value })),
|
||||
],
|
||||
});
|
||||
}
|
||||
if (path === `/${module}/5`)
|
||||
return reply({
|
||||
image: { id: 6, name: "Ubuntu 24.04" },
|
||||
port: 22,
|
||||
username: "root",
|
||||
cpu: 2,
|
||||
memory: 4,
|
||||
config: { reinstall_sms_verify: 0, reset_password_sms_verify: 0 },
|
||||
});
|
||||
if (path === `/${module}/5/status`)
|
||||
return reply({ status: "on", rescue: false });
|
||||
if (path === `/${module}/5/remote_info`)
|
||||
return reply({ username: "root", port: 22 });
|
||||
if (
|
||||
path === "/remf_finance/5/snapshot" ||
|
||||
path === "/remf_finance/5/backup"
|
||||
)
|
||||
return reply({ disk: [] });
|
||||
if (path === `/${module}/5/disk`)
|
||||
return reply({
|
||||
list: [
|
||||
{
|
||||
id: 10,
|
||||
name: "验收数据盘",
|
||||
size: 50,
|
||||
type: "SSD",
|
||||
type2: "data",
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === `/${module}/5/flow`)
|
||||
return reply({ total: 100, used: 20, leave: 80 });
|
||||
if (path === `/${module}/5/real_data`)
|
||||
return reply({ cpu_usage: state.value, memory_usage: state.value });
|
||||
if (path === `/${module}/5/chart`) {
|
||||
state.chartReads++;
|
||||
if (module === "remf_finance") {
|
||||
const type = url.searchParams.get("type");
|
||||
return reply({
|
||||
unit: type === "cpu" || type === "memory" ? "%" : "KB/s",
|
||||
label: [type === "memory" ? "已用" : "采样"],
|
||||
list: [times.map((time) => ({ time, value: state.value }))],
|
||||
});
|
||||
}
|
||||
return reply({
|
||||
list: [0, 1, 2].map((index) => ({
|
||||
time: epoch === null ? null : epoch + index * 86400,
|
||||
value: state.value,
|
||||
total: 100,
|
||||
used: state.value,
|
||||
in_bw: state.value,
|
||||
out_bw: state.value,
|
||||
read_bytes: state.value,
|
||||
write_bytes: state.value,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (path === "/kky_ecs/disk/5") return reply({ list: [] });
|
||||
if (path === "/kky_ecs/5/traffic")
|
||||
return reply({
|
||||
billing: { mode: "bandwidth", monthly_quota: 0, unit_price: "0.00" },
|
||||
usage_status: "available",
|
||||
quota_status: "not_applicable",
|
||||
});
|
||||
if (path === "/kky_ecs/monitor/5") {
|
||||
state.chartReads++;
|
||||
return reply({
|
||||
sample_info: times.map((timestamp) => ({ timestamp })),
|
||||
cpu: [{ title: "CPU", value: times.map(() => state.value) }],
|
||||
memory: [
|
||||
{ title: "total", name: "total", value: [100, 100, 100] },
|
||||
{
|
||||
title: "free",
|
||||
name: "free",
|
||||
value: times.map(() =>
|
||||
typeof state.value === "number" ? 100 - state.value : state.value,
|
||||
),
|
||||
},
|
||||
],
|
||||
bandwidth: [{ title: "入", value: [50000, 50000, 50000] }],
|
||||
disk: [{ title: "读", value: times.map(() => state.value) }],
|
||||
});
|
||||
}
|
||||
if (path === `/product/123/${module}/order_page`)
|
||||
return reply({
|
||||
data_disk: [
|
||||
{
|
||||
type: "step",
|
||||
min_value: 10,
|
||||
max_value: 500,
|
||||
other_config: { disk_type: "SSD" },
|
||||
},
|
||||
],
|
||||
config: { disk_limit_num: 4 },
|
||||
});
|
||||
if (
|
||||
path === `/${module}/5/disk/resize` ||
|
||||
path === `/${module}/5/disk/price`
|
||||
) {
|
||||
state.quotes.push({
|
||||
path,
|
||||
body: request.postDataJSON(),
|
||||
authenticated: !!request.headers().authorization,
|
||||
});
|
||||
return reply({ price: "10.00" });
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
async function openMonitor(page) {
|
||||
await page.getByRole("tab", { name: "监控", exact: true }).click();
|
||||
await eventually(
|
||||
() => page.locator(".mon .chart-card").count(),
|
||||
(count) => count === 4,
|
||||
"The real monitor must finish mounting its four metric cards",
|
||||
);
|
||||
await eventually(
|
||||
() => page.getByRole("button", { name: "刷新", exact: true }).isEnabled(),
|
||||
Boolean,
|
||||
"The current HTTP refresh must finish",
|
||||
);
|
||||
}
|
||||
|
||||
async function hoverMeasurement(page, viewport, expectedValue) {
|
||||
await viewport.locator("canvas").waitFor();
|
||||
const box = await viewport.boundingBox();
|
||||
assert.ok(box, "The rendered chart must have a visible viewport");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
return eventually(
|
||||
() => viewport.innerText(),
|
||||
(text) => text.includes(expectedValue),
|
||||
"A real pointer must expose the supplied measurement in the tooltip",
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"overview distinguishes missing live measurements from an observed zero",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await instanceBoundary(page, { value: null });
|
||||
await login(page, "/servers/server?id=5");
|
||||
await eventually(
|
||||
async () =>
|
||||
(await page.locator(".ov .res .ring-value").count()) +
|
||||
(await page.locator(".ov .resource-unavailable").count()),
|
||||
(count) => count === 2,
|
||||
"Resource measurements must finish loading",
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator(".ov .res .ring-value").count(),
|
||||
0,
|
||||
"Null is not a measured0%",
|
||||
);
|
||||
assert.equal(await page.locator(".ov .resource-unavailable").count(), 2);
|
||||
return { missingLiveMeasurements: 2, fabricatedZeroRings: 0 };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"monitor keeps null blank and malformed samples unavailable instead of drawing zero",
|
||||
{ timeout: 45000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
const state = await instanceBoundary(page, { value: null });
|
||||
await login(page, "/servers/server?id=5");
|
||||
await openMonitor(page);
|
||||
const observations = [];
|
||||
for (const value of [null, "", " ", "invalid", false]) {
|
||||
if (observations.length) {
|
||||
state.value = value;
|
||||
const before = state.chartReads;
|
||||
await page.getByRole("button", { name: "刷新", exact: true }).click();
|
||||
await eventually(
|
||||
() => state.chartReads,
|
||||
(count) => count === before + 4,
|
||||
"Refresh must read every metric throughHTTP",
|
||||
);
|
||||
await eventually(
|
||||
() =>
|
||||
page
|
||||
.getByRole("button", { name: "刷新", exact: true })
|
||||
.isEnabled(),
|
||||
Boolean,
|
||||
"Current measurement request must settle",
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
await page.locator(".mon .chart-viewport canvas").count(),
|
||||
0,
|
||||
"Missing or malformed values must not become a real measurement curve",
|
||||
);
|
||||
assert.equal(await page.locator(".mon .chart-empty").count(), 4);
|
||||
observations.push({ input: value, unavailableCharts: 4 });
|
||||
}
|
||||
return { cases: observations };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const module of ["mf_cloud", "remf_finance", "kky_ecs"]) {
|
||||
test(
|
||||
`real zero ${module} CPU remains visible alongside other metric charts`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await instanceBoundary(page, { module, value: 0 });
|
||||
await login(page, "/servers/server?id=5");
|
||||
await eventually(
|
||||
() => page.locator(".ov .res .ring-value").allTextContents(),
|
||||
(values) =>
|
||||
values.length === 2 && values.every((value) => value === "0%"),
|
||||
"Actual zero must remain an observed resource value",
|
||||
);
|
||||
await openMonitor(page);
|
||||
await eventually(
|
||||
() => page.locator(".mon .chart-viewport canvas").count(),
|
||||
(count) => count === 4,
|
||||
"Observed zero measurements must finish drawing every metric chart",
|
||||
);
|
||||
const tooltip = await hoverMeasurement(
|
||||
page,
|
||||
page.locator(".mon .chart-viewport").first(),
|
||||
"0%",
|
||||
);
|
||||
return {
|
||||
module,
|
||||
observedZeroRings: 2,
|
||||
renderedMetricCharts: 4,
|
||||
observedCpuZero: true,
|
||||
tooltip,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const module of ["mf_cloud", "remf_finance", "kky_ecs"]) {
|
||||
test(
|
||||
`multi-day ${module} monitoring tooltip identifies the sample date`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await instanceBoundary(page, { module });
|
||||
await login(page, "/servers/server?id=5");
|
||||
const overviewTooltip = await hoverMeasurement(
|
||||
page,
|
||||
page.locator(".ov .network-chart-viewport"),
|
||||
"50",
|
||||
);
|
||||
assert.match(overviewTooltip, /2026[-/年]11[-/月]0?6/);
|
||||
await openMonitor(page);
|
||||
if (module !== "kky_ecs") {
|
||||
await page.getByRole("button", { name: "7天", exact: true }).click();
|
||||
await eventually(
|
||||
() =>
|
||||
page
|
||||
.getByRole("button", { name: "刷新", exact: true })
|
||||
.isEnabled(),
|
||||
Boolean,
|
||||
"Weekly range must finish reading",
|
||||
);
|
||||
}
|
||||
const tooltips = [];
|
||||
for (const viewport of await page
|
||||
.locator(".mon .chart-viewport")
|
||||
.all()) {
|
||||
const tooltip = await hoverMeasurement(page, viewport, "50");
|
||||
assert.match(
|
||||
tooltip,
|
||||
/2026[-/年]11[-/月]0?6/,
|
||||
"Equal HH:mm samples on different days require the actual date in the tooltip",
|
||||
);
|
||||
assert.match(tooltip, /08:00/);
|
||||
tooltips.push(tooltip);
|
||||
}
|
||||
return {
|
||||
module,
|
||||
suppliedMiddleDate: "2026-11-06",
|
||||
overviewTooltip,
|
||||
tooltips,
|
||||
dateIdentifiesSample: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"KKY overview keeps an absent CPU and free-memory sample unavailable",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await instanceBoundary(page, { module: "kky_ecs", value: null });
|
||||
await login(page, "/servers/server?id=5");
|
||||
await eventually(
|
||||
() => page.locator(".ov .resource-unavailable").count(),
|
||||
(count) => count === 2,
|
||||
"Missing CPU and partial memory history cannot fabricate usage",
|
||||
);
|
||||
assert.equal(await page.locator(".ov .res .ring-value").count(), 0);
|
||||
return { module: "kky_ecs", missingHistoricalMeasurements: 2 };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"KKY overview keeps a successful empty history unavailable",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await instanceBoundary(page, { module: "kky_ecs" });
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/kky_ecs/monitor/5`, (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
status: 200,
|
||||
msg: "",
|
||||
data: { cpu: [], memory: [], bandwidth: [], sample_info: [] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await login(page, "/servers/server?id=5");
|
||||
await eventually(
|
||||
() => page.locator(".ov .resource-unavailable").count(),
|
||||
(count) => count === 2,
|
||||
"An empty measurement history is not two observed zero values",
|
||||
);
|
||||
assert.equal(await page.locator(".ov .res .ring-value").count(), 0);
|
||||
return {
|
||||
module: "kky_ecs",
|
||||
emptyHistory: true,
|
||||
unavailableMeasurements: 2,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"physical-server monitoring tooltip retains the actual sample date",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await instanceBoundary(page, { module: "mf_dcim" });
|
||||
await login(page, "/servers/server?id=5");
|
||||
await page.getByRole("tab", { name: "监控", exact: true }).click();
|
||||
await page.getByRole("button", { name: "7 天", exact: true }).click();
|
||||
const viewport = page.locator(".monitor:not(.compact) .chart-viewport");
|
||||
await eventually(
|
||||
() => viewport.getAttribute("aria-busy"),
|
||||
(busy) => busy === "false",
|
||||
"Physical-server sampling request must settle",
|
||||
);
|
||||
const tooltip = await hoverMeasurement(page, viewport, "50");
|
||||
assert.match(
|
||||
tooltip,
|
||||
/2026[-/年]11[-/月]0?6/,
|
||||
"Physical-server tooltip must identify the day as well as the time",
|
||||
);
|
||||
assert.match(tooltip, /08:00/);
|
||||
return { module: "mf_dcim", tooltip, dateIdentifiesSample: true };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const input of [" ", false]) {
|
||||
test(
|
||||
`physical-server monitoring rejects non-measurement ${JSON.stringify(input)}`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await instanceBoundary(page, { module: "mf_dcim", value: input });
|
||||
await login(page, "/servers/server?id=5");
|
||||
await page.getByRole("tab", { name: "监控", exact: true }).click();
|
||||
const viewport = page.locator(".monitor:not(.compact) .chart-viewport");
|
||||
await eventually(
|
||||
() => viewport.getAttribute("aria-busy"),
|
||||
(busy) => busy === "false",
|
||||
"Physical-server sampling request must settle",
|
||||
);
|
||||
assert.equal(
|
||||
await viewport.locator("canvas").count(),
|
||||
0,
|
||||
"Whitespace and booleans are not bandwidth samples",
|
||||
);
|
||||
assert.equal(
|
||||
await viewport
|
||||
.getByText("所选时段暂无带宽采样", { exact: true })
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
return { input, unavailableBandwidthChart: true };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"physical-server monitoring does not turn absent sample times into 1970",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await instanceBoundary(page, { module: "mf_dcim", time: null });
|
||||
await login(page, "/servers/server?id=5");
|
||||
await page.getByRole("tab", { name: "监控", exact: true }).click();
|
||||
const viewport = page.locator(".monitor:not(.compact) .chart-viewport");
|
||||
await eventually(
|
||||
() => viewport.getAttribute("aria-busy"),
|
||||
(busy) => busy === "false",
|
||||
"Physical-server sampling request must settle",
|
||||
);
|
||||
assert.equal(
|
||||
await viewport.locator("canvas").count(),
|
||||
0,
|
||||
"A value without a sample time must not become a 1970 measurement",
|
||||
);
|
||||
assert.equal(
|
||||
await viewport
|
||||
.getByText("所选时段暂无带宽采样", { exact: true })
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
return { missingTimestamp: null, fabricatedEpochChart: false };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"generic-instance charts distinguish unavailable samples from a measured zero stroke",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
const state = await instanceBoundary(page, {
|
||||
module: "idcsmart_common",
|
||||
value: 0,
|
||||
});
|
||||
await login(page, "/servers/server?id=5");
|
||||
await page.getByRole("button", { name: "监控", exact: true }).click();
|
||||
const chart = page.locator(".common-chart");
|
||||
await chart.locator("canvas").waitFor();
|
||||
// richText 提示也绘制在 Canvas 内;验收实际图像中的数据线,不读取 ECharts 私有实例或 option。
|
||||
const measurementPixels = () =>
|
||||
chart
|
||||
.locator("canvas")
|
||||
.first()
|
||||
.evaluate((canvas) => {
|
||||
const copy = document.createElement("canvas");
|
||||
copy.width = canvas.width;
|
||||
copy.height = canvas.height;
|
||||
const context = copy.getContext("2d");
|
||||
context.drawImage(canvas, 0, 0);
|
||||
const { data } = context.getImageData(
|
||||
0,
|
||||
0,
|
||||
copy.width,
|
||||
copy.height,
|
||||
);
|
||||
const scale = copy.width / canvas.clientWidth;
|
||||
let colored = 0;
|
||||
for (
|
||||
let y = Math.ceil(48 * scale);
|
||||
y < copy.height - 40 * scale;
|
||||
y++
|
||||
) {
|
||||
for (
|
||||
let x = Math.ceil(60 * scale);
|
||||
x < copy.width - 25 * scale;
|
||||
x++
|
||||
) {
|
||||
const index = (y * copy.width + x) * 4;
|
||||
const channels = [
|
||||
data[index],
|
||||
data[index + 1],
|
||||
data[index + 2],
|
||||
];
|
||||
if (
|
||||
data[index + 3] > 80 &&
|
||||
Math.max(...channels) - Math.min(...channels) > 40
|
||||
)
|
||||
colored++;
|
||||
}
|
||||
}
|
||||
return colored;
|
||||
});
|
||||
await eventually(
|
||||
measurementPixels,
|
||||
(pixels) => pixels > 100,
|
||||
"Measured zero must draw a real colored series, not an empty chart",
|
||||
);
|
||||
const cases = [];
|
||||
for (const value of [" ", false, null, "invalid"]) {
|
||||
state.value = value;
|
||||
const before = state.chartReads;
|
||||
await page.getByRole("button", { name: "刷新", exact: true }).click();
|
||||
await eventually(
|
||||
() => state.chartReads,
|
||||
(count) => count === before + 1,
|
||||
"Generic monitoring refresh must use the configured HTTP boundary",
|
||||
);
|
||||
await eventually(
|
||||
() =>
|
||||
page.getByRole("button", { name: "刷新", exact: true }).isEnabled(),
|
||||
Boolean,
|
||||
"Generic monitoring refresh must settle",
|
||||
);
|
||||
await eventually(
|
||||
measurementPixels,
|
||||
(pixels) => pixels === 0,
|
||||
"Unavailable input must not leave a fabricated colored zero curve",
|
||||
);
|
||||
cases.push({ input: value, hasMeasurementStroke: false });
|
||||
}
|
||||
return {
|
||||
module: "idcsmart_common",
|
||||
measuredZeroHasStroke: true,
|
||||
unavailableCases: cases,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"disk resize visible label names the editable capacity and preserves quote inputs",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const state = await instanceBoundary(page);
|
||||
await login(page, "/servers/server?id=5");
|
||||
await page.getByRole("tab", { name: "硬盘", exact: true }).click();
|
||||
await page.getByRole("button", { name: "扩容", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", {
|
||||
name: "磁盘扩容",
|
||||
exact: true,
|
||||
});
|
||||
await dialog.getByRole("spinbutton").waitFor();
|
||||
assert.equal(
|
||||
await dialog
|
||||
.getByRole("spinbutton", { name: "扩容至", exact: true })
|
||||
.count(),
|
||||
1,
|
||||
"Visible resize label must name the native spinbutton",
|
||||
);
|
||||
const field = dialog.getByRole("spinbutton", {
|
||||
name: "扩容至",
|
||||
exact: true,
|
||||
});
|
||||
await field.fill("80");
|
||||
await field.press("Tab");
|
||||
await eventually(
|
||||
() => state.quotes.at(-1)?.body.resize_data_disk?.[0]?.size,
|
||||
(size) => size === 80,
|
||||
"Keyboard edit must reach the real resize quote",
|
||||
);
|
||||
assert.equal(state.quotes.at(-1).authenticated, true);
|
||||
return {
|
||||
accessibleName: "扩容至",
|
||||
quotedCapacityGb: 80,
|
||||
authenticatedQuote: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"new disk capacity visible label names its editable field",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const state = await instanceBoundary(page);
|
||||
await login(page, "/servers/server?id=5");
|
||||
await page.getByRole("tab", { name: "硬盘", exact: true }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "购买数据盘", exact: true })
|
||||
.click();
|
||||
const dialog = page.getByRole("dialog", {
|
||||
name: "购买数据盘",
|
||||
exact: true,
|
||||
});
|
||||
await dialog.getByRole("spinbutton").waitFor();
|
||||
assert.equal(
|
||||
await dialog
|
||||
.getByRole("spinbutton", { name: "容量", exact: true })
|
||||
.count(),
|
||||
1,
|
||||
"Visible add-capacity label must name the native spinbutton",
|
||||
);
|
||||
const field = dialog.getByRole("spinbutton", {
|
||||
name: "容量",
|
||||
exact: true,
|
||||
});
|
||||
await field.fill("40");
|
||||
await field.press("Tab");
|
||||
await eventually(
|
||||
() => state.quotes.at(-1)?.body.add_disk?.[0]?.size,
|
||||
(size) => size === 40,
|
||||
"Keyboard edit must reach the real add-disk quote",
|
||||
);
|
||||
assert.equal(state.quotes.at(-1).authenticated, true);
|
||||
return {
|
||||
accessibleName: "容量",
|
||||
quotedCapacityGb: 40,
|
||||
authenticatedQuote: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -1,376 +0,0 @@
|
||||
/**
|
||||
* 列表视觉回归补齐迁移后的盲区:实际页面密度、主题、空态和手机卡片。
|
||||
* 只控制既有外部 HTTP 边界;登录、路由、OpenVue 和 CSS 层叠使用生产构建。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { backendOrigin, login, scenario } from "./helpers/console-browser.mjs";
|
||||
|
||||
const created = 1730000000;
|
||||
const title = "本地列表验收";
|
||||
const list = (row, empty) => ({
|
||||
list: empty ? [] : [row],
|
||||
count: Number(!empty),
|
||||
});
|
||||
const pages = [
|
||||
["SSH keys", "/security/ssh-keys", "table", "audit-key"],
|
||||
["logbook", "/logbook", "table", title],
|
||||
["domains", "/security/domains", "table", "example.invalid"],
|
||||
["groups", "/security/groups", "table", title],
|
||||
["messages", "/messages", "table", title],
|
||||
["news", "/news", "article", title],
|
||||
["notices", "/notices", "article", title],
|
||||
["tickets", "/tickets", "table", title],
|
||||
["transfer", "/transfer", "table", title],
|
||||
["embedded news", "/resources?section=news", "article", title],
|
||||
["embedded notices", "/resources?section=notices", "article", title],
|
||||
];
|
||||
|
||||
async function listBoundary(page, state) {
|
||||
await page.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||
const path = new URL(route.request().url()).pathname.replace(
|
||||
/^\/console\/v1/,
|
||||
"",
|
||||
);
|
||||
const rows = {
|
||||
"/ssh_key": {
|
||||
id: 7,
|
||||
name: "audit-key",
|
||||
public_key: `ssh-ed25519 ${"A".repeat(180)}`,
|
||||
finger_print: "SHA256:local-list-check",
|
||||
},
|
||||
"/log": {
|
||||
id: 7,
|
||||
create_time: created,
|
||||
description: title,
|
||||
ip: "192.0.2.7",
|
||||
},
|
||||
"/domain_whitelist/record": {
|
||||
id: 7,
|
||||
host_id: 5,
|
||||
host_name: title,
|
||||
product_name: "本地云产品",
|
||||
domains: ["example.invalid"],
|
||||
status: 1,
|
||||
create_time: created,
|
||||
can_remove: 0,
|
||||
},
|
||||
"/security_group": {
|
||||
id: 7,
|
||||
name: title,
|
||||
description: "规则说明",
|
||||
rule_num: 2,
|
||||
host_num: 1,
|
||||
create_time: created,
|
||||
},
|
||||
"/client_care/mail/list": {
|
||||
id: 7,
|
||||
title,
|
||||
type: "product",
|
||||
read: 0,
|
||||
create_time: created,
|
||||
},
|
||||
"/news": {
|
||||
id: 7,
|
||||
title,
|
||||
excerpt: "文章摘要",
|
||||
type: "产品",
|
||||
create_time: created,
|
||||
},
|
||||
"/announcement": { id: 7, title, create_time: created },
|
||||
"/ticket": {
|
||||
id: 7,
|
||||
ticket_num: "AUDIT-7",
|
||||
title,
|
||||
name: "产品支持",
|
||||
status: "待回复",
|
||||
color: "#0284c7",
|
||||
create_time: created,
|
||||
post_time: created,
|
||||
last_reply_time: created,
|
||||
},
|
||||
"/host_transfer/host": {
|
||||
id: 7,
|
||||
name: title,
|
||||
product_name: "本地云产品",
|
||||
dedicate_ip: "192.0.2.7",
|
||||
renew_amount: "20.00",
|
||||
billing_cycle_name: "月付",
|
||||
due_time: created,
|
||||
},
|
||||
};
|
||||
const staticResponses = {
|
||||
"/domain_whitelist/host": {
|
||||
list: [{ id: 5, name: title, product_name: "本地云产品" }],
|
||||
},
|
||||
"/certification/info": { is_certification: true },
|
||||
"/account/notification-preferences": {
|
||||
email_enabled: false,
|
||||
sms_enabled: false,
|
||||
email_available: true,
|
||||
sms_available: false,
|
||||
},
|
||||
"/news/type": {
|
||||
list: [{ id: 1, name: "产品", news_num: Number(!state.empty) }],
|
||||
},
|
||||
"/announcement/type": { list: [{ id: 1, name: "平台公告" }] },
|
||||
"/ticket/status": { list: [{ id: 1, name: "待回复", color: "#0284c7" }] },
|
||||
"/ticket/type": { list: [{ id: 1, name: "产品支持" }] },
|
||||
"/ticket/statistic": { 1: Number(!state.empty) },
|
||||
"/host_transfer/config": { certification: false },
|
||||
"/host_transfer/transfer_code": { transfer_code: "LOCAL-ONLY-TRANSFER" },
|
||||
};
|
||||
const data = Object.hasOwn(rows, path)
|
||||
? list(rows[path], state.empty)
|
||||
: staticResponses[path];
|
||||
if (data === undefined) return route.fallback();
|
||||
return route.fulfill({ json: { status: 200, msg: "", data } });
|
||||
});
|
||||
}
|
||||
|
||||
async function measure(page) {
|
||||
return page.evaluate(() => {
|
||||
const read = (selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return null;
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
gap: style.gap,
|
||||
padding: style.padding,
|
||||
background: style.backgroundColor,
|
||||
weight: style.fontWeight,
|
||||
fontSize: style.fontSize,
|
||||
shadow: style.boxShadow,
|
||||
display: style.display,
|
||||
};
|
||||
};
|
||||
const row = document.querySelector(
|
||||
".p-datatable-tbody > tr:not(.p-datatable-empty-message)",
|
||||
);
|
||||
return {
|
||||
dark: document.documentElement.classList.contains("app-dark"),
|
||||
shell: Boolean(document.querySelector(".deni-layout")),
|
||||
page: read(".list-page"),
|
||||
toolbar: read(".list-toolbar"),
|
||||
table: read(".list-table"),
|
||||
article: read(".news-card, .notice-item"),
|
||||
header: read(".p-datatable-header-cell"),
|
||||
cell: read(
|
||||
".p-datatable-tbody > tr:not(.p-datatable-empty-message) > td",
|
||||
),
|
||||
empty: read(".list-empty"),
|
||||
emptyIcon: read(".list-empty > i"),
|
||||
emptyTitle: read(".list-empty-title"),
|
||||
articleCount: document.querySelectorAll(".news-card, .notice-item")
|
||||
.length,
|
||||
tableCount: document.querySelectorAll(".list-table .p-datatable").length,
|
||||
columns: [...document.querySelectorAll(".p-datatable-thead th")].map(
|
||||
(cell) => ({
|
||||
title: cell.textContent.trim(),
|
||||
left: cell.getBoundingClientRect().left,
|
||||
width: cell.getBoundingClientRect().width,
|
||||
}),
|
||||
),
|
||||
overflowingButtons: [
|
||||
...document.querySelectorAll(".p-datatable-tbody td button"),
|
||||
]
|
||||
.filter((button) => {
|
||||
const box = button.getBoundingClientRect();
|
||||
const cell = button.closest("td").getBoundingClientRect();
|
||||
return (
|
||||
box.width > 0 &&
|
||||
box.height > 0 &&
|
||||
(box.left < cell.left - 1 || box.right > cell.right + 1)
|
||||
);
|
||||
})
|
||||
.map(
|
||||
(button) =>
|
||||
button.textContent.trim() || button.getAttribute("aria-label"),
|
||||
),
|
||||
rowDisplay: row ? getComputedStyle(row).display : null,
|
||||
actionLabelLines: [
|
||||
...document.querySelectorAll(".row-actions .p-button-label"),
|
||||
].map((label) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(label);
|
||||
return [...range.getClientRects()].filter((rect) => rect.width > 0)
|
||||
.length;
|
||||
}),
|
||||
visibleCells: row
|
||||
? [...row.querySelectorAll("td")]
|
||||
.filter((cell) => getComputedStyle(cell).display !== "none")
|
||||
.map((cell) => ({
|
||||
label: cell.getAttribute("data-label"),
|
||||
text: cell.innerText.trim(),
|
||||
}))
|
||||
: [],
|
||||
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function luminance(color) {
|
||||
const channels = color
|
||||
.match(/[\d.]+/g)
|
||||
.slice(0, 3)
|
||||
.map((value) => {
|
||||
const channel = Number(value) / 255;
|
||||
return channel <= 0.04045
|
||||
? channel / 12.92
|
||||
: ((channel + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
||||
}
|
||||
|
||||
for (const [name, routePath, kind, visibleText] of pages) {
|
||||
for (const width of [1440, 390]) {
|
||||
for (const theme of ["light", "dark"]) {
|
||||
test(
|
||||
`${name} keeps shared list density at ${width}px in ${theme}`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, { loginMode: "password-only" }, async (page) => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await page.emulateMedia({
|
||||
colorScheme: theme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const state = { empty: true };
|
||||
await listBoundary(page, state);
|
||||
await login(page, routePath);
|
||||
await page.locator(".list-empty").waitFor();
|
||||
await page
|
||||
.locator(".p-datatable-mask")
|
||||
.waitFor({ state: "hidden" });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!document
|
||||
.querySelector(".list-empty-title")
|
||||
?.textContent.includes("加载"),
|
||||
);
|
||||
const empty = await measure(page);
|
||||
state.empty = false;
|
||||
await page.reload();
|
||||
const content = page.locator(
|
||||
kind === "table"
|
||||
? ".p-datatable-tbody > tr:not(.p-datatable-empty-message)"
|
||||
: ".news-card, .notice-item",
|
||||
);
|
||||
await content.first().waitFor();
|
||||
await page
|
||||
.locator(".p-datatable-mask")
|
||||
.waitFor({ state: "hidden" });
|
||||
const filled = await measure(page);
|
||||
const visible = content
|
||||
.getByText(visibleText, { exact: true })
|
||||
.first();
|
||||
assert.equal(await visible.isVisible(), true);
|
||||
// 容器可能没有直接绘制文字;对比度应取可见业务文字及其实际承载背景。
|
||||
filled.text = await visible.evaluate((element) => {
|
||||
const rgb = (color) => color.match(/[\d.]+/g).map(Number);
|
||||
const layers = [];
|
||||
for (let owner = element; owner; owner = owner.parentElement)
|
||||
layers.push(rgb(getComputedStyle(owner).backgroundColor));
|
||||
let background = [255, 255, 255];
|
||||
for (const layer of layers.reverse()) {
|
||||
const alpha = layer[3] ?? 1;
|
||||
background = background.map(
|
||||
(channel, index) =>
|
||||
channel * (1 - alpha) + layer[index] * alpha,
|
||||
);
|
||||
}
|
||||
return {
|
||||
color: getComputedStyle(element).color,
|
||||
background: `rgb(${background.join(", ")})`,
|
||||
};
|
||||
});
|
||||
const violations = [];
|
||||
for (const [stateName, observed] of [
|
||||
["empty", empty],
|
||||
["filled", filled],
|
||||
]) {
|
||||
if (!observed.shell)
|
||||
violations.push(`${stateName}: missing shell`);
|
||||
if (observed.dark !== (theme === "dark"))
|
||||
violations.push(`${stateName}: wrong theme`);
|
||||
if (observed.page?.gap !== "16px")
|
||||
violations.push(`${stateName}: page gap`);
|
||||
if (observed.toolbar?.gap !== "8px")
|
||||
violations.push(`${stateName}: toolbar gap`);
|
||||
if (observed.horizontalOverflow)
|
||||
violations.push(`${stateName}: document overflow`);
|
||||
}
|
||||
if (empty.empty?.gap !== "6px") violations.push("empty: gap");
|
||||
if (empty.empty?.padding !== "48px 24px")
|
||||
violations.push("empty: padding");
|
||||
if (empty.emptyIcon?.fontSize !== "16px")
|
||||
violations.push("empty: icon density");
|
||||
if (kind === "table") {
|
||||
if (filled.overflowingButtons.length)
|
||||
violations.push(
|
||||
`filled: buttons outside cell ${filled.overflowingButtons.join(", ")}`,
|
||||
);
|
||||
if (width >= 640) {
|
||||
if (empty.columns.length !== filled.columns.length)
|
||||
violations.push("filled: column set changed");
|
||||
for (let index = 0; index < empty.columns.length; index++) {
|
||||
const before = empty.columns[index];
|
||||
const after = filled.columns[index];
|
||||
if (
|
||||
!after ||
|
||||
["left", "width"].some(
|
||||
(dimension) =>
|
||||
Math.abs(before[dimension] - after[dimension]) > 1,
|
||||
)
|
||||
)
|
||||
violations.push(
|
||||
`filled: column geometry changed ${before.title}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (filled.tableCount !== 1)
|
||||
violations.push("filled: missing actual table");
|
||||
if (filled.header?.background !== "rgba(0, 0, 0, 0)")
|
||||
violations.push("filled: opaque header");
|
||||
if (filled.header?.weight !== "500")
|
||||
violations.push("filled: header weight");
|
||||
if (filled.header?.padding !== "12px 16px")
|
||||
violations.push("filled: header padding");
|
||||
if (filled.table?.shadow !== "none")
|
||||
violations.push("filled: panel shadow");
|
||||
if (width > 640 && filled.cell?.padding !== "12px 16px")
|
||||
violations.push("filled: cell density");
|
||||
if (width < 640 && !["block", "grid"].includes(filled.rowDisplay))
|
||||
violations.push("filled: mobile card missing");
|
||||
const visibleData = filled.visibleCells
|
||||
.map((cell) => cell.text)
|
||||
.join(" ");
|
||||
if (!visibleData.includes(visibleText))
|
||||
violations.push("filled: card data hidden");
|
||||
if (filled.actionLabelLines.some((lines) => lines > 1))
|
||||
violations.push("filled: row action label wraps");
|
||||
} else {
|
||||
if (filled.tableCount !== 0)
|
||||
violations.push("filled: article page became a table");
|
||||
if (filled.articleCount !== 1)
|
||||
violations.push("filled: missing article card");
|
||||
}
|
||||
const surface = kind === "table" ? filled.table : filled.article;
|
||||
const background = luminance(surface.background);
|
||||
const textBackground = luminance(filled.text.background);
|
||||
const foreground = luminance(filled.text.color);
|
||||
if (theme === "light" ? background < 0.5 : background > 0.1)
|
||||
violations.push("filled: surface ignores theme");
|
||||
if (
|
||||
(Math.max(textBackground, foreground) + 0.05) /
|
||||
(Math.min(textBackground, foreground) + 0.05) <
|
||||
4.5
|
||||
)
|
||||
violations.push("filled: unreadable theme contrast");
|
||||
assert.deepEqual(violations, [], JSON.stringify({ empty, filled }));
|
||||
return { routePath, width, theme, kind, empty, filled };
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/** 通知摘要失败、加载和空结果必须在真实外壳内明确区分。 */
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { backendOrigin, login, scenario } from "./helpers/console-browser.mjs";
|
||||
|
||||
test("Failed notification summaries expose retry instead of an empty inbox", (t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
let retry = false;
|
||||
let release;
|
||||
const pending = new Promise((resolve) => (release = resolve));
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/client_care/mail`, async (route) => {
|
||||
if (retry) await pending;
|
||||
await route.fulfill({
|
||||
json: retry
|
||||
? {
|
||||
status: 200,
|
||||
msg: "",
|
||||
data: {
|
||||
credit_limit: {
|
||||
count: 1,
|
||||
list: [
|
||||
{
|
||||
id: 7,
|
||||
title: "待处理通知",
|
||||
read: 0,
|
||||
create_time: 1700000000,
|
||||
},
|
||||
],
|
||||
type: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
: { status: 503, msg: "通知服务暂不可用", data: {} },
|
||||
});
|
||||
});
|
||||
await login(page);
|
||||
await page.getByRole("button", { name: "查看消息", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "事件通知", exact: true });
|
||||
await dialog.getByRole("alert").waitFor();
|
||||
assert.equal(
|
||||
await dialog.getByText("暂无事件通知", { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
retry = true;
|
||||
await dialog
|
||||
.getByRole("button", { name: "重新加载通知", exact: true })
|
||||
.click();
|
||||
await dialog.getByRole("status").waitFor();
|
||||
assert.equal(await dialog.getByRole("alert").count(), 0);
|
||||
assert.equal(
|
||||
await dialog.getByText("暂无事件通知", { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
release();
|
||||
await dialog
|
||||
.getByRole("button", { name: "待处理通知", exact: false })
|
||||
.waitFor();
|
||||
assert.equal(await dialog.getByRole("status").count(), 0);
|
||||
return {
|
||||
unavailableNotEmpty: true,
|
||||
pendingNotEmpty: true,
|
||||
explicitRetryRecovered: true,
|
||||
};
|
||||
}));
|
||||
|
||||
test("A successful empty notification summary remains a genuine empty inbox", (t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await login(page);
|
||||
await page.getByRole("button", { name: "查看消息", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "事件通知", exact: true });
|
||||
await dialog.getByText("暂无事件通知", { exact: true }).waitFor();
|
||||
assert.equal(await dialog.getByRole("alert").count(), 0);
|
||||
assert.equal(await dialog.getByRole("status").count(), 0);
|
||||
return { successfulEmptySummary: true, falseFailure: false };
|
||||
}));
|
||||
@@ -1,950 +0,0 @@
|
||||
/** 登录结果的会话归属:真实页面、真实跨标签 storage 事件和受控外部 HTTP 响应。 */
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
eventually,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => (resolve = done));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function respond(route, data, status = 200, msg = "") {
|
||||
return route.fulfill({
|
||||
json: { status, msg, data },
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
}
|
||||
|
||||
async function oauthBoundary(page, handle) {
|
||||
// 只声明 OAuth 的外部协议;普通登录、账户和列表仍使用独立 HTTP 假后端。
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||
const path = new URL(route.request().url()).pathname.replace(
|
||||
/^\/console\/v1/,
|
||||
"",
|
||||
);
|
||||
if (await handle(route, path)) return;
|
||||
// 基线复现若错误接纳旧会话,让该外部账号的真实首页继续加载,不以 401 掩盖覆盖。
|
||||
const previousAccount = {
|
||||
"/index": {
|
||||
account: { id: 99, username: "older-oauth-account", credit: "0.00" },
|
||||
},
|
||||
"/menu": { menu: [] },
|
||||
"/client_care/mail": { credit_limit: { count: 0, list: [], type: [] } },
|
||||
"/certification/info": {},
|
||||
"/host": { list: [], count: 0 },
|
||||
"/ticket": { list: [], count: 0 },
|
||||
"/ticket/status": { list: [] },
|
||||
"/ticket/statistic": {},
|
||||
"/announcement": { list: [], count: 0 },
|
||||
"/recommend/promoter": {},
|
||||
"/transaction": { list: [], count: 0 },
|
||||
"/withdraw/rule/credit": {},
|
||||
"/ssh_key": { list: [], count: 0 },
|
||||
};
|
||||
if (
|
||||
route.request().headers().authorization ===
|
||||
"Bearer test-older-oauth-session" &&
|
||||
Object.hasOwn(previousAccount, path)
|
||||
)
|
||||
return respond(route, previousAccount[path]);
|
||||
return route.fallback();
|
||||
});
|
||||
}
|
||||
|
||||
async function changeSession(page, transition) {
|
||||
// 不预置 token,也不手工派发 storage;另一个真实标签完成登录和可选的退出。
|
||||
await page.evaluate(() => {
|
||||
window.oauthSessionTransitions = [];
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === "jwt")
|
||||
window.oauthSessionTransitions.push(
|
||||
event.newValue ? "login" : "logout",
|
||||
);
|
||||
});
|
||||
});
|
||||
const other = await page.context().newPage();
|
||||
await login(other);
|
||||
await other.getByText("audit-01", { exact: true }).waitFor();
|
||||
await eventually(
|
||||
() => page.evaluate(() => window.oauthSessionTransitions),
|
||||
(events) => events.includes("login"),
|
||||
"New login must synchronize before the pending OAuth result is released",
|
||||
);
|
||||
if (transition === "logout") {
|
||||
await other.getByRole("button", { name: "用户菜单", exact: true }).click();
|
||||
await other
|
||||
.getByRole("menuitem", { name: "退出登录", exact: true })
|
||||
.click();
|
||||
await other.waitForURL((url) => url.pathname === "/login");
|
||||
await eventually(
|
||||
() => page.evaluate(() => window.oauthSessionTransitions),
|
||||
(events) => events.includes("logout"),
|
||||
"Logout must synchronize before the pending OAuth result is released",
|
||||
);
|
||||
}
|
||||
return other;
|
||||
}
|
||||
|
||||
async function sessionIsPreserved(page, transition) {
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
(loggedOut) =>
|
||||
localStorage.getItem("jwt") ===
|
||||
(loggedOut ? null : "e2e-session-token"),
|
||||
transition === "logout",
|
||||
),
|
||||
true,
|
||||
"An older result must preserve the session established by the newer action",
|
||||
);
|
||||
}
|
||||
|
||||
async function genericOAuthBoundary(page, handle = async () => false) {
|
||||
// 外部授权页与后端回调均通过真实弹窗访问;回调遵循生产的 about:blank + close 协议。
|
||||
await page.context().route(`${backendOrigin}/oauth-provider`, (route) =>
|
||||
route.fulfill({
|
||||
contentType: "text/html",
|
||||
body: `<meta charset="utf-8"><form action="${backendOrigin}/console/v1/oauth/callback/testprovider"><button>确认本地授权</button></form>`,
|
||||
}),
|
||||
);
|
||||
await oauthBoundary(page, async (route, path) => {
|
||||
if (await handle(route, path)) return true;
|
||||
if (path === "/common") {
|
||||
await respond(route, {
|
||||
website_name: "本地授权验收",
|
||||
login_phone_password: 1,
|
||||
login_email_password: 1,
|
||||
register_phone: 1,
|
||||
register_email: 1,
|
||||
oauth: [{ name: "testprovider", title: "本地授权" }],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (path === "/oauth/testprovider") {
|
||||
await respond(route, { url: `${backendOrigin}/oauth-provider` });
|
||||
return true;
|
||||
}
|
||||
if (path === "/oauth/callback/testprovider") {
|
||||
await route.fulfill({
|
||||
contentType: "text/html",
|
||||
body: '<script>window.location.href="about:blank";window.close()</script>',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async function openGenericOAuth(page, button = "本地授权登录") {
|
||||
const opened = page.waitForEvent("popup", { timeout: 2500 });
|
||||
await page.getByRole("button", { name: button, exact: true }).click();
|
||||
const popup = await opened;
|
||||
await popup.waitForURL(`${backendOrigin}/oauth-provider`);
|
||||
assert.equal(await popup.evaluate(() => window.opener === null), true);
|
||||
return popup;
|
||||
}
|
||||
|
||||
test(
|
||||
"generic OAuth login completes from its real popup and preserves the requested page",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
let tokenReads = 0;
|
||||
await genericOAuthBoundary(page, async (route, path) => {
|
||||
if (path !== "/oauth/token") return false;
|
||||
tokenReads += 1;
|
||||
await respond(route, { jwt: "e2e-session-token" });
|
||||
return true;
|
||||
});
|
||||
await page.goto(`${appOrigin}/login?redirect=/security/ssh-keys`);
|
||||
const popup = await openGenericOAuth(page);
|
||||
assert.equal(new URL(page.url()).pathname, "/login");
|
||||
await popup.getByRole("button", { name: "确认本地授权" }).click();
|
||||
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
||||
await page.getByText("audit-01", { exact: true }).waitFor();
|
||||
await sessionIsPreserved(page, "login");
|
||||
assert.equal(popup.isClosed(), true);
|
||||
assert.equal(tokenReads, 1);
|
||||
return { popupClosed: true, requestedPageRestored: true, tokenReads };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"generic OAuth continues a verified binding and keeps the requested page",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await genericOAuthBoundary(page, async (route, path) => {
|
||||
if (path === "/oauth/token") {
|
||||
await respond(route, { url: `${backendOrigin}/oauth.htm` });
|
||||
return true;
|
||||
}
|
||||
if (path === "/oauth/client/bind") {
|
||||
await respond(route, { jwt: "e2e-session-token" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
await page.goto(`${appOrigin}/login?redirect=/security/ssh-keys`);
|
||||
const popup = await openGenericOAuth(page);
|
||||
await popup.getByRole("button", { name: "确认本地授权" }).click();
|
||||
await page.waitForURL((url) => url.pathname === "/oauth/bind");
|
||||
assert.equal(
|
||||
new URL(page.url()).searchParams.get("redirect"),
|
||||
"/security/ssh-keys",
|
||||
);
|
||||
await page.getByLabel("手机号", { exact: true }).fill("13800138000");
|
||||
await page.getByLabel("验证码", { exact: true }).fill("123456");
|
||||
await page.getByRole("checkbox").check();
|
||||
await page.getByRole("button", { name: "确认绑定", exact: true }).click();
|
||||
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
||||
await sessionIsPreserved(page, "login");
|
||||
return { bindingVerified: true, requestedPageRestored: true };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"closing a generic OAuth popup requires server confirmation and permits retry",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
let tokenReads = 0;
|
||||
await genericOAuthBoundary(page, async (route, path) => {
|
||||
if (path !== "/oauth/token") return false;
|
||||
tokenReads += 1;
|
||||
await respond(route, {});
|
||||
return true;
|
||||
});
|
||||
await page.goto(`${appOrigin}/login`);
|
||||
const popup = await openGenericOAuth(page);
|
||||
await popup.close();
|
||||
await page.getByRole("alert").waitFor();
|
||||
assert.equal(new URL(page.url()).pathname, "/login");
|
||||
await sessionIsPreserved(page, "logout");
|
||||
assert.equal(tokenReads, 1);
|
||||
const retry = await openGenericOAuth(page);
|
||||
await retry.close();
|
||||
await eventually(
|
||||
() => page.getByRole("button", { name: "本地授权登录" }).isEnabled(),
|
||||
Boolean,
|
||||
"Cancelled authorization must permit another attempt",
|
||||
);
|
||||
assert.equal(tokenReads, 2);
|
||||
return { noUnconfirmedLogin: true, retryAllowed: true, tokenReads };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"generic OAuth expires its external popup without consuming a later result",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
let tokenReads = 0;
|
||||
await genericOAuthBoundary(page, async (route, path) => {
|
||||
if (path !== "/oauth/token") return false;
|
||||
tokenReads += 1;
|
||||
await respond(route, { jwt: "e2e-session-token" });
|
||||
return true;
|
||||
});
|
||||
await page.goto(`${appOrigin}/login`);
|
||||
const popup = await openGenericOAuth(page);
|
||||
// 只推进外部墙上时钟,保留浏览器真实轮询调度,避免混用已启动页面的虚拟计时器。
|
||||
await page.clock.setFixedTime(
|
||||
await page.evaluate(() => Date.now() + 10 * 60 * 1000 + 1),
|
||||
);
|
||||
await page.getByRole("alert").waitFor();
|
||||
assert.equal(
|
||||
await page.getByRole("button", { name: "本地授权登录" }).isEnabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(popup.isClosed(), false);
|
||||
await popup.getByRole("button", { name: "确认本地授权" }).click();
|
||||
await eventually(
|
||||
() => popup.isClosed(),
|
||||
Boolean,
|
||||
"The external callback must close its own popup",
|
||||
);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((done) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(done)),
|
||||
),
|
||||
);
|
||||
await sessionIsPreserved(page, "logout");
|
||||
assert.equal(tokenReads, 0);
|
||||
return {
|
||||
expiredAttemptReleased: true,
|
||||
lateCallbackIgnored: true,
|
||||
noResultConsumed: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"generic OAuth cleans up a blocked popup without starting server authorization",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
let authorizations = 0;
|
||||
await genericOAuthBoundary(page, async (route, path) => {
|
||||
if (path === "/oauth/testprovider") authorizations += 1;
|
||||
return false;
|
||||
});
|
||||
await page.goto(`${appOrigin}/login`);
|
||||
// 浏览器能力是外部边界:模拟弹窗策略返回 null,不替换应用内部模块。
|
||||
await page.evaluate(() => {
|
||||
window.open = () => null;
|
||||
});
|
||||
await page.getByRole("button", { name: "本地授权登录" }).click();
|
||||
await page.getByRole("alert").waitFor();
|
||||
assert.equal(
|
||||
await page.getByRole("button", { name: "本地授权登录" }).isEnabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(authorizations, 0);
|
||||
await sessionIsPreserved(page, "logout");
|
||||
return { blockedPopupReported: true, noAuthorizationStarted: true };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"generic OAuth rejects an unsafe authorization URL and closes its blank popup",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await genericOAuthBoundary(page, async (route, path) => {
|
||||
if (path !== "/oauth/testprovider") return false;
|
||||
await respond(route, { url: "javascript:window.name='unsafe'" });
|
||||
return true;
|
||||
});
|
||||
await page.goto(`${appOrigin}/login`);
|
||||
const opened = page.waitForEvent("popup");
|
||||
await page.getByRole("button", { name: "本地授权登录" }).click();
|
||||
const popup = await opened;
|
||||
await page.getByRole("alert").waitFor();
|
||||
await eventually(
|
||||
() => popup.isClosed(),
|
||||
Boolean,
|
||||
"An unsafe authorization address must close its popup",
|
||||
);
|
||||
assert.equal(new URL(page.url()).pathname, "/login");
|
||||
await sessionIsPreserved(page, "logout");
|
||||
return { unsafeUrlRejected: true, popupClosed: true };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"generic OAuth discards a pending login result after leaving its page",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
await genericOAuthBoundary(page, async (route, path) => {
|
||||
if (path !== "/oauth/token") return false;
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
await respond(route, { jwt: "test-older-oauth-session" });
|
||||
return true;
|
||||
});
|
||||
await page.goto(`${appOrigin}/login`);
|
||||
const popup = await openGenericOAuth(page);
|
||||
await popup.getByRole("button", { name: "确认本地授权" }).click();
|
||||
await started.promise;
|
||||
await page.getByRole("link", { name: "注册账号", exact: true }).click();
|
||||
await page.waitForURL((url) => url.pathname === "/register");
|
||||
const response = page.waitForResponse((entry) =>
|
||||
entry.url().endsWith("/oauth/token"),
|
||||
);
|
||||
release.resolve();
|
||||
await (await response).finished();
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((done) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(done)),
|
||||
),
|
||||
);
|
||||
assert.equal(new URL(page.url()).pathname, "/register");
|
||||
await sessionIsPreserved(page, "logout");
|
||||
return { unmountedResultIgnored: true, noOldNavigation: true };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const abandon of ["close", "leave", "login", "logout"]) {
|
||||
test(
|
||||
`generic OAuth ignores a late authorization URL after ${abandon}`,
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
let tokenReads = 0;
|
||||
await genericOAuthBoundary(page, async (route, path) => {
|
||||
if (path === "/oauth/testprovider") {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
await respond(route, { url: `${backendOrigin}/oauth-provider` });
|
||||
return true;
|
||||
}
|
||||
if (path === "/oauth/token") {
|
||||
tokenReads += 1;
|
||||
await respond(route, { jwt: "test-older-oauth-session" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
await page.goto(`${appOrigin}/login`);
|
||||
const opened = page.waitForEvent("popup");
|
||||
await page.getByRole("button", { name: "本地授权登录" }).click();
|
||||
const popup = await opened;
|
||||
await started.promise;
|
||||
if (abandon === "close") await popup.close();
|
||||
else if (abandon === "leave")
|
||||
await page
|
||||
.getByRole("link", { name: "注册账号", exact: true })
|
||||
.click();
|
||||
else await changeSession(page, abandon);
|
||||
await eventually(
|
||||
() => popup.isClosed(),
|
||||
Boolean,
|
||||
"Abandoned authorization must close its popup",
|
||||
);
|
||||
const response = page.waitForResponse((entry) =>
|
||||
entry.url().endsWith("/oauth/testprovider"),
|
||||
);
|
||||
release.resolve();
|
||||
await (await response).finished();
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((done) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(done)),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
new URL(page.url()).pathname,
|
||||
abandon === "leave" ? "/register" : "/login",
|
||||
);
|
||||
await sessionIsPreserved(
|
||||
page,
|
||||
abandon === "login" ? "login" : "logout",
|
||||
);
|
||||
assert.equal(tokenReads, 0);
|
||||
assert.equal(await page.getByRole("alert").count(), 0);
|
||||
return { abandon, lateUrlIgnored: true, popupClosed: true, tokenReads };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const transition of ["login", "logout"]) {
|
||||
for (const outcome of ["jwt", "bind", "error"]) {
|
||||
test(
|
||||
`generic OAuth ignores a late ${outcome} after cross-tab ${transition}`,
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
await genericOAuthBoundary(page, async (route, path) => {
|
||||
if (path !== "/oauth/token") return false;
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
await respond(
|
||||
route,
|
||||
outcome === "jwt"
|
||||
? { jwt: "test-older-oauth-session" }
|
||||
: { url: "/oauth/bind" },
|
||||
outcome === "error" ? 422 : 200,
|
||||
outcome === "error" ? "过期授权请求失败" : "",
|
||||
);
|
||||
return true;
|
||||
});
|
||||
await page.goto(`${appOrigin}/login`);
|
||||
const popup = await openGenericOAuth(page);
|
||||
await popup.getByRole("button", { name: "确认本地授权" }).click();
|
||||
await started.promise;
|
||||
await changeSession(page, transition);
|
||||
const response = page.waitForResponse((entry) =>
|
||||
entry.url().endsWith("/oauth/token"),
|
||||
);
|
||||
release.resolve();
|
||||
await (await response).finished();
|
||||
await eventually(
|
||||
() =>
|
||||
page.getByRole("button", { name: "本地授权登录" }).isEnabled(),
|
||||
Boolean,
|
||||
"A stale authorization must release its login button",
|
||||
);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((done) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(done)),
|
||||
),
|
||||
);
|
||||
assert.equal(new URL(page.url()).pathname, "/login");
|
||||
assert.equal(await page.getByRole("alert").count(), 0);
|
||||
await sessionIsPreserved(page, transition);
|
||||
return {
|
||||
transition,
|
||||
outcome,
|
||||
lateResultIgnored: true,
|
||||
newerSessionPreserved: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const abandon of ["leave", "login", "logout"]) {
|
||||
test(
|
||||
`generic OAuth does not consume a popup closed after ${abandon}`,
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
let tokenReads = 0;
|
||||
await genericOAuthBoundary(page, async (route, path) => {
|
||||
if (path !== "/oauth/token") return false;
|
||||
tokenReads += 1;
|
||||
await respond(route, { jwt: "test-older-oauth-session" });
|
||||
return true;
|
||||
});
|
||||
await page.goto(`${appOrigin}/login`);
|
||||
const popup = await openGenericOAuth(page);
|
||||
if (abandon === "leave") {
|
||||
await page
|
||||
.getByRole("link", { name: "注册账号", exact: true })
|
||||
.click();
|
||||
await page.waitForURL((url) => url.pathname === "/register");
|
||||
} else {
|
||||
await changeSession(page, abandon);
|
||||
await eventually(
|
||||
() =>
|
||||
page.getByRole("button", { name: "本地授权登录" }).isEnabled(),
|
||||
Boolean,
|
||||
"A newer session must release the old authorization attempt",
|
||||
);
|
||||
}
|
||||
// 外部窗口保留 opener 隔离;完成旧回调后,已结束的发起页也不得再消费结果。
|
||||
await popup.getByRole("button", { name: "确认本地授权" }).click();
|
||||
await eventually(
|
||||
() => popup.isClosed(),
|
||||
Boolean,
|
||||
"The old callback must close its own popup",
|
||||
);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((done) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(done)),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
new URL(page.url()).pathname,
|
||||
abandon === "leave" ? "/register" : "/login",
|
||||
);
|
||||
assert.equal(await page.getByRole("alert").count(), 0);
|
||||
await sessionIsPreserved(
|
||||
page,
|
||||
abandon === "login" ? "login" : "logout",
|
||||
);
|
||||
assert.equal(tokenReads, 0);
|
||||
return { abandon, endedAttemptNotResumed: true, tokenReads };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const completed of [true, false]) {
|
||||
test(
|
||||
`generic OAuth verifies a ${completed ? "completed" : "cancelled"} authenticated link without consuming a login token`,
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
let linked = false;
|
||||
let tokenReads = 0;
|
||||
await genericOAuthBoundary(page, async (route, path) => {
|
||||
if (path === "/account") {
|
||||
await respond(route, {
|
||||
account: {
|
||||
id: 1,
|
||||
username: "audit-user",
|
||||
oauth: [
|
||||
{
|
||||
name: "testprovider",
|
||||
title: "本地授权",
|
||||
link: linked ? 1 : 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (path === "/oauth/callback/testprovider") linked = true;
|
||||
if (path === "/oauth/token") {
|
||||
tokenReads += 1;
|
||||
await respond(route, {});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
await login(page, "/account/connections");
|
||||
await page.getByText("未关联", { exact: true }).waitFor();
|
||||
const popup = await openGenericOAuth(page, "关联账号");
|
||||
if (completed) {
|
||||
await popup.getByRole("button", { name: "确认本地授权" }).click();
|
||||
await page.getByText("已关联", { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole("button", { name: "取消关联", exact: true })
|
||||
.isVisible(),
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
await popup.close();
|
||||
await page.getByRole("alert").waitFor();
|
||||
await page.getByText("未关联", { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole("button", { name: "关联账号", exact: true })
|
||||
.isEnabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole("button", { name: "取消关联", exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert.equal(tokenReads, 0);
|
||||
await sessionIsPreserved(page, "login");
|
||||
return {
|
||||
serverLinkVerified: true,
|
||||
completed,
|
||||
tokenReads,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"OAuth callback establishes a current session normally",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await oauthBoundary(page, async (route, path) => {
|
||||
if (path !== "/oauth/token") return false;
|
||||
await respond(route, { jwt: "e2e-session-token" });
|
||||
return true;
|
||||
});
|
||||
await page.goto(`${appOrigin}/oauth/callback`);
|
||||
await page.waitForURL((url) => url.pathname === "/");
|
||||
await page
|
||||
.getByRole("button", { name: "用户菜单", exact: true })
|
||||
.waitFor();
|
||||
await sessionIsPreserved(page, "login");
|
||||
return { currentOAuthSessionAccepted: true, memberHomeVisible: true };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"OAuth verified binding establishes a current session normally",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await oauthBoundary(page, async (route, path) => {
|
||||
if (path === "/oauth/token") {
|
||||
await respond(route, { url: "/oauth/bind" });
|
||||
return true;
|
||||
}
|
||||
if (path === "/oauth/client/bind") {
|
||||
await respond(route, { jwt: "e2e-session-token" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
await page.goto(`${appOrigin}/oauth/bind?redirect=/security/ssh-keys`);
|
||||
await page.getByLabel("手机号", { exact: true }).fill("13800138000");
|
||||
await page.getByLabel("验证码", { exact: true }).fill("123456");
|
||||
await page.getByRole("checkbox").check();
|
||||
await page.getByRole("button", { name: "确认绑定", exact: true }).click();
|
||||
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
||||
await page.getByText("audit-01", { exact: true }).waitFor();
|
||||
await sessionIsPreserved(page, "login");
|
||||
return { verifiedBindingAccepted: true, protectedPageVisible: true };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const pagePath of ["/oauth/callback", "/oauth/bind"]) {
|
||||
for (const transition of ["login", "logout"]) {
|
||||
for (const outcome of ["jwt", "bind", "error"]) {
|
||||
test(
|
||||
`${pagePath} ignores older ${outcome} after cross-tab ${transition}`,
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
await oauthBoundary(page, async (route, path) => {
|
||||
if (path !== "/oauth/token") return false;
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
await respond(
|
||||
route,
|
||||
outcome === "jwt"
|
||||
? { jwt: "test-older-oauth-session" }
|
||||
: { url: "/oauth/bind" },
|
||||
outcome === "error" ? 422 : 200,
|
||||
outcome === "error" ? "过期授权请求失败" : "",
|
||||
);
|
||||
return true;
|
||||
});
|
||||
await page.goto(`${appOrigin}${pagePath}`);
|
||||
await started.promise;
|
||||
await changeSession(page, transition);
|
||||
release.resolve();
|
||||
const retry = page.getByRole("button", {
|
||||
name:
|
||||
pagePath === "/oauth/callback" ? "重新验证" : "验证授权会话",
|
||||
exact: true,
|
||||
});
|
||||
await eventually(
|
||||
() => retry.isEnabled().catch(() => false),
|
||||
Boolean,
|
||||
"The old request must finish without navigating or enabling its old binding flow",
|
||||
);
|
||||
assert.equal(new URL(page.url()).pathname, pagePath);
|
||||
assert.equal(await page.getByRole("alert").count(), 0);
|
||||
assert.equal(
|
||||
await page.getByLabel("手机号", { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await sessionIsPreserved(page, transition);
|
||||
return {
|
||||
transition,
|
||||
outcome,
|
||||
newerSessionPreserved: true,
|
||||
noOldNavigation: true,
|
||||
noOldError: true,
|
||||
noOldBindingForm: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const transition of ["login", "logout"]) {
|
||||
test(
|
||||
`OAuth binding submission ignores its late session after cross-tab ${transition}`,
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
await oauthBoundary(page, async (route, path) => {
|
||||
if (path === "/oauth/token") {
|
||||
await respond(route, { url: "/oauth/bind" });
|
||||
return true;
|
||||
}
|
||||
if (path === "/oauth/client/bind") {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
await respond(route, { jwt: "test-older-oauth-session" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
await page.goto(`${appOrigin}/oauth/bind?redirect=/security/ssh-keys`);
|
||||
await page.getByLabel("手机号", { exact: true }).fill("13800138000");
|
||||
await page.getByLabel("验证码", { exact: true }).fill("123456");
|
||||
await page.getByRole("checkbox").check();
|
||||
const submit = page.getByRole("button", {
|
||||
name: "确认绑定",
|
||||
exact: true,
|
||||
});
|
||||
await submit.click();
|
||||
await started.promise;
|
||||
await changeSession(page, transition);
|
||||
release.resolve();
|
||||
await eventually(
|
||||
() => submit.isEnabled().catch(() => false),
|
||||
Boolean,
|
||||
"The old binding must finish without replacing the newer session",
|
||||
);
|
||||
assert.equal(new URL(page.url()).pathname, "/oauth/bind");
|
||||
await sessionIsPreserved(page, transition);
|
||||
return {
|
||||
transition,
|
||||
lateBindingIgnored: true,
|
||||
newerSessionPreserved: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function wechatBoundary(page, statusHandler) {
|
||||
await oauthBoundary(page, async (route, path) => {
|
||||
if (path === "/common") {
|
||||
await respond(route, {
|
||||
website_name: "本地扫码验收",
|
||||
login_phone_password: 1,
|
||||
login_email_password: 1,
|
||||
login_phone_verify: 1,
|
||||
register_phone: 1,
|
||||
register_email: 1,
|
||||
plugin_configuration: { mp_weixin_notice: { scan_login: 1 } },
|
||||
oauth: [],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (path === "/mp_weixin_notice/qrcode") {
|
||||
await respond(route, {
|
||||
img_url:
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==",
|
||||
ticket: "local-qr-ticket",
|
||||
token: "local-qr-token",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return statusHandler(route, path);
|
||||
});
|
||||
}
|
||||
|
||||
async function requestWechat(page) {
|
||||
await page.goto(`${appOrigin}/login?redirect=/security/ssh-keys`);
|
||||
await page.getByRole("button", { name: "获取二维码", exact: true }).click();
|
||||
await page.getByRole("img", { name: "微信扫码登录", exact: true }).waitFor();
|
||||
}
|
||||
|
||||
test(
|
||||
"a current WeChat scan establishes its session normally",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await wechatBoundary(page, async (route, path) => {
|
||||
if (path !== "/mp_weixin_notice/qrcode/status") return false;
|
||||
await respond(route, { status: "Success", jwt: "e2e-session-token" });
|
||||
return true;
|
||||
});
|
||||
await requestWechat(page);
|
||||
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
||||
await page.getByText("audit-01", { exact: true }).waitFor();
|
||||
await sessionIsPreserved(page, "login");
|
||||
return { currentWechatSessionAccepted: true };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"a current WeChat account selection establishes its session normally",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await wechatBoundary(page, async (route, path) => {
|
||||
if (path === "/mp_weixin_notice/qrcode/status") {
|
||||
await respond(route, {
|
||||
status: "SelectClient",
|
||||
client: [{ id: 1, username: "current-qr-account", status: 1 }],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (path === "/mp_weixin_notice/qrcode/select_client_login") {
|
||||
await respond(route, { jwt: "e2e-session-token" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
await requestWechat(page);
|
||||
await page.locator(".qr-client-btn").click();
|
||||
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
||||
await page.getByText("audit-01", { exact: true }).waitFor();
|
||||
await sessionIsPreserved(page, "login");
|
||||
return { currentWechatSelectionAccepted: true };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const outcome of ["Success", "WaitBind", "SelectClient"]) {
|
||||
test(
|
||||
`WeChat ${outcome} result cannot resume its old scan after a newer session`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
const transition = outcome === "SelectClient" ? "logout" : "login";
|
||||
await wechatBoundary(page, async (route, path) => {
|
||||
if (path === "/mp_weixin_notice/qrcode/status") {
|
||||
if (outcome === "SelectClient") {
|
||||
await respond(route, {
|
||||
status: "SelectClient",
|
||||
client: [{ id: 99, username: "older-qr-account", status: 1 }],
|
||||
});
|
||||
} else {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
await respond(route, {
|
||||
status: outcome,
|
||||
...(outcome === "Success"
|
||||
? { jwt: "test-older-oauth-session" }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path === "/mp_weixin_notice/qrcode/select_client_login") {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
await respond(route, { jwt: "test-older-oauth-session" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
await requestWechat(page);
|
||||
if (outcome === "SelectClient")
|
||||
await page.locator(".qr-client-btn").click();
|
||||
await started.promise;
|
||||
await changeSession(page, transition);
|
||||
const completed = page.waitForResponse((response) =>
|
||||
response
|
||||
.url()
|
||||
.endsWith(
|
||||
outcome === "SelectClient"
|
||||
? "/mp_weixin_notice/qrcode/select_client_login"
|
||||
: "/mp_weixin_notice/qrcode/status?ticket=local-qr-ticket&token=local-qr-token",
|
||||
),
|
||||
);
|
||||
release.resolve();
|
||||
await (await completed).finished();
|
||||
// 网络完成后跨过一次实际渲染,观察迟到结果,而非在释放响应前就读取已清空的二维码。
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((done) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(done)),
|
||||
),
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "获取二维码", exact: true })
|
||||
.waitFor();
|
||||
assert.equal(new URL(page.url()).pathname, "/login");
|
||||
assert.equal(await page.locator(".qr-client-btn").count(), 0);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => sessionStorage.getItem("console.wechat-binding") === null,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await sessionIsPreserved(page, transition);
|
||||
return {
|
||||
outcome,
|
||||
transition,
|
||||
oldScanDiscarded: true,
|
||||
newerSessionPreserved: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,578 +0,0 @@
|
||||
/** 认证与手机号回归仅经生产页面和本地 HTTP 边界验证,不请求真实短信或修改线上账号。 */
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
eventually,
|
||||
fixtureRequest,
|
||||
login,
|
||||
loginAccount,
|
||||
loginPassword,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
async function authBoundary(page, overrides = {}) {
|
||||
// 外部响应使用既有接口信封;只捕获本组声明的接口,不给缺失契约统一返回成功。
|
||||
const observed = [];
|
||||
const replies = {
|
||||
"/register": {},
|
||||
"/account/password_reset": {},
|
||||
"/oauth/token": { url: "/oauth/bind" },
|
||||
"/oauth/client/bind": { jwt: "e2e-session-token" },
|
||||
"/country": {
|
||||
list: [
|
||||
{ id: 1, name: "中国", name_zh: "中国", phone_code: 86 },
|
||||
{ id: 2, name: "美国", name_zh: "美国", phone_code: 1 },
|
||||
],
|
||||
},
|
||||
"/account/phone/old": {},
|
||||
"/account/phone": {},
|
||||
...overrides,
|
||||
};
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace(
|
||||
/^\/console\/v1/,
|
||||
"",
|
||||
);
|
||||
if (!Object.hasOwn(replies, path)) return route.fallback();
|
||||
observed.push({
|
||||
path,
|
||||
method: request.method(),
|
||||
body: request.postDataJSON(),
|
||||
});
|
||||
return route.fulfill({
|
||||
json: { status: 200, msg: "", data: replies[path] },
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
});
|
||||
return observed;
|
||||
}
|
||||
|
||||
async function fixedPhonePrefix(page) {
|
||||
// 固定区号必须是文本,而非可编辑、可选择、可触发国家列表的控件。
|
||||
assert.equal(
|
||||
await page.locator('input[autocomplete="tel-country-code"]').count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByRole("combobox", { name: /国际区号/ }).count(),
|
||||
0,
|
||||
);
|
||||
await page.getByText("中国大陆 +86", { exact: true }).waitFor();
|
||||
}
|
||||
|
||||
async function passwordKeyboard(page, field) {
|
||||
await field.fill("local-only-password");
|
||||
await field.focus();
|
||||
await page.keyboard.press("Tab");
|
||||
const owner = field.locator("..");
|
||||
const show = owner.getByRole("button", { name: "显示密码", exact: true });
|
||||
assert.equal(await show.evaluate((e) => e === document.activeElement), true);
|
||||
assert.equal(await show.getAttribute("aria-pressed"), "false");
|
||||
await page.keyboard.press("Space");
|
||||
assert.equal(await field.getAttribute("type"), "text");
|
||||
const hide = owner.getByRole("button", { name: "隐藏密码", exact: true });
|
||||
assert.equal(await hide.getAttribute("aria-pressed"), "true");
|
||||
await page.keyboard.press("Enter");
|
||||
assert.equal(await field.getAttribute("type"), "password");
|
||||
assert.equal(await field.inputValue(), "local-only-password");
|
||||
}
|
||||
|
||||
for (const [path, labels, autocomplete] of [
|
||||
["/login", ["密码"], "current-password"],
|
||||
["/register", ["密码 *", "确认密码 *"], "new-password"],
|
||||
["/forgot-password", ["新密码 *", "确认新密码 *"], "new-password"],
|
||||
]) {
|
||||
test(
|
||||
`password manager semantics reach native inputs on ${path}`,
|
||||
{ timeout: 20000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await authBoundary(page);
|
||||
await page.goto(appOrigin + path);
|
||||
for (const label of labels) {
|
||||
const field = page.getByLabel(label, { exact: true });
|
||||
await field.waitFor();
|
||||
assert.equal(await field.getAttribute("autocomplete"), autocomplete);
|
||||
}
|
||||
return {
|
||||
nativePasswordAutocomplete: autocomplete,
|
||||
fields: labels.length,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"password visibility is keyboard operable without submitting the form",
|
||||
{ timeout: 20000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.goto(`${appOrigin}/login`);
|
||||
await passwordKeyboard(page, page.getByLabel("密码", { exact: true }));
|
||||
assert.equal(
|
||||
(await fixtureRequest("requests")).some((r) => r.path === "/login"),
|
||||
false,
|
||||
);
|
||||
return { keyboardShowHide: true, noAccidentalSubmit: true };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const width of [320, 390]) {
|
||||
test(
|
||||
`password text remains editable beside its visibility button at ${width}px`,
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await page.goto(`${appOrigin}/register`);
|
||||
const field = page.getByLabel("密码 *", { exact: true });
|
||||
const password = "long-local-password-".repeat(4);
|
||||
const observations = [];
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
// dir是浏览器渲染环境,不宣称产品新增了RTL语言入口或全站RTL支持。
|
||||
await page.locator("html").evaluate((element, dir) => {
|
||||
element.dir = dir;
|
||||
}, direction);
|
||||
await field.fill(password);
|
||||
await field.press("End");
|
||||
const point = await field.evaluate((input) => {
|
||||
const style = getComputedStyle(input);
|
||||
// 读取相对输入框 padding box 的编辑区末端;locator 点击会等滚动/布局稳定,
|
||||
// 不把测量后的绝对坐标误用于已自然滚动的页面。
|
||||
return {
|
||||
x:
|
||||
style.direction === "rtl"
|
||||
? parseFloat(style.paddingLeft) + 1
|
||||
: input.clientWidth - parseFloat(style.paddingRight) - 1,
|
||||
y: input.clientHeight / 2,
|
||||
overflowing: input.scrollWidth > input.clientWidth,
|
||||
};
|
||||
});
|
||||
assert.equal(
|
||||
point.overflowing,
|
||||
true,
|
||||
"Exercise a scrolled long password",
|
||||
);
|
||||
await field.click({ position: { x: point.x, y: point.y } });
|
||||
assert.equal(
|
||||
await field.getAttribute("type"),
|
||||
"password",
|
||||
`${direction} text editing must not activate the visibility button`,
|
||||
);
|
||||
assert.equal(
|
||||
await field.evaluate((input) => input === document.activeElement),
|
||||
true,
|
||||
"A real click in the text editor must keep native input focus",
|
||||
);
|
||||
await page.keyboard.type("Z");
|
||||
const edited = await field.inputValue();
|
||||
assert.equal(edited.length, password.length + 1);
|
||||
assert.equal(edited.replace("Z", ""), password);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
observations.push({ direction, textClickEditsWithoutToggling: true });
|
||||
}
|
||||
return { viewportWidth: width, editing: observations };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"pending login disables password editing and visibility then restores native attributes",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
let release;
|
||||
let received = false;
|
||||
const pending = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/login`, async (route) => {
|
||||
received = true;
|
||||
await pending;
|
||||
await route.fulfill({
|
||||
json: { status: 400, msg: "本地验证失败", data: {} },
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
});
|
||||
try {
|
||||
await page.goto(`${appOrigin}/login`);
|
||||
const field = page.getByLabel("密码", { exact: true });
|
||||
await page.getByLabel("账号", { exact: true }).fill(loginAccount);
|
||||
await field.fill(loginPassword);
|
||||
const toggle = field.locator("..").getByRole("button", {
|
||||
name: "显示密码",
|
||||
exact: true,
|
||||
});
|
||||
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||
await eventually(
|
||||
() => received,
|
||||
Boolean,
|
||||
"Login must reach the HTTP boundary",
|
||||
);
|
||||
assert.equal(await field.isDisabled(), true);
|
||||
assert.equal(await toggle.isDisabled(), true);
|
||||
const box = await toggle.boundingBox();
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.keyboard.type("Z");
|
||||
assert.equal(await field.getAttribute("type"), "password");
|
||||
assert.equal(await field.inputValue(), loginPassword);
|
||||
release();
|
||||
await eventually(
|
||||
() => field.isEnabled(),
|
||||
Boolean,
|
||||
"Rejected login unlocks the form",
|
||||
);
|
||||
assert.equal(await toggle.isEnabled(), true);
|
||||
assert.equal(
|
||||
await field.getAttribute("aria-describedby"),
|
||||
"login-error",
|
||||
);
|
||||
return { disabledInputAndToggle: true, nativeAriaRestored: true };
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
async function contrast(locator) {
|
||||
return locator.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const rgb = (value) =>
|
||||
value
|
||||
.match(/[\d.]+/g)
|
||||
.slice(0, 3)
|
||||
.map(Number);
|
||||
const light = (color) =>
|
||||
rgb(color)
|
||||
.map((channel) => {
|
||||
const v = channel / 255;
|
||||
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
})
|
||||
.reduce(
|
||||
(sum, value, i) => sum + value * [0.2126, 0.7152, 0.0722][i],
|
||||
0,
|
||||
);
|
||||
const fg = light(style.color),
|
||||
bg = light(style.backgroundColor);
|
||||
return (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05);
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
"primary auth actions stay readable in normal hover and focus states across themes",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.goto(`${appOrigin}/login`);
|
||||
const submit = page.getByRole("button", { name: "登录", exact: true });
|
||||
await eventually(
|
||||
() => submit.isEnabled(),
|
||||
Boolean,
|
||||
"Auth configuration must finish loading",
|
||||
);
|
||||
const results = [];
|
||||
for (const theme of ["light", "dark"]) {
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await page.mouse.move(1, 1);
|
||||
await submit.blur();
|
||||
await page.waitForTimeout(200);
|
||||
assert.ok(
|
||||
(await contrast(submit)) >= 4.5,
|
||||
`${theme} normal action text contrast`,
|
||||
);
|
||||
await submit.hover();
|
||||
await page.waitForTimeout(200);
|
||||
assert.ok(
|
||||
(await contrast(submit)) >= 4.5,
|
||||
`${theme} hovered action text contrast`,
|
||||
);
|
||||
await page.mouse.move(1, 1);
|
||||
await submit.focus();
|
||||
await page.waitForTimeout(200);
|
||||
assert.ok(
|
||||
(await contrast(submit)) >= 4.5,
|
||||
`${theme} focused action text contrast`,
|
||||
);
|
||||
results.push({ theme, normalHoverFocusReadable: true });
|
||||
}
|
||||
return { themes: results };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"phone registration uses fixed mainland prefix for SMS and registration",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const writes = await authBoundary(page);
|
||||
await page.goto(`${appOrigin}/register`);
|
||||
await page.getByLabel("手机号", { exact: true }).waitFor();
|
||||
await fixedPhonePrefix(page);
|
||||
await page.getByLabel("手机号", { exact: true }).fill("13800138000");
|
||||
await page
|
||||
.getByRole("button", { name: "获取验证码", exact: true })
|
||||
.click();
|
||||
await eventually(
|
||||
() => fixtureRequest("requests"),
|
||||
(r) => r.some((e) => e.path === "/phone/code"),
|
||||
"Registration SMS must reach HTTP",
|
||||
);
|
||||
const sms = (await fixtureRequest("requests")).find(
|
||||
(r) => r.path === "/phone/code",
|
||||
);
|
||||
assert.equal(sms.body.phone_code, 86);
|
||||
await page.getByLabel("验证码 *", { exact: true }).fill("123456");
|
||||
await page
|
||||
.getByLabel("密码 *", { exact: true })
|
||||
.fill("new-local-password");
|
||||
await page
|
||||
.getByLabel("确认密码 *", { exact: true })
|
||||
.fill("new-local-password");
|
||||
await page.getByRole("button", { name: "创建账户", exact: true }).click();
|
||||
await page.waitForURL((u) => u.pathname === "/login");
|
||||
assert.equal(
|
||||
String(writes.find((r) => r.path === "/register").body.phone_code),
|
||||
"86",
|
||||
);
|
||||
assert.equal(
|
||||
writes.some((r) => r.path === "/country"),
|
||||
false,
|
||||
);
|
||||
return {
|
||||
smsPrefix: 86,
|
||||
registrationPrefix: 86,
|
||||
countryPickerAbsent: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"phone password recovery uses fixed mainland prefix",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const writes = await authBoundary(page);
|
||||
await page.goto(`${appOrigin}/forgot-password`);
|
||||
await page.getByLabel("手机号", { exact: true }).waitFor();
|
||||
await fixedPhonePrefix(page);
|
||||
await page.getByLabel("手机号", { exact: true }).fill("13800138000");
|
||||
await page
|
||||
.getByRole("button", { name: "获取验证码", exact: true })
|
||||
.click();
|
||||
await eventually(
|
||||
() => fixtureRequest("requests"),
|
||||
(r) => r.some((e) => e.path === "/phone/code"),
|
||||
"Recovery SMS must reach HTTP",
|
||||
);
|
||||
assert.equal(
|
||||
(await fixtureRequest("requests")).find((r) => r.path === "/phone/code")
|
||||
.body.phone_code,
|
||||
86,
|
||||
);
|
||||
await page.getByLabel("验证码 *", { exact: true }).fill("123456");
|
||||
await page
|
||||
.getByLabel("新密码 *", { exact: true })
|
||||
.fill("new-local-password");
|
||||
await page
|
||||
.getByLabel("确认新密码 *", { exact: true })
|
||||
.fill("new-local-password");
|
||||
await page.getByRole("button", { name: "重置密码", exact: true }).click();
|
||||
await page.waitForURL(
|
||||
(u) => u.pathname === "/login" && u.searchParams.get("reset") === "1",
|
||||
);
|
||||
assert.equal(
|
||||
String(
|
||||
writes.find((r) => r.path === "/account/password_reset").body
|
||||
.phone_code,
|
||||
),
|
||||
"86",
|
||||
);
|
||||
return { smsPrefix: 86, resetPrefix: 86 };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"OAuth phone binding uses fixed mainland prefix after external session verification",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const writes = await authBoundary(page);
|
||||
await page.goto(`${appOrigin}/oauth/bind?redirect=/security/ssh-keys`);
|
||||
await page.getByLabel("手机号", { exact: true }).waitFor();
|
||||
await fixedPhonePrefix(page);
|
||||
await page.getByLabel("手机号", { exact: true }).fill("13800138000");
|
||||
await page
|
||||
.getByRole("button", { name: "获取验证码", exact: true })
|
||||
.click();
|
||||
await eventually(
|
||||
() => fixtureRequest("requests"),
|
||||
(r) => r.some((e) => e.path === "/phone/code"),
|
||||
"Binding SMS must reach HTTP",
|
||||
);
|
||||
assert.equal(
|
||||
(await fixtureRequest("requests")).find((r) => r.path === "/phone/code")
|
||||
.body.phone_code,
|
||||
86,
|
||||
);
|
||||
await page.getByLabel("验证码", { exact: true }).fill("123456");
|
||||
await page.getByRole("checkbox").check();
|
||||
await page.getByRole("button", { name: "确认绑定", exact: true }).click();
|
||||
await page.waitForURL((u) => u.pathname === "/security/ssh-keys");
|
||||
assert.equal(
|
||||
String(
|
||||
writes.find((r) => r.path === "/oauth/client/bind").body.phone_code,
|
||||
),
|
||||
"86",
|
||||
);
|
||||
return { authorizedBeforeBind: true, smsPrefix: 86, bindingPrefix: 86 };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const existingPhone of [false, true]) {
|
||||
test(
|
||||
`account ${existingPhone ? "change" : "new"} phone uses fixed prefix without changing stored phone or address countries`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const writes = await authBoundary(page, {
|
||||
"/account": {
|
||||
account: {
|
||||
id: 1,
|
||||
username: "audit-user",
|
||||
email: "audit@example.invalid",
|
||||
phone: existingPhone ? "2025550100" : "",
|
||||
phone_code: 1,
|
||||
country_id: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
await login(page, "/account?tab=phone");
|
||||
const panel = page.getByRole("tabpanel", {
|
||||
name: "手机号",
|
||||
exact: true,
|
||||
});
|
||||
await panel.waitFor();
|
||||
if (existingPhone) {
|
||||
assert.match(await panel.innerText(), /\+1 /);
|
||||
await panel
|
||||
.getByRole("button", { name: "获取验证码", exact: true })
|
||||
.click();
|
||||
await eventually(
|
||||
() => fixtureRequest("requests"),
|
||||
(r) => r.some((e) => e.path === "/phone/code"),
|
||||
"Old-phone SMS must reach HTTP",
|
||||
);
|
||||
const oldSms = (await fixtureRequest("requests")).find(
|
||||
(r) => r.path === "/phone/code",
|
||||
);
|
||||
assert.equal(oldSms.body.action, "verify");
|
||||
assert.equal(
|
||||
"phone_code" in oldSms.body,
|
||||
false,
|
||||
"Old-phone verification must use the saved backend number",
|
||||
);
|
||||
await panel
|
||||
.getByLabel("原手机验证码", { exact: true })
|
||||
.fill("123456");
|
||||
await panel
|
||||
.getByRole("button", { name: "下一步", exact: true })
|
||||
.click();
|
||||
}
|
||||
await panel
|
||||
.getByRole("textbox", { name: "新手机号", exact: true })
|
||||
.waitFor();
|
||||
await fixedPhonePrefix(page);
|
||||
await panel
|
||||
.getByRole("textbox", { name: "新手机号", exact: true })
|
||||
.fill("13800138000");
|
||||
await panel
|
||||
.getByRole("button", { name: "获取验证码", exact: true })
|
||||
.click();
|
||||
await eventually(
|
||||
() => fixtureRequest("requests"),
|
||||
(r) =>
|
||||
r.some(
|
||||
(e) => e.path === "/phone/code" && e.body.action === "update",
|
||||
),
|
||||
"New-phone SMS must reach HTTP",
|
||||
);
|
||||
assert.equal(
|
||||
(await fixtureRequest("requests")).find(
|
||||
(r) => r.path === "/phone/code" && r.body.action === "update",
|
||||
).body.phone_code,
|
||||
86,
|
||||
);
|
||||
await panel.getByLabel("新手机验证码", { exact: true }).fill("123456");
|
||||
await panel
|
||||
.getByRole("button", { name: "保存变更", exact: true })
|
||||
.click();
|
||||
await eventually(
|
||||
() => writes.find((r) => r.path === "/account/phone"),
|
||||
Boolean,
|
||||
"New phone must reach update HTTP",
|
||||
);
|
||||
assert.equal(
|
||||
writes.find((r) => r.path === "/account/phone").body.phone_code,
|
||||
86,
|
||||
);
|
||||
await page.getByRole("tab", { name: "资料", exact: true }).click();
|
||||
const country = page.getByRole("combobox", {
|
||||
name: "国家/地区",
|
||||
exact: true,
|
||||
});
|
||||
await country.click();
|
||||
await page.getByRole("option", { name: "中国", exact: true }).waitFor();
|
||||
await page.getByRole("option", { name: "美国", exact: true }).waitFor();
|
||||
return {
|
||||
phonePrefix: 86,
|
||||
oldPhonePreserved: true,
|
||||
addressCountriesStillSelectable: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"account password editor shares native semantics and keyboard visibility",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await authBoundary(page);
|
||||
await login(page, "/account?tab=password");
|
||||
const panel = page.getByRole("tabpanel", { name: "密码", exact: true });
|
||||
await panel.waitFor();
|
||||
const current = panel
|
||||
.getByLabel("当前密码", { exact: true })
|
||||
.and(panel.locator("input"));
|
||||
assert.equal(
|
||||
await current.getAttribute("autocomplete"),
|
||||
"current-password",
|
||||
);
|
||||
assert.equal(
|
||||
await panel
|
||||
.getByLabel("新密码", { exact: true })
|
||||
.getAttribute("autocomplete"),
|
||||
"new-password",
|
||||
);
|
||||
assert.equal(
|
||||
await panel
|
||||
.getByLabel("确认新密码", { exact: true })
|
||||
.getAttribute("autocomplete"),
|
||||
"new-password",
|
||||
);
|
||||
await passwordKeyboard(page, current);
|
||||
return { nativeSemantics: true, sharedKeyboardShowHide: true };
|
||||
}),
|
||||
);
|
||||
@@ -1,436 +0,0 @@
|
||||
/** 商品目录回归走公开页面及真实配置器,只在既有后端 HTTP 边界提供生产形状数据。 */
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
eventually,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
async function catalogBoundary(page) {
|
||||
const state = { productReads: [], quotes: [] };
|
||||
const variants = [
|
||||
{
|
||||
id: 201,
|
||||
name: "未控库存",
|
||||
stock_control: 0,
|
||||
qty: 0,
|
||||
sync: 0,
|
||||
modelQty: 0,
|
||||
},
|
||||
{
|
||||
id: 202,
|
||||
name: "商品售罄",
|
||||
stock_control: 1,
|
||||
qty: 0,
|
||||
sync: 0,
|
||||
modelQty: 8,
|
||||
},
|
||||
{
|
||||
id: 203,
|
||||
name: "共同约束",
|
||||
stock_control: 1,
|
||||
qty: 8,
|
||||
sync: 1,
|
||||
modelQty: 3,
|
||||
},
|
||||
{
|
||||
id: 204,
|
||||
name: "型号约束",
|
||||
stock_control: 0,
|
||||
qty: 0,
|
||||
sync: 1,
|
||||
modelQty: 4,
|
||||
},
|
||||
{
|
||||
id: 205,
|
||||
name: "型号售罄",
|
||||
stock_control: 1,
|
||||
qty: 8,
|
||||
sync: 1,
|
||||
modelQty: 0,
|
||||
},
|
||||
{
|
||||
id: 206,
|
||||
name: "库存待确认",
|
||||
stock_control: 1,
|
||||
qty: null,
|
||||
sync: 0,
|
||||
modelQty: 8,
|
||||
},
|
||||
{
|
||||
id: 207,
|
||||
name: "商品限制更小",
|
||||
stock_control: 1,
|
||||
qty: 2,
|
||||
sync: 1,
|
||||
modelQty: 9,
|
||||
},
|
||||
{
|
||||
id: 208,
|
||||
name: "仅商品限制",
|
||||
stock_control: 1,
|
||||
qty: 5,
|
||||
sync: 0,
|
||||
modelQty: 0,
|
||||
},
|
||||
];
|
||||
const product = (entry) => ({
|
||||
...entry,
|
||||
module: "mf_dcim",
|
||||
description: "",
|
||||
price: "350.00",
|
||||
cycle: "月",
|
||||
product_group_id_second: 7,
|
||||
});
|
||||
const lineOptions = [
|
||||
{
|
||||
id: 11,
|
||||
name: "阶梯带宽",
|
||||
bill_type: "bw",
|
||||
bw: [
|
||||
{ type: "step", value: "0", min_value: 30, max_value: 30, step: 1 },
|
||||
{ type: "step", value: "0", min_value: 31, max_value: 1000, step: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: "固定带宽",
|
||||
bill_type: "bw",
|
||||
bw: [{ type: "radio", value: "100", min_value: 999, max_value: 999 }],
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
name: "区间流量",
|
||||
bill_type: "flow",
|
||||
flow: [
|
||||
{
|
||||
type: "total",
|
||||
value: "0",
|
||||
min_value: 500,
|
||||
max_value: 2000,
|
||||
step: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
// 每个请求仍经过 useApi 的真实配置/解析链,未替换任何项目内部模块。
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname.replace(/^\/console\/v1/, "");
|
||||
const reply = (data) =>
|
||||
route.fulfill({
|
||||
json: { status: 200, msg: "", data },
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
if (path === "/product/group/first")
|
||||
return reply({ list: [{ id: 6, name: "国内云服务器" }] });
|
||||
if (path === "/product/group/second")
|
||||
return reply({ list: [{ id: 7, parent_id: 6, name: "测试线路" }] });
|
||||
if (path === "/product")
|
||||
return reply({ list: variants.map(product), count: variants.length });
|
||||
const match = /^\/product\/([^/]+)(.*)$/.exec(path);
|
||||
if (!match) return route.fallback();
|
||||
const id = Number(match[1]);
|
||||
const suffix = match[2];
|
||||
if (request.method() === "GET") state.productReads.push(path);
|
||||
if (!suffix) {
|
||||
const entry = variants.find((entry) => entry.id === id);
|
||||
return reply({
|
||||
product: entry
|
||||
? product(entry)
|
||||
: {
|
||||
id,
|
||||
module: "mf_dcim",
|
||||
order_allowed: false,
|
||||
order_block_reason: "边界商品已停止订购",
|
||||
},
|
||||
});
|
||||
}
|
||||
const entry = variants.find((entry) => entry.id === id) ?? variants[0];
|
||||
if (suffix === "/mf_dcim/order_page")
|
||||
return reply({
|
||||
model_config: [
|
||||
{
|
||||
id,
|
||||
name: `${entry.name}机型`,
|
||||
cpu: "8核",
|
||||
memory: "16G",
|
||||
disk: "480G SSD",
|
||||
qty: entry.modelQty,
|
||||
auto_sync_dcim_stock: entry.sync,
|
||||
},
|
||||
],
|
||||
data_center: [
|
||||
{
|
||||
id: 1,
|
||||
name: "中国",
|
||||
city: [
|
||||
{
|
||||
id: 2,
|
||||
name: "测试地区",
|
||||
area: [
|
||||
{
|
||||
id: 3,
|
||||
name: "测试机房",
|
||||
line: lineOptions.map(({ id, name, bill_type }) => ({
|
||||
id,
|
||||
name,
|
||||
bill_type,
|
||||
data_center_id: 1,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const line = /^\/mf_dcim\/line\/(\d+)$/.exec(suffix);
|
||||
if (line)
|
||||
return reply({
|
||||
...lineOptions.find((entry) => entry.id === Number(line[1])),
|
||||
ip: [{ type: "radio", value: "1", desc: "1个" }],
|
||||
defence: [{ type: "radio", value: "0", desc: "基础防护" }],
|
||||
});
|
||||
if (suffix === "/mf_dcim/image")
|
||||
return reply({
|
||||
list: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Ubuntu",
|
||||
image: [
|
||||
{
|
||||
id: 10,
|
||||
name: "Ubuntu 24.04",
|
||||
image_group_id: 1,
|
||||
price: "0.00",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (suffix === "/self_defined_field/order_page")
|
||||
return reply({ data: [] });
|
||||
if (suffix === "/mf_dcim/duration")
|
||||
return reply([{ id: 1, name: "月付", price: "350.00", is_default: 1 }]);
|
||||
if (suffix === "/config_option") {
|
||||
state.quotes.push(request.postDataJSON());
|
||||
return reply({ price_total: "350.00" });
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
test(
|
||||
"catalog stock labels and eligibility share effective product and model limits",
|
||||
{ timeout: 60000 },
|
||||
async (t) => {
|
||||
await scenario(t, {}, async (page) => {
|
||||
const state = await catalogBoundary(page);
|
||||
await page.goto(`${appOrigin}/products/dedicated-server`);
|
||||
const card = (name) =>
|
||||
page.locator(".hardware-card").filter({
|
||||
has: page.getByRole("heading", {
|
||||
name: `${name}机型`,
|
||||
exact: true,
|
||||
}),
|
||||
});
|
||||
await card("未控库存").waitFor();
|
||||
assert.equal(
|
||||
await card("未控库存").getByText("剩余 0 台", { exact: true }).count(),
|
||||
0,
|
||||
"Uncontrolled placeholder zero is not an available-stock count",
|
||||
);
|
||||
assert.equal(
|
||||
await card("未控库存")
|
||||
.getByRole("button", { name: "选择线路", exact: true })
|
||||
.isEnabled(),
|
||||
true,
|
||||
);
|
||||
for (const name of ["商品售罄", "型号售罄"])
|
||||
assert.equal(
|
||||
await card(name)
|
||||
.getByRole("button", { name: "库存不足", exact: true })
|
||||
.isDisabled(),
|
||||
true,
|
||||
);
|
||||
await card("共同约束").getByText("剩余 3 台", { exact: true }).waitFor();
|
||||
await card("型号约束").getByText("剩余 4 台", { exact: true }).waitFor();
|
||||
await card("商品限制更小")
|
||||
.getByText("剩余 2 台", { exact: true })
|
||||
.waitFor();
|
||||
await card("仅商品限制")
|
||||
.getByText("剩余 5 台", { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await card("库存待确认")
|
||||
.getByText("剩余 0 台", { exact: true })
|
||||
.count(),
|
||||
0,
|
||||
"Missing quantity must not be coerced into advertised zero stock",
|
||||
);
|
||||
await page.goto(
|
||||
`${appOrigin}/products/dedicated-server/customize?id=203&model_id=203&line_id=11`,
|
||||
);
|
||||
await page.getByText("当前库存 3 台", { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel("购买数量", { exact: true })
|
||||
.getAttribute("aria-valuemax"),
|
||||
"3",
|
||||
);
|
||||
const beforeSoldOut = state.quotes.length;
|
||||
await page.goto(
|
||||
`${appOrigin}/products/dedicated-server/customize?id=202&model_id=202&line_id=11`,
|
||||
);
|
||||
await page
|
||||
.getByText("所选型号库存不足,请减少数量或选择其他型号")
|
||||
.first()
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
state.quotes.length,
|
||||
beforeSoldOut,
|
||||
"Direct configuration of controlled zero stock must not request a quote",
|
||||
);
|
||||
return {
|
||||
uncontrolledZeroNotAdvertised: true,
|
||||
controlledZeroBlocked: true,
|
||||
jointMinimumStock: 3,
|
||||
modelOnlyStock: 4,
|
||||
productSmallerStock: 2,
|
||||
productOnlyStock: 5,
|
||||
unknownNotTreatedAsZero: true,
|
||||
configurationMatchesCatalog: true,
|
||||
directSoldOutConfigurationDoesNotQuote: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"catalog radio and ranged network summaries agree with configurator defaults",
|
||||
{ timeout: 60000 },
|
||||
async (t) => {
|
||||
await scenario(t, {}, async (page) => {
|
||||
const state = await catalogBoundary(page);
|
||||
const observations = [];
|
||||
for (const [lineId, name, expected, field, value] of [
|
||||
[11, "阶梯带宽", "30 Mbps", "bw", "30"],
|
||||
[12, "固定带宽", "100 Mbps", "bw", "100"],
|
||||
[13, "区间流量", "500 GB", "flow", "500"],
|
||||
]) {
|
||||
await page.goto(`${appOrigin}/products/dedicated-server`);
|
||||
await page
|
||||
.locator(".hardware-card")
|
||||
.filter({
|
||||
has: page.getByRole("heading", {
|
||||
name: "未控库存机型",
|
||||
exact: true,
|
||||
}),
|
||||
})
|
||||
.getByRole("button", { name: "选择线路", exact: true })
|
||||
.click();
|
||||
const line = page.locator(".line-card").filter({
|
||||
has: page.locator(".line-tags").getByText(name, { exact: true }),
|
||||
});
|
||||
await line.locator(".line-specs").waitFor();
|
||||
assert.equal(
|
||||
(await line.innerText()).includes(expected),
|
||||
true,
|
||||
"Network summary must show a selectable value, not the range placeholder",
|
||||
);
|
||||
const before = state.quotes.length;
|
||||
await line.getByRole("link", { name: "立即订购", exact: true }).click();
|
||||
await eventually(
|
||||
() => state.quotes.slice(before),
|
||||
(quotes) =>
|
||||
quotes.some(
|
||||
(quote) =>
|
||||
quote.config_options.line_id === lineId &&
|
||||
quote.config_options[field] === value,
|
||||
),
|
||||
"Configurator quote must use the same valid network value as the catalog",
|
||||
);
|
||||
observations.push({
|
||||
option: name,
|
||||
summary: expected,
|
||||
configuredValue: value,
|
||||
});
|
||||
}
|
||||
return { networkOptions: observations };
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"invalid product identifiers show public errors before product API reads",
|
||||
{ timeout: 90000 },
|
||||
async (t) => {
|
||||
await scenario(t, {}, async (page) => {
|
||||
const state = await catalogBoundary(page);
|
||||
const invalid = [
|
||||
"2147483648",
|
||||
"999999999999999999999999999999999",
|
||||
"0",
|
||||
"-1",
|
||||
"01",
|
||||
"not-a-product",
|
||||
];
|
||||
const observed = [];
|
||||
for (const id of invalid) {
|
||||
for (const path of [
|
||||
`/products/${id}`,
|
||||
`/products/dedicated-server/customize?id=${id}`,
|
||||
`/products/cloud-server?id=${id}`,
|
||||
`/products/cvm?product_id=${id}`,
|
||||
]) {
|
||||
const before = state.productReads.length;
|
||||
await page.goto(appOrigin + path);
|
||||
await eventually(
|
||||
async () =>
|
||||
state.productReads.length > before ||
|
||||
(await page.getByText("商品编号无效").first().isVisible()),
|
||||
Boolean,
|
||||
"Identifier must either be rejected visibly or reach the observed external boundary",
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.productReads.slice(before),
|
||||
[],
|
||||
"Invalid identifier must not enter a product endpoint",
|
||||
);
|
||||
await page.getByText("商品编号无效").first().waitFor();
|
||||
assert.equal(
|
||||
new URL(page.url()).pathname,
|
||||
path.split("?")[0],
|
||||
"Invalid public product identifiers must not redirect to login",
|
||||
);
|
||||
assert.equal(
|
||||
state.productReads.length,
|
||||
before,
|
||||
"Invalid identifier must not enter a product endpoint",
|
||||
);
|
||||
}
|
||||
observed.push(id);
|
||||
}
|
||||
await page.goto(`${appOrigin}/products/2147483647`);
|
||||
await page.getByText("边界商品已停止订购", { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
state.productReads.includes("/product/2147483647"),
|
||||
true,
|
||||
"Largest supported signed 32-bit product identifier remains valid",
|
||||
);
|
||||
return {
|
||||
invalidIdentifiersRejected: observed,
|
||||
publicErrorNotLoginLoop: true,
|
||||
invalidProductApiReads: 0,
|
||||
largestValidIdentifierAccepted: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,284 +0,0 @@
|
||||
/** 官网回归使用真实 CMS 信封和图片字节边界,不替换内部模型或手工触发图片事件。 */
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
eventually,
|
||||
loginAccount,
|
||||
loginPassword,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
// 与线上 CMS 的字段形状一致:旧入口仍由 CMS 覆盖,不能只测试默认文案。
|
||||
function cmsContent() {
|
||||
return {
|
||||
header: {
|
||||
navigation: [
|
||||
{ label: "产品服务", href: "#products", is_show: true },
|
||||
{
|
||||
label: "客户自定义方案",
|
||||
href: "https://customer.example.invalid/plan",
|
||||
is_show: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
hero: {
|
||||
title: "为客户保留的自定义标题",
|
||||
buttonLabel: "浏览产品服务",
|
||||
buttonHref: "/login?redirect=%2Fproducts",
|
||||
},
|
||||
products: {
|
||||
items: [
|
||||
{
|
||||
title: "客户自定义云方案",
|
||||
buttonLabel: "查看云服务器",
|
||||
href: "/login?redirect=%2Fcloud-servers",
|
||||
features: [],
|
||||
is_show: true,
|
||||
},
|
||||
{
|
||||
title: "物理服务器",
|
||||
buttonLabel: "查看物理服务器",
|
||||
href: "/login?redirect=%2Fdedicated-servers",
|
||||
features: [],
|
||||
is_show: true,
|
||||
},
|
||||
{
|
||||
title: "机柜托管",
|
||||
buttonLabel: "了解机柜托管",
|
||||
href: "/login?redirect=%2Fcolocations",
|
||||
features: [],
|
||||
is_show: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
phone: "",
|
||||
email: "",
|
||||
address: "",
|
||||
linkGroups: [
|
||||
{
|
||||
title: "产品服务",
|
||||
links: [
|
||||
{ label: "云服务器", href: "/login?redirect=%2Fcloud-servers" },
|
||||
{ label: "机柜托管", href: "/login?redirect=%2Fcolocations" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function officialBoundary(page, content = {}) {
|
||||
const imageErrors = [];
|
||||
await page
|
||||
.context()
|
||||
.route(`${appOrigin}/official-test-images/**`, (route) => {
|
||||
if (route.request().url().endsWith("valid-avatar.svg")) {
|
||||
return route.fulfill({
|
||||
contentType: "image/svg+xml",
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><rect width="2" height="2" fill="#0052d9"/></svg>',
|
||||
});
|
||||
}
|
||||
// HTTP 成功但图片不可解码,真实触发 img error;不放宽共享 console 错误门禁。
|
||||
imageErrors.push(new URL(route.request().url()).pathname);
|
||||
return route.fulfill({
|
||||
contentType: "image/png",
|
||||
body: "invalid PNG bytes",
|
||||
});
|
||||
});
|
||||
await page.context().route(`${backendOrigin}/console/v1/**`, (route) => {
|
||||
const path = new URL(route.request().url()).pathname.replace(
|
||||
/^\/console\/v1/,
|
||||
"",
|
||||
);
|
||||
const reply = (data) =>
|
||||
route.fulfill({ json: { status: 200, msg: "", data } });
|
||||
if (path === "/official_site_cms/content") return reply({ content });
|
||||
if (path === "/product/group/first") {
|
||||
return reply({
|
||||
list: [
|
||||
{ id: 1, name: "云服务器" },
|
||||
{ id: 2, name: "物理服务器" },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (path === "/product/group/second") {
|
||||
return reply({ list: [{ id: 11, parent_id: 1, name: "公开云线路" }] });
|
||||
}
|
||||
if (path === "/product") return reply({ list: [], count: 0 });
|
||||
if (path === "/ticket/config") return reply({ ticket_notice_open: 0 });
|
||||
if (["/ticket/type", "/ticket/department"].includes(path))
|
||||
return reply({ list: [] });
|
||||
return route.fallback();
|
||||
});
|
||||
return { imageErrors };
|
||||
}
|
||||
|
||||
for (const [label, destination, content] of [
|
||||
["浏览产品服务", "/products", {}],
|
||||
["浏览产品服务", "/products", cmsContent()],
|
||||
["查看云服务器", "/products/cloud-server", cmsContent()],
|
||||
["查看物理服务器", "/products/dedicated-server", cmsContent()],
|
||||
]) {
|
||||
test(
|
||||
`official purchase CTA opens ${destination} anonymously with ${Object.keys(content).length ? "CMS" : "default"} content`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await officialBoundary(page, content);
|
||||
await page.goto(`${appOrigin}/official-site`);
|
||||
if (content.hero) {
|
||||
await page
|
||||
.getByRole("heading", { name: content.hero.title, exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole("link", { name: "客户自定义方案", exact: true })
|
||||
.getAttribute("href"),
|
||||
"https://customer.example.invalid/plan",
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole("heading", { name: "客户自定义云方案", exact: true })
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
}
|
||||
await page.getByRole("link", { name: label, exact: true }).click();
|
||||
await eventually(
|
||||
() => new URL(page.url()).pathname,
|
||||
(path) => path === destination,
|
||||
"Public purchase CTA must reach its product route without a login gate",
|
||||
);
|
||||
await page.locator(".public-order-layout").waitFor();
|
||||
assert.equal(await page.locator("#account").count(), 0);
|
||||
return {
|
||||
anonymousDestination: destination,
|
||||
customContentPreserved: !!content.hero,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"official cabinet consultation reaches an implemented support flow",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, { loginMode: "password-only" }, async (page) => {
|
||||
const content = cmsContent();
|
||||
content.contact = { is_show: false };
|
||||
content.sectionOrder = ["products", "infrastructure", "reviews"];
|
||||
await officialBoundary(page, content);
|
||||
await page.goto(`${appOrigin}/official-site`);
|
||||
await page
|
||||
.getByRole("heading", { name: "为客户保留的自定义标题", exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.locator("#contact").count(),
|
||||
0,
|
||||
"CMS contact visibility must not be overridden",
|
||||
);
|
||||
await page
|
||||
.getByText("机柜托管及定制方案请登录后提交工单咨询", { exact: true })
|
||||
.waitFor();
|
||||
await page
|
||||
.getByRole("link", { name: "了解机柜托管", exact: true })
|
||||
.click();
|
||||
await page.getByRole("button", { name: "登录", exact: true }).waitFor();
|
||||
assert.equal(
|
||||
new URL(page.url()).searchParams.get("redirect"),
|
||||
"/tickets/create",
|
||||
);
|
||||
await page.locator("#account").fill(loginAccount);
|
||||
await page.locator("#password").fill(loginPassword);
|
||||
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||
await page.waitForURL((url) => url.pathname === "/tickets/create");
|
||||
await page.locator(".ticket-create").waitFor();
|
||||
return {
|
||||
consultationAvailable: true,
|
||||
supportRouteOpened: "/tickets/create",
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"official configurable images fall back after real image decoding failures",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const content = cmsContent();
|
||||
content.brand = {
|
||||
name: "破碎工坊网络",
|
||||
logoUrl: "/official-test-images/logo.png",
|
||||
};
|
||||
content.footer.qrImage = "/official-test-images/qr.png";
|
||||
content.footer.qrCaption = "扫码咨询";
|
||||
content.reviews = {
|
||||
is_show: true,
|
||||
items: [
|
||||
{
|
||||
author: "Sophon",
|
||||
company: "",
|
||||
quote: "保留原有客户评价",
|
||||
avatar: "/official-test-images/avatar.png",
|
||||
is_show: true,
|
||||
},
|
||||
{
|
||||
author: "Ramdisk",
|
||||
company: "",
|
||||
quote: "保留可用客户头像",
|
||||
avatar: "/official-test-images/valid-avatar.svg",
|
||||
is_show: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const boundary = await officialBoundary(page, content);
|
||||
await page.goto(`${appOrigin}/official-site`);
|
||||
await page.getByText("保留原有客户评价", { exact: true }).waitFor();
|
||||
await eventually(
|
||||
() => [...new Set(boundary.imageErrors)].sort(),
|
||||
(paths) => paths.length === 3,
|
||||
"The browser must request each configured bad image",
|
||||
);
|
||||
await eventually(
|
||||
() => page.locator(".official-brand-mark").count(),
|
||||
(count) => count === 2,
|
||||
"Both failed brand images must use the existing brand mark",
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator(".official-review-avatar").textContent(),
|
||||
"S",
|
||||
);
|
||||
await page
|
||||
.getByText("二维码暂时无法显示,请使用工单咨询", { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.locator(".official-site img").count(),
|
||||
1,
|
||||
"Only the successfully decoded original customer avatar should remain",
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole("img", { name: "Ramdisk", exact: true })
|
||||
.evaluate((image) => image.complete && image.naturalWidth > 0),
|
||||
true,
|
||||
);
|
||||
assert.equal(await page.getByText("Sophon", { exact: true }).count(), 1);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
"Image fallbacks and consultation text must fit the mobile viewport",
|
||||
);
|
||||
return {
|
||||
brandFallbacks: 2,
|
||||
customerIdentityPreserved: true,
|
||||
qrReplacedByConsultationText: true,
|
||||
imageFailure: "HTTP 200 with invalid PNG bytes",
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -1,423 +0,0 @@
|
||||
/** 工单内容/部门与账务分页/能力开关的契约回归;只控制本地 HTTP 边界。 */
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
eventually,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
async function reply(route, data) {
|
||||
return route.fulfill({
|
||||
json: { status: 200, msg: "", data },
|
||||
headers: { "Access-Control-Allow-Origin": appOrigin },
|
||||
});
|
||||
}
|
||||
|
||||
async function financeLists(page) {
|
||||
const paths = [
|
||||
"/transaction",
|
||||
"/credit",
|
||||
"/withdraw",
|
||||
"/order",
|
||||
"/refund/pending/amount",
|
||||
"/account/credit/freeze",
|
||||
];
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === backendOrigin &&
|
||||
paths.includes(url.pathname.replace("/console/v1", "")),
|
||||
(route) =>
|
||||
reply(
|
||||
route,
|
||||
new URL(route.request().url()).pathname.endsWith(
|
||||
"/refund/pending/amount",
|
||||
)
|
||||
? { amount: "0.00" }
|
||||
: { list: [], count: 0, total_amount: "0.00" },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function ticket(content, replies = []) {
|
||||
return {
|
||||
id: 23,
|
||||
ticket_num: "LOCAL-23",
|
||||
title: "内容契约验收",
|
||||
content,
|
||||
content_format: "text",
|
||||
attachment: [],
|
||||
replies,
|
||||
status: "待回复",
|
||||
can_operate: 1,
|
||||
create_time: 1730000000,
|
||||
username: "audit-user",
|
||||
client_id: 1,
|
||||
color: "#777",
|
||||
};
|
||||
}
|
||||
|
||||
test(
|
||||
"ticket creation submits literal text and its one effective department",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, { loginMode: "password-only" }, async (page) => {
|
||||
const content = " <b>用户代码</b>\n 第二行 <tag>\n尾行 ";
|
||||
const writes = [];
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === backendOrigin &&
|
||||
url.pathname.startsWith("/console/v1/ticket"),
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace(
|
||||
"/console/v1",
|
||||
"",
|
||||
);
|
||||
if (path === "/ticket/config")
|
||||
return reply(route, { ticket_notice_open: 0 });
|
||||
if (["/ticket/type", "/ticket/department"].includes(path))
|
||||
return reply(route, {
|
||||
list: [
|
||||
{ id: 1, name: "账务支持" },
|
||||
{ id: 2, name: "网络支持" },
|
||||
],
|
||||
});
|
||||
if (path === "/ticket" && request.method() === "POST") {
|
||||
writes.push(request.postDataJSON());
|
||||
return reply(route, { id: 23 });
|
||||
}
|
||||
if (path === "/ticket/23")
|
||||
return reply(route, { ticket: ticket(content) });
|
||||
return route.fallback();
|
||||
},
|
||||
);
|
||||
await login(page, "/tickets/create");
|
||||
await page.getByRole("radio", { name: "网络支持", exact: true }).check();
|
||||
await page
|
||||
.getByRole("textbox", { name: "工单标题", exact: true })
|
||||
.fill("内容契约验收");
|
||||
await page
|
||||
.getByRole("textbox", { name: "工单内容", exact: true })
|
||||
.fill(content);
|
||||
await page.getByRole("button", { name: "创建工单", exact: true }).click();
|
||||
await eventually(
|
||||
() => writes,
|
||||
(rows) => rows.length === 1,
|
||||
"工单必须提交一次",
|
||||
);
|
||||
assert.equal(writes[0].ticket_type_id, 2);
|
||||
assert.equal(writes[0].content, content);
|
||||
assert.equal(Object.hasOwn(writes[0], "admin_role_id"), false);
|
||||
await page.waitForURL((url) => url.pathname === "/tickets/23");
|
||||
assert.equal(
|
||||
await page.locator(".message-body").first().textContent(),
|
||||
content,
|
||||
);
|
||||
return {
|
||||
effectiveDepartment: 2,
|
||||
contentKeptLiteral: true,
|
||||
createdTickets: 1,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"ticket text, historical HTML, reply snapshots and reply drafts retain their declared format",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, { loginMode: "password-only" }, async (page) => {
|
||||
const initial = " <b>字面代码</b>\n 空格 <tag>";
|
||||
const quoted = "<i>字面引用</i>\n下一行";
|
||||
const replies = [
|
||||
{
|
||||
id: 11,
|
||||
content: quoted,
|
||||
content_format: "text",
|
||||
type: "Client",
|
||||
client_name: "audit-user",
|
||||
create_time: 1730000001,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
content: "<strong>历史客服排版</strong>",
|
||||
content_format: "html",
|
||||
type: "Admin",
|
||||
admin_name: "本地客服",
|
||||
create_time: 1730000002,
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
content: "快照回复",
|
||||
content_format: "text",
|
||||
type: "Client",
|
||||
client_name: "audit-user",
|
||||
create_time: 1730000003,
|
||||
quote_reply_id: 11,
|
||||
quote_info: {
|
||||
content: quoted,
|
||||
content_format: "text",
|
||||
sender_name: "audit-user",
|
||||
attachment: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
const data = ticket(initial, replies);
|
||||
const writes = [];
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === backendOrigin &&
|
||||
url.pathname.startsWith("/console/v1/ticket/"),
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace(
|
||||
"/console/v1",
|
||||
"",
|
||||
);
|
||||
if (path === "/ticket/config")
|
||||
return reply(route, { ticket_notice_open: 0 });
|
||||
if (path === "/ticket/23") return reply(route, { ticket: data });
|
||||
if (path === "/ticket/23/reply") {
|
||||
const body = request.postDataJSON();
|
||||
writes.push(body);
|
||||
data.replies.push({
|
||||
id: 14,
|
||||
...body,
|
||||
content_format: "text",
|
||||
type: "Client",
|
||||
client_name: "audit-user",
|
||||
create_time: 1730000004,
|
||||
quote_info: {
|
||||
content: quoted,
|
||||
content_format: "text",
|
||||
sender_name: "audit-user",
|
||||
},
|
||||
});
|
||||
return reply(route, {});
|
||||
}
|
||||
return route.fallback();
|
||||
},
|
||||
);
|
||||
await login(page, "/tickets/23");
|
||||
const messages = page.locator(".thread .message");
|
||||
await messages.nth(3).waitFor();
|
||||
const first = messages.first().locator(".message-body");
|
||||
assert.equal(await first.textContent(), initial);
|
||||
assert.equal(await first.locator("b").count(), 0);
|
||||
assert.equal(
|
||||
await first.evaluate((node) => getComputedStyle(node).whiteSpace),
|
||||
"pre-wrap",
|
||||
);
|
||||
assert.equal(
|
||||
await messages.nth(2).locator(".message-body strong").textContent(),
|
||||
"历史客服排版",
|
||||
);
|
||||
assert.equal(
|
||||
await messages.nth(3).locator(".message-quote div").textContent(),
|
||||
quoted,
|
||||
);
|
||||
assert.equal(
|
||||
await messages.nth(3).locator(".message-quote i").count(),
|
||||
0,
|
||||
);
|
||||
await messages
|
||||
.nth(1)
|
||||
.getByRole("button", { name: "引用", exact: true })
|
||||
.click();
|
||||
assert.equal(
|
||||
await page.locator(".reply-quote > div > div").textContent(),
|
||||
quoted,
|
||||
);
|
||||
const draft = " <script>字面错误日志</script>\n 保留缩进 ";
|
||||
await page
|
||||
.getByRole("textbox", { name: "回复内容", exact: true })
|
||||
.fill(draft);
|
||||
await page.getByRole("button", { name: "提交回复", exact: true }).click();
|
||||
await eventually(
|
||||
() => writes,
|
||||
(rows) => rows.length === 1,
|
||||
"回复必须提交一次",
|
||||
);
|
||||
assert.equal(writes[0].content, draft);
|
||||
assert.equal(writes[0].quote_reply_id, 11);
|
||||
await messages.nth(4).waitFor();
|
||||
assert.equal(
|
||||
await messages.nth(4).locator(".message-body").textContent(),
|
||||
draft,
|
||||
);
|
||||
return {
|
||||
textAndSnapshotsKeptLiteral: true,
|
||||
historicalHtmlRendered: true,
|
||||
replyKeptWhitespace: true,
|
||||
quotedReply: 11,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"full freeze lists paginate locally without duplicate rows or extra reads",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, { loginMode: "password-only" }, async (page) => {
|
||||
await financeLists(page);
|
||||
let reads = 0;
|
||||
const list = Array.from({ length: 21 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
amount: "10.00",
|
||||
client_notes: `冻结记录-${index + 1}`,
|
||||
create_time: 1730000000,
|
||||
}));
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === backendOrigin &&
|
||||
url.pathname === "/console/v1/account/credit/freeze",
|
||||
(route) => {
|
||||
reads++;
|
||||
return reply(route, { list, count: list.length });
|
||||
},
|
||||
);
|
||||
await login(page, "/finance?tab=freeze");
|
||||
const panel = page.getByRole("tabpanel").filter({ visible: true });
|
||||
await panel.getByText("冻结记录-1", { exact: true }).waitFor();
|
||||
assert.equal(await panel.locator("tbody tr").count(), 20);
|
||||
await panel.locator(".p-paginator-next").click();
|
||||
await panel.getByText("冻结记录-21", { exact: true }).waitFor();
|
||||
assert.equal(await panel.locator("tbody tr").count(), 1);
|
||||
assert.equal(
|
||||
await panel.getByText("冻结记录-1", { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(reads, 1);
|
||||
return { firstPageRows: 20, secondPageRows: 1, totalReads: reads };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const mode of ["enabled", "disabled", "missing", "failure"]) {
|
||||
test(
|
||||
`balance reminders follow the ${mode} site capability`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, { loginMode: "password-only" }, async (page) => {
|
||||
const writes = [];
|
||||
await financeLists(page);
|
||||
// 配置失败场景先经真实表单建立会话,再整页刷新触发新的公共配置读取。
|
||||
if (mode === "failure") await login(page, "/finance");
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === backendOrigin &&
|
||||
url.pathname === "/console/v1/common",
|
||||
async (route) => {
|
||||
if (mode === "failure")
|
||||
return route.fulfill({
|
||||
json: { status: 400, msg: "本地配置暂不可用", data: {} },
|
||||
headers: { "Access-Control-Allow-Origin": appOrigin },
|
||||
});
|
||||
const response = await route.fetch();
|
||||
const payload = await response.json();
|
||||
if (mode === "missing") delete payload.data.balance_notice_show;
|
||||
else payload.data.balance_notice_show = mode === "enabled" ? 1 : 0;
|
||||
return route.fulfill({ response, json: payload });
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === backendOrigin &&
|
||||
url.pathname === "/console/v1/account/credit/remind",
|
||||
(route) => {
|
||||
writes.push(route.request().postDataJSON());
|
||||
return reply(route, {});
|
||||
},
|
||||
);
|
||||
if (mode === "failure") await page.goto(`${appOrigin}/finance`);
|
||||
else await login(page, "/finance");
|
||||
const panel = page.locator(".reminder-panel");
|
||||
const save = panel.getByRole("button", { name: "保存", exact: true });
|
||||
await save.waitFor();
|
||||
if (mode === "enabled") {
|
||||
await panel.locator("#credit-reminder-enabled").check();
|
||||
await panel.locator("#credit-reminder-amount").fill("25.00");
|
||||
await save.click();
|
||||
await eventually(
|
||||
() => writes,
|
||||
(rows) => rows.length === 1,
|
||||
"开启能力后可保存提醒",
|
||||
);
|
||||
assert.deepEqual(writes[0], {
|
||||
credit_remind: 1,
|
||||
credit_remind_amount: 25,
|
||||
});
|
||||
} else {
|
||||
assert.equal(await save.isDisabled(), true);
|
||||
assert.equal(
|
||||
await panel.locator("#credit-reminder-enabled").isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await panel.locator("#credit-reminder-amount").isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(await panel.getByRole("status").isVisible(), true);
|
||||
assert.equal(writes.length, 0);
|
||||
}
|
||||
return {
|
||||
capability: mode,
|
||||
saved: mode === "enabled",
|
||||
writes: writes.length,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"balance reminders stay disabled while a failed startup configuration is being reloaded",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, { loginMode: "password-only" }, async (page) => {
|
||||
await financeLists(page);
|
||||
await login(page, "/finance");
|
||||
let reads = 0;
|
||||
let release;
|
||||
const held = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === backendOrigin && url.pathname === "/console/v1/common",
|
||||
async (route) => {
|
||||
if (++reads === 1)
|
||||
return route.fulfill({
|
||||
json: { status: 400, msg: "本地首次配置读取失败", data: {} },
|
||||
headers: { "Access-Control-Allow-Origin": appOrigin },
|
||||
});
|
||||
await held;
|
||||
const response = await route.fetch();
|
||||
const payload = await response.json();
|
||||
payload.data.balance_notice_show = 1;
|
||||
return route.fulfill({ response, json: payload });
|
||||
},
|
||||
);
|
||||
try {
|
||||
await page.goto(`${appOrigin}/finance`);
|
||||
const panel = page.locator(".reminder-panel");
|
||||
const save = panel.getByRole("button", { name: "保存", exact: true });
|
||||
await save.waitFor();
|
||||
assert.equal(await save.isDisabled(), true);
|
||||
assert.equal(
|
||||
await panel.locator("#credit-reminder-enabled").isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(await panel.getByRole("status").isVisible(), true);
|
||||
release();
|
||||
await eventually(
|
||||
() => save.isEnabled(),
|
||||
Boolean,
|
||||
"配置确认开启后才允许保存",
|
||||
);
|
||||
return { pendingCapabilityDisabled: true, enabledAfterReload: true };
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,453 +0,0 @@
|
||||
/** 页面/会话/草稿归属:真实页面与真实登录,只有外部 API 响应受控。 */
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
login,
|
||||
loginAccount,
|
||||
loginPassword,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => (resolve = done));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function respond(route, data, status = 200) {
|
||||
return route.fulfill({
|
||||
json: { status, msg: status === 200 ? "" : "本地延迟失败", data },
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
}
|
||||
|
||||
async function boundary(page, handle) {
|
||||
const requests = [];
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname.replace(/^\/console\/v1/, "");
|
||||
const entry = {
|
||||
method: request.method(),
|
||||
path,
|
||||
authenticated: !!request.headers().authorization,
|
||||
};
|
||||
requests.push(entry);
|
||||
if (await handle(route, path, entry, url)) return;
|
||||
return route.fallback();
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
|
||||
async function releaseResponse(page, release, path) {
|
||||
const response = page.waitForResponse(
|
||||
(reply) => new URL(reply.url()).pathname === `/console/v1${path}`,
|
||||
);
|
||||
release.resolve();
|
||||
await (await response).finished();
|
||||
// 真实请求完成后,在有界的空闲观察窗口内断言后续工作未发生。
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
async function signInOnCurrentDocument(page) {
|
||||
await page.getByLabel("账号", { exact: true }).fill(loginAccount);
|
||||
await page.getByLabel("密码", { exact: true }).fill(loginPassword);
|
||||
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||
await page.waitForURL((url) => url.pathname !== "/login");
|
||||
await page.getByRole("button", { name: "用户菜单", exact: true }).waitFor();
|
||||
}
|
||||
|
||||
async function logoutAndSignIn(page) {
|
||||
await page.getByRole("button", { name: "用户菜单", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "退出登录", exact: true }).click();
|
||||
await page.waitForURL((url) => url.pathname === "/login");
|
||||
await signInOnCurrentDocument(page);
|
||||
}
|
||||
|
||||
for (const transition of ["current", "leave", "reauthenticate"]) {
|
||||
test(
|
||||
`message detail ownership after ${transition}`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
const requests = await boundary(page, async (route, path) => {
|
||||
if (path === "/account/notification-preferences") {
|
||||
await respond(route, {
|
||||
email_enabled: false,
|
||||
sms_enabled: false,
|
||||
email_available: true,
|
||||
sms_available: false,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (path === "/client_care/mail/list") {
|
||||
await respond(route, { list: [], count: 0 });
|
||||
return true;
|
||||
}
|
||||
if (path === "/client_care/mail/701") {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
await respond(route, {
|
||||
mail: {
|
||||
id: 701,
|
||||
title: "归属测试消息",
|
||||
content: "<p>本地正文</p>",
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (path === "/client_care/mail/read") {
|
||||
await respond(route, {});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
await login(page, "/messages?id=701");
|
||||
await started.promise;
|
||||
if (transition !== "current") await page.keyboard.press("Escape");
|
||||
if (transition === "leave") {
|
||||
await page
|
||||
.getByRole("navigation", { name: "会员中心导航" })
|
||||
.getByRole("link", { name: "SSH 密钥", exact: true })
|
||||
.click();
|
||||
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
||||
}
|
||||
if (transition === "reauthenticate") await logoutAndSignIn(page);
|
||||
await releaseResponse(page, release, "/client_care/mail/701");
|
||||
const reads = requests.filter(
|
||||
(entry) => entry.path === "/client_care/mail/read",
|
||||
);
|
||||
assert.equal(
|
||||
reads.length,
|
||||
transition === "current" ? 1 : 0,
|
||||
"Only the current detail owner may derive mark-read HTTP",
|
||||
);
|
||||
return {
|
||||
transition,
|
||||
markReadRequests: reads.length,
|
||||
authenticated: reads.every((entry) => entry.authenticated),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const routePath of ["/tickets/create", "/tickets/71"]) {
|
||||
for (const transition of ["current", "leave"]) {
|
||||
test(
|
||||
`ticket attachment queue ${routePath} after ${transition}`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
let uploads = 0;
|
||||
const requests = await boundary(page, async (route, path) => {
|
||||
if (path === "/ticket/config") {
|
||||
await respond(route, { ticket_notice_open: 0 });
|
||||
return true;
|
||||
}
|
||||
if (["/ticket/type", "/ticket/department"].includes(path)) {
|
||||
await respond(route, { list: [{ id: 1, name: "本地支持" }] });
|
||||
return true;
|
||||
}
|
||||
if (path === "/ticket/71") {
|
||||
await respond(route, {
|
||||
ticket: {
|
||||
id: 71,
|
||||
title: "本地工单",
|
||||
content: "<p>本地内容</p>",
|
||||
can_operate: 1,
|
||||
attachment: [],
|
||||
replies: [],
|
||||
create_time: 1700000000,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (path === "/upload") {
|
||||
uploads += 1;
|
||||
if (uploads === 1) {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
await respond(route, {
|
||||
save_name: `local-attachment-${uploads}.txt`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
await login(page, routePath);
|
||||
const input = page.locator('input[type="file"]');
|
||||
await input.setInputFiles(
|
||||
[1, 2].map((id) => ({
|
||||
name: `local-${id}.txt`,
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("non-sensitive local fixture"),
|
||||
})),
|
||||
);
|
||||
await started.promise;
|
||||
if (transition === "leave") {
|
||||
await page
|
||||
.getByRole("navigation", { name: "会员中心导航" })
|
||||
.getByRole("link", { name: "SSH 密钥", exact: true })
|
||||
.click();
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname === "/security/ssh-keys",
|
||||
);
|
||||
}
|
||||
await releaseResponse(page, release, "/upload");
|
||||
assert.equal(
|
||||
uploads,
|
||||
transition === "current" ? 2 : 1,
|
||||
"A departed upload owner must not send the next attachment",
|
||||
);
|
||||
if (transition === "current")
|
||||
await page.getByText("local-2.txt", { exact: true }).waitFor();
|
||||
return {
|
||||
routePath,
|
||||
transition,
|
||||
uploads,
|
||||
authenticated: requests
|
||||
.filter((entry) => entry.path === "/upload")
|
||||
.every((entry) => entry.authenticated),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const transition of ["current", "leave"]) {
|
||||
test(
|
||||
`password reset navigation after ${transition}`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
await boundary(page, async (route, path) => {
|
||||
if (path !== "/account/password_reset") return false;
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
await respond(route, {});
|
||||
return true;
|
||||
});
|
||||
await page.goto(`${appOrigin}/forgot-password`);
|
||||
await page.getByLabel("手机号", { exact: true }).fill("13800138000");
|
||||
await page.getByLabel("验证码 *", { exact: true }).fill("123456");
|
||||
await page
|
||||
.getByLabel("新密码 *", { exact: true })
|
||||
.fill("local-test-password");
|
||||
await page
|
||||
.getByLabel("确认新密码 *", { exact: true })
|
||||
.fill("local-test-password");
|
||||
await page
|
||||
.getByRole("button", { name: "重置密码", exact: true })
|
||||
.click();
|
||||
await started.promise;
|
||||
if (transition === "leave") {
|
||||
await page
|
||||
.getByRole("link", { name: "想起密码了?返回登录", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("link", { name: "注册账号", exact: true })
|
||||
.click();
|
||||
await page.waitForURL((url) => url.pathname === "/register");
|
||||
}
|
||||
await releaseResponse(page, release, "/account/password_reset");
|
||||
assert.equal(
|
||||
new URL(page.url()).pathname,
|
||||
transition === "current" ? "/login" : "/register",
|
||||
"Only the current reset operation may navigate",
|
||||
);
|
||||
return { transition, finalRoute: new URL(page.url()).pathname };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const action of ["options", "submit", "reply", "urge", "close"]) {
|
||||
test(
|
||||
`ticket ${action} cannot derive work after leaving`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
const detail = ["reply", "urge", "close"].includes(action);
|
||||
const heldPath =
|
||||
action === "options"
|
||||
? "/host"
|
||||
: action === "submit"
|
||||
? "/ticket"
|
||||
: `/ticket/71/${action}`;
|
||||
let hostPages = 0;
|
||||
let detailReads = 0;
|
||||
await boundary(page, async (route, path, entry) => {
|
||||
if (path === "/ticket/config") {
|
||||
await respond(route, { ticket_notice_open: 0 });
|
||||
return true;
|
||||
}
|
||||
if (["/ticket/type", "/ticket/department"].includes(path)) {
|
||||
await respond(route, { list: [{ id: 1, name: "本地支持" }] });
|
||||
return true;
|
||||
}
|
||||
if (path === "/ticket/71" && entry.method === "GET") {
|
||||
detailReads += 1;
|
||||
await respond(route, {
|
||||
ticket: {
|
||||
id: 71,
|
||||
title: "本地工单",
|
||||
content: "<p>本地内容</p>",
|
||||
can_operate: 1,
|
||||
attachment: [],
|
||||
replies: [],
|
||||
create_time: 1700000000,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (path !== heldPath) return false;
|
||||
if (action === "options") hostPages += 1;
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
await respond(
|
||||
route,
|
||||
action === "options"
|
||||
? { list: [{ id: 1, name: "本地实例" }], count: 2 }
|
||||
: { id: 71 },
|
||||
);
|
||||
return true;
|
||||
});
|
||||
await login(page, detail ? "/tickets/71" : "/tickets/create");
|
||||
if (action === "submit") {
|
||||
await page
|
||||
.getByPlaceholder("输入工单标题", { exact: true })
|
||||
.fill("本地测试标题");
|
||||
await page
|
||||
.getByRole("textbox", { name: "工单内容", exact: true })
|
||||
.fill("本地测试内容");
|
||||
await page
|
||||
.getByRole("button", { name: "创建工单", exact: true })
|
||||
.click();
|
||||
}
|
||||
if (detail) {
|
||||
await page
|
||||
.getByRole("heading", { name: "本地工单", exact: true })
|
||||
.waitFor();
|
||||
if (action === "reply")
|
||||
await page
|
||||
.getByRole("textbox", { name: "回复内容", exact: true })
|
||||
.fill("本地测试回复");
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: { reply: "提交回复", urge: "催单", close: "关闭工单" }[
|
||||
action
|
||||
],
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
}
|
||||
await started.promise;
|
||||
await page
|
||||
.getByRole("navigation", { name: "会员中心导航" })
|
||||
.getByRole("link", { name: "SSH 密钥", exact: true })
|
||||
.click();
|
||||
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
||||
await releaseResponse(page, release, heldPath);
|
||||
assert.equal(
|
||||
new URL(page.url()).pathname,
|
||||
"/security/ssh-keys",
|
||||
"Old ticket completion must not navigate the current page",
|
||||
);
|
||||
if (action === "options")
|
||||
assert.equal(
|
||||
hostPages,
|
||||
1,
|
||||
"Departed options loader cannot fetch the next host page",
|
||||
);
|
||||
if (detail)
|
||||
assert.equal(
|
||||
detailReads,
|
||||
1,
|
||||
"Departed ticket write cannot derive a detail reload",
|
||||
);
|
||||
return { action, currentRoutePreserved: true, hostPages, detailReads };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const outcome of ["success", "error"]) {
|
||||
test(
|
||||
`SSH save ${outcome} cannot own a reopened editor`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
let saves = 0;
|
||||
await boundary(page, async (route, path, entry) => {
|
||||
if (path !== "/ssh_key" || entry.method !== "POST") return false;
|
||||
saves += 1;
|
||||
if (saves === 1) {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
await respond(
|
||||
route,
|
||||
{},
|
||||
saves === 1 && outcome === "error" ? 422 : 200,
|
||||
);
|
||||
return true;
|
||||
});
|
||||
await login(page);
|
||||
await page.getByText("audit-01", { exact: true }).waitFor();
|
||||
await page
|
||||
.getByRole("button", { name: "添加密钥", exact: true })
|
||||
.click();
|
||||
let editor = page.getByRole("dialog", {
|
||||
name: "添加密钥",
|
||||
exact: true,
|
||||
});
|
||||
await editor.getByLabel("名称 *", { exact: true }).fill("old-draft");
|
||||
await editor
|
||||
.getByLabel("公钥内容 *", { exact: true })
|
||||
.fill("ssh-ed25519 local-old-public-key");
|
||||
await editor.getByRole("button", { name: "保存", exact: true }).click();
|
||||
await started.promise;
|
||||
await editor.getByRole("button", { name: "取消", exact: true }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "添加密钥", exact: true })
|
||||
.click();
|
||||
editor = page.getByRole("dialog", { name: "添加密钥", exact: true });
|
||||
await editor.getByLabel("名称 *", { exact: true }).fill("new-draft");
|
||||
await editor
|
||||
.getByLabel("公钥内容 *", { exact: true })
|
||||
.fill("ssh-ed25519 local-new-public-key");
|
||||
await releaseResponse(page, release, "/ssh_key");
|
||||
assert.equal(
|
||||
await editor.isVisible(),
|
||||
true,
|
||||
"Old completion cannot close the new editor",
|
||||
);
|
||||
assert.equal(
|
||||
await editor.getByLabel("名称 *", { exact: true }).inputValue(),
|
||||
"new-draft",
|
||||
);
|
||||
assert.equal(
|
||||
await editor.getByRole("alert").count(),
|
||||
0,
|
||||
"Old failure cannot become the new draft's error",
|
||||
);
|
||||
await editor.getByRole("button", { name: "保存", exact: true }).click();
|
||||
await editor.waitFor({ state: "hidden" });
|
||||
assert.equal(saves, 2, "The new editor remains saveable");
|
||||
return { outcome, newerDraftPreserved: true, saves };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,658 +0,0 @@
|
||||
/** 购买回归走真实配置页、实例动作和支付界面;只替换已配置后端的 HTTP 响应。 */
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import test from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
eventually,
|
||||
fixtureRequest,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
async function purchaseBoundary(
|
||||
page,
|
||||
{ savedCascade, missingCartRevision = false, rejectCartWrites = 0 } = {},
|
||||
) {
|
||||
const state = {
|
||||
authorized: false,
|
||||
orders: new Map(),
|
||||
created: [],
|
||||
quotes: [],
|
||||
carts: [],
|
||||
settlements: [],
|
||||
requests: [],
|
||||
cartReads: 0,
|
||||
cartRevisions: [],
|
||||
cartWrites: [],
|
||||
cartRows:
|
||||
savedCascade === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
product_id: 123,
|
||||
qty: 1,
|
||||
config_options: {
|
||||
cycle: "free",
|
||||
cascade_configoption: {
|
||||
5: { item_id: savedCascade, quantity: 1 },
|
||||
},
|
||||
},
|
||||
customfield: {},
|
||||
self_defined_field: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
const revision = () =>
|
||||
createHash("sha256").update(JSON.stringify(state.cartRows)).digest("hex");
|
||||
const tree = [
|
||||
{
|
||||
item_name: "隐藏区域",
|
||||
hidden: 1,
|
||||
children: [{ item_id: 10, item_name: "不应售出的节点", is_leaf: 1 }],
|
||||
},
|
||||
{
|
||||
item_name: "常规区域",
|
||||
children: [
|
||||
{ item_id: 20, item_name: "可选节点", is_leaf: 1 },
|
||||
{ item_id: 21, item_name: "隐藏节点", is_leaf: 1, hidden: 1 },
|
||||
],
|
||||
},
|
||||
{ item_id: 30, item_name: "备用区域", is_leaf: 1 },
|
||||
];
|
||||
// 拦截点与生产 apiBase 一致;非本场景接口继续交给现有独立 HTTP 假后端。
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace(
|
||||
/^\/console\/v1/,
|
||||
"",
|
||||
);
|
||||
const method = request.method();
|
||||
const body = request.postDataJSON();
|
||||
const reply = (data, extra = {}) =>
|
||||
route.fulfill({
|
||||
json: { status: 200, msg: "", data, ...extra },
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
const record = () =>
|
||||
state.requests.push({
|
||||
method,
|
||||
path,
|
||||
authenticated: !!request.headers().authorization,
|
||||
});
|
||||
const respond = (data, extra) => {
|
||||
record();
|
||||
return reply(data, extra);
|
||||
};
|
||||
if (path === "/host/5")
|
||||
return respond({
|
||||
host: {
|
||||
id: 5,
|
||||
product_id: 123,
|
||||
module: "mf_cloud",
|
||||
name: "本地镜像购买实例",
|
||||
status: "Active",
|
||||
billing_cycle: "monthly",
|
||||
upgrade_allowed: false,
|
||||
},
|
||||
});
|
||||
if (path === "/host/5/view") return respond({ module: "mf_cloud" });
|
||||
if (path === "/mf_cloud/5")
|
||||
return respond({
|
||||
image: { id: 6, name: "Ubuntu 24.04" },
|
||||
port: 22,
|
||||
username: "root",
|
||||
cpu: 2,
|
||||
memory: 4,
|
||||
config: { reinstall_sms_verify: 0, reset_password_sms_verify: 0 },
|
||||
});
|
||||
if (path === "/mf_cloud/5/status")
|
||||
return respond({ status: "on", rescue: false });
|
||||
if (path === "/mf_cloud/5/disk") return respond({ list: [] });
|
||||
if (path === "/mf_cloud/5/flow")
|
||||
return respond({ total: 100, used: 0, leave: 100 });
|
||||
if (path === "/mf_cloud/5/real_data")
|
||||
return respond({ cpu: 0, memory: 0, disk: 0 });
|
||||
if (path === "/mf_cloud/5/chart") return respond({ list: [] });
|
||||
if (path === "/product/123/mf_cloud/image")
|
||||
return respond({
|
||||
list: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Ubuntu",
|
||||
image: [
|
||||
{
|
||||
id: 6,
|
||||
name: "Ubuntu 24.04",
|
||||
image_group_id: 1,
|
||||
charge: 1,
|
||||
price: "10.00",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === "/mf_cloud/5/image/check")
|
||||
return respond({ price: state.authorized ? "0.00" : "10.00" });
|
||||
if (path === "/mf_cloud/5/image/order") {
|
||||
const id = 42 + state.created.length;
|
||||
state.created.push(id);
|
||||
state.orders.set(id, "Unpaid");
|
||||
return respond({ id });
|
||||
}
|
||||
if (path === "/mf_cloud/5/reinstall") return respond({});
|
||||
const order = /^\/order\/(\d+)$/.exec(path);
|
||||
if (order) {
|
||||
const id = Number(order[1]);
|
||||
if (method === "DELETE") {
|
||||
state.orders.set(id, "Cancelled");
|
||||
return respond({});
|
||||
}
|
||||
const status = state.orders.get(id) ?? "Unpaid";
|
||||
return respond({
|
||||
order: {
|
||||
id,
|
||||
amount: "10.00",
|
||||
amount_unpaid: status === "Paid" ? "0.00" : "10.00",
|
||||
credit: "0.00",
|
||||
type: "new",
|
||||
status,
|
||||
items: [{ id: 1, product_name: "系统镜像", amount: "10.00" }],
|
||||
},
|
||||
customfields: { is_pre_invoice: 0 },
|
||||
});
|
||||
}
|
||||
if (/^\/order\/\d+\/transaction_record$/.test(path))
|
||||
return respond({ list: [] });
|
||||
if (path === "/pay") {
|
||||
state.orders.set(Number(body.id), "Paid");
|
||||
return respond({}, { code: "Paid" });
|
||||
}
|
||||
if (path === "/product/123")
|
||||
return respond({
|
||||
product: {
|
||||
id: 123,
|
||||
name: "本地级联配置",
|
||||
module: "idcsmart_common",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
if (path === "/idcsmart_common/product/123/configoption")
|
||||
return respond({
|
||||
common_product: { pay_type: "free" },
|
||||
configoptions: [
|
||||
{
|
||||
id: 5,
|
||||
option_name: "区域",
|
||||
option_type: "cascade",
|
||||
is_show: 1,
|
||||
tree,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === "/product/123/self_defined_field/order_page")
|
||||
return respond({ data: [] });
|
||||
if (path === "/purchase_limit/check")
|
||||
return respond({ allowed: true, message: "" });
|
||||
if (path === "/product/123/config_option") {
|
||||
state.quotes.push(body);
|
||||
return respond({ price_total: "0.00" });
|
||||
}
|
||||
if (path === "/cart" && method === "POST") {
|
||||
state.carts.push(body);
|
||||
return respond({});
|
||||
}
|
||||
if (path === "/cart" && method === "GET") {
|
||||
state.cartReads++;
|
||||
state.cartRevisions.push(revision());
|
||||
return respond({
|
||||
list: state.cartRows.map((row) => ({ ...row, name: "本地级联配置" })),
|
||||
...(missingCartRevision ? {} : { revision: revision() }),
|
||||
});
|
||||
}
|
||||
if (method === "PUT" && /^\/cart\/\d+(\/qty)?$/.test(path)) {
|
||||
const currentRevision = revision();
|
||||
state.cartWrites.push({
|
||||
method,
|
||||
path,
|
||||
body,
|
||||
cartReads: state.cartReads,
|
||||
revision: currentRevision,
|
||||
});
|
||||
const status =
|
||||
rejectCartWrites ||
|
||||
(!body?.expected_revision
|
||||
? 428
|
||||
: body.expected_revision !== currentRevision
|
||||
? 409
|
||||
: 200);
|
||||
if (status !== 200)
|
||||
return respond({}, { status, msg: "购物车状态待重新确认" });
|
||||
const position = Number(path.split("/")[2]);
|
||||
state.cartRows[position] = path.endsWith("/qty")
|
||||
? { ...state.cartRows[position], qty: body.qty }
|
||||
: {
|
||||
product_id: body.product_id,
|
||||
qty: body.qty,
|
||||
config_options: body.config_options,
|
||||
customfield: body.customfield,
|
||||
self_defined_field: body.self_defined_field,
|
||||
};
|
||||
return respond({ revision: revision() });
|
||||
}
|
||||
if (path === "/product/settle") {
|
||||
state.settlements.push(body);
|
||||
return respond({ order_id: 42, amount: "0.00" });
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
test(
|
||||
"image retry reuses unpaid orders and replaces only a cancelled order",
|
||||
{ timeout: 60000 },
|
||||
async (t) => {
|
||||
await scenario(t, {}, async (page) => {
|
||||
const state = await purchaseBoundary(page);
|
||||
await login(page, "/servers/server?id=5");
|
||||
await page.getByRole("tab", { name: "访问控制", exact: true }).click();
|
||||
await page.getByRole("button", { name: "重装系统", exact: true }).click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "重装系统", exact: true })
|
||||
.getByRole("button", { name: "确认", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "确认重装系统", exact: true })
|
||||
.getByRole("button", { name: "确认重装", exact: true })
|
||||
.click();
|
||||
const purchase = page.getByRole("dialog", {
|
||||
name: "购买系统镜像",
|
||||
exact: true,
|
||||
});
|
||||
const continuePurchase = () =>
|
||||
purchase
|
||||
.getByRole("button", { name: "确认购买 / 继续支付", exact: true })
|
||||
.click();
|
||||
const payment = page.getByRole("dialog", {
|
||||
name: "订单支付",
|
||||
exact: true,
|
||||
});
|
||||
const closePayment = async () => {
|
||||
await payment.getByRole("button", { name: /^(关闭|Close)$/ }).click();
|
||||
await payment.waitFor({ state: "hidden" });
|
||||
};
|
||||
await continuePurchase();
|
||||
await payment.waitFor();
|
||||
await closePayment();
|
||||
await continuePurchase();
|
||||
await payment.waitFor();
|
||||
assert.deepEqual(
|
||||
state.created,
|
||||
[42],
|
||||
"Retrying an unpaid order must not create a second order",
|
||||
);
|
||||
await closePayment();
|
||||
|
||||
// 另一标签使用真实订单取消入口,原实例页保持挂载,不用刷新掩盖缓存生命周期。
|
||||
const orderPage = await page.context().newPage();
|
||||
await orderPage.goto(`${appOrigin}/orders/42`);
|
||||
await orderPage
|
||||
.getByRole("button", { name: "取消订单", exact: true })
|
||||
.click();
|
||||
await orderPage
|
||||
.getByRole("dialog", { name: "取消订单", exact: true })
|
||||
.getByRole("button", { name: "确认取消", exact: true })
|
||||
.click();
|
||||
await eventually(
|
||||
() => state.orders.get(42),
|
||||
(status) => status === "Cancelled",
|
||||
"Second tab must cancel the first order",
|
||||
);
|
||||
await orderPage.close();
|
||||
await continuePurchase();
|
||||
await eventually(
|
||||
() => state.created.length,
|
||||
(count) => count === 2,
|
||||
"Cancelled cached order must be replaced without reloading the instance",
|
||||
);
|
||||
await payment.waitFor();
|
||||
assert.deepEqual(state.created, [42, 43]);
|
||||
await payment
|
||||
.getByRole("button", { name: "确认支付", exact: true })
|
||||
.click();
|
||||
await eventually(
|
||||
() => state.orders.get(43),
|
||||
(status) => status === "Paid",
|
||||
"Payment must complete through the real payment panel",
|
||||
);
|
||||
await purchase.waitFor();
|
||||
await purchase
|
||||
.getByText("镜像授权尚未生效,请稍后重新检查", { exact: true })
|
||||
.waitFor();
|
||||
const paidRetryMessage = purchase.getByText(
|
||||
"镜像订单已支付,授权尚未生效,请稍后重新检查",
|
||||
{ exact: true },
|
||||
);
|
||||
assert.equal(await paidRetryMessage.count(), 0);
|
||||
const readsBeforePaidRetry = state.requests.filter(
|
||||
(request) => request.method === "GET" && request.path === "/order/43",
|
||||
).length;
|
||||
await continuePurchase();
|
||||
// 本次 GET 增量和新文案共同证明重试已完成,不能沿用支付回调的旧提示或按钮状态。
|
||||
await eventually(
|
||||
() =>
|
||||
state.requests.filter(
|
||||
(request) =>
|
||||
request.method === "GET" && request.path === "/order/43",
|
||||
).length,
|
||||
(count) => count > readsBeforePaidRetry,
|
||||
"Paid order retry must fetch fresh server state",
|
||||
);
|
||||
await paidRetryMessage.waitFor();
|
||||
assert.deepEqual(
|
||||
state.created,
|
||||
[42, 43],
|
||||
"Paid but not-yet-authorized orders must not create another order",
|
||||
);
|
||||
assert.equal(await payment.isVisible(), false);
|
||||
state.authorized = true;
|
||||
await continuePurchase();
|
||||
await purchase.waitFor({ state: "hidden" });
|
||||
assert.deepEqual(state.created, [42, 43]);
|
||||
assert.equal(
|
||||
state.requests.filter((request) => request.path.endsWith("/reinstall"))
|
||||
.length,
|
||||
0,
|
||||
"Paying for an image must not automatically reinstall the instance",
|
||||
);
|
||||
assert.ok(state.requests.every((request) => request.authenticated));
|
||||
assert.ok(
|
||||
(await fixtureRequest("requests")).some(
|
||||
(request) => request.path === "/login",
|
||||
),
|
||||
);
|
||||
return {
|
||||
unpaidOrderReused: true,
|
||||
cancelledOrderReplaced: true,
|
||||
paidOrderRetainedUntilAuthorized: true,
|
||||
createdOrders: state.created.length,
|
||||
automaticReinstalls: 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"cascade defaults, visible choices and submitted values agree",
|
||||
{ timeout: 40000 },
|
||||
async (t) => {
|
||||
await scenario(t, {}, async (page) => {
|
||||
const state = await purchaseBoundary(page);
|
||||
await login(page, "/products/123");
|
||||
await eventually(
|
||||
() => state.quotes.length,
|
||||
(count) => count > 0,
|
||||
"Configuration must receive a real quote",
|
||||
);
|
||||
assert.equal(
|
||||
state.quotes.at(-1).config_options.cascade_configoption[5].item_id,
|
||||
20,
|
||||
"Default must skip descendants of hidden ancestors",
|
||||
);
|
||||
const region = page.getByRole("combobox", { name: "区域", exact: true });
|
||||
assert.match(await region.innerText(), /常规区域.*可选节点/);
|
||||
await region.click();
|
||||
const labels = await page.getByRole("option").allTextContents();
|
||||
assert.equal(labels.length, 2);
|
||||
assert.ok(
|
||||
labels.every(
|
||||
(label) => !label.includes("隐藏") && !label.includes("不应售出"),
|
||||
),
|
||||
);
|
||||
const quotesBeforeSelection = state.quotes.length;
|
||||
await page.getByRole("option", { name: "备用区域", exact: true }).click();
|
||||
// 改选非默认项并等本轮新报价;选择处理若失效,不能沿用默认 20 的报价假绿。
|
||||
await eventually(
|
||||
() =>
|
||||
state.quotes
|
||||
.slice(quotesBeforeSelection)
|
||||
.some(
|
||||
(quote) =>
|
||||
quote.config_options.cascade_configoption[5].item_id === 30,
|
||||
),
|
||||
Boolean,
|
||||
"Selecting a non-default visible candidate must request a fresh quote",
|
||||
);
|
||||
assert.match(await region.innerText(), /备用区域/);
|
||||
await page
|
||||
.getByRole("button", { name: "加入购物车", exact: true })
|
||||
.click();
|
||||
await eventually(
|
||||
() => state.carts.length,
|
||||
(count) => count === 1,
|
||||
"Cart must receive the selected visible candidate",
|
||||
);
|
||||
assert.equal(
|
||||
state.carts[0].config_options.cascade_configoption[5].item_id,
|
||||
30,
|
||||
);
|
||||
await page.getByRole("button", { name: "立即购买", exact: true }).click();
|
||||
await eventually(
|
||||
() => state.settlements.length,
|
||||
(count) => count === 1,
|
||||
"Checkout must submit the same visible candidate",
|
||||
);
|
||||
assert.equal(
|
||||
state.settlements[0].config_options.cascade_configoption[5].item_id,
|
||||
30,
|
||||
);
|
||||
return {
|
||||
visibleChoices: labels.length,
|
||||
defaultVisibleItem: 20,
|
||||
cartVisibleItem: 30,
|
||||
checkoutVisibleItem: 30,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"cart editing does not restore a hidden cascade descendant",
|
||||
{ timeout: 40000 },
|
||||
async (t) => {
|
||||
await scenario(t, {}, async (page) => {
|
||||
const state = await purchaseBoundary(page, { savedCascade: 10 });
|
||||
await login(page, "/products/123?edit_position=0");
|
||||
await page
|
||||
.getByRole("button", { name: "重新加载配置", exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
state.quotes.length,
|
||||
0,
|
||||
"An unavailable saved candidate must not receive a valid quote",
|
||||
);
|
||||
assert.equal(state.settlements.length, 0);
|
||||
assert.equal(state.carts.length, 0);
|
||||
return {
|
||||
hiddenSavedCandidateRejected: true,
|
||||
quotesForHiddenCandidate: 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"cart configuration keeps the original revision without fetching a replacement before saving",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const state = await purchaseBoundary(page, { savedCascade: 20 });
|
||||
await login(page, "/products/123?edit_position=0");
|
||||
const save = page.getByRole("button", { name: "保存配置", exact: true });
|
||||
await eventually(
|
||||
() => save.isEnabled(),
|
||||
Boolean,
|
||||
"编辑配置必须先取得有效报价",
|
||||
);
|
||||
await save.click();
|
||||
await eventually(
|
||||
() => state.cartWrites.length,
|
||||
(count) => count === 1,
|
||||
"配置只能提交一次",
|
||||
);
|
||||
const write = state.cartWrites[0];
|
||||
assert.equal(write.body.expected_revision, write.revision);
|
||||
assert.equal(write.cartReads, 1);
|
||||
assert.equal(Object.hasOwn(write.body, "expected_item"), false);
|
||||
assert.equal(Object.hasOwn(write.body, "expected_cart"), false);
|
||||
await page.waitForURL((url) => url.pathname === "/cart");
|
||||
return {
|
||||
originalRevisionSubmitted: true,
|
||||
readsBeforeSave: write.cartReads,
|
||||
configurationWrites: 1,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
for (const status of [409, 428]) {
|
||||
test(
|
||||
`cart editing stops after server ${status} and requires returning to the cart`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const state = await purchaseBoundary(page, {
|
||||
savedCascade: 20,
|
||||
rejectCartWrites: status === 428 ? status : 0,
|
||||
});
|
||||
await login(page, "/products/123?edit_position=0");
|
||||
const save = page.getByRole("button", {
|
||||
name: "保存配置",
|
||||
exact: true,
|
||||
});
|
||||
await eventually(
|
||||
() => save.isEnabled(),
|
||||
Boolean,
|
||||
"编辑配置必须先取得有效报价",
|
||||
);
|
||||
const originalRevision = state.cartRevisions[0];
|
||||
// 另一会话只改动兄弟项,当前商品仍相同,也不能把新版本套在旧位置上。
|
||||
if (status === 409)
|
||||
state.cartRows.push({ ...state.cartRows[0], qty: 2 });
|
||||
await save.click();
|
||||
await eventually(
|
||||
() => state.cartWrites.length,
|
||||
(count) => count === 1,
|
||||
"冲突只允许一次写入尝试",
|
||||
);
|
||||
await page.locator(".p-message-error").waitFor();
|
||||
assert.equal(new URL(page.url()).pathname, "/products/123");
|
||||
assert.equal(state.cartReads, 1);
|
||||
assert.equal(
|
||||
state.cartWrites[0].body.expected_revision,
|
||||
originalRevision,
|
||||
);
|
||||
if (status === 409)
|
||||
assert.notEqual(state.cartWrites[0].revision, originalRevision);
|
||||
await page
|
||||
.locator(".product-links")
|
||||
.getByRole("link", { name: "购物车", exact: true })
|
||||
.click();
|
||||
await page.waitForURL((url) => url.pathname === "/cart");
|
||||
await eventually(
|
||||
() => state.cartReads,
|
||||
(count) => count === 2,
|
||||
"返回购物车后重新读取可确认的列表",
|
||||
);
|
||||
assert.equal(state.cartWrites.length, 1);
|
||||
return {
|
||||
rejectedStatus: status,
|
||||
configurationWrites: 1,
|
||||
readsOnlyAfterReturning: 2,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
`cart server ${status} clears the selection without retrying a positional mutation`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const state = await purchaseBoundary(page, {
|
||||
savedCascade: 20,
|
||||
rejectCartWrites: status,
|
||||
});
|
||||
await login(page, "/cart");
|
||||
const selected = page.getByRole("checkbox", {
|
||||
name: "选择 本地级联配置",
|
||||
exact: true,
|
||||
});
|
||||
await selected.waitFor();
|
||||
assert.equal(await selected.isChecked(), true);
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: "增加 本地级联配置 的数量",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await eventually(
|
||||
() => state.cartWrites.length,
|
||||
(count) => count === 1,
|
||||
"修改数量只提交一次",
|
||||
);
|
||||
await eventually(
|
||||
() => selected.isChecked(),
|
||||
(checked) => !checked,
|
||||
"冲突后必须重新选择商品确认",
|
||||
);
|
||||
assert.equal(
|
||||
state.cartWrites[0].body.expected_revision,
|
||||
state.cartWrites[0].revision,
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(state.cartWrites[0].body, "expected_cart"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole("button", { name: "提交订单", exact: true })
|
||||
.isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(state.cartWrites.length, 1);
|
||||
return {
|
||||
rejectedStatus: status,
|
||||
mutationAttempts: 1,
|
||||
selectionCleared: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"a cart response without a revision offers reload and cannot mutate old positions",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const state = await purchaseBoundary(page, {
|
||||
savedCascade: 20,
|
||||
missingCartRevision: true,
|
||||
});
|
||||
await login(page, "/cart");
|
||||
await page
|
||||
.getByRole("button", { name: "重新加载", exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await page.locator(".cart-item").count(), 0);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole("button", { name: "提交订单", exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(state.cartWrites.length, 0);
|
||||
return { missingRevisionBlocked: true, mutationAttempts: 0 };
|
||||
}),
|
||||
);
|
||||
@@ -1,536 +0,0 @@
|
||||
/**
|
||||
* 通过真实页面操作补齐安全规则输入边界与旧工单查询路由的回归场景。
|
||||
* 仅在已配置的本地 HTTP API 边界提供响应,路由、组件、会话和表单均使用生产实现。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
eventually,
|
||||
fixtureRequest,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
async function fulfillApi(route, data) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "Access-Control-Allow-Origin": appOrigin },
|
||||
body: JSON.stringify({ status: 200, msg: "", data }),
|
||||
});
|
||||
}
|
||||
|
||||
async function securityGroupBoundary(page) {
|
||||
const writes = [];
|
||||
const group = {
|
||||
id: 1,
|
||||
name: "审计安全组",
|
||||
description: "本地规则输入验收",
|
||||
rule_num: 0,
|
||||
host_num: 0,
|
||||
create_time: 1730000000,
|
||||
};
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === backendOrigin &&
|
||||
url.pathname.startsWith("/console/v1/security_group"),
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace("/console/v1", "");
|
||||
if (request.method() === "GET" && path === "/security_group")
|
||||
return fulfillApi(route, { list: [group], count: 1 });
|
||||
if (request.method() === "GET" && path === "/security_group/1/rule")
|
||||
return fulfillApi(route, { list: [], count: 0 });
|
||||
if (
|
||||
request.method() === "POST" &&
|
||||
path === "/security_group/1/rule/batch"
|
||||
) {
|
||||
// 只保留本场景的规则内容与认证布尔值,不把请求头或登录凭证写入验收产物。
|
||||
const body = request.postDataJSON();
|
||||
writes.push({
|
||||
path,
|
||||
body,
|
||||
authenticated: !!request.headers().authorization,
|
||||
});
|
||||
return fulfillApi(route, { success_num: body.rule.length });
|
||||
}
|
||||
return route.fallback();
|
||||
},
|
||||
);
|
||||
return writes;
|
||||
}
|
||||
|
||||
async function openBatchEditor(page) {
|
||||
await page.getByRole("button", { name: "快速添加", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByText("高级规则编辑", { exact: true }).click();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
test(
|
||||
"security group batches preserve normalized inbound and outbound directions",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, { loginMode: "password-only" }, async (page) => {
|
||||
const writes = await securityGroupBoundary(page);
|
||||
await login(page, "/security/groups/group?id=1");
|
||||
const dialog = await openBatchEditor(page);
|
||||
await dialog
|
||||
.locator("textarea")
|
||||
.fill(
|
||||
[
|
||||
"in tcp 22 0.0.0.0/0 inbound-lowercase",
|
||||
"out tcp 443 0.0.0.0/0 outbound-lowercase",
|
||||
"IN TCP 80 0.0.0.0/0 inbound-uppercase",
|
||||
"OUT UDP 53 0.0.0.0/0 outbound-uppercase",
|
||||
].join("\n"),
|
||||
);
|
||||
await dialog.getByRole("button", { name: "添加", exact: true }).click();
|
||||
await dialog.waitFor({ state: "hidden" });
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0].authenticated, true);
|
||||
assert.deepEqual(writes[0].body.rule, [
|
||||
{
|
||||
direction: "in",
|
||||
protocol: "tcp",
|
||||
port: "22",
|
||||
ip: "0.0.0.0/0",
|
||||
description: "inbound-lowercase",
|
||||
},
|
||||
{
|
||||
direction: "out",
|
||||
protocol: "tcp",
|
||||
port: "443",
|
||||
ip: "0.0.0.0/0",
|
||||
description: "outbound-lowercase",
|
||||
},
|
||||
{
|
||||
direction: "in",
|
||||
protocol: "tcp",
|
||||
port: "80",
|
||||
ip: "0.0.0.0/0",
|
||||
description: "inbound-uppercase",
|
||||
},
|
||||
{
|
||||
direction: "out",
|
||||
protocol: "udp",
|
||||
port: "53",
|
||||
ip: "0.0.0.0/0",
|
||||
description: "outbound-uppercase",
|
||||
},
|
||||
]);
|
||||
return {
|
||||
acceptedDirections: writes[0].body.rule.map((rule) => rule.direction),
|
||||
acceptedProtocols: writes[0].body.rule.map((rule) => rule.protocol),
|
||||
submittedBatches: writes.length,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// 每种拒绝输入使用全新的浏览器上下文;上一轮的错误提示不能冒充本轮已处理完成。
|
||||
for (const [name, input] of [
|
||||
["unknown direction", "ot tcp 22 0.0.0.0/0 misspelled-direction"],
|
||||
["unknown protocol", "in tcq 22 0.0.0.0/0 misspelled-protocol"],
|
||||
[
|
||||
"a valid rule followed by an unknown direction",
|
||||
"out tcp 443 0.0.0.0/0 valid-first\not tcp 22 0.0.0.0/0 invalid-second",
|
||||
],
|
||||
]) {
|
||||
test(
|
||||
`security group batches reject ${name} without partial writes`,
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, { loginMode: "password-only" }, async (page) => {
|
||||
const writes = await securityGroupBoundary(page);
|
||||
await login(page, "/security/groups/group?id=1");
|
||||
const dialog = await openBatchEditor(page);
|
||||
// 弹窗本来就有 info 级使用说明;只有错误级别的可见提示才表示本次校验失败。
|
||||
const errorAlert = dialog.locator('[role="alert"].p-message-error');
|
||||
assert.equal(await errorAlert.count(), 0);
|
||||
await dialog.locator("textarea").fill(input);
|
||||
await dialog.getByRole("button", { name: "添加", exact: true }).click();
|
||||
// 等到可观察的拒绝或错误下发,不用休眠长度推断“没有发请求”。
|
||||
await eventually(
|
||||
async () => writes.length > 0 || (await errorAlert.isVisible()),
|
||||
Boolean,
|
||||
"Invalid rules must produce a form error or expose an unexpected write",
|
||||
);
|
||||
assert.equal(
|
||||
writes.length,
|
||||
0,
|
||||
"Unknown enums must never reach the API",
|
||||
);
|
||||
assert.equal(await dialog.isVisible(), true);
|
||||
assert.equal(await errorAlert.isVisible(), true);
|
||||
return { rejectedInput: name, submittedBatches: 0 };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"legacy ticket menu query navigation resets drafts and writes only the selected ticket",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, { loginMode: "password-only" }, async (page) => {
|
||||
const requests = [];
|
||||
const tickets = new Map(
|
||||
[1, 2].map((id) => [
|
||||
id,
|
||||
{
|
||||
id,
|
||||
ticket_num: `AUDIT-${id}`,
|
||||
title: `审计工单${id}`,
|
||||
content: `<p>工单${id}内容</p>`,
|
||||
attachment: [],
|
||||
replies: [],
|
||||
status: "待回复",
|
||||
can_operate: 1,
|
||||
create_time: 1730000000,
|
||||
username: "audit-user",
|
||||
client_id: 1,
|
||||
color: "#777",
|
||||
},
|
||||
]),
|
||||
);
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === backendOrigin &&
|
||||
(url.pathname === "/console/v1/menu" ||
|
||||
url.pathname.startsWith("/console/v1/ticket/")),
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace(
|
||||
"/console/v1",
|
||||
"",
|
||||
);
|
||||
if (path === "/menu")
|
||||
return fulfillApi(route, {
|
||||
menu: [1, 2].map((id) => ({
|
||||
id,
|
||||
name: `打开工单${id}`,
|
||||
menu_type: "custom",
|
||||
url: `ticketDetails.htm?id=${id}`,
|
||||
})),
|
||||
});
|
||||
if (path === "/ticket/config")
|
||||
return fulfillApi(route, { ticket_notice_open: 0 });
|
||||
const match = path.match(/^\/ticket\/(1|2)(?:\/(reply|close))?$/);
|
||||
if (!match) return route.fallback();
|
||||
const ticket = tickets.get(Number(match[1]));
|
||||
requests.push({
|
||||
method: request.method(),
|
||||
path,
|
||||
authenticated: !!request.headers().authorization,
|
||||
});
|
||||
if (request.method() === "GET" && !match[2])
|
||||
return fulfillApi(route, { ticket });
|
||||
if (request.method() === "POST" && match[2] === "reply") {
|
||||
ticket.replies.push({
|
||||
id: 10,
|
||||
content: request.postDataJSON().content,
|
||||
attachment: [],
|
||||
create_time: 1730000001,
|
||||
type: "Client",
|
||||
client_name: "audit-user",
|
||||
client_id: 1,
|
||||
quote_reply_id: 0,
|
||||
quote_info: null,
|
||||
});
|
||||
return fulfillApi(route, {});
|
||||
}
|
||||
if (request.method() === "PUT" && match[2] === "close") {
|
||||
// 展示状态允许后台自定义,操作权限由独立字段收回。
|
||||
ticket.status = "处理完毕";
|
||||
ticket.can_operate = 0;
|
||||
return fulfillApi(route, {});
|
||||
}
|
||||
return route.fallback();
|
||||
},
|
||||
);
|
||||
await login(page, "/tickets/ticket?id=1");
|
||||
await page
|
||||
.getByRole("heading", { name: "审计工单1", exact: true })
|
||||
.waitFor();
|
||||
const editor = page.getByRole("textbox", {
|
||||
name: "回复内容",
|
||||
exact: true,
|
||||
});
|
||||
await editor.fill("第一张工单尚未提交的草稿");
|
||||
// 使用真正的 Nuxt 菜单链接更换 query,不访问组件实例,也不以整页刷新掩盖复用错误。
|
||||
await page
|
||||
.getByRole("navigation", { name: "会员中心导航" })
|
||||
.getByRole("link", { name: "打开工单2", exact: true })
|
||||
.click();
|
||||
await page.waitForURL(
|
||||
(url) =>
|
||||
url.origin === appOrigin &&
|
||||
url.pathname === "/tickets/ticket" &&
|
||||
url.searchParams.get("id") === "2",
|
||||
);
|
||||
await page
|
||||
.getByRole("heading", { name: "审计工单2", exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await editor.inputValue(), "");
|
||||
await editor.fill("第二张工单的回复");
|
||||
await page.getByRole("button", { name: "提交回复", exact: true }).click();
|
||||
await eventually(
|
||||
() => requests.filter((request) => request.method === "POST"),
|
||||
(writes) => writes.length === 1,
|
||||
"The reply must be sent to the selected ticket",
|
||||
);
|
||||
await page.getByRole("button", { name: "关闭工单", exact: true }).click();
|
||||
await eventually(
|
||||
() => requests.filter((request) => request.method === "PUT"),
|
||||
(writes) => writes.length === 1,
|
||||
"The close action must be sent to the selected ticket",
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "关闭工单", exact: true })
|
||||
.waitFor({ state: "hidden" });
|
||||
assert.equal(await editor.isEditable(), false);
|
||||
assert.equal(await page.locator("#reply-files").isDisabled(), true);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole("button", { name: "提交回复", exact: true })
|
||||
.isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByRole("button", { name: "引用", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByRole("button", { name: "催单", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
const writes = requests.filter((request) => request.method !== "GET");
|
||||
assert.deepEqual(
|
||||
writes.map(({ method, path }) => ({ method, path })),
|
||||
[
|
||||
{ method: "POST", path: "/ticket/2/reply" },
|
||||
{ method: "PUT", path: "/ticket/2/close" },
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
requests.every((request) => request.authenticated),
|
||||
true,
|
||||
);
|
||||
assert.equal(tickets.get(1).status, "待回复");
|
||||
assert.equal(
|
||||
(await fixtureRequest("requests")).filter(
|
||||
(request) => request.path === "/login",
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
return {
|
||||
selectedTicketId: 2,
|
||||
previousDraftDiscarded: true,
|
||||
writes: writes.map(({ method, path }) => ({ method, path })),
|
||||
previousTicketUnchanged: true,
|
||||
permissionRevocationDisablesActions: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// 补齐既有工单场景的权限空隙:只读工单在动态详情和旧菜单路由中都不能从展示文案推断可操作。
|
||||
for (const [routePath, status] of [
|
||||
["/tickets/2", "处理中"],
|
||||
["/tickets/ticket?id=2", "服务已结束"],
|
||||
]) {
|
||||
test(
|
||||
`ticket can_operate denies writes at ${routePath} with custom display status`,
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, { loginMode: "password-only" }, async (page) => {
|
||||
const writes = [];
|
||||
const ticket = {
|
||||
id: 2,
|
||||
ticket_num: "AUDIT-2",
|
||||
title: "只读工单",
|
||||
content: "<p>历史工单仍可阅读</p>",
|
||||
status,
|
||||
can_operate: 0,
|
||||
create_time: 1730000000,
|
||||
username: "audit-user",
|
||||
client_id: 1,
|
||||
color: "#777",
|
||||
attachment: [],
|
||||
replies: [
|
||||
{
|
||||
id: 10,
|
||||
content: "<p>历史客服回复</p>",
|
||||
attachment: [],
|
||||
create_time: 1730000001,
|
||||
type: "Admin",
|
||||
admin_name: "客服",
|
||||
quote_reply_id: 0,
|
||||
quote_info: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === backendOrigin &&
|
||||
(url.pathname.startsWith("/console/v1/ticket/") ||
|
||||
url.pathname === "/console/v1/upload"),
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace(
|
||||
"/console/v1",
|
||||
"",
|
||||
);
|
||||
if (path === "/ticket/config")
|
||||
return fulfillApi(route, { ticket_notice_open: 0 });
|
||||
if (request.method() === "GET" && path === "/ticket/2")
|
||||
return fulfillApi(route, { ticket });
|
||||
if (["POST", "PUT"].includes(request.method())) {
|
||||
writes.push({ method: request.method(), path });
|
||||
return fulfillApi(route, { save_name: "unused-attachment.txt" });
|
||||
}
|
||||
return route.fallback();
|
||||
},
|
||||
);
|
||||
await login(page, routePath);
|
||||
await page
|
||||
.getByRole("heading", { name: ticket.title, exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.getByText("历史客服回复", { exact: true }).isVisible(),
|
||||
true,
|
||||
);
|
||||
const editor = page.getByRole("textbox", {
|
||||
name: "回复内容",
|
||||
exact: true,
|
||||
});
|
||||
assert.equal(await editor.isEditable(), false);
|
||||
assert.equal(await page.locator("#reply-files").isDisabled(), true);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole("button", { name: "提交回复", exact: true })
|
||||
.isDisabled(),
|
||||
true,
|
||||
);
|
||||
for (const name of ["引用", "催单", "关闭工单"])
|
||||
assert.equal(
|
||||
await page.getByRole("button", { name, exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
// 文件输入收到浏览器 change 事件时,处理函数也必须遵守权限;仅修改 HTTP 外部边界。
|
||||
await page.locator("#reply-files").setInputFiles({
|
||||
name: "read-only-ticket.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("local permission regression"),
|
||||
});
|
||||
assert.deepEqual(writes, []);
|
||||
return {
|
||||
routePath,
|
||||
displayStatus: status,
|
||||
serverCanOperate: ticket.can_operate,
|
||||
historicalRepliesReadable: true,
|
||||
editingAndWritesBlocked: true,
|
||||
writes,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"in-flight ticket uploads stop the remaining files when server permission is revoked",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, { loginMode: "password-only" }, async (page) => {
|
||||
const uploads = [];
|
||||
const ticket = {
|
||||
id: 2,
|
||||
ticket_num: "AUDIT-2",
|
||||
title: "上传期间关闭工单",
|
||||
content: "<p>本机权限并发验收</p>",
|
||||
status: "处理中",
|
||||
can_operate: 1,
|
||||
create_time: 1730000000,
|
||||
username: "audit-user",
|
||||
client_id: 1,
|
||||
color: "#777",
|
||||
attachment: [],
|
||||
replies: [],
|
||||
};
|
||||
let releaseFirstUpload;
|
||||
const firstUploadResponse = new Promise((resolve) => {
|
||||
releaseFirstUpload = resolve;
|
||||
});
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === backendOrigin &&
|
||||
(url.pathname.startsWith("/console/v1/ticket/") ||
|
||||
url.pathname === "/console/v1/upload"),
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace(
|
||||
"/console/v1",
|
||||
"",
|
||||
);
|
||||
if (path === "/ticket/config")
|
||||
return fulfillApi(route, { ticket_notice_open: 0 });
|
||||
if (request.method() === "GET" && path === "/ticket/2")
|
||||
return fulfillApi(route, { ticket });
|
||||
if (request.method() === "PUT" && path === "/ticket/2/close") {
|
||||
ticket.can_operate = 0;
|
||||
ticket.status = "处理完毕";
|
||||
return fulfillApi(route, {});
|
||||
}
|
||||
if (request.method() === "POST" && path === "/upload") {
|
||||
const index = uploads.push({ method: "POST", path });
|
||||
if (index === 1) await firstUploadResponse;
|
||||
return fulfillApi(route, { save_name: `attachment-${index}.txt` });
|
||||
}
|
||||
return route.fallback();
|
||||
},
|
||||
);
|
||||
try {
|
||||
await login(page, "/tickets/2");
|
||||
await page
|
||||
.getByRole("heading", { name: ticket.title, exact: true })
|
||||
.waitFor();
|
||||
await page.locator("#reply-files").setInputFiles(
|
||||
["first.txt", "second.txt"].map((name) => ({
|
||||
name,
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("local permission regression"),
|
||||
})),
|
||||
);
|
||||
await eventually(
|
||||
() => uploads.length,
|
||||
(count) => count === 1,
|
||||
"First upload reached the local HTTP boundary",
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "关闭工单", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("button", { name: "关闭工单", exact: true })
|
||||
.waitFor({ state: "hidden" });
|
||||
releaseFirstUpload();
|
||||
await page
|
||||
.getByText("正在上传…", { exact: true })
|
||||
.waitFor({ state: "hidden" });
|
||||
assert.equal(
|
||||
uploads.length,
|
||||
1,
|
||||
"Revoked permission must stop the second upload",
|
||||
);
|
||||
assert.equal(await page.locator(".reply-file-list").count(), 0);
|
||||
return {
|
||||
selectedFiles: 2,
|
||||
uploadsStarted: uploads.length,
|
||||
serverCanOperate: ticket.can_operate,
|
||||
alreadyStartedUploadCompleted: true,
|
||||
remainingUploadsStopped: true,
|
||||
revokedUploadNotAddedToDraft: true,
|
||||
};
|
||||
} finally {
|
||||
releaseFirstUpload();
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,65 +0,0 @@
|
||||
/** 浏览器防护由真实应用响应提供;不读取配置源码,也不接触真实凭证。 */
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
test("Application routes consistently provide browser trust boundaries", (t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.goto(`${appOrigin}/login`);
|
||||
const routes = ["/", "/login", "/account/realname", "/security/ssh-keys"];
|
||||
for (const path of routes) {
|
||||
const response = await fetch(`${appOrigin}${path}`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("x-content-type-options"), "nosniff");
|
||||
assert.equal(response.headers.get("referrer-policy"), "no-referrer");
|
||||
assert.equal(response.headers.get("x-frame-options"), "SAMEORIGIN");
|
||||
const policy = new Map(
|
||||
(response.headers.get("content-security-policy") || "")
|
||||
.split(";")
|
||||
.map((directive) => directive.trim().split(/\s+/))
|
||||
.filter(([name]) => name)
|
||||
.map(([name, ...values]) => [name, values]),
|
||||
);
|
||||
assert.deepEqual(policy.get("frame-ancestors"), ["'self'"]);
|
||||
assert.deepEqual(policy.get("object-src"), ["'none'"]);
|
||||
assert.deepEqual(policy.get("base-uri"), ["'self'"]);
|
||||
}
|
||||
return { routes, consistentFramingMimeAndReferrerDefenses: true };
|
||||
}));
|
||||
|
||||
test("Application navigation sends no referrer to resource servers", (t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.goto(`${appOrigin}/login?return_hint=private-test-marker`);
|
||||
for (const origin of [appOrigin, backendOrigin]) {
|
||||
const pixel = `${origin}/privacy-pixel.svg`;
|
||||
let observe;
|
||||
const seen = new Promise((resolve) => (observe = resolve));
|
||||
await page.context().route(pixel, async (route) => {
|
||||
const headers = await route.request().allHeaders();
|
||||
observe(Boolean(headers.referer));
|
||||
await route.fulfill({
|
||||
contentType: "image/svg+xml",
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>',
|
||||
});
|
||||
});
|
||||
await page.evaluate((src) => {
|
||||
const resource = document.createElement("img");
|
||||
resource.alt = "";
|
||||
resource.src = src;
|
||||
document.body.append(resource);
|
||||
}, pixel);
|
||||
assert.equal(
|
||||
await seen,
|
||||
false,
|
||||
"Resource requests must receive no referrer",
|
||||
);
|
||||
}
|
||||
return {
|
||||
sameAndCrossOriginResourcesChecked: true,
|
||||
referrerDisclosed: false,
|
||||
};
|
||||
}));
|
||||
@@ -1,286 +0,0 @@
|
||||
/** 首屏回归:延迟真实入口脚本时仍显示稳定占位,并在业务代码执行前恢复用户主题。 */
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
for (const preference of [
|
||||
{ stored: "dark", system: "light", dark: true },
|
||||
{ stored: "light", system: "dark", dark: false },
|
||||
{ stored: "auto", system: "dark", dark: true },
|
||||
{ stored: "auto", system: "light", dark: false },
|
||||
{ stored: "unknown", system: "dark", dark: true },
|
||||
]) {
|
||||
test(
|
||||
`cold refresh keeps a styled loading shell before scripts: ${preference.stored}/${preference.system}`,
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ colorScheme: preference.system });
|
||||
await page.addInitScript((mode) => {
|
||||
localStorage.setItem("theme", mode);
|
||||
}, preference.stored);
|
||||
let releaseScripts;
|
||||
const scriptsHeld = new Promise((resolve) => {
|
||||
releaseScripts = resolve;
|
||||
});
|
||||
let markRequested;
|
||||
const requested = new Promise((resolve) => {
|
||||
markRequested = resolve;
|
||||
});
|
||||
await page.route(`${appOrigin}/_nuxt/*.js`, async (route) => {
|
||||
markRequested();
|
||||
await scriptsHeld;
|
||||
await route.continue();
|
||||
});
|
||||
let before;
|
||||
try {
|
||||
await page.goto(`${appOrigin}/login`, { waitUntil: "commit" });
|
||||
await requested;
|
||||
await page.getByRole("status", { name: "页面加载中" }).waitFor();
|
||||
before = await page.evaluate(() => ({
|
||||
dark: document.documentElement.classList.contains("app-dark"),
|
||||
colorScheme: document.documentElement.style.colorScheme,
|
||||
rootChildren: document.getElementById("__nuxt").children.length,
|
||||
viewport: innerWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
loaderBackground: getComputedStyle(
|
||||
document.getElementById("console-startup"),
|
||||
).backgroundColor,
|
||||
loaderPosition: getComputedStyle(
|
||||
document.getElementById("console-startup"),
|
||||
).position,
|
||||
}));
|
||||
assert.equal(before.dark, preference.dark);
|
||||
assert.equal(before.colorScheme, preference.dark ? "dark" : "light");
|
||||
assert.equal(before.rootChildren, 0);
|
||||
assert.equal(before.scrollWidth, before.viewport);
|
||||
assert.equal(before.loaderPosition, "fixed");
|
||||
assert.equal(
|
||||
before.loaderBackground,
|
||||
preference.dark ? "rgb(24, 24, 27)" : "rgb(248, 250, 252)",
|
||||
);
|
||||
} finally {
|
||||
releaseScripts();
|
||||
}
|
||||
await page.locator(".blank-layout").waitFor();
|
||||
await page.getByRole("status", { name: "页面加载中" }).waitFor({
|
||||
state: "detached",
|
||||
});
|
||||
assert.equal(
|
||||
await page
|
||||
.locator("html")
|
||||
.evaluate((html) => html.classList.contains("app-dark")),
|
||||
preference.dark,
|
||||
);
|
||||
await page.getByText("密码登录", { exact: true }).click();
|
||||
await page.locator("input[type=password]").waitFor();
|
||||
return {
|
||||
themeBeforeScripts: before.dark,
|
||||
styledShellBeforeScripts: true,
|
||||
loadingShellRemoved: true,
|
||||
loginInteractive: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const boundary of ["bootstrap", "route-css"]) {
|
||||
test(
|
||||
`mobile refresh keeps the shell until ${boundary} is ready`,
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.emulateMedia({
|
||||
colorScheme: "dark",
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
window.startupFrames = [];
|
||||
// 在浏览器实际绘制边界采样占位退场后的前三帧,不向产品代码加入测试状态。
|
||||
function sample() {
|
||||
const shell = document.querySelector(".auth-shell");
|
||||
if (shell && !document.getElementById("__nuxt-loader")) {
|
||||
const style = getComputedStyle(shell);
|
||||
window.startupFrames.push({
|
||||
boxSizing: style.boxSizing,
|
||||
display: style.display,
|
||||
width: shell.getBoundingClientRect().width,
|
||||
viewport: innerWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
dark: document.documentElement.classList.contains("app-dark"),
|
||||
});
|
||||
}
|
||||
if (window.startupFrames.length < 3) requestAnimationFrame(sample);
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
let release;
|
||||
const held = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let markRequested;
|
||||
const requested = new Promise((resolve) => {
|
||||
markRequested = resolve;
|
||||
});
|
||||
const url =
|
||||
boundary === "bootstrap"
|
||||
? `${backendOrigin}/console/v1/common`
|
||||
: `${appOrigin}/_nuxt/login.*.css`;
|
||||
await page.route(url, async (route) => {
|
||||
markRequested();
|
||||
await held;
|
||||
await route.continue();
|
||||
});
|
||||
try {
|
||||
await page.goto(`${appOrigin}/login`, { waitUntil: "commit" });
|
||||
await requested;
|
||||
await page.getByRole("status", { name: "页面加载中" }).waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator(".startup-spinner")
|
||||
.evaluate((element) => getComputedStyle(element).animationName),
|
||||
"none",
|
||||
);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
await page.locator(".auth-shell").waitFor();
|
||||
await page.getByRole("status", { name: "页面加载中" }).waitFor({
|
||||
state: "detached",
|
||||
});
|
||||
await page.waitForFunction(() => window.startupFrames.length === 3);
|
||||
const frames = await page.evaluate(() => window.startupFrames);
|
||||
for (const appearance of frames) {
|
||||
assert.equal(appearance.boxSizing, "border-box");
|
||||
assert.equal(appearance.display, "grid");
|
||||
assert.equal(appearance.dark, true);
|
||||
assert.ok(appearance.width <= appearance.viewport);
|
||||
assert.equal(appearance.scrollWidth, appearance.viewport);
|
||||
}
|
||||
assert.deepEqual(frames[0], frames[2]);
|
||||
return {
|
||||
boundary,
|
||||
shellDuringWait: true,
|
||||
shellRemoved: true,
|
||||
firstVisibleFrames: frames,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"delayed font downloads do not keep the startup shell over a usable page",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
let release;
|
||||
const held = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let markRequested;
|
||||
const requested = new Promise((resolve) => {
|
||||
markRequested = resolve;
|
||||
});
|
||||
await page.route(/\.woff2(?:\?|$)/, async (route) => {
|
||||
markRequested();
|
||||
await held;
|
||||
await route.continue();
|
||||
});
|
||||
try {
|
||||
await page.goto(`${appOrigin}/login`, { waitUntil: "commit" });
|
||||
await requested;
|
||||
await page.getByLabel("密码", { exact: true }).waitFor();
|
||||
await page.getByRole("status", { name: "页面加载中" }).waitFor({
|
||||
state: "detached",
|
||||
});
|
||||
await page
|
||||
.getByLabel("账号", { exact: true })
|
||||
.fill("audit@example.invalid");
|
||||
assert.equal(
|
||||
await page.getByLabel("账号", { exact: true }).inputValue(),
|
||||
"audit@example.invalid",
|
||||
);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
return { interactiveBeforeFonts: true, loadingShellRemoved: true };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"bootstrap failure releases the shell and allows configuration recovery",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
let configurationAvailable = false;
|
||||
await page.route(`${backendOrigin}/console/v1/common`, async (route) => {
|
||||
if (configurationAvailable) return route.continue();
|
||||
return route.fulfill({
|
||||
json: { status: 500, msg: "站点配置暂不可用", data: {} },
|
||||
});
|
||||
});
|
||||
await page.goto(`${appOrigin}/login`);
|
||||
const retry = page.getByRole("button", { name: "重新加载", exact: true });
|
||||
await retry.waitFor();
|
||||
assert.equal(await page.locator("#__nuxt-loader").count(), 0);
|
||||
configurationAvailable = true;
|
||||
await retry.click();
|
||||
await retry.waitFor({ state: "detached" });
|
||||
await page.getByText("验证码登录", { exact: true }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "获取验证码", exact: true })
|
||||
.waitFor();
|
||||
return { failureDoesNotTrapShell: true, configurationRecovered: true };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"member deep-link refresh restores layout without a second loading shell on navigation",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await login(page);
|
||||
await page.goto(`${appOrigin}/security/ssh-keys`);
|
||||
await page
|
||||
.getByRole("heading", { name: "SSH 密钥", exact: true })
|
||||
.waitFor();
|
||||
await page.reload();
|
||||
await page
|
||||
.getByRole("heading", { name: "SSH 密钥", exact: true })
|
||||
.waitFor();
|
||||
await page
|
||||
.getByRole("status", { name: "页面加载中" })
|
||||
.waitFor({ state: "detached" });
|
||||
const layout = await page.locator(".deni-layout").evaluate((element) => ({
|
||||
boxSizing: getComputedStyle(element).boxSizing,
|
||||
viewport: innerWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
assert.equal(layout.boxSizing, "border-box");
|
||||
assert.equal(layout.scrollWidth, layout.viewport);
|
||||
await page.getByRole("button", { name: "打开菜单", exact: true }).click();
|
||||
const navigation = page.getByRole("navigation", {
|
||||
name: "会员中心导航",
|
||||
exact: true,
|
||||
});
|
||||
await navigation
|
||||
.getByRole("button", { name: "安全", exact: true })
|
||||
.click();
|
||||
await navigation
|
||||
.getByRole("link", { name: "安全组", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("heading", { name: "安全组", exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await page.locator("#__nuxt-loader").count(), 0);
|
||||
return { memberRefreshStyled: true, loadingShellOnlyAtStartup: true };
|
||||
}),
|
||||
);
|
||||
@@ -1,411 +0,0 @@
|
||||
/** 主题和字段回归经真实商品、首页、实名认证与实例入口验证;只替换外部 HTTP 响应。 */
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { backendOrigin, login, scenario } from "./helpers/console-browser.mjs";
|
||||
|
||||
async function boundary(page, responses) {
|
||||
const requests = [];
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace(
|
||||
/^\/console\/v1/,
|
||||
"",
|
||||
);
|
||||
if (!Object.hasOwn(responses, path)) return route.fallback();
|
||||
requests.push({
|
||||
path,
|
||||
authenticated: Boolean(request.headers().authorization),
|
||||
multipart:
|
||||
request
|
||||
.headers()
|
||||
["content-type"]?.startsWith("multipart/form-data; boundary=") ===
|
||||
true,
|
||||
});
|
||||
await route.fulfill({
|
||||
json: { status: 200, msg: "", data: responses[path] },
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
|
||||
async function textPresentation(locator) {
|
||||
return locator.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const rgb = (value) => value.match(/[\d.]+/g).map(Number);
|
||||
let background = [255, 255, 255];
|
||||
const layers = [];
|
||||
for (let owner = element; owner; owner = owner.parentElement)
|
||||
layers.push(rgb(getComputedStyle(owner).backgroundColor));
|
||||
for (const layer of layers.reverse()) {
|
||||
const alpha = layer[3] ?? 1;
|
||||
background = background.map(
|
||||
(channel, index) => channel * (1 - alpha) + layer[index] * alpha,
|
||||
);
|
||||
}
|
||||
const foreground = rgb(style.color);
|
||||
const luminance = (color) =>
|
||||
color
|
||||
.slice(0, 3)
|
||||
.map((channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045
|
||||
? value / 12.92
|
||||
: ((value + 0.055) / 1.055) ** 2.4;
|
||||
})
|
||||
.reduce(
|
||||
(total, value, index) =>
|
||||
total + value * [0.2126, 0.7152, 0.0722][index],
|
||||
0,
|
||||
);
|
||||
const light = luminance(foreground);
|
||||
const dark = luminance(background);
|
||||
const tokenColor = new Option().style;
|
||||
tokenColor.color = style.getPropertyValue("--p-text-color");
|
||||
return {
|
||||
color: style.color,
|
||||
textColor: tokenColor.color,
|
||||
background: background.map(Math.round),
|
||||
contrast: (Math.max(light, dark) + 0.05) / (Math.min(light, dark) + 0.05),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
"dark structured catalog descriptions keep readable field text",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await boundary(page, {
|
||||
"/product/group/first": { list: [{ id: 1, name: "云产品" }] },
|
||||
"/product/group/second": {
|
||||
list: [{ id: 2, parent_id: 1, name: "测试分类", description: "" }],
|
||||
},
|
||||
"/product": {
|
||||
list: [
|
||||
{
|
||||
id: 123,
|
||||
name: "布局验收商品",
|
||||
module: "idcsmart_common",
|
||||
res_module: "idcsmart_common",
|
||||
pay_type: "recurring_prepayment",
|
||||
price: "10.00",
|
||||
billing_cycle: "monthly",
|
||||
stock_control: 0,
|
||||
qty: 10,
|
||||
order_allowed: true,
|
||||
description: "配置:4GB 内存\n线路:BGP",
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
},
|
||||
});
|
||||
await login(page, "/products?g=1&sg=2");
|
||||
const value = page
|
||||
.locator(".description-field dd")
|
||||
.filter({ hasText: "4GB" });
|
||||
const label = page
|
||||
.locator(".description-field dt")
|
||||
.filter({ hasText: "配置" });
|
||||
await value.waitFor();
|
||||
await label.waitFor();
|
||||
assert.ok(
|
||||
(await textPresentation(value)).contrast >= 4.5,
|
||||
"Structured description values must remain readable in dark mode",
|
||||
);
|
||||
assert.ok(
|
||||
(await textPresentation(label)).contrast >= 4.5,
|
||||
"Structured description labels must remain readable in dark mode",
|
||||
);
|
||||
return { structuredDescriptionReadable: true };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"dark withdrawal notices do not recolor the home announcement title",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await boundary(page, {
|
||||
"/announcement": {
|
||||
list: [{ id: 1, title: "本地公告", create_time: 1700000000 }],
|
||||
count: 1,
|
||||
},
|
||||
"/withdraw/rule/credit": {
|
||||
status: 1,
|
||||
method: [{ id: 0, name: "账户余额", no_account: true }],
|
||||
min: "1.00",
|
||||
max: "100.00",
|
||||
withdraw_fee_type: "fixed",
|
||||
withdraw_fee: "0.00",
|
||||
},
|
||||
});
|
||||
await login(page, "/");
|
||||
const announcement = page
|
||||
.locator(".home-row-title")
|
||||
.filter({ hasText: "本地公告" });
|
||||
await announcement.waitFor();
|
||||
const home = await textPresentation(announcement);
|
||||
assert.equal(
|
||||
home.color,
|
||||
home.textColor,
|
||||
"The announcement title owns its normal text color",
|
||||
);
|
||||
await page.getByRole("button", { name: "提现", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "提现", exact: true });
|
||||
const notice = dialog.locator(".notice-title");
|
||||
await notice.waitFor();
|
||||
const ownNotice = await textPresentation(notice);
|
||||
assert.ok(
|
||||
ownNotice.contrast >= 4.5,
|
||||
"Withdrawal instructions must remain readable",
|
||||
);
|
||||
assert.notEqual(
|
||||
ownNotice.color,
|
||||
home.color,
|
||||
"The withdrawal notice retains its own emphasis color",
|
||||
);
|
||||
assert.equal((await textPresentation(announcement)).color, home.color);
|
||||
return {
|
||||
homeAnnouncementColorOwned: true,
|
||||
withdrawalNoticeReadable: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"real-name upload fields expose distinct labels and named removal actions",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const requests = await boundary(page, {
|
||||
"/certification/info": {
|
||||
certification_open: 1,
|
||||
certification_company_open: 1,
|
||||
certification_upload: 1,
|
||||
certification_company_need_person: 0,
|
||||
is_certification: 0,
|
||||
person: { status: 0 },
|
||||
company: { status: 0 },
|
||||
},
|
||||
"/upload": { save_name: "identity-front.png" },
|
||||
});
|
||||
await login(page, "/account/realname/personal");
|
||||
const front = page.getByLabel("身份证正面照 *", { exact: true });
|
||||
const back = page.getByLabel("身份证反面照 *", { exact: true });
|
||||
await front.waitFor();
|
||||
await back.waitFor();
|
||||
assert.notEqual(
|
||||
await front.getAttribute("id"),
|
||||
await back.getAttribute("id"),
|
||||
);
|
||||
const file = {
|
||||
name: "identity-front.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from("local-e2e-upload-not-identity-data"),
|
||||
};
|
||||
await front.setInputFiles(file);
|
||||
const remove = page.getByRole("button", {
|
||||
name: "移除身份证正面照 *",
|
||||
exact: true,
|
||||
});
|
||||
await remove.waitFor();
|
||||
assert.deepEqual(
|
||||
requests.filter((request) => request.path === "/upload"),
|
||||
[{ path: "/upload", authenticated: true, multipart: true }],
|
||||
);
|
||||
await remove.click();
|
||||
assert.equal(await remove.count(), 0);
|
||||
await front.setInputFiles(file);
|
||||
await remove.waitFor();
|
||||
return {
|
||||
separateFileInputNames: true,
|
||||
namedRemoval: true,
|
||||
sameFileReselection: true,
|
||||
authenticatedMultipartUpload: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"bank voucher uploads are named from the visible order heading",
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const requests = await boundary(page, {
|
||||
"/order/42": {
|
||||
order: {
|
||||
id: 42,
|
||||
amount: "20.00",
|
||||
amount_unpaid: "20.00",
|
||||
credit: "0.00",
|
||||
type: "new",
|
||||
status: "WaitUpload",
|
||||
gateway: "artificial",
|
||||
create_time: 1700000000,
|
||||
items: [{ id: 1, product_name: "本地测试实例", amount: "20.00" }],
|
||||
},
|
||||
customfields: { is_pre_invoice: 0 },
|
||||
},
|
||||
"/upload": { save_name: "bank-voucher.pdf" },
|
||||
});
|
||||
await login(page, "/orders/42");
|
||||
await page
|
||||
.getByRole("heading", { name: "银行转账凭证", exact: true })
|
||||
.waitFor();
|
||||
const input = page.locator("input[type=file]");
|
||||
await input.waitFor();
|
||||
const accessible = await input.ariaSnapshot();
|
||||
assert.ok(
|
||||
accessible.includes("银行转账凭证"),
|
||||
`The visible voucher heading must name the upload field; browser accessibility tree: ${accessible}`,
|
||||
);
|
||||
await page.getByLabel("银行转账凭证", { exact: true }).setInputFiles({
|
||||
name: "bank-voucher.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("local-e2e-voucher-not-financial-data"),
|
||||
});
|
||||
const remove = page.getByRole("button", {
|
||||
name: "移除凭证",
|
||||
exact: true,
|
||||
});
|
||||
await remove.waitFor();
|
||||
assert.deepEqual(
|
||||
requests.filter((request) => request.path === "/upload"),
|
||||
[{ path: "/upload", authenticated: true, multipart: true }],
|
||||
);
|
||||
await remove.click();
|
||||
assert.equal(await remove.count(), 0);
|
||||
return {
|
||||
visibleHeadingNamesUpload: true,
|
||||
authenticatedMultipartUpload: true,
|
||||
localVoucherRemoval: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"normal kky upgrade plans and disks fit the narrow dialog container",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await boundary(page, {
|
||||
"/host/5": {
|
||||
host: {
|
||||
id: 5,
|
||||
product_id: 123,
|
||||
module: "kky_ecs",
|
||||
name: "本地弹性云",
|
||||
status: "Active",
|
||||
billing_cycle: "monthly",
|
||||
upgrade_allowed: true,
|
||||
renew_amount: "10.00",
|
||||
},
|
||||
},
|
||||
"/kky_ecs/5": {
|
||||
cpu: 2,
|
||||
memory: 4,
|
||||
bw: 10,
|
||||
protect: 0,
|
||||
ip: "192.0.2.5",
|
||||
username: "root",
|
||||
port: 22,
|
||||
image: { name: "Ubuntu 24.04" },
|
||||
},
|
||||
"/kky_ecs/5/status": { status: "on", rescue: false },
|
||||
"/kky_ecs/5/traffic": {
|
||||
mode: "bandwidth",
|
||||
monthly_quota: 100,
|
||||
traffic_show: 0,
|
||||
},
|
||||
"/kky_ecs/disk/5": {
|
||||
list: [
|
||||
{
|
||||
id: "data-1",
|
||||
name: "数据盘",
|
||||
size: 40,
|
||||
type2: "data",
|
||||
fs_type: "SSD",
|
||||
},
|
||||
],
|
||||
},
|
||||
"/kky_ecs/monitor/5": {
|
||||
sample_info: [],
|
||||
cpu: [],
|
||||
memory: [],
|
||||
bandwidth: [],
|
||||
disk: [],
|
||||
},
|
||||
"/host/5/renew/auto": { status: 0 },
|
||||
"/refund": { allow_refund: 0, reasons: [] },
|
||||
"/refund/host/5/refund": { refund: null },
|
||||
"/promo_code/host/5/promo_code": { promo_code: [] },
|
||||
"/kky_ecs/upgrade/5/ins_upgrade_config": [
|
||||
{
|
||||
cpu_num: 4,
|
||||
mem_list: [
|
||||
{ id: 1, memory: 8, is_sell_out: 0, description: "常规升级规格" },
|
||||
],
|
||||
},
|
||||
],
|
||||
"/kky_ecs/upgrade/5/add_disk_config": {
|
||||
min: 10,
|
||||
max: 1000,
|
||||
step: 10,
|
||||
type: ["SSD"],
|
||||
remaining_num: 0,
|
||||
},
|
||||
});
|
||||
await login(page, "/servers/server?id=5");
|
||||
await page.getByRole("tab", { name: "计费", exact: true }).click();
|
||||
await page.getByRole("button", { name: "升降级", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "升降级", exact: true });
|
||||
await dialog.locator(".plans .plan").waitFor();
|
||||
const widths = [];
|
||||
const assertFits = async (gridName, cardName) => {
|
||||
const measured = await dialog
|
||||
.locator(gridName)
|
||||
.evaluate((grid, childSelector) => {
|
||||
const bounds = grid.getBoundingClientRect();
|
||||
const card = grid
|
||||
.querySelector(childSelector)
|
||||
.getBoundingClientRect();
|
||||
return {
|
||||
width: bounds.width,
|
||||
scroll: grid.scrollWidth,
|
||||
client: grid.clientWidth,
|
||||
rightOverflow: card.right - bounds.right,
|
||||
};
|
||||
}, cardName);
|
||||
assert.ok(measured.width > 0);
|
||||
assert.ok(
|
||||
measured.rightOverflow <= 1 && measured.scroll <= measured.client + 1,
|
||||
`${gridName} must shrink to its actual dialog container: ${JSON.stringify(measured)}`,
|
||||
);
|
||||
widths.push({
|
||||
viewport: page.viewportSize().width,
|
||||
grid: gridName,
|
||||
width: Math.round(measured.width),
|
||||
fits: true,
|
||||
});
|
||||
};
|
||||
for (const width of [375, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await assertFits(".plans", ".plan");
|
||||
}
|
||||
await dialog.getByRole("button", { name: /硬盘/ }).click();
|
||||
await dialog
|
||||
.getByRole("button", { name: "扩容已有盘", exact: true })
|
||||
.click();
|
||||
await dialog.locator(".disks .disk").waitFor();
|
||||
for (const width of [375, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await assertFits(".disks", ".disk");
|
||||
}
|
||||
return { grids: widths };
|
||||
}),
|
||||
);
|
||||
@@ -1,603 +0,0 @@
|
||||
/**
|
||||
* 布局回归补齐旧业务 E2E 的视觉盲区:长维护文案、吸顶避让、导航的单一焦点与布局切换清理。
|
||||
* 所有内容来自生产已有的本地 HTTP 边界;尺寸断言读取实际 DOM,不检查源码形状。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { firefox } from "playwright";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
eventually,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
const maintenanceMessage =
|
||||
"系统正在升级维护,部分业务暂时不可用,请稍后再试。如有紧急问题请联系客服,我们正在尽快恢复服务。";
|
||||
|
||||
// 真实 KKY 实例在“网络与安全”中同时提供安全组和白名单入口,不绕过组件权限/加载条件。
|
||||
const networkResponses = {
|
||||
"/host/5": {
|
||||
host: {
|
||||
id: 5,
|
||||
product_id: 123,
|
||||
module: "kky_ecs",
|
||||
name: "本地弹性云",
|
||||
status: "Active",
|
||||
billing_cycle: "monthly",
|
||||
upgrade_allowed: true,
|
||||
},
|
||||
},
|
||||
"/kky_ecs/5": {
|
||||
cpu: 2,
|
||||
memory: 4,
|
||||
bandwidth: 10,
|
||||
protect: 0,
|
||||
bill_method: 1,
|
||||
ip: "192.0.2.5",
|
||||
ips: ["192.0.2.5"],
|
||||
intranet_ip: "10.0.0.5",
|
||||
username: "root",
|
||||
port: 22,
|
||||
image: { name: "Ubuntu 24.04" },
|
||||
security_group_id: 1,
|
||||
security_group_name: "本地安全组",
|
||||
},
|
||||
"/kky_ecs/5/status": { status: "on", rescue: false },
|
||||
"/kky_ecs/disk/5": { list: [] },
|
||||
"/kky_ecs/monitor/5": {
|
||||
sample_info: [],
|
||||
cpu: [],
|
||||
memory: [],
|
||||
bandwidth: [],
|
||||
disk: [],
|
||||
},
|
||||
"/kky_ecs/5/traffic": {
|
||||
billing: { mode: "bandwidth", monthly_quota: 0, unit_price: "0.00" },
|
||||
usage_status: "available",
|
||||
quota_status: "not_applicable",
|
||||
},
|
||||
"/domain_whitelist/form": {
|
||||
fields: [
|
||||
{ name: "domains", title: "域名", type: "textarea", required: true },
|
||||
],
|
||||
ips: ["192.0.2.5"],
|
||||
need_review: true,
|
||||
},
|
||||
"/domain_whitelist/record": { list: [], count: 0 },
|
||||
};
|
||||
|
||||
async function layoutBoundary(
|
||||
page,
|
||||
{ maintenance = true, content = "help", message = maintenanceMessage } = {},
|
||||
) {
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||
const path = new URL(route.request().url()).pathname.replace(
|
||||
/^\/console\/v1/,
|
||||
"",
|
||||
);
|
||||
const reply = (data) =>
|
||||
route.fulfill({ json: { status: 200, msg: "", data } });
|
||||
if (content === "network" && Object.hasOwn(networkResponses, path))
|
||||
return reply(networkResponses[path]);
|
||||
if (path === "/common") {
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
Object.assign(body.data, {
|
||||
maintenance_mode: Number(maintenance),
|
||||
maintenance_mode_message: message,
|
||||
});
|
||||
return route.fulfill({ response, json: body });
|
||||
}
|
||||
if (["/ticket/status", "/ticket/type"].includes(path))
|
||||
return reply({ list: [] });
|
||||
if (path === "/ticket/statistic") return reply({});
|
||||
if (path === "/official_site_cms/content") return reply({ content: {} });
|
||||
if (path === "/help")
|
||||
return reply({
|
||||
list: [
|
||||
{
|
||||
id: 1,
|
||||
name: "产品帮助",
|
||||
helps: [{ id: 1, title: "维护期间的使用说明" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === "/help/1")
|
||||
return reply({
|
||||
help: {
|
||||
id: 1,
|
||||
title: "维护期间的使用说明",
|
||||
content:
|
||||
"<p>这是一段正常帮助正文,用于验证长文章滚动后的布局。</p>".repeat(
|
||||
80,
|
||||
),
|
||||
create_time: 1700000000,
|
||||
},
|
||||
});
|
||||
if (path === "/cart") {
|
||||
const list = Array.from({ length: 14 }, (_, index) => ({
|
||||
product_id: 123,
|
||||
qty: 1,
|
||||
name: `云产品 ${index + 1}`,
|
||||
config_options: { cpu: index + 1 },
|
||||
customfield: {},
|
||||
self_defined_field: {},
|
||||
}));
|
||||
return reply({
|
||||
list,
|
||||
revision: createHash("sha256")
|
||||
.update(JSON.stringify(list))
|
||||
.digest("hex"),
|
||||
});
|
||||
}
|
||||
if (path === "/product/123/config_option")
|
||||
return reply({
|
||||
price_total: "10.00",
|
||||
preview: [{ name: "CPU", value: "2 核" }],
|
||||
});
|
||||
if (path === "/purchase_limit/check")
|
||||
return reply({ allowed: true, message: "" });
|
||||
if (path === "/product/123")
|
||||
return reply({
|
||||
product: {
|
||||
id: 123,
|
||||
name: "可配置产品",
|
||||
module: "idcsmart_common",
|
||||
pay_type: "free",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
if (path === "/idcsmart_common/product/123/configoption")
|
||||
return reply({
|
||||
common_product: { pay_type: "free" },
|
||||
configoptions: Array.from({ length: 18 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
option_name: `扩展资源 ${index + 1}`,
|
||||
option_type: "quantity",
|
||||
is_show: 1,
|
||||
qty_min: 1,
|
||||
qty_max: 10,
|
||||
qty_change: 1,
|
||||
})),
|
||||
});
|
||||
if (path === "/product/123/self_defined_field/order_page")
|
||||
return reply({ data: [] });
|
||||
if (path === "/product" && content === "dedicated")
|
||||
return reply({
|
||||
list: [
|
||||
{
|
||||
id: 123,
|
||||
name: "物理服务器",
|
||||
module: "mf_dcim",
|
||||
price: "100.00",
|
||||
stock_control: 0,
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
});
|
||||
if (path === "/product/123/mf_dcim/order_page")
|
||||
return reply({
|
||||
model_config: Array.from({ length: 12 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `机型 ${index + 1}`,
|
||||
cpu: "Xeon",
|
||||
memory: "32GB",
|
||||
disk: "1TB",
|
||||
})),
|
||||
data_center: [
|
||||
{
|
||||
name: "中国",
|
||||
city: [
|
||||
{
|
||||
name: "上海",
|
||||
area: [{ name: "一区", line: [{ id: 1, name: "BGP" }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === "/product/123/mf_dcim/line/1") return reply({});
|
||||
return route.fallback();
|
||||
});
|
||||
}
|
||||
|
||||
async function bannerGeometry(page) {
|
||||
return page.locator(".maintenance-banner").evaluate((banner) => {
|
||||
const box = banner.getBoundingClientRect(),
|
||||
range = document.createRange();
|
||||
range.selectNodeContents(banner.querySelector(".maintenance-message"));
|
||||
const text = range.getBoundingClientRect(),
|
||||
bar = document.querySelector(".topbar").getBoundingClientRect();
|
||||
return {
|
||||
top: box.top,
|
||||
bottom: box.bottom,
|
||||
height: box.height,
|
||||
textTop: text.top,
|
||||
textBottom: text.bottom,
|
||||
topbarTop: bar.top,
|
||||
topbarBottom: bar.bottom,
|
||||
viewport: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
"maintenance text and page offsets follow content height and viewport changes",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.setViewportSize({ width: 320, height: 740 });
|
||||
await layoutBoundary(page);
|
||||
await login(page, "/security/ssh-keys");
|
||||
await page.locator(".maintenance-banner").waitFor();
|
||||
const sizes = [];
|
||||
for (const width of [320, 375, 1440, 320]) {
|
||||
await page.setViewportSize({ width, height: 740 });
|
||||
const state = await eventually(
|
||||
() => bannerGeometry(page),
|
||||
(state) =>
|
||||
state.textTop >= state.top - 0.5 &&
|
||||
state.textBottom <= state.bottom + 0.5 &&
|
||||
state.topbarTop >= state.bottom - 0.5,
|
||||
"The full notice and topbar must occupy separate visible regions",
|
||||
);
|
||||
// 根元素使用 scrollbar-gutter: stable;Linux/Windows 的经典滚动条预留槽位后,
|
||||
// 内容宽度会比视口窄 15px,只有 macOS 覆盖式滚动条才会相等。验收只要求不产生横向溢出。
|
||||
assert.ok(
|
||||
state.scrollWidth <= state.viewport,
|
||||
`A maintenance notice must not widen the document: ${state.scrollWidth} > ${state.viewport}`,
|
||||
);
|
||||
sizes.push({ width, noticeFits: true, headerBelowNotice: true });
|
||||
}
|
||||
// 从键盘入口验证抽屉关闭控件不被维护条遮挡。
|
||||
const open = page.getByRole("button", { name: "打开菜单", exact: true });
|
||||
await open.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await eventually(
|
||||
() =>
|
||||
page
|
||||
.getByRole("button", { name: "关闭菜单", exact: true })
|
||||
.evaluate((button) => {
|
||||
const r = button.getBoundingClientRect(),
|
||||
hit = document.elementFromPoint(
|
||||
r.left + r.width / 2,
|
||||
r.top + r.height / 2,
|
||||
);
|
||||
return button === document.activeElement && button.contains(hit);
|
||||
}),
|
||||
Boolean,
|
||||
"The notice must not cover the focused drawer close button",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
return { sizes, drawerCloseReachable: true };
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"oversized maintenance notices scroll without pushing navigation off screen",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.setViewportSize({ width: 320, height: 640 });
|
||||
await layoutBoundary(page, { message: maintenanceMessage.repeat(30) });
|
||||
await login(page, "/security/ssh-keys");
|
||||
const banner = page.locator(".maintenance-banner");
|
||||
await banner.waitFor();
|
||||
const state = await eventually(
|
||||
() =>
|
||||
banner.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect(),
|
||||
header = document
|
||||
.querySelector(".topbar")
|
||||
.getBoundingClientRect();
|
||||
return {
|
||||
height: bounds.height,
|
||||
viewportHeight: innerHeight,
|
||||
scrollable: element.scrollHeight > element.clientHeight,
|
||||
belowBanner: header.top >= bounds.bottom - 0.5,
|
||||
navigationVisible: header.bottom < innerHeight,
|
||||
};
|
||||
}),
|
||||
(state) => state.belowBanner && state.navigationVisible,
|
||||
"Even a long notice must leave navigation visible below it",
|
||||
);
|
||||
assert.ok(state.scrollable, "All notice text must remain scrollable");
|
||||
assert.ok(state.height <= state.viewportHeight * 0.4 + 1);
|
||||
const endReachable = await banner.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
const bounds = element.getBoundingClientRect(),
|
||||
text = element.querySelector(".maintenance-message").firstChild,
|
||||
end = document.createRange();
|
||||
end.setStart(text, text.length - 1);
|
||||
end.setEnd(text, text.length);
|
||||
const last = end.getBoundingClientRect();
|
||||
return (
|
||||
element.scrollTop > 0 &&
|
||||
last.top >= bounds.top &&
|
||||
last.bottom <= bounds.bottom + 0.5
|
||||
);
|
||||
});
|
||||
assert.ok(endReachable, "The end of a long notice must remain readable");
|
||||
return { boundedHeight: true, navigationVisible: true, endReachable };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [name, path, selector] of [
|
||||
["help", "/help/article?id=1", ".help-nav"],
|
||||
["cart", "/cart", ".cart-foot"],
|
||||
["dedicated", "/products/dedicated-server", ".catalog-lines"],
|
||||
["configuration", "/products/123", ".config-summary-wrap"],
|
||||
]) {
|
||||
test(
|
||||
`maintenance sticky ${name} clears the actual fixed header`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.setViewportSize({ width: 1440, height: 800 });
|
||||
await layoutBoundary(page, { content: name });
|
||||
await login(page, path);
|
||||
if (name === "dedicated")
|
||||
await page
|
||||
.getByRole("button", { name: "选择线路", exact: true })
|
||||
.first()
|
||||
.click();
|
||||
await page.locator(selector).waitFor();
|
||||
if (name === "help")
|
||||
await page.locator(".content-html p").first().waitFor();
|
||||
await page.evaluate(() => window.scrollTo(0, 500));
|
||||
const state = await eventually(
|
||||
() =>
|
||||
page.locator(selector).evaluate((el) => {
|
||||
const r = el.getBoundingClientRect(),
|
||||
bar = document.querySelector(".topbar").getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(r.left + 10, r.top + 5);
|
||||
return {
|
||||
scrollY,
|
||||
top: r.top,
|
||||
headerBottom: bar.bottom,
|
||||
unobscured: el.contains(hit),
|
||||
sticky: getComputedStyle(el).position,
|
||||
};
|
||||
}),
|
||||
(state) =>
|
||||
state.scrollY > 0 &&
|
||||
state.top >= state.headerBottom - 0.5 &&
|
||||
state.unobscured,
|
||||
"Sticky content must remain below, not behind, the header",
|
||||
);
|
||||
assert.equal(
|
||||
state.sticky,
|
||||
"sticky",
|
||||
"The test must exercise the sticky state",
|
||||
);
|
||||
return {
|
||||
stickyReached: true,
|
||||
headerCleared: true,
|
||||
hitTestUnobscured: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"console layout releases its measured offsets after logging out",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.setViewportSize({ width: 1680, height: 900 });
|
||||
await layoutBoundary(page);
|
||||
await page.goto(`${appOrigin}/official-site`);
|
||||
await page.locator(".official-site").waitFor();
|
||||
const toastTop = () =>
|
||||
page
|
||||
.locator(".operation-toasts")
|
||||
.evaluate((el) => parseFloat(getComputedStyle(el).top));
|
||||
const outsideConsoleTop = await toastTop();
|
||||
await login(page, "/help/article?id=1");
|
||||
const consoleTop = await eventually(
|
||||
toastTop,
|
||||
(top) => top > outsideConsoleTop,
|
||||
"Global notifications must clear the maintenance header",
|
||||
);
|
||||
await page.getByRole("button", { name: "用户菜单", exact: true }).click();
|
||||
await page
|
||||
.getByRole("menuitem", { name: "退出登录", exact: true })
|
||||
.click();
|
||||
await page.waitForURL((url) => url.pathname === "/login");
|
||||
await eventually(
|
||||
toastTop,
|
||||
(top) => Math.abs(top - outsideConsoleTop) < 0.5,
|
||||
"Console geometry must not leak after the console layout unmounts",
|
||||
);
|
||||
return {
|
||||
notificationClearedConsole: consoleTop > outsideConsoleTop,
|
||||
blankLayoutRestored: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [path, name] of [
|
||||
["/cloud-servers", "订购云服务器"],
|
||||
["/dedicated-servers", "订购物理服务器"],
|
||||
["/tickets", "创建工单"],
|
||||
]) {
|
||||
test(
|
||||
`navigation action has one keyboard stop on ${path}`,
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await layoutBoundary(page, { maintenance: false });
|
||||
await login(page, path);
|
||||
const links = page.getByRole("link", { name, exact: true });
|
||||
await links.first().waitFor();
|
||||
for (const link of await links.all()) {
|
||||
assert.equal(
|
||||
await link
|
||||
.locator("button,a[href],input,select,textarea,[tabindex='0']")
|
||||
.count(),
|
||||
0,
|
||||
"A navigation action must not contain another focusable control",
|
||||
);
|
||||
await link.focus();
|
||||
await page.keyboard.press("Tab");
|
||||
assert.equal(
|
||||
await link.evaluate((el) => el.contains(document.activeElement)),
|
||||
false,
|
||||
"Tab must move on from the action, not enter it a second time",
|
||||
);
|
||||
}
|
||||
return {
|
||||
navigationActions: await links.count(),
|
||||
singleKeyboardStop: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"instance network navigation has one keyboard stop per action",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await layoutBoundary(page, { maintenance: false, content: "network" });
|
||||
await login(page, "/servers/server?id=5");
|
||||
await page.getByRole("tab", { name: "网络与安全", exact: true }).click();
|
||||
for (const name of ["前往安全组管理", "前往域名白名单"]) {
|
||||
const link = page.getByRole("link", { name, exact: true });
|
||||
await link.waitFor();
|
||||
await link.focus();
|
||||
await page.keyboard.press("Tab");
|
||||
assert.equal(
|
||||
await link.evaluate((element) =>
|
||||
element.contains(document.activeElement),
|
||||
),
|
||||
false,
|
||||
"Tab must leave each network navigation action after its first stop",
|
||||
);
|
||||
}
|
||||
return { navigationActions: 2, singleKeyboardStop: true };
|
||||
}),
|
||||
);
|
||||
|
||||
// 首页用户 ID 按钮继承标题的任意断行时,Firefox 会把 “#” 和数字拆到两行;按钮必须作为整体排版。
|
||||
// Chromium 下该问题不出现,因此同一场景还要在 Firefox 中验证真实渲染。
|
||||
async function routeHomeAccount(context) {
|
||||
await context.route(`${backendOrigin}/console/v1/index`, async (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback();
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
Object.assign(body.data.account, { id: 6, username: "测试用户" });
|
||||
return route.fulfill({ response, json: body });
|
||||
});
|
||||
}
|
||||
|
||||
async function userIdChipGeometry(page) {
|
||||
const sizes = [];
|
||||
const chip = page.locator(".welcome-title .copy-id");
|
||||
await chip.waitFor();
|
||||
for (const width of [1440, 375, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const state = await eventually(
|
||||
() =>
|
||||
chip.evaluate((button) => {
|
||||
const text = [...button.childNodes].find(
|
||||
(node) => node.nodeType === Node.TEXT_NODE,
|
||||
),
|
||||
value = text.textContent,
|
||||
glyph = (index) => {
|
||||
const range = document.createRange();
|
||||
range.setStart(text, index);
|
||||
range.setEnd(text, index + 1);
|
||||
return range.getBoundingClientRect();
|
||||
},
|
||||
first = glyph(value.indexOf("#")),
|
||||
last = glyph(value.length - 1),
|
||||
box = button.getBoundingClientRect(),
|
||||
lineHeight = parseFloat(getComputedStyle(button).lineHeight);
|
||||
return {
|
||||
label: value.trim(),
|
||||
sameLine: Math.abs(first.top - last.top) <= 0.5,
|
||||
singleLineHeight: box.height < lineHeight * 1.5,
|
||||
};
|
||||
}),
|
||||
(state) => state.sameLine && state.singleLineHeight,
|
||||
"The user id must not wrap inside its copy button",
|
||||
);
|
||||
assert.equal(state.label, "#6");
|
||||
sizes.push({ width, singleLine: true });
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
|
||||
test(
|
||||
"home user id chip stays on one line beside the greeting",
|
||||
{ timeout: 60000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await routeHomeAccount(page.context());
|
||||
await login(page, "/");
|
||||
const chromium = await userIdChipGeometry(page);
|
||||
const firefoxAppData = await mkdtemp(
|
||||
join(tmpdir(), "console-playwright-firefox-app-"),
|
||||
),
|
||||
firefoxLocalAppData = await mkdtemp(
|
||||
join(tmpdir(), "console-playwright-firefox-local-"),
|
||||
);
|
||||
let browser;
|
||||
try {
|
||||
// Firefox 使用测试配置前,不得读取开发者个人应用数据目录。
|
||||
browser = await firefox.launch({
|
||||
env: {
|
||||
...process.env,
|
||||
MOZ_APP_DATA: firefoxAppData,
|
||||
MOZ_LOCAL_APP_DATA: firefoxLocalAppData,
|
||||
},
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
// 与共享场景相同:Firefox 也只能访问两个本地 origin。
|
||||
const blocked = [];
|
||||
await context.route("**/*", (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (
|
||||
!["http:", "https:"].includes(url.protocol) ||
|
||||
[appOrigin, backendOrigin].includes(url.origin)
|
||||
)
|
||||
return route.fallback();
|
||||
blocked.push(
|
||||
`${route.request().method()} ${url.origin}${url.pathname}`,
|
||||
);
|
||||
return route.abort("blockedbyclient");
|
||||
});
|
||||
await routeHomeAccount(context);
|
||||
const firefoxPage = await context.newPage();
|
||||
await login(firefoxPage, "/");
|
||||
const gecko = await userIdChipGeometry(firefoxPage);
|
||||
assert.deepEqual(blocked, [], "Firefox must stay on local origins");
|
||||
return { chromium, firefox: gecko };
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await Promise.all(
|
||||
[firefoxAppData, firefoxLocalAppData].map((directory) =>
|
||||
rm(directory, { recursive: true, force: true }),
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,314 +0,0 @@
|
||||
/**
|
||||
* 回归缺口:CSS 的减动规则不能关闭 Canvas 动画。通过真实登录、实例监控和刷新,
|
||||
* 验证初始系统偏好、同一图表的偏好切换,以及离页后的监听清理。
|
||||
* 仅替换外部 HTTP 与浏览器时钟;比较同轮静态数据的画面,不锁定像素、颜色或动画时长。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
backendOrigin,
|
||||
eventually,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
async function monitorBoundary(page) {
|
||||
const state = {
|
||||
values: [10, 80, 30],
|
||||
chartResponses: 0,
|
||||
holdCharts: false,
|
||||
pendingCharts: [],
|
||||
};
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||
const path = new URL(route.request().url()).pathname.replace(
|
||||
/^\/console\/v1/,
|
||||
"",
|
||||
);
|
||||
const reply = (data) =>
|
||||
route.fulfill({ json: { status: 200, msg: "", data } });
|
||||
if (path === "/host/5")
|
||||
return reply({
|
||||
host: {
|
||||
id: 5,
|
||||
product_id: 123,
|
||||
module: "mf_cloud",
|
||||
name: "本地图表验收实例",
|
||||
status: "Active",
|
||||
billing_cycle: "monthly",
|
||||
upgrade_allowed: false,
|
||||
},
|
||||
});
|
||||
if (path === "/host/5/view") return reply({ module: "mf_cloud" });
|
||||
if (path === "/mf_cloud/5")
|
||||
return reply({
|
||||
image: { id: 6, name: "Ubuntu 24.04" },
|
||||
port: 22,
|
||||
username: "root",
|
||||
cpu: 2,
|
||||
memory: 4,
|
||||
config: { reinstall_sms_verify: 0, reset_password_sms_verify: 0 },
|
||||
});
|
||||
if (path === "/mf_cloud/5/status")
|
||||
return reply({ status: "on", rescue: false });
|
||||
if (path === "/mf_cloud/5/disk") return reply({ list: [] });
|
||||
if (path === "/mf_cloud/5/flow")
|
||||
return reply({ total: 100, used: 20, leave: 80 });
|
||||
if (path === "/mf_cloud/5/real_data")
|
||||
return reply({ cpu: 10, memory: 20, disk: 0 });
|
||||
if (path === "/mf_cloud/5/chart") {
|
||||
state.chartResponses++;
|
||||
// 在 HTTP 边界延迟真实刷新,不替换组件的 loading 状态或图表实例。
|
||||
if (state.holdCharts)
|
||||
await new Promise((resolve) => state.pendingCharts.push(resolve));
|
||||
return reply({
|
||||
list: state.values.map((value, index) => ({
|
||||
time: 1793894400 + index * 60,
|
||||
value,
|
||||
total: 100,
|
||||
used: value,
|
||||
in_bw: value * 10,
|
||||
out_bw: value * 5,
|
||||
read_bytes: value * 10,
|
||||
write_bytes: value * 5,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
async function enterMonitor(page) {
|
||||
// 必须在首次导航前安装,否则图表库可能缓存未被控制的原生 requestAnimationFrame。
|
||||
await page.clock.install();
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
const state = await monitorBoundary(page);
|
||||
await login(page, "/servers/server?id=5");
|
||||
await page.getByRole("tab", { name: "监控", exact: true }).waitFor();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
// pauseAt 是跳转而非真实等待;只留 50ms 时,读时间与暂停之间的调度延迟(并行验收或 CI)
|
||||
// 会让目标落在过去并报 “Cannot fast-forward to the past”,因此留出 1 秒余量。
|
||||
await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000));
|
||||
await page.getByRole("tab", { name: "监控", exact: true }).click();
|
||||
await eventually(
|
||||
async () => {
|
||||
await page.clock.runFor(50);
|
||||
return page.locator(".chart-viewport canvas").count();
|
||||
},
|
||||
(count) => count === 4,
|
||||
"The real monitor must render all four charts",
|
||||
);
|
||||
await eventually(
|
||||
() => page.getByRole("button", { name: "刷新", exact: true }).isEnabled(),
|
||||
Boolean,
|
||||
"Initial chart responses must finish before observing available data",
|
||||
);
|
||||
// 先给无动画图表一个绘制帧,不把 Canvas 的创建本身误认为动画。
|
||||
await page.clock.runFor(50);
|
||||
await page.mouse.move(0, 0);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function observeFrames(page, state) {
|
||||
const sample = () =>
|
||||
page.locator(".chart-viewport canvas").evaluateAll((canvases) =>
|
||||
canvases.map((canvas) => {
|
||||
const image = canvas.toDataURL();
|
||||
const blank = document.createElement("canvas");
|
||||
blank.width = canvas.width;
|
||||
blank.height = canvas.height;
|
||||
// 不对产品画布反复 getImageData:浏览器可能因此从 GPU 切到 CPU,制造一次性光栅差异。
|
||||
return {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
painted: image !== blank.toDataURL(),
|
||||
image,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const first = await sample();
|
||||
assert.equal(
|
||||
first.length > 0 && first.every((frame) => frame.painted),
|
||||
true,
|
||||
"Stable frames must contain rendered chart feedback, not empty canvases",
|
||||
);
|
||||
const responses = state.chartResponses;
|
||||
let changed = false;
|
||||
for (const step of [100, 250, 800, 1000]) {
|
||||
await page.clock.runFor(step);
|
||||
const next = await sample();
|
||||
assert.deepEqual(
|
||||
next.map(({ width, height }) => ({ width, height })),
|
||||
first.map(({ width, height }) => ({ width, height })),
|
||||
"Frame comparisons require unchanged canvas dimensions",
|
||||
);
|
||||
changed ||= next.some((frame, index) => frame.image !== first[index].image);
|
||||
}
|
||||
assert.equal(
|
||||
state.chartResponses,
|
||||
responses,
|
||||
"Stable frame samples must not include another data response",
|
||||
);
|
||||
return { changed, chartCount: first.length, dataUnchanged: true };
|
||||
}
|
||||
|
||||
async function refreshMonitor(page, state, values) {
|
||||
state.values = values;
|
||||
const before = state.chartResponses;
|
||||
await page.getByRole("button", { name: "刷新", exact: true }).click();
|
||||
await eventually(
|
||||
() => state.chartResponses,
|
||||
(count) => count === before + 4,
|
||||
"Refresh must consume new data through all four monitor HTTP requests",
|
||||
);
|
||||
await eventually(
|
||||
() => page.getByRole("button", { name: "刷新", exact: true }).isEnabled(),
|
||||
Boolean,
|
||||
"Frame sampling must begin after the refresh response is displayed",
|
||||
);
|
||||
await page.clock.runFor(50);
|
||||
}
|
||||
|
||||
test(
|
||||
"monitor renders stable initial frames for reduced motion",
|
||||
{ timeout: 45000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const state = await enterMonitor(page);
|
||||
try {
|
||||
const observed = await observeFrames(page, state);
|
||||
assert.equal(
|
||||
observed.changed,
|
||||
false,
|
||||
"Reduced motion must not animate already available chart data",
|
||||
);
|
||||
return { initiallyReduced: true, ...observed };
|
||||
} finally {
|
||||
await page.clock.resume();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"monitor loading feedback follows reduced motion without losing refresh state",
|
||||
{ timeout: 45000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const state = await enterMonitor(page);
|
||||
try {
|
||||
await page.clock.runFor(2000);
|
||||
state.holdCharts = true;
|
||||
const refresh = page.getByRole("button", { name: "刷新", exact: true });
|
||||
await refresh.click();
|
||||
await eventually(
|
||||
() => state.pendingCharts.length,
|
||||
(count) => count === 4,
|
||||
"Loading feedback must be driven by real pending monitor HTTP requests",
|
||||
);
|
||||
assert.equal(await refresh.isDisabled(), true);
|
||||
await page.clock.runFor(50);
|
||||
const reduced = await observeFrames(page, state);
|
||||
assert.equal(
|
||||
reduced.changed,
|
||||
false,
|
||||
"Reduced motion must also keep the canvas loading feedback still",
|
||||
);
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.clock.runFor(50);
|
||||
const normal = await observeFrames(page, state);
|
||||
assert.equal(
|
||||
normal.changed,
|
||||
true,
|
||||
"Normal loading feedback may animate",
|
||||
);
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.clock.runFor(50);
|
||||
const reducedAgain = await observeFrames(page, state);
|
||||
assert.equal(
|
||||
reducedAgain.changed,
|
||||
false,
|
||||
"Loading feedback must react when reduced motion is enabled again",
|
||||
);
|
||||
assert.equal(await refresh.isDisabled(), true);
|
||||
|
||||
state.holdCharts = false;
|
||||
for (const release of state.pendingCharts.splice(0)) release();
|
||||
await eventually(
|
||||
() => refresh.isEnabled(),
|
||||
Boolean,
|
||||
"Releasing the HTTP responses must finish the real refresh",
|
||||
);
|
||||
return {
|
||||
loadingWhileRequestsPending: true,
|
||||
reducedLoadingChangedFrames: reduced.changed,
|
||||
normalLoadingChangedFrames: normal.changed,
|
||||
reducedAgainLoadingChangedFrames: reducedAgain.changed,
|
||||
completedRefresh: true,
|
||||
};
|
||||
} finally {
|
||||
state.holdCharts = false;
|
||||
for (const release of state.pendingCharts.splice(0)) release();
|
||||
await page.clock.resume();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"monitor follows motion preference changes and safely unmounts",
|
||||
{ timeout: 45000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const state = await enterMonitor(page);
|
||||
try {
|
||||
await page.clock.runFor(2000);
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.clock.runFor(50);
|
||||
await refreshMonitor(page, state, [80, 20, 60]);
|
||||
const normal = await observeFrames(page, state);
|
||||
assert.equal(
|
||||
normal.changed,
|
||||
true,
|
||||
"Removing reduced motion must restore the normal chart animation",
|
||||
);
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.clock.runFor(50);
|
||||
await refreshMonitor(page, state, [20, 60, 10]);
|
||||
const reducedAgain = await observeFrames(page, state);
|
||||
assert.equal(
|
||||
reducedAgain.changed,
|
||||
false,
|
||||
"Changing the preference back must stop animating refreshed chart data",
|
||||
);
|
||||
|
||||
// 走真实路由离开实例页,再发系统偏好事件;公共场景收集器会拒绝运行时/控制台错误。
|
||||
await page
|
||||
.getByRole("link", { name: "首页", exact: true })
|
||||
.first()
|
||||
.click();
|
||||
await eventually(
|
||||
async () => {
|
||||
// 导航也依赖浏览器任务/rAF;暂停时钟下只轮询 DOM 会阻止真实路由完成卸载。
|
||||
await page.clock.runFor(50);
|
||||
return page.locator(".chart-viewport canvas").count();
|
||||
},
|
||||
(count) => count === 0,
|
||||
"Leaving the instance must unmount the monitor charts",
|
||||
);
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.clock.runFor(50);
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.clock.runFor(50);
|
||||
return {
|
||||
normalMotionChangedFrames: normal.changed,
|
||||
reducedMotionChangedFrames: reducedAgain.changed,
|
||||
unmountedWithoutErrors: true,
|
||||
};
|
||||
} finally {
|
||||
await page.clock.resume();
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* 认证页复用全站三态主题协议;按钮必须如实说明当前模式,不能承诺下一次一定变深/浅色。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { appOrigin, scenario } from "./helpers/console-browser.mjs";
|
||||
|
||||
test(
|
||||
"auth theme control describes and persists the shared three-mode cycle",
|
||||
{ timeout: 20000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await page.goto(`${appOrigin}/login`);
|
||||
const themeButton = page.getByRole("button", {
|
||||
name: "切换主题,当前跟随系统",
|
||||
exact: true,
|
||||
});
|
||||
await themeButton.waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator("html")
|
||||
.evaluate((html) => html.classList.contains("app-dark")),
|
||||
false,
|
||||
);
|
||||
await themeButton.click();
|
||||
await page
|
||||
.getByRole("button", { name: "切换主题,当前浅色", exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.evaluate(() => localStorage.getItem("theme")),
|
||||
"light",
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "切换主题,当前浅色", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("button", { name: "切换主题,当前深色", exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator("html")
|
||||
.evaluate((html) => html.classList.contains("app-dark")),
|
||||
true,
|
||||
);
|
||||
await page.reload();
|
||||
await page
|
||||
.getByRole("button", { name: "切换主题,当前深色", exact: true })
|
||||
.waitFor();
|
||||
await page
|
||||
.getByRole("button", { name: "切换主题,当前深色", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("button", { name: "切换主题,当前跟随系统", exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator("html")
|
||||
.evaluate((html) => html.classList.contains("app-dark")),
|
||||
false,
|
||||
);
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.waitForFunction(() =>
|
||||
document.documentElement.classList.contains("app-dark"),
|
||||
);
|
||||
return {
|
||||
currentModeDescribed: true,
|
||||
explicitModePersisted: true,
|
||||
automaticModeTracksSystem: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -1,335 +0,0 @@
|
||||
/** OpenVue/Deni 迁移的实际导航、移动表格与弹窗行为,不断言包名或源码形状。 */
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
backendOrigin,
|
||||
eventually,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
const navigation = (page) =>
|
||||
page.getByRole("navigation", { name: "会员中心导航", exact: true });
|
||||
|
||||
async function assertNavigationCannotTakeFocus(page) {
|
||||
const links = page.locator('nav[aria-label="会员中心导航"] a[href]');
|
||||
await links.first().evaluate((element) => element.focus());
|
||||
assert.equal(
|
||||
await links
|
||||
.first()
|
||||
.evaluate((element) =>
|
||||
element.closest("nav").contains(document.activeElement),
|
||||
),
|
||||
false,
|
||||
"The closed navigation cannot receive keyboard focus",
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"mobile navigation traps focus, closes on Escape and returns to its opener",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await login(page, "/security/ssh-keys");
|
||||
await page.getByText("audit-01", { exact: true }).waitFor();
|
||||
const opener = page.getByRole("button", {
|
||||
name: "打开菜单",
|
||||
exact: true,
|
||||
});
|
||||
await assertNavigationCannotTakeFocus(page);
|
||||
await opener.click();
|
||||
const drawer = page.getByRole("dialog", { name: "主导航", exact: true });
|
||||
const close = drawer.getByRole("button", {
|
||||
name: "关闭菜单",
|
||||
exact: true,
|
||||
});
|
||||
await close.waitFor();
|
||||
await eventually(
|
||||
() => close.evaluate((element) => element === document.activeElement),
|
||||
Boolean,
|
||||
"Opening navigation focuses its close action",
|
||||
);
|
||||
const firstLink = drawer.getByRole("link", {
|
||||
name: "破碎工坊首页",
|
||||
exact: true,
|
||||
});
|
||||
const lastLink = drawer.getByRole("link").last();
|
||||
await firstLink.focus();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
assert.equal(
|
||||
await lastLink.evaluate(
|
||||
(element) => element === document.activeElement,
|
||||
),
|
||||
true,
|
||||
"Shift+Tab from the first item wraps to the last drawer action",
|
||||
);
|
||||
await page.keyboard.press("Tab");
|
||||
assert.equal(
|
||||
await firstLink.evaluate(
|
||||
(element) => element === document.activeElement,
|
||||
),
|
||||
true,
|
||||
"Tab from the last item wraps to the first drawer action",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await drawer.waitFor({ state: "hidden" });
|
||||
await eventually(
|
||||
() => opener.evaluate((element) => element === document.activeElement),
|
||||
Boolean,
|
||||
"Closing navigation restores the opener",
|
||||
);
|
||||
await opener.click();
|
||||
// 当前后台菜单把 SSH 放在独立入口;目标安全组位于尚未展开的安全分组。
|
||||
await navigation(page)
|
||||
.getByRole("button", { name: "安全", exact: true })
|
||||
.click();
|
||||
await navigation(page)
|
||||
.getByRole("link", { name: "安全组", exact: true })
|
||||
.click();
|
||||
await page.waitForURL((url) => url.pathname === "/security/groups");
|
||||
await page
|
||||
.getByRole("heading", { name: "安全组", exact: true })
|
||||
.waitFor();
|
||||
await assertNavigationCannotTakeFocus(page);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator("body")
|
||||
.evaluate(
|
||||
(element) => getComputedStyle(element).overflow === "hidden",
|
||||
),
|
||||
false,
|
||||
);
|
||||
return {
|
||||
focusContained: true,
|
||||
escapeRestoresOpener: true,
|
||||
routeClosesDrawer: true,
|
||||
scrollRestored: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"navigation changes between overlay and static layout at the shared breakpoint",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.setViewportSize({ width: 1025, height: 900 });
|
||||
await login(page, "/security/ssh-keys");
|
||||
await navigation(page).waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole("button", { name: "打开菜单", exact: true })
|
||||
.isVisible(),
|
||||
false,
|
||||
);
|
||||
await page.setViewportSize({ width: 1024, height: 900 });
|
||||
const opener = page.getByRole("button", {
|
||||
name: "打开菜单",
|
||||
exact: true,
|
||||
});
|
||||
await opener.waitFor();
|
||||
await assertNavigationCannotTakeFocus(page);
|
||||
await opener.click();
|
||||
await navigation(page).waitFor();
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await navigation(page).waitFor();
|
||||
await eventually(
|
||||
() =>
|
||||
page
|
||||
.locator("body")
|
||||
.evaluate(
|
||||
(element) => getComputedStyle(element).overflow !== "hidden",
|
||||
),
|
||||
Boolean,
|
||||
"Resizing to desktop releases overlay scroll lock",
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByRole("dialog", { name: "主导航", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
const link = navigation(page).getByRole("link", {
|
||||
name: "SSH 密钥",
|
||||
exact: true,
|
||||
});
|
||||
await link.focus();
|
||||
assert.equal(
|
||||
await link.evaluate((element) => element === document.activeElement),
|
||||
true,
|
||||
"Desktop navigation is focusable after the overlay closes",
|
||||
);
|
||||
return {
|
||||
desktopAt1025: true,
|
||||
overlayAt1024: true,
|
||||
desktopResizeReleasesOverlay: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"mobile table retains field labels and an editable dialog without page overflow",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.addInitScript(() => {
|
||||
// 读取浏览器收到的首批单元格,不等待后续帧补写属性,也不改动产品 DOM。
|
||||
window.initialTableCells = null;
|
||||
const observer = new MutationObserver(() => {
|
||||
const cells = document.querySelectorAll(
|
||||
".p-datatable-tbody > tr:not(.p-datatable-empty-message) > td",
|
||||
);
|
||||
if (!cells.length) return;
|
||||
window.initialTableCells = [...cells].map((cell) => ({
|
||||
label: cell.getAttribute("data-label"),
|
||||
visible: getComputedStyle(cell).display !== "none",
|
||||
}));
|
||||
observer.disconnect();
|
||||
});
|
||||
observer.observe(document, { childList: true, subtree: true });
|
||||
});
|
||||
await login(page, "/security/ssh-keys");
|
||||
const name = page.getByText("audit-01", { exact: true });
|
||||
await name.waitFor();
|
||||
const label = await name.evaluate((element) => {
|
||||
const cell = element.closest("td");
|
||||
return {
|
||||
value: cell?.getAttribute("data-label"),
|
||||
displayed: cell ? getComputedStyle(cell, "::before").content : "none",
|
||||
};
|
||||
});
|
||||
assert.equal(label.value, "名称");
|
||||
assert.equal(label.displayed.includes("名称"), true);
|
||||
const initial = await page.evaluate(() => window.initialTableCells);
|
||||
assert.ok(initial?.length > 0);
|
||||
assert.equal(
|
||||
initial.every((cell) => cell.label !== null),
|
||||
true,
|
||||
"Mobile cells must carry layout labels when inserted, without another frame",
|
||||
);
|
||||
assert.ok(
|
||||
initial.some((cell) => cell.label === "名称" && cell.visible),
|
||||
"The primary field must be visible in the initial card geometry",
|
||||
);
|
||||
await page.reload();
|
||||
await name.waitFor();
|
||||
const refreshed = await page.evaluate(() => window.initialTableCells);
|
||||
assert.deepEqual(
|
||||
refreshed,
|
||||
initial,
|
||||
"A deep-link refresh must insert the same complete mobile card geometry",
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "编辑", exact: true })
|
||||
.first()
|
||||
.click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
const field = dialog.getByRole("textbox").first();
|
||||
await field.fill("mobile-key");
|
||||
const box = await dialog.boundingBox();
|
||||
assert.ok(
|
||||
box.x >= -1 && box.x + box.width <= 376,
|
||||
"The editor fits the phone viewport",
|
||||
);
|
||||
await dialog.getByRole("button", { name: "取消", exact: true }).click();
|
||||
await dialog.waitFor({ state: "hidden" });
|
||||
const fits = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth + 1,
|
||||
);
|
||||
assert.equal(fits, true);
|
||||
return {
|
||||
tableFieldLabels: true,
|
||||
initialTableGeometryReady: true,
|
||||
refreshKeepsInitialTableGeometry: true,
|
||||
editableDialogFits: true,
|
||||
pageHasNoHorizontalOverflow: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"dynamic columns share a single colored title and mobile field label",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path.endsWith("/account")) {
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
json.data.account.customfield = {
|
||||
idcsmart_client_level: { id: 1, name: "本地会员" },
|
||||
};
|
||||
return route.fulfill({ response, json });
|
||||
}
|
||||
if (path.endsWith("/country"))
|
||||
return route.fulfill({
|
||||
json: { status: 200, msg: "", data: { list: [] } },
|
||||
});
|
||||
if (!path.endsWith("/client_level")) return route.fallback();
|
||||
return route.fulfill({
|
||||
json: {
|
||||
status: 200,
|
||||
msg: "",
|
||||
data: {
|
||||
level_id: 1,
|
||||
levels: [
|
||||
{
|
||||
id: 1,
|
||||
name: "本地会员",
|
||||
background_color: "#0284c7",
|
||||
discount_list: [
|
||||
{ product_name: "本地云产品", discount_percent: 95 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await login(page, "/account");
|
||||
await page.getByRole("button", { name: "本地会员", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", {
|
||||
name: "等级策略",
|
||||
exact: true,
|
||||
});
|
||||
// 手机字段名来自 ::before,参与无障碍名称;按实际折扣文字定位单元格再核对标签。
|
||||
const discount = dialog.locator("td").filter({ hasText: /^95%$/ });
|
||||
await discount.waitFor();
|
||||
assert.equal(await discount.getAttribute("data-label"), "本地会员");
|
||||
// 手机表头隐藏,切回桌面核对用户实际看到的自定义标题,而非隐藏元素的无障碍名称。
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
const header = dialog.getByRole("columnheader", {
|
||||
name: "本地会员",
|
||||
exact: true,
|
||||
});
|
||||
await header.waitFor();
|
||||
assert.equal(
|
||||
await header.count(),
|
||||
1,
|
||||
"Dynamic columns must display a single title",
|
||||
);
|
||||
assert.equal(
|
||||
await header.evaluate((element) => getComputedStyle(element).color),
|
||||
"rgb(2, 132, 199)",
|
||||
"The server-provided level color must remain visible",
|
||||
);
|
||||
assert.equal(
|
||||
await dialog
|
||||
.locator("td")
|
||||
.filter({ hasText: /^本地云产品$/ })
|
||||
.getAttribute("data-label"),
|
||||
"商品",
|
||||
);
|
||||
return {
|
||||
dynamicHeaderLabel: true,
|
||||
singleTitle: true,
|
||||
levelColorPreserved: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
Reference in new issue
Block a user