完善桌面端端到端回归收口
This commit is contained in:
@@ -147,3 +147,22 @@ npm run desktop:build:app
|
||||
- Apple Developer 凭据、证书、签名身份、公证结果和组织 updater 私钥。
|
||||
- 生产下载源的不可变制品上传和线上 `latest.json` 原子替换。
|
||||
- 真实环境下的自动更新安装、系统通知、单实例和完整人工回归。
|
||||
|
||||
## 8. 2026-07-02 端到端回归优化记录
|
||||
|
||||
本轮端到端优化仍保持在线桌面客户端边界,不引入离线、本地业务存储或本地权限裁决。
|
||||
|
||||
已完成的自动化收口:
|
||||
|
||||
- 服务器地址切换时,桌面设置页调用 `auth.logout({ rememberCurrentStudy: false })`,避免退出时把旧服务器项目记入当前用户的最近项目;随后继续清除当前项目上下文。
|
||||
- 系统通知轮询在部分通知显示失败时,先 ack 已成功显示的通知,再让失败项通过租约重试,贴合“显示成功后 ack;失败等待重试”的回归预期。
|
||||
- 自动更新管理器新增稍后提醒 24 小时抑制、安装失败可重试、检查失败不打断业务和未启用更新状态的单元覆盖。
|
||||
- 新增相关单元测试覆盖服务器切换不记忆旧项目、通知权限未授权不领取、部分通知失败时只 ack 成功项,以及自动更新失败恢复路径。
|
||||
|
||||
仍需人工或真实环境验证:
|
||||
|
||||
- Keychain/凭据库 30 天在线会话恢复。
|
||||
- 原生附件上传、下载、保存和打开。
|
||||
- 系统通知授权/拒绝的 OS 级交互。
|
||||
- 单实例重复启动聚焦主窗口。
|
||||
- 签名 release 构建下的自动更新 feed、验签、安装和重启实物流。
|
||||
|
||||
@@ -188,6 +188,7 @@ describe("desktop layout shell", () => {
|
||||
expect(serverSettings).not.toContain("copyConnectionDiagnostic");
|
||||
expect(serverSettings).toContain("确认切换服务器");
|
||||
expect(serverSettings).toContain("切换服务器会退出当前会话并清除当前项目上下文");
|
||||
expect(serverSettings).toContain("auth.logout({ rememberCurrentStudy: false })");
|
||||
expect(serverSettings).not.toContain("服务器连接已确认");
|
||||
expect(preferences).toContain("connectionDiagnostic");
|
||||
expect(preferences).not.toContain("复制连接诊断");
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getSubscriptionMock = vi.hoisted(() => vi.fn());
|
||||
const claimNotificationsMock = vi.hoisted(() => vi.fn());
|
||||
const acknowledgeNotificationsMock = vi.hoisted(() => vi.fn());
|
||||
const getTokenMock = vi.hoisted(() => vi.fn());
|
||||
const getPermissionMock = vi.hoisted(() => vi.fn());
|
||||
const showNotificationMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../api/desktopNotifications", () => ({
|
||||
getDesktopNotificationSubscription: getSubscriptionMock,
|
||||
claimDesktopNotifications: claimNotificationsMock,
|
||||
acknowledgeDesktopNotifications: acknowledgeNotificationsMock,
|
||||
}));
|
||||
|
||||
vi.mock("../utils/auth", () => ({
|
||||
getToken: getTokenMock,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime", () => ({
|
||||
getNotificationPermission: getPermissionMock,
|
||||
isTauriRuntime: () => true,
|
||||
showSystemNotification: showNotificationMock,
|
||||
}));
|
||||
|
||||
describe("desktop notification manager", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
getTokenMock.mockReturnValue("session-token");
|
||||
getPermissionMock.mockResolvedValue("granted");
|
||||
getSubscriptionMock.mockResolvedValue({ data: { enabled: true } });
|
||||
claimNotificationsMock.mockResolvedValue({
|
||||
data: {
|
||||
claim_token: "claim-token",
|
||||
items: [
|
||||
{ id: "notification-1" },
|
||||
{ id: "notification-2" },
|
||||
],
|
||||
},
|
||||
});
|
||||
acknowledgeNotificationsMock.mockResolvedValue({ data: {} });
|
||||
showNotificationMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { stopDesktopNotificationManager } = await import("./desktopNotificationManager");
|
||||
stopDesktopNotificationManager();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("acknowledges displayed notifications and leaves failed deliveries for retry", async () => {
|
||||
showNotificationMock
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error("notification failed"));
|
||||
const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager");
|
||||
|
||||
initDesktopNotificationManager();
|
||||
triggerDesktopNotificationPoll();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(showNotificationMock).toHaveBeenCalledTimes(2);
|
||||
expect(acknowledgeNotificationsMock).toHaveBeenCalledWith("claim-token", ["notification-1"]);
|
||||
expect(acknowledgeNotificationsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not claim notifications before operating system permission is granted", async () => {
|
||||
getPermissionMock.mockResolvedValue("prompt");
|
||||
const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager");
|
||||
|
||||
initDesktopNotificationManager();
|
||||
triggerDesktopNotificationPoll();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(claimNotificationsMock).not.toHaveBeenCalled();
|
||||
expect(acknowledgeNotificationsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -41,13 +41,21 @@ const poll = async () => {
|
||||
}
|
||||
const { data } = await claimDesktopNotifications();
|
||||
const deliveredIds: string[] = [];
|
||||
let deliveryFailed = false;
|
||||
for (const item of data.items) {
|
||||
await showSystemNotification();
|
||||
deliveredIds.push(item.id);
|
||||
try {
|
||||
await showSystemNotification();
|
||||
deliveredIds.push(item.id);
|
||||
} catch {
|
||||
deliveryFailed = true;
|
||||
}
|
||||
}
|
||||
if (data.claim_token && deliveredIds.length) {
|
||||
await acknowledgeDesktopNotifications(data.claim_token, deliveredIds);
|
||||
}
|
||||
if (deliveryFailed) {
|
||||
throw new Error("desktop notification delivery failed");
|
||||
}
|
||||
failureCount = 0;
|
||||
schedule(POLL_INTERVAL_MS);
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const checkForDesktopUpdateMock = vi.hoisted(() => vi.fn());
|
||||
const installPendingDesktopUpdateMock = vi.hoisted(() => vi.fn());
|
||||
const isDesktopUpdaterAvailableMock = vi.hoisted(() => vi.fn());
|
||||
const messageErrorMock = vi.hoisted(() => vi.fn());
|
||||
const confirmMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../runtime", () => ({
|
||||
checkForDesktopUpdate: checkForDesktopUpdateMock,
|
||||
installPendingDesktopUpdate: installPendingDesktopUpdateMock,
|
||||
isDesktopUpdaterAvailable: isDesktopUpdaterAvailableMock,
|
||||
}));
|
||||
|
||||
vi.mock("element-plus", () => ({
|
||||
ElMessage: {
|
||||
error: messageErrorMock,
|
||||
},
|
||||
ElMessageBox: {
|
||||
confirm: confirmMock,
|
||||
},
|
||||
}));
|
||||
|
||||
const createStorage = (): Storage => {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return data.size;
|
||||
},
|
||||
clear: vi.fn(() => data.clear()),
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
key: vi.fn((index: number) => Array.from(data.keys())[index] ?? null),
|
||||
removeItem: vi.fn((key: string) => data.delete(key)),
|
||||
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
||||
};
|
||||
};
|
||||
|
||||
const update = {
|
||||
version: "0.1.1",
|
||||
currentVersion: "0.1.0",
|
||||
notes: "桌面端稳定化",
|
||||
date: "2026-07-02",
|
||||
};
|
||||
|
||||
describe("desktop update manager", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-02T00:00:00.000Z"));
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: createStorage(),
|
||||
configurable: true,
|
||||
});
|
||||
isDesktopUpdaterAvailableMock.mockReturnValue(true);
|
||||
checkForDesktopUpdateMock.mockResolvedValue(null);
|
||||
installPendingDesktopUpdateMock.mockResolvedValue(undefined);
|
||||
confirmMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { stopDesktopUpdateManager } = await import("./desktopUpdateManager");
|
||||
stopDesktopUpdateManager();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("records up-to-date checks without prompting", async () => {
|
||||
const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager");
|
||||
|
||||
const status = await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true, promptWhenAvailable: true });
|
||||
|
||||
expect(status).toBe("up-to-date");
|
||||
expect(confirmMock).not.toHaveBeenCalled();
|
||||
expect(getDesktopUpdateStatus()).toMatchObject({
|
||||
lastStatus: "up-to-date",
|
||||
pendingUpdate: null,
|
||||
lastError: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses the same postponed version for 24 hours", async () => {
|
||||
checkForDesktopUpdateMock.mockResolvedValue(update);
|
||||
confirmMock.mockRejectedValueOnce("cancel");
|
||||
const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager");
|
||||
|
||||
const postponed = await checkDesktopUpdateAndPrompt({ promptWhenAvailable: true });
|
||||
const postponedStatus = getDesktopUpdateStatus();
|
||||
const suppressed = await checkDesktopUpdateAndPrompt({ promptWhenAvailable: true });
|
||||
|
||||
expect(postponed).toBe("postponed");
|
||||
expect(suppressed).toBe("suppressed");
|
||||
expect(confirmMock).toHaveBeenCalledTimes(1);
|
||||
expect(installPendingDesktopUpdateMock).not.toHaveBeenCalled();
|
||||
expect(postponedStatus.postponedUntil).toBe("2026-07-03T00:00:00.000Z");
|
||||
expect(getDesktopUpdateStatus()).toMatchObject({
|
||||
lastStatus: "suppressed",
|
||||
pendingUpdate: update,
|
||||
postponedUntil: "2026-07-03T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a pending update retryable when installation fails", async () => {
|
||||
checkForDesktopUpdateMock.mockResolvedValue(update);
|
||||
installPendingDesktopUpdateMock.mockRejectedValueOnce(new Error("install failed"));
|
||||
const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager");
|
||||
|
||||
const status = await checkDesktopUpdateAndPrompt({ promptWhenAvailable: true });
|
||||
|
||||
expect(status).toBe("failed");
|
||||
expect(messageErrorMock).toHaveBeenCalledWith("桌面端更新安装失败,请稍后重试或联系管理员。");
|
||||
expect(getDesktopUpdateStatus()).toMatchObject({
|
||||
installing: false,
|
||||
lastStatus: "failed",
|
||||
lastError: "桌面端更新安装失败",
|
||||
pendingUpdate: update,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not interrupt timed update checks when checking fails", async () => {
|
||||
checkForDesktopUpdateMock.mockRejectedValueOnce(new Error("feed unavailable"));
|
||||
const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager");
|
||||
|
||||
const status = await checkDesktopUpdateAndPrompt();
|
||||
|
||||
expect(status).toBe("failed");
|
||||
expect(messageErrorMock).not.toHaveBeenCalled();
|
||||
expect(getDesktopUpdateStatus()).toMatchObject({
|
||||
lastStatus: "failed",
|
||||
lastError: "feed unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports disabled updater builds explicitly", async () => {
|
||||
isDesktopUpdaterAvailableMock.mockReturnValue(false);
|
||||
const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager");
|
||||
|
||||
const status = await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
|
||||
|
||||
expect(status).toBe("disabled");
|
||||
expect(checkForDesktopUpdateMock).not.toHaveBeenCalled();
|
||||
expect(getDesktopUpdateStatus()).toMatchObject({
|
||||
available: false,
|
||||
lastStatus: "disabled",
|
||||
pendingUpdate: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,33 @@ describe("auth store logout", () => {
|
||||
expect(session.timeoutWarningVisible).toBe(false);
|
||||
});
|
||||
|
||||
it("can logout without remembering the current study during desktop server switch", async () => {
|
||||
const { useAuthStore } = await import("./auth");
|
||||
const { useStudyStore } = await import("./study");
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
|
||||
study.setCurrentStudy({
|
||||
id: "study-old-server",
|
||||
name: "旧服务器项目",
|
||||
status: "ACTIVE",
|
||||
is_locked: false,
|
||||
} as any);
|
||||
auth.user = {
|
||||
id: "user-1",
|
||||
email: "admin@test.com",
|
||||
full_name: "Admin",
|
||||
clinical_department: "Admin",
|
||||
status: "ACTIVE",
|
||||
is_admin: true,
|
||||
};
|
||||
|
||||
await auth.logout({ rememberCurrentStudy: false });
|
||||
|
||||
expect(window.localStorage.getItem("ctms_last_study_by_user")).toBeNull();
|
||||
expect(study.currentStudy).toBeNull();
|
||||
});
|
||||
|
||||
it("uses encrypted login by default outside a secure browser context", async () => {
|
||||
vi.stubGlobal("isSecureContext", false);
|
||||
const { useAuthStore } = await import("./auth");
|
||||
|
||||
@@ -62,11 +62,13 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
return data;
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
const logout = async (options: { rememberCurrentStudy?: boolean } = {}) => {
|
||||
const studyStore = useStudyStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || "";
|
||||
studyStore.rememberCurrentStudyForUser(userKey);
|
||||
if (options.rememberCurrentStudy !== false) {
|
||||
studyStore.rememberCurrentStudyForUser(userKey);
|
||||
}
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
forceLogin.value = false;
|
||||
|
||||
@@ -144,7 +144,7 @@ const checkHealth = async (baseUrl: string) => {
|
||||
};
|
||||
|
||||
const clearSessionForServerChange = async () => {
|
||||
await auth.logout();
|
||||
await auth.logout({ rememberCurrentStudy: false });
|
||||
studyStore.clearCurrentStudy();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user