Files
next-ai-draw-io/tests/unit/use-session-manager.test.tsx
T
dayuan.jiang c0fa997186 fix: older defects (batch C) and the second batch's review
Chats:
- New Chat right after an answer saves that chat once. Saves run one at
  a time and read the chat on screen when their turn comes; a save
  scheduled for a chat that is no longer on screen is dropped. A chat
  whose id was still on its way to the URL no longer comes back after
  New Chat (the next answer went into it).
- Crossing the 768 px breakpoint keeps the chat panel: a streaming answer,
  unsaved messages and attachments stay. The panel gets the sizes of each
  side, and a panel collapsed on desktop opens on mobile.
- The chat's export waits for its own reply: an edit's history export
  still on its way no longer answers it with the older diagram, and two
  file saves at once no longer swap results.
- A second edit in one answer is previewed on the first edit's result.
- Stop also ends a running screenshot check; a chat that cannot be saved
  (storage full) can be left with "Continue without saving".
- Small diagrams with shapes count as diagrams; the tool card no longer
  crashes on malformed operations.

Quota and providers:
- Requests that reach the server's own endpoints count toward the quota:
  EdgeOne (always its own endpoint now), a private base URL whatever key
  header is sent, keyless Ollama without a URL. With the quota on, a
  redirect is followed only to a public address. The output cap applies
  to these requests too.
- Stop records the tokens of the steps that finished; the screenshot check
  counts its tokens without counting a request.
- EdgeOne configured only by AI_PROVIDER works, also in the admin Test,
  which forwards the access code. Azure set up only in the admin panel
  works in chat. The Test sends a Bedrock session token.
- The admin panel's Test of an entry without a URL uses the server's URL
  as the server does (no private address check for it); the admin panel
  no longer writes an Ollama URL.

MCP server:
- Write tools and start_session run one at a time, so two at once never
  drop each other's change; a cancelled call waiting its turn is skipped.
  get_diagram and export_diagram keep the session they started with.
- Export to .drawio first gets the user's latest edits from the browser.
- History thumbnails: one that arrives after the next AI write is
  dropped; a sync reply keeps the image; a version that changed only page
  settings is its own entry.
- A diagram over the 10 MB limit is saved without its image, or the user
  is told to download it (the server now answers 413 instead of cutting
  the connection).
- Labels holding text like id='1' or parent='1' are no longer read as
  attributes (a layer or a parent was deleted). A broken bare
  <mxGraphModel> file is refused.
- After a sync reply the tab no longer sends its autosave copy again.

Desktop and files:
- A newer switch of the same preset is not rolled back by an older one
  that failed. .env values with escaped quotes are read whole.
- MCP saved files: a file that could not be read stays protected while a
  folder without permission hides it, and is saved again once deleted.
- The desktop app reports "no chats" only when the count was read and no
  model settings are stored.
2026-10-05 18:57:02 +09:00

