From 11059783fbb716fe2107ab478377f3a842374b55 Mon Sep 17 00:00:00 2001 From: Maxxx-step1121 <129008351+Maxxx-step1121@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:58:04 +0400 Subject: [PATCH 1/9] Harden the browser companion and support parallel action pages Adds an optional pool of up to four fixed ChatGPT pages in one private Chrome profile (one action per conversation; maintenance drains every page; shared 429 back-off and Send spacing), with optional health fields describing capacity. The companion still defaults to one page. Also recovers orphaned Windows Chrome, normalizes renderer priority, waits for editor hydration instead of treating a missing editor as sign-out, journals owned unsent drafts, bounds read-only DOM observations and passive health polls, persists Retry-After across restarts, and adds optional Simplified and Bones presentation levels. Upstream's admission, pairing-window reservation, chat-mode, conversation-limit and transcript rate-limit handling are kept. Co-Authored-By: Claude Opus 5.5 --- browser-companion/protocol-fixtures/v1.json | 18 +- browser-companion/protocol-schema-v1.json | 7 + .../scripts/bones-shell-smoke.ts | 107 +++ .../scripts/build-navigation-stall-patch.cjs | 168 +++++ .../scripts/build-runtime-patch.cjs | 28 + .../scripts/conversation-limit-smoke.ts | 38 ++ .../scripts/conversation-ready-smoke.ts | 272 ++++++++ browser-companion/scripts/deadline-smoke.ts | 51 ++ .../scripts/draft-recovery-smoke.ts | 65 ++ .../scripts/effort-control-smoke.ts | 34 + .../scripts/handoff-inspection-smoke.ts | 54 ++ .../scripts/health-readiness-smoke.ts | 27 + browser-companion/scripts/long-chat-smoke.ts | 164 +++++ .../scripts/long-session-smoke.ts | 46 ++ .../scripts/maintenance-observation-smoke.ts | 44 ++ .../scripts/minimal-page-smoke.ts | 232 +++++++ .../scripts/minimal-style-world-smoke.ts | 39 ++ .../scripts/model-selection-speed-smoke.ts | 59 ++ .../scripts/navigation-memory-smoke.ts | 60 ++ .../scripts/owner-agent-folders-smoke.cjs | 104 +++ .../scripts/owner-agents-ui-smoke.cjs | 159 +++++ .../scripts/owner-auto-mode-smoke.cjs | 65 ++ browser-companion/scripts/package-stage.cjs | 32 + browser-companion/scripts/package.cjs | 11 +- .../scripts/settings-readiness-smoke.ts | 44 ++ .../scripts/setup-capabilities-smoke.ts | 74 +++ browser-companion/scripts/smoke-package.cjs | 13 +- .../scripts/stop-recovery-smoke.ts | 125 ++++ .../scripts/test-setup-preparation.cjs | 104 +++ .../scripts/windows-orphan-smoke.ts | 52 ++ .../windows-session-persistence-smoke.cjs | 37 ++ browser-companion/src/account-settings.ts | 80 +-- browser-companion/src/account.ts | 7 +- browser-companion/src/approval-monitor.ts | 54 ++ browser-companion/src/background-chrome.ts | 5 +- browser-companion/src/browser-pool.ts | 183 ++++++ browser-companion/src/browser-session.ts | 66 +- browser-companion/src/browser-window.ts | 325 ++++++++-- browser-companion/src/chatgpt-driver.ts | 610 ++++++++++++------ browser-companion/src/chrome-surface.ts | 203 +++++- browser-companion/src/composer.ts | 68 +- browser-companion/src/connector-selection.ts | 68 +- browser-companion/src/control-server.ts | 22 +- .../src/conversation-approval.ts | 20 +- .../src/conversation-readiness.ts | 37 ++ browser-companion/src/draft-recovery.ts | 44 ++ browser-companion/src/editor-recovery.ts | 39 ++ browser-companion/src/errors.ts | 2 + browser-companion/src/main.ts | 34 +- browser-companion/src/minimal-page.ts | 295 +++++++++ browser-companion/src/model-selection.ts | 69 +- browser-companion/src/navigation.ts | 160 ++++- browser-companion/src/new-chat.ts | 13 +- browser-companion/src/observation.ts | 34 + browser-companion/src/pairing-model.ts | 34 +- browser-companion/src/presentation-style.ts | 25 + browser-companion/src/presentation.ts | 101 +++ browser-companion/src/protocol.ts | 9 +- browser-companion/src/renderer-memory.ts | 24 + browser-companion/src/response-observer.ts | 24 +- browser-companion/src/session.ts | 100 +-- browser-companion/src/shutdown.ts | 11 + browser-companion/src/stop-controls.ts | 40 ++ browser-companion/src/submission-network.ts | 62 ++ browser-companion/src/turn-binding.ts | 145 ++++- browser-companion/src/turn-renewal.ts | 38 +- .../src/windows-chrome-recovery.ts | 74 +++ browser-companion/src/windows-priority.ts | 85 +++ .../tests/account-settings.test.ts | 141 ++-- .../tests/approval-monitor.test.ts | 60 ++ .../tests/bones-settings-node.test.ts | 15 + browser-companion/tests/browser-pool.test.ts | 224 +++++++ .../tests/browser-simplicity.test.ts | 29 + .../tests/browser-window.test.ts | 397 +++++++++++- .../tests/chatgpt-driver.test.ts | 42 ++ .../tests/chrome-surface.test.ts | 294 ++++++++- browser-companion/tests/composer.test.ts | 52 +- .../tests/connector-cache.test.ts | 4 +- .../tests/connector-selection.test.ts | 17 +- .../tests/control-server.test.ts | 65 +- .../tests/conversation-approval.test.ts | 36 +- .../tests/conversation-limit-node.test.ts | 15 + .../tests/conversation-ready-node.test.ts | 15 + .../tests/draft-recovery-node.test.ts | 11 + .../tests/effort-control-node.test.ts | 15 + .../tests/handoff-draft-recovery.test.ts | 24 + .../tests/handoff-inspection-node.test.ts | 10 + .../tests/long-chat-node.test.ts | 29 + .../tests/long-session-node.test.ts | 20 + .../tests/navigation-memory-node.test.ts | 20 + browser-companion/tests/navigation.test.ts | 188 +++++- browser-companion/tests/observation.test.ts | 58 ++ browser-companion/tests/package-stage.test.ts | 49 ++ .../tests/pairing-submit.test.ts | 49 +- .../tests/patch-compatibility.test.ts | 14 + .../tests/presentation-style.test.ts | 23 + .../tests/renderer-memory.test.ts | 37 ++ .../tests/response-observer.test.ts | 12 + .../tests/retained-cleanup.test.ts | 94 +++ .../session-attention-cancellation.test.ts | 12 + browser-companion/tests/session.test.ts | 8 +- .../tests/settings-readiness-node.test.ts | 15 + .../tests/setup-capabilities-node.test.ts | 15 + browser-companion/tests/shutdown.test.ts | 20 + .../tests/stop-recovery-node.test.ts | 17 + .../tests/submission-lanes.test.ts | 59 ++ .../tests/submission-network.test.ts | 43 ++ browser-companion/tests/turn-binding.test.ts | 29 + browser-companion/tests/turn-renewal.test.ts | 7 +- .../tests/windows-chrome-recovery.test.ts | 39 ++ .../tests/windows-priority.test.ts | 83 +++ src/agent_automation/companion.rs | 137 +++- src/owner_chat.rs | 2 +- tests/agent_automation_companion.rs | 2 +- tests/agent_automation_supervisor.rs | 2 +- 115 files changed, 7376 insertions(+), 716 deletions(-) create mode 100644 browser-companion/scripts/bones-shell-smoke.ts create mode 100644 browser-companion/scripts/build-navigation-stall-patch.cjs create mode 100644 browser-companion/scripts/build-runtime-patch.cjs create mode 100644 browser-companion/scripts/conversation-limit-smoke.ts create mode 100644 browser-companion/scripts/conversation-ready-smoke.ts create mode 100644 browser-companion/scripts/deadline-smoke.ts create mode 100644 browser-companion/scripts/draft-recovery-smoke.ts create mode 100644 browser-companion/scripts/effort-control-smoke.ts create mode 100644 browser-companion/scripts/handoff-inspection-smoke.ts create mode 100644 browser-companion/scripts/health-readiness-smoke.ts create mode 100644 browser-companion/scripts/long-chat-smoke.ts create mode 100644 browser-companion/scripts/long-session-smoke.ts create mode 100644 browser-companion/scripts/maintenance-observation-smoke.ts create mode 100644 browser-companion/scripts/minimal-page-smoke.ts create mode 100644 browser-companion/scripts/minimal-style-world-smoke.ts create mode 100644 browser-companion/scripts/model-selection-speed-smoke.ts create mode 100644 browser-companion/scripts/navigation-memory-smoke.ts create mode 100644 browser-companion/scripts/owner-agent-folders-smoke.cjs create mode 100644 browser-companion/scripts/owner-agents-ui-smoke.cjs create mode 100644 browser-companion/scripts/owner-auto-mode-smoke.cjs create mode 100644 browser-companion/scripts/package-stage.cjs create mode 100644 browser-companion/scripts/settings-readiness-smoke.ts create mode 100644 browser-companion/scripts/setup-capabilities-smoke.ts create mode 100644 browser-companion/scripts/stop-recovery-smoke.ts create mode 100644 browser-companion/scripts/test-setup-preparation.cjs create mode 100644 browser-companion/scripts/windows-orphan-smoke.ts create mode 100644 browser-companion/scripts/windows-session-persistence-smoke.cjs create mode 100644 browser-companion/src/approval-monitor.ts create mode 100644 browser-companion/src/browser-pool.ts create mode 100644 browser-companion/src/conversation-readiness.ts create mode 100644 browser-companion/src/draft-recovery.ts create mode 100644 browser-companion/src/editor-recovery.ts create mode 100644 browser-companion/src/minimal-page.ts create mode 100644 browser-companion/src/observation.ts create mode 100644 browser-companion/src/presentation-style.ts create mode 100644 browser-companion/src/presentation.ts create mode 100644 browser-companion/src/renderer-memory.ts create mode 100644 browser-companion/src/shutdown.ts create mode 100644 browser-companion/src/stop-controls.ts create mode 100644 browser-companion/src/submission-network.ts create mode 100644 browser-companion/src/windows-chrome-recovery.ts create mode 100644 browser-companion/src/windows-priority.ts create mode 100644 browser-companion/tests/approval-monitor.test.ts create mode 100644 browser-companion/tests/bones-settings-node.test.ts create mode 100644 browser-companion/tests/browser-pool.test.ts create mode 100644 browser-companion/tests/browser-simplicity.test.ts create mode 100644 browser-companion/tests/conversation-limit-node.test.ts create mode 100644 browser-companion/tests/conversation-ready-node.test.ts create mode 100644 browser-companion/tests/draft-recovery-node.test.ts create mode 100644 browser-companion/tests/effort-control-node.test.ts create mode 100644 browser-companion/tests/handoff-draft-recovery.test.ts create mode 100644 browser-companion/tests/handoff-inspection-node.test.ts create mode 100644 browser-companion/tests/long-chat-node.test.ts create mode 100644 browser-companion/tests/long-session-node.test.ts create mode 100644 browser-companion/tests/navigation-memory-node.test.ts create mode 100644 browser-companion/tests/observation.test.ts create mode 100644 browser-companion/tests/package-stage.test.ts create mode 100644 browser-companion/tests/patch-compatibility.test.ts create mode 100644 browser-companion/tests/presentation-style.test.ts create mode 100644 browser-companion/tests/renderer-memory.test.ts create mode 100644 browser-companion/tests/retained-cleanup.test.ts create mode 100644 browser-companion/tests/session-attention-cancellation.test.ts create mode 100644 browser-companion/tests/settings-readiness-node.test.ts create mode 100644 browser-companion/tests/setup-capabilities-node.test.ts create mode 100644 browser-companion/tests/shutdown.test.ts create mode 100644 browser-companion/tests/stop-recovery-node.test.ts create mode 100644 browser-companion/tests/submission-lanes.test.ts create mode 100644 browser-companion/tests/submission-network.test.ts create mode 100644 browser-companion/tests/windows-chrome-recovery.test.ts create mode 100644 browser-companion/tests/windows-priority.test.ts diff --git a/browser-companion/protocol-fixtures/v1.json b/browser-companion/protocol-fixtures/v1.json index b30de789..d6b1a9e8 100644 --- a/browser-companion/protocol-fixtures/v1.json +++ b/browser-companion/protocol-fixtures/v1.json @@ -1,19 +1,19 @@ { - "schemaHash": "2f7852fd999d639b18e829bc273b2e38021c40c839ec0feb0ff2c793cfeb9e03", - "build": "fixture+protocol.2f7852fd999d639b18e829bc273b2e38021c40c839ec0feb0ff2c793cfeb9e03", + "schemaHash": "36957a042ed31cae11d0401411999119d39c22af98278a46b25c412b0be765c6", + "build": "fixture+protocol.36957a042ed31cae11d0401411999119d39c22af98278a46b25c412b0be765c6", "descriptor": { "version": 1, "kind": "codexify-browser-companion", "pid": 1234, "endpoint": "http://127.0.0.1:43123", "token": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "build": "fixture+protocol.2f7852fd999d639b18e829bc273b2e38021c40c839ec0feb0ff2c793cfeb9e03", + "build": "fixture+protocol.36957a042ed31cae11d0401411999119d39c22af98278a46b25c412b0be765c6", "createdAt": "2026-10-09T12:00:00Z" }, "health": { "protocolVersion": 1, "kind": "codexify-browser-companion", - "build": "fixture+protocol.2f7852fd999d639b18e829bc273b2e38021c40c839ec0feb0ff2c793cfeb9e03", + "build": "fixture+protocol.36957a042ed31cae11d0401411999119d39c22af98278a46b25c412b0be765c6", "status": "idle", "authenticated": true, "accountFingerprint": "fixture-account", @@ -21,7 +21,7 @@ }, "capabilities": { "protocolVersion": 1, - "build": "fixture+protocol.2f7852fd999d639b18e829bc273b2e38021c40c839ec0feb0ff2c793cfeb9e03", + "build": "fixture+protocol.36957a042ed31cae11d0401411999119d39c22af98278a46b25c412b0be765c6", "accountFingerprint": "fixture-account", "catalogRevision": "fixture-catalog", "models": [ @@ -50,7 +50,7 @@ }, "revealReceipt": { "protocolVersion": 1, - "build": "fixture+protocol.2f7852fd999d639b18e829bc273b2e38021c40c839ec0feb0ff2c793cfeb9e03", + "build": "fixture+protocol.36957a042ed31cae11d0401411999119d39c22af98278a46b25c412b0be765c6", "revealed": true, "activeActionId": null }, @@ -218,7 +218,7 @@ ], "actionSnapshot": { "protocolVersion": 1, - "build": "fixture+protocol.2f7852fd999d639b18e829bc273b2e38021c40c839ec0feb0ff2c793cfeb9e03", + "build": "fixture+protocol.36957a042ed31cae11d0401411999119d39c22af98278a46b25c412b0be765c6", "actionId": "fixture-action", "state": "generating", "sendAccepted": true, @@ -232,7 +232,7 @@ }, "waitingSnapshot": { "protocolVersion": 1, - "build": "fixture+protocol.2f7852fd999d639b18e829bc273b2e38021c40c839ec0feb0ff2c793cfeb9e03", + "build": "fixture+protocol.36957a042ed31cae11d0401411999119d39c22af98278a46b25c412b0be765c6", "actionId": "fixture-waiting", "state": "waiting", "sendAccepted": false, @@ -248,7 +248,7 @@ }, "cancelReceipt": { "protocolVersion": 1, - "build": "fixture+protocol.2f7852fd999d639b18e829bc273b2e38021c40c839ec0feb0ff2c793cfeb9e03", + "build": "fixture+protocol.36957a042ed31cae11d0401411999119d39c22af98278a46b25c412b0be765c6", "actionId": "fixture-action", "state": "ambiguous", "cancelled": true diff --git a/browser-companion/protocol-schema-v1.json b/browser-companion/protocol-schema-v1.json index 0dc135b6..0ecb3016 100644 --- a/browser-companion/protocol-schema-v1.json +++ b/browser-companion/protocol-schema-v1.json @@ -3,6 +3,13 @@ "kind": "codexify-browser-companion", "fieldNaming": "camelCase", "strictObjects": true, + "parallelActionPages": { + "actionCapacity": "optional integer 1..4; absent means one legacy page", + "availableActionSlots": "optional integer 0..4; capacity is not authentication authority", + "activeActionIds": "optional list of at most four opaque action IDs; accepted and uncertain actions retain page ownership", + "readyHealth": "GET /v1/health?ready=1 awaits fresh idle-page session proof; ordinary health remains bounded and passive", + "ordering": "one action per canonical conversation; maintenance drains all page lanes; global HTTP429 and actual Send spacing remain shared" + }, "buildBinding": "+protocol.", "endpoints": [ "GET /v1/health", diff --git a/browser-companion/scripts/bones-shell-smoke.ts b/browser-companion/scripts/bones-shell-smoke.ts new file mode 100644 index 00000000..e7434b2d --- /dev/null +++ b/browser-companion/scripts/bones-shell-smoke.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import {resolve} from "node:path"; +import {chromium} from "patchright"; +import {privateChromeExecutable} from "../src/chrome-executable"; +import {BONES_SHELL_CSS,installMinimalChatGptPage,styleRestoredChatGptPage,type BrowserSimplicity} from "../src/minimal-page"; +import {activeComposer,composerText,insertExactPrompt} from "../src/composer"; +import {readChatGptAccount} from "../src/account"; +import {inspectConnectorSettings,beginConnectorToolRefresh} from "../src/account-settings"; + +// A fresh, entirely routed fixture: never uses the live managed profile/account. +const executablePath=process.env.CODEXIFY_TEST_CHROME_PATH??await privateChromeExecutable(resolve(".browsers",`${process.platform}-${process.arch}`)); +const browser=await chromium.launch({executablePath,headless:true,timeout:20_000}); +const fixture=` + +

