mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 11:39:50 +08:00
feat(ui): canvas-first redesign with a self-hosted draw.io (#957)
The canvas now fills the window and the chat floats on its right. draw.io is served from our own origin (public/drawio, downloaded at build time and checked against the release's SHA-256), so the app can drive the editor directly: - every AI change becomes a version card with a thumbnail; compare, restore or undo it, and Ctrl+Z on the canvas takes an AI change back in one step - shapes the AI just changed are highlighted on the canvas - selected shapes appear as a chip and are sent to the model - draw.io's own simple UI: its toolbar, menus (auto layout, Mermaid, CSV import) and page tabs; the shape library starts closed - new start screen, session rename and switcher, open .drawio files - settings split into Models & keys, Appearance, Advanced and About - Bedrock in the model settings also takes an API key instead of access keys - phones switch between canvas and chat - dark mode uses draw.io's own dark mode, with neutral greys around it With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) the features above that need same origin are off. The chat logic moved from the 1500-line chat panel into a chat engine plus small UI components; zustand holds canvas, versions, settings and UI state.
This commit is contained in:
@@ -199,6 +199,30 @@ describe("Bedrock admin panel credentials", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("uses the client's Bedrock API key before any keys", async () => {
|
||||
process.env.ADMIN_AWS_ACCESS_KEY_ID = "panel-id"
|
||||
process.env.ADMIN_AWS_SECRET_ACCESS_KEY = "panel-secret"
|
||||
const { createAmazonBedrock } = await import("@ai-sdk/amazon-bedrock")
|
||||
|
||||
getAIModel({
|
||||
provider: "bedrock",
|
||||
modelId: "amazon.nova-lite-v1:0",
|
||||
apiKey: "bedrock-api-key",
|
||||
awsAccessKeyId: "client-id",
|
||||
awsSecretAccessKey: "client-secret",
|
||||
awsRegion: "ap-northeast-1",
|
||||
})
|
||||
|
||||
expect(createAmazonBedrock).toHaveBeenCalledWith({
|
||||
region: "ap-northeast-1",
|
||||
apiKey: "bedrock-api-key",
|
||||
// Not the server's AWS_ENDPOINT_URL_BEDROCK_RUNTIME
|
||||
baseURL: "https://bedrock-runtime.ap-northeast-1.amazonaws.com",
|
||||
// Nor its AWS keys, should the SDK drop the key
|
||||
credentialProvider: expect.any(Function),
|
||||
})
|
||||
})
|
||||
|
||||
it("refuses a region that is not a region name", async () => {
|
||||
// It becomes part of the endpoint's host name, with the server's
|
||||
// credentials too
|
||||
@@ -271,8 +295,10 @@ describe("usesServerCredentials", () => {
|
||||
})
|
||||
|
||||
it("looks at the credential each provider actually uses", () => {
|
||||
// A stray x-ai-api-key does not replace the IAM role or Vertex key
|
||||
expect(usesServerCredentials("bedrock", { apiKey: "x" })).toBe(true)
|
||||
// A Bedrock API key is the client's own; a stray x-ai-api-key
|
||||
// does not replace the Vertex key
|
||||
expect(usesServerCredentials("bedrock", { apiKey: "x" })).toBe(false)
|
||||
expect(usesServerCredentials("bedrock", {})).toBe(true)
|
||||
expect(
|
||||
usesServerCredentials("bedrock", {
|
||||
awsAccessKeyId: "id",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment node
|
||||
// The real Bedrock SDK, with the server's own AWS keys in the environment
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { getAIModel } from "@/lib/ai-providers"
|
||||
|
||||
const prompt = [
|
||||
{ role: "user" as const, content: [{ type: "text" as const, text: "hi" }] },
|
||||
]
|
||||
let sent: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("AWS_ACCESS_KEY_ID", "AKIASERVERKEY")
|
||||
vi.stubEnv("AWS_SECRET_ACCESS_KEY", "server-secret")
|
||||
vi.stubEnv("AWS_BEARER_TOKEN_BEDROCK", "")
|
||||
sent = []
|
||||
vi.stubGlobal("fetch", async (_url: unknown, init?: RequestInit) => {
|
||||
sent.push(new Headers(init?.headers).get("authorization") ?? "")
|
||||
return new Response("{}", { status: 400 })
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
const run = async (apiKey: string) => {
|
||||
const { model } = getAIModel({
|
||||
provider: "bedrock",
|
||||
modelId: "amazon.nova-lite-v1:0",
|
||||
apiKey,
|
||||
awsRegion: "us-east-1",
|
||||
})
|
||||
await (model as any).doGenerate({ prompt }).catch(() => {})
|
||||
}
|
||||
|
||||
describe("a Bedrock API key from the user", () => {
|
||||
it("never lets the request fall back to the server's AWS keys", async () => {
|
||||
// The SDK drops a key of spaces
|
||||
await run(" ")
|
||||
expect(sent.some((auth) => auth.includes("AKIASERVERKEY"))).toBe(false)
|
||||
})
|
||||
|
||||
it("is sent as the bearer token", async () => {
|
||||
await run("user-key")
|
||||
expect(sent).toEqual(["Bearer user-key"])
|
||||
})
|
||||
})
|
||||
@@ -62,7 +62,7 @@ describe("exports in flight at the same time", () => {
|
||||
let tag = ""
|
||||
const got: string[] = []
|
||||
act(() => {
|
||||
tag = result.current.handleExportWithoutHistory()
|
||||
tag = result.current.handleExport()
|
||||
result.current.exportResolversRef.current[tag] = (xml) =>
|
||||
got.push(xml)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import pako from "pako"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { diffDiagrams, EMPTY_SUMMARY, isSameDocument } from "@/lib/diagram-diff"
|
||||
|
||||
const doc = (cells: string) =>
|
||||
`<mxfile><diagram name="Page-1" id="p1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
const box = (id: string, value = id) =>
|
||||
`<mxCell id="${id}" value="${value}" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
|
||||
const edge = (id: string, source: string, target: string) =>
|
||||
`<mxCell id="${id}" edge="1" parent="1" source="${source}" target="${target}"><mxGeometry relative="1" as="geometry"/></mxCell>`
|
||||
|
||||
const pageOf = (name: string, cells: string, attrs = "") =>
|
||||
`<diagram name="${name}" id="${name}"><mxGraphModel${attrs}><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
|
||||
const file = (...pages: string[]) => `<mxfile>${pages.join("")}</mxfile>`
|
||||
const packedPage = (name: string, cells: string) => {
|
||||
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel>`
|
||||
const packed = Buffer.from(
|
||||
pako.deflateRaw(encodeURIComponent(model)),
|
||||
).toString("base64")
|
||||
return `<diagram name="${name}" id="${name}">${packed}</diagram>`
|
||||
}
|
||||
|
||||
describe("diffDiagrams", () => {
|
||||
it("counts added shapes and connectors on an empty canvas", () => {
|
||||
const { summary, touchedIds } = diffDiagrams(
|
||||
"",
|
||||
doc(box("a") + box("b") + edge("e", "a", "b")),
|
||||
)
|
||||
expect(summary.shapesAdded).toBe(2)
|
||||
expect(summary.edgesAdded).toBe(1)
|
||||
expect(touchedIds.sort()).toEqual(["a", "b", "e"])
|
||||
})
|
||||
|
||||
it("separates added, changed and removed cells", () => {
|
||||
const before = doc(box("a") + box("b") + edge("e", "a", "b"))
|
||||
const after = doc(box("a", "renamed") + box("c") + edge("e", "a", "c"))
|
||||
const { summary, touchedIds } = diffDiagrams(before, after)
|
||||
expect(summary).toEqual({
|
||||
shapesAdded: 1,
|
||||
shapesRemoved: 1,
|
||||
shapesChanged: 1,
|
||||
edgesAdded: 0,
|
||||
edgesRemoved: 0,
|
||||
edgesChanged: 1,
|
||||
})
|
||||
expect(touchedIds.sort()).toEqual(["a", "c", "e"])
|
||||
})
|
||||
|
||||
it("ignores how the same cell is written", () => {
|
||||
// As the model writes it, and as draw.io saves it (the style as is)
|
||||
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="A" style="rounded=1;html=1;" vertex="1" parent="1"><mxGeometry x="10" y="20" width="80" height="40" as="geometry"/></mxCell></root></mxGraphModel>`
|
||||
const saved = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" parent="1" style="rounded=1;html=1;" value="A" vertex="1"><mxGeometry as="geometry" height="40" width="80" x="10" y="20"/></mxCell></root></mxGraphModel>`
|
||||
expect(diffDiagrams(model, saved).summary).toEqual(EMPTY_SUMMARY)
|
||||
})
|
||||
|
||||
it("reports nothing for identical diagrams", () => {
|
||||
const xml = doc(box("a"))
|
||||
const { summary, touchedIds } = diffDiagrams(xml, xml)
|
||||
expect(summary).toEqual(EMPTY_SUMMARY)
|
||||
expect(touchedIds).toEqual([])
|
||||
})
|
||||
|
||||
it("ignores values draw.io drops when it saves", () => {
|
||||
// The model writes defaults and long numbers; draw.io leaves them out
|
||||
const model = doc(
|
||||
`<mxCell id="a" value="A" style="" vertex="1" connectable="1" parent="1"><mxGeometry x="0" y="0.0" width="80.0" height="40" relative="0" as="geometry"/></mxCell><mxCell id="e" edge="1" parent="1" source="a" target="a"><mxGeometry relative="1" as="geometry"><mxPoint x="0" y="0" as="offset"/></mxGeometry></mxCell>`,
|
||||
)
|
||||
const saved = doc(
|
||||
`<mxCell id="a" value="A" vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell><mxCell id="e" edge="1" parent="1" source="a" target="a"><mxGeometry relative="1" as="geometry"><mxPoint as="offset"/></mxGeometry></mxCell>`,
|
||||
)
|
||||
expect(diffDiagrams(model, saved).summary).toEqual(EMPTY_SUMMARY)
|
||||
expect(isSameDocument(model, saved)).toBe(true)
|
||||
})
|
||||
|
||||
it("sees any change to a style, its order included", () => {
|
||||
const styled = (style: string) =>
|
||||
doc(
|
||||
`<mxCell id="a" style="${style}" vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell>`,
|
||||
)
|
||||
const same = (a: string, b: string) =>
|
||||
isSameDocument(styled(a), styled(b))
|
||||
expect(
|
||||
same("fillColor=#f00;rounded=1", "fillColor=#f00;rounded=1"),
|
||||
).toBe(true)
|
||||
// draw.io applies named styles and keys in order
|
||||
expect(
|
||||
same(
|
||||
"fillColor=#f00;defaultVertex",
|
||||
"defaultVertex;fillColor=#f00",
|
||||
),
|
||||
).toBe(false)
|
||||
// A leading ";" leaves the default style out
|
||||
expect(same("fillColor=#f00", ";fillColor=#f00")).toBe(false)
|
||||
expect(
|
||||
diffDiagrams(styled("fillColor=#f00"), styled(";fillColor=#f00"))
|
||||
.summary.shapesChanged,
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it("tells a label with = in it from a separate attribute", () => {
|
||||
const wrapped = (attrs: string) =>
|
||||
doc(
|
||||
`<UserObject id="2" ${attrs}><mxCell vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell></UserObject>`,
|
||||
)
|
||||
const a = wrapped('label="server region=us-east-1"')
|
||||
const b = wrapped('label="server" region="us-east-1"')
|
||||
expect(isSameDocument(a, b)).toBe(false)
|
||||
expect(diffDiagrams(a, b).summary.shapesChanged).toBe(1)
|
||||
})
|
||||
|
||||
it("reads only a cell's style as a style, not data named style", () => {
|
||||
const wrapped = (style: string) =>
|
||||
doc(
|
||||
`<UserObject id="2" label="%style%" placeholders="1" style="${style}"><mxCell vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell></UserObject>`,
|
||||
)
|
||||
expect(isSameDocument(wrapped("alpha;"), wrapped("alpha"))).toBe(false)
|
||||
})
|
||||
|
||||
it("does not count layers as shapes", () => {
|
||||
const before = doc('<mxCell id="L2" parent="0"/>')
|
||||
const after = doc(box("r"))
|
||||
expect(diffDiagrams(before, after)).toEqual({
|
||||
summary: { ...EMPTY_SUMMARY, shapesAdded: 1 },
|
||||
touchedIds: ["r"],
|
||||
fromScratch: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("ignores empty style entries, as draw.io does", () => {
|
||||
const styled = (style: string) =>
|
||||
doc(
|
||||
`<mxCell id="a" style="${style}" vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell>`,
|
||||
)
|
||||
const same = (a: string, b: string) =>
|
||||
isSameDocument(styled(a), styled(b))
|
||||
expect(same("rounded=1;html=1;", "rounded=1;html=1")).toBe(true)
|
||||
expect(same("rounded=1;;html=1", "rounded=1;html=1")).toBe(true)
|
||||
expect(
|
||||
diffDiagrams(
|
||||
styled("rounded=1;html=1;"),
|
||||
styled("rounded=1;html=1"),
|
||||
).summary,
|
||||
).toEqual(EMPTY_SUMMARY)
|
||||
// A leading ";" still leaves the default style out
|
||||
expect(same(";fillColor=#f00", "fillColor=#f00")).toBe(false)
|
||||
})
|
||||
|
||||
it("reads the id from <UserObject> wrappers", () => {
|
||||
const wrapped = `<UserObject label="Linked" link="https://example.com" id="u1"><mxCell vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell></UserObject>`
|
||||
const { summary, touchedIds } = diffDiagrams(doc(""), doc(wrapped))
|
||||
expect(summary.shapesAdded).toBe(1)
|
||||
expect(touchedIds).toEqual(["u1"])
|
||||
})
|
||||
|
||||
it("only looks at the first page", () => {
|
||||
const twoPages = file(pageOf("p1", box("a")), pageOf("p2", box("z")))
|
||||
expect(diffDiagrams("", twoPages).summary.shapesAdded).toBe(1)
|
||||
})
|
||||
|
||||
it("reads compressed pages", () => {
|
||||
const xml = file(packedPage("p1", box("a") + box("b")))
|
||||
expect(diffDiagrams("", xml).summary.shapesAdded).toBe(2)
|
||||
expect(diffDiagrams(xml, xml).fromScratch).toBe(false)
|
||||
})
|
||||
|
||||
it("treats unparsable XML as empty", () => {
|
||||
expect(
|
||||
diffDiagrams("<mxfile><diagram>", doc(box("a"))).fromScratch,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSameDocument", () => {
|
||||
const a = file(pageOf("One", box("a")), pageOf("Two", box("b")))
|
||||
|
||||
it("is true for the same pages written differently", () => {
|
||||
const b = file(
|
||||
pageOf("One", box("a"), ' dx="300" dy="120" grid="1"'),
|
||||
packedPage("Two", box("b")),
|
||||
)
|
||||
expect(isSameDocument(a, b)).toBe(true)
|
||||
})
|
||||
|
||||
it("sees a change on the second page", () => {
|
||||
const b = file(pageOf("One", box("a")), pageOf("Two", box("b", "x")))
|
||||
expect(diffDiagrams(a, b).summary).toEqual(diffDiagrams(a, a).summary)
|
||||
expect(isSameDocument(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
it("sees added pages, renamed pages and changed page settings", () => {
|
||||
expect(isSameDocument(a, file(pageOf("One", box("a"))))).toBe(false)
|
||||
expect(
|
||||
isSameDocument(
|
||||
a,
|
||||
file(pageOf("One", box("a")), pageOf("Renamed", box("b"))),
|
||||
),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isSameDocument(
|
||||
file(pageOf("One", box("a"), ' background="#ffffff"')),
|
||||
file(pageOf("One", box("a"), ' background="#000000"')),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("compares a setting written on one side with draw.io's default", () => {
|
||||
const withAttrs = (attrs: string) =>
|
||||
file(pageOf("One", box("a"), attrs))
|
||||
const plain = withAttrs("")
|
||||
// Defaults, as draw.io fills them in
|
||||
expect(isSameDocument(plain, withAttrs(' math="0" grid="1"'))).toBe(
|
||||
true,
|
||||
)
|
||||
// A page without a paper size keeps the canvas's, whatever it is
|
||||
expect(
|
||||
isSameDocument(
|
||||
plain,
|
||||
withAttrs(' pageWidth="1654" pageHeight="2339"'),
|
||||
),
|
||||
).toBe(true)
|
||||
// Real changes
|
||||
expect(isSameDocument(plain, withAttrs(' math="1"'))).toBe(false)
|
||||
expect(isSameDocument(plain, withAttrs(' shadow="1"'))).toBe(false)
|
||||
expect(
|
||||
isSameDocument(
|
||||
withAttrs(' pageWidth="850" pageHeight="1100"'),
|
||||
withAttrs(' pageWidth="1654" pageHeight="2339"'),
|
||||
),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isSameDocument(
|
||||
plain,
|
||||
withAttrs(
|
||||
' backgroundImage="{"src":"x.png"}"',
|
||||
),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("sees a background set on one side", () => {
|
||||
expect(
|
||||
isSameDocument(
|
||||
file(pageOf("One", box("a"))),
|
||||
file(pageOf("One", box("a"), ' background="#000000"')),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("sees shapes brought to front or sent back", () => {
|
||||
expect(
|
||||
isSameDocument(
|
||||
file(pageOf("One", box("a") + box("b"))),
|
||||
file(pageOf("One", box("b") + box("a"))),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("sees a hidden layer", () => {
|
||||
const hidden = file(pageOf("One", box("a"))).replace(
|
||||
'<mxCell id="1" parent="0"/>',
|
||||
'<mxCell id="1" parent="0" visible="0"/>',
|
||||
)
|
||||
expect(isSameDocument(file(pageOf("One", box("a"))), hidden)).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("does not care where children of other parents are written", () => {
|
||||
const group = (inner: string) =>
|
||||
`<mxCell id="g" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>${inner}`
|
||||
const child = `<mxCell id="c" vertex="1" parent="g"><mxGeometry as="geometry"/></mxCell>`
|
||||
expect(
|
||||
isSameDocument(
|
||||
file(pageOf("One", group(child) + box("a"))),
|
||||
file(pageOf("One", group("") + box("a") + child)),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("compares file variables, in any order; none is the same as {}", () => {
|
||||
const withVars = (vars: string | null) =>
|
||||
`<mxfile${vars === null ? "" : ` vars="${vars}"`}>${pageOf("One", box("a"))}</mxfile>`
|
||||
const team = (value: string) =>
|
||||
`{"team":"${value}","x":"1"}`
|
||||
expect(
|
||||
isSameDocument(withVars(team("Old")), withVars(team("New"))),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isSameDocument(
|
||||
withVars(team("Old")),
|
||||
withVars(
|
||||
"{"x":"1","team":"Old"}",
|
||||
),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(isSameDocument(withVars(null), withVars(team("Old")))).toBe(
|
||||
false,
|
||||
)
|
||||
expect(isSameDocument(withVars(null), withVars("{}"))).toBe(true)
|
||||
})
|
||||
|
||||
it("compares a wrapper's own data as written, geometry as numbers", () => {
|
||||
const wrapped = (x: string, geometryX: string) =>
|
||||
file(
|
||||
pageOf(
|
||||
"One",
|
||||
`<UserObject id="v" label="%x%" placeholders="1" x="${x}"><mxCell vertex="1" parent="1"><mxGeometry x="${geometryX}" y="0" width="80" height="40" as="geometry"/></mxCell></UserObject>`,
|
||||
),
|
||||
)
|
||||
expect(isSameDocument(wrapped("01", "40"), wrapped("1", "40"))).toBe(
|
||||
false,
|
||||
)
|
||||
expect(isSameDocument(wrapped("1", "40.0"), wrapped("1", "40"))).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("reads numeric page settings as numbers", () => {
|
||||
const page = (attrs: string) => file(pageOf("One", box("a"), attrs))
|
||||
expect(
|
||||
isSameDocument(
|
||||
page(' gridSize="10.0" pageScale="1.0"'),
|
||||
page(' gridSize="10" pageScale="1"'),
|
||||
),
|
||||
).toBe(true)
|
||||
// Written on one side only: the default, also as 10.0
|
||||
expect(isSameDocument(page(' gridSize="10.0"'), page(""))).toBe(true)
|
||||
expect(
|
||||
isSameDocument(page(' gridSize="20"'), page(' gridSize="10"')),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import { formatSelectionContext } from "@/lib/chat-helpers"
|
||||
import { EMPTY_SUMMARY } from "@/lib/diagram-diff"
|
||||
import { withPageDefaults } from "@/lib/drawio/drawio-config"
|
||||
import enDict from "@/lib/i18n/dictionaries/en.json"
|
||||
import jaDict from "@/lib/i18n/dictionaries/ja.json"
|
||||
import zhDict from "@/lib/i18n/dictionaries/zh.json"
|
||||
import zhHantDict from "@/lib/i18n/dictionaries/zh-Hant.json"
|
||||
import { describeChanges, describeTotals } from "@/lib/version-text"
|
||||
import {
|
||||
MAX_VERSIONS,
|
||||
useVersionsStore,
|
||||
versionsFromLegacyHistory,
|
||||
} from "@/stores/versions-store"
|
||||
|
||||
describe("withPageDefaults", () => {
|
||||
it("adds page and grid when the model has none", () => {
|
||||
expect(withPageDefaults("<mxGraphModel><root/></mxGraphModel>")).toBe(
|
||||
'<mxGraphModel page="1" grid="1" gridSize="10"><root/></mxGraphModel>',
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps values the diagram already sets", () => {
|
||||
const xml = '<mxGraphModel dx="10" page="0" grid="0" gridSize="5">'
|
||||
expect(withPageDefaults(xml)).toBe(xml)
|
||||
})
|
||||
|
||||
it("reads attributes written with spaces around =", () => {
|
||||
const xml = '<mxGraphModel page = "0" grid ="0" gridSize= "5">'
|
||||
expect(withPageDefaults(xml)).toBe(xml)
|
||||
})
|
||||
|
||||
it("handles self-closing models and several pages", () => {
|
||||
const xml =
|
||||
'<mxfile><diagram><mxGraphModel/></diagram><diagram><mxGraphModel page="0"></mxGraphModel></diagram></mxfile>'
|
||||
expect(withPageDefaults(xml)).toBe(
|
||||
'<mxfile><diagram><mxGraphModel page="1" grid="1" gridSize="10"/></diagram><diagram><mxGraphModel page="0" grid="1" gridSize="10"></mxGraphModel></diagram></mxfile>',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("describeChanges", () => {
|
||||
const en = {
|
||||
shapesAddedOne: "added 1 shape",
|
||||
shapesAddedOther: "added {count} shapes",
|
||||
edgesChangedOne: "changed 1 connector",
|
||||
edgesChangedOther: "changed {count} connectors",
|
||||
noChanges: "No visible changes",
|
||||
listSeparator: ", ",
|
||||
}
|
||||
|
||||
it("uses singular and plural forms and sentence case", () => {
|
||||
expect(
|
||||
describeChanges(
|
||||
{ ...EMPTY_SUMMARY, shapesAdded: 2, edgesChanged: 1 },
|
||||
en,
|
||||
),
|
||||
).toBe("Added 2 shapes, changed 1 connector")
|
||||
})
|
||||
|
||||
it("says so when nothing changed", () => {
|
||||
expect(describeChanges({ ...EMPTY_SUMMARY }, en)).toBe(
|
||||
"No visible changes",
|
||||
)
|
||||
})
|
||||
|
||||
it("uses the language's list separator", () => {
|
||||
const zh = {
|
||||
shapesAddedOther: "新增 {count} 个图形",
|
||||
edgesChangedOne: "改了 1 条连线",
|
||||
listSeparator: ",",
|
||||
}
|
||||
expect(
|
||||
describeChanges(
|
||||
{ ...EMPTY_SUMMARY, shapesAdded: 3, edgesChanged: 1 },
|
||||
zh,
|
||||
),
|
||||
).toBe("新增 3 个图形,改了 1 条连线")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatSelectionContext", () => {
|
||||
it("lists selected ids with their labels", () => {
|
||||
const text = formatSelectionContext([
|
||||
{ id: "4", label: "EC2" },
|
||||
{ id: "cf", label: "" },
|
||||
])
|
||||
expect(text).toContain('- id="4" (EC2)')
|
||||
expect(text).toContain('- id="cf"')
|
||||
expect(text).not.toContain('id="cf" (')
|
||||
})
|
||||
|
||||
it("returns nothing for missing or malformed input", () => {
|
||||
expect(formatSelectionContext(undefined)).toBe("")
|
||||
expect(formatSelectionContext("4")).toBe("")
|
||||
expect(formatSelectionContext([{ label: "no id" }])).toBe("")
|
||||
})
|
||||
|
||||
it("strips quotes and newlines and limits length and count", () => {
|
||||
const many = Array.from({ length: 80 }, (_, i) => ({
|
||||
id: `c${i}`,
|
||||
label: `line\n"quoted"${"x".repeat(200)}`,
|
||||
}))
|
||||
const text = formatSelectionContext(many)
|
||||
const lines = text.split("\n").filter((l) => l.startsWith("- id="))
|
||||
expect(lines).toHaveLength(50)
|
||||
// The model is told the list is cut short
|
||||
expect(text).toContain("- and 30 more selected shapes not listed")
|
||||
expect(text).not.toContain('"quoted"')
|
||||
expect(lines[0].length).toBeLessThan(130)
|
||||
})
|
||||
})
|
||||
|
||||
describe("describeTotals", () => {
|
||||
it("uses singular and plural forms from the dictionaries", () => {
|
||||
const dicts = [enDict, zhDict, jaDict, zhHantDict].map(
|
||||
(d) => d.versions as Record<string, string>,
|
||||
)
|
||||
const one = { ...EMPTY_SUMMARY, shapesAdded: 1, edgesAdded: 0 }
|
||||
const many = { ...EMPTY_SUMMARY, shapesAdded: 3, edgesAdded: 1 }
|
||||
expect(dicts.map((t) => describeTotals(one, t))).toEqual([
|
||||
"1 shape, 0 connectors",
|
||||
"共 1 个图形、0 条连线",
|
||||
"図形 1 個、コネクタ 0 本",
|
||||
"共 1 個圖形、0 條連線",
|
||||
])
|
||||
expect(describeTotals(many, dicts[0])).toBe("3 shapes, 1 connector")
|
||||
})
|
||||
})
|
||||
|
||||
describe("versions store", () => {
|
||||
beforeEach(() => useVersionsStore.getState().clear())
|
||||
|
||||
const add = (n: number) =>
|
||||
useVersionsStore.getState().addVersion({
|
||||
xml: `<x n="${n}"/>`,
|
||||
beforeXml: `<before n="${n}"/>`,
|
||||
turnIndex: n,
|
||||
summary: { ...EMPTY_SUMMARY },
|
||||
})
|
||||
|
||||
it("keeps the before-XML only on the newest version", () => {
|
||||
add(0)
|
||||
add(2)
|
||||
const [first, second] = useVersionsStore.getState().versions
|
||||
expect(first.beforeXml).toBeUndefined()
|
||||
expect(second.beforeXml).toBe('<before n="2"/>')
|
||||
})
|
||||
|
||||
it("keeps at most MAX_VERSIONS versions, each with its number", () => {
|
||||
for (let i = 0; i < MAX_VERSIONS + 5; i++) add(i)
|
||||
const { versions } = useVersionsStore.getState()
|
||||
expect(versions).toHaveLength(MAX_VERSIONS)
|
||||
expect(versions[0].turnIndex).toBe(5)
|
||||
// v6 stays v6 when v1 to v5 are dropped
|
||||
expect(versions[0].number).toBe(6)
|
||||
expect(versions.at(-1)?.number).toBe(MAX_VERSIONS + 5)
|
||||
})
|
||||
|
||||
it("numbers on from the last version kept, and numbers saved ones", () => {
|
||||
add(0)
|
||||
add(2)
|
||||
add(4)
|
||||
useVersionsStore.getState().removeFromTurn(4)
|
||||
add(4)
|
||||
expect(
|
||||
useVersionsStore.getState().versions.map((v) => v.number),
|
||||
).toEqual([1, 2, 3])
|
||||
const { versions } = useVersionsStore.getState()
|
||||
// Saved before versions had numbers
|
||||
useVersionsStore
|
||||
.getState()
|
||||
.setVersions(versions.map(({ number, ...v }) => v as any))
|
||||
expect(
|
||||
useVersionsStore.getState().versions.map((v) => v.number),
|
||||
).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it("drops versions from a turn that is re-run, none left on the canvas", () => {
|
||||
add(0)
|
||||
add(2)
|
||||
add(4)
|
||||
useVersionsStore.getState().removeFromTurn(2)
|
||||
const state = useVersionsStore.getState()
|
||||
expect(state.versions.map((v) => v.turnIndex)).toEqual([0])
|
||||
expect(state.onCanvasVersionId).toBeNull()
|
||||
})
|
||||
|
||||
it("hand edits clear both version flags", () => {
|
||||
const id = add(0)
|
||||
expect(useVersionsStore.getState().onCanvasVersionId).toBe(id)
|
||||
useVersionsStore.getState().clearCanvasFlags()
|
||||
let state = useVersionsStore.getState()
|
||||
expect(state.onCanvasVersionId).toBeNull()
|
||||
expect(state.versions[0].onCanvas).toBeUndefined()
|
||||
useVersionsStore.getState().setUndone(id)
|
||||
useVersionsStore.getState().clearCanvasFlags()
|
||||
state = useVersionsStore.getState()
|
||||
expect(state.undoneVersionId).toBeNull()
|
||||
expect(state.versions[0].undone).toBeUndefined()
|
||||
})
|
||||
|
||||
it("converts the old history list", () => {
|
||||
const versions = versionsFromLegacyHistory([
|
||||
{ svg: "data:image/svg+xml;base64,AA", xml: "<a/>" },
|
||||
])
|
||||
expect(versions).toHaveLength(1)
|
||||
expect(versions[0]).toMatchObject({ xml: "<a/>", turnIndex: -1 })
|
||||
expect(versionsFromLegacyHistory(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { shortModelName } from "@/components/model-selector"
|
||||
|
||||
describe("shortModelName", () => {
|
||||
it("drops the provider or region prefix the logo already shows", () => {
|
||||
expect(shortModelName("nvidia/nemotron-3-ultra-550b-a55b:free")).toBe(
|
||||
"nemotron-3-ultra-550b-a55b:free",
|
||||
)
|
||||
expect(shortModelName("global.anthropic.claude-opus-5-5")).toBe(
|
||||
"claude-opus-5-5",
|
||||
)
|
||||
expect(shortModelName("us.amazon.nova-2-lite-v1:0")).toBe(
|
||||
"nova-2-lite-v1:0",
|
||||
)
|
||||
expect(shortModelName("openai.gpt-6-luna")).toBe("gpt-6-luna")
|
||||
expect(shortModelName("@tx/deepseek-ai/deepseek-v32")).toBe(
|
||||
"deepseek-v32",
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps the vendor when the rest does not name it", () => {
|
||||
expect(shortModelName("deepseek.r1-v1:0")).toBe("deepseek.r1-v1:0")
|
||||
expect(shortModelName("us.deepseek.r1-v1:0")).toBe("deepseek.r1-v1:0")
|
||||
expect(shortModelName("deepseek.v3-v1:0")).toBe("deepseek.v3-v1:0")
|
||||
})
|
||||
|
||||
it("keeps version numbers with dots", () => {
|
||||
for (const id of [
|
||||
"gpt-5.5",
|
||||
"glm-4.7",
|
||||
"gemini-2.5-flash",
|
||||
"kimi-k2.6",
|
||||
]) {
|
||||
expect(shortModelName(id)).toBe(id)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,10 @@
|
||||
import { cleanup, render } from "@testing-library/react"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
||||
import { ToolInputDetails } from "@/components/chat/tool-activity"
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const dict = {
|
||||
tools: { complete: "Complete" },
|
||||
chat: { copied: "Copied", failedToCopy: "Failed", copyResponse: "Copy" },
|
||||
}
|
||||
|
||||
describe("ToolCallCard", () => {
|
||||
describe("ToolInputDetails", () => {
|
||||
it("shows streamed operations without crashing on broken entries", () => {
|
||||
// A partly streamed or invalid edit_diagram input
|
||||
const operations = [
|
||||
@@ -22,7 +17,7 @@ describe("ToolCallCard", () => {
|
||||
{ operation: "update", cell_id: "3", new_xml: '<mxCell id="3"/>' },
|
||||
]
|
||||
const { container } = render(
|
||||
<ToolCallCard
|
||||
<ToolInputDetails
|
||||
part={
|
||||
{
|
||||
type: "tool-edit_diagram",
|
||||
@@ -31,15 +26,25 @@ describe("ToolCallCard", () => {
|
||||
input: { operations },
|
||||
} as any
|
||||
}
|
||||
expandedTools={{ t1: true }}
|
||||
setExpandedTools={() => {}}
|
||||
onCopy={() => {}}
|
||||
copiedToolCallId={null}
|
||||
copyFailedToolCallId={null}
|
||||
dict={dict}
|
||||
/>,
|
||||
)
|
||||
expect(container.textContent).toContain("cell_id: 3")
|
||||
expect(container.textContent).toContain("update3")
|
||||
expect(container.textContent).toContain('<mxCell id="3"/>')
|
||||
})
|
||||
|
||||
it("ignores an xml input that is not text", () => {
|
||||
const { container } = render(
|
||||
<ToolInputDetails
|
||||
part={
|
||||
{
|
||||
type: "tool-display_diagram",
|
||||
toolCallId: "t2",
|
||||
state: "input-streaming",
|
||||
input: { xml: { broken: true } },
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
)
|
||||
expect(container.textContent).toContain("broken")
|
||||
})
|
||||
})
|
||||
+9
-16
@@ -1,6 +1,5 @@
|
||||
import { render } from "@testing-library/react"
|
||||
import { renderHook } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import en from "@/lib/i18n/dictionaries/en.json"
|
||||
|
||||
const page = (cells: string) =>
|
||||
`<mxfile><diagram id="p" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
|
||||
@@ -19,12 +18,8 @@ vi.mock("@/contexts/diagram-context", () => ({
|
||||
loadDiagram: vi.fn(() => null),
|
||||
}),
|
||||
}))
|
||||
vi.mock("@/hooks/use-dictionary", () => ({ useDictionary: () => en }))
|
||||
|
||||
import { ChatMessageDisplay } from "@/components/chat-message-display"
|
||||
|
||||
// jsdom has no layout
|
||||
Element.prototype.scrollIntoView = () => {}
|
||||
import { useDiagramStreaming } from "@/components/chat/use-diagram-streaming"
|
||||
|
||||
describe("the streaming preview of a second edit", () => {
|
||||
it("starts from the first edit's result", () => {
|
||||
@@ -51,15 +46,13 @@ describe("the streaming preview of a second edit", () => {
|
||||
],
|
||||
},
|
||||
] as any
|
||||
render(
|
||||
<ChatMessageDisplay
|
||||
messages={messages}
|
||||
setInput={() => {}}
|
||||
setFiles={() => {}}
|
||||
processedToolCallsRef={{ current: new Set() }}
|
||||
editDiagramOriginalXmlRef={editDiagramOriginalXmlRef}
|
||||
status="streaming"
|
||||
/>,
|
||||
renderHook(() =>
|
||||
useDiagramStreaming({
|
||||
messages,
|
||||
processedToolCallsRef: { current: new Set() },
|
||||
editDiagramOriginalXmlRef,
|
||||
loadedMessageIdsRef: { current: new Set() },
|
||||
}),
|
||||
)
|
||||
expect(editDiagramOriginalXmlRef.current.get("edit-2")).toBe(
|
||||
AFTER_FIRST_EDIT,
|
||||
@@ -10,6 +10,7 @@ const box = (id: string) =>
|
||||
function setup(partialXml: string) {
|
||||
const refs = {
|
||||
partialXmlRef: { current: partialXml },
|
||||
continuationOriginalRef: { current: null },
|
||||
// A failed edit's preview is still on the canvas, its original kept
|
||||
editDiagramOriginalXmlRef: {
|
||||
current: new Map([["edit-1", "<mxfile>original</mxfile>"]]),
|
||||
@@ -19,30 +20,28 @@ function setup(partialXml: string) {
|
||||
chartXMLRef: { current: "" },
|
||||
}
|
||||
const onDisplayChart = vi.fn(
|
||||
(_xml: string, _skipValidation?: boolean): string | null => null,
|
||||
(
|
||||
_xml: string,
|
||||
_skipValidation?: boolean,
|
||||
_mode?: string,
|
||||
): string | null => null,
|
||||
)
|
||||
const { result } = renderHook(() =>
|
||||
useDiagramToolHandlers({
|
||||
...refs,
|
||||
onDisplayChart,
|
||||
onFetchChart: async () => "",
|
||||
onExport: () => {},
|
||||
enableVlmValidation: false,
|
||||
}),
|
||||
)
|
||||
const addToolOutput = vi.fn()
|
||||
const append = (xml: string) =>
|
||||
const call = (toolName: string, input: object, toolCallId = "call-1") =>
|
||||
result.current.handleToolCall(
|
||||
{
|
||||
toolCall: {
|
||||
toolCallId: "append-1",
|
||||
toolName: "append_diagram",
|
||||
input: { xml },
|
||||
},
|
||||
},
|
||||
{ toolCall: { toolCallId, toolName, input } },
|
||||
addToolOutput,
|
||||
)
|
||||
return { refs, onDisplayChart, addToolOutput, append }
|
||||
const append = (xml: string) => call("append_diagram", { xml }, "append-1")
|
||||
return { refs, onDisplayChart, addToolOutput, append, call }
|
||||
}
|
||||
|
||||
describe("the screenshot check and Stop", () => {
|
||||
@@ -57,13 +56,13 @@ describe("the screenshot check and Stop", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiagramToolHandlers({
|
||||
partialXmlRef: { current: "" },
|
||||
continuationOriginalRef: { current: null },
|
||||
editDiagramOriginalXmlRef: { current: new Map() },
|
||||
processedToolCallsRef: { current: new Set() },
|
||||
validationRetryCountRef: opts.retryCount ?? { current: 0 },
|
||||
chartXMLRef: { current: "" },
|
||||
onDisplayChart: () => null,
|
||||
onFetchChart: async () => "",
|
||||
onExport: () => {},
|
||||
enableVlmValidation: true,
|
||||
captureValidationPng:
|
||||
opts.captureValidationPng ??
|
||||
@@ -192,13 +191,88 @@ describe("append_diagram and the stored previews", () => {
|
||||
expect(refs.processedToolCallsRef.current.has("edit-1")).toBe(false)
|
||||
})
|
||||
|
||||
it("leaves them when the assembled diagram is invalid", async () => {
|
||||
it("takes them when the assembled diagram is invalid, and goes back", async () => {
|
||||
// Back to the diagram before the failed edit's preview, at once
|
||||
const { refs, onDisplayChart, addToolOutput, append } = setup(
|
||||
`<mxCell id="1" value="root id" vertex="1" parent="1">${geometry}</mxCell><mxCell id="3" value="3" vertex="1" parent="1"><mxGeometry x="0" y="0" width="8`,
|
||||
)
|
||||
await append('0" height="40" as="geometry"/></mxCell>')
|
||||
expect(onDisplayChart).not.toHaveBeenCalled()
|
||||
expect(addToolOutput.mock.calls[0][0].state).toBe("output-error")
|
||||
expect(refs.editDiagramOriginalXmlRef.current.size).toBe(1)
|
||||
expect(onDisplayChart.mock.calls).toEqual([
|
||||
["<mxfile>original</mxfile>", true, "revert"],
|
||||
])
|
||||
expect(refs.editDiagramOriginalXmlRef.current.size).toBe(0)
|
||||
expect(refs.processedToolCallsRef.current.has("edit-1")).toBe(true)
|
||||
})
|
||||
|
||||
it("goes back to the diagram before the cut off drawing when it fails", async () => {
|
||||
// The failed edit's preview was drawn on the cut off drawing: its
|
||||
// original must not come back later
|
||||
const invalid = `<mxCell id="1" value="root id" vertex="1" parent="1">${geometry}</mxCell><mxCell id="3" value="3" vertex="1" parent="1"><mxGeometry x="0" y="0" width="8`
|
||||
const valid = `${box("2")}<mxCell id="3" value="3" vertex="1" parent="1"><mxGeometry x="0" y="0" width="8`
|
||||
for (const [partial, commitFails] of [
|
||||
[invalid, false],
|
||||
[valid, true],
|
||||
] as const) {
|
||||
const { refs, onDisplayChart, append } = setup(partial)
|
||||
refs.continuationOriginalRef.current =
|
||||
"<mxfile>before the cut</mxfile>" as any
|
||||
refs.editDiagramOriginalXmlRef.current = new Map([
|
||||
["edit-1", "<mxfile>cut off</mxfile>"],
|
||||
])
|
||||
if (commitFails) {
|
||||
onDisplayChart.mockImplementation((_xml, _skip, mode) =>
|
||||
mode === "commit" ? "load failed" : null,
|
||||
)
|
||||
}
|
||||
await append('0" height="40" as="geometry"/></mxCell>')
|
||||
const calls = onDisplayChart.mock.calls
|
||||
expect(calls.at(-1)).toEqual([
|
||||
"<mxfile>before the cut</mxfile>",
|
||||
true,
|
||||
"revert",
|
||||
])
|
||||
expect(
|
||||
calls.some(([xml]) => xml === "<mxfile>cut off</mxfile>"),
|
||||
).toBe(false)
|
||||
expect(refs.editDiagramOriginalXmlRef.current.size).toBe(0)
|
||||
expect(refs.continuationOriginalRef.current).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("display_diagram and undo", () => {
|
||||
it("commits the diagram as one undo step before any check", async () => {
|
||||
const { onDisplayChart, call } = setup("")
|
||||
await call("display_diagram", { xml: box("2") })
|
||||
expect(onDisplayChart.mock.calls.map((c) => c[2])).toEqual(["commit"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("a cut off drawing", () => {
|
||||
it("is replaced starting from the diagram before it", async () => {
|
||||
const { refs, onDisplayChart, call } = setup("")
|
||||
// Cut off: its preview stays, the diagram before it is kept
|
||||
await call(
|
||||
"display_diagram",
|
||||
{ xml: `${box("2")}<mxCell id="3"` },
|
||||
"d1",
|
||||
)
|
||||
expect(refs.continuationOriginalRef.current).toBe(
|
||||
"<mxfile>original</mxfile>",
|
||||
)
|
||||
// A later drawing (after failed calls undone to the cut off one)
|
||||
// first goes back to it, so its undo step starts there
|
||||
await call("display_diagram", { xml: box("4") }, "d2")
|
||||
expect(
|
||||
onDisplayChart.mock.calls.map(([xml, , mode]) => [
|
||||
xml === "<mxfile>original</mxfile>" ? "original" : "new",
|
||||
mode,
|
||||
]),
|
||||
).toEqual([
|
||||
["original", "revert"],
|
||||
["new", "commit"],
|
||||
])
|
||||
expect(refs.continuationOriginalRef.current).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,4 +31,38 @@ describe("testing Bedrock", () => {
|
||||
)
|
||||
expect(factory.overrides.awsSessionToken).toBe("session-token")
|
||||
})
|
||||
|
||||
it("takes a Bedrock API key instead of access keys", async () => {
|
||||
factory.overrides = null
|
||||
await validateModel(
|
||||
new Request("http://localhost/api/validate-model", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: "bedrock",
|
||||
modelId: "amazon.nova-lite-v1:0",
|
||||
apiKey: "bedrock-api-key",
|
||||
awsRegion: "us-east-1",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(factory.overrides.apiKey).toBe("bedrock-api-key")
|
||||
})
|
||||
|
||||
it("still needs a region with an API key", async () => {
|
||||
factory.overrides = null
|
||||
const response = await validateModel(
|
||||
new Request("http://localhost/api/validate-model", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: "bedrock",
|
||||
modelId: "amazon.nova-lite-v1:0",
|
||||
apiKey: "bedrock-api-key",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
expect(factory.overrides).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user