完善桌面端端到端回归收口
This commit is contained in:
@@ -147,3 +147,22 @@ npm run desktop:build:app
|
|||||||
- Apple Developer 凭据、证书、签名身份、公证结果和组织 updater 私钥。
|
- Apple Developer 凭据、证书、签名身份、公证结果和组织 updater 私钥。
|
||||||
- 生产下载源的不可变制品上传和线上 `latest.json` 原子替换。
|
- 生产下载源的不可变制品上传和线上 `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).not.toContain("copyConnectionDiagnostic");
|
||||||
expect(serverSettings).toContain("确认切换服务器");
|
expect(serverSettings).toContain("确认切换服务器");
|
||||||
expect(serverSettings).toContain("切换服务器会退出当前会话并清除当前项目上下文");
|
expect(serverSettings).toContain("切换服务器会退出当前会话并清除当前项目上下文");
|
||||||
|
expect(serverSettings).toContain("auth.logout({ rememberCurrentStudy: false })");
|
||||||
expect(serverSettings).not.toContain("服务器连接已确认");
|
expect(serverSettings).not.toContain("服务器连接已确认");
|
||||||
expect(preferences).toContain("connectionDiagnostic");
|
expect(preferences).toContain("connectionDiagnostic");
|
||||||
expect(preferences).not.toContain("复制连接诊断");
|
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 { data } = await claimDesktopNotifications();
|
||||||
const deliveredIds: string[] = [];
|
const deliveredIds: string[] = [];
|
||||||
|
let deliveryFailed = false;
|
||||||
for (const item of data.items) {
|
for (const item of data.items) {
|
||||||
|
try {
|
||||||
await showSystemNotification();
|
await showSystemNotification();
|
||||||
deliveredIds.push(item.id);
|
deliveredIds.push(item.id);
|
||||||
|
} catch {
|
||||||
|
deliveryFailed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (data.claim_token && deliveredIds.length) {
|
if (data.claim_token && deliveredIds.length) {
|
||||||
await acknowledgeDesktopNotifications(data.claim_token, deliveredIds);
|
await acknowledgeDesktopNotifications(data.claim_token, deliveredIds);
|
||||||
}
|
}
|
||||||
|
if (deliveryFailed) {
|
||||||
|
throw new Error("desktop notification delivery failed");
|
||||||
|
}
|
||||||
failureCount = 0;
|
failureCount = 0;
|
||||||
schedule(POLL_INTERVAL_MS);
|
schedule(POLL_INTERVAL_MS);
|
||||||
} catch {
|
} 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);
|
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 () => {
|
it("uses encrypted login by default outside a secure browser context", async () => {
|
||||||
vi.stubGlobal("isSecureContext", false);
|
vi.stubGlobal("isSecureContext", false);
|
||||||
const { useAuthStore } = await import("./auth");
|
const { useAuthStore } = await import("./auth");
|
||||||
|
|||||||
@@ -62,11 +62,13 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async (options: { rememberCurrentStudy?: boolean } = {}) => {
|
||||||
const studyStore = useStudyStore();
|
const studyStore = useStudyStore();
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || "";
|
const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || "";
|
||||||
|
if (options.rememberCurrentStudy !== false) {
|
||||||
studyStore.rememberCurrentStudyForUser(userKey);
|
studyStore.rememberCurrentStudyForUser(userKey);
|
||||||
|
}
|
||||||
token.value = null;
|
token.value = null;
|
||||||
user.value = null;
|
user.value = null;
|
||||||
forceLogin.value = false;
|
forceLogin.value = false;
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ const checkHealth = async (baseUrl: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearSessionForServerChange = async () => {
|
const clearSessionForServerChange = async () => {
|
||||||
await auth.logout();
|
await auth.logout({ rememberCurrentStudy: false });
|
||||||
studyStore.clearCurrentStudy();
|
studyStore.clearCurrentStudy();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user