How can I help?

+
+ + +`; +const settingsFixture=`
+

Settings

+
+

Codexify

Manage app

Connector details

+ +
`; +try { + const context=await browser.newContext({viewport:{width:1280,height:800}}); + await context.route("**/*",route=>new URL(route.request().url()).pathname==='/api/auth/session' + ?route.fulfill({json:{user:{id:'fixture-user'},account:{id:'fixture-account',planType:'pro',structure:'personal'}}}) + :route.fulfill({contentType:"text/html",body:new URL(route.request().url()).pathname.startsWith('/settings')?settingsFixture:fixture})); + await installMinimalChatGptPage(context,BONES_SHELL_CSS,1,"bones"); + const page=await context.newPage();await page.goto("https://chatgpt.com/c/bones-shell-fixture"); + const css=async(selector:string,key:string)=>page.locator(selector).evaluate((el,property)=>getComputedStyle(el).getPropertyValue(property),key); + const visible=async(selector:string)=>assert.equal(await page.locator(selector).isVisible(),true,`${selector} must remain usable`); + const bones=async()=>{ + for(const selector of ["#modern-nav","#shell-header","#voice"])assert.equal(await page.locator(selector).isVisible(),false,`${selector} must be hidden in Bones`); + assert.equal(await css("#rail","width"),"0px"); + for(const selector of ["#profile","#new-chat"]){await visible(selector);assert.equal(await css(selector,"position"),"fixed");} + assert.equal(await css("#profile","bottom"),"6px");assert.equal(await css("#new-chat","top"),"6px"); + for(const selector of ["#portal-composer","#prompt-textarea"]){assert.equal(await css(selector,"border-radius"),"0px");assert.equal(await css(selector,"box-shadow"),"none");} + await visible("#legacy-header");await visible("#legacy-model"); + }; + await bones();assert.equal(await page.locator("#welcome").isVisible(),false); + await page.locator("#profile").click();await page.locator("#profile-action").click();await page.locator("#profile-menu").evaluate(el=>{(el as HTMLElement).hidden=true;}); + await page.locator("#new-chat").click();await page.locator("#legacy-model").click(); + assert.equal(await page.locator("#prompt-textarea").getAttribute("contenteditable"),"plaintext-only"); + assert.equal(await page.locator("main #prompt-textarea").count(),0,"Composer must really be in an external portal"); + for(const [trigger,choice,menu] of [["#apps","#app-choice","#apps-menu"],["#effort","#effort-choice","#effort-menu"]]){ + await page.locator(trigger!).click();await page.locator(choice!).click();await page.locator(menu!).evaluate(el=>{(el as HTMLElement).hidden=true;}); + } + const prompt="Exact Bones prompt\nwith approval and tool continuation"; + const composer=await activeComposer(page);await insertExactPrompt(composer,prompt);assert.equal(await composerText(composer),prompt); + await page.locator('[data-testid="send-button"]').click();await page.locator('[data-testid="stop-button"]').click(); + for(const selector of ["#answer-header","#answer-heading","#copy","#approval","#error"])await visible(selector); + await page.locator("#copy").click();await page.locator("#approve").click(); + const actions=await page.evaluate(()=>({clicks:JSON.parse(document.body.dataset.clicks??"{}"),sent:JSON.parse(document.body.dataset.sent??"[]")})); + assert.deepEqual(actions.sent,[prompt]); + for(const id of ["profile","profile-action","new-chat","legacy-model","apps","app-choice","effort","effort-choice","send","stop","copy","approve"])assert.equal(actions.clicks[id],1,`${id} must handle a real click`); + // Hydration and restored-tab styling must repair the same active mode. + await page.evaluate(()=>{const replacement=document.createElement("head");document.head.replaceWith(replacement);}); + await styleRestoredChatGptPage(page,BONES_SHELL_CSS,1,"bones");await bones();await visible("#answer-heading"); + let revision=2; + for(const level of ["simplified","off"] as BrowserSimplicity[]){ + await installMinimalChatGptPage(context,BONES_SHELL_CSS,revision,level);await styleRestoredChatGptPage(page,BONES_SHELL_CSS,revision++,level); + await page.goto(`https://chatgpt.com/c/${level}-shell-fixture`); + for(const selector of ["#rail","#modern-nav","#shell-header","#voice","#legacy-header"])await visible(selector); + assert.notEqual(await css("#rail","width"),"0px");assert.notEqual(await css("#profile","position"),"fixed"); + assert.notEqual(await css("#portal-composer","border-radius"),"0px","Bones must not leak into another mode"); + assert.equal(await page.locator("html").getAttribute("data-codexify-simplicity"),level); + } + await installMinimalChatGptPage(context,BONES_SHELL_CSS,revision,"bones");await styleRestoredChatGptPage(page,BONES_SHELL_CSS,revision,"bones"); + await page.goto("https://chatgpt.com/c/bones-navigation-fixture");await bones();assert.equal(await page.locator("#welcome").isVisible(),false); + // A portal composer can remain mounted while SPA settings replace MAIN. + // Route ownership, rather than generic headings or composer presence, must + // decide which shell chrome may be hidden. + await page.evaluate(()=>{document.body.dataset.chatMain=document.querySelector('main')!.innerHTML;history.pushState({},'', '/settings/plugins-settings/plugin_asdk_app_fixture');document.querySelector('main')!.innerHTML='

Codexify

