From 26eb5b536438187c18dfa70f40dd4dbaf53bef55 Mon Sep 17 00:00:00 2001 From: GCWing Date: Wed, 30 Sep 2026 16:20:18 +0800 Subject: [PATCH] fix: preserve browser scroll position and configure commit attribution --- scripts/check-core-boundaries.test.mjs | 7 + .../cargo-dependency-boundaries.mjs | 3 +- src/crates/adapters/webdriver/Cargo.toml | 4 +- .../webdriver/src/runtime/api/element.rs | 4 +- .../src/runtime/script/core/execution.rs | 2 + .../src/runtime/script/core/interaction.rs | 28 ++ .../webdriver/src/runtime/script/core/mod.rs | 6 +- .../webdriver/src/runtime/script/input.rs | 8 +- .../src/runtime/script/pointer/mouse.rs | 21 +- .../runtime/script/pointer/pointer_source.rs | 4 +- .../src/runtime/script/pointer/wheel.rs | 35 +- .../builtin_skills/commit-push-pr/SKILL.md | 12 +- .../agentic/tools/browser_control/actions.rs | 12 +- .../tools/implementations/skills/mod.rs | 1 + .../tools/implementations/skills/registry.rs | 5 + .../skills/runtime_settings.rs | 100 ++++++ .../contracts/config-contracts/src/types.rs | 28 ++ .../app/scenes/settings/settingsRegistry.ts | 9 +- .../config/components/QuickActionsConfig.tsx | 48 ++- .../AIExperienceConfigService.test.ts | 43 +++ .../services/AIExperienceConfigService.ts | 9 +- .../src/infrastructure/config/types/index.ts | 2 + .../locales/en-US/settings/quick-actions.json | 7 + .../locales/zh-CN/settings/quick-actions.json | 7 + .../locales/zh-TW/settings/quick-actions.json | 7 + .../e2e/page-objects/components/ChatInput.ts | 2 +- tests/e2e/page-objects/components/Header.ts | 6 +- tests/e2e/scripts/run-interaction-scroll.mjs | 308 ++++++++++++++++++ 28 files changed, 673 insertions(+), 55 deletions(-) create mode 100644 src/crates/adapters/webdriver/src/runtime/script/core/interaction.rs create mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/skills/runtime_settings.rs create mode 100644 tests/e2e/scripts/run-interaction-scroll.mjs diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 3310ef6a91..addba6131b 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -3081,6 +3081,10 @@ test('third-party capability profiles reject ambient feature unions and unreview uses_default_features: false, features: ['vendored-libgit2'], }]), + packageAt('openbitfun-webdriver', 'src/crates/adapters/webdriver/Cargo.toml', [{ + name: 'image', kind: null, optional: false, uses_default_features: false, + features: ['jpeg', 'png'], + }]), ]; assert.deepEqual(findThirdPartyCapabilityFeatureViolations(validPackages), []); @@ -3091,6 +3095,7 @@ test('third-party capability profiles reject ambient feature unions and unreview mutatedPackages[2].dependencies[0].features.push('rustls-tls-native-roots'); mutatedPackages[3].dependencies[0].features.push('https'); mutatedPackages[3].dependencies[0].rename = 'private-git2'; + mutatedPackages[4].dependencies[0].features = ['png', 'webp']; mutatedPackages.push(packageAt('future-image-owner', 'src/apps/future/Cargo.toml', [{ name: 'image', kind: null, @@ -3107,6 +3112,8 @@ test('third-party capability profiles reject ambient feature unions and unreview assert.match(messages, /openbitfun-core Tokio Tungstenite dependency has unexpected features: rustls-tls-native-roots/); assert.match(messages, /openbitfun-services-core Git2 dependency has unexpected features: https/); assert.match(messages, /openbitfun-services-core Git2 dependency does not match its reviewed owner shape/); + assert.match(messages, /openbitfun-webdriver Image dependency missing features: jpeg/); + assert.match(messages, /openbitfun-webdriver Image dependency has unexpected features: webp/); assert.match(messages, /future-image-owner Image dependency is missing a reviewed owner profile/); }); diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 7e9162a579..06f3c853a6 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -574,7 +574,8 @@ const THIRD_PARTY_CAPABILITY_PROFILES = new Map([ ['openbitfun-skin-market-service', dependencyProfile(['gif', 'jpeg', 'png', 'webp'], { useDefaultFeatures: false, })], - ['openbitfun-webdriver', dependencyProfile(['png'], { + // The embedded bridge captures PNG and encodes JPEG previews itself. + ['openbitfun-webdriver', dependencyProfile(['jpeg', 'png'], { useDefaultFeatures: false, })], ]), diff --git a/src/crates/adapters/webdriver/Cargo.toml b/src/crates/adapters/webdriver/Cargo.toml index 52b191342b..7232d63728 100644 --- a/src/crates/adapters/webdriver/Cargo.toml +++ b/src/crates/adapters/webdriver/Cargo.toml @@ -16,10 +16,10 @@ tokio = { workspace = true, features = ["net", "rt", "sync", "time"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } -tauri = { workspace = true } +tauri = { workspace = true, features = ["unstable"] } uuid = { workspace = true } base64 = { workspace = true } -image = { workspace = true, features = ["png"] } +image = { workspace = true, features = ["jpeg", "png"] } [target.'cfg(target_os = "macos")'.dependencies] block2 = { workspace = true } diff --git a/src/crates/adapters/webdriver/src/runtime/api/element.rs b/src/crates/adapters/webdriver/src/runtime/api/element.rs index c2130eed4b..dca099d84f 100644 --- a/src/crates/adapters/webdriver/src/runtime/api/element.rs +++ b/src/crates/adapters/webdriver/src/runtime/api/element.rs @@ -67,11 +67,11 @@ pub(crate) fn clear() -> &'static str { } pub(crate) fn send_keys() -> &'static str { - "(id, text) => { const el = window.__openbitfunWd.getElement(id); if (!el) { throw new Error('Element not found'); } window.__openbitfunWd.insertText(el, text); return null; }" + "(id, text) => { const el = window.__openbitfunWd.getElement(id); if (!el) { throw new Error('Element not found'); } if ('value' in el || el.isContentEditable) { window.__openbitfunWd.scrollElementIntoView(el); window.__openbitfunWd.focusWithoutScroll(el); } window.__openbitfunWd.insertText(el, text); return null; }" } pub(crate) fn screenshot_metadata() -> &'static str { - "(id) => { const el = window.__openbitfunWd.getElement(id); if (!el || !el.isConnected) { throw new Error('stale element reference'); } el.scrollIntoView({ block: 'center', inline: 'center' }); const rect = el.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, devicePixelRatio: window.devicePixelRatio || 1 }; }" + "(id) => { const el = window.__openbitfunWd.getElement(id); if (!el || !el.isConnected) { throw new Error('stale element reference'); } window.__openbitfunWd.scrollElementIntoView(el); const rect = el.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, devicePixelRatio: window.devicePixelRatio || 1 }; }" } pub(crate) fn get_shadow_root() -> &'static str { diff --git a/src/crates/adapters/webdriver/src/runtime/script/core/execution.rs b/src/crates/adapters/webdriver/src/runtime/script/core/execution.rs index 09289a117c..aca1e50b50 100644 --- a/src/crates/adapters/webdriver/src/runtime/script/core/execution.rs +++ b/src/crates/adapters/webdriver/src/runtime/script/core/execution.rs @@ -78,6 +78,8 @@ pub(super) fn script() -> &'static str { validateFrameElement, getShadowRoot, isDisplayed, + scrollElementIntoView, + focusWithoutScroll, clearElement, insertText, setElementText, diff --git a/src/crates/adapters/webdriver/src/runtime/script/core/interaction.rs b/src/crates/adapters/webdriver/src/runtime/script/core/interaction.rs new file mode 100644 index 0000000000..ebc80e2e8e --- /dev/null +++ b/src/crates/adapters/webdriver/src/runtime/script/core/interaction.rs @@ -0,0 +1,28 @@ +pub(super) fn script() -> &'static str { + r####" + // Reveal an offscreen target with minimal movement. Centering also scrolls + // overflow:hidden ancestors, which can displace the surrounding app chrome. + const scrollElementIntoView = (element) => { + element.scrollIntoView({ behavior: "instant", block: "nearest", inline: "nearest" }); + }; + + // Positioning belongs to the caller; focus must not move it again, especially + // after pointer coordinates have already been resolved. + const focusWithoutScroll = (element) => { + if (typeof element.focus === "function") { + element.focus({ preventScroll: true }); + } + }; + + // A large target can remain only partly visible after nearest scrolling. + // Use its visible center rather than a point outside the viewport. + const getInViewCenter = (element) => { + const rect = element.getClientRects()[0] || element.getBoundingClientRect(); + const ownerWindow = element.ownerDocument.defaultView || window; + return { + x: (Math.max(0, rect.left) + Math.min(ownerWindow.innerWidth, rect.right)) / 2, + y: (Math.max(0, rect.top) + Math.min(ownerWindow.innerHeight, rect.bottom)) / 2 + }; + }; +"#### +} diff --git a/src/crates/adapters/webdriver/src/runtime/script/core/mod.rs b/src/crates/adapters/webdriver/src/runtime/script/core/mod.rs index 09513b8637..54c74a2b73 100644 --- a/src/crates/adapters/webdriver/src/runtime/script/core/mod.rs +++ b/src/crates/adapters/webdriver/src/runtime/script/core/mod.rs @@ -2,6 +2,7 @@ mod alert; mod context; mod cookie; mod execution; +mod interaction; mod locator; mod runtime; mod shadow; @@ -10,13 +11,14 @@ mod visibility; pub(super) fn head() -> String { format!( - "{runtime}{store}{context}{locator}{shadow}{visibility}", + "{runtime}{store}{context}{locator}{shadow}{visibility}{interaction}", runtime = runtime::script(), store = store::script(), context = context::script(), locator = locator::script(), shadow = shadow::script(), - visibility = visibility::script() + visibility = visibility::script(), + interaction = interaction::script() ) } diff --git a/src/crates/adapters/webdriver/src/runtime/script/input.rs b/src/crates/adapters/webdriver/src/runtime/script/input.rs index fce5f22e49..bdc5543fa7 100644 --- a/src/crates/adapters/webdriver/src/runtime/script/input.rs +++ b/src/crates/adapters/webdriver/src/runtime/script/input.rs @@ -72,8 +72,11 @@ pub(super) fn script() -> &'static str { if (!element) { return; } + if ("value" in element || element.isContentEditable) { + scrollElementIntoView(element); + focusWithoutScroll(element); + } if ("value" in element) { - element.focus(); if (!dispatchBeforeInputEvent(element, "deleteContentBackward", null)) { return; } @@ -82,7 +85,6 @@ pub(super) fn script() -> &'static str { return; } if (element.isContentEditable) { - element.focus(); element.textContent = ""; emitInputEvents(element, "deleteContentBackward", null); } @@ -112,7 +114,7 @@ pub(super) fn script() -> &'static str { return; } const selection = ownerWindow.getSelection(); - element.focus(); + focusWithoutScroll(element); if (selection && selection.rangeCount > 0) { selection.deleteFromDocument(); selection.getRangeAt(0).insertNode(element.ownerDocument.createTextNode(text)); diff --git a/src/crates/adapters/webdriver/src/runtime/script/pointer/mouse.rs b/src/crates/adapters/webdriver/src/runtime/script/pointer/mouse.rs index b00b5ba695..793179392d 100644 --- a/src/crates/adapters/webdriver/src/runtime/script/pointer/mouse.rs +++ b/src/crates/adapters/webdriver/src/runtime/script/pointer/mouse.rs @@ -37,15 +37,16 @@ pub(super) fn script() -> &'static str { }; } - if (origin && typeof origin === "object" && typeof origin[ELEMENT_KEY] === "string") { - const element = getElement(origin[ELEMENT_KEY]); + // Script arguments have already deserialized wire element references. + if (isElementLike(origin) || (origin && typeof origin === "object" && typeof origin[ELEMENT_KEY] === "string")) { + const element = isElementLike(origin) ? origin : getElement(origin[ELEMENT_KEY]); if (!element) { throw new Error("Element not found"); } - const rect = element.getBoundingClientRect(); + const { x, y } = getInViewCenter(element); return { - x: rect.left + rect.width / 2 + (Number(action?.x) || 0), - y: rect.top + rect.height / 2 + (Number(action?.y) || 0), + x: x + (Number(action?.x) || 0), + y: y + (Number(action?.y) || 0), target: element }; } @@ -108,13 +109,9 @@ pub(super) fn script() -> &'static str { if (!element) { throw new Error("Element not found"); } - element.scrollIntoView({ block: "center", inline: "center" }); - if (typeof element.focus === "function") { - element.focus(); - } - const rect = element.getBoundingClientRect(); - const x = rect.left + rect.width / 2; - const y = rect.top + rect.height / 2; + scrollElementIntoView(element); + focusWithoutScroll(element); + const { x, y } = getInViewCenter(element); updatePointerTarget(getFrameContext(), x, y, element); const runtime = ensureRuntimeState(); const buttonMask = pointerButtonMask(button); diff --git a/src/crates/adapters/webdriver/src/runtime/script/pointer/pointer_source.rs b/src/crates/adapters/webdriver/src/runtime/script/pointer/pointer_source.rs index c130e81433..14bc022e0e 100644 --- a/src/crates/adapters/webdriver/src/runtime/script/pointer/pointer_source.rs +++ b/src/crates/adapters/webdriver/src/runtime/script/pointer/pointer_source.rs @@ -35,9 +35,7 @@ pub(super) fn script() -> &'static str { if (action.duration) { await sleep(action.duration); } - if (typeof target.focus === "function") { - target.focus(); - } + focusWithoutScroll(target); runtime.pointer.buttons |= buttonMask; dispatchMouseEvent(target, "mousedown", runtime.pointer.x, runtime.pointer.y, button, runtime.pointer.buttons, frameContext); continue; diff --git a/src/crates/adapters/webdriver/src/runtime/script/pointer/wheel.rs b/src/crates/adapters/webdriver/src/runtime/script/pointer/wheel.rs index e9551d7d89..5cc0a72131 100644 --- a/src/crates/adapters/webdriver/src/runtime/script/pointer/wheel.rs +++ b/src/crates/adapters/webdriver/src/runtime/script/pointer/wheel.rs @@ -1,22 +1,41 @@ pub(super) fn script() -> &'static str { r####" - const findScrollableTarget = (target, doc) => { + const findScrollableTarget = (target, doc, deltaX, deltaY) => { let current = target; while (current && current !== doc.body && current !== doc.documentElement) { - if ( - (current.scrollHeight > current.clientHeight || current.scrollWidth > current.clientWidth) && - current instanceof Element - ) { - return current; + if (isElementLike(current)) { + const style = getOwnerWindow(current).getComputedStyle(current); + const wheelScrollable = (overflow) => ["auto", "scroll", "overlay"].includes(overflow); + // overflow:hidden can be scrolled by script, but never by a user wheel. + if ( + (deltaX && current.scrollWidth > current.clientWidth && wheelScrollable(style.overflowX)) || + (deltaY && current.scrollHeight > current.clientHeight && wheelScrollable(style.overflowY)) + ) { + return current; + } } current = current.parentElement; } - return doc.scrollingElement || doc.documentElement || doc.body; + const viewport = doc.scrollingElement || doc.documentElement || doc.body; + if (!viewport) { + return null; + } + const ownerWindow = doc.defaultView || window; + const style = ownerWindow.getComputedStyle(viewport); + const bodyStyle = doc.body ? ownerWindow.getComputedStyle(doc.body) : style; + // Root overflow:visible propagates the body's overflow to the viewport. + const overflowX = style.overflowX === "visible" ? bodyStyle.overflowX : style.overflowX; + const overflowY = style.overflowY === "visible" ? bodyStyle.overflowY : style.overflowY; + const wheelScrollable = (overflow) => overflow !== "hidden" && overflow !== "clip"; + return ( + (deltaX && viewport.scrollWidth > viewport.clientWidth && wheelScrollable(overflowX)) || + (deltaY && viewport.scrollHeight > viewport.clientHeight && wheelScrollable(overflowY)) + ) ? viewport : null; }; const applyWheelScroll = (target, deltaX, deltaY, frameContext = currentFrameContext) => { const doc = getCurrentDocument(frameContext); - const scrollTarget = findScrollableTarget(target, doc); + const scrollTarget = findScrollableTarget(target, doc, deltaX, deltaY); if (!scrollTarget) { return; } diff --git a/src/crates/assembly/core/builtin_skills/commit-push-pr/SKILL.md b/src/crates/assembly/core/builtin_skills/commit-push-pr/SKILL.md index d3e5d2357d..7f28a2a967 100644 --- a/src/crates/assembly/core/builtin_skills/commit-push-pr/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/commit-push-pr/SKILL.md @@ -13,9 +13,11 @@ Invoking this skill is authorization for the requested commit/push/PR flow. It i ## OpenBitFun attribution contract -Unless the user explicitly opts out, always use the PR footer below exactly once. When this flow creates one or more real commits, also use the commit trailer exactly once in each new commit: +Unless the user explicitly opts out, use the PR footer below exactly once. Commit co-author attribution follows the execution host's setting, resolved when this built-in skill is loaded: -- Commit trailer for each new commit, which GitHub uses for contributor/co-author attribution: +{{OPENBITFUN_GIT_COMMIT_COAUTHOR_POLICY}} + +- Commit trailer when co-author attribution is enabled, which GitHub uses for contributor/co-author attribution: ```text Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> @@ -37,7 +39,7 @@ Do not duplicate either attribution. Preserve an equivalent existing trailer/foo 2. Confirm scope; stage only intended paths. 3. Create `openbitfun/` only when currently on the base branch. 4. Run the repository's focused verification. -5. Commit real changes with the exact OpenBitFun trailer once. +5. Commit real changes, following the resolved commit co-author setting above. 6. Push normally; never force-push automatically. 7. Update the exact matching PR or create a draft PR. 8. End the PR body with the exact OpenBitFun link footer once. @@ -104,11 +106,11 @@ If verification remains failed, blocked, or was explicitly declined by the user: - Derive the subject from the actual diff and repository conventions; do not create a generic `prepare PR` commit. - Keep the real human author/committer identity. The OpenBitFun line is a co-author trailer, not a replacement author. -- Add a blank line before the exact OpenBitFun trailer. +- Only when commit co-author attribution is enabled and the user has not opted out, add a blank line before the exact OpenBitFun trailer. When disabled, do not add this trailer to new commits. - If the intended changes are already committed, do not create an empty attribution commit. - Do not squash multiple existing commits or amend published commits without explicit approval. -Example: +Example when commit co-author attribution is enabled: ```text fix: preserve session state during reconnect diff --git a/src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs b/src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs index 34181beef0..7a6eaaa52c 100644 --- a/src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs +++ b/src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs @@ -648,10 +648,12 @@ impl<'a> BrowserActions<'a> { format!( r#"(function(){{ {js} - el.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }}); - const rect = el.getBoundingClientRect(); - const localX = rect.x + rect.width / 2; - const localY = rect.y + rect.height / 2; + // Reveal the target without repositioning already visible content. + el.scrollIntoView({{ block: 'nearest', inline: 'nearest', behavior: 'instant' }}); + const rect = el.getClientRects()[0] || el.getBoundingClientRect(); + const ownerWindow = el.ownerDocument.defaultView || window; + const localX = (Math.max(0, rect.left) + Math.min(ownerWindow.innerWidth, rect.right)) / 2; + const localY = (Math.max(0, rect.top) + Math.min(ownerWindow.innerHeight, rect.bottom)) / 2; let x = localX; let y = localY; try {{ @@ -721,7 +723,7 @@ impl<'a> BrowserActions<'a> { pub async fn fill(&self, selector: &str, value: &str) -> OpenBitFunResult { let js = Self::resolve_element_js(selector); let focus_js = format!( - r#"(function(){{ {} el.focus(); el.value = ''; el.dispatchEvent(new Event('input', {{ bubbles: true }})); return true; }})()"#, + r#"(function(){{ {} el.scrollIntoView({{ block: 'nearest', inline: 'nearest', behavior: 'instant' }}); el.focus({{ preventScroll: true }}); el.value = ''; el.dispatchEvent(new Event('input', {{ bubbles: true }})); return true; }})()"#, js ); self.evaluate(&focus_js).await?; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/mod.rs index 3eba9e1981..394bd6da63 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/mod.rs @@ -8,6 +8,7 @@ pub mod mode_overrides; pub mod policy; pub mod registry; pub mod resolver; +mod runtime_settings; #[cfg(feature = "file-watch")] mod source_cache; pub mod types; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs index 502ba37783..1d22cd3a06 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs @@ -8,6 +8,7 @@ use super::mode_overrides::{ load_globally_disabled_project_skills, load_globally_disabled_user_skills, load_user_mode_skill_overrides, SkillPolicyWorkspace, UserModeSkillOverrides, }; +use super::runtime_settings::apply_builtin_runtime_settings; #[cfg(feature = "file-watch")] use super::source_cache::{LocalSkillWatchMonitor, LocalSkillWatchRoot, VersionedSnapshotCache}; use super::types::{ @@ -1996,6 +1997,7 @@ impl SkillRegistry { data.source_id = info.source_id; data.source_label = info.source_label; data.dir_name = info.dir_name; + apply_builtin_runtime_settings(&mut data).await?; Ok(data) } @@ -2040,6 +2042,7 @@ impl SkillRegistry { data.source_id = info.source_id; data.source_label = info.source_label; data.dir_name = info.dir_name; + apply_builtin_runtime_settings(&mut data).await?; Ok(data) } @@ -2075,6 +2078,7 @@ impl SkillRegistry { data.source_id = info.source_id; data.source_label = info.source_label; data.dir_name = info.dir_name; + apply_builtin_runtime_settings(&mut data).await?; Ok(data) } @@ -2118,6 +2122,7 @@ impl SkillRegistry { data.source_id = info.source_id; data.source_label = info.source_label; data.dir_name = info.dir_name; + apply_builtin_runtime_settings(&mut data).await?; Ok(data) } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/runtime_settings.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/runtime_settings.rs new file mode 100644 index 0000000000..330fcdb520 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/runtime_settings.rs @@ -0,0 +1,100 @@ +//! Execution-host settings for product-owned built-in skill instructions. + +use super::types::SkillData; +use crate::service::config::global::GlobalConfigManager; +use crate::service::config::types::AIExperienceConfig; +use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; +use openbitfun_agent_runtime::skills::OPENBITFUN_SYSTEM_SKILL_SLOT; + +const COMMIT_SKILL_KEY: &str = "user::openbitfun-system::commit-push-pr"; +const COMMIT_COAUTHOR_POLICY_PLACEHOLDER: &str = "{{OPENBITFUN_GIT_COMMIT_COAUTHOR_POLICY}}"; + +pub(super) async fn apply_builtin_runtime_settings(skill: &mut SkillData) -> OpenBitFunResult<()> { + if skill.key != COMMIT_SKILL_KEY || skill.source_slot != OPENBITFUN_SYSTEM_SKILL_SLOT { + return Ok(()); + } + + // The runtime owns this preference even when the workspace or controller is remote. + // Configuration errors must not silently restore attribution after an opt-out. + let config_service = GlobalConfigManager::get_service().await?; + let settings: AIExperienceConfig = config_service.get_config(Some("app.ai_experience")).await?; + skill.content = + render_commit_coauthor_policy(&skill.content, settings.enable_git_commit_coauthor)?; + Ok(()) +} + +fn render_commit_coauthor_policy(content: &str, enabled: bool) -> OpenBitFunResult { + if !content.contains(COMMIT_COAUTHOR_POLICY_PLACEHOLDER) { + return Err(OpenBitFunError::tool( + "Built-in commit skill is missing its runtime co-author setting".to_string(), + )); + } + + let policy = if enabled { + "The execution host setting `app.ai_experience.enable_git_commit_coauthor` is `true`. Unless the user explicitly opts out, add the exact OpenBitFun co-author trailer below exactly once to each new commit." + } else { + "The execution host setting `app.ai_experience.enable_git_commit_coauthor` is `false`. Do not add the OpenBitFun co-author trailer to new commits. Preserve other co-authors and existing commit history." + }; + Ok(content.replace(COMMIT_COAUTHOR_POLICY_PLACEHOLDER, policy)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::tools::implementations::skills::types::{ + render_loaded_skill_for_assistant, SkillLocation, + }; + + const COMMIT_SKILL: &str = + include_str!("../../../../../builtin_skills/commit-push-pr/SKILL.md"); + + #[test] + fn builtin_commit_coauthor_policy_resolves_both_preferences_for_the_assistant() { + for enabled in [true, false] { + let mut skill = SkillData::from_markdown( + "/openbitfun-system/commit-push-pr".to_string(), + COMMIT_SKILL, + SkillLocation::User, + true, + ) + .unwrap(); + skill.content = render_commit_coauthor_policy(&skill.content, enabled).unwrap(); + let assistant = render_loaded_skill_for_assistant(&skill, false); + assert!(!assistant.contains(COMMIT_COAUTHOR_POLICY_PLACEHOLDER)); + assert!(assistant.contains(&format!( + "`app.ai_experience.enable_git_commit_coauthor` is `{enabled}`" + ))); + if enabled { + assert!(assistant + .contains("add the exact OpenBitFun co-author trailer below exactly once")); + } else { + assert!(assistant + .contains("Do not add the OpenBitFun co-author trailer to new commits")); + assert!(!assistant + .contains("add the exact OpenBitFun co-author trailer below exactly once")); + } + assert!(assistant.contains("Generated with [OpenBitFun](https://github.com/bitfun-ai)")); + } + } + + #[tokio::test] + async fn custom_commit_skills_do_not_receive_product_runtime_settings() { + let mut skill = SkillData::from_markdown( + "/workspace/.agents/skills/commit-push-pr".to_string(), + COMMIT_SKILL, + SkillLocation::Project, + true, + ) + .unwrap(); + let original = skill.content.clone(); + apply_builtin_runtime_settings(&mut skill).await.unwrap(); + assert_eq!(skill.content, original); + } + + #[test] + fn commit_coauthor_policy_rejects_an_unresolved_builtin_contract() { + assert!( + render_commit_coauthor_policy("Unresolved attribution instructions", false).is_err() + ); + } +} diff --git a/src/crates/contracts/config-contracts/src/types.rs b/src/crates/contracts/config-contracts/src/types.rs index 4829aea80d..5710550161 100644 --- a/src/crates/contracts/config-contracts/src/types.rs +++ b/src/crates/contracts/config-contracts/src/types.rs @@ -602,6 +602,9 @@ pub struct AIExperienceConfig { /// User-defined quick actions (post-coding menu); persisted for the web UI. #[serde(default = "default_quick_actions")] pub quick_actions: Vec, + /// Whether built-in commit workflows add OpenBitFun as a Git co-author. + #[serde(default = "default_true")] + pub enable_git_commit_coauthor: bool, } fn default_quick_actions() -> Vec { @@ -1936,6 +1939,7 @@ impl Default for AIExperienceConfig { enable_workspace_search: false, voice_input: VoiceInputConfig::default(), quick_actions: default_quick_actions(), + enable_git_commit_coauthor: true, } } } @@ -2217,6 +2221,30 @@ impl AIModelConfig { #[cfg(test)] mod tests { + #[test] + fn git_commit_coauthor_defaults_for_legacy_settings_and_preserves_opt_out() { + let legacy = serde_json::json!({ + "enable_visual_mode": true, + "quick_actions": [{ + "id": "custom", "label": "Review", "prompt": "Review changes", "enabled": false + }] + }); + let settings: super::AIExperienceConfig = serde_json::from_value(legacy.clone()).unwrap(); + assert!(settings.enable_git_commit_coauthor); + + let mut persisted = serde_json::to_value(settings).unwrap(); + assert_eq!( + persisted["enable_visual_mode"], + legacy["enable_visual_mode"] + ); + assert_eq!(persisted["quick_actions"], legacy["quick_actions"]); + persisted["enable_git_commit_coauthor"] = serde_json::json!(false); + let opted_out: super::AIExperienceConfig = + serde_json::from_value(persisted.clone()).unwrap(); + assert!(!opted_out.enable_git_commit_coauthor); + assert_eq!(serde_json::to_value(opted_out).unwrap(), persisted); + } + #[test] fn global_skill_settings_keep_legacy_values_and_project_scope_on_round_trip() { let legacy = r#"{"globally_disabled_user_skills":["user::home.agents::review"]}"#; diff --git a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts index 71966f3084..0ee63423f9 100644 --- a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts @@ -283,8 +283,13 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [ { id: 'quick-actions', labelKey: 'navigation.views.quick-actions', - keywords: ['quick action', 'commit', 'pull request', 'post coding'], - searchPhrases: [phrase('settings/quick-actions', 'page.title'), phrase('settings/quick-actions', 'page.subtitle')], + keywords: ['quick action', 'commit', 'pull request', 'post coding', 'co-author', 'coauthor', 'attribution', 'github', 'https://github.com/bitfun-ai'], + searchPhrases: [ + phrase('settings/quick-actions', 'page.title'), + phrase('settings/quick-actions', 'page.subtitle'), + phrase('settings/quick-actions', 'commitAttribution.title'), + phrase('settings/quick-actions', 'commitAttribution.label'), + ], }, { id: 'hooks', diff --git a/src/web-ui/src/infrastructure/config/components/QuickActionsConfig.tsx b/src/web-ui/src/infrastructure/config/components/QuickActionsConfig.tsx index cf98ebd96a..6e7ba405d2 100644 --- a/src/web-ui/src/infrastructure/config/components/QuickActionsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/QuickActionsConfig.tsx @@ -25,11 +25,13 @@ import { ConfigPageLayout, ConfigPageContent, ConfigPageSection, + ConfigPageRow, } from './common'; import { aiExperienceConfigService, DEFAULT_QUICK_ACTIONS, type QuickAction, + type AIExperienceSettingsPatch, } from '../services/AIExperienceConfigService'; import { normalizeQuickActionTextForStorage, @@ -46,6 +48,7 @@ import './QuickActionsConfig.scss'; const log = createLogger('QuickActionsConfig'); const BUILTIN_IDS = new Set(['commit', 'create_pr']); +const COMMIT_COAUTHOR_ACCOUNT = `@${new URL('https://github.com/bitfun-ai').pathname.slice(1)}`; type TranslationFn = (key: string, options?: Record) => string; @@ -273,6 +276,7 @@ const QuickActionsConfig: React.FC = () => { const [loadFailed, setLoadFailed] = useState(false); const [saving, setSaving] = useState(false); const [actions, setActions] = useState([]); + const [commitCoauthorEnabled, setCommitCoauthorEnabled] = useState(); const saveQueueRef = useRef>(Promise.resolve()); const pendingSaveCountRef = useRef(0); @@ -285,9 +289,10 @@ const QuickActionsConfig: React.FC = () => { setLoading(true); setLoadFailed(false); try { - const settings = await aiExperienceConfigService.getSettingsAsync(); + const settings = await aiExperienceConfigService.getSettingsAsync({ forceRefresh: true, requireLoaded: true }); const stored = settings.quick_actions; setActions(stored ?? DEFAULT_QUICK_ACTIONS); + setCommitCoauthorEnabled(settings.enable_git_commit_coauthor); } catch (error) { log.error('Failed to load quick actions', error); setLoadFailed(true); @@ -298,7 +303,7 @@ const QuickActionsConfig: React.FC = () => { useEffect(() => { void load(); }, [load]); - const persist = useCallback((next: QuickAction[]): Promise => { + const persistSettings = useCallback((patch: AIExperienceSettingsPatch, onSaved: () => void): Promise => { if (pendingSaveCountRef.current > 0) { return Promise.resolve(false); } @@ -306,12 +311,12 @@ const QuickActionsConfig: React.FC = () => { setSaving(true); const operation = saveQueueRef.current.then(async () => { try { - await aiExperienceConfigService.saveSettings({ quick_actions: next }); - setActions(next); + await aiExperienceConfigService.saveSettings(patch); + onSaved(); notification.success(t('messages.saved')); return true; } catch (error) { - log.error('Failed to save quick actions', error); + log.error('Failed to save quick action settings', error); notification.error(t('messages.saveFailed')); return false; } finally { @@ -323,6 +328,19 @@ const QuickActionsConfig: React.FC = () => { return operation; }, [notification, t]); + const persist = useCallback((next: QuickAction[]) => persistSettings( + { quick_actions: next }, + () => setActions(next), + ), [persistSettings]); + + const handleCommitCoauthorToggle = useCallback((enabled: boolean) => { + if (saving || commitCoauthorEnabled === undefined) return; + void persistSettings( + { enable_git_commit_coauthor: enabled }, + () => setCommitCoauthorEnabled(enabled), + ); + }, [commitCoauthorEnabled, persistSettings, saving]); + const handleToggle = useCallback((id: string) => { if (saving) return; void persist(actions.map(a => a.id === id ? { ...a, enabled: !a.enabled } : a)); @@ -405,6 +423,26 @@ const QuickActionsConfig: React.FC = () => { + + + handleCommitCoauthorToggle(event.currentTarget.checked)} + disabled={saving || commitCoauthorEnabled === undefined} + aria-label={t('commitAttribution.label')} + /> + + + {/* ── Custom actions ────────────────────────────────────────────── */} { await aiExperienceConfigService.reload(); expect(aiExperienceConfigService.getSettings().quick_actions).toEqual([]); }); + + it('keeps commit co-author settings unavailable when an older host omits the field', async () => { + configApiMock.getConfig.mockResolvedValueOnce({ quick_actions: [] }); + const { aiExperienceConfigService } = await import('./AIExperienceConfigService'); + + const settings = await aiExperienceConfigService.getSettingsAsync({ forceRefresh: true, requireLoaded: true }); + + expect(settings.enable_git_commit_coauthor).toBeUndefined(); + expect(settings.quick_actions).toEqual([]); + }); + + it('persists a co-author opt-out without overwriting quick actions or voice settings', async () => { + const persisted = { + enable_git_commit_coauthor: true, + voice_input: { provider: 'cloud', model_id: 'saved-model' }, + quick_actions: [{ id: 'custom', label: 'Review', prompt: 'Review changes', enabled: false }], + }; + configManagerMock.getConfig.mockResolvedValue(persisted); + configManagerMock.setConfig.mockImplementation(async (path: string, value: unknown) => { + expect(path).toBe('app.ai_experience.enable_git_commit_coauthor'); + persisted.enable_git_commit_coauthor = value as boolean; + }); + const { aiExperienceConfigService } = await import('./AIExperienceConfigService'); + + await aiExperienceConfigService.saveSettings({ enable_git_commit_coauthor: false }); + await aiExperienceConfigService.reload(); + + expect(configManagerMock.setConfig).toHaveBeenCalledTimes(1); + expect(aiExperienceConfigService.getSettings()).toMatchObject({ + enable_git_commit_coauthor: false, + voice_input: persisted.voice_input, + quick_actions: persisted.quick_actions, + }); + }); + + it('reports host read failures instead of supplying defaults to a settings editor', async () => { + const error = new Error('Execution host unavailable'); + configApiMock.getConfig.mockRejectedValueOnce(error); + const { aiExperienceConfigService } = await import('./AIExperienceConfigService'); + + await expect(aiExperienceConfigService.getSettingsAsync({ forceRefresh: true, requireLoaded: true })) + .rejects.toBe(error); + }); }); diff --git a/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.ts b/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.ts index 40835e1576..d758dd5b2a 100644 --- a/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.ts +++ b/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.ts @@ -29,6 +29,8 @@ export interface AIExperienceSettings { voice_input: VoiceInputSettings; /** User-defined quick actions shown in the post-coding actions menu. */ quick_actions?: QuickAction[]; + /** Absent on older execution hosts; their commit co-author policy is not configurable. */ + enable_git_commit_coauthor?: boolean; } export type AIExperienceSettingsPatch = Partial> & { @@ -102,6 +104,10 @@ function normalizeSettings(settings: PersistedAIExperienceSettings | null | unde ...currentSettings.voice_input, }, quick_actions: currentSettings.quick_actions ?? DEFAULT_QUICK_ACTIONS, + // Older hosts omit the field; keep it absent so the UI can gate unsupported writes. + enable_git_commit_coauthor: typeof currentSettings.enable_git_commit_coauthor === 'boolean' + ? currentSettings.enable_git_commit_coauthor + : undefined, }; // Legacy configs used null to mean the built-in SVG panda. Resolve null to the current preset. if (!merged.agent_companion_pet) { @@ -159,7 +165,7 @@ export class AIExperienceConfigService { } - async getSettingsAsync(options?: { forceRefresh?: boolean }): Promise { + async getSettingsAsync(options?: { forceRefresh?: boolean; requireLoaded?: boolean }): Promise { this.ensureConfigWatcher(); try { const settings = options?.forceRefresh @@ -169,6 +175,7 @@ export class AIExperienceConfigService { return this.cachedSettings; } catch (error) { log.error('Failed to get config', error); + if (options?.requireLoaded) throw error; return this.getSettings(); } } diff --git a/src/web-ui/src/infrastructure/config/types/index.ts b/src/web-ui/src/infrastructure/config/types/index.ts index d7fd233f92..18d3471b23 100644 --- a/src/web-ui/src/infrastructure/config/types/index.ts +++ b/src/web-ui/src/infrastructure/config/types/index.ts @@ -171,6 +171,8 @@ export interface AIExperienceConfig { voice_input: VoiceInputSettings; /** User-defined quick actions shown in the post-coding actions menu. */ quick_actions?: Array<{ id: string; label: string; prompt: string; enabled: boolean }>; + /** Absent on older hosts that do not support commit co-author preferences. */ + enable_git_commit_coauthor?: boolean; } export interface VoiceInputSettings { diff --git a/src/web-ui/src/locales/en-US/settings/quick-actions.json b/src/web-ui/src/locales/en-US/settings/quick-actions.json index 89301b3561..98f81a42b2 100644 --- a/src/web-ui/src/locales/en-US/settings/quick-actions.json +++ b/src/web-ui/src/locales/en-US/settings/quick-actions.json @@ -4,6 +4,13 @@ "subtitle": "Run common AI prompts quickly from chat." }, "loading": "Loading...", + "commitAttribution": { + "title": "Commit attribution", + "scope": "Shared across workspaces on this execution host. Applies the next time a built-in commit workflow is loaded.", + "label": "Add OpenBitFun as a co-author", + "description": "Credit OpenBitFun ({{account}}) as a co-author of new commits created by the built-in commit workflow.", + "unsupported": "This execution host does not support this setting. Upgrade OpenBitFun on the host." + }, "quickActions": { "defaults": { "commit": { diff --git a/src/web-ui/src/locales/zh-CN/settings/quick-actions.json b/src/web-ui/src/locales/zh-CN/settings/quick-actions.json index 107b99751d..879a0749f0 100644 --- a/src/web-ui/src/locales/zh-CN/settings/quick-actions.json +++ b/src/web-ui/src/locales/zh-CN/settings/quick-actions.json @@ -4,6 +4,13 @@ "subtitle": "在聊天中快速运行常用 AI 指令" }, "loading": "加载中...", + "commitAttribution": { + "title": "提交署名", + "scope": "此执行主机上的所有工作区共用,下次加载内置提交流程时生效。", + "label": "添加 OpenBitFun 联合提交人", + "description": "使用内置提交流程创建新提交时,将 OpenBitFun({{account}})标记为联合提交人。", + "unsupported": "当前执行主机不支持此设置,请升级主机上的 OpenBitFun。" + }, "quickActions": { "defaults": { "commit": { diff --git a/src/web-ui/src/locales/zh-TW/settings/quick-actions.json b/src/web-ui/src/locales/zh-TW/settings/quick-actions.json index bc0c43d730..abbe0fcfb7 100644 --- a/src/web-ui/src/locales/zh-TW/settings/quick-actions.json +++ b/src/web-ui/src/locales/zh-TW/settings/quick-actions.json @@ -4,6 +4,13 @@ "subtitle": "在聊天中快速執行常用 AI 指令" }, "loading": "載入中...", + "commitAttribution": { + "title": "提交署名", + "scope": "此執行主機上的所有工作區共用,下次載入內建提交流程時生效。", + "label": "新增 OpenBitFun 聯合提交人", + "description": "使用內建提交流程建立新提交時,將 OpenBitFun({{account}})標記為聯合提交人。", + "unsupported": "目前執行主機不支援此設定,請升級主機上的 OpenBitFun。" + }, "quickActions": { "defaults": { "commit": { diff --git a/tests/e2e/page-objects/components/ChatInput.ts b/tests/e2e/page-objects/components/ChatInput.ts index 828cf10df6..312ce025bf 100644 --- a/tests/e2e/page-objects/components/ChatInput.ts +++ b/tests/e2e/page-objects/components/ChatInput.ts @@ -163,7 +163,7 @@ export class ChatInput extends BasePage { await input.click(); await browser.pause(50); await browser.execute((element: HTMLElement) => { - element.focus(); + element.focus({ preventScroll: true }); element.textContent = ''; const inputEvent = typeof InputEvent !== 'undefined' ? new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }) diff --git a/tests/e2e/page-objects/components/Header.ts b/tests/e2e/page-objects/components/Header.ts index 771acaec6b..3fa24ae380 100644 --- a/tests/e2e/page-objects/components/Header.ts +++ b/tests/e2e/page-objects/components/Header.ts @@ -88,7 +88,7 @@ export class Header extends BasePage { throw new Error('Minimize button not found'); } - await element.scrollIntoView(); + await element.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); await element.waitForClickable({ timeout: 10000 }); await element.click(); } @@ -120,7 +120,7 @@ export class Header extends BasePage { throw new Error('Maximize button not found'); } - await element.scrollIntoView(); + await element.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); await element.waitForClickable({ timeout: 10000 }); await element.click(); } @@ -152,7 +152,7 @@ export class Header extends BasePage { throw new Error('Close button not found'); } - await element.scrollIntoView(); + await element.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); await element.waitForClickable({ timeout: 10000 }); await element.click(); } diff --git a/tests/e2e/scripts/run-interaction-scroll.mjs b/tests/e2e/scripts/run-interaction-scroll.mjs new file mode 100644 index 0000000000..b8aede1ed6 --- /dev/null +++ b/tests/e2e/scripts/run-interaction-scroll.mjs @@ -0,0 +1,308 @@ +/** Real packaged desktop regression for driver reveal/focus and shell geometry. */ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { cp, mkdir, mkdtemp, open, readFile, stat, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const root = await mkdtemp(path.join(tmpdir(), 'openbitfun-interaction-scroll-')); +const userRoot = path.join(root, 'user'); +const productHome = path.join(root, 'home'); +const frontend = path.join(root, 'frontend'); +const frontendSource = path.resolve(process.env.OPENBITFUN_E2E_FRONTEND_DIR || path.join(repo, 'dist')); +const manifest = await readFile(path.join(frontendSource, 'frontend-revision.json'), 'utf8'); +await cp(frontendSource, frontend, { recursive: true }); +assert.equal(await readFile(path.join(frontendSource, 'frontend-revision.json'), 'utf8'), manifest, + 'The frontend build changed during the snapshot. Finish the build and rerun.'); +assert.equal(await readFile(path.join(frontend, 'frontend-revision.json'), 'utf8'), manifest); +await mkdir(userRoot, { recursive: true }); +await mkdir(productHome, { recursive: true }); +const binaryName = 'openbitfun-desktop' + (process.platform === 'win32' ? '.exe' : ''); +const binarySource = path.resolve(process.env.OPENBITFUN_E2E_DESKTOP_BINARY || path.join(repo, 'target/debug', binaryName)); +const binary = path.join(root, binaryName); +const binaryStat = await stat(binarySource); +await cp(binarySource, binary); +const afterCopy = await stat(binarySource); +assert.equal(afterCopy.mtimeMs, binaryStat.mtimeMs, 'The Desktop binary changed during the snapshot. Rerun after the build.'); +assert.equal(afterCopy.size, binaryStat.size); +assert.equal((await stat(binary)).size, binaryStat.size); + +const probe = createServer(); +probe.listen(0, '127.0.0.1'); +await once(probe, 'listening'); +const port = probe.address().port; +await new Promise(resolve => probe.close(resolve)); +const endpoint = 'http://127.0.0.1:' + port; +const log = await open(path.join(root, 'desktop.log'), 'w'); +const app = spawn(binary, [], { + cwd: repo, windowsHide: true, stdio: ['ignore', log.fd, log.fd], + env: { ...process.env, + OPENBITFUN_USER_ROOT: userRoot, OPENBITFUN_E2E_USER_ROOT: userRoot, + OPENBITFUN_HOME: productHome, OPENBITFUN_E2E_HOME: productHome, + OPENBITFUN_E2E_STORAGE_GUARD: '1', OPENBITFUN_E2E_PACKAGED_FRONTEND: '1', + OPENBITFUN_E2E_FRONTEND_DIR: frontend, OPENBITFUN_E2E_LOG_DIR: path.join(root, 'logs'), + OPENBITFUN_WEBDRIVER_PORT: String(port), OPENBITFUN_WEBDRIVER_LABEL: 'main', + WEBVIEW2_USER_DATA_FOLDER: path.join(root, 'webview'), + }, +}); +console.log('Native interaction scroll evidence: ' + root); +let exitError; +app.on('error', error => { exitError = error; }); +let session; +const elementKey = 'element-6066-11e4-a52e-4f735466cecf'; +const observations = []; +const report = { passed: false, platform: process.platform, frontendSource, frontendRevision: JSON.parse(manifest).revision, + binarySource, binaryModified: binaryStat.mtime.toISOString(), + artifactRoot: root, checks: [], observations }; +await writeFile(path.join(root, 'result.json'), JSON.stringify(report, null, 2)); + +async function request(route, body, method = body === undefined ? 'GET' : 'POST') { + const response = await fetch(endpoint + route, { + method, signal: AbortSignal.timeout(15_000), + ...(body === undefined ? {} : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }), + }); + const result = await response.json(); + if (!response.ok || result.value?.error) throw new Error(JSON.stringify(result)); + return result.value; +} + +async function until(read, description, timeout = 45_000) { + const started = Date.now(); + let last; + while (Date.now() - started < timeout) { + if (exitError) throw exitError; + if (app.exitCode !== null || app.signalCode !== null) throw new Error('Desktop exited before ' + description); + try { const value = await read(); if (value) return value; } catch (error) { last = error; } + await new Promise(resolve => setTimeout(resolve, 250)); + } + throw new Error('Timed out waiting for ' + description + ': ' + (last ?? 'not ready')); +} + +const execute = (fn, ...args) => request('/session/' + session + '/execute/sync', { script: fn.toString(), args }); +const locate = selector => request('/session/' + session + '/element', { using: 'css selector', value: selector }); +const elementAction = (element, action, body = {}) => request('/session/' + session + '/element/' + element[elementKey] + '/' + action, body); +const click = async selector => elementAction(await locate(selector), 'click'); +const settle = () => execute(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))))); +const pointerClick = target => request('/session/' + session + '/actions', { actions: [{ type: 'pointer', id: 'mouse', + parameters: { pointerType: 'mouse' }, actions: [ + { type: 'pointerMove', origin: target, x: 0, y: 0 }, + { type: 'pointerDown', button: 0 }, { type: 'pointerUp', button: 0 }, + ] }] }); + +function chromeGeometry() { + const bar = [...document.querySelectorAll('.openbitfun-scene-top-bar')].find(el => el.getBoundingClientRect().width > 0); + const surface = bar?.closest('.openbitfun-workspace-body__scene-surface'); + const tab = bar?.querySelector('[role=tab]'); + if (!surface) return null; + return { surfaceScrollLeft: surface.scrollLeft, surfaceScrollTop: surface.scrollTop, + barScrollLeft: bar.scrollLeft, barScrollTop: bar.scrollTop, + tabGap: tab ? tab.getBoundingClientRect().left - surface.getBoundingClientRect().left : null, + barPadding: getComputedStyle(bar).paddingLeft }; +} + +async function stableChrome(label, operation) { + const before = await execute(chromeGeometry); + assert.ok(before, 'Scene chrome must be present'); + await operation(); + await settle(); + const after = await execute(chromeGeometry); + observations.push({ label, before, after }); + assert.ok(after, 'Scene chrome must remain present'); + const fields = ['surfaceScrollLeft', 'surfaceScrollTop', 'barScrollLeft', 'barScrollTop']; + if (before.tabGap !== null) fields.push('tabGap'); + for (const field of fields) { + assert.ok(Math.abs(after[field] - before[field]) < 1, label + ' displaced ' + field + ': ' + JSON.stringify({ before, after })); + } + if (before.tabGap !== null) assert.ok(after.tabGap > 0, label + ' clipped the first tab'); + report.checks.push(label); + await writeFile(path.join(root, 'result.json'), JSON.stringify(report, null, 2)); +} + +async function recordClick(element) { + await execute(el => { + window.__openbitfunScrollClick = null; + el.addEventListener('click', event => { + window.__openbitfunScrollClick = { x: event.clientX, y: event.clientY, + viewportWidth: innerWidth, viewportHeight: innerHeight }; + }, { once: true, passive: true }); + return true; + }, element); +} + +async function assertClick() { + const point = await execute(() => window.__openbitfunScrollClick); + assert.ok(point, 'The production target must receive a click'); + assert.ok(point.x >= 0 && point.x < point.viewportWidth && point.y >= 0 && point.y < point.viewportHeight, + 'The click point must lie inside the viewport: ' + JSON.stringify(point)); + return point; +} + +try { + await until(async () => (await request('/status')).ready, 'embedded driver'); + session = (await request('/session', {})).sessionId; + await request('/session/' + session + '/timeouts', { script: 5000 }); + await until(() => execute(() => !!document.querySelector('[data-testid=nav-workspace-new-session-btn]')), 'workspace navigation'); + await click('[data-testid=nav-workspace-new-session-btn]'); + await until(async () => { + const geometry = await execute(chromeGeometry); + return geometry?.tabGap > 0 ? geometry : null; + }, 'session chrome'); + await until(() => execute(() => !!document.querySelector('[data-testid=flowchat-header-right-panel]')), 'session actions'); + await settle(); + report.initial = await execute(chromeGeometry); + assert.ok(report.initial.tabGap > 0); + assert.equal(report.initial.surfaceScrollLeft, 0); + + await stableChrome('element click', async () => { + const target = await locate('[data-testid=flowchat-header-right-panel]'); + await recordClick(target); + await elementAction(target, 'click'); + await assertClick(); + }); + await stableChrome('element screenshot', async () => { + const target = await locate('[data-testid=flowchat-header-right-panel]'); + const encoded = await request('/session/' + session + '/element/' + target[elementKey] + '/screenshot'); + const png = Buffer.from(encoded, 'base64'); + assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a'); + await writeFile(path.join(root, 'element.png'), png); + }); + await stableChrome('wheel skips hidden ancestors', async () => { + const position = await execute(() => { + const rect = document.querySelector('.openbitfun-scene-top-bar').getBoundingClientRect(); + return { x: Math.floor(rect.left + 20), y: Math.floor(rect.top + 2) }; + }); + const ancestors = await execute(({ x, y }) => { + const result = []; + for (let el = document.elementFromPoint(x, y); el; el = el.parentElement) { + const style = getComputedStyle(el); + result.push({ tag: el.tagName, class: el.className, width: el.clientWidth, scrollWidth: el.scrollWidth, + height: el.clientHeight, scrollHeight: el.scrollHeight, overflowX: style.overflowX, overflowY: style.overflowY }); + } + return result; + }, position); + observations.push({ label: 'wheel target ancestors', ancestors }); + await request('/session/' + session + '/actions', { actions: [{ type: 'wheel', id: 'wheel', actions: [ + { type: 'scroll', origin: 'viewport', ...position, deltaX: 64, deltaY: 0 }, + ] }] }); + await request('/session/' + session + '/actions', undefined, 'DELETE'); + }); + await stableChrome('pointer actions', async () => { + const target = await locate('[data-testid=flowchat-header-right-panel]'); + await recordClick(target); + await pointerClick(target); + await assertClick(); + await request('/session/' + session + '/actions', undefined, 'DELETE'); + }); + + const editor = await until(async () => locate('[data-testid=chat-input-textarea][contenteditable=true]'), 'chat composer'); + await stableChrome('contenteditable send keys', async () => { + await elementAction(editor, 'value', { text: 'scroll regression draft' }); + assert.equal(await execute(el => el.textContent, editor), 'scroll regression draft'); + }); + await stableChrome('contenteditable clear', async () => { + await elementAction(editor, 'clear'); + assert.equal(await execute(el => el.textContent, editor), ''); + }); + // A real long settings page supplies a value input and offscreen controls. + await click('[data-testid=nav-footer-settings-item]'); + const openSettings = await until(() => locate('[data-testid=nav-settings-open-item]'), 'settings menu entry', 5000); + await elementAction(openSettings, 'click'); + const shortcutPage = await until(() => locate('[data-testid=settings-nav-page][data-settings-page="application.shortcuts"]'), 'shortcut settings navigation', 10_000); + await elementAction(shortcutPage, 'click'); + const search = await until(() => locate('.kb-shortcuts__search input, input.kb-shortcuts__search'), 'shortcut search input', 5000); + await stableChrome('input send keys', async () => { + await elementAction(search, 'value', { text: 'scroll regression query' }); + assert.equal(await execute(el => el.value, search), 'scroll regression query'); + assert.equal(await execute(el => document.activeElement === el, search), true); + }); + await stableChrome('input clear', async () => { + await elementAction(search, 'clear'); + assert.equal(await execute(el => el.value, search), ''); + }); + + const shortcutSelector = '[data-openbitfun-component=keyboard-shortcuts][data-openbitfun-part=key] button'; + const shortcuts = await until(async () => { + const elements = await request('/session/' + session + '/elements', { using: 'css selector', value: shortcutSelector }); + return elements.length > 1 ? elements : null; + }, 'keyboard shortcut settings'); + const scrollport = await execute(() => { + for (let el = document.querySelector('[data-openbitfun-component=keyboard-shortcuts][data-openbitfun-part=content]'); el; el = el.parentElement) { + if (['auto', 'scroll'].includes(getComputedStyle(el).overflowY) && el.scrollHeight > el.clientHeight) return el; + } + return null; + }); + assert.ok(scrollport, 'The production settings page must have a scrollport'); + const scrollBefore = await execute(el => el.scrollTop, scrollport); + await request('/session/' + session + '/actions', { actions: [{ type: 'wheel', id: 'settings-wheel', actions: [ + { type: 'scroll', origin: scrollport, x: 0, y: 0, deltaX: 0, deltaY: 64 }, + ] }] }); + const scrollAfter = await until(async () => { + const position = await execute(el => el.scrollTop, scrollport); + return position > scrollBefore ? position : null; + }, 'wheel scrolling the real settings viewport', 5000); + observations.push({ label: 'settings wheel', before: scrollBefore, after: scrollAfter }); + report.checks.push('wheel reaches the real scrollport through an element origin'); + await request('/session/' + session + '/actions', undefined, 'DELETE'); + const lastShortcut = shortcuts.at(-1); + const offscreen = await execute(el => { + const rect = el.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom, viewportHeight: innerHeight }; + }, lastShortcut); + assert.ok(offscreen.top >= offscreen.viewportHeight, 'The last real shortcut must start below the viewport'); + await recordClick(lastShortcut); + await elementAction(lastShortcut, 'click'); + const revealedPoint = await assertClick(); + assert.equal(await execute(el => el.getAttribute('aria-pressed'), lastShortcut), 'true'); + const horizontalOffsets = await execute(el => { + const result = []; + for (let parent = el.parentElement; parent; parent = parent.parentElement) { + if (getComputedStyle(parent).overflowX === 'hidden') result.push(parent.scrollLeft); + } + return result; + }, lastShortcut); + assert.ok(horizontalOffsets.every(offset => offset === 0), 'Offscreen reveal must not shift hidden horizontal ancestors'); + observations.push({ label: 'offscreen control', before: offscreen, click: revealedPoint, horizontalOffsets }); + report.checks.push('offscreen control reveal and activation'); + await elementAction(lastShortcut, 'click'); // Stop recording without changing a shortcut. + + const oversized = await locate('[data-openbitfun-component=keyboard-shortcuts][data-openbitfun-part=content]'); + const oversizedHeight = await execute(el => el.getBoundingClientRect().height, oversized); + assert.ok(oversizedHeight > await execute(() => innerHeight), 'The real settings content must exceed the viewport'); + await recordClick(oversized); + await elementAction(oversized, 'click'); + observations.push({ label: 'oversized target', height: oversizedHeight, click: await assertClick() }); + report.checks.push('oversized target uses an in-view click point'); + await recordClick(oversized); + await pointerClick(oversized); + observations.push({ label: 'oversized pointer origin', click: await assertClick() }); + report.checks.push('oversized pointer origin uses an in-view point'); + await request('/session/' + session + '/actions', undefined, 'DELETE'); + report.passed = true; +} catch (error) { + report.error = String(error); + if (session) { + const diagnostics = await execute(() => ({ url: location.href, ready: document.readyState, + text: document.body?.innerText.slice(0, 3000), inputs: [...document.querySelectorAll('input')].map(el => ({ + id: el.id, class: el.className, parent: el.parentElement?.outerHTML.slice(0, 500), + })), pointer: { + x: window.__openbitfunWdRuntimeState?.pointer.x, y: window.__openbitfunWdRuntimeState?.pointer.y, + target: window.__openbitfunWdRuntimeState?.pointer.target?.outerHTML?.slice(0, 500), + } })).catch(String); + await writeFile(path.join(root, 'failure.json'), JSON.stringify(diagnostics, null, 2)); + } + console.error('Native interaction scroll regression failed. Artifacts: ' + root); + throw error; +} finally { + await writeFile(path.join(root, 'result.json'), JSON.stringify(report, null, 2)); + if (session) await request('/session/' + session, undefined, 'DELETE').catch(() => {}); + if (app.exitCode === null && app.signalCode === null) { + app.kill(); + await Promise.race([once(app, 'exit'), new Promise(resolve => setTimeout(resolve, 5000))]); + } + await log.close(); + console.log(JSON.stringify({ passed: report.passed, checks: report.checks, artifactRoot: root })); +}