171 lines
5.9 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
const stored = new Map<string, any>()
// Each write waits for the test to let it finish
let pendingWrites: (() => void)[] = []
// Reads wait for this
let readGate: Promise<void> = Promise.resolve()
vi.mock("@/hooks/use-dictionary", () => ({
useDictionary: () => ({ errors: { sessionSaveFailed: "Not saved" } }),
}))
vi.mock("@/lib/session-storage", async (importActual) => {
const actual = await importActual<typeof import("@/lib/session-storage")>()
return {
createEmptySession: actual.createEmptySession,
extractTitle: actual.extractTitle,
isIndexedDBAvailable: () => true,
migrateFromLocalStorage: async () => null,
readSessionCount: async () => stored.size,
enforceSessionLimit: async () => {},
getSession: async (id: string) => {
await readGate
return stored.get(id) ?? null
},
deleteSession: async (id: string) => {
stored.delete(id)
},
getAllSessionMetadata: async () =>
[...stored.values()].map((s) => ({ id: s.id, title: s.title })),
saveSession: vi.fn(
(session: any) =>
new Promise<boolean>((resolve) => {
pendingWrites.push(() => {
stored.set(session.id, session)
resolve(true)
})
}),
),
}
})
import { useSessionManager } from "@/hooks/use-session-manager"
const data = {
messages: [
{
id: "m1",
role: "user" as const,
parts: [{ type: "text", text: "Draw a cat" }],
},
],
xmlSnapshots: [] as [number, string][],
diagramXml: "",
}
// Let every write waiting now (and those it leads to) finish
async function finishWrites() {
for (let i = 0; i < 10; i++) {
await act(async () => {
const writes = pendingWrites
pendingWrites = []
for (const finish of writes) finish()
await new Promise((r) => setTimeout(r, 0))
})
}
}
async function setup() {
const hook = renderHook(() => useSessionManager())
await waitFor(() => expect(hook.result.current.isLoading).toBe(false))
return hook
}
describe("saving the chat on screen", () => {
beforeEach(() => {
stored.clear()
pendingWrites = []
})
it("creates one session when two saves of a new chat overlap", async () => {
const { result } = await setup()
let saves!: Promise<boolean[]>
act(() => {
saves = Promise.all([
result.current.saveCurrentSession(data),
result.current.saveCurrentSession(data),
])
})
await finishWrites()
expect(await saves).toEqual([true, true])
expect(stored.size).toBe(1)
expect(result.current.currentSessionId).toBe([...stored.keys()][0])
})
it("drops a save scheduled before New Chat", async () => {
const { result } = await setup()
const scheduled = result.current.getChatGeneration()
act(() => result.current.clearCurrentSession())
let save!: Promise<boolean>
act(() => {
save = result.current.saveCurrentSession(data, scheduled)
})
await finishWrites()
expect(await save).toBe(true)
expect(stored.size).toBe(0)
})
it("drops a save of the old chat waiting behind New Chat's save", async () => {
const { result } = await setup()
// The auto-save is scheduled, then New Chat saves and clears
const scheduled = result.current.getChatGeneration()
let newChatSave!: Promise<boolean>
let autoSave!: Promise<boolean>
act(() => {
newChatSave = result.current.saveCurrentSession(data)
autoSave = result.current.saveCurrentSession(data, scheduled)
})
await act(async () => {
await waitFor(() => expect(pendingWrites).toHaveLength(1))
pendingWrites.shift()?.()
await newChatSave
result.current.clearCurrentSession()
})
await finishWrites()
await autoSave
expect(stored.size).toBe(1)
expect(result.current.currentSessionId).toBeNull()
})
it("keeps the new blank chat when a save of the old one ends later", async () => {
const { result } = await setup()
let save!: Promise<boolean>
act(() => {
save = result.current.saveCurrentSession(data)
})
await waitFor(() => expect(pendingWrites).toHaveLength(1))
// New Chat while the write runs
act(() => result.current.clearCurrentSession())
await finishWrites()
await save
expect(stored.size).toBe(1)
expect(result.current.currentSessionId).toBeNull()
expect(result.current.currentSession).toBeNull()
})
it("keeps New Chat when the URL's chat finishes loading after it", async () => {
stored.set("s1", { id: "s1", title: "Old", messages: [] })
const hook = renderHook(
({ id }: { id: string | null }) =>
useSessionManager({ initialSessionId: id }),
{ initialProps: { id: null as string | null } },
)
await waitFor(() => expect(hook.result.current.isLoading).toBe(false))
// The new chat's id reaches the URL; reading it takes a moment
let release!: () => void
readGate = new Promise((r) => {
release = r
})
hook.rerender({ id: "s1" })
act(() => hook.result.current.clearCurrentSession())
await act(async () => {
release()
await new Promise((r) => setTimeout(r, 0))
})
readGate = Promise.resolve()
expect(hook.result.current.currentSessionId).toBeNull()
})
})