';}); + for(const selector of ['#spa-settings-header','#spa-settings-title','#spa-refresh'])await visible(selector); + assert.equal(await page.locator('#prompt-textarea').count(),1,'The external composer intentionally remains during SPA settings'); + assert.equal(await page.locator('html').getAttribute('data-codexify-chat-page'),'false'); + await page.evaluate(()=>{history.replaceState({},'', '/c/spa-chat-return');document.querySelector('main')!.innerHTML=document.body.dataset.chatMain!;}); + await bones();assert.equal(await page.locator('#welcome').isVisible(),false); + await page.goto('https://chatgpt.com/g/g-p-fixture/project');await bones();assert.equal(await page.locator('#welcome').isVisible(),false); + const account=(await readChatGptAccount(page)).fingerprint; + const inspectedAt=performance.now();await inspectConnectorSettings(page,'asdk_app_fixture','Codexify',account);const settingsInspectionMs=performance.now()-inspectedAt; + const settingsVisible=async()=>{for(const selector of ['#settings-header','#settings-title','#settings-tabs','#connector-title','#manage-heading','#refresh','#settings-approval','#approval-header','#approval-heading'])await visible(selector);}; + await settingsVisible();assert.equal(await page.locator('html').getAttribute('data-codexify-chat-page'),'false'); + const refresh=await beginConnectorToolRefresh(page,'asdk_app_fixture','Codexify',account); + try {assert.equal(await refresh.wait(AbortSignal.timeout(2_000)),true);}finally{await refresh.dispose();} + assert.equal(await page.locator('body').getAttribute('data-refreshes'),'1','The real refresh helper must click the fixture control exactly once'); + await page.evaluate(()=>document.head.replaceWith(document.createElement('head'))); + await styleRestoredChatGptPage(page,BONES_SHELL_CSS,revision,'bones');await settingsVisible(); + for(const path of ['/settings/billing','/settings/plugins-settings/plugin_asdk_app_fixture/permissions','/unknown-maintenance-page']){ + await page.goto(`https://chatgpt.com${path}`); + if(path.startsWith('/settings'))await settingsVisible(); + else {await visible('#shell-header');await visible('#welcome');} + assert.equal(await page.locator('html').getAttribute('data-codexify-chat-page'),'false'); + } + console.log(JSON.stringify({ok:true,settingsInspectionMs,settingsRefreshClicks:1,checks:["modern shell hidden","profile/newchat fixed and clickable","legacy model control","portal plaintext composer","Apps/effort menus","exact Send","Stop/Copy/approval/error controls","answer headings","head replacement/restored style","Simplified/Off isolation with extra Bones CSS","navigation persistence","SPA push/replace route changes with retained composer","project chat welcome hidden","full-page plugin and billing headings preserved","actual settings inspection and Refresh helper","settings approvals preserved","unknown routes fail open"],scope:"Fresh routed Chrome fixture; no live profile/account"})); +} finally {await browser.close();} diff --git a/browser-companion/scripts/build-navigation-stall-patch.cjs b/browser-companion/scripts/build-navigation-stall-patch.cjs new file mode 100644 index 00000000..574dc657 --- /dev/null +++ b/browser-companion/scripts/build-navigation-stall-patch.cjs @@ -0,0 +1,168 @@ +// Patch navigation and idle memory maintenance, preserving unrelated bundle code. +const fs = require('node:fs'); +const path = require('node:path'); +const {createHash, randomUUID} = require('node:crypto'); +const ts = require('typescript'); +const asar = require('@electron/asar'); + +function namedFunction(text, filename, name) { + const tree = ts.createSourceFile(filename, text, ts.ScriptTarget.Latest, true); + const matches = tree.statements.filter(node => ts.isFunctionDeclaration(node) && node.name?.text === name); + if (matches.length !== 1) throw new Error('Expected exactly one implementation of ' + name); + const node = matches[0]; + return {start:node.getStart(tree), end:node.end, text:node.getText(tree)}; +} +const navigationFunction = (text, filename) => namedFunction(text, filename, 'navigatePage'); + +function variableInitializer(text, filename, name) { + const tree=ts.createSourceFile(filename,text,ts.ScriptTarget.Latest,true); + const matches=tree.statements.filter(ts.isVariableStatement) + .flatMap(node=>node.declarationList.declarations).filter(node=>node.name.getText(tree)===name); + if(matches.length!==1||!matches[0].initializer)throw new Error('Expected exactly one initializer for '+name); + const node=matches[0].initializer; + return {start:node.getStart(tree),end:node.end,text:node.getText(tree)}; +} + +function endActionMethod(text) { + const tree=ts.createSourceFile('surface.ts',text,ts.ScriptTarget.Latest,true); + const surface=tree.statements.find(node=>ts.isClassDeclaration(node)&&node.name?.text==='ChromeSingleWindowSurface'); + const method=surface?.members.find(node=>ts.isMethodDeclaration(node)&&node.name?.getText(tree)==='endAction'); + if(!method)throw new Error('Expected the managed surface endAction method'); + return {start:method.getStart(tree),end:method.end,text:method.getText(tree)}; +} + +function bundleMethod(text,className,name,required=true) { + const tree=ts.createSourceFile('coordinator.ts',text,ts.ScriptTarget.Latest,true); + const coordinator=tree.statements.find(node=>ts.isClassDeclaration(node)&&node.name?.text===className); + if(!coordinator)throw new Error('Expected the single-window coordinator'); + const method=coordinator.members.find(node=>(ts.isMethodDeclaration(node)||ts.isGetAccessor(node))&&node.name?.getText(tree)===name); + if(!method){if(required)throw new Error('Expected coordinator method '+name);return {start:coordinator.end-1,end:coordinator.end-1,text:''};} + return {start:method.getStart(tree),end:method.end,text:method.getText(tree)}; +} +const coordinatorMethod=(text,name,required=true)=>bundleMethod(text,'SingleWindowCoordinator',name,required); + +// A route transplanted from newer source must never call an API missing from +// the installed class. Syntax-only validation cannot catch this mismatch. +function assertRouteCoordinatorCompatibility(text) { + const tree=ts.createSourceFile('main.js',text,ts.ScriptTarget.Latest,true); + const coordinator=tree.statements.find(node=>ts.isClassDeclaration(node)&&node.name?.text==='SingleWindowCoordinator'); + const route=tree.statements.find(node=>ts.isFunctionDeclaration(node)&&node.name?.text==='route'); + if(!coordinator||!route)throw Error('Missing coordinator or route for compatibility verification'); + const members=new Set(coordinator.members.filter(node=>ts.isMethodDeclaration(node)||ts.isGetAccessor(node)).map(node=>node.name.getText(tree))); + const missing=new Set(); + function visit(node){ + if(ts.isCallExpression(node)&&ts.isPropertyAccessExpression(node.expression) + &&ts.isIdentifier(node.expression.expression)&&node.expression.expression.text==='coordinator' + &&!members.has(node.expression.name.text))missing.add(node.expression.name.text); + ts.forEachChild(node,visit); + } + visit(route); + if(missing.size)throw Error('Patched route requires missing coordinator methods: '+[...missing].join(', ')); +} + +async function main() { + const root = path.resolve(__dirname, '..'); + const version = fs.readFileSync(path.join(root, '../Cargo.toml'), 'utf8').match(/^version\s*=\s*"([^"]+)"/m)[1]; + const live = path.join(process.env.USERPROFILE, '.codexify/browser/runtime', version, 'app/resources/app.asar'); + const before = asar.extractFile(live, 'dist/main.js').toString(); + if(!/function stopControls\s*\(/.test(before))throw new Error('Installed runtime lacks the Stop control helper required by diagnostics'); + const original = navigationFunction(before, 'main.js'); + const source = navigationFunction(fs.readFileSync(path.join(root, 'src/navigation.ts'), 'utf8'), 'navigation.ts'); + const replacement = ts.transpileModule(source.text.replace(/^export\s+/, ''), { + compilerOptions:{target:ts.ScriptTarget.ES2022, module:ts.ModuleKind.ESNext}, + }).outputText.trim(); + let after = before.slice(0, original.start) + replacement + before.slice(original.end); + const oldRecovery=variableInitializer(after,'main.js','recoveryScript'); + const newRecovery=variableInitializer(fs.readFileSync(path.join(root,'src/windows-chrome-recovery.ts'),'utf8'),'windows-chrome-recovery.ts','recoveryScript'); + after=after.slice(0,oldRecovery.start)+newRecovery.text+after.slice(oldRecovery.end); + const oldDiagnostics = namedFunction(after, 'main.js', 'inspectPresentation'); + const diagnostics = namedFunction(fs.readFileSync(path.join(root, 'src/presentation.ts'), 'utf8'), 'presentation.ts', 'inspectPresentation'); + const diagnosticReplacement = ts.transpileModule(diagnostics.text.replace(/^export\s+/, ''), { + compilerOptions:{target:ts.ScriptTarget.ES2022, module:ts.ModuleKind.ESNext}, + }).outputText.trim(); + after = after.slice(0, oldDiagnostics.start) + diagnosticReplacement + after.slice(oldDiagnostics.end); + const oldEnd=endActionMethod(after); + const newEnd=endActionMethod(fs.readFileSync(path.join(root,'src/chrome-surface.ts'),'utf8')); + const wrapper=ts.transpileModule('class ChromeSingleWindowSurface {'+newEnd.text+'}',{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText; + after=after.slice(0,oldEnd.start)+endActionMethod(wrapper).text+after.slice(oldEnd.end); + const memory=ts.transpileModule(fs.readFileSync(path.join(root,'src/renderer-memory.ts'),'utf8'),{ + compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}, + }).outputText.replace(/^import .*;\r?\n/gm,'').replace(/^export /gm,''); + const startMarker='// BEGIN CODEXIFY IDLE MEMORY\n',endMarker='// END CODEXIFY IDLE MEMORY\n'; + const existingStart=after.indexOf(startMarker),existingEnd=after.indexOf(endMarker); + if(existingStart>=0){ + if(existingEnd=0){ + if(oldMonitorEnd createHash('sha256').update(bytes).digest('hex'); + const receipt = {version, live, candidate, previousSha256:hash(fs.readFileSync(live)), sha256:hash(fs.readFileSync(candidate)), + beforeBundleSha256:hash(before), bundleSha256:hash(after), changedFunctions:['navigatePage','recoveryScript','inspectPresentation','ChromeSingleWindowSurface.endAction','maintainRendererMemory','throwIfSessionAttention','ChatGptBrowserDriver.inspectHealth','ChatGptBrowserDriver.approvePendingConversation','route','approveConversationCard','readAutoApprovalPolicy','startAutoApprovalMonitor','main.startAutoApprovalMonitor',...coordinatorMethods.map(name=>'SingleWindowCoordinator.'+name)]}; + fs.writeFileSync(path.join(staging, 'receipt.json'), JSON.stringify(receipt, null, 2)); + fs.writeFileSync(path.join(root, 'artifacts/navigation-stall-ready.json'), JSON.stringify(receipt, null, 2)); + console.log(JSON.stringify(receipt)); +} + +if (require.main === module) main().catch(error => {console.error(error.message); process.exitCode = 1;}); +module.exports = {navigationFunction,assertRouteCoordinatorCompatibility}; diff --git a/browser-companion/scripts/build-runtime-patch.cjs b/browser-companion/scripts/build-runtime-patch.cjs new file mode 100644 index 00000000..218291df --- /dev/null +++ b/browser-companion/scripts/build-runtime-patch.cjs @@ -0,0 +1,28 @@ +// Rebuild only the companion app archive; Chrome and the profile stay separate. +const fs=require('node:fs'); +const path=require('node:path'); +const {createHash,randomUUID}=require('node:crypto'); +const asar=require('@electron/asar'); +const root=path.resolve(__dirname,'..'); +const version=fs.readFileSync(path.join(root,'../Cargo.toml'),'utf8').match(/^version\s*=\s*"([^"]+)"/m)[1]; +const resources=path.join(process.env.USERPROFILE,'.codexify/browser/runtime',version,'app/resources'); +const live=path.join(resources,'app.asar'); +const hash=bytes=>createHash('sha256').update(bytes).digest('hex'); +(async()=>{ + if(fs.existsSync(live+'.unpacked'))throw new Error('This patch requires an entirely packed JavaScript runtime'); + const staging=path.join(root,'artifacts','runtime-patch-'+randomUUID()); + fs.mkdirSync(staging,{recursive:true}); + const unpacked=path.join(staging,'application'); + asar.extractAll(live,unpacked); + const metadata=JSON.parse(fs.readFileSync(path.join(unpacked,'package.json'),'utf8')); + if(metadata.main!=='dist/main.js')throw new Error('Unexpected companion application entrypoint'); + const bundle=fs.readFileSync(path.join(root,'dist/main.js')); + fs.writeFileSync(path.join(unpacked,'dist/main.js'),bundle); + const candidate=path.join(staging,'app.asar'); + await asar.createPackage(unpacked,candidate); + if(hash(asar.extractFile(candidate,'dist/main.js'))!==hash(bundle))throw new Error('Patched runtime bundle verification failed'); + const receipt={version,candidate,previousSha256:hash(fs.readFileSync(live)),sha256:hash(fs.readFileSync(candidate)),bundleSha256:hash(bundle)}; + fs.writeFileSync(path.join(staging,'receipt.json'),JSON.stringify(receipt,null,2)); + fs.writeFileSync(path.join(root,'artifacts','runtime-patch-ready.json'),JSON.stringify(receipt,null,2)); + console.log(JSON.stringify({ok:true,...receipt})); +})().catch(error=>{console.error(error.message);process.exitCode=1;}); diff --git a/browser-companion/scripts/conversation-limit-smoke.ts b/browser-companion/scripts/conversation-limit-smoke.ts new file mode 100644 index 00000000..dacb3d89 --- /dev/null +++ b/browser-companion/scripts/conversation-limit-smoke.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import {mkdtemp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join,resolve} from 'node:path'; +import {chromium} from 'patchright'; +import {privateChromeExecutable} from '../src/chrome-executable'; +import {conversationUnavailableVisible,throwIfSessionAttention} from '../src/browser-session'; +import {CONVERSATION_UNAVAILABLE_MESSAGE} from '../src/errors'; +import {renderActionPrompt} from '../src/protocol'; + +const profile=await mkdtemp(join(tmpdir(),'codexify-length-limit-')); +const executablePath=await privateChromeExecutable(resolve('.browsers',`${process.platform}-${process.arch}`)); +const context=await chromium.launchPersistentContext(profile,{executablePath,headless:true}); +const page=context.pages()[0]!; +const banner=`
You've reached the maximum length for this conversation, but you can keep talking by starting a new chat.
`; +let body='';let newChatClicks=0; +try { + await page.exposeFunction('newChatClicked',()=>newChatClicks++); + await context.route('https://chatgpt.com/**',r=>r.fulfill({contentType:'text/html',body:`
${body}
`})); + const load=async(html:string,url='https://chatgpt.com/c/length-limit-fixture')=>{body=html;await page.goto(url);}; + const composer='
'; + await load(`
Previous activity
${banner}${composer}`); + assert.equal(await conversationUnavailableVisible(page),true); + await assert.rejects(throwIfSessionAttention(page),{code:'conversation_unavailable',message:CONVERSATION_UNAVAILABLE_MESSAGE}); + assert.equal(newChatClicks,0,'Detection must use guarded native task replacement, not click an unverified new-chat button'); + await page.locator('#prompt-textarea').fill('Keep my edited draft'); + assert.equal(await conversationUnavailableVisible(page),false); + assert.equal(await page.locator('#prompt-textarea').innerText(),'Keep my edited draft'); + await page.locator('#prompt-textarea').fill(renderActionPrompt({kind:'offline_goal',goalRevision:1})); + assert.equal(await conversationUnavailableVisible(page),true); + await load(`${composer}`);assert.equal(await conversationUnavailableVisible(page),false); + await load(`
A quoted example: ${banner}
${composer}`);assert.equal(await conversationUnavailableVisible(page),false); + await load(`
${banner}
${composer}`);assert.equal(await conversationUnavailableVisible(page),false); + await load(`${banner}
A tool approval requires your decision
${composer}`);assert.equal(await conversationUnavailableVisible(page),false); + await load(banner,'https://chatgpt.com/');assert.equal(await conversationUnavailableVisible(page),false); + console.log(JSON.stringify({ok:true,maximumLengthDetected:true,usesExistingGuardedReplacement:true,draftsAndApprovalsPreserved:true,quotedAndHiddenBannersIgnored:true})); +}finally{await context.close();const absolute=resolve(profile);if(!absolute.startsWith(resolve(tmpdir())+requireSeparator()+'codexify-length-limit-'))throw new Error('Unexpected test profile');await rm(absolute,{recursive:true,force:true,maxRetries:8,retryDelay:200});} +function requireSeparator(){return process.platform==='win32'?'\\':'/';} diff --git a/browser-companion/scripts/conversation-ready-smoke.ts b/browser-companion/scripts/conversation-ready-smoke.ts new file mode 100644 index 00000000..3eb3f71a --- /dev/null +++ b/browser-companion/scripts/conversation-ready-smoke.ts @@ -0,0 +1,272 @@ +import assert from "node:assert/strict"; +import {mkdtemp,rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join,resolve} from "node:path"; +import {chromium} from "patchright"; +import {privateChromeExecutable} from "../src/chrome-executable"; +import {waitForConversationComposer} from "../src/conversation-readiness"; +import {ChatGptBrowserDriver,type SingleBrowserSurface} from "../src/chatgpt-driver"; +import {SingleWindowCoordinator} from "../src/browser-window"; +import {fingerprintAccountIdentity} from "../src/account"; +import {activeComposer,composerText} from "../src/composer"; +import {selectConnector,ConnectorMenuUnavailableError} from "../src/connector-selection"; +import {refreshConversationEditor} from "../src/editor-recovery"; +import {navigatePage,withConversationCooldownBypass} from "../src/navigation"; +import {protocolBuild,type ExecuteActionRequest} from "../src/protocol"; +import {MINIMAL_CHATGPT_CSS} from "../src/minimal-page"; +import {openNewChat,assertNewChatDraft} from "../src/new-chat"; + +// Patchright's Chrome transport uses Node on Windows, matching the packaged app. +const profile=await mkdtemp(join(tmpdir(),"codexify-hydration-")); +const executablePath=process.env.CODEXIFY_TEST_CHROME_PATH + ?? await privateChromeExecutable(resolve(".browsers",`${process.platform}-${process.arch}`)); +const context=await chromium.launchPersistentContext(profile,{executablePath,headless:true,timeout:20_000}); +const page=context.pages()[0]!; +try { + const url="https://chatgpt.com/c/hydration-fixture"; + let signedOut=false,wrongAccount=false,mode="delayed",sends=0,documentLoads=0,submissionStatus=200,steers=0; + await page.exposeFunction("recordSend",()=>{sends++;}); + const composer=`

Fixture connector

