Просмотр исходного кода

test(dashboard-test): 新增讨论话题页 spec;配置改为 .env;testlog 移出版本库

- src/tests/discussion.spec.ts:/discussion/topic/:id 打开、回复、关闭/重开、
  删回复、#锚点、匿名无权、不存在 404;数据经 v3 API 建删
- 配置写在 .env(模板 .env.example,gitignore),playwright.config.ts 用
  process.loadEnvFile 加载;spec 统一从 src/lib/env.ts 取值,不再写死密码
  (原来 auth.setup 与 users.spec 的默认密码互相矛盾)
- documents/testlog 移到 testlog/ 并 gitignore,只留本机
- collect.ts 过滤 antd List 废弃警告

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 4 дней назад
Родитель
Сommit
f8c7a356fc

+ 19 - 0
dashboard-test/.env.example

@@ -0,0 +1,19 @@
+# dashboard-test 本机配置模板。复制成 .env 再填(.env 已 gitignore,不要提交):
+#   cp .env.example .env
+# 已经在 shell 里设置的同名环境变量优先于本文件。
+
+# 被测站点根地址(dashboard-v6 的 dev server)
+E2E_BASE=http://127.0.0.1:4000/pcd-v2026/
+
+# 测试账号(必填)。与 api-test 共用同一个账号时注意登录限流:同账号 + IP 每分钟 5 次
+E2E_USER=test161
+E2E_PASS=
+
+# 讨论用例挂的句子:必须在测试账号自己的 channel 里、由测试账号编辑过
+E2E_DISCUSSION_SENTENCE=ee5259be-5d1e-4408-a2a6-566a5773eef1
+
+# 截图输出目录;不设则各 spec 写到 testlog/assets/<路由>_latest
+# E2E_SHOT=testlog/assets/channel_20260101_000000
+
+# 设为 0 跳过增删改用例
+# E2E_DESTRUCTIVE=0

+ 2 - 0
dashboard-test/.gitignore

@@ -3,3 +3,5 @@ node_modules/
 playwright/.auth/
 playwright-report/
 test-results/
+testlog/
+.env

+ 37 - 24
dashboard-test/README.md

@@ -16,14 +16,15 @@
 cd dashboard-test
 npm install
 npx playwright install chromium
+cp .env.example .env        # 生成本机配置,填上测试账号的密码(.env 已 gitignore)
 ```
 
 依赖只有两个,且都在 `devDependencies`:
 
-| 包                | 用途                     |
-| ----------------- | ------------------------ |
-| `@playwright/test`| 测试运行器 + 浏览器驱动  |
-| `@types/node`     | TypeScript 类型          |
+| 包                 | 用途                    |
+| ------------------ | ----------------------- |
+| `@playwright/test` | 测试运行器 + 浏览器驱动 |
+| `@types/node`      | TypeScript 类型         |
 
 ## 前置:启动 dev server
 
@@ -40,6 +41,7 @@ cd ../dashboard-v6 && npm run dev            # http://127.0.0.1:4000/pcd-v2026/
 npm test                    # 跑全部
 npm run test:channel        # 只跑 channel 路由
 npm run test:users          # 只跑匿名账号页面(登录 / 注册 / 找回密码 / 重置密码)
+npm run test:discussion     # 只跑讨论话题页 /discussion/topic/:id
 npm run test:ui             # UI Mode,可视化单步调试
 npm run test:headed         # 显示浏览器窗口
 npm run report              # 打开上次的 HTML 报告
@@ -49,18 +51,24 @@ npm run codegen             # 录制操作生成选择器
 正式记录一次测试时,把截图输出到带时间戳的目录:
 
 ```bash
