From 6f131edcf25e28098d446d1a95fec8d92c0b6d56 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Tue, 20 Oct 2026 16:28:01 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=AD=89?= =?UTF-8?q?=E5=BE=85BOSS=E8=81=8A=E5=A4=A9=E5=8F=91=E9=80=81=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=E5=B0=B1=E7=BB=AA=E5=B9=B6=E9=87=8D=E6=96=B0=E6=A0=B8?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chrome-extension/boss-hr-bridge.js | 48 ++++++---- chrome-extension/manifest.json | 2 +- .../tests/boss-hr-assistant.test.cjs | 2 +- .../tests/boss-hr-send-readiness.test.cjs | 92 +++++++++++++++++++ chrome-extension/tests/manifest-id.test.cjs | 2 +- .../profile-scoped-scan-contract.test.cjs | 2 +- 6 files changed, 128 insertions(+), 20 deletions(-) create mode 100644 chrome-extension/tests/boss-hr-send-readiness.test.cjs diff --git a/chrome-extension/boss-hr-bridge.js b/chrome-extension/boss-hr-bridge.js index f6f0cd46..57ed9f15 100644 --- a/chrome-extension/boss-hr-bridge.js +++ b/chrome-extension/boss-hr-bridge.js @@ -2,7 +2,7 @@ "use strict"; const CONTENT_VERSION = "2026-09-30-hr-background-v1"; - const SCRIPT_BUILD = "2026-10-11-hr-read-recovery-v1"; + const SCRIPT_BUILD = "2026-10-20-hr-send-readiness-v1"; if (window.top !== window.self || window.__GET_JOBS_BOSS_HR_BRIDGE__ === SCRIPT_BUILD) return; window.__GET_JOBS_BOSS_HR_BRIDGE__ = SCRIPT_BUILD; const support = globalThis.GetJobsBossHrSupport; @@ -598,9 +598,10 @@ return { success: true, outcome: "FAILED_SAFE", evidence: "输入框内容复核失败" }; } - // Let the reactive editor update its native send control before checking readiness. - await wait(50); - const sendButtons=findTextSendControls(input).filter(sendControlEnabled); + // Background rendering can take longer than one frame and replace the editor. + // Only observe readiness here; never rewrite a changed draft or retry a send. + const readiness=await waitForTextSendControl(command); + if (!readiness.button) return {success:true,outcome:readiness.stale?"STALE":"FAILED_SAFE",evidence:readiness.evidence}; if (Date.now() >= command.deadlineAt) return { success: true, outcome: "FAILED_SAFE", evidence: "发送命令已过期" }; await guard(); const recheck=support.currentSession(document,{uid:command.uid}); @@ -611,18 +612,10 @@ || (command.expectedInboundRound?.length && (finalRound.length!==command.expectedInboundRound.length || finalRound.some((m,i)=>!support.messagesMatch(m,command.expectedInboundRound[i]))))) return {success:true,outcome:"STALE",evidence:"点击发送前会话或消息发生变化"}; if(Date.now()>=command.deadlineAt) return {success:true,outcome:"FAILED_SAFE",evidence:"授权复核后租约已过期"}; - let dispatched = false; - if (sendButtons.length === 1) { - sendDispatched=true; - sendButtons[0].click(); - dispatched = true; - } else if (sendButtons.length === 0) { - return { success: true, outcome: "FAILED_SAFE", evidence: "真实发送按钮不可用,未触发发送动作" }; - } else { - return { success: true, outcome: "FAILED_SAFE", evidence: "发送按钮命中多个候选项" }; - } - - if (!dispatched) return { success: true, outcome: "FAILED_SAFE", evidence: "未触发发送动作" }; + const finalControl=textSendState(command.draft); + if (!finalControl.button) return {success:true,outcome:finalControl.stale?"STALE":"FAILED_SAFE",evidence:finalControl.evidence}; + sendDispatched=true; + finalControl.button.click(); const confirmed = await waitForOutbound(command.draft, before.length); return confirmed ? { success: true, outcome: "SENT", evidence: "DOM_OUTBOUND_EXACT_MATCH", observedLatestInbound: latest } @@ -831,6 +824,29 @@ .filter(element=>visible(element) && /^发送(?:[((]Enter[))])?$/.test(support.normalizeText(element.textContent))); } + function textSendState(draft) { + const inputs=Array.from(document.querySelectorAll("#chat-input,textarea,[contenteditable='true']")).filter(visible); + if(inputs.length!==1) return {evidence:"聊天输入框未唯一命中,未触发发送动作"}; + const input=inputs[0]; + if(support.normalizeText(inputValue(input))!==support.normalizeText(draft)) + return {stale:true,evidence:"等待发送按钮时草稿已变化,保留当前输入,未触发发送动作"}; + const controls=findTextSendControls(input); + if(controls.length>1) return {ambiguous:true,evidence:"发送按钮命中多个候选项"}; + const button=controls.length===1 && sendControlEnabled(controls[0]) ? controls[0] : null; + return {button,evidence:"真实发送按钮不可用,未触发发送动作"}; + } + + async function waitForTextSendControl(command) { + const deadline=Math.min(command.deadlineAt,Date.now()+2500); + for(;;) { + await guard(); + const state=textSendState(command.draft); + const remaining=deadline-Date.now(); + if(state.button || state.stale || state.ambiguous || remaining<=0) return state; + await wait(Math.min(100,remaining)); + } + } + function sendControlEnabled(element) { return !element.disabled && element.getAttribute("aria-disabled")!=="true" && !element.hasAttribute("disabled") && !element.classList.contains("disabled") diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index ae086bd6..a976ef65 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "投递牛马 Chrome Bridge", - "version": "1.10.16", + "version": "1.10.17", "minimum_chrome_version": "132", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzzdIlNVOv76Y/cSWrjD5Tg2Vlsha8yWHzsn46PBsg724/2dftOUzIIr2n70VRaRgGwEd8FjO/Y768Ori443zF4pQpWvuxXxm05YO25ILQ/+aJLmUycAEdWbkdhcagr4YXnXJdYlSCGSAToSQBjk+owQOdlBLQn5wofPoshrqayoJjRQ5aAUj1SuSlnNv9iimle8GMA1IaA1l5rw6K/chfcgwMTg6HxRAIoludt5JGbIBryi2Lu1hOJRMaDnL7A57ofBnn3qx3H2HIGWGkkTW9EMkls0XMXwx8+mJVIj5HSYl0EeuCvEoTa1W3i1CbOf3kY2yCPKS3Qz3lOvJiwJ4ZQIDAQAB", "description": "Use signed-in Chrome tabs to scan jobs, confirm deliveries, and review BOSS HR reply drafts for 投递牛马.", diff --git a/chrome-extension/tests/boss-hr-assistant.test.cjs b/chrome-extension/tests/boss-hr-assistant.test.cjs index 2ff274a1..dd4b40e3 100644 --- a/chrome-extension/tests/boss-hr-assistant.test.cjs +++ b/chrome-extension/tests/boss-hr-assistant.test.cjs @@ -15,7 +15,7 @@ test("manifest loads the direct HR bridge and one-minute alarm capability", () = const bossScripts = manifest.content_scripts.find((entry) => entry.matches.includes("https://www.zhipin.com/*")).js; assert.deepEqual(bossScripts.slice(-3), ["boss-hr-support.js", "boss-hr-bridge.js", "boss-hr-assistant.js"]); assert.ok(manifest.permissions.includes("alarms")); - assert.equal(manifest.version, "1.10.16"); + assert.equal(manifest.version, "1.10.17"); }); test("assistant uses the shared background host and preserves explicit manual send", () => { diff --git a/chrome-extension/tests/boss-hr-send-readiness.test.cjs b/chrome-extension/tests/boss-hr-send-readiness.test.cjs new file mode 100644 index 00000000..ed31f30c --- /dev/null +++ b/chrome-extension/tests/boss-hr-send-readiness.test.cjs @@ -0,0 +1,92 @@ +const test=require('node:test'); +const assert=require('node:assert/strict'); +const vm=require('node:vm'); +const {readFileSync}=require('node:fs'); +const source=readFileSync(require.resolve('../boss-hr-bridge.js'),'utf8'); + +function harness({readyAt=350,replaceAt=Infinity,changeDraftAt=Infinity,ambiguousAt=Infinity,pauseAt=Infinity,changeRoundAt=Infinity,disableOnFinalGuard=false}={}) { + let clock=1000,clicks=0,writtenAt=null,readyObserved=false,finalDisabled=false; + const inbound={from:'对方',type:'文本',text:'合成问候',time:'今天',messageId:'inbound'}; + const messages=[inbound]; + const normalize=value=>String(value??'').trim(); + const button={textContent:'发送',classList:{contains:()=>false},getAttribute:()=>null,hasAttribute:()=>false, + getBoundingClientRect:()=>({width:60,height:28}),click:()=>{clicks++;messages.push({from:'本人',type:'文本',text:'合成回复',messageId:'outbound'});}}; + Object.defineProperty(button,'disabled',{get:()=>{ + const disabled=writtenAt===null || clock-writtenAt({textContent:'',focus:()=>{},dispatchEvent:()=>{if(writtenAt===null)writtenAt=clock;}, + getBoundingClientRect:()=>({width:600,height:80}),closest(){return {querySelectorAll:()=>this===input?(clock-writtenAt>=ambiguousAt?[button,{...button}]:[button]):[]};}}); + let input=makeInput(),replaced=false; + const advance=ms=>{ + clock+=ms; + if(writtenAt!==null && clock-writtenAt>=replaceAt && !replaced){const text=input.textContent;input=makeInput();input.textContent=text;replaced=true;} + if(writtenAt!==null && clock-writtenAt>=changeDraftAt)input.textContent='本人未发送草稿'; + if(writtenAt!==null && clock-writtenAt>=changeRoundAt)messages.splice(0,messages.length,{...inbound,text:'新的HR提问'}); + }; + const document={querySelectorAll:()=>[input]}; + const window={addEventListener:()=>{}};window.top=window;window.self=window; + class ClockDate extends Date {static now(){return clock;}} + const support={normalizeText:normalize,pageSafety:()=>({safe:true}),currentSession:()=>({uid:'uid',title:'合成HR',observedCompanyName:'合成公司'}), + readMessages:()=>messages.map(x=>({...x})),latestInbound:rows=>rows.findLast(x=>x.from==='对方'),messagesMatch:(a,b)=>a?.text===b?.text}; + const code=source.replace(/\}\)\(\);\s*$/,`window.test={executeSend}; + guard=async()=>hooks.guard();wait=async ms=>hooks.wait(ms); + locateByUid=async()=>({matches:[{}],unique:{}});openConversation=async()=>({success:true}); + readContext=async()=>({messages:GetJobsBossHrSupport.readMessages(),complete:true});})();`); + vm.runInNewContext(code,{window,document,Date:ClockDate,location:{pathname:'/web/geek/chat'}, + crypto:{randomUUID:()=> 'document'},sessionStorage:{getItem:()=>null}, + HTMLTextAreaElement:class {},HTMLInputElement:class {},InputEvent:class {},Event:class {}, + getComputedStyle:()=>({display:'block',visibility:'visible'}),GetJobsBossHrSupport:support, + chrome:{runtime:{onMessage:{addListener:()=>{}}}},hooks:{wait:advance,guard:()=>{ + if(writtenAt!==null && clock-writtenAt>=pauseAt)throw new Error('USER_PAUSED'); + if(disableOnFinalGuard && readyObserved)finalDisabled=true; + }}}); + return {send:async(deadline=20000)=>window.test.executeSend({commandId:'command',uid:'uid',draft:'合成回复',hrName:'合成HR',companyName:'合成公司', + expectedLatestInbound:inbound,expectedInboundRound:[inbound],deadlineAt:1000+deadline}), + clicks:()=>clicks,draft:()=>input.textContent,elapsed:()=>clock-1000,replaced:()=>replaced}; +} + +test('waits for delayed editor readiness and clicks once after the native control enables',async()=>{ + const h=harness();const result=await h.send(); + assert.equal(result.outcome,'SENT');assert.equal(h.clicks(),1);assert.ok(h.elapsed()>=350); +}); + +test('reacquires an editor replaced during reactive rendering without rewriting its draft',async()=>{ + const h=harness({replaceAt:100});const result=await h.send(); + assert.equal(h.replaced(),true);assert.equal(result.outcome,'SENT');assert.equal(h.clicks(),1); +}); + +test('keeps a changed user draft and never sends it',async()=>{ + const h=harness({changeDraftAt:100});const result=await h.send(); + assert.equal(result.outcome,'STALE');assert.equal(h.clicks(),0);assert.equal(h.draft(),'本人未发送草稿'); +}); + +test('a permanently disabled control stops safely within the readiness budget',async()=>{ + const h=harness({readyAt:Infinity});const result=await h.send(); + assert.equal(result.outcome,'FAILED_SAFE');assert.equal(h.clicks(),0);assert.equal(h.elapsed(),2500); +}); + +test('readiness waiting respects the command deadline',async()=>{ + const h=harness({readyAt:Infinity});const result=await h.send(250); + assert.equal(result.outcome,'FAILED_SAFE');assert.equal(h.clicks(),0);assert.ok(h.elapsed()<=250); +}); + +test('ambiguous native send controls fail closed without clicking either',async()=>{ + const h=harness({ambiguousAt:100});const result=await h.send(); + assert.equal(result.outcome,'FAILED_SAFE');assert.equal(h.clicks(),0);assert.match(result.evidence,/多个/); +}); + +test('manual pause during readiness waiting aborts before a send',async()=>{ + const h=harness({pauseAt:100});await assert.rejects(h.send(),/USER_PAUSED/);assert.equal(h.clicks(),0); +}); + +test('a changed inbound round is rechecked after readiness',async()=>{ + const h=harness({changeRoundAt:100});const result=await h.send(); + assert.equal(result.outcome,'STALE');assert.equal(h.clicks(),0); +}); + +test('the native control is checked again after the final authorization guard',async()=>{ + const h=harness({disableOnFinalGuard:true});const result=await h.send(); + assert.equal(result.outcome,'FAILED_SAFE');assert.equal(h.clicks(),0); +}); diff --git a/chrome-extension/tests/manifest-id.test.cjs b/chrome-extension/tests/manifest-id.test.cjs index e4745e08..4a058e65 100644 --- a/chrome-extension/tests/manifest-id.test.cjs +++ b/chrome-extension/tests/manifest-id.test.cjs @@ -17,7 +17,7 @@ function extensionIdFromKey(key) { test('manifest public key derives the backend allowlisted extension id', () => { const manifestPath = path.join(__dirname, '..', 'manifest.json'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - assert.equal(manifest.version, '1.10.16'); + assert.equal(manifest.version, '1.10.17'); assert.equal(extensionIdFromKey(manifest.key), EXPECTED_EXTENSION_ID); const publicKey = crypto.createPublicKey({ diff --git a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs index 92c6f6f6..635fd4af 100644 --- a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs +++ b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs @@ -15,7 +15,7 @@ test("extension release and both content scripts use the profile-scoped contract const boss = source("boss-content.js"); const zhilian = source("zhilian-content.js"); - assert.equal(manifest.version, "1.10.16"); + assert.equal(manifest.version, "1.10.17"); assert.match(background, /BACKGROUND_VERSION = "2026-09-14-runtime-adapters"/); assert.match(background, /REQUIRED_BOSS_CONTENT_VERSION = "1.10.5"/); assert.match(boss, /EXTENSION_VERSION = "1.10.5"/); From 517644132fdbddea8cedb87cc5840520aca84418 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Tue, 20 Oct 2026 16:34:40 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=9B=B4?= =?UTF-8?q?=E6=96=B0HR=E6=89=A9=E5=B1=95=E6=97=B6=E5=9C=A8=E5=8E=9F?= =?UTF-8?q?=E9=A1=B5=E9=87=8D=E8=BF=9E=E5=8A=A9=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chrome-extension/background.js | 11 +++++++---- chrome-extension/boss-hr-assistant.js | 2 +- .../tests/background-tab-routing.test.cjs | 12 ++++++++++++ chrome-extension/tests/boss-hr-panel.test.cjs | 19 +++++++++++++++---- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/chrome-extension/background.js b/chrome-extension/background.js index 5649d920..0ede346c 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -126,10 +126,13 @@ chrome.runtime.onInstalled?.addListener?.(() => { const scanObserver = GetJobsScanObserver.create({storage:chrome.storage.local, request:requestLocalApi, version:chrome.runtime.getManifest().version}); const bossHrHost = GetJobsBossHrHost.create({ chrome, request: requestLocalApi, - ensureContent: async tabId => { - await ensureContentScript(tabId, "boss-content.js"); - await chrome.scripting.executeScript({ target:{tabId}, world:"MAIN", files:["boss-hr-identity.js"] }); - } }); + ensureContent: ensureBossHrContent }); +async function ensureBossHrContent(tabId) { + // HR has its own idempotent builds. Reconnect them in place after an extension + // update without restarting the unrelated job scanner or reloading the page. + await chrome.scripting.executeScript({ target:{tabId}, world:"MAIN", files:["boss-hr-identity.js"] }); + await chrome.scripting.executeScript({ target:{tabId}, files:["boss-hr-support.js","boss-hr-bridge.js","boss-hr-assistant.js"] }); +} bossHrHost.initialize().catch(()=>{}); chrome.runtime.onStartup?.addListener?.(()=>bossHrHost.initialize(true).catch(()=>{})); chrome.storage.local.get('__GET_JOBS_SCAN_OBSERVER_V1__').then(data=>{if(Object.keys(data.__GET_JOBS_SCAN_OBSERVER_V1__||{}).length) chrome.alarms?.create?.('scan-observer-sync',{periodInMinutes:0.5});}).catch(()=>{}); diff --git a/chrome-extension/boss-hr-assistant.js b/chrome-extension/boss-hr-assistant.js index 117df1d1..ef4fbe48 100644 --- a/chrome-extension/boss-hr-assistant.js +++ b/chrome-extension/boss-hr-assistant.js @@ -1,7 +1,7 @@ (function () { "use strict"; - const PANEL_VERSION = "2026-10-08-hr-background-ui-v4"; + const PANEL_VERSION = "2026-10-20-hr-background-ui-v5"; if (window.top !== window.self || window.__GET_JOBS_BOSS_HR_ASSISTANT__ === PANEL_VERSION) return; window.__GET_JOBS_BOSS_HR_ASSISTANT_CLEANUP__?.(); window.__GET_JOBS_BOSS_HR_ASSISTANT__ = PANEL_VERSION; diff --git a/chrome-extension/tests/background-tab-routing.test.cjs b/chrome-extension/tests/background-tab-routing.test.cjs index eb14f5d0..c35e51c5 100644 --- a/chrome-extension/tests/background-tab-routing.test.cjs +++ b/chrome-extension/tests/background-tab-routing.test.cjs @@ -43,6 +43,18 @@ test('overlay pause changes the persistent background host instead of the legacy const BOSS_CONTENT_VERSION = readContentVersion("boss-content.js"); const ZHILIAN_CONTENT_VERSION = readContentVersion("zhilian-content.js"); +test('HR reconnects its own scripts on the existing tab without the legacy scanner or page navigation',async()=>{ + const h=loadBackground({tabs:[{id:7,windowId:8,url:'https://www.zhipin.com/web/geek/chat?getjobs-autopilot=1',status:'complete'}],contentReady:false}); + h.context.ensureContentScript=async()=>{throw new Error('legacy content context is unavailable after extension update');}; + await h.context.ensureBossHrContent(7); + assert.deepEqual(h.executedScripts.map(item=>({tab:item.target.tabId,world:item.world,files:Array.from(item.files)})),[ + {tab:7,world:'MAIN',files:['boss-hr-identity.js']}, + {tab:7,world:undefined,files:['boss-hr-support.js','boss-hr-bridge.js','boss-hr-assistant.js']} + ]); + assert.equal(h.tabUpdates.length,0);assert.equal(h.windowUpdates.length,0);assert.equal(h.windowCreates.length,0); + assert.equal(h.tabList.length,1); +}); + function jsonResponse(body, { ok = true, status = 200 } = {}) { return { ok, diff --git a/chrome-extension/tests/boss-hr-panel.test.cjs b/chrome-extension/tests/boss-hr-panel.test.cjs index c3b31138..5298197d 100644 --- a/chrome-extension/tests/boss-hr-panel.test.cjs +++ b/chrome-extension/tests/boss-hr-panel.test.cjs @@ -15,9 +15,9 @@ class Node { attachShadow(){this.shadow=new Node('shadow');return this.shadow;} } const flush=()=>new Promise(resolve=>setImmediate(resolve)); -async function harness({mismatch=false,hostState={},statusState={},policyState={}}={}){ +async function harness({mismatch=false,hostState={},statusState={},policyState={},previousBuild}={}){ const document={documentElement:new Node('html'),getElementById:()=>null,createElement:tag=>new Node(tag)}; - let timer,fail=false,held=new Set(),clock=0,timeoutId=0; + let timer,fail=false,held=new Set(),clock=0,timeoutId=0,oldCleanups=0; const operations=[],pending=[],timeouts=new Map(); const binding={watchSessionId:'watch',hostGeneration:'generation',pageDocumentId:'document'}; const host={transport:'CHROME_BACKGROUND',state:'RUNNING',intentEnabled:true,profileId:4,...binding,...hostState}; @@ -30,14 +30,25 @@ async function harness({mismatch=false,hostState={},statusState={},policyState={ const window={setInterval:fn=>{timer=fn;return 1;},clearInterval:()=>{}, setTimeout:(fn,ms)=>{const id=++timeoutId;timeouts.set(id,{fn,at:clock+ms});return id;},clearTimeout:id=>timeouts.delete(id)}; window.top=window;window.self=window; - vm.runInNewContext(fs.readFileSync(require.resolve('../boss-hr-assistant.js'),'utf8'),{document,window,chrome:{runtime},location:{pathname:'/web/geek/chat'},Date}); + if(previousBuild){window.__GET_JOBS_BOSS_HR_ASSISTANT__=previousBuild;window.__GET_JOBS_BOSS_HR_ASSISTANT_CLEANUP__=()=>oldCleanups++;} + const source=fs.readFileSync(require.resolve('../boss-hr-assistant.js'),'utf8'); + const context=vm.createContext({document,window,chrome:{runtime},location:{pathname:'/web/geek/chat'},Date}); + vm.runInContext(source,context); await flush();const root=document.documentElement.childNodes[0].shadow; const nodes=()=>{const result=[];const walk=node=>{result.push(node);node.childNodes.forEach(walk);};walk(root);return result;}; - return {nodes,operations,tick:async()=>{timer();await flush();},fail:()=>{fail=true;}, + return {nodes,operations,reinject:()=>vm.runInContext(source,context),oldCleanups:()=>oldCleanups,panels:()=>document.documentElement.childNodes.length, + tick:async()=>{timer();await flush();},fail:()=>{fail=true;}, hold:operations=>{held=new Set(operations);},setHost:value=>Object.assign(host,value),setStatus:value=>Object.assign(status,value), release:async response=>{pending.splice(0).forEach(item=>item.reply(response || item.response));await flush();}, expire:async ms=>{clock+=ms;for(const [id,timeout] of timeouts)if(timeout.at<=clock){timeouts.delete(id);timeout.fn();}await flush();}}; } + +test('an extension update replaces the prior panel connection in place and repeated injection stays single',async()=>{ + const h=await harness({previousBuild:'2026-10-08-hr-background-ui-v4'}); + assert.equal(h.oldCleanups(),1);assert.equal(h.panels(),1); + assert.ok(h.nodes().some(node=>node.textContent==='后台托管:运行中')); + h.reinject();await flush();assert.equal(h.oldCleanups(),1);assert.equal(h.panels(),1); +}); test('panel shows the saved 15 day policy and shared status, with collapsed records and same-host pause',async()=>{ const h=await harness(); assert.ok(h.nodes().some(node=>node.textContent==='后台托管:运行中'));