Loading panel/src/components/ConfirmDialog.test.tsx 0 → 100644 +90 −0 Changes for panel/src/components/ConfirmDialog.test.tsx: 90 added lines, 0 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom import { describe, it, expect, vi } from "vitest"; import { useState } from "react"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import i18next from "i18next"; import { ConfirmDialog } from "./ConfirmDialog"; function Harness({ onConfirm }: { onConfirm: () => Promise<void> }) { const [open, setOpen] = useState(true); return ( <> <span>{open ? "open" : "closed"}</span> <button onClick={() => setOpen(true)}>reopen</button> <ConfirmDialog open={open} onOpenChange={setOpen} title="Remove it?" description="It cannot come back." confirmLabel="Remove" onConfirm={onConfirm} /> </> ); } describe("ConfirmDialog", () => { it("closes once the action succeeds", async () => { const action = vi.fn().mockResolvedValue(undefined); render(<Harness onConfirm={action} />); await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" })); expect(action).toHaveBeenCalledTimes(1); expect(await screen.findByText("closed")).toBeTruthy(); expect(screen.queryByRole("dialog")).toBeNull(); }); it("keeps the dialog open with the reason when the action fails, and lets it be retried", async () => { const action = vi .fn() .mockRejectedValueOnce({ status: 409, code: "test", message: "still in use" }) .mockResolvedValueOnce(undefined); render(<Harness onConfirm={action} />); const dialog = screen.getByRole("dialog"); await userEvent.click(within(dialog).getByRole("button", { name: "Remove" })); expect((await within(dialog).findByRole("alert")).textContent).toBe("still in use"); expect(screen.getByText("open")).toBeTruthy(); await userEvent.click(within(dialog).getByRole("button", { name: "Remove" })); expect(await screen.findByText("closed")).toBeTruthy(); expect(action).toHaveBeenCalledTimes(2); }); it("cannot be dismissed while the action runs, and clears the old failure on retry", async () => { let finish!: (v?: unknown) => void; const action = vi .fn() .mockRejectedValueOnce({ status: 409, code: "test", message: "still in use" }) .mockImplementationOnce(() => new Promise((r) => (finish = r))); render(<Harness onConfirm={action} />); const dialog = screen.getByRole("dialog"); const cancel = within(dialog).getByRole("button", { name: i18next.t("common:cancel") }); await userEvent.click(within(dialog).getByRole("button", { name: "Remove" })); await within(dialog).findByRole("alert"); await userEvent.click(within(dialog).getByRole("button", { name: "Remove" })); expect(within(dialog).queryByRole("alert")).toBeNull(); await userEvent.keyboard("{Escape}"); await userEvent.click(cancel, { pointerEventsCheck: 0 }); expect(screen.getByText("open")).toBeTruthy(); finish(); expect(await screen.findByText("closed")).toBeTruthy(); }); it("forgets an old failure when reopened", async () => { const action = vi.fn().mockRejectedValue({ status: 409, code: "test", message: "still in use" }); render(<Harness onConfirm={action} />); const dialog = screen.getByRole("dialog"); await userEvent.click(within(dialog).getByRole("button", { name: "Remove" })); await within(dialog).findByRole("alert"); await userEvent.click(within(dialog).getByRole("button", { name: i18next.t("common:cancel") })); expect(await screen.findByText("closed")).toBeTruthy(); await userEvent.click(screen.getByRole("button", { name: "reopen" })); expect(within(await screen.findByRole("dialog")).queryByRole("alert")).toBeNull(); }); }); panel/src/components/ConfirmDialog.tsx 0 → 100644 +84 −0 Changes for panel/src/components/ConfirmDialog.tsx: 84 added lines, 0 removed lines. Original line number Diff line number Diff line import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { ConfirmFooter } from "@/components/ConfirmFooter"; import { MessageLine } from "@/components/MessageLine"; import { humanizeError } from "@/lib/api"; interface Props { open: boolean; onOpenChange: (open: boolean) => void; title: string; description?: ReactNode; confirmLabel: string; /** Defaults to "Cancel"; name what dismissing keeps when the action is itself a cancel. */ cancelLabel?: string; confirmVariant?: "destructive" | "default"; /** Runs the action. The dialog closes when it resolves; a rejection is shown in * the dialog, which stays open so the action can be retried or dismissed. */ onConfirm: () => Promise<void>; } // ConfirmDialog asks before an action that cannot be taken back, in the panel's // own dialog rather than window.confirm (which some embedded browsers block, so // the action could never be confirmed there). The failure stays next to the // buttons that caused it. export function ConfirmDialog({ open, onOpenChange, title, description, confirmLabel, cancelLabel, confirmVariant = "destructive", onConfirm, }: Props) { const { t } = useTranslation("common"); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null); function setOpen(next: boolean) { if (busy) return; if (!next) setError(null); onOpenChange(next); } async function confirm() { setBusy(true); setError(null); try { await onConfirm(); setBusy(false); onOpenChange(false); } catch (err) { setError(humanizeError(err)); setBusy(false); } } return ( <Dialog open={open} onOpenChange={setOpen}> <DialogContent> <DialogHeader> <DialogTitle>{title}</DialogTitle> {description && <DialogDescription>{description}</DialogDescription>} </DialogHeader> {error && <MessageLine kind="error" message={error} compact />} <ConfirmFooter onCancel={() => setOpen(false)} onConfirm={() => void confirm()} loading={busy} cancelLabel={cancelLabel ?? t("cancel")} confirmLabel={confirmLabel} confirmVariant={confirmVariant} /> </DialogContent> </Dialog> ); } panel/src/i18n/resources/en-US/admin.json +10 −2 Changes for panel/src/i18n/resources/en-US/admin.json: 10 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -9,8 +9,16 @@ "add_image_btn": "Add Image", "image_ref_placeholder": "e.g. registry.felis.svc:5000/image:tag", "delete_image_tooltip": "Delete Whitelist Image", "delete_confirm": "Are you sure you want to delete this image?", "cancel_confirm": "Are you sure you want to cancel this build job?", "image_source_built": "Built here", "image_source_external": "External", "image_source_recommended": "Recommended", "delete_image_title": "Remove this image from the whitelist?", "delete_image_desc": "New servers can no longer be created from", "delete_image_confirm": "Remove image", "cancel_build_title": "Stop this build?", "cancel_build_desc": "The build stops now and pushes nothing:", "cancel_build_keep": "Keep building", "cancel_build_confirm": "Stop build", "delete_btn": "Delete", "builds_title": "Build Pipeline", "builds_subtitle": "History of image build jobs and trigger controls. The build pod runs inside an isolated namespace with automated Trivy scans.", Loading panel/src/i18n/resources/zh-CN/admin.json +10 −2 Changes for panel/src/i18n/resources/zh-CN/admin.json: 10 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -9,8 +9,16 @@ "add_image_btn": "添加镜像", "image_ref_placeholder": "例如: registry.felis.svc:5000/image:tag", "delete_image_tooltip": "删除白名单镜像", "delete_confirm": "确定删除此镜像吗?", "cancel_confirm": "确定要取消该构建任务吗?", "image_source_built": "平台构建", "image_source_external": "外部", "image_source_recommended": "推荐", "delete_image_title": "从白名单移除这个镜像?", "delete_image_desc": "移除后,新服务器将不能再选用", "delete_image_confirm": "移除镜像", "cancel_build_title": "停止这次构建?", "cancel_build_desc": "构建会立即停止,不会推送任何镜像:", "cancel_build_keep": "继续构建", "cancel_build_confirm": "停止构建", "delete_btn": "删除", "builds_title": "构建流水线", "builds_subtitle": "平台镜像构建任务历史及触发终端。构建 Pod 运行在隔离沙箱中,完成构建后将自动运行安全扫描。", Loading panel/src/pages/admin/ImageAdmin.test.tsx 0 → 100644 +96 −0 Changes for panel/src/pages/admin/ImageAdmin.test.tsx: 96 added lines, 0 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import i18next from "i18next"; import { ImageAdmin } from "./ImageAdmin"; const calls = vi.hoisted(() => ({ listImages: vi.fn(), removeImage: vi.fn() })); vi.mock("@/lib/api", async (importOriginal) => { const actual = await importOriginal<typeof import("@/lib/api")>(); return { ...actual, api: { ...actual.api, ...calls } }; }); const t = (key: string) => i18next.t(key); const REF = "registry.felis.svc:5000/paper:1.21"; beforeEach(() => { for (const fn of Object.values(calls)) fn.mockReset(); calls.listImages.mockResolvedValue([{ image_ref: REF, source: "external", enabled: true }]); // A native dialog here would block (or be blocked in an embedded browser). vi.spyOn(window, "confirm").mockImplementation(() => { throw new Error("window.confirm used"); }); vi.spyOn(window, "alert").mockImplementation(() => { throw new Error("window.alert used"); }); }); afterEach(() => vi.restoreAllMocks()); async function openRemove() { render( <MemoryRouter> <ImageAdmin /> </MemoryRouter>, ); await userEvent.click(await screen.findByRole("button", { name: t("admin:delete_image_tooltip") })); return screen.getByRole("dialog", { name: t("admin:delete_image_title") }); } describe("ImageAdmin removal", () => { it("asks in a dialog that names the image, then removes it", async () => { calls.removeImage.mockResolvedValue(undefined); const dialog = await openRemove(); expect(within(dialog).getByText(REF)).toBeTruthy(); expect(calls.removeImage).not.toHaveBeenCalled(); await userEvent.click(within(dialog).getByRole("button", { name: t("admin:delete_image_confirm") })); expect(calls.removeImage).toHaveBeenCalledWith(REF); await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(calls.listImages).toHaveBeenCalledTimes(2); }); it("shows a failed removal in the dialog", async () => { calls.removeImage.mockRejectedValue({ status: 409, code: "test", message: "a server still uses it" }); const dialog = await openRemove(); await userEvent.click(within(dialog).getByRole("button", { name: t("admin:delete_image_confirm") })); expect((await within(dialog).findByRole("alert")).textContent).toBe("a server still uses it"); expect(screen.getByRole("dialog")).toBeTruthy(); }); it("leaves the image alone when the dialog is dismissed", async () => { const dialog = await openRemove(); await userEvent.click(within(dialog).getByRole("button", { name: t("common:cancel") })); expect(screen.queryByRole("dialog")).toBeNull(); expect(calls.removeImage).not.toHaveBeenCalled(); }); }); describe("ImageAdmin source", () => { afterEach(() => i18next.changeLanguage("en-US")); it("names the sources Felis writes in the UI language, finds them by that name, and shows others as stored", async () => { calls.listImages.mockResolvedValue([ { image_ref: "registry.felis.svc:5000/paper:demo", source: "recommended", enabled: true }, { image_ref: "docker.io/itzg/minecraft-server:java21", source: "external", enabled: true }, { image_ref: "mirror.example.test/forge:1.20", source: "community-mirror", enabled: true }, ]); await i18next.changeLanguage("zh-CN"); render( <MemoryRouter> <ImageAdmin /> </MemoryRouter>, ); expect(await screen.findByText("推荐")).toBeTruthy(); expect(screen.getByText("外部")).toBeTruthy(); expect(screen.getByText("community-mirror")).toBeTruthy(); expect(screen.queryByText("recommended")).toBeNull(); await userEvent.type(screen.getByRole("textbox"), "推荐"); await vi.waitFor(() => expect(screen.queryByText("docker.io/itzg/minecraft-server:java21")).toBeNull()); expect(screen.getByText("registry.felis.svc:5000/paper:demo")).toBeTruthy(); }); }); Loading
panel/src/components/ConfirmDialog.test.tsx 0 → 100644 +90 −0 Changes for panel/src/components/ConfirmDialog.test.tsx: 90 added lines, 0 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom import { describe, it, expect, vi } from "vitest"; import { useState } from "react"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import i18next from "i18next"; import { ConfirmDialog } from "./ConfirmDialog"; function Harness({ onConfirm }: { onConfirm: () => Promise<void> }) { const [open, setOpen] = useState(true); return ( <> <span>{open ? "open" : "closed"}</span> <button onClick={() => setOpen(true)}>reopen</button> <ConfirmDialog open={open} onOpenChange={setOpen} title="Remove it?" description="It cannot come back." confirmLabel="Remove" onConfirm={onConfirm} /> </> ); } describe("ConfirmDialog", () => { it("closes once the action succeeds", async () => { const action = vi.fn().mockResolvedValue(undefined); render(<Harness onConfirm={action} />); await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" })); expect(action).toHaveBeenCalledTimes(1); expect(await screen.findByText("closed")).toBeTruthy(); expect(screen.queryByRole("dialog")).toBeNull(); }); it("keeps the dialog open with the reason when the action fails, and lets it be retried", async () => { const action = vi .fn() .mockRejectedValueOnce({ status: 409, code: "test", message: "still in use" }) .mockResolvedValueOnce(undefined); render(<Harness onConfirm={action} />); const dialog = screen.getByRole("dialog"); await userEvent.click(within(dialog).getByRole("button", { name: "Remove" })); expect((await within(dialog).findByRole("alert")).textContent).toBe("still in use"); expect(screen.getByText("open")).toBeTruthy(); await userEvent.click(within(dialog).getByRole("button", { name: "Remove" })); expect(await screen.findByText("closed")).toBeTruthy(); expect(action).toHaveBeenCalledTimes(2); }); it("cannot be dismissed while the action runs, and clears the old failure on retry", async () => { let finish!: (v?: unknown) => void; const action = vi .fn() .mockRejectedValueOnce({ status: 409, code: "test", message: "still in use" }) .mockImplementationOnce(() => new Promise((r) => (finish = r))); render(<Harness onConfirm={action} />); const dialog = screen.getByRole("dialog"); const cancel = within(dialog).getByRole("button", { name: i18next.t("common:cancel") }); await userEvent.click(within(dialog).getByRole("button", { name: "Remove" })); await within(dialog).findByRole("alert"); await userEvent.click(within(dialog).getByRole("button", { name: "Remove" })); expect(within(dialog).queryByRole("alert")).toBeNull(); await userEvent.keyboard("{Escape}"); await userEvent.click(cancel, { pointerEventsCheck: 0 }); expect(screen.getByText("open")).toBeTruthy(); finish(); expect(await screen.findByText("closed")).toBeTruthy(); }); it("forgets an old failure when reopened", async () => { const action = vi.fn().mockRejectedValue({ status: 409, code: "test", message: "still in use" }); render(<Harness onConfirm={action} />); const dialog = screen.getByRole("dialog"); await userEvent.click(within(dialog).getByRole("button", { name: "Remove" })); await within(dialog).findByRole("alert"); await userEvent.click(within(dialog).getByRole("button", { name: i18next.t("common:cancel") })); expect(await screen.findByText("closed")).toBeTruthy(); await userEvent.click(screen.getByRole("button", { name: "reopen" })); expect(within(await screen.findByRole("dialog")).queryByRole("alert")).toBeNull(); }); });
panel/src/components/ConfirmDialog.tsx 0 → 100644 +84 −0 Changes for panel/src/components/ConfirmDialog.tsx: 84 added lines, 0 removed lines. Original line number Diff line number Diff line import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { ConfirmFooter } from "@/components/ConfirmFooter"; import { MessageLine } from "@/components/MessageLine"; import { humanizeError } from "@/lib/api"; interface Props { open: boolean; onOpenChange: (open: boolean) => void; title: string; description?: ReactNode; confirmLabel: string; /** Defaults to "Cancel"; name what dismissing keeps when the action is itself a cancel. */ cancelLabel?: string; confirmVariant?: "destructive" | "default"; /** Runs the action. The dialog closes when it resolves; a rejection is shown in * the dialog, which stays open so the action can be retried or dismissed. */ onConfirm: () => Promise<void>; } // ConfirmDialog asks before an action that cannot be taken back, in the panel's // own dialog rather than window.confirm (which some embedded browsers block, so // the action could never be confirmed there). The failure stays next to the // buttons that caused it. export function ConfirmDialog({ open, onOpenChange, title, description, confirmLabel, cancelLabel, confirmVariant = "destructive", onConfirm, }: Props) { const { t } = useTranslation("common"); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null); function setOpen(next: boolean) { if (busy) return; if (!next) setError(null); onOpenChange(next); } async function confirm() { setBusy(true); setError(null); try { await onConfirm(); setBusy(false); onOpenChange(false); } catch (err) { setError(humanizeError(err)); setBusy(false); } } return ( <Dialog open={open} onOpenChange={setOpen}> <DialogContent> <DialogHeader> <DialogTitle>{title}</DialogTitle> {description && <DialogDescription>{description}</DialogDescription>} </DialogHeader> {error && <MessageLine kind="error" message={error} compact />} <ConfirmFooter onCancel={() => setOpen(false)} onConfirm={() => void confirm()} loading={busy} cancelLabel={cancelLabel ?? t("cancel")} confirmLabel={confirmLabel} confirmVariant={confirmVariant} /> </DialogContent> </Dialog> ); }
panel/src/i18n/resources/en-US/admin.json +10 −2 Changes for panel/src/i18n/resources/en-US/admin.json: 10 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -9,8 +9,16 @@ "add_image_btn": "Add Image", "image_ref_placeholder": "e.g. registry.felis.svc:5000/image:tag", "delete_image_tooltip": "Delete Whitelist Image", "delete_confirm": "Are you sure you want to delete this image?", "cancel_confirm": "Are you sure you want to cancel this build job?", "image_source_built": "Built here", "image_source_external": "External", "image_source_recommended": "Recommended", "delete_image_title": "Remove this image from the whitelist?", "delete_image_desc": "New servers can no longer be created from", "delete_image_confirm": "Remove image", "cancel_build_title": "Stop this build?", "cancel_build_desc": "The build stops now and pushes nothing:", "cancel_build_keep": "Keep building", "cancel_build_confirm": "Stop build", "delete_btn": "Delete", "builds_title": "Build Pipeline", "builds_subtitle": "History of image build jobs and trigger controls. The build pod runs inside an isolated namespace with automated Trivy scans.", Loading
panel/src/i18n/resources/zh-CN/admin.json +10 −2 Changes for panel/src/i18n/resources/zh-CN/admin.json: 10 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -9,8 +9,16 @@ "add_image_btn": "添加镜像", "image_ref_placeholder": "例如: registry.felis.svc:5000/image:tag", "delete_image_tooltip": "删除白名单镜像", "delete_confirm": "确定删除此镜像吗?", "cancel_confirm": "确定要取消该构建任务吗?", "image_source_built": "平台构建", "image_source_external": "外部", "image_source_recommended": "推荐", "delete_image_title": "从白名单移除这个镜像?", "delete_image_desc": "移除后,新服务器将不能再选用", "delete_image_confirm": "移除镜像", "cancel_build_title": "停止这次构建?", "cancel_build_desc": "构建会立即停止,不会推送任何镜像:", "cancel_build_keep": "继续构建", "cancel_build_confirm": "停止构建", "delete_btn": "删除", "builds_title": "构建流水线", "builds_subtitle": "平台镜像构建任务历史及触发终端。构建 Pod 运行在隔离沙箱中,完成构建后将自动运行安全扫描。", Loading
panel/src/pages/admin/ImageAdmin.test.tsx 0 → 100644 +96 −0 Changes for panel/src/pages/admin/ImageAdmin.test.tsx: 96 added lines, 0 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import i18next from "i18next"; import { ImageAdmin } from "./ImageAdmin"; const calls = vi.hoisted(() => ({ listImages: vi.fn(), removeImage: vi.fn() })); vi.mock("@/lib/api", async (importOriginal) => { const actual = await importOriginal<typeof import("@/lib/api")>(); return { ...actual, api: { ...actual.api, ...calls } }; }); const t = (key: string) => i18next.t(key); const REF = "registry.felis.svc:5000/paper:1.21"; beforeEach(() => { for (const fn of Object.values(calls)) fn.mockReset(); calls.listImages.mockResolvedValue([{ image_ref: REF, source: "external", enabled: true }]); // A native dialog here would block (or be blocked in an embedded browser). vi.spyOn(window, "confirm").mockImplementation(() => { throw new Error("window.confirm used"); }); vi.spyOn(window, "alert").mockImplementation(() => { throw new Error("window.alert used"); }); }); afterEach(() => vi.restoreAllMocks()); async function openRemove() { render( <MemoryRouter> <ImageAdmin /> </MemoryRouter>, ); await userEvent.click(await screen.findByRole("button", { name: t("admin:delete_image_tooltip") })); return screen.getByRole("dialog", { name: t("admin:delete_image_title") }); } describe("ImageAdmin removal", () => { it("asks in a dialog that names the image, then removes it", async () => { calls.removeImage.mockResolvedValue(undefined); const dialog = await openRemove(); expect(within(dialog).getByText(REF)).toBeTruthy(); expect(calls.removeImage).not.toHaveBeenCalled(); await userEvent.click(within(dialog).getByRole("button", { name: t("admin:delete_image_confirm") })); expect(calls.removeImage).toHaveBeenCalledWith(REF); await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(calls.listImages).toHaveBeenCalledTimes(2); }); it("shows a failed removal in the dialog", async () => { calls.removeImage.mockRejectedValue({ status: 409, code: "test", message: "a server still uses it" }); const dialog = await openRemove(); await userEvent.click(within(dialog).getByRole("button", { name: t("admin:delete_image_confirm") })); expect((await within(dialog).findByRole("alert")).textContent).toBe("a server still uses it"); expect(screen.getByRole("dialog")).toBeTruthy(); }); it("leaves the image alone when the dialog is dismissed", async () => { const dialog = await openRemove(); await userEvent.click(within(dialog).getByRole("button", { name: t("common:cancel") })); expect(screen.queryByRole("dialog")).toBeNull(); expect(calls.removeImage).not.toHaveBeenCalled(); }); }); describe("ImageAdmin source", () => { afterEach(() => i18next.changeLanguage("en-US")); it("names the sources Felis writes in the UI language, finds them by that name, and shows others as stored", async () => { calls.listImages.mockResolvedValue([ { image_ref: "registry.felis.svc:5000/paper:demo", source: "recommended", enabled: true }, { image_ref: "docker.io/itzg/minecraft-server:java21", source: "external", enabled: true }, { image_ref: "mirror.example.test/forge:1.20", source: "community-mirror", enabled: true }, ]); await i18next.changeLanguage("zh-CN"); render( <MemoryRouter> <ImageAdmin /> </MemoryRouter>, ); expect(await screen.findByText("推荐")).toBeTruthy(); expect(screen.getByText("外部")).toBeTruthy(); expect(screen.getByText("community-mirror")).toBeTruthy(); expect(screen.queryByText("recommended")).toBeNull(); await userEvent.type(screen.getByRole("textbox"), "推荐"); await vi.waitFor(() => expect(screen.queryByText("docker.io/itzg/minecraft-server:java21")).toBeNull()); expect(screen.getByText("registry.felis.svc:5000/paper:demo")).toBeTruthy(); }); });