`; + await context.route("https://chatgpt.com/**",async route=>{ + if(new URL(route.request().url()).pathname==="/backend-api/f/steer_turn") { + steers++;await route.fulfill({json:{steered:true}});return; + } + if(new URL(route.request().url()).pathname==="/backend-api/f/conversation") { + await route.fulfill({status:submissionStatus,json:{accepted:submissionStatus===200}});return; + } + if(new URL(route.request().url()).pathname==="/api/auth/session"){ + await route.fulfill({json:signedOut?{}:{user:{id:"fixture-user"},account:{id:wrongAccount?"other-account":"fixture-account",planType:"pro",structure:"personal"}}}); + return; + } + if(route.request().isNavigationRequest())documentLoads++; + if(mode==="hanging-navigation"&&route.request().isNavigationRequest()) { + await new Promise(resolve=>setTimeout(resolve,18_000)); + await route.abort().catch(()=>{});return; + } + await route.fulfill({contentType:"text/html",body:`
`}); + }); + const surface:SingleBrowserSurface={requireSubmissionNetworkProof:true,page:async()=>page,navigate:async(destination,signal)=>{await withConversationCooldownBypass(true,()=>navigatePage(page,destination,{signal}));return page;},reveal:async()=>{},beginAction:async()=>{},endAction:async()=>{},close:async()=>{}}; + const build=protocolBuild("hydration-smoke"); + const request:ExecuteActionRequest={actionId:"delayed",idempotencyKey:"delayed",pairingGeneration:1,url,expectedUrl:url, + expectedAccountFingerprint:fingerprintAccountIdentity("fixture-user","fixture-account"),connectorName:"Fixture connector",model:null,effort:null, + autoApproveToolCalls:false,prompt:{kind:"offline_goal",goalRevision:1}}; + // Match the real surface: it opens ChatGPT before publishing its control + // server. Do not manufacture authentication-required from about:blank. + await page.goto(url,{waitUntil:'domcontentloaded'}); + const coordinator=new SingleWindowCoordinator(new ChatGptBrowserDriver(surface,build),build); + await coordinator.execute(request); + const events=[];for await(const event of coordinator.events(request.actionId,0))events.push(event); + assert.equal(events.at(-1)?.type,"submission_complete",JSON.stringify(events)); + assert.equal(sends,1,"Hydrated conversation must receive exactly one continuation"); + await coordinator.close(); + + mode="delayed-no-connector";await page.goto("https://chatgpt.com/",{waitUntil:"domcontentloaded"}); + await waitForConversationComposer(page); + const started=performance.now(); + await assert.rejects(selectConnector(page,"Fixture connector"),ConnectorMenuUnavailableError); + assert.equal((await composerText(await activeComposer(page))).trim(),""); + const missingConnectorMs=Math.round(performance.now()-started); + assert(missingConnectorMs<20_000,"Missing optional connector must finish before the shared stall deadline"); + assert.equal(sends,1); + + mode="delayed-click-connector";await page.goto(url,{waitUntil:"domcontentloaded"});await waitForConversationComposer(page); + const connectorProgress:string[]=[]; + const clicked=await selectConnector(page,"Fixture connector",undefined,stage=>connectorProgress.push(stage));assert.equal(clicked.identity,"plugin:fixture"); + assert.deepEqual(connectorProgress,["query_inserted","menu_ready","app_selected"]); + assert.equal(sends,1,"Selecting an app must never submit the composer"); + + mode="delayed-no-connector";await page.goto(url,{waitUntil:"domcontentloaded"});await waitForConversationComposer(page); + const paired=new SingleWindowCoordinator(new ChatGptBrowserDriver(surface,build),build); + await paired.execute({...request,actionId:"paired-without-menu",idempotencyKey:"paired-without-menu"}); + const pairedEvents=[];for await(const event of paired.events("paired-without-menu",0))pairedEvents.push(event); + assert.equal(pairedEvents.at(-1)?.type,"submission_complete",JSON.stringify({events:pairedEvents,editor:await (await activeComposer(page)).evaluate(element=>element.innerHTML)})); + assert.equal(sends,2,"A paired conversation continues once without reopening its app menu"); + assert.equal(await page.locator('body').getAttribute('data-app-queries'),null,"Paired continuation must not type an app query"); + await paired.close(); + + mode="delayed";await page.goto(url,{waitUntil:"domcontentloaded"});await waitForConversationComposer(page); + const beforeRefresh=documentLoads,progress:string[]=[]; + await refreshConversationEditor(page,url,request.expectedAccountFingerprint,new AbortController().signal,step=>progress.push(step)); + assert.equal(documentLoads,beforeRefresh+1); + assert.deepEqual(progress,["started","document_loaded","ready"]); + await (await activeComposer(page)).fill("User draft"); + await assert.rejects(refreshConversationEditor(page,url,request.expectedAccountFingerprint,new AbortController().signal,()=>{}),{code:"external_interference"}); + assert.equal(documentLoads,beforeRefresh+1,"Recovery must preserve a user draft without reloading"); + assert.equal(await composerText(await activeComposer(page)),"User draft"); + + await page.goto("https://chatgpt.com/",{waitUntil:"domcontentloaded"}); + const stuckModel=new SingleWindowCoordinator(new ChatGptBrowserDriver(surface,build),build); + const beforeModel=documentLoads,modelStarted=performance.now(); + await stuckModel.execute({...request,actionId:"stuck-model",idempotencyKey:"stuck-model",model:"gpt-6-sol",effort:"high"}); + const modelEvents=[];for await(const event of stuckModel.events("stuck-model",0))modelEvents.push(event); + assert.equal(documentLoads,beforeModel+2,"Missing model control gets exactly one recovery refresh"); + assert.equal(modelEvents.at(-1)?.type,"failed",JSON.stringify(modelEvents)); + assert.equal((modelEvents.at(-1) as any).code,"model_unavailable"); + assert.equal((await stuckModel.action("stuck-model"))!.sendAccepted,false); + assert.equal((await stuckModel.health()).status,"idle"); + assert.equal(page.isClosed(),false,"A missing model control must not quit Chrome"); + const stuckModelMs=Math.round(performance.now()-modelStarted); + assert(stuckModelMs<25_000); + await stuckModel.execute({...request,actionId:"after-stuck-model",idempotencyKey:"after-stuck-model"}); + const resumed=[];for await(const event of stuckModel.events("after-stuck-model",0))resumed.push(event); + assert.equal(resumed.at(-1)?.type,"submission_complete",JSON.stringify(resumed)); + assert.equal(sends,3,"The same browser must accept the next continuation"); + await stuckModel.close(); + + mode="hanging-navigation"; + const hanging=new SingleWindowCoordinator(new ChatGptBrowserDriver(surface,build),build); + const loadStarted=performance.now(),hangingUrl="https://chatgpt.com/c/hanging-load"; + await hanging.execute({...request,actionId:"hanging-load",idempotencyKey:"hanging-load",url:hangingUrl,expectedUrl:hangingUrl}); + const loadEvents=[];for await(const event of hanging.events("hanging-load",0))loadEvents.push(event); + assert.equal(loadEvents.at(-1)?.type,"failed",JSON.stringify(loadEvents)); + assert.equal((await hanging.action("hanging-load"))!.sendAccepted,false); + assert(performance.now()-loadStarted<25_000,"A stalled load must finish before forced browser retirement"); + assert.equal(page.isClosed(),false); + mode="delayed";await page.goto(url,{waitUntil:"domcontentloaded"});await waitForConversationComposer(page); + assert.equal((await hanging.health()).status,"idle");await hanging.close(); + + // A leftover Temporary draft must never initialize a durable worker. + await page.goto('https://chatgpt.com/?temporary-chat=true',{waitUntil:'domcontentloaded'});await waitForConversationComposer(page); + await assert.rejects(assertNewChatDraft(page,null),{code:'conversation_unavailable'}); + await openNewChat(surface,null); + assert.equal(page.url(),'https://chatgpt.com/','Worker creation must leave a Temporary draft for a saved chat'); + // A new draft has no persistent conversation ID; refresh its real destination. + const projectUrl="https://chatgpt.com/g/g-p-0123456789abcdef0123456789abcdef/project"; + await page.goto(projectUrl,{waitUntil:"domcontentloaded"}); await waitForConversationComposer(page); + const beforeDraft=documentLoads; + await refreshConversationEditor(page,"https://chatgpt.com/c/local-draft",request.expectedAccountFingerprint,new AbortController().signal,()=>{},{projectUrl}); + assert.equal(page.url(),projectUrl); + assert.equal(documentLoads,beforeDraft+1); + assert.equal(sends,3,"Refreshing a new draft must not submit initialization"); + + // A signed-in shell that never renders is an editor failure, never sign-in. + mode="missing";await page.goto(url,{waitUntil:"domcontentloaded"}); + await assert.rejects(waitForConversationComposer(page,undefined,120),{code:"editor_unavailable"}); + const abort=new AbortController();setTimeout(()=>abort.abort(),50); + await assert.rejects(waitForConversationComposer(page,abort.signal,2_000),{name:"AbortError"}); + mode="login";signedOut=true;await page.goto(url,{waitUntil:"domcontentloaded"}); + await assert.rejects(waitForConversationComposer(page),{code:"authentication_required"}); + + // Hydration cannot bypass the separate, fresh account fingerprint check. + mode="delayed";signedOut=false;wrongAccount=true; + await page.goto("https://chatgpt.com/",{waitUntil:"domcontentloaded"}); + const other=new SingleWindowCoordinator(new ChatGptBrowserDriver(surface,build),build); + await assert.rejects(other.execute({...request,actionId:"wrong-account",idempotencyKey:"wrong-account"}),/account changed/); + assert.equal(sends,3,"Account mismatch must not send another continuation"); + await other.close(); + wrongAccount=false;mode='delayed-end-turn';await page.goto(url,{waitUntil:'domcontentloaded'});await waitForConversationComposer(page); + await page.addStyleTag({content:MINIMAL_CHATGPT_CSS}); + assert.equal(await page.getByTestId('end-turn-button').isVisible(),true,'Compact styling must keep End turn visible'); + await page.getByTestId('end-turn-button').evaluate(element=>{element.removeAttribute('data-testid');element.removeAttribute('aria-label');}); + const endTurn=new SingleWindowCoordinator(new ChatGptBrowserDriver(surface,build),build); + await endTurn.execute({...request,actionId:'end-then-continue',idempotencyKey:'end-then-continue'}); + const ended=[];for await(const event of endTurn.events('end-then-continue',0))ended.push(event); + assert.equal(ended.at(-1)?.type,'submission_complete',JSON.stringify(ended)); + assert.equal(await page.locator('body').getAttribute('data-end-requests'),'1'); + assert.equal(steers,0,'A stalled turn must end before sending a new continuation'); + assert.equal(sends,4);await endTurn.close(); + mode='delayed-hidden-running';await page.goto(url,{waitUntil:'domcontentloaded'});await waitForConversationComposer(page); + const staleTurn=new SingleWindowCoordinator(new ChatGptBrowserDriver(surface,build),build); + const beforeSteer=sends,beforeSteerLoads=documentLoads; + await staleTurn.execute({...request,actionId:'hidden-old-turn',idempotencyKey:'hidden-old-turn'}); + const steered=[];for await(const event of staleTurn.events('hidden-old-turn',0))steered.push(event); + assert.equal(steered.at(-1)?.type,'ambiguous_send',JSON.stringify(steered)); + assert.equal(steered.some(event=>event.type==='send_accepted'),false); + assert.equal(steers,1);assert.equal(sends,beforeSteer+1,'Never replay the uncertain send'); + assert.equal(documentLoads,beforeSteerLoads,'Never reload after the uncertain send'); + assert.equal(await page.locator('body').getAttribute('data-stop-requests'),'1','Wait for the newly exposed disabled Stop before ending the old turn'); + await staleTurn.execute({...request,actionId:'fresh-after-steer',idempotencyKey:'fresh-after-steer'}); + const fresh=[];for await(const event of staleTurn.events('fresh-after-steer',0))fresh.push(event); + assert.equal(fresh.at(-1)?.type,'submission_complete',JSON.stringify(fresh)); + assert.equal(steers,1,'The next distinct goal continuation must start a fresh turn'); + assert.equal(sends,beforeSteer+2);await staleTurn.close(); + mode='delayed';await page.goto(url,{waitUntil:'domcontentloaded'});await waitForConversationComposer(page); + let releaseLate!:()=>void,lateEntered=false,lateClosed=0; + const lateGate=new Promise(resolve=>{releaseLate=resolve;}); + const lateSurface:SingleBrowserSurface={...surface,navigate:async destination=>{ + lateEntered=true;await lateGate; + // Reproduce a browser transport that finishes its navigation after abort. + await page.goto(destination,{waitUntil:'domcontentloaded'});return page; + },close:async()=>{lateClosed++;}}; + const late=new SingleWindowCoordinator(new ChatGptBrowserDriver(lateSurface,build),build,{operationMs:1000,cleanupMs:100,hardStopMs:100}); + const lateUrl='https://chatgpt.com/c/late-cancelled-load',beforeLate=sends; + await late.execute({...request,actionId:'late-load',idempotencyKey:'late-load',url:lateUrl,expectedUrl:lateUrl}); + const lateEvents=[];for await(const event of late.events('late-load',0))lateEvents.push(event); + assert(lateEntered);assert.equal((lateEvents.at(-1) as any).code,'conversation_unavailable'); + assert.equal(lateClosed,0);assert.equal(page.isClosed(),false);assert.equal(sends,beforeLate); + assert.equal((await late.health()).activeActionId,'late-load');assert.equal((await late.liveness()).available,true); + await assert.rejects(late.execute({...request,actionId:'while-late-load',idempotencyKey:'while-late-load'}),/still owns/); + releaseLate(); + const cleanupDeadline=Date.now()+5000; + while((await late.health()).activeActionId&&Date.now()setTimeout(resolve,50)); + assert.equal((await late.health()).activeActionId,null);assert.equal(sends,beforeLate,'Aborted late navigation cannot submit'); + assert.equal(lateClosed,0);await late.close();assert.equal(lateClosed,1); + const sameBrowser=new SingleWindowCoordinator(new ChatGptBrowserDriver(surface,build),build); + await sameBrowser.execute({...request,actionId:'after-late-navigation',idempotencyKey:'after-late-navigation'}); + const afterLate=[];for await(const event of sameBrowser.events('after-late-navigation',0))afterLate.push(event); + assert.equal(afterLate.at(-1)?.type,'submission_complete',JSON.stringify(afterLate));assert.equal(sends,beforeLate+1); + await sameBrowser.close(); + mode='delayed';submissionStatus=429;await page.goto(url,{waitUntil:'domcontentloaded'}); + const optimistic=new SingleWindowCoordinator(new ChatGptBrowserDriver(surface,build),build); + await optimistic.execute({...request,actionId:'rejected-optimistic',idempotencyKey:'rejected-optimistic'}); + const rejected=[];for await(const event of optimistic.events('rejected-optimistic',0))rejected.push(event); + assert.equal(rejected.at(-1)?.type,'ambiguous_send',JSON.stringify(rejected)); + assert.equal(rejected.some(event=>event.type==='send_accepted'),false,'An optimistic bubble is not server acceptance'); + assert.equal((await optimistic.health()).status,'idle','A server cooldown must preserve the signed-in browser'); + assert.equal(page.isClosed(),false);await optimistic.close(); + console.log(JSON.stringify({ok:true,lateNavigationRetainsBrowserAndLease:true,sameBrowserContinuationAfterLateCleanup:true,serverProofRejectsOptimisticBubble:true,pairedContinuationSkipsAppMenu:true,delayedComposerAcceptedOnce:true,missingEditorClassified:true,abortRespected:true,wrongAccountRejected:true,missingConnectorMs,stuckModelMs,refreshPreservesDraft:true,missingModelKeepsBrowserOpen:true,newDraftRefreshPreservesProject:true,stalledNavigationKeepsBrowserOpen:true})); +} finally { + console.error("fixture cleanup: releasing routes"); + await context.unrouteAll({behavior:"ignoreErrors"}); + console.error("fixture cleanup: closing Chrome"); + const closingSession=await context.newCDPSession(page).catch(()=>null); + if(closingSession)await closingSession.send("Browser.close").catch(()=>{}); + await context.close(); + console.error("fixture cleanup: removing profile"); + await rm(profile,{recursive:true,force:true,maxRetries:8,retryDelay:200}); + console.error("fixture cleanup: complete"); +} diff --git a/browser-companion/scripts/deadline-smoke.ts b/browser-companion/scripts/deadline-smoke.ts new file mode 100644 index 00000000..35273bea --- /dev/null +++ b/browser-companion/scripts/deadline-smoke.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import {SingleWindowCoordinator, type BrowserDriver} from "../src/browser-window"; +import {protocolBuild, type ExecuteActionRequest} from "../src/protocol"; + +const build = protocolBuild("deadline-smoke"); +const watchdog = setTimeout(() => { console.error("Browser deadline did not release ownership"); process.exit(1); }, 5_000); +try { + for (const phase of ["before-send", "attempted", "accepted"] as const) { + let attempted = false; + const driver: BrowserDriver = { + async health() { return {authenticated:true, accountFingerprint:"account-1", status:"idle", detail:null}; }, + async login() { throw new Error("Unexpected login"); }, + async capabilities() { throw new Error("Unexpected capabilities"); }, + async reveal() {}, + submissionMayHaveBeenAccepted() { return attempted; }, + async execute({request, signal, emit}) { + emit({type:"model_verified", model:"gpt-6-sol", effort:"high"}); + emit({type:"connector_verified"}); + if (request.actionId === "next") { + emit({type:"send_accepted"}); + emit({type:"submission_complete", canonicalUrl:request.expectedUrl}); + return; + } + attempted = phase !== "before-send"; + if (phase === "accepted") emit({type:"send_accepted"}); + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), {once:true}); + }); + }, + }; + const coordinator = new SingleWindowCoordinator(driver, build, {operationMs:60, cleanupMs:40, hardStopMs:40}); + const request: ExecuteActionRequest = { + actionId:"stalled", idempotencyKey:"stalled-key", pairingGeneration:1, + url:"https://chatgpt.com/c/fixture", expectedUrl:"https://chatgpt.com/c/fixture", + expectedAccountFingerprint:"account-1", connectorName:"Codexify", + model:"gpt-6-sol", effort:"high", autoApproveToolCalls:false, + prompt:{kind:"offline_goal", goalRevision:1}, + }; + await coordinator.execute(request); + const events = []; + for await (const event of coordinator.events(request.actionId, 0)) events.push(event); + assert.equal(events.at(-1)?.type, phase === "before-send" ? "failed" : "ambiguous_send"); + assert.equal((await coordinator.health()).activeActionId, null); + await coordinator.execute({...request, actionId:"next", idempotencyKey:"next-key"}); + const next = []; + for await (const event of coordinator.events("next", 0)) next.push(event); + assert.equal(next.at(-1)?.type, "submission_complete"); + await coordinator.close(); + } + console.log(JSON.stringify({ok:true, phases:3})); +} finally { clearTimeout(watchdog); } diff --git a/browser-companion/scripts/draft-recovery-smoke.ts b/browser-companion/scripts/draft-recovery-smoke.ts new file mode 100644 index 00000000..9560bd4b --- /dev/null +++ b/browser-companion/scripts/draft-recovery-smoke.ts @@ -0,0 +1,65 @@ +process.env.CODEXIFY_BROWSER_RECOVERY_ENHANCEMENTS="1"; +import assert from 'node:assert/strict'; +import {mkdtemp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join,resolve} from 'node:path'; +import {chromium} from 'patchright'; +import {privateChromeExecutable} from '../src/chrome-executable'; +import {ChatGptBrowserDriver,type SingleBrowserSurface} from '../src/chatgpt-driver'; +import {fingerprintAccountIdentity} from '../src/account'; +import {protocolBuild,renderActionPrompt,type ExecuteActionRequest} from '../src/protocol'; +import {activeComposer,composerText} from '../src/composer'; +import {DraftRecoveryJournal} from '../src/draft-recovery'; + +const profile=await mkdtemp(join(tmpdir(),'codexify-draft-recovery-')); +const executablePath=await privateChromeExecutable(resolve('.browsers',`${process.platform}-${process.arch}`)); +const context=await chromium.launchPersistentContext(profile,{executablePath,headless:true}); +const page=context.pages()[0]!,url='https://chatgpt.com/c/draft-recovery-fixture'; +let draft='',sends=0,stops=0; +const prompt=renderActionPrompt({kind:'offline_goal',goalRevision:1}); +try { + await page.exposeFunction('recordSend',()=>sends++);await page.exposeFunction('recordStop',()=>stops++); + await context.route('https://chatgpt.com/**',route=>{ + const path=new URL(route.request().url()).pathname; + if(path==='/api/auth/session')return route.fulfill({json:{user:{id:'draft-user'},account:{id:'draft-account',structure:'personal',planType:'pro'}}}); + if(path.endsWith('/conversation'))return route.fulfill({json:{accepted:true}}); + return route.fulfill({contentType:'text/html',body:`
+


