test: session-scan 内存泄漏测试 + ui-sweep 界面验收脚本
This commit is contained in:
296
plugins/pi-mail-bridge/test/session-scan.test.mjs
Normal file
296
plugins/pi-mail-bridge/test/session-scan.test.mjs
Normal file
@ -0,0 +1,296 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { createSessionScanner } from '../src/session-scan.mjs';
|
||||
|
||||
/** 造一个会话目录树。返回根目录,用完由调用方删。 */
|
||||
function makeRoot() {
|
||||
return mkdtempSync(join(tmpdir(), 'pi-scan-'));
|
||||
}
|
||||
|
||||
/** 写一条会话文件。lines 是 header 之后的行(对象,会被 JSON 化)。 */
|
||||
function writeSession(root, cwdSlug, fileName, header, lines = []) {
|
||||
const dir = join(root, cwdSlug);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = join(dir, fileName);
|
||||
const body = [JSON.stringify({ type: 'session', version: 3, ...header })]
|
||||
.concat(lines.map((l) => JSON.stringify(l)))
|
||||
.join('\n');
|
||||
writeFileSync(file, `${body}\n`);
|
||||
return file;
|
||||
}
|
||||
|
||||
const msg = (text) => ({
|
||||
type: 'message',
|
||||
id: Math.random().toString(36).slice(2, 10),
|
||||
message: { role: 'user', content: [{ type: 'text', text }] },
|
||||
});
|
||||
|
||||
const info = (name) => ({ type: 'session_info', id: 'aa', parentId: 'bb', name });
|
||||
|
||||
test('取到 header 的 id 与 cwd,以及 session_info 的 name', async () => {
|
||||
const root = makeRoot();
|
||||
try {
|
||||
writeSession(root, '--tmp-proj--', 'a.jsonl',
|
||||
{ id: 'sess-1', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/tmp/proj' },
|
||||
[msg('你好'), info('重构导入路径'), msg('继续')]);
|
||||
|
||||
const { scan } = createSessionScanner({ sessionsDir: root });
|
||||
const out = await scan();
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].id, 'sess-1');
|
||||
assert.equal(out[0].cwd, '/tmp/proj');
|
||||
assert.equal(out[0].name, '重构导入路径');
|
||||
assert.ok(out[0].modified instanceof Date);
|
||||
assert.ok(out[0].path.endsWith('a.jsonl'));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('多次改名时取最后一个 session_info(与 SDK 语义一致)', async () => {
|
||||
const root = makeRoot();
|
||||
try {
|
||||
writeSession(root, '--x--', 'a.jsonl',
|
||||
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
|
||||
[info('旧名'), msg('干活'), info('新名')]);
|
||||
const { scan } = createSessionScanner({ sessionsDir: root });
|
||||
assert.equal((await scan())[0].name, '新名');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('显式清名(session_info 不带 name)→ name 为 undefined', async () => {
|
||||
const root = makeRoot();
|
||||
try {
|
||||
writeSession(root, '--x--', 'a.jsonl',
|
||||
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
|
||||
[info('有名字'), { type: 'session_info', id: 'cc', parentId: 'dd' }]);
|
||||
const { scan } = createSessionScanner({ sessionsDir: root });
|
||||
assert.equal((await scan())[0].name, undefined);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('老会话的 cwd 是空串时照实返回,不冒充', async () => {
|
||||
// snapshotPiSessions 会按空 workspace 上报。拿桥自己的 cwd 顶上去会让
|
||||
// 那条会话在错误的工作目录下出现在补全候选里。
|
||||
const root = makeRoot();
|
||||
try {
|
||||
writeSession(root, '--x--', 'a.jsonl',
|
||||
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z' },
|
||||
[info('无 cwd 的老会话')]);
|
||||
const { scan } = createSessionScanner({ sessionsDir: root });
|
||||
assert.equal((await scan())[0].cwd, '');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('第二次扫描不重读未变化的文件(这就是省下来的那 282MB)', async () => {
|
||||
const root = makeRoot();
|
||||
try {
|
||||
writeSession(root, '--x--', 'a.jsonl',
|
||||
{ id: 's1', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
|
||||
[info('甲'), msg('内容')]);
|
||||
writeSession(root, '--y--', 'b.jsonl',
|
||||
{ id: 's2', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/y' },
|
||||
[info('乙')]);
|
||||
|
||||
const sc = createSessionScanner({ sessionsDir: root });
|
||||
await sc.scan();
|
||||
const after1 = sc.stats();
|
||||
assert.equal(after1.fullScans, 2);
|
||||
|
||||
await sc.scan();
|
||||
const after2 = sc.stats();
|
||||
assert.equal(after2.fullScans, 2, '没变化的文件不该被重扫');
|
||||
assert.equal(after2.tailScans, 0);
|
||||
assert.equal(after2.tailBytes, after1.tailBytes, '一个字节都不该多读');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('文件追加后只扫尾部,且能看到新名字', async () => {
|
||||
const root = makeRoot();
|
||||
try {
|
||||
const file = writeSession(root, '--x--', 'a.jsonl',
|
||||
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
|
||||
[info('第一版')]);
|
||||
|
||||
const sc = createSessionScanner({ sessionsDir: root });
|
||||
assert.equal((await sc.scan())[0].name, '第一版');
|
||||
const bytes1 = sc.stats().tailBytes;
|
||||
|
||||
appendFileSync(file, `${JSON.stringify(msg('新一轮'))}\n${JSON.stringify(info('第二版'))}\n`);
|
||||
const out = await sc.scan();
|
||||
assert.equal(out[0].name, '第二版');
|
||||
assert.equal(sc.stats().tailScans, 1);
|
||||
const delta = sc.stats().tailBytes - bytes1;
|
||||
assert.ok(delta > 0 && delta < 400, `只该读新增的那一小段,实际 ${delta} 字节`);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('★尾部没有 session_info 时保留旧 name,不能清空', async () => {
|
||||
// 写错成 `cached.name = name` 会让每次有新消息的会话都丢掉名字,
|
||||
// 而没有 name 的会话不上报(S-1)—— 于是**活跃**会话反而从补全里消失。
|
||||
const root = makeRoot();
|
||||
try {
|
||||
const file = writeSession(root, '--x--', 'a.jsonl',
|
||||
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
|
||||
[info('要保住的名字')]);
|
||||
|
||||
const sc = createSessionScanner({ sessionsDir: root });
|
||||
assert.equal((await sc.scan())[0].name, '要保住的名字');
|
||||
|
||||
appendFileSync(file, `${JSON.stringify(msg('只是普通消息'))}\n`);
|
||||
assert.equal((await sc.scan())[0].name, '要保住的名字');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('★巨大的 message 行不进内存也不影响解析', async () => {
|
||||
// 本机实测单行最长 2.63MB。listAll 会把它整行读进来并 parse;
|
||||
// 这里只要求 name 仍能取到,且扫描不抛错。
|
||||
const root = makeRoot();
|
||||
try {
|
||||
const huge = 'x'.repeat(3 * 1024 * 1024);
|
||||
writeSession(root, '--x--', 'a.jsonl',
|
||||
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
|
||||
[msg(huge), info('巨行之后的名字'), msg(huge)]);
|
||||
|
||||
const sc = createSessionScanner({ sessionsDir: root });
|
||||
const out = await sc.scan();
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].name, '巨行之后的名字');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('首行不是 session 的文件被忽略,且不会每拍重读', async () => {
|
||||
const root = makeRoot();
|
||||
try {
|
||||
const dir = join(root, '--x--');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'junk.jsonl'), `${JSON.stringify({ type: 'message' })}\n`);
|
||||
writeFileSync(join(dir, 'empty.jsonl'), '');
|
||||
writeFileSync(join(dir, 'broken.jsonl'), '这不是 json\n');
|
||||
|
||||
const sc = createSessionScanner({ sessionsDir: root });
|
||||
assert.deepEqual(await sc.scan(), []);
|
||||
const n = sc.stats().fullScans;
|
||||
await sc.scan();
|
||||
assert.equal(sc.stats().fullScans, n, '空壳条目不该被反复重读');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('★会话文件被删后缓存条目跟着走(缓存自身不是下一个泄露源)', async () => {
|
||||
const root = makeRoot();
|
||||
try {
|
||||
writeSession(root, '--x--', 'a.jsonl',
|
||||
{ id: 's1', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' }, [info('甲')]);
|
||||
writeSession(root, '--x--', 'b.jsonl',
|
||||
{ id: 's2', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' }, [info('乙')]);
|
||||
|
||||
const sc = createSessionScanner({ sessionsDir: root });
|
||||
await sc.scan();
|
||||
assert.equal(sc.stats().tracked, 2);
|
||||
|
||||
rmSync(join(root, '--x--', 'a.jsonl'));
|
||||
const out = await sc.scan();
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(sc.stats().tracked, 1, '磁盘上没了的文件必须从缓存里消失');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('文件变小(被截断/重写)时整份重扫而不是从越界偏移读', async () => {
|
||||
const root = makeRoot();
|
||||
try {
|
||||
const file = writeSession(root, '--x--', 'a.jsonl',
|
||||
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
|
||||
[msg('很多内容'.repeat(200)), info('旧')]);
|
||||
|
||||
const sc = createSessionScanner({ sessionsDir: root });
|
||||
await sc.scan();
|
||||
const fulls = sc.stats().fullScans;
|
||||
|
||||
// 重写成更短的内容,且换了名字
|
||||
writeSession(root, '--x--', 'a.jsonl',
|
||||
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' }, [info('新')]);
|
||||
|
||||
const out = await sc.scan();
|
||||
assert.equal(out[0].name, '新');
|
||||
assert.equal(sc.stats().fullScans, fulls + 1, '变小必须触发整份重扫');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('目录不存在 → 空列表(pi 从没跑过是正常状态)', async () => {
|
||||
const sc = createSessionScanner({ sessionsDir: join(tmpdir(), `nope-${Date.now()}`) });
|
||||
assert.deepEqual(await sc.scan(), []);
|
||||
});
|
||||
|
||||
test('★读目录遇到非 ENOENT 错误必须抛出,不能返回空数组', async () => {
|
||||
// 返回空数组的语义是「平台确实没有会话」,会把服务端镜像抹掉(W-3 / N-7)。
|
||||
// 一次 EACCES 就能清空别人的补全候选 —— 必须让调用方看到失败并省略字段。
|
||||
const root = makeRoot();
|
||||
try {
|
||||
// 用一个普通文件当 sessionsDir:readdir 会给 ENOTDIR,而不是 ENOENT
|
||||
const notADir = join(root, 'file');
|
||||
writeFileSync(notADir, 'x');
|
||||
const sc = createSessionScanner({ sessionsDir: notADir });
|
||||
await assert.rejects(() => sc.scan(), (e) => e?.code === 'ENOTDIR');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('缺 sessionsDir 时当场抛错', () => {
|
||||
assert.throws(() => createSessionScanner(), /sessionsDir/);
|
||||
assert.throws(() => createSessionScanner({}), /sessionsDir/);
|
||||
});
|
||||
|
||||
test('跨多个 cwd 子目录汇总', async () => {
|
||||
const root = makeRoot();
|
||||
try {
|
||||
writeSession(root, '--a--', '1.jsonl', { id: 'a1', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/a' }, [info('甲')]);
|
||||
writeSession(root, '--b--', '2.jsonl', { id: 'b1', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/b' }, [info('乙')]);
|
||||
writeSession(root, '--b--', '3.jsonl', { id: 'b2', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/b' }, [info('丙')]);
|
||||
|
||||
const sc = createSessionScanner({ sessionsDir: root });
|
||||
const out = await sc.scan();
|
||||
assert.equal(out.length, 3);
|
||||
assert.deepEqual([...out.map((s) => s.id)].sort(), ['a1', 'b1', 'b2']);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('非 .jsonl 文件被跳过', async () => {
|
||||
const root = makeRoot();
|
||||
try {
|
||||
const dir = join(root, '--x--');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'notes.txt'), 'hello');
|
||||
writeSession(root, '--x--', 'a.jsonl', { id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' }, [info('甲')]);
|
||||
const sc = createSessionScanner({ sessionsDir: root });
|
||||
assert.equal((await sc.scan()).length, 1);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
583
web/test/manual/ui-sweep.mjs
Normal file
583
web/test/manual/ui-sweep.mjs
Normal file
@ -0,0 +1,583 @@
|
||||
/**
|
||||
* WebUI 能力巡检 —— 覆盖没有专项脚本守着的界面。
|
||||
*
|
||||
* 已有六个专项脚本各守一件事(窄屏覆盖式布局、宽屏三栏回归、深色主题、
|
||||
* 强调色对比度、收件箱分组、地址维度)。它们加起来仍有一大半界面没人看:
|
||||
* 日历三视图与事件编辑器、新建邮件的三段式补全、卡片/列表双视图、对话树、
|
||||
* 转发页、管理页四个 tab、账号页与密钥面板。这个脚本补的就是那部分。
|
||||
*
|
||||
* # 只读原则(它跑在生产库上)
|
||||
*
|
||||
* 一律不做不可逆动作:不发信、不建日程、不停用/删除 Agent、不吊销密钥、
|
||||
* 不改密码。表单只打开与填写,靠「取消」退出。
|
||||
*
|
||||
* 归档是唯一需要解释的例外:点每行的归档图标只调 `requestArchive`,那是个
|
||||
* **纯前端状态**(把该地址记进 `pendingArchive` 以渲染确认框),真正发请求的是
|
||||
* 确认框里的「确认归档」。所以这里点图标 → 验确认框 → 点取消,全程零副作用。
|
||||
*
|
||||
* # 三条定位纪律(每条都是被一次假失败教出来的)
|
||||
*
|
||||
* **一、走 title / placeholder,不走可见文字。** 侧栏图标按钮的可见文字是缩写
|
||||
* (`收件`/`联系`/`用户`),头像按钮的可见文字是用户名前两字 —— 按全名去
|
||||
* `hasText` 一个都点不到。而「新建」在侧栏(新建邮件)与日历头部(新建日程)
|
||||
* 各有一个,靠文字选会点错页面:侧栏那个有 `title`,日历那个没有。
|
||||
*
|
||||
* **二、单字按钮必须精确匹配。** 日历的视图切换是 `月`/`周`/`日` 三个单字按钮,
|
||||
* 而 `hasText` 是子串匹配 —— `日` 会先命中侧栏的 `日历`,于是「切到日视图」
|
||||
* 实际上点了导航,scale 一直停在上一个视图,然后「日视图没有小时刻度」
|
||||
* 报一个假失败。
|
||||
*
|
||||
* **三、判「某控件在不在」不搜 body 全文。** `AddressInput` 补全下拉的 hint
|
||||
* 里就写着「会话别名(new 为新建)」,全文搜索会把补全提示误当成那个只在
|
||||
* 新建会话时出现的输入框。判据要落在 placeholder / inputmode 上。
|
||||
*
|
||||
* 用法:
|
||||
* ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \
|
||||
* node web/test/manual/ui-sweep.mjs
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
const { browser, page, issues } = await openApp(WIDE);
|
||||
|
||||
let pass = 0;
|
||||
const fails = [];
|
||||
const skips = [];
|
||||
|
||||
const ok = (n, d = '') => { pass++; console.log(` 通过 ${n}${d ? ' — ' + d : ''}`); };
|
||||
const bad = (n, d = '') => { fails.push(n); console.log(` 失败 ${n}${d ? ' — ' + d : ''}`); };
|
||||
const skip = (n, why) => { skips.push(n); console.log(` 跳过 ${n} — ${why}`); };
|
||||
const check = (n, cond, d = '') => (cond ? ok(n, d) : bad(n, d));
|
||||
|
||||
async function section(title, fn) {
|
||||
console.log(`\n─── ${title} ───`);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
bad(`${title} 整段异常`, String(e?.message || e).slice(0, 150));
|
||||
}
|
||||
}
|
||||
|
||||
/** 点一个 CSS 选择器命中的元素;找不到返回 false 而不是抛。 */
|
||||
async function click(sel, wait = 0) {
|
||||
const loc = page.locator(sel).first();
|
||||
if ((await loc.count()) === 0) return false;
|
||||
await loc.click({ timeout: 5000 }).catch(() => {});
|
||||
if (wait) await page.waitForTimeout(wait);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 侧栏导航:可见文字是缩写,title 才是全名。 */
|
||||
const nav = (title, wait = 1200) => click(`button[title="${title}"]`, wait);
|
||||
|
||||
/** 按可见文字点按钮(子串匹配,用于 tab、取消这类长标签)。 */
|
||||
async function clickText(text, wait = 0) {
|
||||
const loc = page.locator('button').filter({ hasText: text }).first();
|
||||
if ((await loc.count()) === 0) return false;
|
||||
await loc.click({ timeout: 5000 }).catch(() => {});
|
||||
if (wait) await page.waitForTimeout(wait);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 按可见文字**精确**点按钮。单字标签(月/周/日)必须走这个。 */
|
||||
async function clickExact(text, wait = 0) {
|
||||
const loc = page.locator('button').filter({ hasText: new RegExp(`^${text}$`) }).first();
|
||||
if ((await loc.count()) === 0) return false;
|
||||
await loc.click({ timeout: 5000 }).catch(() => {});
|
||||
if (wait) await page.waitForTimeout(wait);
|
||||
return true;
|
||||
}
|
||||
|
||||
const has = (sel) => page.locator(sel).count().then((n) => n > 0);
|
||||
const bodyText = () => page.evaluate(() => document.body.innerText);
|
||||
const titles = () =>
|
||||
page.evaluate(() =>
|
||||
[...document.querySelectorAll('button[title]')].map((b) => b.title).slice(0, 24));
|
||||
|
||||
await page.waitForSelector('button', { timeout: 20000 });
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('实时推送与连接状态', async () => {
|
||||
check('EventSource 可用', await page.evaluate(() => typeof window.EventSource === 'function'));
|
||||
// 指示器只在异常时出声是合理设计 —— 这里要的是「不会在正常时误报断线」
|
||||
const shown = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('*')]
|
||||
.filter((e) => e.children.length === 0)
|
||||
.map((e) => (e.textContent || '').trim())
|
||||
.find((t) => /已断开|连接中|重连中/.test(t)) || null);
|
||||
check('正常状态下不误报断线', !shown, shown ? `显示了「${shown}」` : '');
|
||||
check('侧栏头像上挂着连接状态点', await has('button[title*="点击管理账号"] span'));
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('日历:月 / 周 / 日三视图 + 农历', async () => {
|
||||
if (!(await nav('日历', 3200))) return bad('找不到日历入口', (await titles()).join(' / '));
|
||||
|
||||
// 月视图与周视图都是 grid-cols-7,靠子元素数区分:月 = 7 表头 + 42 格,周 = 7 列
|
||||
const cells = () => page.locator('[class*="grid-cols-7"] > *').count();
|
||||
const hourMarks = async () => ((await bodyText()).match(/\b([01]\d|2[0-3]):00\b/g) || []).length;
|
||||
|
||||
const month = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
weekHeader: /日[\s\S]{0,8}一[\s\S]{0,8}二[\s\S]{0,8}三/.test(txt),
|
||||
lunar: (txt.match(/初[一二三四五六七八九十]|十[一二三四五]|廿[一二三四五六七八九]|正月|腊月|冬月/g) || []).length,
|
||||
scaleBtns: [...document.querySelectorAll('button')]
|
||||
.map((b) => (b.innerText || '').trim())
|
||||
.filter((t) => ['月', '周', '日'].includes(t)),
|
||||
};
|
||||
});
|
||||
check('月视图有星期表头', month.weekHeader);
|
||||
check('月视图是整月网格', (await cells()) >= 42, `${await cells()} 个格子`);
|
||||
check('显示农历', month.lunar > 0, `命中 ${month.lunar} 处`);
|
||||
check('有三档视图切换', month.scaleBtns.length === 3, month.scaleBtns.join(','));
|
||||
check('月视图不按小时排', (await hourMarks()) === 0);
|
||||
|
||||
// 周视图:7 列按天,每列头显示 M.D。它刻意**不**按小时排 ——
|
||||
// 一周 × 24 小时的格子在任何屏宽下都读不了,按小时是日视图的事
|
||||
if (await clickExact('周', 1500)) {
|
||||
const dates = ((await bodyText()).match(/\b\d{1,2}\.\d{1,2}\b/g) || []).length;
|
||||
check('周视图是 7 列', (await cells()) === 7, `${await cells()} 列`);
|
||||
check('周视图每列有日期', dates >= 7, `${dates} 个 M.D`);
|
||||
check('周视图不按小时排', (await hourMarks()) === 0);
|
||||
} else skip('周视图', '按钮不存在');
|
||||
|
||||
// 日视图:唯一按小时排的视图,HOURS 是完整 24 行
|
||||
if (await clickExact('日', 1500)) {
|
||||
const marks = await hourMarks();
|
||||
check('日视图有 24 行小时刻度', marks >= 24, `${marks} 个 HH:00`);
|
||||
} else skip('日视图', '按钮不存在');
|
||||
|
||||
await clickExact('月', 1200);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('日历:新建事件(打开后取消,不保存)', async () => {
|
||||
// 侧栏的「新建」有 title="新建邮件",日历头部那个没有 title —— 用它区分
|
||||
const calNew = page.locator('button:not([title])').filter({ hasText: /^新建$/ }).first();
|
||||
if ((await calNew.count()) === 0) return skip('新建事件', '找不到日历的新建按钮');
|
||||
await calNew.click().catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const ed = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
const inputs = [...document.querySelectorAll('input, select, textarea')];
|
||||
return {
|
||||
count: inputs.length,
|
||||
kinds: [...new Set(inputs.map((i) => i.type || i.tagName.toLowerCase()))].join(','),
|
||||
title: /标题/.test(txt),
|
||||
time: !!document.querySelector('input[type=datetime-local]'),
|
||||
remind: /提前|提醒/.test(txt),
|
||||
recur: /重复|不重复|每天|每周|每月|每年/.test(txt),
|
||||
lunar: /农历/.test(txt),
|
||||
preview: /Agent 会收到/.test(txt),
|
||||
// 附件要挂在某个 event_id 上才能上传,所以新建态**刻意**没有附件段
|
||||
// (`{editing && ...}`)—— 存盘后再打开才有
|
||||
attach: /附件(随提醒邮件一起发出)/.test(txt),
|
||||
// 单收件人时不该出现投递模式:那是多收件人才有的选择
|
||||
deliveryModeEarly: /分别发送|一起发送/.test(txt),
|
||||
};
|
||||
});
|
||||
check('有输入控件', ed.count >= 3, `${ed.count} 个(${ed.kinds})`);
|
||||
check('有标题字段', ed.title);
|
||||
check('有 datetime-local 时间选择', ed.time);
|
||||
check('有提醒提前量', ed.remind);
|
||||
check('有重复规则', ed.recur);
|
||||
check('重复规则含农历选项', ed.lunar);
|
||||
check('有「Agent 会收到」正文预览', ed.preview);
|
||||
check('新建态不显示附件段(附件需先有 event_id)', !ed.attach);
|
||||
check('收件人不足两个时不显示投递模式', !ed.deliveryModeEarly);
|
||||
|
||||
// 加两个收件人:`unusedAgents` 快捷按钮的文字是 `+ <agent名>`
|
||||
const added = await page.evaluate(() => {
|
||||
const btns = [...document.querySelectorAll('button')]
|
||||
.filter((b) => /^\+\s+\S+$/.test((b.innerText || '').trim()));
|
||||
btns.slice(0, 2).forEach((b) => b.click());
|
||||
return btns.slice(0, 2).map((b) => (b.innerText || '').trim());
|
||||
});
|
||||
await page.waitForTimeout(900);
|
||||
if (added.length < 2) {
|
||||
skip('多收件人投递模式', `只找到 ${added.length} 个快捷收件人按钮`);
|
||||
} else {
|
||||
const dm = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
separate: /分别发送/.test(txt),
|
||||
together: /一起发送/.test(txt),
|
||||
radios: document.querySelectorAll('input[type=radio]').length,
|
||||
// 两种模式的后果必须写清楚,否则人分不出该选哪个
|
||||
explains: /互相看不到/.test(txt) && /共享同一条线索/.test(txt),
|
||||
};
|
||||
});
|
||||
check('多收件人出现「分别发送」', dm.separate, added.join(' '));
|
||||
check('多收件人出现「一起发送」', dm.together);
|
||||
check('投递模式是单选而非多选', dm.radios >= 2, `${dm.radios} 个 radio`);
|
||||
check('两种模式都说明了后果', dm.explains);
|
||||
}
|
||||
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('日历:打开已有事件(附件段只在编辑态出现)', async () => {
|
||||
// 月视图格子是 div,事件胶囊才是带 title 的 button
|
||||
const chip = page.locator('[class*="grid-cols-7"] button[title]').first();
|
||||
if ((await chip.count()) === 0) return skip('已有事件编辑态', '本月没有事件可点');
|
||||
const label = await chip.getAttribute('title');
|
||||
await chip.click().catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const e = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
attach: /附件(随提醒邮件一起发出)/.test(txt),
|
||||
// 暂停/取消的事件不再触发提醒 —— 状态必须可改,否则只能删了重建
|
||||
status: !!document.querySelector('select') && /暂停|已取消/.test(txt),
|
||||
del: [...document.querySelectorAll('button')].some((b) => /删除/.test(b.innerText || '')),
|
||||
};
|
||||
});
|
||||
check('编辑已有事件时出现附件段', e.attach, label || '');
|
||||
check('可改事件状态', e.status);
|
||||
check('有删除入口(未点击)', e.del);
|
||||
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('新建邮件:整页 + 三段式补全', async () => {
|
||||
await nav('收件箱', 1000);
|
||||
if (!(await nav('新建邮件', 1500))) return bad('找不到新建邮件入口', (await titles()).join(' / '));
|
||||
|
||||
const c = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
// 「新建邮件 = 右侧整页而非弹窗」是明确的设计决定:
|
||||
// 判据是没有铺满视口的半透明遮罩
|
||||
overlay: !!document.querySelector('div.fixed.inset-0[class*="bg-black"]'),
|
||||
subject: /主题/.test(txt),
|
||||
cc: /抄送/.test(txt),
|
||||
attach: /附件/.test(txt),
|
||||
preview: /预览/.test(txt),
|
||||
};
|
||||
});
|
||||
check('是整页而非弹窗', !c.overlay);
|
||||
check('有主题', c.subject);
|
||||
check('有正文 textarea', await has('textarea'));
|
||||
check('有抄送', c.cc);
|
||||
check('有附件入口', c.attach);
|
||||
check('正文有 Markdown 预览', c.preview);
|
||||
|
||||
const addr = page.locator('input[spellcheck=false]').first();
|
||||
const typeAddr = async (v) => {
|
||||
await addr.click().catch(() => {});
|
||||
await addr.fill(v).catch(() => {});
|
||||
await page.waitForTimeout(1300);
|
||||
};
|
||||
// 补全下拉是 button 列表,取每项首行当候选
|
||||
const options = () =>
|
||||
page.evaluate(() =>
|
||||
[...document.querySelectorAll('div.absolute.z-20 button')]
|
||||
.map((b) => (b.innerText || '').trim().split('\n')[0]));
|
||||
|
||||
await typeAddr('p');
|
||||
const s1 = await options();
|
||||
check('name 段有候选', s1.includes('pi'), `${s1.length} 个:${s1.slice(0, 4).join(' ')}`);
|
||||
|
||||
await typeAddr('pi@');
|
||||
const s2 = await options();
|
||||
check('path 段有候选', s2.some((o) => o.startsWith('/')), s2.slice(0, 3).join(' '));
|
||||
|
||||
await typeAddr('pi@/home/program/agentmail.');
|
||||
const s3 = await options();
|
||||
check('session 段候选含保留字 new', s3.includes('new'), `${s3.length} 个候选`);
|
||||
|
||||
// `session_alias` 与 `max_rounds` 只在本次投递新建会话时生效 —— 界面必须据此
|
||||
// 显隐,否则人会以为续谈时也能改这两个约定
|
||||
await typeAddr('pi@/home/program/agentmail.new');
|
||||
await page.keyboard.press('Escape'); // 关掉补全,免得它盖住下面的字段
|
||||
await page.waitForTimeout(500);
|
||||
check('.new 时出现「会话别名」输入', await has('input[placeholder="refactor-auth"]'));
|
||||
check('.new 时出现「往返预算」输入', await has('input[inputmode="numeric"]'));
|
||||
|
||||
await typeAddr('pi@/home/program/agentmail.子任务-跑一条命令');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
check('续谈已有会话时隐藏「会话别名」', !(await has('input[placeholder="refactor-auth"]')));
|
||||
check('续谈已有会话时隐藏「往返预算」', !(await has('input[inputmode="numeric"]')));
|
||||
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('对话树 + 转发页(填完取消)', async () => {
|
||||
await nav('收件箱', 1300);
|
||||
|
||||
// 收件箱按会话分组:组头与邮件行的 className 都含 `w-full text-left`,
|
||||
// 靠「点完有没有出现含『发件』的 dl」区分
|
||||
let opened = false;
|
||||
for (let round = 0; round < 3 && !opened; round++) {
|
||||
const btns = await page.$$('button.w-full.text-left');
|
||||
for (const b of btns) {
|
||||
await b.click().catch(() => {});
|
||||
await page.waitForTimeout(340);
|
||||
opened = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('dl')].some((dl) => dl.textContent.includes('发件')));
|
||||
if (opened) break;
|
||||
}
|
||||
}
|
||||
if (!opened) return bad('打开一封邮件', '没找到可点开的邮件行');
|
||||
ok('打开一封邮件');
|
||||
|
||||
if (await clickText('对话树', 1800)) {
|
||||
const tree = await page.evaluate(() => ({
|
||||
back: [...document.querySelectorAll('button')].some((b) => /返回|关闭|收起/.test(b.innerText || '')),
|
||||
// 每个节点按 depth 缩进:style={{ paddingLeft: indent }}
|
||||
nodes: document.querySelectorAll('[style*="padding-left"]').length,
|
||||
}));
|
||||
check('对话树有返回出口', tree.back);
|
||||
check('对话树按层级缩进', tree.nodes > 0, `${tree.nodes} 个节点`);
|
||||
for (const t of ['返回', '关闭', '收起']) if (await clickText(t, 1000)) break;
|
||||
} else skip('对话树', '按钮不存在');
|
||||
|
||||
if (await clickText('转发', 1300)) {
|
||||
const f = await page.evaluate(() => ({
|
||||
inputs: document.querySelectorAll('input').length,
|
||||
cc: /抄送/.test(document.body.innerText),
|
||||
hint: /转发|Fwd|原文|新收件人/.test(document.body.innerText),
|
||||
}));
|
||||
check('转发页有收件人输入', f.inputs > 0, `${f.inputs} 个输入框`);
|
||||
check('转发页有抄送', f.cc);
|
||||
check('转发页有转发语境提示', f.hint);
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
} else skip('转发', '按钮不存在');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('管理页四个 tab(只看不动)', async () => {
|
||||
if (!(await nav('用户管理', 1700))) return bad('找不到管理入口', (await titles()).join(' / '));
|
||||
|
||||
const tabs = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('button')]
|
||||
.map((b) => (b.innerText || '').replace(/\s+/g, ' ').trim())
|
||||
.filter((t) => /^(用户管理|Agent 密钥|Agent 管理|模型范围)/.test(t)));
|
||||
check('四个 tab 都在', tabs.length >= 4, tabs.join(' | '));
|
||||
|
||||
const u = await page.evaluate(() => ({
|
||||
admin: /jianf/.test(document.body.innerText),
|
||||
role: /管理员/.test(document.body.innerText),
|
||||
create: [...document.querySelectorAll('button')].some((b) => /新建用户/.test(b.innerText || '')),
|
||||
}));
|
||||
check('用户列表显示管理员', u.admin);
|
||||
check('显示角色徽标', u.role);
|
||||
check('有新建用户入口', u.create);
|
||||
|
||||
if (await clickText('Agent 密钥', 1500)) {
|
||||
const k = await page.evaluate(() => {
|
||||
const codes = [...document.querySelectorAll('code')].map((c) => (c.textContent || '').trim());
|
||||
return {
|
||||
heading: /Agent 接入密钥/.test(document.body.innerText),
|
||||
n: codes.length,
|
||||
max: Math.max(0, ...codes.map((c) => c.length)),
|
||||
sample: codes.slice(0, 3),
|
||||
};
|
||||
});
|
||||
check('标题是「Agent 接入密钥」', k.heading);
|
||||
check('列出了密钥', k.n > 0, `${k.n} 条`);
|
||||
// 密钥全文只在刚签发时展示一次,列表只该给 token_hint
|
||||
check('列表不泄露密钥全文', k.max <= 24, `最长 ${k.max} 字符:${k.sample.join(' ')}`);
|
||||
|
||||
// 三种生命周期在创建表单里,表单默认收起 —— 不展开是看不到的
|
||||
if (await clickText('新建密钥', 1100)) {
|
||||
const f = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
types: ['长期', '一次性', '限时'].filter((t) => txt.includes(t)),
|
||||
hints: /永不过期/.test(txt) && /首次使用后立即失效/.test(txt),
|
||||
// Agent 面板独有的两项:绑定 Agent 名、登记插件本地生成的密钥
|
||||
agentOnly: document.querySelectorAll(
|
||||
'input[placeholder*="绑定到 Agent"], input[placeholder*="登记插件"]').length,
|
||||
};
|
||||
});
|
||||
check('三种生命周期都在', f.types.length === 3, f.types.join(','));
|
||||
check('每种都写了含义', f.hints);
|
||||
check('Agent 面板有绑定/登记两项', f.agentOnly === 2, `${f.agentOnly} 个`);
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
ok('未创建任何密钥(点了取消)');
|
||||
} else skip('密钥生命周期', '找不到新建密钥按钮');
|
||||
} else skip('Agent 密钥 tab', '按钮不存在');
|
||||
|
||||
if (await clickText('Agent 管理', 1500)) {
|
||||
const a = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
agents: ['pi', 'opencode', 'dsh', 'homeagent'].filter((n) => new RegExp(`\\b${n}\\b`).test(txt)),
|
||||
online: (txt.match(/在线/g) || []).length,
|
||||
budgetInputs: document.querySelectorAll('input[inputmode=numeric]').length,
|
||||
disable: document.querySelectorAll('button[title*="停用"], button[title*="恢复"]').length,
|
||||
del: document.querySelectorAll('button[title*="彻底删除"]').length,
|
||||
// 停用/恢复的后果必须写在界面上(密钥不会自动回来)。少了这句,
|
||||
// 实测代价是 opencode 拿旧密钥重试 18 小时、gateway 日志 2690 次 401
|
||||
warnsKeys: /密钥不会自动回来|重新签发/.test(txt),
|
||||
};
|
||||
});
|
||||
check('四个 Agent 都列出来了', a.agents.length === 4, a.agents.join(','));
|
||||
check('显示在线状态', a.online > 0, `${a.online} 处「在线」`);
|
||||
check('有默认预算输入框', a.budgetInputs >= 4, `${a.budgetInputs} 个`);
|
||||
check('有停用/恢复入口', a.disable > 0, `${a.disable} 个`);
|
||||
check('有删除入口', a.del > 0, `${a.del} 个`);
|
||||
check('说明了恢复后须重新签发密钥', a.warnsKeys);
|
||||
ok('未点击任何停用/删除(只读巡检)');
|
||||
} else skip('Agent 管理 tab', '按钮不存在');
|
||||
|
||||
if (await clickText('模型范围', 1500)) {
|
||||
const before = (await bodyText()).length;
|
||||
// 明确展开 opencode:本机只有它的目录足够大(24 个模型)。
|
||||
// 随便点第一行可能落在 homeagent —— 它一个模型都没上报,
|
||||
// 于是「清单长什么样」根本验不到。
|
||||
let expanded = false;
|
||||
for (const r of await page.$$('button')) {
|
||||
if ((await r.innerText().catch(() => '')).trim().startsWith('opencode')) {
|
||||
await r.click().catch(() => {});
|
||||
expanded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!expanded) return skip('模型清单', '找不到 opencode 那一行');
|
||||
await page.waitForTimeout(1900);
|
||||
|
||||
const m = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
len: txt.length,
|
||||
catalogHeader: /平台上报的模型/.test(txt),
|
||||
// 「配可用模型必须是选择而非手打」:清单是一排可切换的等宽按钮,
|
||||
// 不是让人敲模型名的输入框
|
||||
toggles: [...document.querySelectorAll('button')]
|
||||
.filter((b) => /font-mono/.test(b.className || '')).length,
|
||||
freeText: [...document.querySelectorAll('input')]
|
||||
.filter((i) => /model|模型/i.test(i.placeholder || '')).length,
|
||||
order: /尝试顺序|按序尝试/.test(txt),
|
||||
unrestricted: /不限定/.test(txt),
|
||||
};
|
||||
});
|
||||
check('展开后内容变多', m.len > before, `${before} → ${m.len} 字符`);
|
||||
check('列出平台上报的模型清单', m.catalogHeader);
|
||||
check('模型是点选而非手打', m.toggles > 0 && m.freeText === 0,
|
||||
`${m.toggles} 个可切换项 / ${m.freeText} 个自由输入`);
|
||||
check('说明了顺序即优先级', m.order);
|
||||
check('说明了不选 = 不限定', m.unrestricted);
|
||||
} else skip('模型范围 tab', '按钮不存在');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('账号页:密钥面板 + 主题三态', async () => {
|
||||
// 账号入口是头像按钮,可见文字是用户名前两字
|
||||
if (!(await click('button[title*="点击管理账号"]', 1700))) {
|
||||
return bad('找不到账号入口', (await titles()).join(' / '));
|
||||
}
|
||||
|
||||
const a = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
profile: /基本资料/.test(txt),
|
||||
pwd: /修改密码/.test(txt),
|
||||
clientKeys: /客户端连接密钥/.test(txt),
|
||||
cannotRegister: /不能用于注册 Agent/.test(txt),
|
||||
logout: /退出登录/.test(txt),
|
||||
themes: [...document.querySelectorAll('button[title]')]
|
||||
.map((b) => b.title)
|
||||
.filter((t) => /始终使用浅色|始终使用深色|随系统/.test(t)),
|
||||
};
|
||||
});
|
||||
check('显示基本资料', a.profile);
|
||||
check('有修改密码(未提交)', a.pwd);
|
||||
check('有客户端连接密钥面板', a.clientKeys);
|
||||
check('写明用户密钥不能注册 Agent', a.cannotRegister);
|
||||
check('主题是三态而非开关', a.themes.length === 3, a.themes.join(' / '));
|
||||
check('有退出登录(未点击)', a.logout);
|
||||
|
||||
// 同一个 KeyPanel 的 user variant:不该出现 Agent 专属的两项
|
||||
if (await clickText('新建密钥', 1100)) {
|
||||
const n = await page.evaluate(() => document.querySelectorAll(
|
||||
'input[placeholder*="绑定到 Agent"], input[placeholder*="登记插件"]').length);
|
||||
check('用户面板没有绑定/登记项', n === 0, `${n} 个`);
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
} else skip('用户密钥面板 variant 差异', '找不到新建密钥按钮');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('联系人页:列表 / 卡片双视图 + 归档确认框', async () => {
|
||||
// 中间栏的列表/卡片切换属于 ContactPanel,而它只在「联系人」视图挂载 ——
|
||||
// 在收件箱里那一栏是 MailList,找不到这个按钮
|
||||
if (!(await nav('联系人', 1600))) return bad('找不到联系人入口', (await titles()).join(' / '));
|
||||
|
||||
const c = await page.evaluate(() => ({
|
||||
heading: /联系人/.test(document.body.innerText),
|
||||
archiveBtns: document.querySelectorAll('button[title^="归档该"]').length,
|
||||
paths: (document.body.innerText.match(/\/home\/|\/tmp\/|\/root/g) || []).length,
|
||||
viewArchived: [...document.querySelectorAll('button')].some((b) => (b.innerText || '').trim() === '归档'),
|
||||
}));
|
||||
check('中间栏标题是「联系人」', c.heading);
|
||||
check('列出联系人行', c.archiveBtns > 0, `${c.archiveBtns} 行`);
|
||||
check('行内显示工作区路径', c.paths > 0, `${c.paths} 处路径`);
|
||||
check('头部有「查看已归档」开关', c.viewArchived);
|
||||
|
||||
if (!(await click('button[title="切换到卡片视图"]', 1700))) {
|
||||
skip('卡片视图', '没找到切到卡片的按钮');
|
||||
} else {
|
||||
const card = await page.evaluate(() => ({
|
||||
heading: /工作列表/.test(document.body.innerText),
|
||||
// 卡片带预算徽标:title 形如「往返预算:已用 3/20」
|
||||
budgetChip: document.querySelectorAll('[title^="往返预算"]').length,
|
||||
pref: localStorage.getItem('agentmail.contactView'),
|
||||
}));
|
||||
check('切到卡片后标题变「工作列表」', card.heading);
|
||||
check('卡片有往返预算徽标', card.budgetChip > 0, `${card.budgetChip} 个`);
|
||||
check('视图偏好落 localStorage', card.pref === 'card', `agentmail.contactView=${card.pref}`);
|
||||
|
||||
check('能切回列表视图', await click('button[title="切换到列表视图"]', 1400));
|
||||
const back = await page.evaluate(() => ({
|
||||
heading: /联系人/.test(document.body.innerText),
|
||||
pref: localStorage.getItem('agentmail.contactView'),
|
||||
}));
|
||||
check('切回后标题恢复「联系人」', back.heading);
|
||||
check('偏好跟着回到 list', back.pref === 'list', `agentmail.contactView=${back.pref}`);
|
||||
}
|
||||
|
||||
if (c.archiveBtns === 0) return skip('归档确认框', '没有归档按钮');
|
||||
// requestArchive 只写前端状态(为渲染确认框),不发请求 —— 点它是安全的
|
||||
await click('button[title^="归档该"]', 1000);
|
||||
const confirm = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
// 确认框是唯一渲染 contact.address 全文的地方,顺带验三段地址成形
|
||||
addr: (txt.match(/[a-z][\w.-]*@\/[^\s??]+/) || [null])[0],
|
||||
explains: /session 将被归档/.test(txt),
|
||||
confirmBtn: [...document.querySelectorAll('button')].some((b) => /确认归档/.test(b.innerText || '')),
|
||||
cancelBtn: [...document.querySelectorAll('button')].some((b) => (b.innerText || '').trim() === '取消'),
|
||||
};
|
||||
});
|
||||
check('确认框显示完整三段地址', !!confirm.addr && confirm.addr.includes('.'), confirm.addr || '(没找到)');
|
||||
check('确认框说明了后果', confirm.explains);
|
||||
check('确认框有确认按钮(未点击)', confirm.confirmBtn);
|
||||
check('确认框可取消', confirm.cancelBtn);
|
||||
if (confirm.cancelBtn) {
|
||||
await clickText('取消', 900);
|
||||
const gone = await page.evaluate(() =>
|
||||
![...document.querySelectorAll('button')].some((b) => /确认归档/.test(b.innerText || '')));
|
||||
check('取消后确认框消失,未归档任何会话', gone);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
console.log('\n─── JS 运行时错误 ───');
|
||||
if (issues.length === 0) ok('无 pageerror / console.error');
|
||||
else {
|
||||
bad(`${issues.length} 条运行时问题`);
|
||||
issues.slice(0, 8).forEach((i) => console.log(' ', i));
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`\n═══ ${pass} 通过 / ${fails.length} 失败 / ${skips.length} 跳过 ═══`);
|
||||
if (fails.length) { console.log('失败项:'); fails.forEach((f) => console.log(' -', f)); }
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
Reference in New Issue
Block a user