-E2E_SHOT=documents/testlog/assets/channel_20260907_034904 npm run test:channel
+E2E_SHOT=testlog/assets/channel_20260907_034904 npm run test:channel
 ```
 
-### 环境变量
+### 配置
 
-| 变量               | 默认值                              | 说明                         |
-| ------------------ | ----------------------------------- | ---------------------------- |
-| `E2E_BASE`         | `http://127.0.0.1:4000/pcd-v2026/`  | 被测站点根地址               |
-| `E2E_USER`         | `test161`                           | 测试账号                     |
-| `E2E_PASS`         | `12345`                             | 密码                         |
-| `E2E_SHOT`         | `documents/testlog/assets/channel_latest` | 截图输出目录           |
-| `E2E_DESTRUCTIVE`  | 未设置(执行)                      | 设为 `0` 跳过增删改用例      |
+写在 `.env`(模板 `.env.example`),由 `playwright.config.ts` 用 Node 自带的
+`process.loadEnvFile` 加载;shell 里已设置的同名环境变量优先。spec 里一律从
+`src/lib/env.ts` 取值,不直接读 `process.env`,也不写死账号密码——缺 `E2E_USER` /
+`E2E_PASS` 会直接报错提示。
+
+| 变量                      | 默认值                                    | 说明                                            |
+| ------------------------- | ----------------------------------------- | ----------------------------------------------- |
+| `E2E_BASE`                | `http://127.0.0.1:4000/pcd-v2026/`        | 被测站点根地址                                  |
+| `E2E_USER`                | (必填)                                  | 测试账号                                        |
+| `E2E_PASS`                | (必填)                                  | 密码                                            |
+| `E2E_SHOT`                | `testlog/assets/<路由>_latest`            | 截图输出目录                                    |
+| `E2E_DESTRUCTIVE`         | 未设置(执行)                            | 设为 `0` 跳过增删改用例                         |
+| `E2E_DISCUSSION_SENTENCE` | `ee5259be-…`(本地开发库)                | 讨论用例挂的句子:须在测试账号自己的 channel 里 |
 
 ---
 
