diff --git a/chrome-extension/background.js b/chrome-extension/background.js index dd4e2c9..65727c0 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -63,7 +63,7 @@ const CONTENT_READY_RETRIES = 12; const CONTENT_READY_INTERVAL_MS = 250; const TAB_LOAD_TIMEOUT_MS = 10000; const DELIVERY_NAVIGATION_TIMEOUT_MS = 15000; -const REQUIRED_BOSS_CONTENT_VERSION = "1.8.19"; +const REQUIRED_BOSS_CONTENT_VERSION = "1.8.20"; const REQUIRED_ZHILIAN_CONTENT_VERSION = "1.8.16"; const LOCAL_API_BASE_URLS = ["http://127.0.0.1:6866"]; const BOSS_LOCAL_API_MAX_ATTEMPTS = 3; diff --git a/chrome-extension/boss-content.js b/chrome-extension/boss-content.js index 79ffd3f..6a91dab 100644 --- a/chrome-extension/boss-content.js +++ b/chrome-extension/boss-content.js @@ -1,5 +1,5 @@ (function () { - const EXTENSION_VERSION = "1.8.19"; + const EXTENSION_VERSION = "1.8.20"; // Manifest injection and a readiness probe can meet in the same document. // Reuse its runner instead of leaving the first runner alive without a listener. if (window.__GET_JOBS_BOSS_CONTENT_VERSION__ === EXTENSION_VERSION) return; diff --git a/chrome-extension/boss-delivery-support.js b/chrome-extension/boss-delivery-support.js index 6881393..e3e3a1c 100644 --- a/chrome-extension/boss-delivery-support.js +++ b/chrome-extension/boss-delivery-support.js @@ -4,7 +4,7 @@ const url = new URL(value); if (url.protocol !== "https:" || !/(^|\.)zhipin\.com$/.test(url.hostname)) return ""; if (/\/(?:verify|captcha)(?:[/.]|$)/i.test(url.pathname)) return "Boss页面出现安全验证,已暂停投递,请手动处理"; - if (/\/(?:passport|login)(?:[/.]|$)/i.test(url.pathname)) return "Boss登录状态失效,已暂停投递,请手动登录"; + if (/\/(?:passport|login)(?:[/.]|$)|^\/web\/user(?:\/|$)/i.test(url.pathname)) return "Boss登录状态失效,已暂停投递,请手动登录"; } catch {} return ""; } @@ -14,13 +14,14 @@ const common = /permission|Cannot access|站点权限|脚本未就绪|扩展版本|登录|安全验证|验证码/i.test(message); return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", greetingOutcome: "NOT_SENT", greetingEvidence: "PRE_ACTION_ERROR", - actionStarted: false, haltBatch: common, message, + actionStarted: false, haltBatch: common || error?.code === "BOSS_PAGE_NOT_READY", message, + ...(error?.code ? { errorCode: error.code } : {}), failureType: /安全验证|验证码/.test(message) ? "PLATFORM_VERIFICATION" : /登录/.test(message) ? "LOGIN_EXPIRED" : /permission|Cannot access|站点权限/i.test(message) ? "EXTENSION_PERMISSION" : "PRE_ACTION_ERROR" }; } - async function prepare({ chrome, tabId, targetUrl, navigate, ensure, sleep }) { + async function prepare({ chrome, tabId, targetUrl, navigate, ensure, sleep, timeoutMs = 20000, now = Date.now }) { if (targetUrl) { try { const target = new URL(targetUrl); @@ -28,43 +29,61 @@ || !/^\/job_detail\/[^/]+\.html$/.test(target.pathname)) throw new Error(); } catch { return preparationFailure(new Error("目标岗位链接无效,尚未执行投递")); } } + // Navigate once. Readiness polling must not restart a slow load or erase a + // login/verification redirect. Preflight has no target and only observes. + try { + const current = await chrome.tabs.get(tabId); + const blocked = authenticationBlock(current.url) || authenticationBlock(current.pendingUrl); + if (blocked) throw new Error(blocked); + if (targetUrl) await navigate(tabId, targetUrl); + } catch (error) { + const current = await chrome.tabs.get(tabId).catch(() => null); + const blocked = authenticationBlock(current?.url) || authenticationBlock(current?.pendingUrl); + return preparationFailure(blocked ? new Error(blocked) : error); + } + const deadline = now() + timeoutMs; let lastError; - for (let attempt = 0; attempt < 3; attempt++) { + do { try { - if (targetUrl) await navigate(tabId, targetUrl); const before = await chrome.tabs.get(tabId); + const blocked = authenticationBlock(before.url) || authenticationBlock(before.pendingUrl); + if (blocked) throw new Error(blocked); + // A new tab may still report about:blank, with only pendingUrl populated. + if (before.status === "loading" || (before.pendingUrl && before.pendingUrl !== before.url)) { + throw new Error("Boss页面仍在加载,尚未执行投递"); + } const url = new URL(before.url || ""); if (url.protocol !== "https:" || !(url.hostname === "zhipin.com" || url.hostname.endsWith(".zhipin.com"))) { - throw new Error("Boss页面尚未就绪,请打开已登录的Boss岗位页面"); + throw new Error("Boss页面尚未就绪,尚未执行投递"); } - const blocked = authenticationBlock(url.href); - if (blocked) throw new Error(blocked); if (targetUrl && url.pathname !== new URL(targetUrl).pathname) throw new Error("目标岗位ID与当前页面不一致,尚未执行投递"); - if (before.status === "loading" || (before.pendingUrl && before.pendingUrl !== before.url)) { - throw new Error("Boss页面仍在跳转,尚未执行投递"); - } - if (!await chrome.permissions.contains({ origins: [`${url.origin}/*`] })) { - throw new Error(`Chrome扩展缺少站点权限:${url.origin},请在扩展设置中允许访问该站点`); + if (!await chrome.permissions.contains({ origins: [url.origin + "/*"] })) { + throw new Error("Chrome扩展缺少站点权限:" + url.origin + ",请在扩展设置中允许访问该站点"); } await ensure(tabId); const status = await chrome.tabs.sendMessage(tabId, { source: "GET_JOBS_BACKGROUND", type: "BOSS_PAGE_STATUS" }); - if (!status?.chromePageReady || !status?.isLoggedIn) throw new Error(status?.message || "Boss页面登录状态未确认"); + if (status?.hasSecurityPrompt || status?.hasLoginPrompt) throw new Error( + (status.hasSecurityPrompt ? "Boss页面出现安全验证" : "Boss登录状态失效") + (status.message ? ":" + status.message : "")); + if (!status?.chromePageReady || !status?.isLoggedIn) throw new Error(status?.message || "Boss页面尚未就绪"); const after = await chrome.tabs.get(tabId); if (after.url !== before.url || after.status === "loading" || (after.pendingUrl && after.pendingUrl !== after.url)) { throw new Error("Boss页面在准备过程中发生跳转,尚未执行投递"); } + if (status.currentUrl && status.currentUrl !== after.url) throw new Error("Boss页面回执来自旧页面,尚未执行投递"); return { success: true, actionStarted: false, tabId, currentUrl: after.url }; } catch (error) { lastError = error; - if (/站点权限|登录|安全验证|验证码/.test(error?.message || "")) break; - // Navigation may time out before yielding a verification redirect. Preserve that - // page instead of navigating to the job again on the next preparation attempt. + if (/站点权限|permission|Cannot access|登录|安全验证|验证码|版本不一致|目标岗位ID/i.test(error?.message || "")) break; const current = await chrome.tabs.get(tabId).catch(() => null); const blocked = authenticationBlock(current?.url) || authenticationBlock(current?.pendingUrl); if (blocked) { lastError = new Error(blocked); break; } - if (attempt < 2) await sleep(500); + if (!current || now() >= deadline) break; + await sleep(Math.min(250, deadline - now())); } - } + } while (now() < deadline); + if (now() >= deadline) lastError = Object.assign(new Error( + "Boss页面等待 " + Math.ceil(timeoutMs / 1000) + " 秒后仍未就绪,尚未执行投递。" + (lastError?.message || "") + ), { code: "BOSS_PAGE_NOT_READY" }); return preparationFailure(lastError); } const api = { prepare, preparationFailure }; diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 2ac93d9..98653b9 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "投递牛马 Chrome Bridge", - "version": "1.8.19", + "version": "1.8.20", "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 投递牛马.", "icons": { diff --git a/chrome-extension/tests/boss-delivery-preparation.test.cjs b/chrome-extension/tests/boss-delivery-preparation.test.cjs index f2437bb..d951925 100644 --- a/chrome-extension/tests/boss-delivery-preparation.test.cjs +++ b/chrome-extension/tests/boss-delivery-preparation.test.cjs @@ -4,14 +4,15 @@ const { prepare } = require('../boss-delivery-support.js'); const url = 'https://www.zhipin.com/job_detail/abc.html'; function harness(overrides = {}) { let injections = 0, sends = 0, navigations = 0; - const options = { tabId: 1, targetUrl: url, sleep: async () => {}, + let elapsed = 0; + const options = { tabId: 1, targetUrl: url, now: () => elapsed, sleep: async ms => { elapsed += ms; }, navigate: async () => { navigations++; }, ensure: async () => { injections++; }, chrome: { permissions: { contains: async () => true }, tabs: { get: async () => ({ id: 1, url, status: 'complete' }), sendMessage: async (_, message) => { assert.equal(message.type, 'BOSS_PAGE_STATUS'); sends++; return { chromePageReady: true, isLoggedIn: true }; } } } }; overrides.configure?.(options); - return { run: () => prepare(options), counts: () => ({ injections, sends, navigations }) }; + return { run: () => prepare(options), counts: () => ({ injections, sends, navigations }), elapsed: () => elapsed }; } test('permission denial occurs before any delivery action and halts batch', async () => { const h = harness({ configure: o => { o.chrome.permissions.contains = async () => false; } }); @@ -24,10 +25,14 @@ test('the screenshot injection error is not UNKNOWN', async () => { const h = harness({ configure: o => { o.ensure = async () => { throw new Error('Cannot access contents of the page. Extension manifest must request permission to access the respective host.'); }; } }); const r = await h.run(); assert.equal(r.outcome, 'FAILED'); assert.equal(r.actionStarted, false); assert.equal(r.haltBatch, true); }); -test('loading page is retried twice without injecting or sending', async () => { +test('loading page has a bounded wait without injecting, sending or repeated navigation', async () => { const h = harness({ configure: o => { o.chrome.tabs.get = async () => ({ url, status: 'loading' }); } }); - assert.equal((await h.run()).actionStarted, false); - assert.deepEqual(h.counts(), { injections: 0, sends: 0, navigations: 3 }); + const result = await h.run(); + assert.equal(result.actionStarted, false); + assert.equal(result.errorCode, 'BOSS_PAGE_NOT_READY'); + assert.equal(result.haltBatch, true); + assert.equal(h.elapsed(), 20000); + assert.deepEqual(h.counts(), { injections: 0, sends: 0, navigations: 1 }); }); test('navigation during script readiness does not pass preparation', async () => { let n = 0; @@ -39,21 +44,22 @@ test('ready page only probes status and never sends a delivery command', async ( }); test('verification and login redirects halt immediately without repeated navigation', async () => { - for (const [path, type] of [['/web/passport/zp/verify.html', 'PLATFORM_VERIFICATION'], ['/web/passport/login', 'LOGIN_EXPIRED']]) { + for (const [path, type] of [['/web/passport/zp/verify.html', 'PLATFORM_VERIFICATION'], ['/web/passport/login', 'LOGIN_EXPIRED'], ['/web/user/', 'LOGIN_EXPIRED']]) { const h = harness({ configure: o => { o.chrome.tabs.get = async () => ({ url: 'https://www.zhipin.com' + path, status: 'complete' }); } }); const result = await h.run(); assert.equal(result.failureType, type); assert.equal(result.haltBatch, true); assert.equal(result.actionStarted, false); - assert.deepEqual(h.counts(), { injections: 0, sends: 0, navigations: 1 }); + assert.deepEqual(h.counts(), { injections: 0, sends: 0, navigations: 0 }); } }); test('a navigation timeout on verification preserves the page without retrying navigation', async () => { const h = harness({ configure: o => { + let redirected = false; const navigate = o.navigate; - o.navigate = async () => { await navigate(); throw new Error('岗位导航超时'); }; - o.chrome.tabs.get = async () => ({url:'https://www.zhipin.com/web/passport/zp/verify.html',status:'complete'}); + o.navigate = async () => { await navigate(); redirected = true; throw new Error('岗位导航超时'); }; + o.chrome.tabs.get = async () => ({url:redirected?'https://www.zhipin.com/web/passport/zp/verify.html':url,status:'complete'}); } }); const result = await h.run(); assert.equal(result.haltBatch, true); @@ -61,6 +67,56 @@ test('a navigation timeout on verification preserves the page without retrying n assert.deepEqual(h.counts(), {injections:0,sends:0,navigations:1}); }); +test('cold preflight waits more than one second for the committed page without navigation', async () => { + const h = harness({ configure: o => { + delete o.targetUrl; + o.chrome.tabs.get = async () => o.now() < 3500 + ? {url:'about:blank',pendingUrl:'https://www.zhipin.com/',status:'loading'} + : {url:'https://www.zhipin.com/',status:'complete'}; + } }); + assert.equal((await h.run()).success,true); + assert.equal(h.elapsed(),3500); + assert.deepEqual(h.counts(),{injections:1,sends:1,navigations:0}); +}); + +test('pending login redirect is preserved even when the committed job still looks ready', async () => { + const h = harness({ configure: o => { o.chrome.tabs.get = async () => ({url,pendingUrl:'https://www.zhipin.com/web/user/',status:'loading'}); } }); + assert.equal((await h.run()).failureType,'LOGIN_EXPIRED'); + assert.deepEqual(h.counts(),{injections:0,sends:0,navigations:0}); +}); + +test('a newly created blank tab without pendingUrl waits instead of being classified as logged out', async () => { + const h = harness({ configure: o => { + delete o.targetUrl; + o.chrome.tabs.get = async () => ({url:o.now()<1250?'about:blank':url,status:'complete'}); + } }); + assert.equal((await h.run()).success,true); + assert.equal(h.elapsed(),1250); + assert.deepEqual(h.counts(),{injections:1,sends:1,navigations:0}); +}); + +test('explicit login and security status halt immediately even with a generic page message', async () => { + for (const [flag, type] of [['hasLoginPrompt', 'LOGIN_EXPIRED'], ['hasSecurityPrompt', 'PLATFORM_VERIFICATION']]) { + const h = harness({ configure: o => { + o.chrome.tabs.sendMessage = async () => ({[flag]:true,message:'请稍后再试'}); + } }); + const result = await h.run(); + assert.equal(result.failureType,type); + assert.equal(result.actionStarted,false); + assert.equal(result.haltBatch,true); + assert.equal(h.elapsed(),0); + } +}); + +test('readiness from an old document is rejected until its current URL matches', async () => { + const h = harness({ configure: o => { + o.chrome.tabs.sendMessage = async () => ({chromePageReady:true,isLoggedIn:true,currentUrl:o.now()<1500?url.replace('abc','other'):url}); + } }); + assert.equal((await h.run()).success,true); + assert.equal(h.elapsed(),1500); + assert.equal(h.counts().navigations,1); +}); + test('rejects an unsupported target before navigating and rejects a changed job ID', async () => { const invalid = harness({ configure: o => { o.targetUrl = 'https://example.com/job_detail/abc.html'; } }); assert.equal((await invalid.run()).evidence, 'PRE_ACTION_ERROR'); diff --git a/chrome-extension/tests/boss-hr-assistant.test.cjs b/chrome-extension/tests/boss-hr-assistant.test.cjs index 3cd8c46..01013f8 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.8.19"); + assert.equal(manifest.version, "1.8.20"); }); test("assistant exposes policy-gated dedicated watch and preserves explicit manual send", () => { diff --git a/chrome-extension/tests/manifest-id.test.cjs b/chrome-extension/tests/manifest-id.test.cjs index 3d8ba0c..d691517 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.8.19'); + assert.equal(manifest.version, '1.8.20'); 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 66c0af3..bd7fc3a 100644 --- a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs +++ b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs @@ -15,10 +15,10 @@ 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.8.19"); + assert.equal(manifest.version, "1.8.20"); assert.match(background, /BACKGROUND_VERSION = "2026-09-14-runtime-adapters"/); - assert.match(background, /REQUIRED_BOSS_CONTENT_VERSION = "1.8.19"/); - assert.match(boss, /EXTENSION_VERSION = "1.8.19"/); + assert.match(background, /REQUIRED_BOSS_CONTENT_VERSION = "1.8.20"/); + assert.match(boss, /EXTENSION_VERSION = "1.8.20"/); assert.match(zhilian, /EXTENSION_VERSION = "1.8.16"/); assert.match(background, /REQUIRED_ZHILIAN_CONTENT_VERSION = "1.8.16"/); const frontendBridge = fs.readFileSync(path.resolve(extensionDir, "../front/lib/chromeBridge.ts"), "utf8"); diff --git a/docs/delivery-recovery.md b/docs/delivery-recovery.md index e3847f6..9132001 100644 --- a/docs/delivery-recovery.md +++ b/docs/delivery-recovery.md @@ -1,7 +1,9 @@ # BOSS / 智联投递恢复 -扩展版本 1.8.19,后台与页面协议 `2026-09-14-runtime-adapters`。 +扩展版本 1.8.20,后台与页面协议 `2026-09-14-runtime-adapters`。 +- BOSS 预检对新建标签页最多等待 20 秒,等待已提交 URL、页面加载及脚本回执同时就绪,避免冷启动超过 1 秒就误报“页面仍在跳转”。岗位导航仅执行一次,重试只读就绪检查;登录、安全验证和权限不足立即暂停,超时仍不触发投递。 +- 隔离 Chromium 回归覆盖真实 MV3 后台和页面脚本的完整流程:延迟首页加载、预检、岗位导航、跨文档进入聊天、多行话术只发送一次和结果记录。招聘页面及 API 均为离线合成响应,不代表真实账号投递验收。 - BOSS 富文本输入框保留确认话术中的真实换行,不再用 textContent 覆盖 innerText 生成的换行结构;发送前仍逐字核对原话术。 - BOSS 点击操作只触发一次 click,避免先派发 click 再调用 `.click()` 导致收藏切换两次、重复打开沟通入口或重复发送话术。 - 页面进入前进/后退缓存(BFCache)时立即撤销旧页面执行资格;返回页面后重新注入脚本,旧异步任务不得继续点击或填写话术。 diff --git a/src/test/java/com/getjobs/application/service/BrowserRuntimeRegressionTest.java b/src/test/java/com/getjobs/application/service/BrowserRuntimeRegressionTest.java index b7a529d..5c469ce 100644 --- a/src/test/java/com/getjobs/application/service/BrowserRuntimeRegressionTest.java +++ b/src/test/java/com/getjobs/application/service/BrowserRuntimeRegressionTest.java @@ -17,7 +17,7 @@ class BrowserRuntimeRegressionTest { Page page; com.sun.net.httpserver.HttpServer api; - @BeforeEach void start() throws Exception { + @BeforeEach void start(TestInfo testInfo) throws Exception { Path extension = temp.resolve("extension"); Path source = Path.of("chrome-extension").toAbsolutePath(); try (var files = Files.walk(source)) { @@ -34,12 +34,46 @@ class BrowserRuntimeRegressionTest { Path background = extension.resolve("background.js"); Files.writeString(background, "globalThis.fetch = async () => { throw new Error('OFFLINE_REGRESSION_NETWORK_DENIED'); };\n" + Files.readString(background)); + if (testInfo.getTestMethod().orElseThrow().getName().equals("bossColdPreflightNavigatesAndSendsOneMultilineGreetingAcrossDocuments")) { + // Test-only bridge into the real MV3 worker; never copied to production. + Files.writeString(background, """ + \nconst fixtureReceipts=[]; + // Chrome-created tabs can navigate before Playwright attaches its + // route interceptor. Hand the blank tab to the test before navigation. + const fixtureCreateTab=chrome.tabs.create.bind(chrome.tabs); + chrome.tabs.create=options=>{ + if(options.url!=='https://www.zhipin.com/')throw new Error('UNEXPECTED_FIXTURE_TAB'); + return fixtureCreateTab({...options,url:'about:blank'}); + }; + globalThis.fetch=async(url,options={})=>{ + const parsed=new URL(url);let body; + if(parsed.origin!=='http://127.0.0.1:6866')throw new Error('OFFLINE_NETWORK_DENIED'); + if(parsed.pathname==='/api/local-auth/action-token')body={success:true,data:{token:'offline-token'}}; + else if(parsed.pathname.endsWith('/validate-dispatch'))body={success:true}; + else if(parsed.pathname.endsWith('/runtime/claim'))body={success:true,enabled:false}; + else if(parsed.pathname==='/api/boss/jobs/10/delivery-result'){ + const result=JSON.parse(options.body);fixtureReceipts.push(result);body={success:true,accepted:true,state:result.outcome}; + } else throw new Error('UNEXPECTED_OFFLINE_API:'+parsed.pathname); + return new Response(JSON.stringify(body),{status:200,headers:{'Content-Type':'application/json'}}); + }; + chrome.runtime.onMessage.addListener((message,sender,reply)=>{ + if(message.type!=='OFFLINE_REGRESSION_REQUEST')return; + if(message.receipts){reply(fixtureReceipts);return;} + chrome.tabs.query({}).then(tabs=>{ + const owner=tabs.find(t=>t.url==='http://localhost:6866/offline-regression'); + return handlePageMessage(message.payload,{tab:owner}); + }).then(reply,error=>reply({fixtureError:error.message})); + return true; + }); + """, StandardOpenOption.APPEND); + } Files.writeString(extension.resolve("regression-probe.html"), "Offline regression"); playwright = Playwright.create(); context = playwright.chromium().launchPersistentContext(temp.resolve("profile"), new BrowserType.LaunchPersistentContextOptions().setChannel("chromium").setHeadless(true) .setArgs(List.of("--disable-extensions-except=" + extension, "--load-extension=" + extension, - "--disable-background-networking"))); + "--disable-background-networking", + "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1"))); context.setDefaultTimeout(10000); context.route("**/*", route -> { if (route.request().url().startsWith("chrome-extension://")) route.resume(); @@ -108,6 +142,72 @@ Object probe(String body) { assertThat(page.locator("body").innerHTML()).isEqualTo(original); } + @Test void bossColdPreflightNavigatesAndSendsOneMultilineGreetingAcrossDocuments() throws Exception { + // Real MV3 background/content scripts and document navigation; all pages + // and API responses are synthetic, with real recruitment network denied. + String workbench = "http://localhost:6866/offline-regression"; + context.route(workbench, route -> route.fulfill(new Route.FulfillOptions().setContentType("text/html") + .setBody("Offline workbench"))); + context.route("https://www.zhipin.com/", route -> { + try { Thread.sleep(2300); } catch (InterruptedException error) { Thread.currentThread().interrupt(); } + route.fulfill(new Route.FulfillOptions().setContentType("text/html") + .setBody("Offline BOSS
测试岗位首页
")); + }); + context.route("https://www.zhipin.com/job_detail/fixture10.html", route -> route.fulfill( + new Route.FulfillOptions().setContentType("text/html").setBody(""" + Offline job +

测试岗位

测试职责

+ +
+ """))); + context.route("https://www.zhipin.com/web/geek/chat", route -> route.fulfill( + new Route.FulfillOptions().setContentType("text/html").setBody(""" + Offline chat +
+
测试HR测试公司
+
+
+
+ +
+ + """))); + page.navigate(workbench); + assertThat(ping()).isEqualTo(true); + Page landing = context.waitForPage(() -> worker.evaluate(""" + ()=>{window.fixturePreflight=chrome.runtime.sendMessage({type:'OFFLINE_REGRESSION_REQUEST',payload:{type:'BOSS_DELIVERY_PREFLIGHT',platform:'boss'}});} + """)); + // The context now owns the tab and its routes before the first HTTPS request. + landing.navigate("https://www.zhipin.com/"); + Object prepared = worker.evaluate("()=>window.fixturePreflight"); + assertThat(((Map)prepared).get("success")).as("preflight: %s", prepared).isEqualTo(true); + Object result = worker.evaluate(""" + ()=>chrome.runtime.sendMessage({type:'OFFLINE_REGRESSION_REQUEST',payload:{type:'BOSS_DELIVER_ONE',platform:'boss',runtimeProtocol:'application-runtime/1', + runId:'fixture-run',runtimeSessionId:'fixture-session',correlationId:'fixture-correlation', + task:{id:10,profileId:1,requestKey:'fixture-delivery',url:'https://www.zhipin.com/job_detail/fixture10.html', + greeting:'测试岗位沟通。\\n个人作品集:https://example.invalid/'}}}) + """); + assertThat(((Map)result).get("outcome")).as("delivery: %s", result).isEqualTo("CONFIRMED"); + assertThat(((Map)result).get("greetingOutcome")).isEqualTo("CONFIRMED"); + Page chat = context.pages().stream().filter(p -> p.url().equals("https://www.zhipin.com/web/geek/chat")).findFirst().orElseThrow(); + assertThat(chat.locator(".message-self").count()).isEqualTo(1); + assertThat(chat.locator(".text-content").innerText()).isEqualTo("测试岗位沟通。\n个人作品集:https://example.invalid/"); + assertThat(chat.evaluate("() => [sessionStorage.fixtureContactClicks,sessionStorage.fixtureSendClicks]")) + .isEqualTo(List.of("1","1")); + assertThat(worker.evaluate("async() => {const receipts=await chrome.runtime.sendMessage({type:'OFFLINE_REGRESSION_REQUEST',receipts:true});return receipts.length > 0 && receipts.every(r=>r.outcome==='CONFIRMED' && r.greetingOutcome==='CONFIRMED') }")) + .isEqualTo(true); + } + @Test void bossGreetingKeepsNewlinesInTheRealContenteditable() throws Exception { fixture("boss", "detail/redacted-header-only-20260915.html", "
");