feat: server transfer rotation

This commit is contained in:
naiba
2026-05-25 10:15:24 +00:00
parent 142fc19469
commit b23a6c0be1
60 changed files with 4488 additions and 1359 deletions
+88
View File
@@ -0,0 +1,88 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
const toastCalls: Array<{ title: string; description: string }> = []
vi.mock("sonner", () => ({
toast: (title: string, opts?: { description?: string }) => {
toastCalls.push({ title, description: opts?.description ?? "" })
},
}))
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) => {
if (vars && typeof vars.count === "number") {
return `${key}=${vars.count}`
}
return key
},
}),
initReactI18next: { type: "3rdParty", init: () => undefined },
Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
}))
const batchMoveServer = vi.fn()
vi.mock("@/api/server", () => ({
batchMoveServer: (...args: unknown[]) => batchMoveServer(...args),
}))
import { BatchMoveServerIcon } from "@/components/batch-move-server-icon"
beforeEach(() => {
toastCalls.length = 0
batchMoveServer.mockReset()
})
afterEach(() => {
document.body.innerHTML = ""
})
async function openDialogAndSubmit(serverIds: number[], toUser: number) {
render(<BatchMoveServerIcon serverIds={serverIds} />)
await act(async () => {
fireEvent.click(screen.getByRole("button"))
})
const userInput = await screen.findByPlaceholderText("User ID")
await act(async () => {
fireEvent.change(userInput, { target: { value: String(toUser) } })
})
const submit = screen.getByRole("button", { name: "Move" })
await act(async () => {
fireEvent.click(submit)
})
await waitFor(() => expect(toastCalls.length).toBeGreaterThan(0))
}
test("BatchMoveServer toast surfaces agent_too_old count so operator sees the failure", async () => {
batchMoveServer.mockResolvedValueOnce([
{ server_id: 1, status: "agent_too_old", error: "agent build older than v1.18.0" },
{ server_id: 2, status: "pending", transfer_id: 99 },
])
await openDialogAndSubmit([1, 2], 300)
expect(batchMoveServer).toHaveBeenCalledOnce()
const summary = toastCalls[toastCalls.length - 1]
expect(summary, "submission must surface a toast").toBeDefined()
expect(summary.description, "toast must include the agent_too_old count").toContain(
"Transfer.AgentTooOldCount=1",
)
expect(summary.description).toContain("Transfer.PendingCount=1")
})
test("BatchMoveServer toast does not show Done fallback when every server is agent_too_old", async () => {
batchMoveServer.mockResolvedValueOnce([
{ server_id: 1, status: "agent_too_old", error: "older than v1.18.0" },
{ server_id: 2, status: "agent_too_old", error: "older than v1.18.0" },
])
await openDialogAndSubmit([1, 2], 300)
const summary = toastCalls[toastCalls.length - 1]
expect(summary).toBeDefined()
expect(
summary.description,
"all-failed batch must NOT collapse to a generic Done label — operator would think it succeeded",
).toContain("Transfer.AgentTooOldCount=2")
expect(summary.description).not.toBe("Done")
})
+246
View File
@@ -0,0 +1,246 @@
import { act, render, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
type DivProps = React.ComponentPropsWithoutRef<"div"> & { asChild?: boolean }
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
asChild?: boolean
size?: string
variant?: string
}
type IconButtonProps = ButtonProps & { icon?: string }
type SentWebSocketData = string | ArrayBufferLike | Blob | ArrayBufferView
vi.mock("../components/ui/button", () => ({
Button: (props: ButtonProps) => {
const { asChild, size, variant, ...buttonProps } = props
void asChild
void size
void variant
return <button {...buttonProps} />
},
}))
vi.mock("../components/ui/input", () => ({
Input: (props: React.ComponentPropsWithoutRef<"input">) => <input {...props} />,
}))
vi.mock("../components/ui/table", () => ({
Table: (props: React.ComponentPropsWithoutRef<"table">) => <table {...props} />,
TableHeader: (props: React.ComponentPropsWithoutRef<"thead">) => <thead {...props} />,
TableBody: (props: React.ComponentPropsWithoutRef<"tbody">) => <tbody {...props} />,
TableRow: (props: React.ComponentPropsWithoutRef<"tr">) => <tr {...props} />,
TableCell: (props: React.ComponentPropsWithoutRef<"td">) => <td {...props} />,
TableHead: (props: React.ComponentPropsWithoutRef<"th">) => <th {...props} />,
}))
vi.mock("../components/ui/dropdown-menu", () => ({
DropdownMenu: (props: DivProps) => <div {...props} />,
DropdownMenuTrigger: (props: DivProps) => <div {...props} />,
DropdownMenuContent: (props: DivProps) => <div {...props} />,
DropdownMenuItem: (props: DivProps) => <div {...props} />,
}))
vi.mock("../components/ui/alert-dialog", () => ({
AlertDialog: (props: DivProps) => <div {...props} />,
AlertDialogTrigger: (props: DivProps) => <div {...props} />,
AlertDialogContent: (props: DivProps) => <div {...props} />,
AlertDialogHeader: (props: DivProps) => <div {...props} />,
AlertDialogFooter: (props: DivProps) => <div {...props} />,
AlertDialogTitle: (props: DivProps) => <div {...props} />,
AlertDialogDescription: (props: DivProps) => <div {...props} />,
AlertDialogCancel: (props: DivProps) => <div {...props} />,
AlertDialogAction: (props: DivProps) => <div {...props} />,
}))
vi.mock("../components/ui/drawer", () => ({
Drawer: (props: DivProps) => <div {...props} />,
DrawerContent: (props: DivProps) => <div {...props} />,
DrawerHeader: (props: DivProps) => <div {...props} />,
DrawerTitle: (props: DivProps) => <div {...props} />,
DrawerTrigger: (props: DivProps) => <div {...props} />,
}))
vi.mock("../components/xui/overlayless-sheet", () => ({
Sheet: (props: DivProps) => <div {...props} />,
SheetContent: (props: DivProps) => <div {...props} />,
SheetDescription: (props: DivProps) => <div {...props} />,
SheetHeader: (props: DivProps) => <div {...props} />,
SheetTitle: (props: DivProps) => <div {...props} />,
SheetTrigger: (props: DivProps) => <div {...props} />,
}))
vi.mock("../components/xui/filepath", () => ({
Filepath: () => <div data-testid="filepath" />,
}))
vi.mock("../components/xui/icon-button", () => ({
IconButton: (props: IconButtonProps) => {
const { asChild, icon, size, variant, ...buttonProps } = props
void asChild
void icon
void size
void variant
return <button {...buttonProps} />
},
}))
vi.mock("../components/xui/virtulized-data-table", () => ({
DataTable: () => <div data-testid="data-table" />,
}))
vi.mock("lucide-react", () => ({
File: () => <div data-testid="file-icon" />,
Folder: () => <div data-testid="folder-icon" />,
}))
const translate = (key: string) => key
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: translate }),
initReactI18next: { type: "3rdParty", init: () => {} },
}))
vi.mock("sonner", () => ({ toast: vi.fn() }))
vi.mock("@/lib/utils", () => ({
copyToClipboard: vi.fn(),
fm: {
parseFMList: async (buf: ArrayBufferLike) => {
const view = new DataView(buf)
const pathLength = view.getUint32(4, false)
const pathBytes = new Uint8Array(buf, 8, pathLength)
return { path: new TextDecoder().decode(pathBytes), fmList: [] }
},
},
fmWorker: new Worker(""),
formatPath: (path: string) => path,
}))
import { FMComponent } from "../components/fm"
const webSockets: MockWebSocket[] = []
class MockWebSocket extends EventTarget implements WebSocket {
static readonly CONNECTING = 0
static readonly OPEN = 1
static readonly CLOSING = 2
static readonly CLOSED = 3
readonly CONNECTING = MockWebSocket.CONNECTING
readonly OPEN = MockWebSocket.OPEN
readonly CLOSING = MockWebSocket.CLOSING
readonly CLOSED = MockWebSocket.CLOSED
readonly bufferedAmount = 0
readonly extensions = ""
readonly protocol = ""
readonly url: string
binaryType: BinaryType = "arraybuffer"
onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null = null
onerror: ((this: WebSocket, ev: Event) => unknown) | null = null
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null
onopen: ((this: WebSocket, ev: Event) => unknown) | null = null
readyState = MockWebSocket.CONNECTING
sent: SentWebSocketData[] = []
constructor(url: string | URL) {
super()
this.url = url.toString()
webSockets.push(this)
}
addEventListener<K extends keyof WebSocketEventMap>(
type: K,
listener: (this: WebSocket, ev: WebSocketEventMap[K]) => unknown,
options?: boolean | AddEventListenerOptions,
): void
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener(type, listener, options)
}
removeEventListener<K extends keyof WebSocketEventMap>(
type: K,
listener: (this: WebSocket, ev: WebSocketEventMap[K]) => unknown,
options?: boolean | EventListenerOptions,
): void
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | EventListenerOptions,
): void {
super.removeEventListener(type, listener, options)
}
close(): void {
this.readyState = MockWebSocket.CLOSED
}
open(): void {
this.readyState = MockWebSocket.OPEN
this.onopen?.call(this, new Event("open"))
}
send(data: SentWebSocketData): void {
this.sent.push(data)
}
}
beforeEach(() => {
webSockets.length = 0
globalThis.WebSocket = MockWebSocket
})
afterEach(() => {
vi.clearAllMocks()
})
const encodeFileNameMessage = (path: string) => {
const identifier = new Uint8Array([0x4e, 0x5a, 0x46, 0x4e])
const pathBytes = new TextEncoder().encode(path)
const payload = new Uint8Array(identifier.length + 4 + pathBytes.length)
payload.set(identifier, 0)
new DataView(payload.buffer).setUint32(identifier.length, pathBytes.length, false)
payload.set(pathBytes, identifier.length + 4)
return payload.buffer
}
const encodeCompleteMessage = () => new Uint8Array([0x4e, 0x5a, 0x55, 0x50]).buffer
const decodeListPath = (data: SentWebSocketData) => {
if (!ArrayBuffer.isView(data)) return null
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
if (bytes[0] !== 0) return null
return new TextDecoder().decode(bytes.slice(1))
}
test("FM websocket lifecycle reuses the socket on path changes", async () => {
const { unmount } = render(<FMComponent wsUrl="/ws/file/test" />)
await waitFor(() => {
expect(webSockets).toHaveLength(1)
})
const socket = webSockets[0]
act(() => {
socket.open()
})
await act(async () => {
await socket.onmessage?.call(
socket,
new MessageEvent("message", { data: encodeFileNameMessage("/new/path") }),
)
})
await waitFor(() => {
expect(webSockets).toHaveLength(1)
expect(decodeListPath(socket.sent[socket.sent.length - 1])).toBe("/new/path")
})
socket.sent.length = 0
await act(async () => {
await socket.onmessage?.call(socket, new MessageEvent("message", { data: encodeCompleteMessage() }))
})
expect(webSockets).toHaveLength(1)
expect(decodeListPath(socket.sent[socket.sent.length - 1])).toBe("/new/path")
unmount()
expect(socket.readyState).toBe(MockWebSocket.CLOSED)
})
+36
View File
@@ -0,0 +1,36 @@
class TestWorker implements Worker {
onmessage: ((this: Worker, ev: MessageEvent) => unknown) | null = null
onmessageerror: ((this: Worker, ev: MessageEvent) => unknown) | null = null
onerror: ((this: AbstractWorker, ev: ErrorEvent) => unknown) | null = null
addEventListener(): void {}
removeEventListener(): void {}
dispatchEvent(): boolean {
return true
}
postMessage(): void {}
terminate(): void {}
}
class TestResizeObserver implements ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
class TestIntersectionObserver implements IntersectionObserver {
readonly root: Element | Document | null = null
readonly rootMargin = ""
readonly thresholds: ReadonlyArray<number> = []
observe(): void {}
unobserve(): void {}
disconnect(): void {}
takeRecords(): IntersectionObserverEntry[] {
return []
}
}
globalThis.Worker = TestWorker
globalThis.ResizeObserver = TestResizeObserver
globalThis.IntersectionObserver = TestIntersectionObserver
+103
View File
@@ -0,0 +1,103 @@
import { render } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
vi.mock("sonner", () => ({ toast: () => undefined }))
vi.mock("@/lib/utils", () => ({
sleep: () => Promise.resolve(),
cn: (...args: unknown[]) => args.filter(Boolean).join(" "),
}))
const attachAddonInstances: { ws: WebSocket }[] = []
vi.mock("@xterm/addon-attach", () => ({
AttachAddon: class {
ws: WebSocket
constructor(ws: WebSocket) {
this.ws = ws
attachAddonInstances.push(this)
}
activate() {}
dispose() {}
},
}))
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
activate() {}
dispose() {}
fit() {}
proposeDimensions() {
return { rows: 24, cols: 80 }
}
},
}))
vi.mock("@xterm/xterm", () => ({
Terminal: class {
loadAddon() {}
open() {}
dispose() {}
},
}))
class FakeWebSocket {
static instances: FakeWebSocket[] = []
url: string
binaryType = "arraybuffer"
onopen: ((ev: Event) => unknown) | null = null
onclose: ((ev: Event) => unknown) | null = null
onerror: ((ev: Event) => unknown) | null = null
onmessage: ((ev: MessageEvent) => unknown) | null = null
readyState = 0
closeCalls = 0
constructor(url: string | URL) {
this.url = url.toString()
FakeWebSocket.instances.push(this)
}
close() {
this.closeCalls += 1
this.readyState = 3
}
send() {}
addEventListener() {}
removeEventListener() {}
dispatchEvent() {
return true
}
}
beforeEach(() => {
FakeWebSocket.instances = []
attachAddonInstances.length = 0
;(globalThis as { WebSocket: typeof WebSocket }).WebSocket =
FakeWebSocket as unknown as typeof WebSocket
})
afterEach(() => {
vi.clearAllMocks()
})
test("XtermComponent closes the previous WebSocket and re-attaches xterm when wsUrl changes", async () => {
const { XtermComponent } = await import("../components/terminal")
const noop = () => undefined
const { rerender } = render(
<XtermComponent wsUrl="/api/v1/ws/terminal/session-1" setClose={noop} />,
)
expect(FakeWebSocket.instances).toHaveLength(1)
const firstSocket = FakeWebSocket.instances[0]
expect(attachAddonInstances).toHaveLength(1)
expect(attachAddonInstances[0].ws).toBe(firstSocket as unknown as WebSocket)
rerender(<XtermComponent wsUrl="/api/v1/ws/terminal/session-2" setClose={noop} />)
expect(FakeWebSocket.instances).toHaveLength(2)
const secondSocket = FakeWebSocket.instances[1]
expect(firstSocket.closeCalls).toBeGreaterThanOrEqual(1)
expect(attachAddonInstances).toHaveLength(2)
expect(attachAddonInstances[1].ws).toBe(secondSocket as unknown as WebSocket)
})
+139
View File
@@ -0,0 +1,139 @@
import { act, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
const toastCalls: Array<{ title: string; description: string }> = []
vi.mock("sonner", () => ({
toast: (title: string, opts?: { description?: string }) => {
toastCalls.push({ title, description: opts?.description ?? "" })
},
}))
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
initReactI18next: { type: "3rdParty", init: () => undefined },
Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
}))
const cancelServerTransfer = vi.fn()
const retryServerTransfer = vi.fn()
vi.mock("@/api/transfer", () => ({
cancelServerTransfer: (...args: unknown[]) => cancelServerTransfer(...args),
retryServerTransfer: (...args: unknown[]) => retryServerTransfer(...args),
}))
const swrFetcher = vi.fn()
vi.mock("@/api/api", () => ({
swrFetcher: (...args: unknown[]) => swrFetcher(...args),
}))
vi.mock("swr", () => ({
default: (_key: string, _fetcher: unknown) => ({
data: mockedRows,
mutate: vi.fn(),
error: undefined,
}),
}))
let mockProfile: { id: number; role: number } | undefined
vi.mock("@/hooks/useAuth", () => ({
useAuth: () => ({ profile: mockProfile }),
}))
vi.mock("@/hooks/useMainStore", () => ({
useMainStore: (selector?: (s: { profile?: { id: number; role: number } }) => unknown) => {
const store = { profile: mockProfile }
return selector ? selector(store) : store
},
}))
import type { ModelServerTransfer } from "@/types"
let mockedRows: ModelServerTransfer[] = []
beforeEach(() => {
toastCalls.length = 0
cancelServerTransfer.mockReset()
retryServerTransfer.mockReset()
mockedRows = []
mockProfile = undefined
})
afterEach(() => {
document.body.innerHTML = ""
})
function makeRow(overrides: Partial<ModelServerTransfer>): ModelServerTransfer {
return {
id: 1,
server_id: 10,
from_user_id: 100,
to_user_id: 200,
initiator_id: 100,
status: 0,
last_error: "",
acked_at: "",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
...overrides,
} as ModelServerTransfer
}
async function renderPage() {
const { default: TransferPage } = await import("@/routes/transfer")
await act(async () => {
render(<TransferPage />)
})
await waitFor(() => {
expect(screen.getByText("Transfer.Title")).toBeTruthy()
})
}
test("non-admin member who is the FromUserID sees Cancel for pending rows but NOT Retry for terminal rows", async () => {
mockProfile = { id: 100, role: 1 }
mockedRows = [
makeRow({ id: 1, status: 0, from_user_id: 100 }),
makeRow({ id: 2, status: 2, from_user_id: 100 }),
makeRow({ id: 3, status: 4, from_user_id: 100 }),
]
await renderPage()
expect(screen.queryAllByRole("button", { name: "Cancel" }).length).toBeGreaterThan(0)
expect(
screen.queryAllByRole("button", { name: "Transfer.Retry" }),
"Retry is admin-only on the backend; rendering it for members produces guaranteed permission_denied on click",
).toHaveLength(0)
})
test("non-admin member who is only the ToUserID or InitiatorID sees neither Cancel nor Retry", async () => {
mockProfile = { id: 200, role: 1 }
mockedRows = [
makeRow({ id: 1, status: 0, from_user_id: 100, to_user_id: 200 }),
makeRow({ id: 2, status: 3, from_user_id: 100, to_user_id: 200 }),
]
await renderPage()
expect(
screen.queryAllByRole("button", { name: "Cancel" }),
"backend cancelServerTransfer rejects non-admins that are not the FromUserID; UI must not pretend it works",
).toHaveLength(0)
expect(
screen.queryAllByRole("button", { name: "Transfer.Retry" }),
"backend retryServerTransfer is admin-only; non-admin must not see the button",
).toHaveLength(0)
})
test("admin sees both Cancel for pending and Retry for terminal", async () => {
mockProfile = { id: 1, role: 0 }
mockedRows = [
makeRow({ id: 1, status: 0 }),
makeRow({ id: 2, status: 2 }),
]
await renderPage()
expect(screen.queryAllByRole("button", { name: "Cancel" }).length).toBe(1)
expect(screen.queryAllByRole("button", { name: "Transfer.Retry" }).length).toBe(1)
})