| 1 | import assert from "node:assert/strict"; |
| 2 | import { JSDOM } from "jsdom"; |
| 3 | import React, { act } from "react"; |
| 4 | import { createRoot } from "react-dom/client"; |
| 5 | import { ModelsSection } from "../components/SettingsPanel"; |
| 6 | import { LocaleProvider } from "../lib/i18n"; |
| 7 | import type { AppBindings } from "../lib/bridge"; |
| 8 | import { baseSettings, installCanvasMock } from "../test-support/settingsTestFixtures"; |
| 9 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 10 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { |
| 11 | pretendToBeVisual: true, |
| 12 | url: "http://localhost/", |
| 13 | }); |
| 14 | // React's legacy input-event fallback expects these IE hooks when JSDOM does |
| 15 | // not expose native input event support. The custom threshold editor focuses |
| 16 | // its input on open, so keep that production behavior testable without noise. |
| 17 | Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} }); |
| 18 | Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} }); |
| 19 | installCanvasMock(dom.window as unknown as Window); |
| 20 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 21 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 22 | globalThis.document = dom.window.document; |
| 23 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 24 | globalThis.Node = dom.window.Node; |
| 25 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 26 | globalThis.Event = dom.window.Event; |
| 27 | globalThis.CustomEvent = dom.window.CustomEvent; |
| 28 | globalThis.KeyboardEvent = dom.window.KeyboardEvent; |
| 29 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 30 | globalThis.localStorage = dom.window.localStorage; |
| 31 | globalThis.sessionStorage = dom.window.sessionStorage; |
| 32 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 33 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 34 | window.scrollTo = () => {}; |
| 35 | window.matchMedia = () => ({ matches: true, addEventListener() {}, removeEventListener() {} }) as unknown as MediaQueryList; |
| 36 | Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", { configurable: true, value: () => ({x:10,y:10,top:10,left:10,right:310,bottom:54,width:300,height:44,toJSON(){return {};}}) }); |
| 37 | localStorage.clear(); |
| 38 | |
| 39 | |
| 40 | let selected = ""; |
| 41 | installDesktopHostStub({ApplyModelSettings:async(change)=>{ |
| 42 | assert.equal(change.kind,"preference"); |
| 43 | if(change.kind === "preference" && change.field === "search") selected=change.ref; |
| 44 | return {requestId:change.requestId,persisted:true,revision:"saved",application:"not_required",targets:[],issues:[],appliedCatalogs:[]}; |
| 45 | }} as Partial<AppBindings> as AppBindings); |
| 46 | const host=document.getElementById("root")!; |
| 47 | const root=createRoot(host); |
| 48 | const settings=baseSettings(); |
| 49 | settings.webSearchModel="auto"; |
| 50 | settings.webSearchModels=[]; |
| 51 | const render=async()=>{await act(async()=>{root.render(<LocaleProvider><ModelsSection s={{...settings}} busy={false} subtab="usage" apply={async fn=>{await fn();return true;}} /></LocaleProvider>);});}; |
| 52 | await render(); |
| 53 | const picker=()=>host.querySelector<HTMLButtonElement>('button[aria-label="Web search"],button[aria-label="网页搜索"]')!; |
| 54 | assert.ok(picker(),"search picker rendered"); |
| 55 | assert.equal(picker().disabled,false,"auto remains selectable without candidates"); |
| 56 | settings.webSearchModel="removed/org/model"; |
| 57 | settings.webSearchModelStatus="invalid"; |
| 58 | settings.webSearchModelReason="Search connection is not added"; |
| 59 | await render(); |
| 60 | assert.ok(picker().textContent?.includes("removed/org/model"),"invalid ref retained"); |
| 61 | assert.ok(host.querySelector('[role="status"]'),"invalid assignment explained"); |
| 62 | await act(async()=>picker().click()); |
| 63 | const auto=Array.from(document.querySelectorAll<HTMLElement>('[role="option"]')).find(el=>/auto|自动/i.test(el.textContent??"")); |
| 64 | assert.ok(auto,"automatic option available"); |
| 65 | await act(async()=>auto.click()); |
| 66 | assert.equal(selected,"auto","automatic choice reaches binding"); |
| 67 | settings.webSearchModelOverridden=true; |
| 68 | settings.effectiveWebSearchModel="project/m"; |
| 69 | settings.webSearchModelStatus="ready"; |
| 70 | await render(); |
| 71 | assert.ok(host.textContent?.includes("project/m"),"project override visible"); |
| 72 | assert.ok(host.querySelector('[role="status"]'),"stale global assignment remains visible under a valid project override"); |
| 73 | const helpButtons = host.querySelectorAll<HTMLButtonElement>(".model-setting-help"); |
| 74 | assert.equal(helpButtons.length, 6, "all model assignments and subagent effort have help"); |
| 75 | assert.equal(document.querySelector('[role="tooltip"]'), null, "help starts hidden"); |
| 76 | const searchHelp = Array.from(helpButtons).find(button => /Web search|网页搜索/.test(button.getAttribute("aria-label") ?? ""))!; |
| 77 | await act(async () => searchHelp.focus()); |
| 78 | assert.equal(searchHelp.getAttribute("aria-expanded"), "true", "keyboard focus opens help"); |
| 79 | assert.ok(document.querySelector('[role="tooltip"]')?.textContent?.includes("Running tasks"), "search activation explained in help"); |
| 80 | await act(async () => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))); |
| 81 | assert.equal(searchHelp.getAttribute("aria-expanded"), "false", "Escape closes help"); |
| 82 | await act(async () => searchHelp.click()); |
| 83 | assert.equal(searchHelp.getAttribute("aria-expanded"), "true", "click pins help"); |
| 84 | await act(async () => document.body.click()); |
| 85 | assert.equal(searchHelp.getAttribute("aria-expanded"), "false", "outside click closes help"); |
| 86 | await act(async()=>root.unmount()); |
| 87 | console.log("web search model settings passed"); |
| 88 |