+ `}); + }); + await page.goto(url); + await (await activeComposer(page)).evaluate(e=>e.innerHTML='

cached text\u200b

'); + assert.equal(await composerText(await activeComposer(page)),'','hidden editor markup is not a draft'); + const request:ExecuteActionRequest={actionId:'draft-recovery',idempotencyKey:'draft-recovery',url,expectedUrl:url,pairingGeneration:1,expectedAccountFingerprint:fingerprintAccountIdentity('draft-user','draft-account'),connectorName:'Fixture connector',model:null,effort:null,autoApproveToolCalls:false,prompt:{kind:'offline_goal',goalRevision:1}}; + const surface:SingleBrowserSurface={requireSubmissionNetworkProof:true,page:async()=>page,navigate:async u=>{await page.goto(u);return page;},reveal:async()=>{},beginAction:async()=>{},endAction:async()=>{},close:async()=>{}}; + for(const text of [prompt,prompt.replace(/\n/g,'\n\n'),'@Fixture connector']){ + draft=text;await page.goto(url);const before=sends; + const stopped=stops; + await assert.rejects(new ChatGptBrowserDriver(surface,protocolBuild('draft-recovery'),undefined,false).execute({request,renderedPrompt:prompt,signal:AbortSignal.timeout(10000),emit:()=>{}}),{code:'external_interference'}); + assert.equal(await composerText(await activeComposer(page)),text,'disabled recovery preserves stale drafts'); + assert.equal(sends,before);assert.equal(stops,stopped); + await new ChatGptBrowserDriver(surface,protocolBuild('draft-recovery')).execute({request,renderedPrompt:prompt,signal:AbortSignal.timeout(25000),emit:()=>{}}); + assert.equal(sends,before+1);assert.equal(await composerText(await activeComposer(page)),''); + } + for(const text of ['My actual draft',prompt+' edited by me']){ + draft=text;await page.goto(url);const before=sends,stopped=stops; + await assert.rejects(new ChatGptBrowserDriver(surface,protocolBuild('draft-recovery')).execute({request,renderedPrompt:prompt,signal:AbortSignal.timeout(10000),emit:()=>{}}),{code:'external_interference'}); + assert.equal(await composerText(await activeComposer(page)),text);assert.equal(sends,before);assert.equal(stops,stopped); + } + const cachePath=join(profile,'connector-cache.json'),journalPath=cachePath+'.drafts.json',account=request.expectedAccountFingerprint; + const combined:ExecuteActionRequest={...request,actionId:'new-retry',prompt:{kind:'offline_combined',goalRevision:1,batchId:'batch',messages:[{id:'one',markdown:'Work on the next verified task'}]}}; + const combinedPrompt=renderActionPrompt(combined.prompt),journal=new DraftRecoveryJournal(journalPath); + journal.prepare(account,url,'interrupted-before-send',combinedPrompt);draft=combinedPrompt;await page.goto(url);const before=sends; + await new ChatGptBrowserDriver(surface,protocolBuild('draft-recovery'),cachePath).execute({request:combined,renderedPrompt:combinedPrompt,signal:AbortSignal.timeout(25000),emit:()=>{}}); + assert.equal(sends,before+1,'a new driver recovers the recorded unsent draft once'); + journal.prepare(account,url,'uncertain-send',combinedPrompt);journal.beforeSend('uncertain-send');draft=combinedPrompt;await page.goto(url); + await assert.rejects(new ChatGptBrowserDriver(surface,protocolBuild('draft-recovery'),cachePath).execute({request:combined,renderedPrompt:combinedPrompt,signal:AbortSignal.timeout(10000),emit:()=>{}}),{code:'external_interference'}); + assert.equal(sends,before+1,'an uncertain send with user messages must not be replayed'); + assert.equal(await composerText(await activeComposer(page)),combinedPrompt); + console.log(JSON.stringify({ok:true,invisibleMarkupIgnored:true,staleAutomationDraftRecovered:true,editedDraftPreserved:true,sends})); +} finally {await context.unrouteAll({behavior:'ignoreErrors'});await context.close();await rm(profile,{recursive:true,force:true,maxRetries:5,retryDelay:250});} diff --git a/browser-companion/scripts/effort-control-smoke.ts b/browser-companion/scripts/effort-control-smoke.ts new file mode 100644 index 00000000..bba95075 --- /dev/null +++ b/browser-companion/scripts/effort-control-smoke.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import {mkdtemp,rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join,resolve} from "node:path"; +import {chromium} from "patchright"; +import {privateChromeExecutable} from "../src/chrome-executable"; +import {activateChatGptEffortMenu,CHATGPT_EFFORT_CONTROL_SELECTOR} from "../src/session"; + +const profile=await mkdtemp(join(tmpdir(),"codexify-effort-control-")); +const executablePath=await privateChromeExecutable(resolve(".browsers",`${process.platform}-${process.arch}`)); +const context=await chromium.launchPersistentContext(profile,{executablePath,headless:true,timeout:20_000}); +try { + const page=context.pages()[0]!; + await page.route("https://chatgpt.com/**",route=>route.fulfill({contentType:"text/html",body:` +
+ + `})); + await page.goto("https://chatgpt.com/",{waitUntil:"domcontentloaded"}); + const control=page.locator(CHATGPT_EFFORT_CONTROL_SELECTOR); + let timeout:ReturnType|undefined; + const started=performance.now(); + const activated=await Promise.race([activateChatGptEffortMenu(page,control,{settleMs:500}),new Promise((_yes,no)=>{timeout=setTimeout(()=>no(new Error("Model control style change stalled the selector instead of opening its owned picker")),3_000);})]).finally(()=>clearTimeout(timeout)); + assert.equal(activated.method,"click"); + assert.equal(await page.locator('button[data-tone="neutral"]').count(),0); + assert.equal(await control.count(),1,"The model control must remain addressable while open"); + assert(performance.now()-started<3_000); + const aborted=new AbortController();aborted.abort(); + await assert.rejects(activateChatGptEffortMenu(page,control,{signal:aborted.signal}),{name:"AbortError"}); + assert.equal(await page.locator('body').getAttribute('data-clicks'),'1'); + console.log(JSON.stringify({ok:true,styleChangePreservesOwnedControl:true,abortedActivationDoesNotClick:true})); +} finally { + await context.close(); + await rm(profile,{recursive:true,force:true,maxRetries:8,retryDelay:200}); +} diff --git a/browser-companion/scripts/handoff-inspection-smoke.ts b/browser-companion/scripts/handoff-inspection-smoke.ts new file mode 100644 index 00000000..0a126da9 --- /dev/null +++ b/browser-companion/scripts/handoff-inspection-smoke.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import {mkdtemp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join,resolve} from 'node:path'; +import {chromium} from 'patchright'; +import {privateChromeExecutable} from '../src/chrome-executable'; +import {ChatGptBrowserDriver,type SingleBrowserSurface} from '../src/chatgpt-driver'; +import {fingerprintAccountIdentity} from '../src/account'; +import {protocolBuild} from '../src/protocol'; +import {createCompletedHandoffVerifier} from '../src/turn-renewal'; +const profile=await mkdtemp(join(tmpdir(),'codexify-handoff-inspection-')); +const context=await chromium.launchPersistentContext(profile,{executablePath:process.env.CODEXIFY_TEST_CHROME_PATH??await privateChromeExecutable(resolve('.browsers',`${process.platform}-${process.arch}`)),headless:true}); +const page=context.pages()[0]!,url='https://chatgpt.com/c/handoff-inspection',account=fingerprintAccountIdentity('probe-user','probe-account'),id=`renew-${'a'.repeat(64)}`; +let accountId='probe-account',loads=0,accountReads=0; +try { + await context.route('https://chatgpt.com/**',route=>{ + if(new URL(route.request().url()).pathname==='/api/auth/session'){accountReads++;return route.fulfill({json:{user:{id:'probe-user'},account:{id:accountId,structure:'personal',planType:'pro'}}});} + return route.fulfill({contentType:'text/html',body:'
'}); + }); + const surface:SingleBrowserSurface={page:async()=>page,navigate:async u=>{loads++;await page.goto(u);return page;},reveal:async()=>{},beginAction:async()=>{},endAction:async()=>{},close:async()=>{}}; + const driver=new ChatGptBrowserDriver(surface,protocolBuild('handoff-inspection')); + await page.goto(url);assert.equal(await driver.turnHandoffComplete(url,account,id),false,'a real incomplete response is a negative probe'); + accountId='different-account';await assert.rejects(driver.turnHandoffComplete(url,account,id),{code:'authentication_required'});accountId='probe-account'; + await page.goto('https://chatgpt.com/c/other');assert.equal(await driver.turnHandoffComplete(url,account,id),false);assert.equal(loads,1); + await page.goto('https://chatgpt.com/c/other');await assert.rejects(driver.turnHandoffComplete(url,account,id),{code:'editor_unavailable'});assert.equal(loads,1,'a skipped visit cannot masquerade as a real negative probe'); + const recheckStartedAt=performance.now(); + await new Promise(resolve=>setTimeout(resolve,1_010)); + assert.equal(await driver.turnHandoffComplete(url,account,id),false); + assert.equal(loads,2,'a healthy pending handoff can be revisited after one second'); + const offTabRecheckMs=performance.now()-recheckStartedAt; + await page.goto(url); + await page.evaluate(id=>{ + document.querySelector('[data-testid="stop-button"]')!.remove(); + const turn=document.createElement('article');turn.dataset.turnId='final-handoff';turn.dataset.turnIdContainer='final-handoff';turn.dataset.testid='conversation-turn-1';turn.dataset.turn='assistant'; + turn.innerHTML='

Verified implementation and next steps.

'; + turn.querySelector('p')!.textContent=`Turn handoff ${id}`;document.body.append(turn); + },id); + const verify=createCompletedHandoffVerifier(page,url,account,id); + const before=accountReads,started=performance.now();assert.equal(await verify(),true); + const initialProofMs=performance.now()-started; + assert.equal(accountReads-before,2,'Initial proof requires two fresh account checks and stable snapshots'); + const currentBefore=accountReads,recheckStarted=performance.now();assert.equal(await verify(),true); + const unchangedRecheckMs=performance.now()-recheckStarted; + assert.equal(accountReads-currentBefore,1,'An unchanged proof still requires fresh current account identity'); + await page.locator('.markdown p').last().evaluate(el=>{el.textContent='Changed after verification';}); + assert.equal(await verify(),false,'Changed handoff text must invalidate the action-local proof'); + await page.locator('.markdown p').last().evaluate(el=>{el.textContent='Verified implementation and next steps.';}); + assert.equal(await verify(),true); + await page.evaluate(()=>{const stop=document.createElement('button');stop.dataset.testid='stop-button';stop.textContent='Stop';document.body.append(stop);}); + assert.equal(await verify(),false,'A resumed turn cannot reuse a completed handoff'); + await page.locator('[data-testid="stop-button"]').evaluate(el=>el.remove()); + accountId='different-account';assert.equal(await verify(),false,'Cached handoff proof must never cache account authorization');accountId='probe-account'; + console.log(JSON.stringify({ok:true,realNegativeProbe:true,accountChangesBlocked:true,skippedProbeIsNotFailure:true,offTabRecheckMs,initialProofMs,unchangedRecheckMs,initialAccountReads:2,recheckAccountReads:1,changedReportBlocked:true,runningTurnBlocked:true})); +} finally {await context.unrouteAll({behavior:'ignoreErrors'});await context.close();await rm(profile,{recursive:true,force:true,maxRetries:5,retryDelay:250});} diff --git a/browser-companion/scripts/health-readiness-smoke.ts b/browser-companion/scripts/health-readiness-smoke.ts new file mode 100644 index 00000000..1b13057c --- /dev/null +++ b/browser-companion/scripts/health-readiness-smoke.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import {chromium,type Page} from 'patchright'; +import {ChatGptBrowserDriver,type SingleBrowserSurface} from '../src/chatgpt-driver'; +import {protocolBuild} from '../src/protocol'; + +const browser=await chromium.launch({executablePath:process.env.CODEXIFY_TEST_CHROME_PATH,headless:true}); +try { + const context=await browser.newContext();const page=await context.newPage(); + let signedOut=false; + await context.route('https://chatgpt.com/**',route=>new URL(route.request().url()).pathname==='/api/auth/session' + ?route.fulfill({json:signedOut?{}:{user:{id:'fixture-user'},account:{id:'fixture-account',planType:'pro',structure:'personal'}}}) + :route.fulfill({contentType:'text/html',body:'
Preserved draft
'})); + const surface:SingleBrowserSurface={page:async()=>page,navigate:async url=>{await page.goto(url);return page;},beginAction:async()=>{},endAction:async()=>{},reveal:async()=>{}}; + await page.goto('https://chatgpt.com/c/health-hydration'); + const driver=new ChatGptBrowserDriver(surface,protocolBuild('test-build')); + const started=performance.now();const health=await driver.health();const elapsedMs=performance.now()-started; + assert.equal(health.authenticated,true);assert.equal(health.status,'idle','An authenticated hydrating editor must not make the browser unavailable'); + assert(elapsedMs<1000,'Passive health must not wait for editor readiness'); + assert.equal(await page.locator('#prompt-textarea').innerText(),'Preserved draft'); + await page.evaluate(()=>{const first=document.querySelector('#prompt-textarea')!;first.setAttribute('contenteditable','true');first.parentElement!.append(first.cloneNode(true));}); + assert.equal((await driver.health()).status,'idle','Duplicate transient editors are an action-readiness concern'); + signedOut=true;await page.goto('https://chatgpt.com/auth/login'); + assert.equal((await driver.health()).status,'authentication_required','Real sign-out still requires authentication'); + await page.close(); + assert.equal((await driver.health()).status,'unavailable','A real closed page stays unavailable'); + console.log(JSON.stringify({ok:true,healthMs:elapsedMs,checks:['disabled hydration','duplicate hydration','draft preserved','real sign-out','closed page']})); +} finally {await browser.close();} diff --git a/browser-companion/scripts/long-chat-smoke.ts b/browser-companion/scripts/long-chat-smoke.ts new file mode 100644 index 00000000..6356d8c6 --- /dev/null +++ b/browser-companion/scripts/long-chat-smoke.ts @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { chromium } from "patchright"; +import { installMinimalChatGptPage } from "../src/minimal-page"; +import { privateChromeExecutable } from "../src/chrome-executable"; +import { captureSubmissionBaseline, readTurnDomState } from "../src/turn-binding"; +import {responseDomSnapshot, type ChatGptResponseDomCache} from "../src/response-observer"; + +// Runs under Node, not Bun: Patchright's Windows Chrome pipe can hang under Bun. +const profile = await mkdtemp(join(tmpdir(), "codexify-long-chat-")); +const executablePath = process.env.CODEXIFY_TEST_CHROME_PATH + ?? await privateChromeExecutable(resolve(".browsers", `${process.platform}-${process.arch}`)); +let context: Awaited> | undefined; +try { + context = await chromium.launchPersistentContext(profile, { + executablePath, headless: true, timeout: 20_000, + }); + await installMinimalChatGptPage(context); + const page = context.pages()[0] ?? await context.newPage(); + await page.route("https://chatgpt.com/**", route => route.fulfill({ + contentType: "text/html", + body: ` + +
+
+ + + +
`, + })); + await page.goto("https://chatgpt.com/c/long-fixture", { timeout: 10_000 }); + await page.evaluate(() => { + const host = document.querySelector("#conversation")!; + for (let i = 0; i < 650; i++) { + const group = document.createElement("div"); + group.setAttribute("data-turn-key", `old-${i}`); + group.innerHTML = '
Question

Answer

'; + host.appendChild(group); + } + }); + const start = performance.now(); + const baseline = await readTurnDomState(page); + const firstScanMs = performance.now() - start; + assert.equal(baseline.responseIdentities.length, 650); + assert.equal(baseline.generationRunning, true); + const scans = async () => page.evaluate(() => (globalThis as any).__CODEXIFY_TURN_IDENTITIES__.scans as number); + assert.equal(await scans(), 1); + const repeatStart = performance.now(); + for (let i = 0; i < 20; i++) { + await page.evaluate(value => { + document.querySelector(".markdown")!.textContent = `Streaming answer ${value}`; + }, i); + const state = await readTurnDomState(page); + assert.deepEqual(state.responseIdentities, baseline.responseIdentities); + } + const repeatPollMs = performance.now() - repeatStart; + assert.equal(await scans(), 1); + for (let i = 0; i < 20; i++) { + await page.evaluate(value => document.querySelector("#prompt-textarea")!.setAttribute("data-testid", `composer-status-${value}`), i); + await readTurnDomState(page); + } + assert.equal(await scans(), 1, "Composer status changes must not scan 650 previous turns"); + const submission = await captureSubmissionBaseline(page, "Follow-up message"); + assert.equal((await readTurnDomState(page, submission.browserBaselineId!)).newResponseIdentities.length, 0); + await page.locator('[data-testid="stop-button"]').evaluate(button => button.remove()); + assert.equal((await readTurnDomState(page)).generationRunning, true, "End turn remains a live Stop control"); + assert.equal(await scans(), 1); + await page.evaluate(() => { + const node = document.createElement("div"); + node.setAttribute("data-turn-key", "new"); + node.innerHTML = '
New question
Answer
'; + document.querySelector("#conversation")!.appendChild(node); + }); + assert.equal((await readTurnDomState(page)).responseIdentities.at(-1), "group:assistant:new"); + assert.deepEqual((await readTurnDomState(page, submission.browserBaselineId!)).newResponseIdentities, ["group:assistant:new"]); + assert.equal(await scans(), 2); + await page.evaluate(() => history.pushState({}, "", "/c/different")); + assert.equal((await readTurnDomState(page)).responseIdentities.length, 651); + assert.equal(await scans(), 3); + const display = async (selector: string) => page.locator(selector).evaluate(n => getComputedStyle(n).display); + assert.equal(await display('nav[aria-label="Chat history"]'), "none"); + assert.notEqual(await display('[data-testid="end-turn-button"]'), "none"); + await page.locator('[data-testid="end-turn-button"]').evaluate(button => button.remove()); + assert.equal((await readTurnDomState(page)).generationRunning, false); + assert.notEqual(await display('[data-testid="copy-turn-action-button"]'), "none"); + assert.notEqual(await display("#prompt-textarea"), "none"); + const fullScans = await scans(); + const forcedStart = performance.now(); + for (let i = 0; i < 20; i++) { + await page.evaluate(() => { (globalThis as any).__CODEXIFY_TURN_IDENTITIES__.dirty = true; }); + await readTurnDomState(page); + } + const forcedPollMs = performance.now() - forcedStart; + assert.equal(await scans(), fullScans + 20); + // ChatGPT sometimes inserts a grouped user shell before attaching its + // assistant response. The response must still be considered a new turn. + await page.evaluate(() => { + const group = document.createElement("div"); + group.setAttribute("data-turn-key", "optimistic-shell"); + group.innerHTML = '
Pending follow-up
'; + document.querySelector("#conversation")!.appendChild(group); + }); + const optimisticBaseline = await captureSubmissionBaseline(page, "New follow-up"); + assert.equal(optimisticBaseline.initialTurnIdentities.includes("group:assistant:optimistic-shell"), false); + await page.evaluate(() => { + const answer = document.createElement("div"); + answer.setAttribute("data-conversation-role", "assistant"); + answer.textContent = "Response appeared"; + document.querySelector('[data-turn-key="optimistic-shell"]')!.appendChild(answer); + }); + assert.deepEqual((await readTurnDomState(page, optimisticBaseline.browserBaselineId!)).newResponseIdentities, ["group:assistant:optimistic-shell"]); + await page.evaluate(() => { + const group = document.createElement("div"); + group.setAttribute("data-turn-key", "empty-shell"); + document.querySelector("#conversation")!.appendChild(group); + }); + const emptyBaseline = await captureSubmissionBaseline(page, "Another follow-up"); + assert.equal(emptyBaseline.initialTurnIdentities.includes("group:user:empty-shell"), false); + await page.evaluate(() => { + const bubble = document.createElement("div"); + bubble.setAttribute("data-user-message-bubble", ""); + bubble.textContent = "Now actually sent"; + document.querySelector('[data-turn-key="empty-shell"]')!.appendChild(bubble); + }); + assert.deepEqual((await readTurnDomState(page, emptyBaseline.browserBaselineId!)).newUserIdentities, ["group:user:empty-shell"]); + await page.evaluate(() => { + const answer = document.createElement("section"); + answer.id = "performance-answer"; + answer.innerHTML = Array.from({length:200}, (_, i) => `