@@ -71,19 +79,22 @@ E2E_SHOT=documents/testlog/assets/channel_20260907_034904 npm run test:channel
 ```
 dashboard-test/
 ├── playwright.config.ts       配置:baseURL、串行执行、trace/video、setup 依赖
+├── .env.example               本机配置模板 → 复制成 .env(gitignore)
 ├── src/
 │   ├── tests/
 │   │   ├── auth.setup.ts      登录一次,存 storageState
 │   │   ├── channel.spec.ts    /workspace/channel 及子路由
+│   │   ├── discussion.spec.ts /discussion/topic/:id(讨论话题页,数据经 v3 API 建删)
 │   │   └── users.spec.ts      /anonymous 下的登录、注册、邀请注册、找回密码、重置密码
 │   └── lib/
-│       └── collect.ts         console / pageerror / HTTP 错误采集与噪音过滤
+│       ├── collect.ts         console / pageerror / HTTP 错误采集与噪音过滤
+│       └── env.ts             配置的唯一出口(账号、地址、种子数据)
 ├── documents/
-│   ├── development/
-│   │   └── testing-strategy.md   方案取舍与约定
-│   └── testlog/
-│       ├── channel_<时间戳>.md    每次测试的结论与待办
-│       └── assets/<路由>_<时间戳>/ 该次测试的截图
+│   └── development/
+│       └── testing-strategy.md   方案取舍与约定
+├── testlog/                   测试日志与截图(gitignore,只在本机)
+│   ├── <路由>_<时间戳>.md      每次测试的结论与待办
+│   └── assets/<路由>_<时间戳>/ 该次测试的截图
 ├── playwright/.auth/          登录态缓存(gitignore)
 ├── playwright-report/         HTML 报告(gitignore)
 └── test-results/              失败用例的 trace / video(gitignore)
@@ -116,7 +127,8 @@ dashboard-test/
 - BUG-1 Webhooks 页签不应跳到已废弃的 `/studio/...`
 - BUG-4 不存在的频道不应出现 `Unexpected Application Error`
 
-所以 `npm test` 现在是 **11 通过 / 2 失败**(channel 4/2、users 7/0),两个失败即上述待修项。
+所以 `npm test` 现在是 **15 通过 / 2 失败**(含 setup;channel 3/2、users 7/0、discussion 4/0),
+两个失败即上述待修项。
 
 修复后的缺陷断言保留作回归保护:`src/tests/users.spec.ts` 的「回归」组里有
 BUG-5(中文界面下 v3 错误文案应为中文,2026-10-08 修复)。
@@ -144,14 +156,15 @@ setup 与 `users.spec.ts` 都用 `E2E_USER` 登录,api-test 也用同一个账
 
 ### 测试日志
 
-每次正式测试在 `documents/testlog/` 下写一个
+每次正式测试在 `testlog/`(已 gitignore,不进版本库)下写一个
 `<路由名>_<YYYYMMDD_HHMMSS>.md`,截图放同名 assets 目录并在 md 中内嵌引用。
 
 固定结构:概览表 → 结论 → 一、通过的功能(含截图)→ 二、待修复
 (每条一个 `- [ ]` 代办,含日志片段、截图、定位到文件行号的原因、修复方向)→
 三、未覆盖 / 待确认。
 
-已有记录:
+本机已有记录:
 
-- [`documents/testlog/channel_20260907_034904.md`](./documents/testlog/channel_20260907_034904.md)
-- [`documents/testlog/users_20261008_072502.md`](./documents/testlog/users_20261008_072502.md)
+- [`testlog/channel_20260907_034904.md`](./testlog/channel_20260907_034904.md)
+- [`testlog/users_20261008_072502.md`](./testlog/users_20261008_072502.md)
+- [`testlog/discussion_20261015_023438.md`](./testlog/discussion_20261015_023438.md)

+ 2 - 2
dashboard-test/documents/development/testing-strategy.md

@@ -86,8 +86,8 @@ dashboard-v6 的 `package.json`、`package-lock.json`、`node_modules` **零改
 | `src/lib/collect.ts`                            | 日志采集与噪音过滤                         |
 | `README.md`                                     | 运行方式与环境变量                         |
 | `documents/development/testing-strategy.md`     | 本文:方案取舍与约定                       |
-| `documents/testlog/<路由名>_<时间戳>.md`        | 每次测试的日志,待修项用 `- [ ]` 代办标记  |
-| `documents/testlog/assets/<路由名>_<时间戳>/`   | 该次测试的截图,在 md 中内嵌引用           |
+| `testlog/<路由名>_<时间戳>.md`        | 每次测试的日志,待修项用 `- [ ]` 代办标记  |
+| `testlog/assets/<路由名>_<时间戳>/`   | 该次测试的截图,在 md 中内嵌引用           |
 
 目录布局参照 `dashboard-v6`:代码在 `src/`,文档在 `documents/`。
 

+ 1 - 0
dashboard-test/package.json

@@ -10,6 +10,7 @@
     "test:headed": "playwright test --headed",
     "test:channel": "playwright test channel",
     "test:users": "playwright test users",
+    "test:discussion": "playwright test discussion",
     "report": "playwright show-report",
     "codegen": "playwright codegen http://127.0.0.1:4000/pcd-v2026"
   },

+ 7 - 1
dashboard-test/playwright.config.ts

@@ -1,4 +1,10 @@
 import { defineConfig, devices } from '@playwright/test';
+import { existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+/* 本机配置:.env(模板 .env.example)。已在环境里的同名变量优先,不会被覆盖 */
+const envFile = fileURLToPath(new URL('.env', import.meta.url));
+if (existsSync(envFile)) process.loadEnvFile(envFile);
 
 /**
  * dashboard-v6 端到端测试配置
@@ -20,7 +26,7 @@ export default defineConfig({
   reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
 
   use: {
-    baseURL: process.env.E2E_BASE ?? 'http://127.0.0.1:4000/pcd-v2026/',
+    baseURL: process.env.E2E_BASE || 'http://127.0.0.1:4000/pcd-v2026/',
     viewport: { width: 1600, height: 1000 },
     trace: 'retain-on-failure',
     video: 'retain-on-failure',

+ 1 - 1
dashboard-test/src/lib/collect.ts

@@ -2,7 +2,7 @@ import type { Page } from '@playwright/test';
 
 /** 已知的第三方库废弃警告 / React 开发态噪音,不计入问题清单 */
 const NOISE =
-  /Spin.*deprecated|destroyOnClose|Dropdown\.Button.*deprecated|ProList.*deprecated|Alert.*deprecated|React does not recognize|Invalid DOM property|empty string \(""\) was passed|Static function can not consume|React DevTools/;
+  /Spin.*deprecated|destroyOnClose|Dropdown\.Button.*deprecated|ProList.*deprecated|`List` component is deprecated|Alert.*deprecated|React does not recognize|Invalid DOM property|empty string \(""\) was passed|Static function can not consume|React DevTools/;
 
 /**
  * 挂上 console / pageerror / 请求失败 / HTTP 4xx-5xx 采集。

+ 21 - 0
dashboard-test/src/lib/env.ts

@@ -0,0 +1,21 @@
+/**
+ * 测试配置的唯一出口。值来自环境变量,或 playwright.config.ts 加载的 `.env`
+ * (模板见 `.env.example`)。spec 里不要再直接读 process.env、也不要写死账号密码。
+ */
+function required(name: string): string {
+  const value = process.env[name];
+  if (!value) {
+    throw new Error(`缺少 ${name}:cp .env.example .env 后填写,或在环境变量里设置`);
+  }
+  return value;
+}
+
+export const BASE = process.env.E2E_BASE || 'http://127.0.0.1:4000/pcd-v2026/';
+export const USER = required('E2E_USER');
+export const PASS = required('E2E_PASS');
+export const DISCUSSION_SENTENCE =
+  process.env.E2E_DISCUSSION_SENTENCE || 'ee5259be-5d1e-4408-a2a6-566a5773eef1';
+export const DESTRUCTIVE = process.env.E2E_DESTRUCTIVE !== '0';
+
+/** 截图目录:E2E_SHOT 优先,否则 testlog/assets/<name>_latest */
+export const shotDir = (name: string) => process.env.E2E_SHOT || `testlog/assets/${name}_latest`;

+ 1 - 2
dashboard-test/src/tests/auth.setup.ts

@@ -1,6 +1,7 @@
 import { test as setup, expect } from "@playwright/test";
 import path from "node:path";
 import { fileURLToPath } from "node:url";
+import { USER, PASS } from "../lib/env.js";
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 export const AUTH_FILE = path.join(
@@ -8,8 +9,6 @@ export const AUTH_FILE = path.join(
   "../../playwright/.auth/user.json",
 );
 
-const USER = process.env.E2E_USER ?? "test161";
-const PASS = process.env.E2E_PASS ?? "11111";
 
 setup("登录并保存会话", async ({ page }) => {
   await page.goto("anonymous/sign-in");

+ 5 - 4
dashboard-test/src/tests/channel.spec.ts

@@ -1,13 +1,14 @@
 import { test, expect, type Page } from '@playwright/test';
 import { collect } from '../lib/collect.js';
+import { DESTRUCTIVE, shotDir } from '../lib/env.js';
 
 /**
  * /workspace/channel 及其子路由(:channelId、:channelId/setting)
  *
- * 截图输出目录由 E2E_SHOT 指定,默认写到 documents/testlog/assets/channel_latest,
- * 正式记录时传入带时间戳的目录,与 documents/testlog/channel_<时间戳>.md 对应。
+ * 截图输出目录由 E2E_SHOT 指定,默认写到 testlog/assets/channel_latest,
+ * 正式记录时传入带时间戳的目录,与 testlog/channel_<时间戳>.md 对应。
  */
-const SHOT = process.env.E2E_SHOT ?? 'documents/testlog/assets/channel_latest';
+const SHOT = shotDir('channel');
 const TAB = '.ant-pro-table-list-toolbar-inline-menu-item';
 
 const shot = (page: Page, name: string) =>
@@ -200,7 +201,7 @@ test.describe('频道设置 /workspace/channel/:channelId/setting', () => {
  *  E2E_DESTRUCTIVE=0 可跳过
  * ══════════════════════════════════════════════════════════ */
 test.describe('频道增删改(破坏性)', () => {
-  test.skip(process.env.E2E_DESTRUCTIVE === '0', '已通过 E2E_DESTRUCTIVE=0 跳过');
+  test.skip(!DESTRUCTIVE, '已通过 E2E_DESTRUCTIVE=0 跳过');
 
   test('新建 → 编辑保存 → 转让弹窗 → 删除 → 校验失效', async ({ page }) => {
     const log = collect(page);

+ 171 - 0
dashboard-test/src/tests/discussion.spec.ts

@@ -0,0 +1,171 @@
+import { test, expect, type APIRequestContext, type Page } from '@playwright/test';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { collect } from '../lib/collect.js';
+import { BASE, DISCUSSION_SENTENCE, shotDir } from '../lib/env.js';
+
+/**
+ * 讨论话题页 /discussion/topic/:topicId(「复制链接」拷出的地址)
+ *
+ * 数据:测试前用 v3 API 在测试账号自己的句子上建一个 discussion 话题 + 一条回复
+ * (notify=false,不发通知),测试后把剩下的回复和话题删掉。删除是软删,
+ * 表里会留两三行带 deleted_at 的记录,对外不可见。
+ * 句子换环境时用 E2E_DISCUSSION_SENTENCE 覆盖(与 api-test 的 discussion_sentence 同一条)。
+ */
+const SHOT = shotDir('discussion');
+const SENTENCE = DISCUSSION_SENTENCE;
+/** 走 dev server 的 /api 代理 */
+const API = new URL('/api/v3', BASE).toString();
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const AUTH_FILE = path.join(__dirname, '../../playwright/.auth/user.json');
+
+/** setup 项目登录后存下的 token(v6 放在 localStorage 的 token 里) */
+function token(): string {
+  const state = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8')) as {
+    origins: { localStorage: { name: string; value: string }[] }[];
+  };
+  const t = state.origins.flatMap((o) => o.localStorage).find((i) => i.name === 'token')?.value;
+  if (!t) throw new Error('playwright/.auth/user.json 里没有 token,setup 是否跑过?');
+  return t;
+}
+
+async function call(request: APIRequestContext, method: string, url: string, data?: object) {
+  const res = await request.fetch(`${API}${url}`, {
+    method,
+    data,
+    headers: { Authorization: `Bearer ${token()}`, Accept: 'application/json' },
+  });
+  return { status: res.status(), body: await res.json().catch(() => null) };
+}
+
+const shot = (page: Page, name: string) =>
+  page.screenshot({ path: `${SHOT}/${name}.png`, fullPage: true });
+const items = (page: Page) => page.locator('[id^=answer-]');
+const editors = (page: Page) => page.locator('.w-md-editor');
+
+/** 打开某条(话题或回复)右上角的 ⋯ 菜单,点其中一项 */
+async function menu(page: Page, id: string, label: string) {
+  await page.locator(`#answer-${id} .anticon-more`).click();
+  await page
+    .locator('.ant-dropdown:not(.ant-dropdown-hidden) .ant-dropdown-menu-item')
+    .filter({ hasText: new RegExp(`^${label}$`) })
+    .click();
+}
+
+test.describe('话题页 /discussion/topic/:id', () => {
+  const stamp = `e2e-tmp-${Date.now()}`;
+  let topicId = '';
+  let replyId = '';
+
+  test.beforeAll(async ({ request }) => {
+    const topic = await call(request, 'POST', '/discussions', {
+      target_type: 'sentence',
+      target_id: SENTENCE,
+      type: 'discussion',
+      title: stamp,
+      content: '由 dashboard-test 建立,测完即删',
+      notify: false,
+    });
+    expect(topic.status, JSON.stringify(topic.body)).toBe(201);
+    topicId = topic.body.data.id;
+
+    const reply = await call(request, 'POST', `/discussions/${topicId}/replies`, {
+      content: `${stamp} 第一条回复`,
+      notify: false,
+    });
+    expect(reply.status).toBe(201);
+    replyId = reply.body.data.id;
+  });
+
+  test.afterAll(async ({ request }) => {
+    if (!topicId) return;
+    const left = await call(request, 'GET', `/discussions/${topicId}/replies`);
+    for (const r of left.body?.data ?? []) {
+      await call(request, 'DELETE', `/discussions/${r.id}`);
+    }
+    await call(request, 'DELETE', `/discussions/${topicId}`);
+  });
+
+  test('登录用户:打开、回复、关闭 / 重开、删回复', async ({ page }) => {
+    const log = collect(page);
+
+    await test.step('1. 打开话题页', async () => {
+      await page.goto(`discussion/topic/${topicId}`, { waitUntil: 'networkidle' });
+      await expect(page.getByText(stamp, { exact: true })).toBeVisible();
+      await expect(items(page)).toHaveCount(2); // 话题 + 一条回复
+      await expect(editors(page)).toHaveCount(1); // 回复框
+      await shot(page, '01-topic');
+      const problems = [...log.all];
+      log.dump('1. 打开话题页');
+      expect(problems).toEqual([]);
+    });
+
+    let uiReplyId = '';
+    await test.step('2. 发一条回复', async () => {
+      await editors(page).locator('textarea').fill(`${stamp} 页面上发的回复`);
+      const posted = page.waitForResponse(
+        (r) => r.url().endsWith(`/discussions/${topicId}/replies`) && r.request().method() === 'POST',
+      );
+      await page.getByRole('button', { name: /提 *交/ }).click();
+      const res = await posted;
+      expect(res.status()).toBe(201);
+      uiReplyId = (await res.json()).data.id;
+      await expect(page.locator(`#answer-${uiReplyId}`)).toContainText('页面上发的回复');
+      await shot(page, '02-replied');
+      log.dump('2. 发一条回复');
+    });
+
+    await test.step('3. 关闭话题 → 回复框消失;重新打开 → 回复框回来', async () => {
+      await menu(page, topicId, '关闭');
+      await expect(editors(page)).toHaveCount(0);
+      await shot(page, '03-closed');
+
+      await menu(page, topicId, '打开');
+      await expect(editors(page)).toHaveCount(1);
+      log.dump('3. 关闭 / 重开');
+    });
+
+    await test.step('4. 删掉页面上发的回复', async () => {
+      await menu(page, uiReplyId, '删除');
+      const deleted = page.waitForResponse(
+        (r) => r.url().endsWith(`/discussions/${uiReplyId}`) && r.request().method() === 'DELETE',
+      );
+      await page.locator('.ant-modal-confirm-btns .ant-btn-dangerous').click();
+      expect((await deleted).status()).toBe(200);
+      await expect(page.locator(`#answer-${uiReplyId}`)).toHaveCount(0);
+      await shot(page, '04-reply-deleted');
+      log.dump('4. 删回复');
+    });
+  });
+
+  test('回复链接带 #锚点:定位到那条回复', async ({ page }) => {
+    await page.goto(`discussion/topic/${topicId}#${replyId}`, { waitUntil: 'networkidle' });
+    await expect(page.locator(`#answer-${replyId}`)).toBeInViewport();
+  });
+
+  test('匿名访问 discussion 类话题 → 无权查看', async ({ browser }) => {
+    // discussion / issue 是协作类,匿名不可见(后端 404)
+    const context = await browser.newContext({
+      baseURL: BASE,
+      storageState: { cookies: [], origins: [] },
+    });
+    const page = await context.newPage();
+    await page.goto(`discussion/topic/${topicId}`, { waitUntil: 'networkidle' });
+    await expect(page.getByText('话题不存在或无权查看')).toBeVisible();
+    await expect(page.getByText(stamp)).toHaveCount(0);
+    await shot(page, '05-anonymous');
+    await context.close();
+  });
+
+  test('不存在的话题 → 404 页,不崩', async ({ page }) => {
+    const log = collect(page);
+    await page.goto('discussion/topic/00000000-0000-4000-8000-000000000000', {
+      waitUntil: 'networkidle',
+    });
+    await expect(page.getByText('话题不存在或无权查看')).toBeVisible();
+    // 只允许那一条预期的 404
+    expect(log.all.filter((l) => !l.includes('[HTTP 404]') && !l.includes('404 (Not Found)'))).toEqual([]);
+  });
+});

