Stabilize CI runtime for updated deps and unblock Dependabot auto-format flow (#185)

* chore: apply ci and dependabot remediation changes

* chore: auto-fix linting and formatting issues

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot
2026-08-01 10:58:33 +08:00
committed by GitHub
parent 95b51f0209
commit 04fbddb979
59 changed files with 627 additions and 516 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 22.19.0
cache: npm
cache-dependency-path: package-lock.json
+1
View File
@@ -1 +1,2 @@
src/main.tsx
.github/workflows/*
+3 -1
View File
@@ -3460,7 +3460,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.1.1",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
+94 -23
View File
@@ -47,20 +47,56 @@ export const deleteApiToken = async (id: number): Promise<void> => {
}
export const SCOPE_OPTIONS = [
{ value: "nezha:inventory:read", label: "Inventory: read", desc: "List servers & groups (server.list)" },
{ value: "nezha:inventory:delete", label: "Inventory: delete", desc: "Delete servers & server groups" },
{
value: "nezha:inventory:read",
label: "Inventory: read",
desc: "List servers & groups (server.list)",
},
{
value: "nezha:inventory:delete",
label: "Inventory: delete",
desc: "Delete servers & server groups",
},
{ value: "nezha:inventory:*", label: "Inventory: all", desc: "List + delete servers & groups" },
{ value: "nezha:server:read", label: "Server: read", desc: "Inspect a server, read files & metrics (needs server id)" },
{
value: "nezha:server:read",
label: "Server: read",
desc: "Inspect a server, read files & metrics (needs server id)",
},
{ value: "nezha:server:write", label: "Server: write", desc: "Edit a server, push files" },
{ value: "nezha:server:delete", label: "Server: delete", desc: "Delete files on a server" },
{ value: "nezha:server:exec", label: "Server: exec", desc: "Run shell commands on servers" },
{ value: "nezha:server:*", label: "Server: all", desc: "Every server operation (read+write+delete+exec)" },
{ value: "nezha:service:read", label: "Service monitor: read", desc: "List service monitors & history" },
{ value: "nezha:service:write", label: "Service monitor: write", desc: "Create / edit service monitors" },
{ value: "nezha:service:delete", label: "Service monitor: delete", desc: "Delete service monitors" },
{ value: "nezha:service:*", label: "Service monitor: all", desc: "Every service monitor permission" },
{
value: "nezha:server:*",
label: "Server: all",
desc: "Every server operation (read+write+delete+exec)",
},
{
value: "nezha:service:read",
label: "Service monitor: read",
desc: "List service monitors & history",
},
{
value: "nezha:service:write",
label: "Service monitor: write",
desc: "Create / edit service monitors",
},
{
value: "nezha:service:delete",
label: "Service monitor: delete",
desc: "Delete service monitors",
},
{
value: "nezha:service:*",
label: "Service monitor: all",
desc: "Every service monitor permission",
},
{ value: "nezha:alertrule:read", label: "Alert rule: read", desc: "List alert rules" },
{ value: "nezha:alertrule:write", label: "Alert rule: write", desc: "Create / edit alert rules" },
{
value: "nezha:alertrule:write",
label: "Alert rule: write",
desc: "Create / edit alert rules",
},
{ value: "nezha:alertrule:delete", label: "Alert rule: delete", desc: "Delete alert rules" },
{ value: "nezha:alertrule:*", label: "Alert rule: all", desc: "Every alert-rule permission" },
{ value: "nezha:cron:read", label: "Cron: read", desc: "List scheduled tasks" },
@@ -69,13 +105,41 @@ export const SCOPE_OPTIONS = [
{ value: "nezha:cron:exec", label: "Cron: trigger", desc: "Manually trigger scheduled tasks" },
{ value: "nezha:cron:*", label: "Cron: all", desc: "Every cron permission" },
{ value: "nezha:notification:read", label: "Notification: read", desc: "List notifications" },
{ value: "nezha:notification:write", label: "Notification: write", desc: "Create / edit notifications" },
{ value: "nezha:notification:delete", label: "Notification: delete", desc: "Delete notifications" },
{ value: "nezha:notification:*", label: "Notification: all", desc: "Every notification permission" },
{ value: "nezha:notification-group:read", label: "Notification group: read", desc: "List notification groups" },
{ value: "nezha:notification-group:write", label: "Notification group: write", desc: "Create / edit groups" },
{ value: "nezha:notification-group:delete", label: "Notification group: delete", desc: "Delete groups" },
{ value: "nezha:notification-group:*", label: "Notification group: all", desc: "Every notification-group permission" },
{
value: "nezha:notification:write",
label: "Notification: write",
desc: "Create / edit notifications",
},
{
value: "nezha:notification:delete",
label: "Notification: delete",
desc: "Delete notifications",
},
{
value: "nezha:notification:*",
label: "Notification: all",
desc: "Every notification permission",
},
{
value: "nezha:notification-group:read",
label: "Notification group: read",
desc: "List notification groups",
},
{
value: "nezha:notification-group:write",
label: "Notification group: write",
desc: "Create / edit groups",
},
{
value: "nezha:notification-group:delete",
label: "Notification group: delete",
desc: "Delete groups",
},
{
value: "nezha:notification-group:*",
label: "Notification group: all",
desc: "Every notification-group permission",
},
{ value: "nezha:ddns:read", label: "DDNS: read", desc: "List DDNS profiles" },
{ value: "nezha:ddns:write", label: "DDNS: write", desc: "Create / edit DDNS profiles" },
{ value: "nezha:ddns:delete", label: "DDNS: delete", desc: "Delete DDNS profiles" },
@@ -86,21 +150,27 @@ export const SCOPE_OPTIONS = [
{ value: "nezha:nat:*", label: "NAT: all", desc: "Every NAT permission" },
{ value: "nezha:transfer:read", label: "Transfer: read", desc: "Read server transfer state" },
{ value: "nezha:transfer:write", label: "Transfer: write", desc: "Cancel / retry transfers" },
{ value: "nezha:transfer:delete", label: "Transfer: delete", desc: "Delete server transfer records" },
{
value: "nezha:transfer:delete",
label: "Transfer: delete",
desc: "Delete server transfer records",
},
{ value: "nezha:transfer:*", label: "Transfer: all", desc: "Every transfer permission" },
{ value: "nezha:admin:*", label: "Admin: all (admin only)", desc: "User / WAF / Setting / Online-user management" },
{
value: "nezha:admin:*",
label: "Admin: all (admin only)",
desc: "User / WAF / Setting / Online-user management",
},
{ value: "nezha:*", label: "Everything (admin only)", desc: "Full access to all resources" },
] as const
export type Scope = (typeof SCOPE_OPTIONS)[number]["value"]
export type ParseServerIDsResult =
| { ok: true; value: number[] | undefined }
| { ok: false; error: string }
{ ok: true; value: number[] | undefined } | { ok: false; error: string }
export type ParseExpiresInDaysResult =
| { ok: true; value: number | undefined }
| { ok: false; error: string }
{ ok: true; value: number | undefined } | { ok: false; error: string }
// Validates the "expires in days" field before it is sent to the backend.
// The backend model field is `ExpiresInDays int`, so a fractional value would
@@ -130,7 +200,8 @@ export function parseServerIDsInput(raw: string): ParseServerIDsResult {
// Server IDs are uint64 on the backend; reject values that lose
// precision as a JS number, otherwise the PAT could bind to a
// different server than the operator typed.
if (!Number.isSafeInteger(n) || n <= 0) return { ok: false, error: `invalid server id: ${p}` }
if (!Number.isSafeInteger(n) || n <= 0)
return { ok: false, error: `invalid server id: ${p}` }
if (seen.has(n)) continue
seen.add(n)
out.push(n)
+5 -1
View File
@@ -26,7 +26,11 @@ export const deleteServer = async (id: number[]): Promise<void> => {
export const batchMoveServer = async (
data: ModelBatchMoveServerForm,
): Promise<ModelBatchMoveServerResult[]> => {
return fetcher<ModelBatchMoveServerResult[]>(FetcherMethod.POST, "/api/v1/batch-move/server", data)
return fetcher<ModelBatchMoveServerResult[]>(
FetcherMethod.POST,
"/api/v1/batch-move/server",
data,
)
}
export const forceUpdateServer = async (id: number[]): Promise<ModelServerTaskResponse> => {
+1 -4
View File
@@ -195,10 +195,7 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2 my-2"
>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 my-2">
<FormField
control={form.control}
name="name"
+8 -2
View File
@@ -71,7 +71,10 @@ const arraysEqual = (a: Uint8Array, b: Uint8Array) => {
return true
}
export const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl, ...props }) => {
export const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({
wsUrl,
...props
}) => {
const { t } = useTranslation()
const fmRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WebSocket | null>(null)
@@ -342,7 +345,10 @@ export const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({
try {
await copyToClipboard(formatPath(currentPath))
} catch (error) {
const description = error instanceof Error ? error.message : t("Results.UnExpectedError")
const description =
error instanceof Error
? error.message
: t("Results.UnExpectedError")
toast("FM" + " " + t("Error"), {
description,
})
+3 -5
View File
@@ -64,7 +64,8 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
const { t } = useTranslation()
type NotificationFormInput = z.input<typeof notificationFormSchema>
type NotificationFormData = z.output<typeof notificationFormSchema>
type NotificationDefaults = ModelNotification & Partial<Pick<NotificationFormData, "skip_check">>
type NotificationDefaults = ModelNotification &
Partial<Pick<NotificationFormData, "skip_check">>
const notificationDefaults: NotificationDefaults | undefined = data
const form = useForm<NotificationFormInput, unknown, NotificationFormData>({
@@ -133,10 +134,7 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2 my-2"
>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 my-2">
<FormField
control={form.control}
name="name"
+6 -2
View File
@@ -467,7 +467,9 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
{publicNoteObj.billingDataMod
?.startDate
? new Date(
publicNoteObj.billingDataMod!.startDate!,
publicNoteObj
.billingDataMod!
.startDate!,
).toLocaleDateString()
: "YYYY-MM-DD"}
</Button>
@@ -492,7 +494,9 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
.billingDataMod
?.startDate
? new Date(
publicNoteObj.billingDataMod!.startDate!,
publicNoteObj
.billingDataMod!
.startDate!,
)
: undefined
}
+1 -2
View File
@@ -23,8 +23,7 @@ const badgeVariants = cva(
)
export interface BadgeProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
extends HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
+1 -2
View File
@@ -31,8 +31,7 @@ const buttonVariants = cva(
)
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean
}
+1 -1
View File
@@ -36,7 +36,7 @@ const FormField = <
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
+5 -4
View File
@@ -1,9 +1,7 @@
import { useEffect, useState } from "react"
type SVGProps = React.ComponentPropsWithoutRef<"svg">
type IconMarkupState =
| { status: "ready"; icon: ParsedIconMarkup }
| { status: "missing" }
type IconMarkupState = { status: "ready"; icon: ParsedIconMarkup } | { status: "missing" }
type ParsedIconMarkup = {
title?: string
@@ -26,7 +24,10 @@ const iconLoadersBySlug = Object.fromEntries(
const iconMarkupCache = new Map<string, IconMarkupState>()
function toProviderSlug(provider: string) {
return provider.trim().replace(/[^a-z0-9]+/gi, "").toLowerCase()
return provider
.trim()
.replace(/[^a-z0-9]+/gi, "")
.toLowerCase()
}
function loadProviderIconMarkup(provider: string) {
+2 -1
View File
@@ -67,7 +67,8 @@ const multiSelectVariants = cva(
* Props for MultiSelect component
*/
interface MultiSelectProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof multiSelectVariants> {
/**
* An array of option objects to be displayed in the multi-select component.
+2 -1
View File
@@ -37,7 +37,8 @@ const sheetVariants = cva(
)
interface SheetContentProps
extends ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
extends
ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {
setOpen: Dispatch<SetStateAction<boolean>>
}
+5 -2
View File
@@ -53,7 +53,8 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const navigate = useNavigate()
const login = useCallback(async (username: string, password: string) => {
const login = useCallback(
async (username: string, password: string) => {
try {
await loginRequest(username, password)
const user = await getProfile()
@@ -73,7 +74,9 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
// mount probe; clear loading so ProtectedRoute stops blanking.
setLoading(false)
}
}, [navigate, setProfile, t])
},
[navigate, setProfile, t],
)
const loginOauth2 = useCallback(async () => {
try {
+1 -1
View File
@@ -117,5 +117,5 @@ body,
}
::-webkit-scrollbar-thumb {
@apply rounded-full border-[1px] border-solid border-transparent bg-border bg-clip-padding;
@apply bg-border rounded-full border-[1px] border-solid border-transparent bg-clip-padding;
}
+18 -6
View File
@@ -98,7 +98,10 @@ export default function ApiTokensPage() {
</TableRow>
) : !data || data.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
<TableCell
colSpan={6}
className="h-24 text-center text-muted-foreground"
>
{t("NoResults")}
</TableCell>
</TableRow>
@@ -119,10 +122,14 @@ export default function ApiTokensPage() {
</div>
</TableCell>
<TableCell className="text-xs">
{tok.server_ids?.length ? tok.server_ids.join(", ") : t("ApiTokenAllServers")}
{tok.server_ids?.length
? tok.server_ids.join(", ")
: t("ApiTokenAllServers")}
</TableCell>
<TableCell className="text-xs">
{tok.expires_at ? new Date(tok.expires_at).toLocaleString() : t("ApiTokenNever")}
{tok.expires_at
? new Date(tok.expires_at).toLocaleString()
: t("ApiTokenNever")}
</TableCell>
<TableCell className="text-xs">
{tok.last_used_at
@@ -239,7 +246,8 @@ function CreateApiTokenDialog({
<Label>{t("Scopes")}</Label>
<div className="max-h-72 space-y-2 overflow-y-auto pr-2">
{SCOPE_OPTIONS.map((s) => {
const adminOnly = s.value === "nezha:*" || s.value === "nezha:admin:*"
const adminOnly =
s.value === "nezha:*" || s.value === "nezha:admin:*"
const disabled = adminOnly && !isAdmin
return (
<label
@@ -248,12 +256,16 @@ function CreateApiTokenDialog({
>
<Checkbox
checked={scopes.includes(s.value)}
onCheckedChange={() => !disabled && toggleScope(s.value)}
onCheckedChange={() =>
!disabled && toggleScope(s.value)
}
disabled={disabled}
/>
<div className="flex flex-col">
<span className="font-mono text-xs">{s.value}</span>
<span className="text-xs text-muted-foreground">{s.desc}</span>
<span className="text-xs text-muted-foreground">
{s.desc}
</span>
</div>
</label>
)
+7 -5
View File
@@ -224,9 +224,13 @@ export default function SettingsPage() {
</div>
</SelectItem>
<div className="px-8 py-1">
{safeExternalHref(template.repository) ? (
{safeExternalHref(
template.repository,
) ? (
<a
href={safeExternalHref(template.repository)}
href={safeExternalHref(
template.repository,
)}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
@@ -552,9 +556,7 @@ export default function SettingsPage() {
checked={field.value}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">
{t("EnableMCP")}
</Label>
<Label className="text-sm">{t("EnableMCP")}</Label>
</div>
</FormControl>
<FormMessage />
+3 -8
View File
@@ -1,7 +1,7 @@
import { swrFetcher } from "@/api/api"
import { cancelServerTransfer, retryServerTransfer } from "@/api/transfer"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
@@ -45,10 +45,7 @@ export default function TransferPage() {
const { profile } = useAuth()
const isAdmin = profile?.role === 0
const callerID = profile?.id
const { data, mutate, error } = useSWR<ModelServerTransfer[]>(
"/api/v1/transfer",
swrFetcher,
)
const { data, mutate, error } = useSWR<ModelServerTransfer[]>("/api/v1/transfer", swrFetcher)
useEffect(() => {
if (error) {
@@ -182,9 +179,7 @@ export default function TransferPage() {
return (
<div className="px-3">
<h1 className="mt-6 text-2xl font-semibold">{t("Transfer.Title")}</h1>
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">
{t("Transfer.PageHint")}
</p>
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">{t("Transfer.PageHint")}</p>
<Table className="mt-6">
<TableHeader>
<TableRow>
+9 -8
View File
@@ -6,10 +6,13 @@ const realFetch = globalThis.fetch
function mockFetch(payload: unknown, ok = true, success = true) {
globalThis.fetch = vi.fn(async () => {
return new Response(JSON.stringify({ success, error: success ? "" : "boom", data: payload }), {
return new Response(
JSON.stringify({ success, error: success ? "" : "boom", data: payload }),
{
status: ok ? 200 : 500,
headers: { "Content-Type": "application/json" },
})
},
)
}) as unknown as typeof fetch
}
@@ -94,9 +97,9 @@ test("createApiToken POSTs /api/v1/api-tokens and serializes scopes / server_ids
test("createApiToken surfaces server error via thrown Error", async () => {
mockFetch(null, true, false)
await expect(
createApiToken({ name: "x", scopes: ["nezha:server:read"] }),
).rejects.toThrow("boom")
await expect(createApiToken({ name: "x", scopes: ["nezha:server:read"] })).rejects.toThrow(
"boom",
)
})
test("listApiTokens normalizes null scopes to an empty array so the table cannot crash", async () => {
@@ -107,9 +110,7 @@ test("listApiTokens normalizes null scopes to an empty array so the table cannot
return new Response(
JSON.stringify({
success: true,
data: [
{ id: 1, name: "legacy", scopes: null, created_at: "2025-01-01T00:00:00Z" },
],
data: [{ id: 1, name: "legacy", scopes: null, created_at: "2025-01-01T00:00:00Z" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
+1 -2
View File
@@ -9,8 +9,7 @@ const setProfileSpy = vi.fn((p: any) => {
vi.mock("./useMainStore", () => ({}))
vi.mock("@/hooks/useMainStore", () => ({
useMainStore: (selector: any) =>
selector({ profile: profileStore, setProfile: setProfileSpy }),
useMainStore: (selector: any) => selector({ profile: profileStore, setProfile: setProfileSpy }),
}))
vi.mock("react-i18next", () => ({
+1 -2
View File
@@ -9,8 +9,7 @@ const setProfileSpy = vi.fn((p: any) => {
vi.mock("./useMainStore", () => ({}))
vi.mock("@/hooks/useMainStore", () => ({
useMainStore: (selector: any) =>
selector({ profile: profileStore, setProfile: setProfileSpy }),
useMainStore: (selector: any) => selector({ profile: profileStore, setProfile: setProfileSpy }),
}))
vi.mock("react-i18next", () => ({
+1 -2
View File
@@ -1,3 +1,4 @@
import { BatchMoveServerIcon } from "@/components/batch-move-server-icon"
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
@@ -26,8 +27,6 @@ vi.mock("@/api/server", () => ({
batchMoveServer: (...args: unknown[]) => batchMoveServer(...args),
}))
import { BatchMoveServerIcon } from "@/components/batch-move-server-icon"
beforeEach(() => {
toastCalls.length = 0
batchMoveServer.mockReset()
+3 -1
View File
@@ -83,7 +83,9 @@ test("auto refresh-token uses POST (backend route is POST)", async () => {
// Revoke (DELETE) commonly returns 204 / empty body. The fetcher must not
// blow up on response.json() of an empty body and must resolve successfully.
test("DELETE tolerates an empty 204 response body", async () => {
globalThis.fetch = vi.fn(async () => new Response(null, { status: 204 })) as unknown as typeof fetch
globalThis.fetch = vi.fn(
async () => new Response(null, { status: 204 }),
) as unknown as typeof fetch
await expect(fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/9")).resolves.toBeUndefined()
})
+6 -3
View File
@@ -1,6 +1,8 @@
import { act, render, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
import { FMComponent } from "../components/fm"
type DivProps = React.ComponentPropsWithoutRef<"div"> & { asChild?: boolean }
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
asChild?: boolean
@@ -106,8 +108,6 @@ vi.mock("@/lib/utils", () => ({
formatPath: (path: string) => path,
}))
import { FMComponent } from "../components/fm"
const webSockets: MockWebSocket[] = []
class MockWebSocket extends EventTarget implements WebSocket {
@@ -235,7 +235,10 @@ test("FM websocket lifecycle reuses the socket on path changes", async () => {
socket.sent.length = 0
await act(async () => {
await socket.onmessage?.call(socket, new MessageEvent("message", { data: encodeCompleteMessage() }))
await socket.onmessage?.call(
socket,
new MessageEvent("message", { data: encodeCompleteMessage() }),
)
})
expect(webSockets).toHaveLength(1)
+5 -18
View File
@@ -32,15 +32,11 @@ function renderAtPath(path: string, child: React.ReactNode) {
<Routes>
<Route
path="/dashboard/login"
element={
<ProtectedRoute>{child}</ProtectedRoute>
}
element={<ProtectedRoute>{child}</ProtectedRoute>}
/>
<Route
path="/dashboard/*"
element={
<ProtectedRoute>{child}</ProtectedRoute>
}
element={<ProtectedRoute>{child}</ProtectedRoute>}
/>
</Routes>
</MemoryRouter>,
@@ -50,18 +46,12 @@ function renderAtPath(path: string, child: React.ReactNode) {
}
test("ProtectedRoute does not mount children for protected paths while auth is loading", async () => {
await renderAtPath(
"/dashboard",
<div data-testid="protected-child">protected</div>,
)
await renderAtPath("/dashboard", <div data-testid="protected-child">protected</div>)
expect(document.querySelector("[data-testid='protected-child']")).toBeNull()
})
test("ProtectedRoute renders children on the login page even while auth is loading", async () => {
await renderAtPath(
"/dashboard/login",
<div data-testid="login-child">login</div>,
)
await renderAtPath("/dashboard/login", <div data-testid="login-child">login</div>)
expect(document.querySelector("[data-testid='login-child']")).not.toBeNull()
})
@@ -100,9 +90,6 @@ test("ProtectedRoute redirects unauthenticated users without mounting protected
test("ProtectedRoute renders children once an authenticated profile resolves", async () => {
mockLoading = false
mockProfile = { id: 1, role: 0 }
await renderAtPath(
"/dashboard",
<div data-testid="protected-child">protected</div>,
)
await renderAtPath("/dashboard", <div data-testid="protected-child">protected</div>)
expect(document.querySelector("[data-testid='protected-child']")).not.toBeNull()
})
+3 -1
View File
@@ -47,7 +47,9 @@ vi.mock("@/hooks/useNotfication", () => ({
}))
const updateSettings = vi.fn()
vi.mock("@/api/settings", () => ({ updateSettings: (...args: unknown[]) => updateSettings(...args) }))
vi.mock("@/api/settings", () => ({
updateSettings: (...args: unknown[]) => updateSettings(...args),
}))
beforeEach(() => {
toastCalls.length = 0
+2 -6
View File
@@ -1,3 +1,4 @@
import type { ModelServerTransfer } from "@/types"
import { act, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
@@ -47,8 +48,6 @@ vi.mock("@/hooks/useMainStore", () => ({
},
}))
import type { ModelServerTransfer } from "@/types"
let mockedRows: ModelServerTransfer[] = []
beforeEach(() => {
@@ -127,10 +126,7 @@ test("non-admin member who is only the ToUserID or InitiatorID sees neither Canc
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 }),
]
mockedRows = [makeRow({ id: 1, status: 0 }), makeRow({ id: 2, status: 2 })]
await renderPage()
+4 -1
View File
@@ -85,7 +85,10 @@ test("an API token can authenticate /mcp", async ({ page }) => {
headers: await csrfHeaders(page),
data: { ...baseSettings, enable_mcp: true },
})
expect(enableResp.ok(), "PATCH /api/v1/setting must succeed to enable MCP for the test").toBeTruthy()
expect(
enableResp.ok(),
"PATCH /api/v1/setting must succeed to enable MCP for the test",
).toBeTruthy()
try {
const apiResp = await page.request.post("/api/v1/api-tokens", {
+12 -2
View File
@@ -1,6 +1,13 @@
import { expect } from "@playwright/test"
import { csrfRequest, defaultAdmin, expectAuthenticated, expectUnauthenticated, loginAs, test } from "./fixtures"
import {
csrfRequest,
defaultAdmin,
expectAuthenticated,
expectUnauthenticated,
loginAs,
test,
} from "./fixtures"
test("login persists session via cookie and getProfile succeeds", async ({ page }) => {
await loginAs(page, defaultAdmin)
@@ -51,7 +58,10 @@ test("password change rotates TokenVersion and revokes existing session", async
reject_password: false,
},
})
expect(restoreResp.ok(), "password restore must succeed so other suites can still log in").toBeTruthy()
expect(
restoreResp.ok(),
"password restore must succeed so other suites can still log in",
).toBeTruthy()
}
}
})
+3 -2
View File
@@ -1,4 +1,4 @@
import { Page, Request, expect, test as base } from "@playwright/test"
import { Page, Request, test as base, expect } from "@playwright/test"
export type LoginContext = {
username: string
@@ -39,7 +39,8 @@ export async function csrfHeaders(page: Page): Promise<Record<string, string>> {
await expect
.poll(
async () => {
value = (await page.context().cookies()).find((c) => c.name === "nz-csrf")?.value ?? ""
value =
(await page.context().cookies()).find((c) => c.name === "nz-csrf")?.value ?? ""
return value
},
{ timeout: 10_000 },
+4 -1
View File
@@ -17,6 +17,9 @@ test("file manager creation only accepts POST", async ({ adminPage: page }) => {
})
expect(postResp.status()).toBe(200)
const body = await postResp.json()
expect(body.success, "without a connected agent server the POST surfaces a Service error, but the route is reachable").not.toBe(true)
expect(
body.success,
"without a connected agent server the POST surfaces a Service error, but the route is reachable",
).not.toBe(true)
expect(body.error).toBeTruthy()
})
+14 -4
View File
@@ -2,7 +2,10 @@ import { expect } from "@playwright/test"
import { csrfHeaders, test } from "./fixtures"
test("server-group hides guest-empty groups from anonymous callers", async ({ adminPage: page, browser }) => {
test("server-group hides guest-empty groups from anonymous callers", async ({
adminPage: page,
browser,
}) => {
const tag = Date.now().toString(36)
const visibleName = `e2e-visible-${tag}`
const hiddenName = `e2e-hidden-${tag}`
@@ -12,7 +15,9 @@ test("server-group hides guest-empty groups from anonymous callers", async ({ ad
// scenario boils down to "no group is guest-visible", which the assertion below still covers.
const serversResp = await page.request.get("/api/v1/server")
expect(serversResp.ok()).toBeTruthy()
const serversBody = (await serversResp.json()) as { data: Array<{ id: number; hide_for_guest?: boolean }> }
const serversBody = (await serversResp.json()) as {
data: Array<{ id: number; hide_for_guest?: boolean }>
}
const publicServer = serversBody.data?.find((s) => !s.hide_for_guest)
const createdGroupIDs: number[] = []
@@ -40,9 +45,14 @@ test("server-group hides guest-empty groups from anonymous callers", async ({ ad
data: Array<{ group: { name: string }; servers: number[] }>
}
const names = (guestBody.data || []).map((it) => it.group.name)
expect(names, "guest must NOT see groups with zero visible servers").not.toContain(hiddenName)
expect(names, "guest must NOT see groups with zero visible servers").not.toContain(
hiddenName,
)
if (publicServer) {
expect(names, "guest still sees groups that contain a guest-visible server").toContain(visibleName)
expect(
names,
"guest still sees groups that contain a guest-visible server",
).toContain(visibleName)
}
} finally {
await guestCtx.close()