Visible chunk ${i}

`).join(""); + document.querySelector("main")!.appendChild(answer); + const original = getComputedStyle; + (globalThis as any).__styleReads = 0; + globalThis.getComputedStyle = (...args: Parameters) => { + (globalThis as any).__styleReads++; + return original(...args); + }; + }); + const answer = page.locator("#performance-answer"); + const answerCache: ChatGptResponseDomCache = {}; + assert.ok((await responseDomSnapshot(answer, answerCache)).visibleText.includes("Visible chunk 199")); + await page.evaluate(() => { (globalThis as any).__styleReads = 0; }); + assert.ok((await responseDomSnapshot(answer, answerCache)).visibleText.includes("Visible chunk 199")); + const cachedStyleReads = await page.evaluate(() => (globalThis as any).__styleReads as number); + assert.ok(cachedStyleReads <= 210, `Shared ancestors were resolved repeatedly: ${cachedStyleReads}`); + assert.equal(answerCache.fullScans, 1); + assert.equal(answerCache.cacheHits, 1); + await answer.evaluate(element => { element.style.display = "none"; }); + assert.equal((await responseDomSnapshot(answer, answerCache)).visibleText, ""); + await answer.evaluate(element => { element.style.display = ""; }); + assert.ok((await responseDomSnapshot(answer, answerCache)).visibleText.includes("Visible chunk 199")); + console.log(JSON.stringify({ + ok:true, turns:650, firstScanMs:Math.round(firstScanMs), + cached20PollsMs:Math.round(repeatPollMs), + forced20PollsMs:Math.round(forcedPollMs), + fullScansBeforeBenchmark:fullScans, + fullScansAfterBenchmark:await scans(), + cachedStyleReads, + })); +} finally { + if (context) await context.close(); + await rm(profile, { recursive:true, force:true }); +} diff --git a/browser-companion/scripts/long-session-smoke.ts b/browser-companion/scripts/long-session-smoke.ts new file mode 100644 index 00000000..fd8d9f1d --- /dev/null +++ b/browser-companion/scripts/long-session-smoke.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import {createServer} from 'node:http'; +import {mkdtemp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join,resolve,sep} from 'node:path'; +import {chromium} from 'patchright'; +import {privateChromeExecutable} from '../src/chrome-executable'; +import {backgroundChromeArgs} from '../src/background-chrome'; +import {maintainRendererMemory} from '../src/renderer-memory'; + +// A disposable local profile; the user's chats and managed browser are untouched. +const markup='long session fixture'+Array.from({length:500},(_,i)=>`

