From a13fa53a8dff6ff41d4a8e588c5dc513dc50e181 Mon Sep 17 00:00:00 2001 From: eggplantiny <57978095+eggplantiny@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:59:24 +0900 Subject: [PATCH] Fix AppImage shell environment and agent header overflow --- .github/workflows/ci.yml | 1 + CHANGELOG.md | 6 + crates/core/src/engine.rs | 2 + crates/core/src/engine/child_environment.rs | 144 +++++++++++++++++ crates/core/tests/child_environment.rs | 85 ++++++++++ crates/core/tests/shell_integration.rs | 2 + .../ui/src/components/SessionAgents.svelte | 107 ++++++++++++ packages/ui/src/components/TabStrip.svelte | 33 ++-- packages/ui/src/lib/i18n.svelte.ts | 8 + tests/collaboration/package.json | 3 +- .../scripts/header-input-regressions.mjs | 152 ++++++++++++++++++ 11 files changed, 527 insertions(+), 16 deletions(-) create mode 100644 crates/core/src/engine/child_environment.rs create mode 100644 crates/core/tests/child_environment.rs create mode 100644 packages/ui/src/components/SessionAgents.svelte create mode 100644 tests/collaboration/scripts/header-input-regressions.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4eb6237..aa5589c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,6 +178,7 @@ jobs: - run: npm run test:app-lifetime -w @conn/ui - run: npm run test:collaboration - run: npm run test:input -w @conn/collaboration-tests + - run: npm run test:regressions -w @conn/collaboration-tests - run: npm run package:web -- --debug required: name: Required checks diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f5ab6..6ffd9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Keep agent connections accessible through a compact session-header summary and a keyboard-accessible list. Preserve distinct connections with repeated names, show full long names, and keep the close control visible. +- Remove inherited AppImage runtime paths from host-side terminal children while preserving host paths and explicit profile overrides. Host commands such as Flatpak no longer load the desktop bundle's libraries. +- Add production UI/PTY/MCP regressions for empty Readline edits, retained human input, connection overflow, resizing, disconnects and session controls. Make the Readline fixture independent of the test runner's terminal type. + +한국어: 세션 헤더의 에이전트 연결을 대표 이름과 추가 연결 수로 요약하고, 키보드로 열 수 있는 목록에서 긴 이름과 같은 이름의 개별 연결을 확인할 수 있게 했습니다. 닫기 버튼을 보존하고, AppImage 실행 경로가 공유 셸에 유출되지 않도록 사용자 경로·프로필 재정의를 유지하며 복원합니다. 빈 입력·남은 인간 입력·배지 overflow·창 크기·연결 해제·세션 조작의 실제 UI/PTY/MCP 회귀 검증을 추가했습니다. + ## 0.8.8 — Preview · 2026-09-23 - Keep the complete agent command through supported cursor edits, including wrapped input and colored/wide prompts. History, completion and uncertain edits no longer let a screen-row fragment determine approval for a different physical command. diff --git a/crates/core/src/engine.rs b/crates/core/src/engine.rs index 68aa3f1..8d86c4e 100644 --- a/crates/core/src/engine.rs +++ b/crates/core/src/engine.rs @@ -24,6 +24,7 @@ use crate::policy::PolicyStore; use crate::session::{EventSink, Session, SessionConfig, SharedSession}; mod child_killer; +mod child_environment; use child_killer::ChildKiller; /// Move-owned startup specification for trusted native callers. Never serialized. @@ -149,6 +150,7 @@ impl Engine { let shell = profile.program.clone(); let mut cmd = CommandBuilder::new(&plan.program); cmd.args(&plan.args); + child_environment::restore_host_environment(&mut cmd); cmd.env("CONN", "1"); cmd.env("TERM", std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into())); for (k, v) in &cfg.env { cmd.env(k, v); } diff --git a/crates/core/src/engine/child_environment.rs b/crates/core/src/engine/child_environment.rs new file mode 100644 index 0000000..0cf4e84 --- /dev/null +++ b/crates/core/src/engine/child_environment.rs @@ -0,0 +1,144 @@ +//! AppImage's runtime paths belong to the desktop, not its host-side PTY child. +//! Restore inherited paths before applying explicit engine/profile overrides. +use portable_pty::CommandBuilder; + +pub(super) fn restore_host_environment(cmd: &mut CommandBuilder) { + #[cfg(target_os = "linux")] + restore_appimage_paths(cmd); + #[cfg(not(target_os = "linux"))] + let _ = cmd; +} + +#[cfg(target_os = "linux")] +fn restore_appimage_paths(cmd: &mut CommandBuilder) { + use std::path::{Component, Path, PathBuf}; + fn normalized(path: &Path) -> PathBuf { + let mut result = PathBuf::new(); + for part in path.components() { + match part { + Component::ParentDir => { + result.pop(); + } + Component::CurDir => {} + _ => result.push(part.as_os_str()), + } + } + result + } + if cmd.get_env("APPIMAGE").is_none_or(|value| value.is_empty()) { + return; + } + let Some(appdir) = cmd + .get_env("APPDIR") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + else { + return; + }; + let appdir = normalized(&appdir); + // A malformed root must never strip the host's entire environment. + if appdir.parent().is_none() { + return; + } + let bundled = |path: &Path| path.is_absolute() && normalized(path).starts_with(&appdir); + for key in [ + "LD_LIBRARY_PATH", + "PATH", + "XDG_DATA_DIRS", + "GI_TYPELIB_PATH", + "GTK_PATH", + "GIO_EXTRA_MODULES", + ] { + let Some(value) = cmd.get_env(key) else { + continue; + }; + let paths: Vec<_> = std::env::split_paths(value).collect(); + if !paths.iter().any(|path| bundled(path)) { + continue; + } + let retained: Vec<_> = paths.into_iter().filter(|path| !bundled(path)).collect(); + if retained.is_empty() { + cmd.env_remove(key); + } else if let Ok(value) = std::env::join_paths(retained) { + cmd.env(key, value); + } + } + for key in [ + "GTK_DATA_PREFIX", + "GTK_EXE_PREFIX", + "GSETTINGS_SCHEMA_DIR", + "GTK_IM_MODULE_FILE", + "GDK_PIXBUF_MODULE_FILE", + "GDK_PIXBUF_MODULEDIR", + ] { + if cmd + .get_env(key) + .is_some_and(|value| bundled(Path::new(value))) + { + cmd.env_remove(key); + } + } +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + fn appimage() -> CommandBuilder { + let mut cmd = CommandBuilder::new("/bin/sh"); + cmd.env_clear(); + cmd.env("APPIMAGE", "/opt/Conn.AppImage"); + cmd.env("APPDIR", "/tmp/conn-bundle"); + cmd + } + #[test] + fn keeps_host_paths_empty_segments_and_similar_prefixes() { + let mut cmd = appimage(); + cmd.env("LD_LIBRARY_PATH", "/tmp/conn-bundle/usr/lib:/opt/user/lib::/tmp/conn-bundle-other/lib:/tmp/conn-bundle/../host/lib"); + restore_host_environment(&mut cmd); + assert_eq!( + cmd.get_env("LD_LIBRARY_PATH").unwrap(), + "/opt/user/lib::/tmp/conn-bundle-other/lib:/tmp/conn-bundle/../host/lib" + ); + } + #[test] + fn removes_bundle_only_runtime_settings() { + let mut cmd = appimage(); + cmd.env( + "LD_LIBRARY_PATH", + "/tmp/conn-bundle//usr/lib:/tmp/conn-bundle/usr/lib/../lib64", + ); + cmd.env("GIO_EXTRA_MODULES", "/tmp/conn-bundle/usr/lib/gio/modules"); + cmd.env( + "GTK_IM_MODULE_FILE", + "/tmp/conn-bundle/usr/lib/immodules.cache", + ); + cmd.env("GSETTINGS_SCHEMA_DIR", "/opt/user/schemas"); + cmd.env("PATH", "/tmp/conn-bundle/usr/bin:/usr/local/bin:/usr/bin"); + restore_host_environment(&mut cmd); + assert!(cmd.get_env("LD_LIBRARY_PATH").is_none()); + assert!(cmd.get_env("GIO_EXTRA_MODULES").is_none()); + assert!(cmd.get_env("GTK_IM_MODULE_FILE").is_none()); + assert_eq!( + cmd.get_env("GSETTINGS_SCHEMA_DIR").unwrap(), + "/opt/user/schemas" + ); + assert_eq!(cmd.get_env("PATH").unwrap(), "/usr/local/bin:/usr/bin"); + } + #[test] + fn ordinary_launches_and_malformed_appdirs_are_unchanged() { + for appdir in [None, Some("/"), Some("relative/bundle")] { + let mut cmd = appimage(); + cmd.env("LD_LIBRARY_PATH", "/tmp/conn-bundle/usr/lib:/opt/user/lib"); + if let Some(path) = appdir { + cmd.env("APPDIR", path); + } else { + cmd.env_remove("APPIMAGE"); + } + restore_host_environment(&mut cmd); + assert_eq!( + cmd.get_env("LD_LIBRARY_PATH").unwrap(), + "/tmp/conn-bundle/usr/lib:/opt/user/lib" + ); + } + } +} diff --git a/crates/core/tests/child_environment.rs b/crates/core/tests/child_environment.rs new file mode 100644 index 0000000..7829e24 --- /dev/null +++ b/crates/core/tests/child_environment.rs @@ -0,0 +1,85 @@ +#![cfg(target_os = "linux")] +mod common; +use conn_core::{ + audit::Audit, + backend::Profile, + policy::{Policy, PolicyStore}, + Engine, EngineConfig, +}; +use std::{ + process::Command, + time::{Duration, Instant}, +}; + +// Each case runs in its own process: never mutate global environment alongside +// other PTY tests. Check what an actual Engine child receives, including overrides. +#[test] +fn appimage_host_shell_environment_and_profile_overrides() { + for case in ["inherited", "profile", "ordinary"] { + let mut child = Command::new(std::env::current_exe().unwrap()); + child + .args([ + "--exact", + "child_environment_fixture", + "--ignored", + "--nocapture", + ]) + .env("CONN_ENV_TEST_CASE", case) + .env("APPDIR", "/tmp/conn-bundle") + .env("APPIMAGE", "/opt/Conn.AppImage") + .env( + "LD_LIBRARY_PATH", + "/tmp/conn-bundle/usr/lib:/opt/user-lib::/opt/another-lib", + ); + if case == "ordinary" { + child.env_remove("APPIMAGE"); + } + let output = child.output().unwrap(); + assert!( + output.status.success(), + "{case}: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[test] +#[ignore = "launched by the parent with a dedicated environment"] +fn child_environment_fixture() { + let case = std::env::var("CONN_ENV_TEST_CASE").unwrap(); + let mut profile = Profile::local("environment-test".into(), "/bin/sh".into()); + profile.args = vec![ + "-c".into(), + "printf 'SHELL_LD=<%s>\\n' \"${LD_LIBRARY_PATH-UNSET}\"".into(), + ]; + if case == "profile" { + profile + .env + .insert("LD_LIBRARY_PATH".into(), "/opt/profile-lib".into()); + } + let output: common::Buf = Default::default(); + let engine = Engine::spawn(EngineConfig { + profile: Some(profile), + audit: Some(Audit::null()), + policy: Some(PolicyStore::from_policy( + Policy::parse("default: deny\n").unwrap(), + )), + output_frame: Some(common::frame_sink(&output)), + ..Default::default() + }) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !engine.has_exited() { + assert!(Instant::now() < deadline, "child did not exit"); + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(engine.wait(), Some(0)); + let expected = match case.as_str() { + "profile" => "/opt/profile-lib", + "ordinary" => "/tmp/conn-bundle/usr/lib:/opt/user-lib::/opt/another-lib", + _ => "/opt/user-lib::/opt/another-lib", + }; + assert!(String::from_utf8_lossy(&output.lock().unwrap()) + .contains(&format!("SHELL_LD=<{expected}>"))); +} diff --git a/crates/core/tests/shell_integration.rs b/crates/core/tests/shell_integration.rs index 33438c4..fd08c69 100644 --- a/crates/core/tests/shell_integration.rs +++ b/crates/core/tests/shell_integration.rs @@ -33,6 +33,8 @@ impl Shell { vec!["-i".into()] }; profile.env.insert("PS1".into(), "conn-test$ ".into()); + // Exercise Readline terminal keys independently of the test runner's TERM. + profile.env.insert("TERM".into(), "xterm-256color".into()); profile.env.insert("HISTFILE".into(), "/dev/null".into()); profile.env.insert("PROMPT_COMMAND".into(), prompt.into()); profile.env.insert("HISTCONTROL".into(), "".into()); diff --git a/packages/ui/src/components/SessionAgents.svelte b/packages/ui/src/components/SessionAgents.svelte new file mode 100644 index 0000000..42b3eb7 --- /dev/null +++ b/packages/ui/src/components/SessionAgents.svelte @@ -0,0 +1,107 @@ + + + { if (open) position(); }} /> +{#if representative} +
+ +
+{/if} +{#if open} + +{/if} + + diff --git a/packages/ui/src/components/TabStrip.svelte b/packages/ui/src/components/TabStrip.svelte index 09e51e6..999c3ca 100644 --- a/packages/ui/src/components/TabStrip.svelte +++ b/packages/ui/src/components/TabStrip.svelte @@ -4,6 +4,7 @@ const { t, st, tab, cur } = app; import { agentColor } from '../lib/themes'; import AppMenu from "./AppMenu.svelte"; + import SessionAgents from "./SessionAgents.svelte"; let { onselect, onclose, onnew, onsettings, onpalette, ontimeline }: { onpalette: () => void; ontimeline: () => void; onsettings: () => void; onselect: (id: string) => void; onclose: (id: string) => void; onnew: (profileId?: string) => void } = $props(); @@ -15,18 +16,20 @@ {@const agent = tb.controller.type === "agent"} {@const waiting = tb.proposal?.ready ? tb.proposal : null} {@const c = agent ? agentColor(tb.controller.agentId) : tb.attention ? agentColor(tb.attention.agentId) : waiting ? agentColor(waiting.agentId) : "var(--muted)"} - + + + {/each} @@ -39,22 +42,22 @@ .tabs:has(:global(.app-menu .menu)) { z-index: 29; } .new:disabled { opacity: .4; cursor: default; } .tablist { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; scrollbar-width: thin; } - .tab { flex-shrink: 0; display: flex; align-items: center; gap: 7px; height: 28px; padding: 0 10px 0 9px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--muted); font-size: 12px; cursor: pointer; max-width: 340px; transition: background .15s, color .15s, border-color .15s; } - .tab:hover { background: var(--surface2); } - .tab.on { background: var(--surface); border-color: var(--line); color: var(--fg); } - .tab.agent.on { border-color: color-mix(in srgb, var(--c) 55%, var(--line)); } + .tab-item { flex-shrink: 0; display: flex; align-items: center; gap: 7px; height: 28px; padding: 0 6px 0 9px; min-width: 0; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--muted); font-size: 12px; cursor: pointer; max-width: 340px; transition: background .15s, color .15s, border-color .15s; } + .tab-item:hover { background: var(--surface2); } + .tab-item.on { background: var(--surface); border-color: var(--line); color: var(--fg); } + .tab-item.agent.on { border-color: color-mix(in srgb, var(--c) 55%, var(--line)); } + .tab { display: flex; align-items: center; gap: 7px; min-width: 0; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; } + .tab:focus-visible, .x:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; border-radius: 4px; } .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--c); flex: 0 0 7px; } - .tab.knock .dot { animation: knock 1.4s ease-in-out infinite; } - .tab.dead .dot { background: var(--danger); } + .tab-item.knock .dot { animation: knock 1.4s ease-in-out infinite; } + .tab-item.dead .dot { background: var(--danger); } @keyframes knock { 0%, 100% { transform: scale(1); opacity: .6; } 50% { transform: scale(1.6); opacity: 1; } } .n { font-variant-numeric: tabular-nums; color: var(--muted); font-size: 11px; } - .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 4ch; } - .by { font-size: 10px; color: var(--a); border: 1px solid color-mix(in srgb, var(--a) 45%, transparent); border-radius: 999px; padding: 0 5px; line-height: 14px; white-space: nowrap; } - .badge { font-size: 10px; padding: 1px 6px; border-radius: 999px; background: var(--surface2); color: var(--fg); white-space: nowrap; } + .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; max-width: 200px; } + .badge { flex-shrink: 0; font-size: 10px; padding: 1px 6px; border-radius: 999px; background: var(--surface2); color: var(--fg); white-space: nowrap; } .badge.warn { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); } .badge.prop { background: color-mix(in srgb, var(--a) 22%, transparent); color: var(--a); } - .x { margin-left: 2px; color: transparent; padding: 0 2px; border-radius: 4px; } - .tab:hover .x { color: var(--muted); } + .x { flex: 0 0 20px; width: 20px; height: 22px; padding: 0; border: 0; background: transparent; color: var(--muted); border-radius: 4px; cursor: pointer; font: inherit; } .x:hover { color: var(--fg); background: var(--line); } .new { flex-shrink: 0; width: 26px; height: 26px; border-radius: 7px; border: 1px dashed var(--line); background: transparent; color: var(--muted); cursor: pointer; font-size: 15px; line-height: 1; } .new:hover { color: var(--fg); border-color: var(--muted); } diff --git a/packages/ui/src/lib/i18n.svelte.ts b/packages/ui/src/lib/i18n.svelte.ts index c549016..4e73bac 100644 --- a/packages/ui/src/lib/i18n.svelte.ts +++ b/packages/ui/src/lib/i18n.svelte.ts @@ -264,6 +264,10 @@ const en: Dict = { "on": "On", "human": "you", "tab.n": "tab {n}", + "tab.close": "Close {tab}", + "tab.agents.open": "Show {n} agent connections for {tab}", + "tab.agents.title": "Agent connections ({n})", + "tab.agents.connection": "Connection #{n}", "copied": "Copied", "copy.failed": "Copy failed", @@ -891,6 +895,10 @@ const ko: Dict = { "on": "켬", "human": "사람", "tab.n": "{n}번 탭", + "tab.close": "{tab} 닫기", + "tab.agents.open": "{tab}의 에이전트 연결 {n}개 보기", + "tab.agents.title": "에이전트 연결 ({n}개)", + "tab.agents.connection": "연결 #{n}", "copied": "복사됨", "copy.failed": "복사 실패", diff --git a/tests/collaboration/package.json b/tests/collaboration/package.json index 5ee9170..f9e5425 100644 --- a/tests/collaboration/package.json +++ b/tests/collaboration/package.json @@ -5,7 +5,8 @@ "scripts": { "test": "node scripts/web-mcp.mjs", "test:input": "node scripts/input-rendering.mjs", - "test:editor": "node scripts/editor-input.mjs" + "test:editor": "node scripts/editor-input.mjs", + "test:regressions": "node scripts/header-input-regressions.mjs" }, "dependencies": { "@conn/ui": "*" diff --git a/tests/collaboration/scripts/header-input-regressions.mjs b/tests/collaboration/scripts/header-input-regressions.mjs new file mode 100644 index 0000000..a162de9 --- /dev/null +++ b/tests/collaboration/scripts/header-input-regressions.mjs @@ -0,0 +1,152 @@ +// Built shared UI, real Rust PTYs and distinct live MCP connections, including +// duplicate names. Isolated settings; no production shell or user profile edits. +import {chromium} from 'playwright'; +import {spawn} from 'node:child_process'; +import {mkdtempSync, mkdirSync, writeFileSync, readFileSync, openSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {resolve, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {createInterface} from 'node:readline'; +import assert from 'node:assert/strict'; +const root=fileURLToPath(new URL('../../../',import.meta.url)); +const state=mkdtempSync(join(tmpdir(),'conn-header-input-')); +const evidence=process.env.CONN_REGRESSION_OUTPUT??join(state,'evidence');mkdirSync(evidence,{recursive:true}); +const binary=name=>resolve(process.env.CONN_TEST_TARGET_DIR??join(root,'target'),'debug',name+(process.platform==='win32'?'.exe':'')); +const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +const agents=[], cases=[], errors=[]; +let browser, page; +writeFileSync(join(state,'app.json'),JSON.stringify({mode:'autopilot',gate:true,pacing:{enterGraceMs:0,minWriteIntervalMs:0,leaseTtlSecs:120,approvalTtlSecs:120}})); +writeFileSync(join(state,'profiles.json'),JSON.stringify({version:1,revision:0,defaultProfile:'fixture',profiles:[{id:'fixture',name:'Header and input regression',backend:'local',shell:'posix',program:'/bin/bash',args:['--noprofile','--norc','-i'],env:{PS1:'fixture$ ',HISTFILE:'/dev/null'},cwd:state}]})); +const log=openSync(join(state,'runtime.log'),'w'); +const server=spawn(binary('conn-web'),['serve','--state-dir',state,'--port','0','--ui-dir',resolve(root,'frontends/web/dist'),'--setup-home',join(state,'setup-home')],{stdio:['ignore',log,log]}); +async function until(run,label) { + const end=Date.now()+15000; + do {const value=await run();if(value)return value;await delay(80);} while(Date.now(){ + const message=JSON.parse(line), pending=this.pending.get(message.id);if(!pending)return; + this.pending.delete(message.id);clearTimeout(pending.timer); + if(message.error)pending.reject(new Error(JSON.stringify(message.error)));else pending.resolve(message.result); + });agents.push(this); + } + call(method,params={}) { + const id=++this.id; + return new Promise((resolve,reject)=>{ + const timer=setTimeout(()=>{this.pending.delete(id);reject(new Error('MCP timeout: '+method));},18000); + this.pending.set(id,{resolve,reject,timer});this.child.stdin.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n'); + }); + } + async start() { + const response=await this.call('initialize',{protocolVersion:'2025-06-18',clientInfo:{name:'Conn regression test',version:'1'},capabilities:{}}); + assert.equal(response.serverInfo.version,JSON.parse(readFileSync(join(root,'packages/ui/package.json'),'utf8')).version); + await this.call('tools/list'); + } + async tool(name,args={}) { + const result=await this.call('tools/call',{name:'terminal_'+name,arguments:args}); + const text=result.content.find(item=>item.type==='text').text; + return result.isError?{error:text}:JSON.parse(text); + } + close() {if(this.closed)return;this.closed=true;this.child.stdin.end();this.child.kill();for(const pending of this.pending.values())clearTimeout(pending.timer);this.pending.clear();} +} +const input=()=>page.locator('.host:not([hidden]) .xterm-helper-textarea'); +const summary=()=>page.locator('.tab-item .agents .trigger'); +async function human(text='',keys=[]) { + await input().focus();if(text)await page.keyboard.insertText(text); + for(const key of keys){await page.keyboard.press(key);await delay(80);}await delay(200); +} +async function shareAll() { + await page.locator('button.dot:not(.hidden)').click();await page.locator('.sharing-row button').click(); + const checks=page.locator('.sharing input[type="checkbox"]'); + await until(async()=>await checks.count()===agents.filter(agent=>!agent.closed).length,'live sharing candidates'); + for(const checkbox of await checks.all())await checkbox.check(); + await page.locator('.sharing footer .btn:not(.ghost)').click();await page.locator('.sharing').waitFor({state:'hidden'}); +} +async function attach(name) { + const agent=new MCP(name);await agent.start();await page.locator('[data-request-key^="connection:"] .primary').click(); + await page.locator('[data-request-key^="connection:"]').waitFor({state:'hidden'});await shareAll(); + assert.equal((await agent.tool('list_tabs')).tabs,1);await agent.tool('snapshot');return agent; +} +async function control(agent) { + const promise=agent.tool('request_control',{reason:'Run harmless regression markers in the disposable terminal.'});promise.catch(()=>{}); + await page.locator('[data-request-key*=":control:"] .primary').click(); + const response=await promise;assert.equal(response.type,'agent'); +} +async function run(agent,marker) { + assert.ok(!(await agent.tool('type',{text:"printf '%s\\n' "+marker})).error); + const sent=await agent.tool('send_key',{key:'ENTER',intent:'Print a harmless regression marker.'}); + if(sent.status==='pending') { + await page.locator('[data-request-key*=":review:"] .btn.ok').click(); + await until(async()=>['granted','executed'].includes((await agent.tool('check_approval',{approvalId:sent.approvalId})).state),'review completes'); + } else assert.equal(sent.status,'executed'); + await until(async()=>(await agent.tool('snapshot')).screen.some(row=>row.trim()===marker),'actual output '+marker); +} +async function capture(name) {await page.screenshot({path:join(evidence,name+'.png')});cases.push(name);console.log('PASS '+name);} +async function layout(count) { + await until(async()=>await summary().count()===1 && (await summary().getAttribute('aria-label')).startsWith(`Show ${count} agent connections`),'summary count '+count); + const metrics=await page.evaluate(()=>{ + const item=document.querySelector('.tab-item'), list=document.querySelector('.tablist'), close=item.querySelector('.x'), badge=item.querySelector('.agents'); + const box=element=>{const b=element.getBoundingClientRect();return {left:b.left,right:b.right,top:b.top,bottom:b.bottom};}; + return {item:box(item),list:box(list),close:box(close),badge:box(badge),client:list.clientWidth,scroll:list.scrollWidth}; + }); + assert.ok(metrics.scroll<=metrics.client+1,JSON.stringify(metrics)); + for(const control of [metrics.close,metrics.badge])assert.ok(control.left>=metrics.list.left && control.right<=metrics.list.right+1,JSON.stringify(metrics)); + assert.equal(await page.locator('.tab-item .x').isVisible(),true);assert.equal(await page.locator('.tabs .new').isVisible(),true); +} +try { + const meta=await until(()=>{try{return JSON.parse(readFileSync(join(state,'connection.json'),'utf8'));}catch{return null;}},'web host starts'); + browser=await chromium.launch();page=await browser.newPage({viewport:{width:1120,height:720},reducedMotion:'reduce'});page.setDefaultTimeout(15000); + page.on('pageerror',error=>errors.push(error.message)); + await page.goto(meta.url+'/#token='+encodeURIComponent(meta.bootstrapToken)); + await page.locator('.host:not([hidden])[data-terminal-ready="true"]').waitFor();await human("bind 'set editing-mode emacs'",['Enter']); + const agent=await attach('same-agent');await control(agent);await run(agent,'REGRESSION_BASELINE'); + for(const [name,text,keys] of [['backspace','x',['Backspace']],['home-delete','x',['Home','Delete']],['left-empty','',['ArrowLeft']],['ctrl-l-empty','',['Control+l']],['ctrl-w','word',['Control+w']]]) { + await human('',['Control+c']);await human(text,keys);await control(agent);await run(agent,'REGRESSION_'+name.replaceAll('-','_'));await capture('input-'+name); + } + await human('',['Control+c']);await human("printf '%s\\n' HUMAN_SUFFIX",['Home','Control+u']);await control(agent); + const before=(await agent.tool('snapshot')).screen; + assert.match((await agent.tool('type',{text:"printf '%s\\n' AGENT_PREFIX; "})).error,/input_pending/); + assert.match((await agent.tool('send_key',{key:'ENTER',intent:'Check retained input protection.'})).error,/input_pending/); + assert.deepEqual((await agent.tool('snapshot')).screen,before);await capture('retained-input-blocked');await human('',['Control+c']); + for(let count=2;count<=5;count++){await attach('same-agent');await layout(count);} + await capture('repeated-five-summary'); + for(let count=6;count<=7;count++)await attach('long-agent-name-repeated-for-overflow-abcdefghijklmnopqrst'); + for(const width of [720,1120,1280,1920]) { + await page.setViewportSize({width,height:720});await layout(7);await summary().click(); + const popup=page.getByRole('dialog',{name:'Agent connections (7)',exact:true});await popup.waitFor();assert.equal(await popup.locator('li').count(),7); + const names=await popup.locator('.agent-name').allTextContents();assert.equal(names.filter(name=>name==='same-agent').length,5);assert.equal(names.filter(name=>name.startsWith('long-agent-name')).length,2); + assert.equal(new Set(await popup.locator('li small').allTextContents()).size,7,'same names retain distinct connection IDs'); + const bounds=await popup.boundingBox();assert.ok(bounds.x>=0 && bounds.x+bounds.width<=width && bounds.y+bounds.height<=720); + assert.equal(await popup.evaluate(element=>Boolean(element.closest('.tablist'))),false,'details escape the clipping container'); + await capture('seven-connections-'+width);await page.keyboard.press('Escape');await popup.waitFor({state:'hidden'}); + assert.equal(await summary().evaluate(element=>element===document.activeElement),true,'Escape restores trigger focus'); + } + await control(agents.at(-1));await layout(7); + assert.equal(await summary().locator('.name').textContent(),agents.at(-1).name,'current controller becomes the representative'); + assert.ok((await summary().boundingBox()).width<=132,'long representative stays bounded'); + await summary().click();await page.getByRole('dialog',{name:'Agent connections (7)',exact:true}).waitFor(); + assert.ok((await page.locator('.connections .control').textContent()).includes(agents.at(-1).name),'full controller name remains accessible'); + await capture('long-controller-summary');await page.keyboard.press('Escape');await human('',['Control+c']); + await summary().focus();await page.keyboard.press('Enter');await page.getByRole('dialog',{name:'Agent connections (7)',exact:true}).waitFor(); + await page.keyboard.press('Tab');assert.equal(await page.locator('.tab-item .x').evaluate(element=>element===document.activeElement),true,'Tab continues to close control'); + await summary().click();for(const other of agents.slice(1))other.close();await layout(1); + await page.getByRole('dialog',{name:'Agent connections (1)',exact:true}).waitFor();assert.equal(await page.locator('.connections li').count(),1); + await page.keyboard.press('Escape');await capture('after-disconnect'); + await page.locator('.app-menu .trigger').click();await page.getByRole('menuitem',{name:/^Settings/}).click(); + await page.locator('.category-list').getByRole('button',{name:'Appearance',exact:true}).click();await page.getByRole('button',{name:'한국어',exact:true}).click(); + await page.locator('.sheet header .btn.ghost').click();assert.match(await summary().getAttribute('aria-label'),/에이전트 연결 1개 보기/); + await summary().click();await page.getByRole('dialog',{name:'에이전트 연결 (1개)',exact:true}).waitFor();await capture('connections-korean');await page.keyboard.press('Escape'); + await summary().click();agent.close();await summary().waitFor({state:'hidden'});await page.locator('.connections').waitFor({state:'hidden'}); + assert.equal(await page.getByRole('tab').evaluate(element=>element===document.activeElement),true,'last disconnect restores the tab focus'); + await page.locator('.tabs .new').click();await until(async()=>await page.getByRole('tab').count()===2,'new session stays usable'); + await page.locator('.tab-item .x').last().click();await until(async()=>await page.getByRole('tab').count()===1,'close control stays usable'); + assert.deepEqual(errors,[]);await capture('session-controls'); +} catch(error) {process.exitCode=1;console.error(error);if(page)await page.screenshot({path:join(evidence,'failure.png')});} +finally { + writeFileSync(join(evidence,'results.json'),JSON.stringify({cases,errors,passed:!process.exitCode},null,2)); + for(const agent of agents)agent.close();await browser?.close();server.kill(); +}