chore(release): 1.3.3 发布并退役旧联动
This commit is contained in:
1 parent
b7cdc7c9ea
commit
c0ebf88138
184 files changed
+5450
-11705
No files matched your search
@@ -20,7 +20,6 @@
|
||||
* packages/web-admin/public/favicon.png 标签页图标(64)
|
||||
* packages/web-admin/public/favicon.svg 标签页图标(内嵌 128 位图的 SVG 壳)
|
||||
* packages/web-admin/public/logo.png **界面内**的品牌标(侧栏 / 登录页,128)
|
||||
* packages/classisland-plugin/icon.png ClassIsland 插件图标(256)
|
||||
* packages/desktop-client/build/classhelper.{png,ico} 图标源在客户端的镜像
|
||||
* packages/desktop-client/build/icon.{png,ico} electron-builder 的**兜底约定名**
|
||||
* (`win.icon` 显式指向 classhelper.ico,
|
||||
@@ -204,13 +203,15 @@ app.whenReady().then(async () => {
|
||||
writeIfChanged(path.join(webPublic, 'icon-512.png'), pngBySize.get(512));
|
||||
writeIfChanged(path.join(webPublic, 'apple-touch-icon.png'), pngBySize.get(180));
|
||||
writeIfChanged(path.join(webPublic, 'favicon.png'), pngBySize.get(64));
|
||||
writeIfChanged(path.join(webPublic, 'favicon.svg'), Buffer.from(buildFaviconSvg(pngBySize.get(128)), 'utf8'));
|
||||
writeIfChanged(
|
||||
path.join(webPublic, 'favicon.svg'),
|
||||
Buffer.from(buildFaviconSvg(pngBySize.get(128)), 'utf8'),
|
||||
);
|
||||
// 界面内的品牌标(侧栏 / 登录页)。与 favicon 分开一份的理由:favicon 是给浏览器标签页用的,
|
||||
// 而这张是**渲染进页面**的(`<img src="/logo.png">`),尺寸与缓存策略都不该混在一起。
|
||||
writeIfChanged(path.join(webPublic, 'logo.png'), pngBySize.get(128));
|
||||
|
||||
// ── ClassIsland 插件(清单同目录的 icon.png,ClassIsland 按约定读取) ──────
|
||||
writeIfChanged(path.join(ROOT, 'packages', 'classisland-plugin', 'icon.png'), pngBySize.get(256));
|
||||
|
||||
// ── 桌面客户端 ──────────────────────────────────────────────────────────
|
||||
// classhelper.* 是源的镜像;icon.* 是 electron-builder 的兜底约定名(默认找 build/icon.ico),
|
||||
|
||||
+98
-12
@@ -8,13 +8,17 @@
|
||||
*
|
||||
* 做法:用管理员账号建一个随机班级码的临时班级 → 设为冒烟客户端的登录账号 →
|
||||
* 顺手给它备一份"当天的作业"(新班没有种子数据,作业看板用例要有东西可看)→ 收尾删掉。
|
||||
* 拿不到管理员账号(离线 / 没给凭据)时返回 null,调用方自行回退到"复用演示班级"。
|
||||
* 拿不到管理员账号时返回 null,调用方应中止联网验收,不得回退到真实班级或重置其密码。
|
||||
*/
|
||||
export async function provisionSmokeClass(options = {}) {
|
||||
const cleanups = [];
|
||||
const cleanup = async () => {
|
||||
for (const remove of [...cleanups].reverse()) await remove().catch(() => undefined);
|
||||
cleanups.length = 0;
|
||||
};
|
||||
const base = options.base ?? process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api';
|
||||
const admin = options.admin ?? process.env.ELECTRON_SMOKE_ADMIN ?? 'admin';
|
||||
const adminPassword =
|
||||
options.adminPassword ?? process.env.ELECTRON_SMOKE_ADMIN_PASSWORD ?? 'admin123';
|
||||
const adminPassword = options.adminPassword ?? process.env.ELECTRON_SMOKE_ADMIN_PASSWORD ?? 'admin123';
|
||||
const password = options.password ?? process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? 'smoke123456';
|
||||
const className = options.className ?? '冒烟验证班';
|
||||
const code = `SMOKE${Math.random().toString(36).slice(2, 7).toUpperCase()}`;
|
||||
@@ -35,19 +39,104 @@ export async function provisionSmokeClass(options = {}) {
|
||||
if (!token) return null;
|
||||
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||||
|
||||
const teacherUsername = `smoke_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const teacherPassword = 'smoke-test-password';
|
||||
const teacher = await fetch(`${base}/teachers`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
username: teacherUsername,
|
||||
password: teacherPassword,
|
||||
name: '冒烟验证老师',
|
||||
role: 'TEACHER',
|
||||
}),
|
||||
}).then((response) => response.json());
|
||||
const teacherId = teacher?.data?.id;
|
||||
if (!teacherId) throw new Error('无法创建独立验证教师');
|
||||
cleanups.push(() => fetch(`${base}/teachers/${teacherId}`, { method: 'DELETE', headers }));
|
||||
|
||||
const created = await fetch(`${base}/classes`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ name: className, grade: '验证', code }),
|
||||
body: JSON.stringify({
|
||||
name: className,
|
||||
grade: '验证',
|
||||
code,
|
||||
teacherId,
|
||||
enrollmentYear: 2099,
|
||||
classIndex: 1 + Math.floor(Math.random() * 99),
|
||||
}),
|
||||
}).then((response) => response.json());
|
||||
const classId = created?.data?.id;
|
||||
if (!classId) return null;
|
||||
if (!classId) throw new Error('无法创建独立验证班级');
|
||||
cleanups.push(() => fetch(`${base}/classes/${classId}`, { method: 'DELETE', headers }));
|
||||
|
||||
await fetch(`${base}/classes/${classId}/class-account`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify({ code, password }),
|
||||
});
|
||||
const course = await fetch(`${base}/courses`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ classId, name: '语文', teacherId }),
|
||||
}).then((response) => response.json());
|
||||
const courseId = course?.data?.id;
|
||||
if (!courseId) throw new Error('无法创建验证课程');
|
||||
const student = await fetch(`${base}/students`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ classId, studentNo: code, name: '冒烟验证同学' }),
|
||||
}).then((response) => response.json());
|
||||
const studentId = student?.data?.id;
|
||||
if (!studentId) throw new Error('无法创建验证学生');
|
||||
for (const [route, body] of [
|
||||
['/grades', { classId, courseId, studentId, examName: '冒烟验证', score: 80, totalScore: 100 }],
|
||||
[
|
||||
'/schedules',
|
||||
{
|
||||
classId,
|
||||
courseId,
|
||||
dayOfWeek: new Date().getDay() || 7,
|
||||
startTime: '08:00',
|
||||
endTime: '08:45',
|
||||
weekStart: 1,
|
||||
weekEnd: 20,
|
||||
},
|
||||
],
|
||||
]) {
|
||||
const response = await fetch(`${base}${route}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) throw new Error(`无法创建验证数据:${route}`);
|
||||
}
|
||||
// 图表回归需要不同科目的得分率及不同人数的等级分布。
|
||||
for (const [name, score] of [
|
||||
['英语', 80],
|
||||
['历史', 40],
|
||||
]) {
|
||||
const extra = await fetch(`${base}/courses`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ classId, name, teacherId }),
|
||||
}).then((response) => response.json());
|
||||
if (!extra?.data?.id) throw new Error('无法创建图表验证课程');
|
||||
const response = await fetch(`${base}/grades`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
classId,
|
||||
courseId: extra.data.id,
|
||||
studentId,
|
||||
examName: '冒烟验证',
|
||||
score,
|
||||
totalScore: 100,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('无法创建图表验证成绩');
|
||||
}
|
||||
|
||||
// 作业看板用例需要"某天有作业":临时班没有种子数据,这里补一条当天的
|
||||
await fetch(`${base}/homeworks`, {
|
||||
@@ -66,16 +155,13 @@ export async function provisionSmokeClass(options = {}) {
|
||||
password,
|
||||
classId,
|
||||
className,
|
||||
teacherUsername,
|
||||
teacherPassword,
|
||||
/** 收尾:删掉临时班级(连带它的班级账号/作业/通知) */
|
||||
async cleanup() {
|
||||
try {
|
||||
await fetch(`${base}/classes/${classId}`, { method: 'DELETE', headers });
|
||||
} catch {
|
||||
// 清理失败不影响结论
|
||||
}
|
||||
},
|
||||
cleanup,
|
||||
};
|
||||
} catch {
|
||||
await cleanup();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,12 @@ function todayDayOfWeek(now = new Date()) {
|
||||
* @returns {Promise<{token: string, classId: string, className: string, courseId: string,
|
||||
* scheduleId: string, inClass: boolean, period: object|null}>}
|
||||
*/
|
||||
export async function setupInClassProbe({ baseUrl, username = 'teacher1', password = 'teacher123' } = {}) {
|
||||
export async function setupInClassProbe({
|
||||
baseUrl,
|
||||
username = 'teacher1',
|
||||
password = 'teacher123',
|
||||
classId,
|
||||
} = {}) {
|
||||
const base = (baseUrl ?? 'http://127.0.0.1:4000').replace(/\/$/, '');
|
||||
|
||||
const login = await call(base, '/api/auth/login', {
|
||||
@@ -67,7 +72,11 @@ export async function setupInClassProbe({ baseUrl, username = 'teacher1', passwo
|
||||
}
|
||||
|
||||
const classes = await call(base, '/api/classes', { token });
|
||||
const targetClass = Array.isArray(classes.payload?.data) ? classes.payload.data[0] : null;
|
||||
const targetClass = Array.isArray(classes.payload?.data)
|
||||
? classId
|
||||
? classes.payload.data.find((item) => item.id === classId)
|
||||
: classes.payload.data[0]
|
||||
: null;
|
||||
if (!targetClass) throw new Error('探针失败:该教师名下没有班级,无法制造上课时段');
|
||||
|
||||
const courses = await call(base, `/api/courses?classId=${encodeURIComponent(targetClass.id)}`, {
|
||||
|
||||
+72
-211
@@ -40,8 +40,7 @@ const MENU_ITEMS = [
|
||||
{ label: '作业发布', path: '/homeworks' },
|
||||
{ label: '通知发布', path: '/notifications' },
|
||||
{ label: '成绩录入', path: '/grades' },
|
||||
// 2026-10-11 起「ClassIsland 联动」与「客户端设备」合并成这一项;
|
||||
// 门控是 `classisland:notify`(默认 ADMIN + TEACHER),所以教师也看得见。
|
||||
// 按 client:read 展示设备状态入口。
|
||||
{ label: '客户端', path: '/clients' },
|
||||
];
|
||||
|
||||
@@ -133,6 +132,55 @@ async function runAdminTeacherChecks(win) {
|
||||
return true;
|
||||
})()`);
|
||||
|
||||
for (const item of [
|
||||
{ label: '学期周次', path: '/term-weeks' },
|
||||
{ label: '毕业归档', path: '/archives' },
|
||||
{ label: '权限组', path: '/permissions' },
|
||||
]) {
|
||||
await win.webContents.executeJavaScript(
|
||||
`Array.from(document.querySelectorAll('.el-menu-item')).find(node => node.textContent.trim().startsWith(${JSON.stringify(item.label)}))?.click()`,
|
||||
);
|
||||
const ready = await waitFor(
|
||||
win,
|
||||
`location.pathname === ${JSON.stringify(item.path)} && Boolean(document.querySelector('.page-title'))`,
|
||||
8000,
|
||||
);
|
||||
record(`管理侧栏入口:${item.label}`, ready);
|
||||
}
|
||||
// 客户端推送弹窗真实切换安装时机,只检查界面,不向设备发送更新。
|
||||
await win.loadURL(`${TARGET_URL.replace(/\/$/, '')}/clients`);
|
||||
await waitFor(
|
||||
win,
|
||||
`Array.from(document.querySelectorAll('button')).some(n => n.textContent.includes('一键推送更新'))`,
|
||||
15000,
|
||||
);
|
||||
const updateTiming = await win.webContents.executeJavaScript(`(async () => {
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
Array.from(document.querySelectorAll('button')).find(n => n.textContent.includes('一键推送更新'))?.click();
|
||||
await sleep(500);
|
||||
const dialog = Array.from(document.querySelectorAll('.el-dialog')).find(n => n.textContent.includes('安装时机'));
|
||||
if (!dialog) return { reason: '未找到更新弹窗' };
|
||||
const radios = Array.from(dialog.querySelectorAll('.el-radio'));
|
||||
const pick = label => radios.find(n => n.textContent.trim() === label);
|
||||
const afterSchool = pick('放学后更新');
|
||||
const initial = afterSchool?.classList.contains('is-checked');
|
||||
pick('立即更新')?.click(); await sleep(100);
|
||||
const immediate = pick('立即更新')?.classList.contains('is-checked');
|
||||
afterSchool?.click(); await sleep(100);
|
||||
const timeItem = Array.from(dialog.querySelectorAll('.el-form-item')).find(n => n.textContent.includes('最早安装时间'));
|
||||
timeItem?.querySelector('.el-select__wrapper')?.click(); await sleep(200);
|
||||
Array.from(document.querySelectorAll('.el-select-dropdown__item')).find(n => n.textContent.trim() === '18:00')?.click();
|
||||
await sleep(150);
|
||||
const time = timeItem?.textContent.includes('18:00');
|
||||
Array.from(dialog.querySelectorAll('button')).find(n => n.textContent.trim() === '取消')?.click();
|
||||
return { initial, immediate, time, idle: Boolean(pick('空闲时更新')) };
|
||||
})()`);
|
||||
record(
|
||||
'客户端更新时机可切换立即、放学后与空闲,放学时间可设置',
|
||||
updateTiming?.initial && updateTiming?.immediate && updateTiming?.time && updateTiming?.idle,
|
||||
JSON.stringify(updateTiming),
|
||||
);
|
||||
|
||||
// 1.2) 数据库管理页(仅管理员):点开菜单 → 状态卡与备份表渲染
|
||||
const databasePage = await win.webContents.executeJavaScript(`(async () => {
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -340,9 +388,17 @@ async function main() {
|
||||
width: 1440,
|
||||
height: 900,
|
||||
show: false,
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false },
|
||||
focusable: false,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
backgroundThrottling: false,
|
||||
offscreen: true,
|
||||
},
|
||||
});
|
||||
|
||||
win.on('show', () => record('后台验证禁止显示窗口', false, '出现 show 事件'));
|
||||
win.on('focus', () => record('后台验证禁止抢占焦点', false, '出现 focus 事件'));
|
||||
// 把渲染进程的报错转发到控制台(兼容 Electron 新旧事件签名)
|
||||
win.webContents.on('console-message', (...args) => {
|
||||
const first = args[0];
|
||||
@@ -1054,7 +1110,7 @@ async function main() {
|
||||
const listDeadline = Date.now() + 8000;
|
||||
let rowText = '';
|
||||
while (Date.now() < listDeadline) {
|
||||
const rows = Array.from(document.querySelectorAll('.el-table__row'));
|
||||
const rows = Array.from(document.querySelectorAll('.timetable-cell'));
|
||||
const hit = rows.find((row) => (row.textContent ?? '').includes(subjectName));
|
||||
if (hit) {
|
||||
rowText = (hit.textContent ?? '').replace(/\\s+/g, ' ').trim();
|
||||
@@ -1074,7 +1130,7 @@ async function main() {
|
||||
|
||||
// 删掉刚写入的那条课表**以及自动建出来的课程**,保持演示数据不变
|
||||
const removedUnified = await win.webContents.executeJavaScript(`(async () => {
|
||||
const rows = Array.from(document.querySelectorAll('.el-table__row'));
|
||||
const rows = Array.from(document.querySelectorAll('.timetable-cell'));
|
||||
const row = rows.find((item) => (item.textContent ?? '').includes(${JSON.stringify(
|
||||
unifiedSubject?.subjectName ?? '',
|
||||
)}));
|
||||
@@ -1118,211 +1174,17 @@ async function main() {
|
||||
: `原因=${removedUnified?.reason ?? '-'}`,
|
||||
);
|
||||
|
||||
// 7.84 「客户端」页(合并了原来的「ClassIsland 联动」与「客户端设备」):
|
||||
// 侧栏点得进、班级卡片渲染出来、两个批量按钮在
|
||||
const hubUi = await win.webContents.executeJavaScript(`(async () => {
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
// 客户端页保留设备、远控与安装包入口;退役联动入口已移除。
|
||||
const clientsUi = await win.webContents.executeJavaScript(`(async () => {
|
||||
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
|
||||
(node.textContent ?? '').trim().startsWith('客户端'),
|
||||
);
|
||||
if (!menu) return { ok: false, reason: '教师端看不到「客户端」入口' };
|
||||
(node.textContent ?? '').trim().startsWith('客户端'));
|
||||
if (!menu) return false;
|
||||
menu.click();
|
||||
const deadline = Date.now() + 6000;
|
||||
while (Date.now() < deadline && location.pathname !== '/clients') await sleep(80);
|
||||
if (location.pathname !== '/clients') return { ok: false, reason: '点击后没有跳到 /clients' };
|
||||
await sleep(1200);
|
||||
const title = (document.querySelector('.page-title')?.textContent ?? '').trim();
|
||||
const buttons = Array.from(document.querySelectorAll('button')).map((node) => (node.textContent ?? '').trim());
|
||||
const cards = document.querySelectorAll('.class-card').length;
|
||||
// 详情抽屉:点第一张卡的「详情」,抽屉里应有两条链路的表格
|
||||
const detail = Array.from(document.querySelectorAll('.class-card .card-foot button')).find((node) =>
|
||||
(node.textContent ?? '').trim() === '详情',
|
||||
);
|
||||
if (detail) {
|
||||
detail.click();
|
||||
await sleep(900);
|
||||
}
|
||||
const drawer = document.querySelector('.el-drawer');
|
||||
const drawerText = (drawer?.textContent ?? '').trim();
|
||||
const close = document.querySelector('.el-drawer__close-btn');
|
||||
if (close) close.click();
|
||||
await sleep(400);
|
||||
return {
|
||||
ok: true,
|
||||
title,
|
||||
cards,
|
||||
hasBatchMirror: buttons.includes('批量下发课表'),
|
||||
hasBatchUpdate: buttons.includes('批量下发更新'),
|
||||
hasNotify: buttons.includes('下发提醒'),
|
||||
drawerOpened: Boolean(drawer),
|
||||
drawerHasBothSides: drawerText.includes('ClassIsland 教室端') && drawerText.includes('ClassHelper 客户端'),
|
||||
path: location.pathname,
|
||||
};
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
return location.pathname === '/clients' && document.body.innerText.includes('一键推送更新') &&
|
||||
!document.body.innerText.includes('ClassIsland 联动');
|
||||
})()`);
|
||||
record(
|
||||
'「客户端」页(合并 ClassIsland 联动 + 客户端设备):卡片渲染 + 批量下发 + 详情抽屉',
|
||||
Boolean(hubUi?.ok) &&
|
||||
hubUi?.title === '客户端' &&
|
||||
hubUi?.cards > 0 &&
|
||||
hubUi?.hasBatchMirror === true &&
|
||||
hubUi?.drawerOpened === true &&
|
||||
hubUi?.drawerHasBothSides === true,
|
||||
`path=${hubUi?.path ?? '-'} 标题=${hubUi?.title ?? '-'} 卡片=${hubUi?.cards ?? 0} ` +
|
||||
`批量下发课表=${hubUi?.hasBatchMirror ? '有' : '无'} 抽屉=${hubUi?.drawerOpened ? '开' : '未开'} ` +
|
||||
`两端清单都在=${hubUi?.drawerHasBothSides ? '是' : '否'}` +
|
||||
`${hubUi?.reason ? ` 原因=${hubUi.reason}` : ''}`,
|
||||
);
|
||||
|
||||
// 7.85 ClassIsland 联动页:班主任为本班签发设备令牌 → 页面下发提醒表单可用 → 收尾删除设备
|
||||
// (这条链路是"老师在 Web 端发提醒 → 教室里 ClassIsland 全屏弹出"的入口,必须真实点得动)
|
||||
// 这一页 2026-10-11 起**从侧栏摘掉**(常用动作合并进「客户端」),路由还在,
|
||||
// 所以这里用 history 直接过去,后面几条断言仍然盯着这页本身。
|
||||
const integrationUi = await win.webContents.executeJavaScript(`(async () => {
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
history.pushState({}, '', '/integrations');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
const deadline = Date.now() + 6000;
|
||||
while (Date.now() < deadline && location.pathname !== '/integrations') await sleep(80);
|
||||
if (location.pathname !== '/integrations') return { ok: false, reason: '没能跳到 /integrations(旧版联动页)' };
|
||||
await sleep(900);
|
||||
|
||||
const buttons = Array.from(document.querySelectorAll('button')).map((node) => (node.textContent ?? '').trim());
|
||||
const title = (document.querySelector('.page-title')?.textContent ?? '').trim();
|
||||
|
||||
// 打开「下发提醒」弹窗:表单字段齐不齐
|
||||
const notifyButton = Array.from(document.querySelectorAll('button')).find((node) =>
|
||||
(node.textContent ?? '').trim() === '下发提醒',
|
||||
);
|
||||
if (!notifyButton) return { ok: false, title, reason: '页面没有「下发提醒」按钮' };
|
||||
notifyButton.click();
|
||||
await sleep(700);
|
||||
const notifyDialog = Array.from(document.querySelectorAll('.el-dialog')).find((node) =>
|
||||
(node.querySelector('.el-dialog__title')?.textContent ?? '').includes('下发提醒到 ClassIsland'),
|
||||
);
|
||||
if (!notifyDialog) return { ok: false, title, reason: '下发提醒弹窗未打开' };
|
||||
const labels = Array.from(notifyDialog.querySelectorAll('.el-form-item__label')).map((node) =>
|
||||
(node.textContent ?? '').trim(),
|
||||
);
|
||||
const submit = Array.from(notifyDialog.querySelectorAll('.el-dialog__footer button')).some(
|
||||
(node) => (node.textContent ?? '').trim() === '立即下发',
|
||||
);
|
||||
const cancel = Array.from(document.querySelectorAll('.el-dialog__footer button')).find((node) =>
|
||||
(node.textContent ?? '').trim() === '取消',
|
||||
);
|
||||
if (cancel) cancel.click();
|
||||
await sleep(400);
|
||||
|
||||
// 打开「接入新设备」:生成令牌 → 弹窗必须给出 chci_ 明文
|
||||
// 收尾要删掉本次新增的设备,因此先记下当前已有的 id,之后按"集合差"清理
|
||||
const authHeaders = { authorization: 'Bearer ' + (localStorage.getItem('classhelper.token') ?? '') };
|
||||
const before = (await (await fetch('/api/integrations/devices', { headers: authHeaders })).json()).data ?? [];
|
||||
const beforeIds = new Set(before.map((item) => item.id));
|
||||
|
||||
const createButton = Array.from(document.querySelectorAll('button')).find((node) =>
|
||||
(node.textContent ?? '').trim() === '接入新设备',
|
||||
);
|
||||
if (!createButton) return { ok: false, title, reason: '页面没有「接入新设备」按钮' };
|
||||
createButton.click();
|
||||
await sleep(600);
|
||||
const createDialog = Array.from(document.querySelectorAll('.el-dialog')).find((node) =>
|
||||
(node.querySelector('.el-dialog__title')?.textContent ?? '').includes('接入新设备'),
|
||||
);
|
||||
if (!createDialog) return { ok: false, title, reason: '接入新设备弹窗未打开' };
|
||||
const classSelected = Boolean((createDialog.querySelector('.el-select')?.textContent ?? '').trim());
|
||||
|
||||
// 「设备名称」是 el-input,且同一弹窗里 el-select 也渲染 input,
|
||||
// 所以必须按表单项标签定位,否则会写进班级选择框里(这正是第一次写这个用例时踩的坑)
|
||||
const nameItem = Array.from(createDialog.querySelectorAll('.el-form-item')).find(
|
||||
(node) => (node.querySelector('.el-form-item__label')?.textContent ?? '').trim() === '设备名称',
|
||||
);
|
||||
const nameInput = nameItem?.querySelector('input');
|
||||
if (!nameInput) return { ok: false, title, reason: '「设备名称」输入框未找到' };
|
||||
nameInput.value = 'UI 冒烟设备';
|
||||
nameInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(200);
|
||||
const generate = Array.from(createDialog.querySelectorAll('.el-dialog__footer button')).find((node) =>
|
||||
(node.textContent ?? '').trim() === '生成设备令牌',
|
||||
);
|
||||
if (!generate) return { ok: false, title, reason: '弹窗没有「生成设备令牌」按钮' };
|
||||
generate.click();
|
||||
|
||||
const tokenDeadline = Date.now() + 8000;
|
||||
let tokenText = '';
|
||||
while (Date.now() < tokenDeadline) {
|
||||
const box = document.querySelector('.el-dialog textarea');
|
||||
tokenText = (box?.value ?? '').trim();
|
||||
if (tokenText.startsWith('chci_')) break;
|
||||
await sleep(150);
|
||||
}
|
||||
const hasCopy = Array.from(document.querySelectorAll('.el-dialog__footer button')).some((node) =>
|
||||
(node.textContent ?? '').trim() === '复制令牌',
|
||||
);
|
||||
const closeToken = Array.from(document.querySelectorAll('.el-dialog__footer button')).find((node) =>
|
||||
(node.textContent ?? '').trim() === '关闭',
|
||||
);
|
||||
if (closeToken) closeToken.click();
|
||||
await sleep(400);
|
||||
|
||||
// 用接口再确认一次"确实新建了一台设备"(界面上的令牌弹窗也可能是假象)
|
||||
const after = (await (await fetch('/api/integrations/devices', { headers: authHeaders })).json()).data ?? [];
|
||||
const created = after.filter((item) => !beforeIds.has(item.id));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
title,
|
||||
hasNotify: buttons.includes('下发提醒'),
|
||||
hasCreate: buttons.includes('接入新设备'),
|
||||
labels,
|
||||
submit,
|
||||
classSelected,
|
||||
tokenPrefixOk: tokenText.startsWith('chci_'),
|
||||
tokenLength: tokenText.length,
|
||||
hasCopy,
|
||||
path: location.pathname,
|
||||
createdCount: created.length,
|
||||
createdName: created[0]?.name ?? '',
|
||||
createdIds: created.map((item) => item.id),
|
||||
};
|
||||
})()`);
|
||||
record(
|
||||
'ClassHelper 联动页(教师为本班签发设备令牌 + 下发提醒表单)',
|
||||
Boolean(integrationUi?.ok) &&
|
||||
integrationUi?.title === 'ClassHelper 联动' &&
|
||||
integrationUi?.hasNotify === true &&
|
||||
integrationUi?.hasCreate === true &&
|
||||
integrationUi?.submit === true &&
|
||||
integrationUi?.classSelected === true &&
|
||||
integrationUi?.tokenPrefixOk === true &&
|
||||
integrationUi?.tokenLength > 40 &&
|
||||
integrationUi?.hasCopy === true &&
|
||||
integrationUi?.createdCount === 1 &&
|
||||
integrationUi?.createdName === 'UI 冒烟设备' &&
|
||||
['目标班级', '标题', '内容', '显示时长', '优先级', '语音朗读', '同步通知中心'].every((label) =>
|
||||
(integrationUi?.labels ?? []).includes(label),
|
||||
),
|
||||
`path=${integrationUi?.path ?? '-'} 标题=${integrationUi?.title ?? '-'} 表单字段=[${(integrationUi?.labels ?? []).join('/')}] ` +
|
||||
`令牌前缀=${integrationUi?.tokenPrefixOk ? 'chci_' : '(异常)'}(${integrationUi?.tokenLength ?? 0} 字符) ` +
|
||||
`新建设备=${integrationUi?.createdCount ?? 0}台(${integrationUi?.createdName ?? '-'})` +
|
||||
`${integrationUi?.reason ? ` 原因=${integrationUi.reason}` : ''}`,
|
||||
);
|
||||
|
||||
// 收尾:把本次新建的设备按 id 删掉(演示数据里不该留下"UI 冒烟设备")
|
||||
const integrationCleanup = await win.webContents.executeJavaScript(`(async () => {
|
||||
const token = localStorage.getItem('classhelper.token') ?? '';
|
||||
const headers = { authorization: 'Bearer ' + token };
|
||||
const ids = ${JSON.stringify(integrationUi?.createdIds ?? [])};
|
||||
for (const id of ids) {
|
||||
await fetch('/api/integrations/devices/' + id, { method: 'DELETE', headers });
|
||||
}
|
||||
const list = (await (await fetch('/api/integrations/devices', { headers })).json()).data ?? [];
|
||||
return { ok: true, removed: ids.length, left: list.filter((item) => ids.includes(item.id)).length };
|
||||
})()`);
|
||||
record(
|
||||
'ClassIsland 联动用例收尾:冒烟设备已删除(不污染演示数据)',
|
||||
Boolean(integrationCleanup?.ok) && integrationCleanup?.left === 0,
|
||||
`删除=${integrationCleanup?.removed ?? 0} 残留=${integrationCleanup?.left ?? '-'}` +
|
||||
(integrationCleanup?.reason ? ` 原因=${integrationCleanup.reason}` : ''),
|
||||
);
|
||||
record('客户端设备页可以进入且已移除联动入口', clientsUi === true);
|
||||
|
||||
// 7.9 管理员专属:教师录入(本轮新增「教师管理」页,仅管理员可见可用)
|
||||
await runAdminTeacherChecks(win);
|
||||
@@ -1361,13 +1223,11 @@ async function captureMobileShot(win, name) {
|
||||
* 6. 弹窗宽度自适应(不超出视口且接近整屏宽)
|
||||
* 7. 工具栏控件铺满整行
|
||||
*
|
||||
* 注意:这套检查必须让窗口真正可见(showInactive)。
|
||||
* Chromium 对隐藏的页面会冻结 CSS transition,Vue 的 <Transition> 收不到
|
||||
* transitionend,抽屉会一直停在 enter-from(表现为"点了没反应")。
|
||||
* 使用离屏渲染并关闭后台节流,让隐藏窗口的 CSS transition 正常完成。
|
||||
* 整套检查保持窗口隐藏、不可聚焦,不占用用户前台。
|
||||
*/
|
||||
async function runMobileChecks(win) {
|
||||
const MOBILE = { width: 390, height: 844 };
|
||||
win.showInactive();
|
||||
await sleep(500);
|
||||
win.setBounds({ ...win.getBounds(), width: MOBILE.width, height: MOBILE.height });
|
||||
await sleep(600);
|
||||
@@ -1576,6 +1436,7 @@ async function runMobileChecks(win) {
|
||||
}
|
||||
|
||||
async function finish(win) {
|
||||
record('全程后台结束(窗口隐藏且未聚焦)', !win.isVisible() && !win.isFocused());
|
||||
const failed = results.filter((item) => !item.ok);
|
||||
const passed = results.length - failed.length;
|
||||
console.log(`\n[ui-smoke] ${passed}/${results.length} 项通过`);
|
||||
|
||||
@@ -21,6 +21,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveElectronEnv, resolveElectronExecutable } from '../lib/electron-env.mjs';
|
||||
import { cleanupInClassProbe, setupInClassProbe } from './live-probe.mjs';
|
||||
import { provisionSmokeClass } from '../lib/smoke-class.mjs';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const electronDir = path.join(root, 'packages', 'desktop-client', 'node_modules', 'electron');
|
||||
@@ -41,7 +42,14 @@ if (!fs.existsSync(executable)) {
|
||||
const entry = path.join(root, 'scripts', 'ui-smoke', 'main.cjs');
|
||||
const target = process.env.UI_SMOKE_URL ?? 'http://127.0.0.1:4000/';
|
||||
const resultFile = process.env.UI_SMOKE_RESULT ?? path.join(root, '.cache', 'ui-smoke-result.json');
|
||||
const profileDir = path.join(root, '.cache', 'ui-smoke-profile');
|
||||
const profileDir = path.join(root, '.cache', `ui-smoke-profile-${process.pid}`);
|
||||
const startedAt = Date.now();
|
||||
const context = await provisionSmokeClass({
|
||||
base: `${new URL(target).origin}/api`,
|
||||
admin: process.env.UI_SMOKE_ADMIN ?? 'admin',
|
||||
adminPassword: process.env.UI_SMOKE_ADMIN_PASS ?? 'admin123',
|
||||
});
|
||||
if (!context) throw new Error('无法创建独立 UI 验证班级与教师,请检查测试服务端和管理员凭据');
|
||||
|
||||
// 每次运行前清空浏览器配置目录,避免 Service Worker / 缓存影响结果
|
||||
fs.rmSync(profileDir, { recursive: true, force: true });
|
||||
@@ -64,8 +72,9 @@ if (process.env.UI_SMOKE_SKIP_PROBE === '1') {
|
||||
try {
|
||||
probe = await setupInClassProbe({
|
||||
baseUrl: new URL(target).origin,
|
||||
username: process.env.UI_SMOKE_USER ?? 'teacher1',
|
||||
password: process.env.UI_SMOKE_PASS ?? 'teacher123',
|
||||
username: context.teacherUsername,
|
||||
password: context.teacherPassword,
|
||||
classId: context.classId,
|
||||
});
|
||||
if (!probe.inClass) probeError = '探针课表已创建,但服务端仍未判定为上课时段';
|
||||
console.log(
|
||||
@@ -82,8 +91,11 @@ if (process.env.UI_SMOKE_SKIP_PROBE === '1') {
|
||||
const child = spawn(executable, [entry], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env: resolveElectronEnv({
|
||||
UI_SMOKE_URL: target,
|
||||
UI_SMOKE_USER: context.teacherUsername,
|
||||
UI_SMOKE_PASS: context.teacherPassword,
|
||||
UI_SMOKE_RESULT: resultFile,
|
||||
UI_SMOKE_PROFILE: profileDir,
|
||||
UI_SMOKE_URGENT_TITLE: urgentTitle,
|
||||
@@ -91,8 +103,13 @@ const child = spawn(executable, [entry], {
|
||||
UI_SMOKE_PROBE_DETAIL: probeError,
|
||||
}),
|
||||
});
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('[ui-smoke] 后台验收超过 120 秒,终止验证进程');
|
||||
child.kill();
|
||||
}, 120_000);
|
||||
|
||||
child.on('exit', async (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (probe) {
|
||||
try {
|
||||
const removed = await cleanupInClassProbe(probe, { notificationTitle: urgentTitle });
|
||||
@@ -106,6 +123,8 @@ child.on('exit', async (code) => {
|
||||
}
|
||||
}
|
||||
|
||||
await context.cleanup();
|
||||
console.log(`[ui-smoke] 后台验收总耗时:${((Date.now() - startedAt) / 1000).toFixed(1)} 秒`);
|
||||
if (code === 0) {
|
||||
console.log(`[ui-smoke] 全部通过,结果文件:${resultFile}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/** 用隔离的配置与模拟操作系统验证心跳、远控与更新,绝不执行真实关机或安装器。 */
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const requireClient = createRequire(path.join(root, 'packages/desktop-client/package.json'));
|
||||
const { build } = requireClient('esbuild');
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'classhelper-agent-'));
|
||||
const originalFetch = globalThis.fetch;
|
||||
let passed = 0;
|
||||
const h = {
|
||||
config: {
|
||||
serverUrl: 'http://127.0.0.1:4199',
|
||||
token: 'test-session',
|
||||
deviceKey: 'regression-device-key',
|
||||
autoUpdate: true,
|
||||
pendingUpdateVersion: '',
|
||||
pendingUpdateFile: '',
|
||||
},
|
||||
version: '1.0.0',
|
||||
visible: false,
|
||||
inClass: false,
|
||||
confirmed: true,
|
||||
spawnCode: 0,
|
||||
spawnError: false,
|
||||
spawned: [],
|
||||
acked: [],
|
||||
ackFails: false,
|
||||
quitCount: 0,
|
||||
};
|
||||
globalThis.__clientAgentTest = h;
|
||||
function check(name, fn) {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
h.spawn = (file, args, options) => {
|
||||
h.spawned.push({ file, args, options });
|
||||
const child = new EventEmitter();
|
||||
child.unref = () => {};
|
||||
queueMicrotask(() => {
|
||||
if (h.holdSpawn) {
|
||||
h.heldChild = child;
|
||||
return;
|
||||
}
|
||||
if (h.spawnError) child.emit('error', new Error('模拟权限不足'));
|
||||
else {
|
||||
child.emit('spawn');
|
||||
child.emit('exit', h.spawnCode);
|
||||
}
|
||||
});
|
||||
return child;
|
||||
};
|
||||
const mockSources = {
|
||||
electron: `const h=globalThis.__clientAgentTest;
|
||||
export const BrowserWindow={getAllWindows:()=>[{isDestroyed:()=>false,isVisible:()=>h.visible}]};
|
||||
export const app={getVersion:()=>h.version,getPath:(name)=>name==='exe'?'C:/测试目录/ClassHelper.exe':${JSON.stringify(temp)},quit:()=>h.quitCount++};
|
||||
export const dialog={showMessageBox:async()=>({response:h.confirmed?1:0})};`,
|
||||
config: `const h=globalThis.__clientAgentTest; export const getConfig=()=>h.config;
|
||||
export const saveConfig=(patch)=>Object.assign(h.config,patch);`,
|
||||
island: `export const island={getState:()=>({inClass:globalThis.__clientAgentTest.inClass})};`,
|
||||
logger: `export const logger={info:()=>{},warn:()=>{},error:()=>{},debug:()=>{}};`,
|
||||
process: `export const spawn=(...args)=>globalThis.__clientAgentTest.spawn(...args);`,
|
||||
};
|
||||
try {
|
||||
const bundle = path.join(temp, 'agent.mjs');
|
||||
await build({
|
||||
entryPoints: [path.join(root, 'packages/desktop-client/src/main/client-agent.ts')],
|
||||
outfile: bundle,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
{
|
||||
name: '模拟操作系统',
|
||||
setup(b) {
|
||||
b.onResolve({ filter: /^(electron|node:child_process)$/ }, (args) => ({
|
||||
path: args.path === 'electron' ? 'electron' : 'process',
|
||||
namespace: 'mock',
|
||||
}));
|
||||
b.onResolve({ filter: /^\.\/(config|island|logger)\.js$/ }, (args) => ({
|
||||
path: path.basename(args.path, '.js'),
|
||||
namespace: 'mock',
|
||||
}));
|
||||
b.onLoad({ filter: /.*/, namespace: 'mock' }, (args) => ({
|
||||
contents: mockSources[args.path],
|
||||
loader: 'js',
|
||||
}));
|
||||
b.onLoad({ filter: /client-agent\.ts$/ }, async (args) => ({
|
||||
contents:
|
||||
fs.readFileSync(args.path, 'utf8') +
|
||||
`\nexport { beat, handleCommand, executeCommand };
|
||||
export { syncUpdatePolicy, considerUpdate, maybeInstallPendingUpdate, setQuitHandler } from './auto-update.js';`,
|
||||
loader: 'ts',
|
||||
}));
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const agent = await import(pathToFileURL(bundle).href);
|
||||
agent.setQuitHandler(() => h.quitCount++);
|
||||
let responseData = {
|
||||
deviceId: 'test-device',
|
||||
updateTargetVersion: null,
|
||||
update: null,
|
||||
command: null,
|
||||
updateMode: 'idle',
|
||||
updateNotBefore: null,
|
||||
};
|
||||
globalThis.fetch = async (url, options) => {
|
||||
if (url.includes('/ack')) {
|
||||
h.acked.push(JSON.parse(options.body));
|
||||
return { ok: !h.ackFails, status: h.ackFails ? 503 : 200 };
|
||||
}
|
||||
if (url.includes('/heartbeat'))
|
||||
return { ok: true, status: 200, json: async () => ({ success: true, data: responseData }) };
|
||||
// 下载故意暂不完成:心跳必须仍然返回。
|
||||
return {
|
||||
ok: true,
|
||||
body: {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
await new Promise(() => {});
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
const command = (id) => ({
|
||||
id,
|
||||
deviceId: 'test-device',
|
||||
kind: 'lock',
|
||||
payload: '验证',
|
||||
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
||||
});
|
||||
responseData.command = command('from-heartbeat');
|
||||
await agent.beat();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
check('正确解包统一响应并执行心跳里的指令、送回执行结果', () => {
|
||||
assert.equal(h.spawned.length, 1);
|
||||
assert.equal(h.acked[0].ok, true);
|
||||
});
|
||||
h.confirmed = false;
|
||||
await agent.handleCommand(command('cancel'));
|
||||
check('本机取消不执行系统命令且失败回执', () => {
|
||||
assert.equal(h.spawned.length, 1);
|
||||
assert.equal(h.acked.at(-1).ok, false);
|
||||
});
|
||||
h.confirmed = true;
|
||||
h.spawnCode = 5;
|
||||
const failed = await agent.executeCommand('shutdown');
|
||||
check('系统命令非零退出不再谎报成功,关机保留回执时间', () => {
|
||||
assert.equal(failed.ok, false);
|
||||
assert.deepEqual(h.spawned.at(-1).args, ['/s', '/t', '5']);
|
||||
});
|
||||
h.spawnCode = 0;
|
||||
h.spawnError = true;
|
||||
const startFailed = await agent.executeCommand('restart');
|
||||
check('系统进程启动失败得到失败结果', () => assert.equal(startFailed.ok, false));
|
||||
h.spawnError = false;
|
||||
h.ackFails = true;
|
||||
await agent.handleCommand(command('retry'));
|
||||
const spawnCount = h.spawned.length;
|
||||
h.ackFails = false;
|
||||
await agent.handleCommand(command('retry'));
|
||||
check('回执失败重试时不重复执行命令', () => assert.equal(h.spawned.length, spawnCount));
|
||||
const restartedAgent = await import(`${pathToFileURL(bundle).href}?restart=1`);
|
||||
await restartedAgent.handleCommand(command('retry'));
|
||||
check('客户端重启后从磁盘恢复结果,不重复执行系统命令', () => assert.equal(h.spawned.length, spawnCount));
|
||||
|
||||
h.holdSpawn = true;
|
||||
const ackCount = h.acked.length;
|
||||
const executing = agent.handleCommand(command('still-executing'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await agent.handleCommand(command('still-executing'));
|
||||
check('执行中收到重复心跳不会提前回传中间结果', () => assert.equal(h.acked.length, ackCount));
|
||||
h.holdSpawn = false;
|
||||
h.heldChild.emit('exit', 0);
|
||||
await executing;
|
||||
|
||||
responseData = {
|
||||
...responseData,
|
||||
command: null,
|
||||
updateTargetVersion: '2.0.0',
|
||||
updateMode: 'after-school',
|
||||
updateNotBefore: new Date(Date.now() + 3600000).toISOString(),
|
||||
update: {
|
||||
version: '2.0.0',
|
||||
fileName: 'test-setup.exe',
|
||||
url: '/download',
|
||||
sha256: 'a'.repeat(64),
|
||||
size: 100,
|
||||
},
|
||||
};
|
||||
await Promise.race([
|
||||
agent.beat(),
|
||||
new Promise((_, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('下载阻塞了心跳')), 1000);
|
||||
timer.unref();
|
||||
}),
|
||||
]);
|
||||
check('慢速下载不阻塞心跳', () => assert.equal(agent.getClientAgentStatus().updateTargetVersion, '2.0.0'));
|
||||
const installer = path.join(temp, 'updates', 'verified-setup.exe');
|
||||
fs.mkdirSync(path.dirname(installer), { recursive: true });
|
||||
fs.writeFileSync(installer, '模拟安装器');
|
||||
Object.assign(h.config, { pendingUpdateVersion: '2.0.0', pendingUpdateFile: installer });
|
||||
await agent.maybeInstallPendingUpdate();
|
||||
check('放学前即使窗口隐藏也不安装', () => assert.equal(h.quitCount, 0));
|
||||
agent.syncUpdatePolicy({ ...responseData, updateNotBefore: new Date(Date.now() - 60_000).toISOString() });
|
||||
h.visible = true;
|
||||
h.inClass = true;
|
||||
const beforeSchoolInstall = h.spawned.length;
|
||||
await agent.maybeInstallPendingUpdate();
|
||||
check('到达放学时间但仍在上课时继续等待', () => assert.equal(h.spawned.length, beforeSchoolInstall));
|
||||
h.inClass = false;
|
||||
h.spawnError = true;
|
||||
await agent.maybeInstallPendingUpdate();
|
||||
check('放学后无需隐藏窗口,安装助手启动失败时保留当前进程', () => {
|
||||
assert.equal(h.spawned.length, beforeSchoolInstall + 1);
|
||||
assert.equal(h.quitCount, 0);
|
||||
});
|
||||
h.spawnError = false;
|
||||
agent.syncUpdatePolicy({ ...responseData, updateTargetVersion: null });
|
||||
await agent.maybeInstallPendingUpdate();
|
||||
check('取消下发清除安装意图', () => assert.equal(h.config.pendingUpdateVersion, ''));
|
||||
check('取消下发会删除已下载的受管安装包', () => assert.equal(fs.existsSync(installer), false));
|
||||
fs.writeFileSync(installer, '模拟安装器');
|
||||
agent.syncUpdatePolicy({ ...responseData, updateMode: 'immediate' });
|
||||
Object.assign(h.config, { pendingUpdateVersion: '2.0.0', pendingUpdateFile: installer, autoUpdate: false });
|
||||
await agent.maybeInstallPendingUpdate();
|
||||
check('本机关掉自动更新时立即策略也不安装', () => assert.equal(h.quitCount, 0));
|
||||
h.config.autoUpdate = true;
|
||||
h.visible = true;
|
||||
h.inClass = true;
|
||||
await agent.maybeInstallPendingUpdate();
|
||||
check('立即更新在校验包就绪后启动等待旧进程退出的安装助手', () => {
|
||||
assert.equal(h.quitCount, 1);
|
||||
assert.equal(h.spawned.at(-1).file, 'powershell.exe');
|
||||
const script = Buffer.from(h.spawned.at(-1).args.at(-1), 'base64').toString('utf16le');
|
||||
assert.match(script, /Wait-Process -Id/);
|
||||
assert.match(script, /-ArgumentList '\/S','--updated','--force-run'/);
|
||||
assert.match(script, /if \(\$installer.ExitCode -ne 0/);
|
||||
assert.match(script, /-WindowStyle|Start-Process/);
|
||||
});
|
||||
console.log(`客户端代理回归:${passed}/${passed} 项通过`);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
delete globalThis.__clientAgentTest;
|
||||
fs.rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/** 用临时旧版 Prisma 数据库验收覆盖升级,避免回放旧 SQL 或丢失业务数据。 */
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const requireServer = createRequire(path.join(root, 'packages/server/package.json'));
|
||||
const { createClient } = requireServer('@libsql/client');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'classhelper-upgrade-'));
|
||||
const databaseUrl = `file:${path.join(dir, 'upgrade.db').replaceAll('\\', '/')}`;
|
||||
const client = createClient({ url: databaseUrl });
|
||||
try {
|
||||
const migrationRoot = path.join(root, 'packages/server/prisma/migrations');
|
||||
const names = fs
|
||||
.readdirSync(migrationRoot)
|
||||
.filter((name) => fs.existsSync(path.join(migrationRoot, name, 'migration.sql')))
|
||||
.sort();
|
||||
assert.equal(names.at(-1), '20261017090000_permission_read_keys');
|
||||
const baseline = names.indexOf('20261016090000_retire_integration_and_schedule_updates');
|
||||
await client.execute(
|
||||
'CREATE TABLE "_prisma_migrations" (migration_name TEXT, finished_at DATETIME, rolled_back_at DATETIME)',
|
||||
);
|
||||
for (const name of names.slice(0, baseline)) {
|
||||
const sql = fs.readFileSync(path.join(migrationRoot, name, 'migration.sql'), 'utf8');
|
||||
await client.executeMultiple(sql);
|
||||
await client.execute({
|
||||
sql: 'INSERT INTO "_prisma_migrations" VALUES (?, CURRENT_TIMESTAMP, NULL)',
|
||||
args: [name],
|
||||
});
|
||||
}
|
||||
await client.execute(
|
||||
`INSERT INTO "User" (id,username,passwordHash,name,role,updatedAt) VALUES ('u','fixture','unused','升级测试','ADMIN',CURRENT_TIMESTAMP)`,
|
||||
);
|
||||
await client.execute(
|
||||
`INSERT INTO "Class" (id,name,grade,code,teacherId,updatedAt) VALUES ('c','升级保留班级','高一','TEST','u',CURRENT_TIMESTAMP)`,
|
||||
);
|
||||
await client.execute(`INSERT INTO "Course" (id,name,classId,teacherId) VALUES ('course','语文','c','u')`);
|
||||
await client.execute(
|
||||
`INSERT INTO "Schedule" (id,classId,courseId,dayOfWeek,startTime,endTime,source,updatedAt) VALUES ('s','c','course',1,'08:00','08:45','classisland',CURRENT_TIMESTAMP)`,
|
||||
);
|
||||
await client.execute(
|
||||
`INSERT INTO "PermissionGroup" (id,name,updatedAt) VALUES ('custom','教师默认权限',CURRENT_TIMESTAMP)`,
|
||||
);
|
||||
const run = (expected) => {
|
||||
const script = `const {ensureSchema}=await import('./packages/server/src/lib/db-bootstrap.ts');
|
||||
const result=await ensureSchema(); if(result.applied!==${expected})throw new Error(JSON.stringify(result));
|
||||
const {ensureDefaultGroups}=await import('./packages/server/src/lib/permissions.ts');
|
||||
await ensureDefaultGroups();process.exit(0);`;
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--import', './packages/server/node_modules/tsx/dist/loader.mjs', '--input-type=module', '-e', script],
|
||||
{
|
||||
cwd: root,
|
||||
env: { ...process.env, DATABASE_URL: databaseUrl, DATABASE_PROVIDER: 'sqlite' },
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
};
|
||||
run(names.length - baseline);
|
||||
const tables = (await client.execute(`SELECT name FROM sqlite_master WHERE type='table'`)).rows.map(
|
||||
(row) => row.name,
|
||||
);
|
||||
assert.ok(!tables.includes('IntegrationDevice') && !tables.includes('ClassIslandPush'));
|
||||
assert.equal((await client.execute(`SELECT name FROM "Class" WHERE id='c'`)).rows[0].name, '升级保留班级');
|
||||
assert.equal((await client.execute(`SELECT source FROM "Schedule" WHERE id='s'`)).rows[0].source, 'manual');
|
||||
const defaults = (await client.execute(`SELECT id,name FROM "PermissionGroup" WHERE defaultRole='TEACHER'`))
|
||||
.rows;
|
||||
assert.equal(defaults.length, 1);
|
||||
assert.notEqual(defaults[0].name, '教师默认权限');
|
||||
await client.execute({ sql: 'DELETE FROM "PermissionGroupItem" WHERE groupId=?', args: [defaults[0].id] });
|
||||
run(0);
|
||||
assert.equal(
|
||||
(
|
||||
await client.execute({
|
||||
sql: 'SELECT count(*) AS n FROM "PermissionGroupItem" WHERE groupId=?',
|
||||
args: [defaults[0].id],
|
||||
})
|
||||
).rows[0].n,
|
||||
0,
|
||||
);
|
||||
console.log(
|
||||
'数据库升级回归通过:接纳 Prisma 迁移记录、保留业务课表、移除联动、避开默认组重名、重启保留空权限组。',
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
await fsp
|
||||
.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
.catch((error) => console.error('清理临时测试库失败:', error.message));
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/** 针对独立测试服务验证默认权限、远控回执与更新时机;不运行系统指令。 */
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const base = process.env.VERIFY_BASE_URL ?? 'http://127.0.0.1:4199';
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
let passed = 0;
|
||||
async function api(route, token, body, method = body === undefined ? 'GET' : 'POST') {
|
||||
const res = await fetch(`${base}/api${route}`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, ...(await res.json()) };
|
||||
}
|
||||
function check(name, value) {
|
||||
assert.ok(value, name);
|
||||
passed++;
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
const admin = (await api('/auth/login', null, { username: 'admin', password: 'admin123' })).data.token;
|
||||
const teacher = (await api('/auth/login', null, { username: 'teacher1', password: 'teacher123' })).data.token;
|
||||
const classLogin = await api('/auth/class-login', null, { code: 'G101', password: '123456' });
|
||||
const classToken = classLogin.data.token;
|
||||
const groups = (await api('/permissions/groups', admin)).data;
|
||||
const teacherDefault = groups.find((g) => g.defaultRole === 'TEACHER');
|
||||
const adminDefault = groups.find((g) => g.defaultRole === 'ADMIN');
|
||||
const key = `api-regression-${Date.now()}`;
|
||||
let deviceId, scheduleId, staffDeviceId;
|
||||
const version = `99.0.0-regression-${Date.now()}`;
|
||||
const releaseDir = path.join(root, 'packages/data/client-releases', version);
|
||||
const releaseName = `ClassHelper-${version}-x64-client-setup.exe`;
|
||||
try {
|
||||
check('角色默认组已创建且可以识别对应角色', teacherDefault && adminDefault);
|
||||
await api(`/permissions/groups/${teacherDefault.id}`, admin, { name: '回归教师默认组', keys: [] }, 'PATCH');
|
||||
let mine = await api('/permissions/me', teacher);
|
||||
check('未分组教师使用数据库默认组,空组真正收回权限', mine.data.permissions.length === 0);
|
||||
check('默认组收权后路由实时拒绝', (await api('/clients/devices', teacher)).status === 403);
|
||||
await api(
|
||||
`/permissions/groups/${teacherDefault.id}`,
|
||||
admin,
|
||||
{ keys: ['client:read', 'client:manage', 'client:update-push', 'remote:control', 'class:read'] },
|
||||
'PATCH',
|
||||
);
|
||||
mine = await api('/permissions/me', teacher);
|
||||
check('默认组改名后继续生效,可赋予远控与推送权限', mine.data.permissions.includes('remote:control'));
|
||||
check('拥有推送权限的教师可读取安装包列表', (await api('/update/client/releases', teacher)).status === 200);
|
||||
await api(`/permissions/groups/${adminDefault.id}`, admin, { keys: [] }, 'PATCH');
|
||||
mine = await api('/permissions/me', admin);
|
||||
check(
|
||||
'管理员默认组可收权,保留两个防锁死入口',
|
||||
mine.data.permissions.length === 2 &&
|
||||
mine.data.permissions.includes('permission:manage') &&
|
||||
mine.data.permissions.includes('system:manage'),
|
||||
);
|
||||
await api(`/permissions/groups/${adminDefault.id}`, admin, { keys: adminDefault.keys }, 'PATCH');
|
||||
|
||||
const beat = await api('/clients/heartbeat', classToken, {
|
||||
agentProtocol: 2,
|
||||
deviceKey: key,
|
||||
name: '回归设备',
|
||||
clientVersion: '1.0.0',
|
||||
platform: 'win32-x64',
|
||||
autoUpdate: false,
|
||||
});
|
||||
deviceId = beat.data.deviceId;
|
||||
check('班级端心跳返回统一响应与设备编号', beat.success && deviceId);
|
||||
let devices = (await api('/clients/devices', admin)).data;
|
||||
check(
|
||||
'设备是否接受自动更新与客户端真实配置一致',
|
||||
devices.find((d) => d.id === deviceId)?.autoUpdate === false,
|
||||
);
|
||||
const cmd = await api('/clients/commands', admin, {
|
||||
deviceIds: [deviceId],
|
||||
kind: 'lock',
|
||||
note: '回归,不执行真实锁屏',
|
||||
});
|
||||
check('指令成功入队', cmd.data.succeeded === 1);
|
||||
const received = (await api('/clients/heartbeat', classToken, { agentProtocol: 2, deviceKey: key })).data;
|
||||
check('下次心跳带回待执行指令', received.command?.kind === 'lock');
|
||||
const legacy = await api('/clients/heartbeat', classToken, { deviceKey: key });
|
||||
check(
|
||||
'兼容旧客户端读取顶层指令',
|
||||
legacy.command?.id === received.command.id && legacy.data.command.id === legacy.command.id,
|
||||
);
|
||||
const wrong = await api(`/clients/commands/${received.command.id}/ack`, classToken, {
|
||||
deviceKey: 'wrong-device-key',
|
||||
ok: true,
|
||||
});
|
||||
check('回执拒绝其他设备身份', wrong.status === 403);
|
||||
await api(`/clients/commands/${received.command.id}/ack`, classToken, {
|
||||
agentProtocol: 2,
|
||||
deviceKey: key,
|
||||
ok: false,
|
||||
result: '模拟系统拒绝',
|
||||
});
|
||||
await api(`/clients/commands/${received.command.id}/ack`, classToken, {
|
||||
agentProtocol: 2,
|
||||
deviceKey: key,
|
||||
ok: true,
|
||||
result: '不应覆盖',
|
||||
});
|
||||
devices = (await api('/clients/devices', admin)).data;
|
||||
check(
|
||||
'回执幂等并在设备页保留失败原因',
|
||||
devices.find((d) => d.id === deviceId)?.lastCommand?.result.includes('模拟系统拒绝'),
|
||||
);
|
||||
|
||||
fs.mkdirSync(releaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(releaseDir, releaseName), '用于下载校验的测试载荷,不是安装器');
|
||||
const registered = await api('/update/client/releases', admin, { version, fileName: releaseName });
|
||||
check('本地缓存安装包可登记', registered.status === 201);
|
||||
staffDeviceId = (await api('/clients/heartbeat', admin, { agentProtocol: 2, deviceKey: `${key}-staff` }))
|
||||
.data.deviceId;
|
||||
const deniedCommand = await api('/clients/commands', teacher, { deviceIds: [staffDeviceId], kind: 'lock' });
|
||||
check(
|
||||
'教师获远控权限后仍不能操作班级范围外设备',
|
||||
deniedCommand.data.failed === 1 && deniedCommand.data.succeeded === 0,
|
||||
);
|
||||
check(
|
||||
'教师获更新权限后不能越过班级范围推送',
|
||||
(await api('/clients/updates', teacher, { deviceIds: [staffDeviceId], version, mode: 'immediate' }))
|
||||
.status === 400,
|
||||
);
|
||||
check(
|
||||
'教师获设备管理权限后不能删除范围外设备',
|
||||
(await api(`/clients/devices/${staffDeviceId}`, teacher, undefined, 'DELETE')).status === 404,
|
||||
);
|
||||
let pushed = await api('/clients/updates', teacher, {
|
||||
deviceIds: [deviceId],
|
||||
version,
|
||||
mode: 'after-school',
|
||||
afterSchoolTime: '23:45',
|
||||
});
|
||||
check('教师获授权后可推送放学后更新', pushed.status === 200);
|
||||
let scheduled = (await api('/clients/heartbeat', classToken, { agentProtocol: 2, deviceKey: key })).data;
|
||||
check(
|
||||
'更新策略与下载信息随心跳返回',
|
||||
scheduled.updateMode === 'after-school' &&
|
||||
scheduled.update?.sha256?.length === 64 &&
|
||||
new Date(scheduled.updateNotBefore).getHours() === 23,
|
||||
);
|
||||
const legacyScheduled = await api('/clients/heartbeat', classToken, { deviceKey: key });
|
||||
const downloaded = await fetch(`${base}${scheduled.update.url}`, {
|
||||
headers: { authorization: `Bearer ${classToken}` },
|
||||
});
|
||||
const downloadedBytes = Buffer.from(await downloaded.arrayBuffer());
|
||||
check(
|
||||
'班级端可下载安装包,文件长度与 SHA256 匹配',
|
||||
downloaded.ok &&
|
||||
downloadedBytes.length === scheduled.update.size &&
|
||||
crypto.createHash('sha256').update(downloadedBytes).digest('hex') === scheduled.update.sha256,
|
||||
);
|
||||
check(
|
||||
'旧客户端放学前不拿到安装包',
|
||||
Date.now() >= Date.parse(scheduled.updateNotBefore) || legacyScheduled.update === null,
|
||||
);
|
||||
await api('/clients/updates', admin, { deviceIds: [deviceId], version, mode: 'immediate' });
|
||||
scheduled = (await api('/clients/heartbeat', classToken, { agentProtocol: 2, deviceKey: key })).data;
|
||||
check(
|
||||
'同一版本可从延后改成立即安装',
|
||||
scheduled.updateMode === 'immediate' && scheduled.updateNotBefore === null,
|
||||
);
|
||||
check(
|
||||
'非法放学时间被拒绝',
|
||||
(
|
||||
await api('/clients/updates', admin, {
|
||||
deviceIds: [deviceId],
|
||||
version,
|
||||
mode: 'after-school',
|
||||
afterSchoolTime: '29:99',
|
||||
})
|
||||
).status === 422,
|
||||
);
|
||||
const testClassId = classLogin.data.user.classId;
|
||||
const courses = (await api(`/courses?classId=${testClassId}`, admin)).data;
|
||||
const schedule = await api('/schedules', admin, {
|
||||
classId: testClassId,
|
||||
courseId: courses[0].id,
|
||||
dayOfWeek: new Date().getDay() || 7,
|
||||
startTime: '23:58',
|
||||
endTime: '23:59',
|
||||
weekStart: 1,
|
||||
weekEnd: 20,
|
||||
});
|
||||
scheduleId = schedule.data?.id;
|
||||
check('创建放学时间回归课表', schedule.status === 201 && scheduleId);
|
||||
await api('/clients/updates', admin, {
|
||||
all: true,
|
||||
version,
|
||||
mode: 'after-school',
|
||||
afterSchoolTime: '00:01',
|
||||
});
|
||||
const allScheduled = (await api('/clients/heartbeat', classToken, { agentProtocol: 2, deviceKey: key }))
|
||||
.data;
|
||||
check(
|
||||
'全部设备推送仍按各班当天最后一节课计算放学时间',
|
||||
new Date(allScheduled.updateNotBefore).getHours() === 23 &&
|
||||
new Date(allScheduled.updateNotBefore).getMinutes() === 59,
|
||||
);
|
||||
await api('/clients/updates', admin, { deviceIds: [deviceId], clear: true });
|
||||
scheduled = (await api('/clients/heartbeat', classToken, { agentProtocol: 2, deviceKey: key })).data;
|
||||
check(
|
||||
'取消推送会清除目标版本与时间',
|
||||
scheduled.updateTargetVersion === null && scheduled.updateNotBefore === null && scheduled.update === null,
|
||||
);
|
||||
check('退役联动接口返回 404', (await api('/integrations/classisland/status', admin)).status === 404);
|
||||
check(
|
||||
'退役通知渠道接口返回 404',
|
||||
(await api(`/classes/${classLogin.data.user.classId}/notification-channel`, classToken)).status === 404,
|
||||
);
|
||||
} finally {
|
||||
await api(
|
||||
`/permissions/groups/${adminDefault.id}`,
|
||||
admin,
|
||||
{ name: adminDefault.name, keys: adminDefault.keys },
|
||||
'PATCH',
|
||||
);
|
||||
await api(
|
||||
`/permissions/groups/${teacherDefault.id}`,
|
||||
admin,
|
||||
{ name: teacherDefault.name, keys: teacherDefault.keys },
|
||||
'PATCH',
|
||||
);
|
||||
if (deviceId) await api(`/clients/devices/${deviceId}`, admin, undefined, 'DELETE');
|
||||
if (staffDeviceId) await api(`/clients/devices/${staffDeviceId}`, admin, undefined, 'DELETE');
|
||||
if (scheduleId) await api(`/schedules/${scheduleId}`, admin, undefined, 'DELETE');
|
||||
await api(`/update/client/releases/${version}`, admin, undefined, 'DELETE');
|
||||
fs.rmSync(releaseDir, { recursive: true, force: true });
|
||||
}
|
||||
console.log(`客户端 API 回归:${passed}/${passed} 项通过`);
|
||||
@@ -13,7 +13,7 @@
|
||||
* 联网用例需要后端在 http://127.0.0.1:4000(默认自动探测;探测不到时退化为离线用例)。
|
||||
* 退出码:0 = 两次启动都全绿且 `packaged: true`。
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -38,7 +38,8 @@ const installedExe = path.join(
|
||||
const exe = path.resolve(argOf('--exe') ?? installedExe);
|
||||
const classCode = argOf('--code') ?? process.env.ELECTRON_SMOKE_CLASS_CODE ?? 'G101';
|
||||
const classPassword = argOf('--password') ?? process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '123456';
|
||||
const forceOffline = argv.includes('--offline');
|
||||
const mode = argv.includes('--visual') ? 'visual' : 'quick';
|
||||
const forceOffline = !argv.includes('--online') || argv.includes('--offline');
|
||||
|
||||
if (!fs.existsSync(exe)) {
|
||||
console.error(`[packaged-smoke] 找不到打包后的 EXE:${exe}`);
|
||||
@@ -48,6 +49,53 @@ if (!fs.existsSync(exe)) {
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
if (mode === 'quick') {
|
||||
const backgroundMarker = Buffer.from('ELECTRON_SMOKE_MODE');
|
||||
const candidates = [
|
||||
path.join(path.dirname(exe), 'resources/app.asar'),
|
||||
path.join(path.dirname(exe), 'resources/app/dist/main/index.js'),
|
||||
];
|
||||
let supportsBackground = candidates.some(
|
||||
(file) => fs.existsSync(file) && fs.readFileSync(file).includes(Buffer.from('ELECTRON_SMOKE_MODE')),
|
||||
);
|
||||
|
||||
// electron-builder 的 portable 目标把整个 app 放在 7z 自解压文件里,EXE 旁边没有 resources/。
|
||||
// 从本仓库打包缓存里的 7za 只读取 app.asar 到内存,确认本次便携包也包含后台模式标记。
|
||||
if (!supportsBackground && /-portable\.exe$/i.test(exe)) {
|
||||
try {
|
||||
const cacheDir = path.join(repoRoot, '.cache', 'electron-builder');
|
||||
const sevenZip = fs
|
||||
.readdirSync(cacheDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith('7zip@'))
|
||||
.flatMap((entry) => {
|
||||
const packageDir = path.join(cacheDir, entry.name);
|
||||
return fs
|
||||
.readdirSync(packageDir, { withFileTypes: true })
|
||||
.filter((child) => child.isDirectory())
|
||||
.map((child) => path.join(packageDir, child.name, 'bin', '7za.exe'));
|
||||
})
|
||||
.find((file) => fs.existsSync(file));
|
||||
|
||||
if (sevenZip) {
|
||||
const payload = spawnSync(sevenZip, ['e', '-so', exe, 'resources/app.asar'], {
|
||||
encoding: null,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
});
|
||||
supportsBackground = payload.status === 0 && payload.stdout?.includes(backgroundMarker);
|
||||
}
|
||||
} catch {
|
||||
// 下方的提示会说明该产物未确认支持后台模式。
|
||||
}
|
||||
}
|
||||
|
||||
if (!supportsBackground) {
|
||||
console.error(
|
||||
'[packaged-smoke] 无法确认这个产物支持后台快速模式。请验证新构建的产物,或显式 --visual 执行桌面验收。',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
let online = forceOffline ? '0' : null;
|
||||
if (online === null) {
|
||||
@@ -78,8 +126,10 @@ function launch(extraEnv) {
|
||||
const child = spawn(exe, [], {
|
||||
cwd: path.dirname(exe),
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env: resolveElectronEnv({
|
||||
ELECTRON_SMOKE_TEST: '1',
|
||||
ELECTRON_SMOKE_MODE: mode,
|
||||
ELECTRON_SMOKE_PROFILE: profileDir,
|
||||
ELECTRON_SMOKE_ONLINE: online,
|
||||
ELECTRON_SMOKE_CLASS_CODE: classCode,
|
||||
@@ -118,7 +168,11 @@ try {
|
||||
}
|
||||
console.log('\n[packaged-smoke] 二次启动校验(单实例锁 / 句柄是否已释放)...');
|
||||
fs.rmSync(resultFile, { force: true });
|
||||
const secondCode = await launch({ ELECTRON_SMOKE_ONLINE: '0', ELECTRON_SMOKE_RESULT: resultFile });
|
||||
const secondCode = await launch({
|
||||
ELECTRON_SMOKE_ONLINE: '0',
|
||||
ELECTRON_SMOKE_MODE: 'quick',
|
||||
ELECTRON_SMOKE_RESULT: resultFile,
|
||||
});
|
||||
const second = readResult();
|
||||
|
||||
const ok =
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
/** 只针对独立测试服务:验证审查发现的越权、导入、日期和广播缺陷,不访问外网。 */
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { SOCKET_EVENTS, SOCKET_ROOMS, dayKeyLocal } from '../packages/shared/dist/index.js';
|
||||
const { io } = createRequire(path.resolve('packages/desktop-client/package.json'))('socket.io-client');
|
||||
const base = process.env.VERIFY_BASE_URL ?? 'http://127.0.0.1:4199';
|
||||
const stamp = Date.now();
|
||||
const extraGroups = [];
|
||||
let passed = 0;
|
||||
async function api(route, token, body, method = body === undefined ? 'GET' : 'POST') {
|
||||
const response = await fetch(`${base}/api${route}`, {
|
||||
method,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return { status: response.status, ...(await response.json()) };
|
||||
}
|
||||
function check(name, value) {
|
||||
assert.ok(value, name);
|
||||
passed++;
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
function event(socket, name, accepts = () => true) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
socket.off(name, onEvent);
|
||||
reject(new Error(`未收到 ${name}`));
|
||||
}, 5000);
|
||||
const onEvent = (value) => {
|
||||
if (!accepts(value)) return;
|
||||
clearTimeout(timer);
|
||||
socket.off(name, onEvent);
|
||||
resolve(value);
|
||||
};
|
||||
socket.on(name, onEvent);
|
||||
});
|
||||
}
|
||||
const admin = (await api('/auth/login', null, { username: 'admin', password: 'admin123' })).data.token;
|
||||
const classIds = [],
|
||||
users = [],
|
||||
sockets = [];
|
||||
let groupId, previousAi;
|
||||
try {
|
||||
const staff = (
|
||||
await api('/teachers', admin, {
|
||||
username: `review_${stamp}`,
|
||||
password: 'review-test-123',
|
||||
name: '审查验证教师',
|
||||
role: 'TEACHER',
|
||||
})
|
||||
).data;
|
||||
assert.ok(staff?.id);
|
||||
users.push(staff.id);
|
||||
const staffToken = (
|
||||
await api('/auth/login', null, { username: staff.username, password: 'review-test-123' })
|
||||
).data.token;
|
||||
for (const [index, teacherId] of [
|
||||
[0, staff.id],
|
||||
[1, null],
|
||||
]) {
|
||||
const created = await api('/classes', admin, {
|
||||
name: `审查验证班${index}`,
|
||||
grade: '验证',
|
||||
enrollmentYear: 2098,
|
||||
classIndex: index + 1,
|
||||
code: `RV${stamp}${index}`,
|
||||
...(teacherId ? { teacherId } : {}),
|
||||
termWeeks: 10,
|
||||
});
|
||||
assert.ok(created.data?.id, JSON.stringify(created));
|
||||
classIds.push(created.data.id);
|
||||
await api(
|
||||
`/classes/${created.data.id}/class-account`,
|
||||
admin,
|
||||
{ code: `RV${stamp}${index}`, password: 'review-test-123' },
|
||||
'PATCH',
|
||||
);
|
||||
}
|
||||
const [a, b] = classIds;
|
||||
const courseA = (await api('/courses', admin, { classId: a, name: '语文', teacherId: staff.id })).data;
|
||||
const courseB = (await api('/courses', admin, { classId: b, name: '语文' })).data;
|
||||
const students = [];
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const student = await api('/students', admin, {
|
||||
classId: index < 2 ? a : b,
|
||||
name: index < 2 ? '同名同学' : '别班同学',
|
||||
studentNo: `R${stamp}${index}`,
|
||||
});
|
||||
assert.ok(student.data?.id, JSON.stringify(student));
|
||||
students.push(student.data);
|
||||
}
|
||||
const group = (
|
||||
await api('/permissions/groups', admin, { name: `审查只读组${stamp}`, keys: ['dashboard:read'] })
|
||||
).data;
|
||||
groupId = group.id;
|
||||
await api(`/permissions/users/${staff.id}/groups`, admin, { groupIds: [groupId] }, 'PUT');
|
||||
check(
|
||||
'移除成绩查询权限后员工不能按学号查询',
|
||||
(await api(`/grades/student/${students[0].studentNo}?classId=${a}`, staffToken)).status === 403,
|
||||
);
|
||||
check(
|
||||
'移除更新检查权限后直接接口被拒绝且不发起外网请求',
|
||||
(await api('/update/check', staffToken)).status === 403,
|
||||
);
|
||||
await api(
|
||||
`/permissions/groups/${groupId}`,
|
||||
admin,
|
||||
{ keys: ['roster:read', 'teacher:read', 'call:create', 'ai:use', 'student:grade-query'] },
|
||||
'PATCH',
|
||||
);
|
||||
check('看板读取权限可以真正收回', (await api('/dashboard/summary', staffToken)).status === 403);
|
||||
check('只读名单权限可以读取本班', (await api(`/students?classId=${a}`, staffToken)).status === 200);
|
||||
check(
|
||||
'只读名单权限不能新增学生',
|
||||
(await api('/students', staffToken, { classId: a, name: '不应创建', studentNo: `NO${stamp}` })).status ===
|
||||
403,
|
||||
);
|
||||
check('教师只读权限可以读取教师列表', (await api('/teachers', staffToken)).status === 200);
|
||||
check(
|
||||
'教师只读权限不能创建教师',
|
||||
(await api('/teachers', staffToken, { username: `no_${stamp}`, name: '不应创建' })).status === 403,
|
||||
);
|
||||
check(
|
||||
'按别班学生 ID 查询调班历史仍会拒绝',
|
||||
(await api(`/students/transfers?studentId=${students[2].id}`, staffToken)).status === 403,
|
||||
);
|
||||
const ownLogin = (
|
||||
await api('/auth/class-login', null, { code: `RV${stamp}0`, password: 'review-test-123' })
|
||||
).data;
|
||||
const otherLogin = (
|
||||
await api('/auth/class-login', null, { code: `RV${stamp}1`, password: 'review-test-123' })
|
||||
).data;
|
||||
for (const token of [ownLogin.token, otherLogin.token]) {
|
||||
const socket = io(base, { auth: { token }, transports: ['websocket'], autoConnect: false });
|
||||
sockets.push(socket);
|
||||
const ready = event(socket, SOCKET_EVENTS.connected);
|
||||
socket.connect();
|
||||
const payload = await ready;
|
||||
check(
|
||||
'班级端连接后自动加入自己的班级房间',
|
||||
payload.rooms.some((room) => room === SOCKET_ROOMS.class(token === ownLogin.token ? a : b)),
|
||||
);
|
||||
}
|
||||
const join = await new Promise((resolve) =>
|
||||
sockets[0].timeout(3000).emit('class:join', a, (error, result) => resolve(error ? null : result)),
|
||||
);
|
||||
check('班级端显式加入本班成功', join?.ok === true);
|
||||
const forbiddenJoin = await new Promise((resolve) =>
|
||||
sockets[0].timeout(3000).emit('class:join', b, (error, result) => resolve(error ? null : result)),
|
||||
);
|
||||
check('班级端不能显式加入别班', forbiddenJoin?.ok === false);
|
||||
const call = await api('/calls', staffToken, {
|
||||
classId: a,
|
||||
studentId: students[0].id,
|
||||
message: '审查验证叫人',
|
||||
});
|
||||
check('叫人记录写入本地所属日期', call.data?.assignDate === dayKeyLocal(new Date()));
|
||||
const dayList = await api(`/notifications?classId=${a}&date=${dayKeyLocal(new Date())}`, admin);
|
||||
check(
|
||||
'按日期查看时能找到叫人记录',
|
||||
dayList.data?.some((item) => item.id === call.data.id),
|
||||
);
|
||||
const removed = event(sockets[0], SOCKET_EVENTS.notificationDeleted);
|
||||
await api(`/notifications/${call.data.id}`, admin, undefined, 'DELETE');
|
||||
check('删除通知会广播给对应班级', (await removed).id === call.data.id);
|
||||
const badSchedule = await api('/schedules', admin, {
|
||||
classId: a,
|
||||
courseId: courseA.id,
|
||||
dayOfWeek: 1,
|
||||
startTime: '08:00',
|
||||
endTime: '08:45',
|
||||
weekStart: 15,
|
||||
});
|
||||
check('按班级学期兜底后仍检查起始周不能越过结束周', badSchedule.status === 400);
|
||||
await api(`/classes/${a}`, admin, { termWeeks: 30 }, 'PATCH');
|
||||
const imported = await api('/imports/class-plan', admin, {
|
||||
classId: a,
|
||||
mode: 'merge',
|
||||
payload: {
|
||||
Subjects: { s: { Name: '语文' } },
|
||||
TimeLayouts: { t: { Layouts: [{ TimeType: 0, StartTime: '08:00:00', EndTime: '08:45:00' }] } },
|
||||
ClassPlans: {
|
||||
p: {
|
||||
Name: '周一',
|
||||
TimeLayoutId: 't',
|
||||
TimeRule: { WeekDay: 1, WeekCountDiv: 1, WeekCountDivTotal: 1 },
|
||||
Classes: [{ SubjectId: 's', IsEnabled: true }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
check('导入课表采用班级配置的 30 周', imported.data?.weekEnd === 30);
|
||||
const className = (await api(`/classes/${a}`, admin)).data.name;
|
||||
const relations = await api('/imports/table/commit', admin, {
|
||||
kind: 'classTeachers',
|
||||
fileName: 'relations.csv',
|
||||
mode: 'upsert',
|
||||
useTeacherNo: true,
|
||||
mapping: { className: '班级', subjectName: '科目', teacherNo: '工号', teacherName: '姓名', role: '角色' },
|
||||
contentBase64: Buffer.from(
|
||||
`\ufeff班级,科目,工号,姓名,角色\n${className},语文,${staff.username},审查验证教师,科任`,
|
||||
).toString('base64'),
|
||||
});
|
||||
check(
|
||||
'任课关系导入可按表格班级列提交,无需额外单选班级',
|
||||
relations.status === 200 &&
|
||||
relations.data?.errors.length === 0 &&
|
||||
relations.data.inserted + relations.data.updated > 0,
|
||||
);
|
||||
const csv = `\ufeff学号,姓名,考试,分数\n,同名同学,审查考试,80`;
|
||||
const importBase = {
|
||||
kind: 'grades',
|
||||
classId: a,
|
||||
fileName: 'review.csv',
|
||||
mode: 'upsert',
|
||||
mapping: { studentNo: '学号', name: '姓名', examName: '考试', score: '分数' },
|
||||
};
|
||||
const ambiguous = await api('/imports/table/commit', admin, {
|
||||
...importBase,
|
||||
contentBase64: Buffer.from(csv).toString('base64'),
|
||||
});
|
||||
check(
|
||||
'成绩导入遇到同名学生返回行级消歧提示',
|
||||
ambiguous.data?.errors?.some((item) => item.message.includes('同名')),
|
||||
);
|
||||
const exact = await api('/imports/table/commit', admin, {
|
||||
...importBase,
|
||||
contentBase64: Buffer.from(csv.replace('\n,', `\n${students[0].studentNo},`)).toString('base64'),
|
||||
});
|
||||
check('提供正确学号后同名学生也能精确导入', exact.data?.inserted === 1);
|
||||
const gradeA = (
|
||||
await api('/grades', admin, {
|
||||
classId: a,
|
||||
courseId: courseA.id,
|
||||
studentId: students[0].id,
|
||||
examName: '批改等级',
|
||||
score: 80,
|
||||
})
|
||||
).data;
|
||||
const gradeB = (
|
||||
await api('/grades', admin, {
|
||||
classId: b,
|
||||
courseId: courseB.id,
|
||||
studentId: students[2].id,
|
||||
examName: '批改等级',
|
||||
score: 75,
|
||||
})
|
||||
).data;
|
||||
const updateA = event(sockets[0], SOCKET_EVENTS.gradeUpdated, (value) => value.bulk),
|
||||
updateB = event(sockets[1], SOCKET_EVENTS.gradeUpdated, (value) => value.bulk);
|
||||
await api(
|
||||
'/grades/levels',
|
||||
admin,
|
||||
{
|
||||
items: [
|
||||
{ id: gradeA.id, level: '优秀' },
|
||||
{ id: gradeB.id, level: '良好' },
|
||||
],
|
||||
},
|
||||
'PATCH',
|
||||
);
|
||||
check('跨班修改等级时两个班都会收到广播', (await updateA).count === 1 && (await updateB).count === 1);
|
||||
const custom = await api(
|
||||
'/grades/levels',
|
||||
admin,
|
||||
{ classId: a, examName: '批改等级', levelType: 'custom' },
|
||||
'PATCH',
|
||||
);
|
||||
check('自定义等级没有规则时禁止整批猜算', custom.status === 400);
|
||||
const roleUser = (
|
||||
await api('/teachers', admin, {
|
||||
username: `review_admin_${stamp}`,
|
||||
password: 'review-test-123',
|
||||
name: '保留管理员',
|
||||
role: 'ADMIN',
|
||||
})
|
||||
).data;
|
||||
users.push(roleUser.id);
|
||||
const roleImport = await api('/imports/table/commit', admin, {
|
||||
kind: 'teachers',
|
||||
fileName: 'role.csv',
|
||||
mode: 'upsert',
|
||||
mapping: { username: '工号', name: '姓名', role: '角色' },
|
||||
contentBase64: Buffer.from(`\ufeff工号,姓名,角色\n${roleUser.username},保留管理员,未知角色`).toString(
|
||||
'base64',
|
||||
),
|
||||
});
|
||||
assert.ok(roleImport.status < 300, JSON.stringify(roleImport));
|
||||
check(
|
||||
'角色导入夹具确实更新了目标账号',
|
||||
roleImport.data?.updated === 1 && roleImport.data.errors.length === 0,
|
||||
);
|
||||
const teachers = (await api('/teachers', admin)).data;
|
||||
check(
|
||||
'教师导入不能因未知角色静默降级管理员',
|
||||
teachers.find((item) => item.id === roleUser.id)?.role === 'ADMIN',
|
||||
);
|
||||
const baseKeys = [
|
||||
'roster:read',
|
||||
'teacher:read',
|
||||
'call:create',
|
||||
'ai:use',
|
||||
'student:grade-query',
|
||||
'update:check',
|
||||
];
|
||||
await api(`/permissions/groups/${groupId}`, admin, { keys: baseKeys }, 'PATCH');
|
||||
const initialUpdate = (await api('/update/check', admin)).data;
|
||||
check(
|
||||
'未授予强制检查时教师的 force 参数不能绕过缓存',
|
||||
(await api('/update/check?force=1', staffToken)).data.checkedAt === initialUpdate.checkedAt,
|
||||
);
|
||||
await api(`/permissions/groups/${groupId}`, admin, { keys: [...baseKeys, 'update:force'] }, 'PATCH');
|
||||
const forcedUpdate = (await api('/update/check?force=1', staffToken)).data;
|
||||
check('授予强制检查权限后教师也能按权限绕过缓存', forcedUpdate.checkedAt !== initialUpdate.checkedAt);
|
||||
const readUpdateGroup = (
|
||||
await api('/permissions/groups', admin, { name: `审查管理员只读更新${stamp}`, keys: ['update:check'] })
|
||||
).data;
|
||||
extraGroups.push(readUpdateGroup.id);
|
||||
await api(`/permissions/users/${roleUser.id}/groups`, admin, { groupIds: [readUpdateGroup.id] }, 'PUT');
|
||||
const limitedAdmin = (
|
||||
await api('/auth/login', null, { username: roleUser.username, password: 'review-test-123' })
|
||||
).data.token;
|
||||
check(
|
||||
'管理员没有强制检查权限时同样不能绕过缓存',
|
||||
(await api('/update/check?force=1', limitedAdmin)).data.checkedAt === forcedUpdate.checkedAt,
|
||||
);
|
||||
previousAi = (await api('/ai/settings', admin)).data;
|
||||
await api('/ai/settings', admin, { enabled: true, provider: 'mock' }, 'PUT');
|
||||
check(
|
||||
'助手不能用别班上下文查询科目',
|
||||
(await api('/ai/interpret', staffToken, { classId: b, text: '发布通知测试' })).status === 403,
|
||||
);
|
||||
const interpreted = await api('/ai/interpret', staffToken, { classId: a, text: '发布通知,明天带书' });
|
||||
check('自己的班级可以离线解析', interpreted.status === 200);
|
||||
const audits = (await api('/ai/audit', admin)).data;
|
||||
check('解析审计使用明确的解析状态', ['interpreted', 'unrecognized'].includes(audits[0]?.status));
|
||||
check(
|
||||
'协议相对附件地址不能写入作业',
|
||||
(
|
||||
await api('/homeworks', admin, {
|
||||
classId: a,
|
||||
title: '链接测试',
|
||||
content: '链接测试',
|
||||
attachmentUrl: '//invalid.example/file',
|
||||
})
|
||||
).status === 422,
|
||||
);
|
||||
console.log(`审查专项接口回归:${passed}/${passed} 项通过`);
|
||||
} finally {
|
||||
for (const socket of sockets) socket.disconnect();
|
||||
if (previousAi) await api('/ai/settings', admin, previousAi, 'PUT');
|
||||
for (const id of [...classIds].reverse()) await api(`/classes/${id}`, admin, undefined, 'DELETE');
|
||||
for (const id of users) await api(`/teachers/${id}`, admin, undefined, 'DELETE');
|
||||
if (groupId) await api(`/permissions/groups/${groupId}`, admin, undefined, 'DELETE');
|
||||
for (const id of extraGroups) await api(`/permissions/groups/${id}`, admin, undefined, 'DELETE');
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/** 隔离验证缓存故障恢复、实时通知竞态和崩溃恢复,不创建任何桌面窗口。 */
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { EventEmitter } from 'node:events';
|
||||
const root = path.resolve('.');
|
||||
const { build } = createRequire(path.join(root, 'packages/desktop-client/package.json'))('esbuild');
|
||||
fs.mkdirSync(path.join(root, '.cache/tmp'), { recursive: true });
|
||||
const temp = fs.mkdtempSync(path.join(root, '.cache/tmp/review-client-'));
|
||||
let passed = 0;
|
||||
function check(name, value) {
|
||||
assert.ok(value, name);
|
||||
passed++;
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
const h = { writes: [], pending: [], app: new EventEmitter(), handlers: {}, restarts: 0, recoveries: 0 };
|
||||
globalThis.__reviewClientTest = h;
|
||||
async function bundle(name, entry, mocks = {}, append = '', prefix = '') {
|
||||
const target = path.join(temp, `${name}.mjs`);
|
||||
await build({
|
||||
entryPoints: [path.join(root, entry)],
|
||||
outfile: target,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
bundle: true,
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
{
|
||||
name: '隔离依赖',
|
||||
setup(b) {
|
||||
b.onResolve({ filter: /.*/ }, (args) => {
|
||||
const key = Object.keys(mocks).find((key) => args.path === key);
|
||||
if (key) return { path: key, namespace: 'review-mock' };
|
||||
});
|
||||
b.onLoad({ filter: /.*/, namespace: 'review-mock' }, (args) => ({
|
||||
contents: mocks[args.path],
|
||||
loader: 'js',
|
||||
}));
|
||||
b.onLoad({ filter: /\.(ts)$/ }, (args) =>
|
||||
args.path === path.join(root, entry)
|
||||
? { contents: prefix + fs.readFileSync(args.path, 'utf8') + append, loader: 'ts' }
|
||||
: null,
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return import(pathToFileURL(target).href);
|
||||
}
|
||||
const refMock =
|
||||
'export const ref=(value)=>({value}); export const computed=(fn)=>({get value(){return fn()}});';
|
||||
try {
|
||||
const previousStorage = globalThis.localStorage;
|
||||
globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} };
|
||||
try {
|
||||
h.webPermissions = ['permission:manage', 'system:manage'];
|
||||
const webAuth = await bundle('web-auth', 'packages/web-admin/src/stores/auth.ts', {
|
||||
vue: refMock,
|
||||
pinia: 'export const defineStore=(_name, setup)=>setup;',
|
||||
'@/api':
|
||||
'export const authApi={}; export const permissionApi={me:async()=>({permissions:globalThis.__reviewClientTest.webPermissions,fromGroups:false})};',
|
||||
});
|
||||
const auth = webAuth.useAuthStore();
|
||||
auth.user.value = { role: 'ADMIN' };
|
||||
auth.token.value = 'admin-test';
|
||||
await auth.ensurePermissions();
|
||||
check(
|
||||
'管理员界面不会绕过已收回的默认组权限',
|
||||
!auth.can('remote:control') && !auth.can('ai:use') && auth.can('permission:manage'),
|
||||
);
|
||||
h.webPermissions.push('remote:control');
|
||||
await auth.ensurePermissions(true);
|
||||
check('刷新权限后管理员界面采用当前生效集合', auth.can('remote:control'));
|
||||
} finally {
|
||||
globalThis.localStorage = previousStorage;
|
||||
}
|
||||
const requests = [];
|
||||
const originalIndexedDB = globalThis.indexedDB;
|
||||
globalThis.indexedDB = {
|
||||
open() {
|
||||
const request = {};
|
||||
requests.push(request);
|
||||
return request;
|
||||
},
|
||||
};
|
||||
try {
|
||||
const db = await bundle('database', 'packages/desktop-client/src/renderer/cache/db.ts');
|
||||
const first = db.cacheGet('grades', 'key');
|
||||
requests[0].error = new Error('首次打开失败');
|
||||
requests[0].onerror();
|
||||
await assert.rejects(first, /首次打开失败/);
|
||||
const second = db.cacheGet('grades', 'key');
|
||||
check('IndexedDB 打开失败后会重新发起请求', requests.length === 2);
|
||||
let closed = false;
|
||||
const connection = {
|
||||
close() {
|
||||
closed = true;
|
||||
},
|
||||
transaction() {
|
||||
const transaction = {
|
||||
objectStore: () => ({
|
||||
get() {
|
||||
const request = { result: undefined };
|
||||
queueMicrotask(() => transaction.oncomplete());
|
||||
return request;
|
||||
},
|
||||
}),
|
||||
};
|
||||
return transaction;
|
||||
},
|
||||
};
|
||||
requests[1].result = connection;
|
||||
requests[1].onsuccess();
|
||||
check('重试成功后可以正常读取缓存', (await second) === null);
|
||||
connection.onversionchange();
|
||||
check('版本变化会关闭旧连接', closed);
|
||||
const blocked = db.cacheGet('grades', 'key');
|
||||
requests[2].onblocked();
|
||||
await assert.rejects(blocked, /其它窗口/);
|
||||
const retry = db.cacheGet('grades', 'key');
|
||||
requests[3].result = connection;
|
||||
requests[3].onsuccess();
|
||||
check('数据库被占用后也可以恢复', (await retry) === null && requests.length === 4);
|
||||
} finally {
|
||||
globalThis.indexedDB = originalIndexedDB;
|
||||
}
|
||||
|
||||
const notifications = await bundle(
|
||||
'notifications',
|
||||
'packages/desktop-client/src/renderer/stores/notifications.ts',
|
||||
{
|
||||
vue: refMock,
|
||||
pinia: 'export const defineStore=(_name, setup)=>setup;',
|
||||
'../api/index.js':
|
||||
'const h=globalThis.__reviewClientTest; export const notificationApi={list:()=>new Promise(resolve=>h.pending.push(resolve)),markRead:async()=>({}),markAllRead:async()=>({marked:1}),unreadCount:async()=>({count:0})};',
|
||||
'../cache/db.js':
|
||||
'const h=globalThis.__reviewClientTest; export const cacheGet=async()=>null; export const cacheSet=async(...args)=>{h.writes.push(args)};',
|
||||
'./db.js':
|
||||
'const h=globalThis.__reviewClientTest; export const cacheGet=async()=>null; export const cacheSet=async(...args)=>{h.writes.push(args)};',
|
||||
'./app.js': 'export const useAppStore=()=>({markSynced(){}});',
|
||||
},
|
||||
`\nexport {setCacheScope} from '../cache/scope.js';`,
|
||||
);
|
||||
const store = notifications.useNotificationStore();
|
||||
const item = {
|
||||
id: 'n',
|
||||
title: '实时通知',
|
||||
content: '内容',
|
||||
classId: 'c',
|
||||
priority: 'NORMAL',
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
let loading = store.load();
|
||||
await store.pushRealtime(item);
|
||||
h.pending.shift()([]);
|
||||
await loading;
|
||||
check('列表请求中的实时新通知不会丢失', store.items.value[0]?.id === 'n' && store.unreadCount.value === 1);
|
||||
loading = store.load();
|
||||
await store.markRead('n');
|
||||
h.pending.shift()([{ ...item, read: false }]);
|
||||
await loading;
|
||||
check('迟到的列表快照不会撤销本地已读', store.items.value[0]?.read === true);
|
||||
loading = store.load();
|
||||
await store.removeRealtime('n');
|
||||
h.pending.shift()([item]);
|
||||
await loading;
|
||||
check('已删除通知不会被迟到快照复活', store.items.value.length === 0);
|
||||
loading = store.load();
|
||||
notifications.setCacheScope('http://other.invalid', 'other-class');
|
||||
h.pending.shift()([item]);
|
||||
await loading;
|
||||
check('旧会话迟到响应不会污染换班后的列表', store.items.value.length === 0);
|
||||
|
||||
const previousWindow = globalThis.window;
|
||||
const noon = new Date();
|
||||
noon.setHours(10, 0, 0, 0);
|
||||
h.FixedDate = class extends Date {
|
||||
constructor(...args) {
|
||||
super(...(args.length ? args : [noon.getTime()]));
|
||||
}
|
||||
};
|
||||
h.classStates = [];
|
||||
globalThis.window = { desktop: { islandSetClassState: (state) => h.classStates.push(state) } };
|
||||
h.auth = { token: 'session', classId: 'c' };
|
||||
h.termWeek = 9;
|
||||
h.appStore = {
|
||||
currentWeek: 1,
|
||||
async refreshTerm() {
|
||||
this.currentWeek = h.termWeek;
|
||||
},
|
||||
};
|
||||
h.scheduleLoader = async (params) => {
|
||||
h.requestedWeek = params.week;
|
||||
return [
|
||||
{
|
||||
id: 's',
|
||||
classId: 'c',
|
||||
courseId: 'course',
|
||||
course: { name: '语文' },
|
||||
dayOfWeek: noon.getDay() || 7,
|
||||
startTime: '08:00',
|
||||
endTime: '11:00',
|
||||
weekStart: params.week,
|
||||
weekEnd: params.week,
|
||||
weekParity: 'ALL',
|
||||
},
|
||||
];
|
||||
};
|
||||
const bridge = await bundle(
|
||||
'bridge',
|
||||
'packages/desktop-client/src/renderer/island/bridge.ts',
|
||||
{
|
||||
vue: 'export const watch=()=>()=>{};',
|
||||
'../api/index.js':
|
||||
'const h=globalThis.__reviewClientTest; export const scheduleApi={list:(params)=>h.scheduleLoader(params)}; export const notificationApi={list:async()=>[]};',
|
||||
'./db.js': 'export const cacheGet=async()=>null; export const cacheSet=async()=>{};',
|
||||
'../stores/app.js':
|
||||
'export const useAppStore=()=>globalThis.__reviewClientTest.appStore; export const readOfflineSince=()=>null; export const clearOfflineSince=()=>{};',
|
||||
'../stores/auth.js': 'export const useAuthStore=()=>globalThis.__reviewClientTest.auth;',
|
||||
'../stores/notifications.js': 'export const useNotificationStore=()=>({});',
|
||||
'../stores/realtime.js': 'export const useRealtimeStore=()=>({on(){},off(){}});',
|
||||
},
|
||||
`\nexport {setCacheScope} from '../cache/scope.js';`,
|
||||
'const Date=globalThis.__reviewClientTest.FixedDate;\n',
|
||||
);
|
||||
try {
|
||||
await bridge.refreshIslandSchedules();
|
||||
check(
|
||||
'先解析真实教学周再请求课表,不会误取第 1 周',
|
||||
h.requestedWeek === 9 && bridge.getIslandClassContext().inClass,
|
||||
);
|
||||
const loader = h.scheduleLoader;
|
||||
h.scheduleLoader = async () => {
|
||||
throw new Error('短暂断网');
|
||||
};
|
||||
await bridge.refreshIslandSchedules();
|
||||
check(
|
||||
'无缓存的失败刷新不会清掉已知课表或上课状态',
|
||||
bridge.getIslandSchedules().length === 1 && bridge.getIslandClassContext().inClass,
|
||||
);
|
||||
h.scheduleLoader = loader;
|
||||
h.termWeek = 10;
|
||||
await bridge.refreshIslandSchedules();
|
||||
check('教学周变更后按新周请求并计算', h.requestedWeek === 10 && bridge.getIslandClassContext().inClass);
|
||||
h.scheduleLoader = () =>
|
||||
new Promise((resolve) => {
|
||||
h.finishSchedule = resolve;
|
||||
});
|
||||
const pending = bridge.refreshIslandSchedules();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
bridge.stopIslandBridge();
|
||||
bridge.setCacheScope('http://changed.invalid', 'class-b');
|
||||
h.finishSchedule([]);
|
||||
await pending;
|
||||
check('停止桥接后旧班迟到课表不能恢复旧会话', bridge.getIslandSchedules().length === 0);
|
||||
} finally {
|
||||
globalThis.window = previousWindow;
|
||||
}
|
||||
|
||||
h.app.relaunch = () => h.restarts++;
|
||||
h.process = {
|
||||
env: {},
|
||||
on: (event, handler) => {
|
||||
h.handlers[event] = handler;
|
||||
},
|
||||
};
|
||||
const guard = await bundle(
|
||||
'guard',
|
||||
'packages/desktop-client/src/main/crash-guard.ts',
|
||||
{
|
||||
electron: 'export const app=globalThis.__reviewClientTest.app;',
|
||||
'./logger.js': 'export const logger={info(){},warn(){},error(){}};',
|
||||
},
|
||||
'',
|
||||
'const process=globalThis.__reviewClientTest.process;\n',
|
||||
);
|
||||
guard.installCrashGuard({
|
||||
recoverMainWindow: async () => {
|
||||
h.recoveries++;
|
||||
await new Promise((resolve) => {
|
||||
h.finishRecovery = resolve;
|
||||
});
|
||||
},
|
||||
isIdle: () => true,
|
||||
quitApp() {},
|
||||
});
|
||||
h.app.emit('render-process-gone', {}, { id: 99 }, { reason: 'crashed', exitCode: 2 });
|
||||
check('没有主窗口时不会把灵动岛崩溃当成主窗口崩溃', h.recoveries === 0);
|
||||
const win = new EventEmitter();
|
||||
Object.assign(win, { webContents: { id: 10 }, isDestroyed: () => false, hide() {} });
|
||||
guard.watchMainWindow(win);
|
||||
h.app.emit('render-process-gone', {}, { id: 10 }, { reason: 'crashed', exitCode: 2 });
|
||||
h.app.emit('render-process-gone', {}, { id: 10 }, { reason: 'crashed', exitCode: 2 });
|
||||
check('异步窗口恢复完成前不重复重建', h.recoveries === 1);
|
||||
h.finishRecovery();
|
||||
await Promise.resolve();
|
||||
h.handlers.unhandledRejection(new Error('普通请求失败'));
|
||||
check('普通 Promise 拒绝不会触发应用重启', h.restarts === 0);
|
||||
h.handlers.uncaughtException(new Error('致命异常'));
|
||||
check('空闲时真正致命的异常仍可重启恢复', h.restarts === 1);
|
||||
console.log(`审查客户端逻辑回归:${passed}/${passed} 项通过`);
|
||||
} finally {
|
||||
delete globalThis.__reviewClientTest;
|
||||
fs.rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
+8
-34
@@ -4,16 +4,13 @@
|
||||
*
|
||||
* ## 为什么要这个脚本
|
||||
*
|
||||
* 这个仓库里**同一个版本号写在 14 处(12 个文件)**:5 份 package.json、运行时常量、两个 compose 文件、
|
||||
* 插件清单、插件 C# 源码里的一个 const,以及官网页面上三处手写的版本号、下载页那一处兜底值。
|
||||
* 这个仓库里**同一个版本号写在 12 处(10 个文件)**:5 份 package.json、运行时常量、两个 compose 文件、
|
||||
* 官网页面上三处手写的版本号、下载页那一处兜底值。
|
||||
* 手工改必然漏一处,
|
||||
* 而漏掉的那处**不会报错**,只会在某个不常走的分支上表现为"版本号对不上":
|
||||
*
|
||||
* - `deploy/docker-compose*.yml` 镜像 tag 落后 → 服务器上跑的还是旧镜像;
|
||||
* - `deploy/package.runtime.json` 容器 / 安装包里那份依赖清单的版本(人看,但会误导排查);
|
||||
* - `manifest.yml` + `BridgeService.cs` ClassIsland 上显示的插件版本
|
||||
* (清单里的 version 与实际上报的 PluginVersion 不一致时,
|
||||
* Web 端「ClassIsland 联动」页显示的版本会对不上插件市场);
|
||||
* - `website/index.html` 的那三处 官网是纯静态站、无构建步骤,没有哪段代码会去读
|
||||
* `package.json`,漏改的后果是**首页上一直挂着旧版本号** —— 那是别人看到的第一眼;
|
||||
* - `website/download.html` 的 `<meta name="ch-version">` 下载页拿它当"读不到版本清单"时的兜底,
|
||||
@@ -28,7 +25,7 @@
|
||||
* node scripts/version.mjs --bump patch # 1.1.2 → 1.1.3
|
||||
* node scripts/version.mjs --bump minor # 1.1.2 → 1.2.0
|
||||
* node scripts/version.mjs --bump major # 1.1.2 → 2.0.0
|
||||
* node scripts/version.mjs --set 1.2.0 # 显式指定(插件同时设为 1.2.0.0)
|
||||
* node scripts/version.mjs --set 1.2.0 # 显式指定
|
||||
* node scripts/version.mjs --bump minor --dry # 只看会改哪些文件,不落盘
|
||||
*
|
||||
* 版本口径与完整发布流程见 docs/dev/release.md。
|
||||
@@ -61,21 +58,6 @@ const PRODUCT_TARGETS = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 插件版本(`x.y.z.0`)。第 4 段留给"只改插件、不动产品版本"的补丁发布;
|
||||
* `--bump` / `--set` 一律把它归零,要单独发插件补丁时手工改这两处即可。
|
||||
*/
|
||||
const PLUGIN_TARGETS = [
|
||||
{
|
||||
file: 'packages/classisland-plugin/manifest.yml',
|
||||
pattern: /^(version:[ \t]*)(\d+\.\d+\.\d+\.\d+)([ \t]*)$/m,
|
||||
},
|
||||
{
|
||||
file: 'packages/classisland-plugin/src/Services/BridgeService.cs',
|
||||
pattern: /(private const string PluginVersion = ")(\d+\.\d+\.\d+\.\d+)(";)/,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 官网首页与下载页上那几处手写的版本号。官网是纯静态站,没有构建步骤、也没有哪段代码会去读
|
||||
* `package.json`,所以这几处只能靠脚本一起改 —— 漏了的话官网会一直显示旧版本号,
|
||||
@@ -100,10 +82,9 @@ const SITE_TARGETS = [
|
||||
},
|
||||
];
|
||||
|
||||
const ALL_TARGETS = [...PRODUCT_TARGETS, ...PLUGIN_TARGETS, ...SITE_TARGETS];
|
||||
const ALL_TARGETS = [...PRODUCT_TARGETS, ...SITE_TARGETS];
|
||||
|
||||
const PRODUCT_RE = /^\d+\.\d+\.\d+$/;
|
||||
const PLUGIN_RE = /^\d+\.\d+\.\d+\.\d+$/;
|
||||
|
||||
/** 输出用的名字:同一个文件里有多个落点时带上括号里的说明,否则三行看起来一模一样 */
|
||||
function name(target) {
|
||||
@@ -160,11 +141,8 @@ function check(product) {
|
||||
problems.push(`${target.file}:匹配不到版本号`);
|
||||
continue;
|
||||
}
|
||||
// 插件版本是四段,只校验前三段;第 4 段允许不为 0(插件专属补丁号)
|
||||
const expected = PLUGIN_RE.test(version) ? `${product}.0` : product;
|
||||
const ok = PLUGIN_RE.test(version)
|
||||
? version.split('.').slice(0, 3).join('.') === product
|
||||
: version === expected;
|
||||
const expected = product;
|
||||
const ok = version === expected;
|
||||
console.log(`${ok ? ' ' : '!!'} ${name(target).padEnd(width)} ${version}`);
|
||||
if (!ok) problems.push(`${target.file}:${version} ≠ ${expected}`);
|
||||
}
|
||||
@@ -175,19 +153,15 @@ function check(product) {
|
||||
console.error('\n对齐方式:pnpm version:check 之后用 pnpm version:bump patch 统一改写。');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n全部一致 ✅(插件第 4 段是插件专属补丁号,允许不为 0)');
|
||||
console.log('\n全部一致 ✅');
|
||||
}
|
||||
|
||||
function apply(next, { dry, product }) {
|
||||
const pluginNext = `${next}.0`;
|
||||
const lines = [];
|
||||
for (const target of PRODUCT_TARGETS) lines.push(writeVersion(target, next, { dry }));
|
||||
for (const target of PLUGIN_TARGETS) lines.push(writeVersion(target, pluginNext, { dry }));
|
||||
for (const target of SITE_TARGETS) lines.push(writeVersion(target, next, { dry }));
|
||||
|
||||
console.log(
|
||||
`${dry ? '[dry] 将改写' : '已改写'} ${product} → ${next}(插件 ${product}.0 → ${pluginNext}):\n`,
|
||||
);
|
||||
console.log(`${dry ? '[dry] 将改写' : '已改写'} ${product} → ${next}:\n`);
|
||||
for (const line of lines) console.log(` ${line}`);
|
||||
console.log('\n接着按 docs/dev/release.md 走发布流程;GitHub Release 的 tag(v' + next + ')也要对齐。');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user