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.
This commit is contained in:
164
web/components/lifecycle-regressions.test.mjs
Normal file
164
web/components/lifecycle-regressions.test.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
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 = \(\) => ""/)
|
||||
})
|
||||
Reference in New Issue
Block a user