mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(studio): hold backup deletions until receipts settle
This commit is contained in:
1 parent
444ca4fa45
commit
e3e2ea39dd
2 files changed
+191
-6
No files matched your search
@@ -0,0 +1,179 @@
|
||||
// @vitest-environment jsdom
|
||||
import { StrictMode } from "react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Backup } from "./Backup";
|
||||
import { SsePort } from "../port/sse";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
import type { BackupCatalog, BackupEntry } from "../port/port";
|
||||
|
||||
const backups: BackupEntry[] = ["one/backup", "two/backup"].map((id, index) => ({
|
||||
id, label: index === 0 ? "first laptop" : "second laptop", format: 1, appVersion: "2.29.0",
|
||||
platform: "darwin/arm64", categories: ["settings"], ciphertextBytes: 2048, createdAt: "2026-10-01T00:00:00Z",
|
||||
}));
|
||||
|
||||
function reply(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function draw(strict: boolean) {
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
const removed = new Set<string>();
|
||||
const sent: { id: string; resolve: (response: Response) => void }[] = [];
|
||||
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
const method = init?.method ?? "GET";
|
||||
if (method === "GET" && path === "/backups") {
|
||||
const catalog: BackupCatalog = {
|
||||
categories: [{ id: "settings", defaultOn: true, consent: false }],
|
||||
backups: backups.filter((backup) => !removed.has(backup.id)),
|
||||
limits: { maxCount: 10, maxBytes: 1048576 }, minPassphrase: 10,
|
||||
};
|
||||
return Promise.resolve(reply(catalog));
|
||||
}
|
||||
if (method === "DELETE" && backups.some((backup) => path === "/backups/" + encodeURIComponent(backup.id))) {
|
||||
const id = decodeURIComponent(path.slice("/backups/".length));
|
||||
return new Promise<Response>((resolve) => sent.push({ id, resolve }));
|
||||
}
|
||||
throw new Error(`Unexpected ${method} ${path}`);
|
||||
}));
|
||||
const host = <Backup port={new SsePort("http://kernel.example")} />;
|
||||
return {
|
||||
...render(strict ? <StrictMode>{host}</StrictMode> : host), sent,
|
||||
async finish(index: number, status = 204) {
|
||||
const request = sent[index]!;
|
||||
await act(async () => {
|
||||
if (status === 204) {
|
||||
removed.add(request.id);
|
||||
request.resolve(new Response(null, { status }));
|
||||
} else {
|
||||
request.resolve(reply({ code: "backup.cloud_unavailable", error: "controlled cloud failure", params: { detail: "controlled cloud failure" } }, status));
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function drop(id = backups[0]!.id) {
|
||||
return document.querySelector<HTMLButtonElement>(`[data-action="backup.delete"][data-target="${id}"]`)!;
|
||||
}
|
||||
|
||||
async function begin(id = backups[0]!.id) {
|
||||
await screen.findByText("first laptop");
|
||||
await userEvent.click(drop(id));
|
||||
expect(drop(id).textContent).toBe("确认删除");
|
||||
await userEvent.click(drop(id));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
});
|
||||
|
||||
it.each([false, true])("sends one deletion while its successful receipt is pending (StrictMode=%s)", async (strict) => {
|
||||
const { sent, finish } = draw(strict);
|
||||
await begin();
|
||||
expect(sent.map((request) => request.id)).toEqual([backups[0]!.id]);
|
||||
await userEvent.click(drop());
|
||||
await userEvent.click(drop());
|
||||
const during = sent.map((request) => request.id);
|
||||
await finish(0);
|
||||
await waitFor(() => expect(screen.queryByText("first laptop")).toBeNull());
|
||||
expect(screen.getByText("second laptop")).toBeTruthy();
|
||||
expect(during).toEqual([backups[0]!.id]);
|
||||
});
|
||||
|
||||
it.each([false, true])("holds a pending deletion through mouse leave and allows a fresh retry after refusal (StrictMode=%s)", async (strict) => {
|
||||
const { sent, finish } = draw(strict);
|
||||
await begin();
|
||||
await userEvent.unhover(drop());
|
||||
await userEvent.click(drop());
|
||||
await userEvent.click(drop());
|
||||
const during = sent.map((request) => request.id);
|
||||
await finish(0, 502);
|
||||
await screen.findByText("暂时连不上备份服务,稍后再试:controlled cloud failure");
|
||||
expect(during).toEqual([backups[0]!.id]);
|
||||
expect(drop().textContent).toBe("删除");
|
||||
await begin();
|
||||
expect(sent.map((request) => request.id)).toEqual([backups[0]!.id, backups[0]!.id]);
|
||||
await finish(1);
|
||||
await waitFor(() => expect(screen.queryByText("first laptop")).toBeNull());
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps another backup confirmation when an earlier deletion is refused (StrictMode=%s)", async (strict) => {
|
||||
const { sent, finish } = draw(strict);
|
||||
await begin();
|
||||
await userEvent.click(drop(backups[1]!.id));
|
||||
expect(drop(backups[1]!.id).textContent).toBe("确认删除");
|
||||
await finish(0, 502);
|
||||
await screen.findByText("暂时连不上备份服务,稍后再试:controlled cloud failure");
|
||||
expect(drop(backups[1]!.id).textContent).toBe("确认删除");
|
||||
await userEvent.click(drop(backups[1]!.id));
|
||||
expect(sent.map((request) => request.id)).toEqual([backups[0]!.id, backups[1]!.id]);
|
||||
await finish(1);
|
||||
await waitFor(() => expect(screen.queryByText("second laptop")).toBeNull());
|
||||
expect(screen.getByText("first laptop")).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps distinct pending deletions independent when receipts arrive in reverse order (StrictMode=%s)", async (strict) => {
|
||||
const { sent, finish } = draw(strict);
|
||||
await begin();
|
||||
await userEvent.click(drop(backups[1]!.id));
|
||||
await userEvent.click(drop(backups[1]!.id));
|
||||
expect(sent.map((request) => request.id)).toEqual([backups[0]!.id, backups[1]!.id]);
|
||||
await finish(1);
|
||||
await waitFor(() => expect(screen.queryByText("second laptop")).toBeNull());
|
||||
await userEvent.click(drop());
|
||||
await userEvent.click(drop());
|
||||
const during = sent.map((request) => request.id);
|
||||
await finish(0, 502);
|
||||
await screen.findByText("暂时连不上备份服务,稍后再试:controlled cloud failure");
|
||||
expect(during).toEqual([backups[0]!.id, backups[1]!.id]);
|
||||
expect(drop().textContent).toBe("删除");
|
||||
});
|
||||
|
||||
it.each([false, true])("retains the existing one-confirmation and mouse-leave cancellation flow (StrictMode=%s)", async (strict) => {
|
||||
const { sent } = draw(strict);
|
||||
await screen.findByText("first laptop");
|
||||
await userEvent.click(drop());
|
||||
await userEvent.click(drop(backups[1]!.id));
|
||||
expect(drop().textContent).toBe("删除");
|
||||
expect(drop(backups[1]!.id).textContent).toBe("确认删除");
|
||||
await userEvent.unhover(drop(backups[1]!.id));
|
||||
expect(drop(backups[1]!.id).textContent).toBe("删除");
|
||||
expect(sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps backup drafts and restore controls available during deletion (StrictMode=%s)", async (strict) => {
|
||||
const { sent, finish } = draw(strict);
|
||||
await begin();
|
||||
const label = screen.getByRole<HTMLInputElement>("textbox", { name: "备注(可选)" });
|
||||
await userEvent.type(label, "independent draft");
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: /模型与界面设置/ }));
|
||||
expect(label.value).toBe("independent draft");
|
||||
expect(screen.getByRole<HTMLInputElement>("checkbox", { name: /模型与界面设置/ }).checked).toBe(false);
|
||||
expect(Array.from(document.querySelectorAll<HTMLButtonElement>('[data-action="backup.restore"]')).every((button) => !button.disabled)).toBe(true);
|
||||
expect(sent).toHaveLength(1);
|
||||
await finish(0, 502);
|
||||
});
|
||||
|
||||
it.each([false, true])("does not resend deletion through repeated keyboard activation (StrictMode=%s)", async (strict) => {
|
||||
const { sent, finish } = draw(strict);
|
||||
await screen.findByText("first laptop");
|
||||
drop().focus();
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(drop().textContent).toBe("确认删除");
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(sent.map((request) => request.id)).toEqual([backups[0]!.id]);
|
||||
await userEvent.keyboard("{Enter}{Enter}");
|
||||
const during = sent.map((request) => request.id);
|
||||
await finish(0, 502);
|
||||
await screen.findByText("暂时连不上备份服务,稍后再试:controlled cloud failure");
|
||||
expect(during).toEqual([backups[0]!.id]);
|
||||
});
|
||||
@@ -29,7 +29,7 @@ function BackupInput({ port }: { port: AgentPort }) {
|
||||
const [again, setAgain] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [dropping, setDropping] = useState("");
|
||||
const [dropping, setDropping] = useState(() => new Map<string, "confirm" | "pending">());
|
||||
const [restoring, setRestoring] = useState<BackupEntry | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -88,16 +88,22 @@ function BackupInput({ port }: { port: AgentPort }) {
|
||||
};
|
||||
|
||||
const drop = async (id: string) => {
|
||||
if (dropping !== id) {
|
||||
setDropping(id);
|
||||
if (dropping.get(id) !== "confirm") {
|
||||
setDropping((current) => new Map([...current].filter(([, phase]) => phase === "pending")).set(id, "confirm"));
|
||||
return;
|
||||
}
|
||||
setDropping("");
|
||||
setDropping((current) => new Map(current).set(id, "pending"));
|
||||
try {
|
||||
await port.deleteBackup(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
setDropping((current) => {
|
||||
const next = new Map(current);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -169,8 +175,8 @@ function BackupInput({ port }: { port: AgentPort }) {
|
||||
<button className="btn sm" data-action="backup.restore" data-target={b.id} onClick={() => setRestoring(b)}>
|
||||
{t("恢复…")}
|
||||
</button>
|
||||
<button className="btn sm" data-action="backup.delete" data-target={b.id} onClick={() => void drop(b.id)} onMouseLeave={() => setDropping("")}>
|
||||
{t(dropping === b.id ? "确认删除" : "删除")}
|
||||
<button className="btn sm" data-action="backup.delete" data-target={b.id} disabled={dropping.get(b.id) === "pending"} aria-busy={dropping.get(b.id) === "pending" || undefined} onClick={() => void drop(b.id)} onMouseLeave={() => setDropping((current) => current.get(b.id) === "confirm" ? new Map([...current].filter(([id]) => id !== b.id)) : current)}>
|
||||
{t(dropping.get(b.id) === "confirm" ? "确认删除" : "删除")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user