Local fixture turn ${i}

`).join(''); +const server=createServer((_request,response)=>{response.setHeader('Content-Type','text/html');response.end(markup);}); +await new Promise(yes=>server.listen(0,'127.0.0.1',yes)); +const address=server.address();if(!address||typeof address==='string')throw Error('No fixture port'); +const profile=await mkdtemp(join(tmpdir(),'codexify-long-session-')); +if(!resolve(profile).startsWith(resolve(tmpdir())+sep)||!profile.includes('codexify-long-session-'))throw Error('Unexpected disposable profile'); +let context:Awaited>|undefined; +try { + const executablePath=await privateChromeExecutable(resolve('.browsers',`${process.platform}-${process.arch}`)); + context=await chromium.launchPersistentContext(profile,{executablePath,headless:true,ignoreDefaultArgs:true, + args:[...await backgroundChromeArgs(profile),'--headless=new'],timeout:15000}); + const page=context.pages()[0]??await context.newPage(),session=await context.newCDPSession(page); + const samples:Array<{index:number;ms:number;heapBytes:number;documents:number;nodes:number}>=[]; + for(let index=0;index<80;index++){ + const start=performance.now(); + await page.goto(`http://127.0.0.1:${address.port}/chat-${index}`,{waitUntil:'domcontentloaded',timeout:5000}); + assert.equal(await page.locator('article').count(),500); + await maintainRendererMemory(page,index*60_000); + const [heap,dom]=await Promise.all([session.send('Runtime.getHeapUsage'),session.send('Memory.getDOMCounters')]); + samples.push({index,ms:Math.round(performance.now()-start),heapBytes:heap.usedSize,documents:dom.documents,nodes:dom.nodes}); + } + await session.send('HeapProfiler.collectGarbage'); + const finalHeap=await session.send('Runtime.getHeapUsage'),finalDom=await session.send('Memory.getDOMCounters'); + await session.detach(); + const percentile=(values:number[],p:number)=>[...values].sort((a,b)=>a-b)[Math.floor((values.length-1)*p)]!; + assert.ok(finalHeap.usedSize<128*1024*1024,`Retained heap grew to ${finalHeap.usedSize}`); + assert.ok(finalDom.documents<20,`Retained ${finalDom.documents} navigation documents`); + console.log(JSON.stringify({ok:true,switches:samples.length,turnsPerPage:500,medianMs:percentile(samples.map(s=>s.ms),.5), + first20MedianMs:percentile(samples.slice(0,20).map(s=>s.ms),.5),last20MedianMs:percentile(samples.slice(-20).map(s=>s.ms),.5), + p95Ms:percentile(samples.map(s=>s.ms),.95),maxHeapBytes:Math.max(...samples.map(s=>s.heapBytes)),finalHeapBytes:finalHeap.usedSize,finalDom})); +} finally { + await context?.close(); + await rm(profile,{recursive:true,force:true}); + await new Promise((yes,no)=>server.close(error=>error?no(error):yes())); +} diff --git a/browser-companion/scripts/maintenance-observation-smoke.ts b/browser-companion/scripts/maintenance-observation-smoke.ts new file mode 100644 index 00000000..880687d0 --- /dev/null +++ b/browser-companion/scripts/maintenance-observation-smoke.ts @@ -0,0 +1,44 @@ +// Isolated real-Chrome regression: no user profile or ChatGPT session is opened. +import assert from 'node:assert/strict'; +import {chromium} from 'patchright'; +import {SingleWindowCoordinator,type BrowserDriver} from '../src/browser-window'; +import {protocolBuild,type CapabilityCatalog} from '../src/protocol'; + +async function main(){ + const browser=await chromium.launch({executablePath:process.env.CODEXIFY_TEST_CHROME,headless:true}); + const context=await browser.newContext(),page=await context.newPage(); + const build=protocolBuild('maintenance-observation-smoke');let calls=0,closed=0; + const catalog:CapabilityCatalog={protocolVersion:1,build,accountFingerprint:'fixture',catalogRevision:'fixture',models:[]}; + const driver:BrowserDriver={ + async health(){return {authenticated:true,accountFingerprint:'fixture',status:'idle',detail:null};}, + async liveness(){return page.evaluate(()=>true);}, + async login(){throw Error('Unexpected login');}, + async capabilities(){ + if(++calls===1)await page.evaluate(()=>new Promise(resolve=>{(globalThis as any).releaseObservation=resolve;})); + return catalog; + }, + async reveal(){},async execute(){throw Error('Unexpected Send');}, + async close(){closed++;await browser.close();}, + }; + const budget={operationMs:150,cleanupMs:50,hardStopMs:40}; + const coordinator=new SingleWindowCoordinator(driver,build,budget); + try{ + await page.setContent('Disposable maintenance regression'); + await context.addCookies([{name:'fixture_cookie',value:'fake',url:'http://127.0.0.1'}]); + await assert.rejects(coordinator.capabilities(),/timed out/u); + assert.equal(closed,0);assert(browser.isConnected());assert(!page.isClosed()); + assert.equal((await coordinator.health()).status,'busy');assert((await coordinator.liveness()).available); + await assert.rejects(coordinator.capabilities(),/busy/u);assert.equal(calls,1); + await page.evaluate(()=>{(globalThis as any).releaseObservation();}); + await new Promise(resolve=>setTimeout(resolve,20)); + assert.equal((await coordinator.capabilities()).accountFingerprint,'fixture');assert.equal(calls,2); + assert.equal((await context.cookies('http://127.0.0.1'))[0]?.name,'fixture_cookie'); + assert.equal(closed,0); + console.log('REAL_CHROME_OBSERVATION_PRESERVATION_OK'); + }finally{ + await page.evaluate(()=>{(globalThis as any).releaseObservation?.();}).catch(()=>{}); + budget.operationMs=5000; // Real process shutdown is outside the scaled observation deadline. + await coordinator.close();await browser.close(); + } +} +void main().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/browser-companion/scripts/minimal-page-smoke.ts b/browser-companion/scripts/minimal-page-smoke.ts new file mode 100644 index 00000000..26a8788b --- /dev/null +++ b/browser-companion/scripts/minimal-page-smoke.ts @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import {writeFile} from "node:fs/promises"; +import {resolve} from "node:path"; +import {chromium} from "patchright"; +import {privateChromeExecutable} from "../src/chrome-executable"; +import {installMinimalChatGptPage, styleRestoredChatGptPage, MINIMAL_CHATGPT_CSS} from "../src/minimal-page"; +import {activeComposer, composerText, insertExactPrompt} from "../src/composer"; +import {captureSubmissionBaseline, readTurnDomState} from "../src/turn-binding"; +import {responseDomSnapshot} from "../src/response-observer"; +import {inspectPresentation} from "../src/presentation"; + +// Run the bundled script under Node on Windows, matching the packaged driver. +// All requests are fixtures; this never opens the managed account/profile. +const executablePath=process.env.CODEXIFY_TEST_CHROME_PATH + ?? await privateChromeExecutable(resolve(".browsers",`${process.platform}-${process.arch}`)); +const browser=await chromium.launch({executablePath, headless:true, timeout:20_000}); +try { + const context=await browser.newContext({viewport:{width:1280,height:800},reducedMotion:"reduce"}); + await installMinimalChatGptPage(context); + let sends=0; + const page=await context.newPage(); + await page.exposeFunction("fixtureSend",()=>{sends++;}); + const fixture=`
History
Suggestions
+
+
+ +
+ + + + Working +
`; + await context.route("**/*",route=>route.fulfill({contentType:"text/html",body:fixture, + ...(new URL(route.request().url()).pathname==="/c/csp-fixture"?{headers:{"Content-Security-Policy":"style-src 'nonce-fixture'"}}:{})})); + await page.goto("https://chatgpt.com/c/minimal-fixture",{waitUntil:"domcontentloaded"}); + const turnCount=320; + await page.evaluate(count=>{ + const host=document.querySelector("main")!; + for(let index=0;index +
`+ + Array.from({length:48},(_,row)=>`

Turn ${index}, line ${row}: ${"Source code and tool output with wrapping text. ".repeat(6)}

`).join("")+ + `
tool_call_${index}()
`; + host.append(outer); + } + },turnCount); + const css=async(selector:string,property:string)=>page.locator(selector).first().evaluate((el,key)=>getComputedStyle(el).getPropertyValue(key),property); + assert.equal(await css('nav[aria-label="Chat history"]',"display"),"none"); + assert.equal(await css('#codexify-fast-indicator',"position"),"fixed"); + assert.equal(await css('#prompt-textarea',"font-size"),"14px"); + assert.equal(await css('#app-shell-sidebar',"width"),'52px'); + assert.equal(await page.locator('#app-shell-sidebar').evaluate(el=>getComputedStyle(el.parentElement!).width),'52px'); + assert.equal(await css('[class*="ConversationSidebar"]','display'),'none'); + assert.equal(await css('[class~="group/home-suggestions"]','display'),'none'); + assert.notEqual(await css('#account','display'),'none','Keep account/navigation rail accessible'); + await page.evaluate(()=>{ + document.getElementById('codexify-minimal-chatgpt')!.remove(); + document.getElementById('codexify-fast-indicator')!.remove(); + }); + await page.locator('#codexify-fast-indicator').waitFor({state:"visible"}); + assert.equal(await page.locator('#codexify-minimal-chatgpt').count(),1,"Hydration must not permanently remove fast mode"); + const diagnostic=await inspectPresentation(page); + assert.equal(diagnostic.mode,"fast-v2"); + assert.equal(diagnostic.badgeVisible,true); + assert.equal(diagnostic.turnCount,turnCount); + assert.equal(JSON.stringify(diagnostic).includes("Source code and tool output"),false,"Diagnostics must exclude conversation text"); + for(const selector of ['#prompt-textarea','[data-testid="send-button"]','[data-testid="stop-button"]','#model','#connector','#approval']) { + assert.notEqual(await css(selector,"display"),"none",`${selector} must remain usable`); + } + assert.equal(await page.evaluate(()=>matchMedia("(prefers-reduced-motion: reduce)").matches),true); + assert(parseFloat(await css('.moving',"animation-duration"))<.001); + assert.equal(await css('[data-turn-key="old-0"]',"content-visibility"),"auto"); + assert.equal(await css('[data-turn-key="old-0"] section',"content-visibility"),"visible","Nested turn shells must not be contained twice"); + assert.equal(await css('[data-turn-id-container="old-1"]',"content-visibility"),"auto"); + assert.equal(await css('[data-turn-id="old-1"]',"content-visibility"),"visible"); + for(let index=turnCount-4;index :nth-child(${index+1})`,"content-visibility"),"visible"); + } + // Expanded controls and focused history must immediately leave containment. + const oldCopy=page.locator('[data-turn-key="old-0"] button'); + await oldCopy.evaluate(el=>el.setAttribute("aria-expanded","true")); + assert.equal(await css('[data-turn-key="old-0"]',"content-visibility"),"visible"); + await oldCopy.evaluate(el=>el.removeAttribute("aria-expanded")); + await oldCopy.focus(); + assert.equal(await css('[data-turn-key="old-0"]',"content-visibility"),"visible"); + await page.locator("#prompt-textarea").focus(); + + const state=await readTurnDomState(page); + assert.equal(state.responseIdentities.length,turnCount); + assert.equal(state.generationRunning,true); + const oldSnapshot=await responseDomSnapshot(page.locator('[data-turn-key="old-0"]')); + assert(oldSnapshot.visibleText.includes("Turn 0, line 47"),"Offscreen historical output must remain extractable"); + const text="Exact prompt\nwith continuation evidence"; + const baseline=await captureSubmissionBaseline(page,text); + const composer=await activeComposer(page); + await insertExactPrompt(composer,text); + assert.equal(await composerText(composer),text); + await page.locator('[data-testid="send-button"]').click(); + assert.equal(sends,1); + assert.deepEqual((await readTurnDomState(page,baseline.browserBaselineId!)).newResponseIdentities,["group:assistant:accepted"]); + const fresh=await responseDomSnapshot(page.locator('[data-turn-key="accepted"]')); + assert.equal(fresh.visibleText,"Fresh answer"); + assert.equal(fresh.completionActionVisible,true); + + // Full navigation, SPA replacement and restored styling retain one stylesheet. + await styleRestoredChatGptPage(page); + assert.equal(await page.locator('#codexify-minimal-chatgpt').count(),1); + const other=await context.newPage(); + await other.goto("https://fixture.invalid/"); + assert.equal(await other.locator('#codexify-minimal-chatgpt').count(),0,"Account/other-origin pages must be untouched"); + await other.close(); + const strict=await context.newPage(); + await strict.goto("https://chatgpt.com/c/csp-fixture"); + const strictStyle=await strict.locator('#codexify-fast-indicator').evaluate(el=>({position:getComputedStyle(el).position, + inlineSheetBlocked:(document.getElementById('codexify-minimal-chatgpt') as HTMLStyleElement).sheet===null})); + assert.equal(strictStyle.inlineSheetBlocked,true,"Fixture must actually reject the old inline-style mechanism"); + assert.equal(strictStyle.position,"fixed","Constructed fast-mode styles must survive strict site CSP"); + await strict.close(); + + // Alternate the old and new styles in the SAME document/process. Compare + // forced width reflows of a long chat, not model/network/total action speed. + const oldCss='nav[aria-label="Chat history"] { display:none !important; }'; + const session=await context.newCDPSession(page); + await session.send("Performance.enable"); + const metrics=async()=>{ + const result=await session.send("Performance.getMetrics"); + return Object.fromEntries(result.metrics.map(({name,value})=>[name,value])); + }; + const samples:{mode:string;layoutMs:number;styleMs:number;wallMs:number}[]=[]; + for(const mode of ["baseline","minimal","minimal","baseline","baseline","minimal"]) { + await page.evaluate(css=>{ + // The real fast mode also survives HEAD replacement via an adopted + // sheet. Remove that second copy in BOTH benchmark arms so the baseline + // really exercises the old presentation, rather than measuring fast twice. + document.adoptedStyleSheets=[]; + document.querySelector('#codexify-minimal-chatgpt')!.textContent=css; + scrollTo(0,document.body.scrollHeight); + },mode==="minimal"?MINIMAL_CHATGPT_CSS:oldCss); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve())))); + const before=await metrics(); + const start=performance.now(); + await page.evaluate(()=>{ + const main=document.querySelector('main')!; + for(let index=0;index<8;index++) { + main.style.width=`${index%2?900:850}px`; + void document.body.offsetHeight; + } + }); + const wallMs=performance.now()-start; + const after=await metrics(); + samples.push({mode,layoutMs:(after.LayoutDuration!-before.LayoutDuration!)*1000,styleMs:(after.RecalcStyleDuration!-before.RecalcStyleDuration!)*1000,wallMs}); + } + const median=(values:number[])=>[...values].sort((a,b)=>a-b)[Math.floor(values.length/2)]!; + const baselineLayoutMs=median(samples.filter(s=>s.mode==="baseline").map(s=>s.layoutMs)); + const minimalLayoutMs=median(samples.filter(s=>s.mode==="minimal").map(s=>s.layoutMs)); + assert(minimalLayoutMs{script=String(fn);argument=arg;}} as unknown as BrowserContext, + '#probe {width:340px !important;} #probe::before {content:"OLD DEBUG"}',1); + await context.addInitScript({content:`(${script})(${JSON.stringify(argument)})`}); + await context.route('**/*',route=>route.fulfill({contentType:'text/html',body:'
Fixture
'})); + const page=await context.newPage();await page.goto('https://chatgpt.com/c/style-world-fixture'); + const session=await context.newCDPSession(page); + const tree=await session.send('Page.getFrameTree'); + const world=await session.send('Page.createIsolatedWorld',{frameId:tree.frameTree.frame.id,worldName:'codexify-style-regression'}); + await installMinimalChatGptPage({addInitScript:async(fn:unknown,arg:unknown)=>{script=String(fn);argument=arg;}} as unknown as BrowserContext, + '#probe {width:52px !important;}',2); + const applied=await session.send('Runtime.evaluate',{expression:`(${script})(${JSON.stringify(argument)})`,contextId:world.executionContextId,returnByValue:true}); + assert.equal(applied.exceptionDetails,undefined); + await page.evaluate(()=>{const child=document.createElement('meta');child.name='fixture-hydration';document.head.append(child);}); + const state=await page.locator('#probe').evaluate(el=>({width:getComputedStyle(el).width,debug:getComputedStyle(el,'::before').content, + sheets:document.adoptedStyleSheets.filter(sheet=>[...sheet.cssRules].some(rule=>'selectorText' in rule&&rule.selectorText==='#codexify-fast-indicator')).length})); + assert.equal(state.width,'52px','The latest revision must win across script worlds'); + assert(['none','normal','""'].includes(state.debug),'Obsolete debug CSS must disappear'); + assert.equal(state.sheets,1,'Only one owned adopted stylesheet may remain'); + await styleRestoredChatGptPage(page,'#probe {width:400px !important;}',1); + assert.equal(await page.locator('#probe').evaluate(el=>getComputedStyle(el).width),'52px','Old controller must not reclaim the shared document'); + await installMinimalChatGptPage({addInitScript:async(fn:unknown,arg:unknown)=>{script=String(fn);argument=arg;}} as unknown as BrowserContext,"",3,"off"); + const disabled=await session.send('Runtime.evaluate',{expression:`(${script})(${JSON.stringify(argument)})`,contextId:world.executionContextId,returnByValue:true}); + assert.equal(disabled.exceptionDetails,undefined); + await page.evaluate(()=>document.head.append(document.createElement('meta'))); + assert.equal(await page.locator('#codexify-fast-indicator').count(),0,'Disabling must also remove sheets owned by older script worlds'); + assert.equal(await page.evaluate(()=>document.adoptedStyleSheets.length),0); + await styleRestoredChatGptPage(page,"",4,"bones"); + assert.equal(await page.locator('html').getAttribute('data-codexify-simplicity'),'bones','A newer level may re-enable style after Off'); + assert.equal(await page.locator('#codexify-fast-indicator').textContent(),'CODEXIFY · BONES'); + console.log(JSON.stringify({ok:true,checks:['cross-world replacement','obsolete rules removed','one adopted sheet','hydration preserves latest revision','older world cannot revert','Off removes old-world styles','new level re-enables after Off']})); +} finally {await browser.close();} diff --git a/browser-companion/scripts/model-selection-speed-smoke.ts b/browser-companion/scripts/model-selection-speed-smoke.ts new file mode 100644 index 00000000..0cacc1c1 --- /dev/null +++ b/browser-companion/scripts/model-selection-speed-smoke.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import {chromium} from 'patchright'; +import {selectAvailableModelAndEffort,assertSelectedModelAndEffort} from '../src/model-selection'; +import {ChatGptBrowserDriver,type SingleBrowserSurface} from '../src/chatgpt-driver'; +import {protocolBuild} from '../src/protocol'; + +const browser=await chromium.launch({executablePath:process.env.CODEXIFY_TEST_CHROME_PATH,headless:true}); +try { + const context=await browser.newContext(),page=await context.newPage(); + let accountId='fixture-account',planType='pro',hydrateAfter=0; + await context.route('**/*',route=>new URL(route.request().url()).pathname==='/api/auth/session' + ?route.fulfill({json:{user:{id:'fixture-user'},account:{id:accountId,planType,structure:'personal'}}}) + :route.fulfill({contentType:'text/html',body:` +
+ + `})); + const runs=[]; + for(const deferred of [false,true]){ + await page.goto('https://chatgpt.com/c/model-speed-fixture'); + const started=performance.now(); + const selected=await selectAvailableModelAndEffort(page,'gpt-6-sol','xhigh',undefined,undefined,undefined,deferred); + await assertSelectedModelAndEffort(page,selected); + const opens=Number(await page.locator('body').getAttribute('data-opens')); + assert.equal(opens,deferred?2:3,'Deferred confirmation must remove exactly one redundant picker visit'); + runs.push({deferred,opens,elapsedMs:performance.now()-started}); + await page.locator('#effort').click();await page.locator('#range').press('ArrowLeft');await page.keyboard.press('Escape'); + await page.locator('#effort').evaluate(el=>{el.textContent='Extra High';}); + await assert.rejects(()=>assertSelectedModelAndEffort(page,selected),/requested effort/,'A stale label must not conceal a changed slider value'); + } + const surface:SingleBrowserSurface={page:async()=>page,navigate:async url=>{await page.goto(url);return page;},beginAction:async()=>{},endAction:async()=>{},reveal:async()=>{}}; + const driver=new ChatGptBrowserDriver(surface,protocolBuild('test-build')); + await page.goto('https://chatgpt.com/'); + const freshStarted=performance.now();const original=await driver.capabilities();const freshCatalogMs=performance.now()-freshStarted; + const opensBefore=Number(await page.locator('body').getAttribute('data-opens')); + const cachedStarted=performance.now();const cached=await driver.prepareNewChat(null);const cachedPreparationMs=performance.now()-cachedStarted; + assert.deepEqual(cached,original); + assert.equal(Number(await page.locator('body').getAttribute('data-opens')),opensBefore,'Batch preparation must not rediscover an unchanged account catalogue'); + await driver.capabilities(); + const afterExplicit=Number(await page.locator('body').getAttribute('data-opens')); + assert(afterExplicit>opensBefore,'Explicit catalogue refresh must still inspect the live picker'); + accountId='different-account';const changed=await driver.prepareNewChat(null); + assert.notEqual(changed.accountFingerprint,original.accountFingerprint); + const afterAccount=Number(await page.locator('body').getAttribute('data-opens'));assert(afterAccount>afterExplicit,'Changed accounts must rescan'); + planType='team';await driver.prepareNewChat(null); + assert(Number(await page.locator('body').getAttribute('data-opens'))>afterAccount,'Changed plans must rescan'); + hydrateAfter=350;await page.goto('https://chatgpt.com/'); + await driver.capabilities(); + console.log(JSON.stringify({ok:true,runs,freshCatalogMs,cachedPreparationMs,checks:['one fewer picker visit','fresh final slider and family proof','wrong value with unchanged label rejected','batch catalogue reuse','explicit refresh remains fresh','account and plan changes rescan','catalogue hydration succeeds on first attempt']})); +} finally {await browser.close();} diff --git a/browser-companion/scripts/navigation-memory-smoke.ts b/browser-companion/scripts/navigation-memory-smoke.ts new file mode 100644 index 00000000..0bf72309 --- /dev/null +++ b/browser-companion/scripts/navigation-memory-smoke.ts @@ -0,0 +1,60 @@ +import {createServer} from "node:http"; +import assert from "node:assert/strict"; +import {mkdtemp,rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join,resolve,sep} from "node:path"; +import {chromium} from "patchright"; +import {privateChromeExecutable} from "../src/chrome-executable"; +import {backgroundChromeArgs} from "../src/background-chrome"; + +// Isolated local pages only: no managed profile, account or running agent. +const server=createServer((_request,response)=>{ + response.setHeader("Content-Type","text/html"); + response.end(`navigation cache fixture
`); +}); +await new Promise(yes=>server.listen(0,"127.0.0.1",yes)); +const address=server.address(); +if(!address||typeof address==="string")throw new Error("Missing local fixture port"); +const origin=`http://127.0.0.1:${address.port}`; +const executablePath=await privateChromeExecutable(resolve(".browsers",`${process.platform}-${process.arch}`)); +const results=[]; +try { + for(const disableCache of [false,true]) { + const profile=await mkdtemp(join(tmpdir(),"codexify-navigation-memory-")); + if(!resolve(profile).startsWith(resolve(tmpdir())+sep)||!profile.includes("codexify-navigation-memory-"))throw new Error("Unsafe fixture profile"); + let context:Awaited>|undefined; + try { + const args=await backgroundChromeArgs(profile); + // Measure the old policy independently after the production fix lands. + const baseline=args.filter(arg=>arg!=="--disable-back-forward-cache"); + context=await chromium.launchPersistentContext(profile,{executablePath, + headless:true,ignoreDefaultArgs:true,timeout:15000, + args:[...baseline,"--headless=new",...(disableCache?["--disable-back-forward-cache"]:[])]}); + const page=context.pages()[0]??await context.newPage(); + await page.goto(`${origin}/a`); + const original=await page.evaluate(()=>document.documentElement.dataset.documentId); + await page.goto(`${origin}/b`); + const session=await context.newCDPSession(page); + await session.send("HeapProfiler.collectGarbage"); + const counters=await session.send("Memory.getDOMCounters"); + await session.detach(); + // A BFCache restore does not emit a new load event. Observe its exact + // target without imposing a load-event timeout on the baseline. + await page.evaluate(()=>history.back()); + await page.waitForURL(`${origin}/a`,{waitUntil:"commit",timeout:5000}); + const result=await page.evaluate(()=>({sameDocument:document.documentElement.dataset.documentId,restored:document.documentElement.dataset.restored})); + if(!original||!result.sameDocument)throw new Error("Fixture document identity missing"); + results.push({disableCache,reusedPreviousDocument:result.sameDocument===original,persisted:result.restored,retainedDocuments:counters.documents}); + } finally {await context?.close();await rm(profile,{recursive:true,force:true});} + } + assert.equal(results[1]?.reusedPreviousDocument,false); + assert.equal(results[1]?.persisted,"false"); + // If this Chrome supports BFCache under automation, verify actual retained + // document reduction as well as the fresh navigation identity. + if(results[0]?.persisted==="true")assert.ok(results[1]!.retainedDocuments((yes,no)=>server.close(error=>error?no(error):yes()));} diff --git a/browser-companion/scripts/owner-agent-folders-smoke.cjs b/browser-companion/scripts/owner-agent-folders-smoke.cjs new file mode 100644 index 00000000..3fcd1b18 --- /dev/null +++ b/browser-companion/scripts/owner-agent-folders-smoke.cjs @@ -0,0 +1,104 @@ +// Isolated owner-UI fixture. Native access is GET-only; mutations fail closed. +const fs=require('node:fs'),path=require('node:path'),os=require('node:os'),assert=require('node:assert/strict'); +const {chromium}=require('patchright'); +const repo=path.resolve(__dirname,'../..'),read=file=>fs.readFileSync(path.join(repo,'src',file),'utf8'); +const section=(source,start,end)=>source.split(start)[1].split(end)[0]; +(async()=>{ + const root=path.join(os.homedir(),'.codexify'),auth=JSON.parse(fs.readFileSync(path.join(root,'owner-chat.json'),'utf8')); + const base='http://127.0.0.1:'+auth.port,real='901363c803db5b95fc3d60af3327e560de6ebc3e284c1997daacf8e124742136'; + const get=async suffix=>{const response=await fetch(base+suffix,{headers:{Authorization:'Bearer '+auth.token}});assert(response.ok);return response.json();}; + const widget=await get('/api/chats/'+real),automation=await get('/api/chats/'+real+'/automation'); + automation.features={agentTeams:true,autoMode:true,agentFolders:true,conversationRecovery:true}; + let component=read('markdown_chat_ui.html').replace('/* CODEXIFY_MARKDOWN_LIBRARY */',read('vendor/markdown-it.min.js')).replace('/* CODEXIFY_MARKDOWN_RENDERER */',read('markdown_chat_render.js')).replace('/* CODEXIFY_ATTACHMENT_COMPOSER */',read('chat_attachments_ui.js')); + let style=section(component,'').replaceAll(':root:not([data-theme="light"])',':host(:not([data-theme="light"]))').replaceAll(':root[data-theme="dark"]',':host([data-theme="dark"])').replaceAll(':root',':host').replace('body {',':host { display:block;'); + style+='\n#chat{max-width:none;height:100%;border:0;border-radius:0;display:flex;flex-direction:column}#panel{display:flex;flex:1;min-height:0;flex-direction:column}#panel[hidden]{display:none}#messages{flex:1;max-height:none;min-height:0}'; + let html=read('owner_chat.html').replace('','