+ 3 - 4
dashboard-test/src/tests/users.spec.ts

@@ -1,5 +1,6 @@
 import { test, expect, type Page, type Request } from '@playwright/test';
 import { collect } from '../lib/collect.js';
+import { USER, PASS, shotDir } from '../lib/env.js';
 
 /**
  * 匿名账号页面:/anonymous/sign-in、/anonymous/sign-up、/anonymous/sign-up/:token、
@@ -16,9 +17,9 @@ import { collect } from '../lib/collect.js';
  *   错误展示和**前端发出的请求体**,后端行为由 api-v13 的 Pest 测试覆盖
  *   (SignUpV3Test、PasswordResetV3Test)。
  *
- * 截图输出目录由 E2E_SHOT 指定,默认 documents/testlog/assets/users_latest。
+ * 截图输出目录由 E2E_SHOT 指定,默认 testlog/assets/users_latest。
  */
-const SHOT = process.env.E2E_SHOT ?? 'documents/testlog/assets/users_latest';
+const SHOT = shotDir('users');
 
 const shot = (page: Page, name: string) =>
   page.screenshot({ path: `${SHOT}/${name}.png`, fullPage: true });
@@ -310,8 +311,6 @@ test.describe('重置密码 /anonymous/reset-password/:token', () => {
   });
 });
 
-const USER = process.env.E2E_USER ?? 'test161';
-const PASS = process.env.E2E_PASS ?? '12345';
 
 /**
  * 打开登录页。不等 networkidle:登录过的页面上有轮询请求,网络可能一直不空闲,