diff --git a/README.md b/README.md index f6dbd743..95f62c0d 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,31 @@ connector name. If it is missing, Codexify asks for it in a popup, saves it in a restart. Cancelling leaves the action untouched. Quickstart saves the name you enter during setup; no connector name is assumed automatically. +For a lighter managed ChatGPT window, use **Settings → Browser → Browser +simplicity**: **Simplified** reduces decoration and defers offscreen history; +**Bones** adds compact navigation, plain messages/code and a minimal composer. +Both preserve model/apps menus, Stop, Copy and tool approvals. **Off** restores +the full interface. Existing simplification settings remain compatible; restart +Codexify to apply a level. + +**Fast setup** in Settings → General or Browser applies the recommended +configuration in one save: Steer delivery, stalled-browser recovery, automatic +retries, progress tracking and agent controls. Browser simplification +(Simplified/Bones), infinite retries and turn renewal are not part of it; enable +each separately if you want it. It also +reduces animations, sounds and recap interruptions. Restart once using the +existing button; tool refresh is queued automatically when needed. Model/effort +choices, renewal limits and explicit per-chat stops remain yours. New quickstart +setups receive these defaults automatically; reruns preserve saved choices. +For scripted setup, `codexify config fast-setup` applies the same validated preset +in one command. Use `--config ` to target a particular installation. + +Healthy conversation switching now defaults to a one-second interval; actual +HTTP 429 and Retry-After backoffs still apply. An explicitly saved interval is +preserved: set **Conversation loading interval (seconds)** to `1` for this policy. +Long tasks should retain an active saved goal and rollout journal so automatic +continuation can resume the same objective after an interrupted turn. + **Turn renewal** is independent of the progress journal and is **off by default**. Enable it in **Settings → Chats & goals → Turn renewal**. Its per-model/effort limits have individual enable switches and remain editable while disabled. @@ -565,6 +590,7 @@ Report reproducible bugs in [GitHub Issues](https://github.com/devnoname120/code | Setup and usage guides | [Wiki](https://github.com/devnoname120/codexify/wiki) | | CLI options, configuration, and tool behavior | [Technical reference](docs/REFERENCE.md) | | Additional MCP servers and reusable workflows | [MCP bridging](docs/REFERENCE.md#bridging-other-mcp-servers) · [Skills](docs/REFERENCE.md#skills) | +| Control agents from any AI client | [Universal agent connection](docs/CODING_APP_AGENTS.md) | | Building or contributing | [Architecture](docs/ARCHITECTURE.md) · [Development commands](docs/REFERENCE.md#dev-commands) | ## License 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('','