Files
agent-desk-ng/web/components/lifecycle-regressions.test.mjs
mono 2f7a491666 feat: harden support, localize knowledge and add operator availability [skip ci]
Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.

Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.

Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.

Expected source tree: 0a4a7528e9.
2026-10-16 15:21:42 +00:00

165 lines
8.7 KiB
JavaScript

import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import test from "node:test"
import { deferred, loadTypeScriptModule } from "../lib/test-support/load-typescript-module.mjs"
const jsx = (type, props) => ({ type, props })
const jsxRuntime = { jsx, jsxs: jsx, Fragment: "Fragment" }
const i18n = { useI18n: () => (key) => key }
// Minimal deterministic hook driver: render-phase resets rerender before effects,
// and dependency changes/unmount run prior effect cleanup. No browser or network.
function hooks() {
const states = [], effects = []
let stateIndex, effectIndex, dirty, pending
const react = {
useState(initial) {
const index = stateIndex++
if (!(index in states)) states[index] = typeof initial === "function" ? initial() : initial
return [states[index], (next) => {
const value = typeof next === "function" ? next(states[index]) : next
if (!Object.is(value, states[index])) { states[index] = value; dirty = true }
}]
},
useEffect(run, deps) {
const index = effectIndex++
if (!effects[index] || deps.some((dep, i) => !Object.is(dep, effects[index].deps[i]))) {
pending.set(index, { run, deps })
}
},
useMemo: (fn) => fn(),
useCallback: (fn) => fn,
useRef: (value) => ({ current: value }),
createContext: () => ({ Provider: "Provider" }),
}
return { react,
render(component, props) {
let result, attempts = 0
do {
assert.ok(attempts++ < 10, "render-phase state must settle")
stateIndex = 0; effectIndex = 0; dirty = false; pending = new Map()
result = component(props)
} while (dirty)
for (const [index, effect] of pending) {
effects[index]?.cleanup?.()
effects[index] = { deps: effect.deps, cleanup: effect.run() }
}
return result
},
unmount() { effects.forEach((effect) => effect.cleanup?.()) },
}
}
function find(node, type) {
if (!node || typeof node !== "object") return undefined
if (node?.type === type) return node
const children = node?.props?.children
for (const child of Array.isArray(children) ? children : [children]) {
const result = find(child, type)
if (result) return result
}
}
const flush = async () => { await Promise.resolve(); await Promise.resolve() }
test("bulk move resets selection on reopen/base switch and ignores obsolete directory responses", async () => {
const h = hooks(), requests = []
const { KnowledgeBulkMoveDialog } = loadTypeScriptModule(new URL("../app/(dashboard)/dashboard/knowledge/_components/knowledge-bulk-move-dialog.tsx", import.meta.url), {
react: h.react, "react/jsx-runtime": jsxRuntime,
"@/i18n/provider": i18n,
"@/components/option-combobox": { OptionCombobox: "Combobox" },
"@/components/project-dialog": { ProjectDialog: "Dialog" },
"@/components/ui/button": { Button: "Button" },
"@/lib/api/admin": { fetchKnowledgeDirectories: (id) => { const request = deferred(); requests.push({ id, ...request }); return request.promise } },
})
let props = { open: true, knowledgeBaseId: 1, moving: false, selectedCount: 2, onOpenChange() {}, onSubmit() {} }
let tree = h.render(KnowledgeBulkMoveDialog, props)
find(tree, "Combobox").props.onChange("42")
tree = h.render(KnowledgeBulkMoveDialog, props)
assert.equal(find(tree, "Combobox").props.value, "42")
props = { ...props, open: false }
h.render(KnowledgeBulkMoveDialog, props)
props = { ...props, open: true }
tree = h.render(KnowledgeBulkMoveDialog, props)
assert.equal(find(tree, "Combobox").props.value, "0")
requests[0].resolve([{ id: 99, name: "obsolete" }]); await flush()
tree = h.render(KnowledgeBulkMoveDialog, props)
assert.equal(find(tree, "Combobox").props.options.length, 1)
requests[1].resolve([{ id: 11, name: "previous base" }]); await flush()
tree = h.render(KnowledgeBulkMoveDialog, props)
assert.equal(find(tree, "Combobox").props.options[1].label, "previous base")
find(tree, "Combobox").props.onChange("42")
props = { ...props, knowledgeBaseId: 2 }
tree = h.render(KnowledgeBulkMoveDialog, props)
assert.equal(find(tree, "Combobox").props.value, "0")
assert.equal(find(tree, "Combobox").props.options.length, 1)
assert.equal(requests.at(-1).id, 2)
requests.at(-1).resolve([{ id: 22, name: "current" }]); await flush()
tree = h.render(KnowledgeBulkMoveDialog, props)
assert.equal(find(tree, "Combobox").props.options[1].label, "current")
h.unmount()
requests[1].resolve([{ id: 98, name: "old reopening" }]); await flush()
})
test("palette snapshots hydrate consistently and notify same-tab and storage changes", () => {
const events = new EventTarget(), values = new Map([["dashboard_palette", "green"]])
const document = { documentElement: { dataset: {} } }
let store, effect
const { PaletteToggle } = loadTypeScriptModule(new URL("./palette-toggle.tsx", import.meta.url), {
react: {
useSyncExternalStore: (subscribe, getSnapshot, getServerSnapshot) => { store = { subscribe, getSnapshot, getServerSnapshot }; return getSnapshot() },
useEffect: (run) => { effect = run },
},
"react/jsx-runtime": jsxRuntime, "@/i18n/provider": i18n,
"lucide-react": { CircleIcon: "Circle", DropletsIcon: "Droplets", PaletteIcon: "Palette" },
"@/components/ui/button": { Button: "Button" },
"@/components/ui/dropdown-menu": Object.fromEntries(["DropdownMenu", "DropdownMenuContent", "DropdownMenuRadioGroup", "DropdownMenuRadioItem", "DropdownMenuTrigger"].map((name) => [name, name])),
}, { window: { addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events), dispatchEvent: events.dispatchEvent.bind(events), localStorage: { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value) } }, document, Event })
const tree = PaletteToggle()
assert.equal(store.getServerSnapshot(), "plain")
assert.equal(store.getSnapshot(), "green")
effect(); assert.equal(document.documentElement.dataset.palette, "green")
let changes = 0
const unsubscribe = store.subscribe(() => { changes++ })
find(tree, "DropdownMenuRadioGroup").props.onValueChange("blue")
assert.equal(changes, 1)
assert.equal(store.getSnapshot(), "blue")
assert.equal(document.documentElement.dataset.palette, "blue")
const storageEvent = (key) => { const event = new Event("storage"); Object.defineProperty(event, "key", { value: key }); events.dispatchEvent(event) }
storageEvent("unrelated"); assert.equal(changes, 1)
values.set("dashboard_palette", "gray"); storageEvent("dashboard_palette")
assert.equal(changes, 2); assert.equal(store.getSnapshot(), "gray")
unsubscribe(); storageEvent(null); assert.equal(changes, 2)
})
test("initial notification count ignores completion after provider unmount", async () => {
const h = hooks(), response = deferred()
const { NotificationProvider } = loadTypeScriptModule(new URL("./notification-provider.tsx", import.meta.url), {
react: h.react, "react/jsx-runtime": jsxRuntime,
"@/i18n/provider": { ...i18n, useAppLocale: () => ({ locale: "en-US" }) },
"next/navigation": { useRouter: () => ({ push() {} }) },
sonner: { toast() {} },
"@/lib/auth": { readSession: () => ({ user: { id: 1 }, accessToken: "isolated-fixture" }) },
"@/lib/api/notification": { fetchNotificationUnreadCount: () => response.promise },
"@/lib/realtime-connection": { createRealtimeConnectionManager: () => ({ connect() {}, disconnect() {} }) },
"@/lib/notification-i18n": { localizeNotificationItem: (item) => item },
})
let tree = h.render(NotificationProvider, { children: null })
assert.equal(tree.props.value.unreadCount, 0)
h.unmount(); response.resolve({ unreadCount: 999 }); await flush()
tree = h.render(NotificationProvider, { children: null })
assert.equal(tree.props.value.unreadCount, 0)
})
test("editor fullscreen and channel origin do not require mount-state effects", async () => {
const editor = await readFile(new URL("./content-editor/index.tsx", import.meta.url), "utf8")
assert.match(editor, /const \[fullscreen, setFullscreen\] = useState\(false\)/)
assert.match(editor, /if \(fullscreen\) \{\s*return createPortal\(content, document.body\)/)
assert.doesNotMatch(editor, /setMounted/)
const html = await readFile(new URL("./content-editor/html-editor.tsx", import.meta.url), "utf8")
assert.match(html, /allowedModes.length > 1\s*\? \[\{ key: "separator-mode", type: "separator" \} as const\]/)
assert.doesNotMatch(html, /actions\.splice/)
const channel = await readFile(new URL("../app/(dashboard)/dashboard/channels/_components/edit.tsx", import.meta.url), "utf8")
assert.match(channel, /useSyncExternalStore\(subscribeToOrigin, readBrowserOrigin, readServerOrigin\)/)
assert.match(channel, /const readServerOrigin = \(\) => ""/)
})