Skip to content

feat(snapshot): carry provider keyboard observations through acquisition - #3593

Open
thymikee wants to merge 7 commits into
mainfrom
feat/provider-keyboard-carriage
Open

thymikee wants to merge 7 commits into
mainfrom
feat/provider-keyboard-carriage

Conversation

@thymikee

@thymikee thymikee commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Summary

Carry Appium iOS/Android and Limrun keyboard observations through acquisition and projected snapshots. snapshot --json distinguishes measured visible, policy derived (docked-tree), visibility-only, and unknown unmeasurable facts. For example, {kind: "visibility-only", visible: true} confirms presence without claiming a frame.

Derived bands require usable docking, orientation and continuous-column evidence. Missing keyboard nodes never prove absence. Android Appium supplies no display measurement on this path, so app-content bounds cannot establish a derived band. Resolvable docked tree evidence outweighs a disagreeing visibility probe. Assistant markers own only themselves; app controls beneath them remain subject to tap occlusion after projection.

Updates help/docs and fixes all nine review findings (25 files).

Closes #3529.

Validation

Commit 0b5e645372957901aac7aa1c8820ef8d21085e08.

Same focused four-file command Result
Before fixes (5d537cb06) 5 failed, 86 passed
Head 91 passed

pnpm check:affected --run: 1,139 files / 9,540 tests passed. CI eager-closure/test-size gates: 845 tests passed. Layering passed after commit. Ownership and unchanged-comparison mutants fail.

Live on pre-rebase c13634284 (provider acquisition and keyboard-policy sources are identical at this head): assigned iOS Appium, private Android Appium AVD (including floating Gboard), and Limrun passed; commands and outputs: #3593 (comment). Safari's hideKeyboard retained its visible keyboard. Private resources cleaned up. CI: 17 passed after bounded monitoring; two smoke checks, compatibility/provenance, and typecheck/package remain pending. Cubic passed; zero unresolved threads.

@thymikee

Copy link
Copy Markdown
Member Author

Live provider verification for this change. Android Appium was repeated on ae0bf4361; final head 5d537cb06 then moved unchanged policy imports behind acquisition and moved ownership annotation from the carrier factory to the provider adapters. The earlier iOS and Limrun runs exercise the retained acquisition/publication paths. Projection and geometry-rejection fixes are pinned by regression tests. No source daemon was shared with other workers.

  • Appium 3.8.0 / XCUITest 12.17.1: iPhone 18 Pro Max, iOS 27.0, UDID 1C2C9FD4-B720-4160-BAEB-7D4724D7B5F3. Safari's actual keyboard source acquired and published {"kind":"derived","derivation":"docked-tree","frame":{"x":0,"y":653,"width":440,"height":303}}. An earlier keyboard-hidden capture published visibility-only:false. hideKeyboard did not dismiss Safari's keyboard; the subsequent capture correctly retained its derived frame.
  • Appium / UiAutomator2 8.7.0 (final head): private Android 15 AVD tmp-issue-3529, port 5562. Settings search produced visibility-only:false → true → false. Enabling Gboard's Floating option (located from the real Appium multiwindow source) published visibility-only:true with no invented frame; screenshot confirmed floating mode. After dismissal it published false. Under earlier concurrent host load the 1.5-second optional probe timed out and correctly published unmeasurable:keyboard-visibility-probe-failed.
  • Limrun SDK 0.60.0: Settings search's keyboard was visibly present in its screenshot, while the returned tree omitted Keyboard/Key nodes. Response publication correctly kept unmeasurable:keyboard-not-observed. This is evidence that missing nodes must never imply absence.

Commands used (scripts below contain the production client/interactor/acquisition/presentation calls):

printf 'no\n' | avdmanager create avd -n tmp-issue-3529 -k 'system-images;android-35;google_apis;arm64-v8a' -d pixel_7
emulator -avd tmp-issue-3529 -port 5562 -no-snapshot -no-window
pnpm --dir /private/tmp/issue-3529-appium add appium appium-xcuitest-driver appium-uiautomator2-driver
# From /private/tmp/issue-3529-appium:
node node_modules/appium/build/lib/main.js --port 4739 --address 127.0.0.1 --log /private/tmp/issue-3529-appium-server.log
# From the issue worktree:
node --experimental-strip-types /private/tmp/issue-3529-live.ts ios
node --experimental-strip-types /private/tmp/issue-3529-live-final.ts android
set -a && . /Users/michal/Developer/agent-device/.env && set +a
node --experimental-strip-types /private/tmp/issue-3529-limrun.ts

Cleanup: all Appium sessions deleted, the late-completing first WDA allocation explicitly deleted, WDA terminated on the assigned iPhone, private Appium server stopped, private ADB server on port 5039 stopped, private AVD shut down and deleted, every private Limrun instance deleted. The T3 Safari preparation session was closed.

Appium reproduction script (run from the checkout; use node --experimental-strip-types --input-type=module - ios/android with this code on stdin)
import {parseWebDriverSourceFacts} from './packages/provider-webdriver/src/webdriver-source.ts';
import fs from 'node:fs/promises';
import { WebDriverClient } from './packages/provider-webdriver/src/webdriver-client.ts';
import { createWebDriverInteractor } from './packages/provider-webdriver/src/webdriver-interactor.ts';
import { createCloudWebDriverCapabilities } from './packages/provider-webdriver/src/capabilities.ts';
import { presentIosSnapshotAcquisition } from './packages/capture-kit/src/snapshot/ios-snapshot-runtime.ts';
const platform = process.argv[2] === 'ios' ? 'ios' : 'android';
const client = new WebDriverClient({ clientVersion: 'issue-3529', endpoint: 'http://127.0.0.1:4739', requestPolicy: { timeoutMs: 30_000, sessionCreateTimeoutMs: 360_000 } });
const caps = platform === 'ios' ? { platformName: 'iOS', 'appium:automationName': 'XCUITest', 'appium:udid': '1C2C9FD4-B720-4160-BAEB-7D4724D7B5F3', 'appium:bundleId': 'com.apple.mobilesafari', 'appium:noReset': true, 'appium:wdaLocalPort': 8199, 'appium:mjpegServerPort': 9199, 'appium:derivedDataPath': '/private/tmp/issue-3529-wda' } : { platformName: 'Android', 'appium:automationName': 'UiAutomator2', 'appium:udid': 'emulator-5562', 'appium:appPackage': 'com.android.settings', 'appium:appActivity': '.Settings', 'appium:noReset': true, 'appium:adbPort': 5039, 'appium:systemPort': 8299, 'appium:adbExecTimeout': 60000 };
let created=false;
try {
  const session=await client.createSession(caps);
  created=true;
  await client.activateApp(platform === 'ios' ? 'com.apple.mobilesafari' : 'com.android.settings');
  await new Promise(r=>setTimeout(r,1500));
  const interactor = createWebDriverInteractor({ client, backend: platform === 'ios' ? 'xctest' : 'android', capabilities: createCloudWebDriverCapabilities({ provider: 'test', platform }), targetId: platform === 'ios' ? caps['appium:udid'] : 'emulator-5562' });
  const report = async (phase: string) => {
    const raw = await interactor.snapshot();
    const result = 'stage' in raw ? presentIosSnapshotAcquisition(raw) : raw;
    await fs.writeFile(`/private/tmp/issue-3529-${platform}-${phase}.json`, JSON.stringify(result));
    console.log(JSON.stringify({ platform, phase, producer: result.producer, keyboard: result.keyboard, nodes: result.nodes?.length, fields: result.nodes?.filter(n => /textfield|edittext|searchfield/i.test(n.type ?? '')).map(n=>({type:n.type,label:n.label,identifier:n.identifier,rect:n.rect})) }));
    return result;
  };
  let before = await report('before');
  const search = before.nodes?.find(n => /search/i.test(n.label ?? n.identifier ?? '') && n.rect && n.rect.width > 0 && n.rect.height > 0);
  const field = before.nodes?.find(n => /textfield|edittext|searchfield/i.test(n.type ?? '') && n.rect && n.rect.width > 0);
  const target = field ?? search;
  if (!target?.rect) throw new Error('No text/search target available');
  await interactor.tap(target.rect.x+target.rect.width/2, target.rect.y+target.rect.height/2);
  await new Promise(r=>setTimeout(r,1000));
  let up = await report('keyboard-up');
  const nextField=up.nodes?.find(n=>/edittext/i.test(n.type??'') && n.rect);
  if(platform==='android' && nextField?.rect) { await interactor.tap(nextField.rect.x+nextField.rect.width/2,nextField.rect.y+nextField.rect.height/2); await new Promise(r=>setTimeout(r,1000)); up=await report('focused-keyboard'); await client.screenshot('/private/tmp/issue-3529-android-keyboard.png'); await fetch(`http://127.0.0.1:4739/session/${session.sessionId}/appium/settings`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({settings:{enableMultiWindows:true}})}); await fs.writeFile('/private/tmp/issue-3529-android-multiwindow.xml',await client.source()); console.log(JSON.stringify({platform,unboundedVisibility:await client.isKeyboardShown(15_000)})); }
  if (platform === 'android') {
    const tapNamed = async (label) => {
      const facts=parseWebDriverSourceFacts(await client.source(),'android');
      const node=facts.nodes.find(n=>n.label===label && n.rect);
      if(!node?.rect) return false;
      await interactor.tap(node.rect.x+node.rect.width/2,node.rect.y+node.rect.height/2);
      await new Promise(r=>setTimeout(r,1000));return true;
    };
    if(await tapNamed('Open features menu')) {
      if(await tapNamed('Floating')) {
        await report('floating-keyboard');
        await client.screenshot('/private/tmp/issue-3529-android-floating.png');
      } else console.log('Floating option was not available');
    }
  }
  await client.hideKeyboard().catch(()=>undefined);
  await report('after');
} finally { if (created) await client.deleteSession(); }
Limrun reproduction script (same stdin setup; credentials stay in the host environment)
import fs from 'node:fs/promises';
import Limrun from './node_modules/@limrun/api/index.mjs';
import { createInstanceClient } from './node_modules/@limrun/api/ios-client.mjs';
import { captureLimrunIosSnapshot } from './packages/provider-limrun/src/ios-snapshot-adapter.ts';
import { presentIosSnapshotAcquisition } from './packages/capture-kit/src/snapshot/ios-snapshot-runtime.ts';
const api = new Limrun({ apiKey: process.env.LIMRUN_API_KEY });
let instance, client;
try {
 instance = await api.iosInstances.create({ wait: true, metadata: { displayName:'issue-3529 keyboard carriage', labels:{issue:'3529'} }, spec:{} });
 console.log(JSON.stringify({allocated:instance.metadata.id}));
 client = await createInstanceClient({apiUrl:instance.status.apiUrl,token:instance.status.token,logLevel:'warn'});
 await client.launchApp('com.apple.Preferences');
 await new Promise(r=>setTimeout(r,1500));
 const report = async (phase) => {
  const result = presentIosSnapshotAcquisition(await captureLimrunIosSnapshot({client,instanceId:instance.metadata.id}));
  console.log(JSON.stringify({phase,producer:result.producer,keyboard:result.keyboard,nodes:result.nodes.length, targets:result.nodes.filter(n=>/search|textfield/i.test((n.type??'')+' '+(n.label??''))).map(n=>({type:n.type,label:n.label,rect:n.rect}))}));
  return result;
 };
 const before = await report('before');
 const field = before.nodes.find(n=>/textfield|searchfield/i.test(n.type??'') && n.rect && n.rect.width>0);
 if (field) {await client.tap(field.rect.x+field.rect.width/2,field.rect.y+field.rect.height/2);await new Promise(r=>setTimeout(r,1500)); await report('keyboard-up'); const shot=await client.screenshot(); await fs.writeFile('/private/tmp/issue-3529-limrun-keyboard.jpg',Buffer.from(shot.base64,'base64')); }
 else console.log('No text field was available for keyboard-up check');
} catch(error) { console.log(JSON.stringify({error: error instanceof Error ? error.message : String(error)}));process.exitCode=1; }
finally { client?.disconnect(); if(instance){await api.iosInstances.delete(instance.metadata.id); console.log('Deleted private Limrun instance');} }

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 23 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/provider-webdriver/src/webdriver-interactor.ts Outdated
Comment thread packages/provider-webdriver/src/webdriver-ios-snapshot.ts
Comment thread packages/contracts/src/tap-keyboard-occlusion.test.ts Outdated
Comment thread src/commands/schema/cli-help-workflows.ts Outdated
Comment thread website/docs/docs/snapshots.md Outdated
Comment thread src/commands/capture/runtime/snapshot-unchanged.ts
Comment thread packages/kernel/src/record.ts
Comment thread packages/kernel/src/snapshot.ts
Comment thread packages/contracts/src/tap-keyboard-occlusion.ts Outdated
@thymikee

Copy link
Copy Markdown
Member Author

Round 2 live verification at c136342840a4da88abad4bc518502c4ae07e6f9f using the production WebDriver client/interactor/acquisition/presentation paths, then rebased to 0b5e645372957901aac7aa1c8820ef8d21085e08. git diff c13634284 HEAD -- the keyboard policy, both WebDriver acquisition modules, Limrun adapter and golden table is empty; private state /private/tmp/3593-state.

  • Appium 3.8.0 / XCUITest 12.17.1, assigned iPhone 18 Pro Max 1C2C9FD4-B720-4160-BAEB-7D4724D7B5F3: hidden visibility-only:false; Safari keyboard-up derived:docked-tree, frame (0,653,440,303); after hideKeyboard, the keyboard remained visible and the derived frame remained. No dismissal success is claimed.
  • Appium / UiAutomator2 8.7.0, private Android 15 AVD review-3593, port 5562: hidden → shown → floating → dismissed yielded visibility-only false → true → true → false. The floating snapshot contained 194 nodes, including the multiwindow keyboard source, without inventing a display extent or keyboard frame. Screenshot inspected: Gboard floating panel above the bottom navigation strip. The first attempt failed during fresh-boot Appium settings installation; retry after boot completion passed.
  • Limrun iOS: before and keyboard-up both preserved unmeasurable:keyboard-not-observed; screenshot confirmed the keyboard was present while the tree omitted keyboard nodes. Private instance deleted.

Commands:

pnpm build
pnpm build:android
AGENT_DEVICE_STATE_DIR=/private/tmp/3593-state pnpm clean:daemon
mkdir -p /private/tmp/3593-avds /private/tmp/3593-android-home
ANDROID_AVD_HOME=/private/tmp/3593-avds ANDROID_USER_HOME=/private/tmp/3593-android-home avdmanager create avd -n review-3593 -k 'system-images;android-35;google_apis;arm64-v8a' -d pixel_7 --path /private/tmp/3593-avds/review-3593.avd < /dev/null
ANDROID_AVD_HOME=/private/tmp/3593-avds ANDROID_USER_HOME=/private/tmp/3593-android-home emulator -avd review-3593 -port 5562 -no-snapshot -no-window
APPIUM_HOME=/private/tmp/issue-3529-appium node /private/tmp/issue-3529-appium/node_modules/appium/build/lib/main.js --port 4739 --address 127.0.0.1 --log /private/tmp/3593-appium-server.log
AGENT_DEVICE_STATE_DIR=/private/tmp/3593-state node --experimental-strip-types /private/tmp/3593-live.ts ios
AGENT_DEVICE_STATE_DIR=/private/tmp/3593-state node --experimental-strip-types /private/tmp/3593-live.ts android
set -a && . /Users/michal/Developer/agent-device/.env && set +a
AGENT_DEVICE_STATE_DIR=/private/tmp/3593-state node --experimental-strip-types /private/tmp/3593-limrun.ts

Both Appium sessions returned HTTP 200 on DELETE. WDA was terminated only on the assigned simulator. Private server stopped, ADB port 5039 stopped, AVD shut down and deleted; device panels closed. No shared agent-device session was opened.

Exact Appium reproduction script
import {parseWebDriverSourceFacts} from '/Users/michal/.t3/worktrees/agent-device/t3-f028aae4/packages/provider-webdriver/src/webdriver-source.ts';
import fs from 'node:fs/promises';
import { WebDriverClient } from '/Users/michal/.t3/worktrees/agent-device/t3-f028aae4/packages/provider-webdriver/src/webdriver-client.ts';
import { createWebDriverInteractor } from '/Users/michal/.t3/worktrees/agent-device/t3-f028aae4/packages/provider-webdriver/src/webdriver-interactor.ts';
import { createCloudWebDriverCapabilities } from '/Users/michal/.t3/worktrees/agent-device/t3-f028aae4/packages/provider-webdriver/src/capabilities.ts';
import { presentIosSnapshotAcquisition } from '/Users/michal/.t3/worktrees/agent-device/t3-f028aae4/packages/capture-kit/src/snapshot/ios-snapshot-runtime.ts';
const platform = process.argv[2] === 'ios' ? 'ios' : 'android';
const client = new WebDriverClient({ clientVersion: '3593-r2', endpoint: 'http://127.0.0.1:4739', requestPolicy: { timeoutMs: 30_000, sessionCreateTimeoutMs: 360_000 } });
const caps = platform === 'ios' ? { platformName: 'iOS', 'appium:automationName': 'XCUITest', 'appium:udid': '1C2C9FD4-B720-4160-BAEB-7D4724D7B5F3', 'appium:bundleId': 'com.apple.mobilesafari', 'appium:noReset': true, 'appium:wdaLocalPort': 8199, 'appium:mjpegServerPort': 9199, 'appium:derivedDataPath': '/private/tmp/3593-wda' } : { platformName: 'Android', 'appium:automationName': 'UiAutomator2', 'appium:udid': 'emulator-5562', 'appium:appPackage': 'com.android.settings', 'appium:appActivity': '.Settings', 'appium:noReset': true, 'appium:adbPort': 5039, 'appium:systemPort': 8299, 'appium:adbExecTimeout': 60000 };
let created=false;
try {
  const session=await client.createSession(caps);
  created=true;
  await client.activateApp(platform === 'ios' ? 'com.apple.mobilesafari' : 'com.android.settings');
  await new Promise(r=>setTimeout(r,1500));
  const interactor = createWebDriverInteractor({ client, backend: platform === 'ios' ? 'xctest' : 'android', capabilities: createCloudWebDriverCapabilities({ provider: 'test', platform }), targetId: platform === 'ios' ? caps['appium:udid'] : 'emulator-5562' });
  const report = async (phase: string) => {
    const raw = await interactor.snapshot();
    const result = 'stage' in raw ? presentIosSnapshotAcquisition(raw) : raw;
    await fs.writeFile(`/private/tmp/3593-${platform}-${phase}.json`, JSON.stringify(result));
    console.log(JSON.stringify({ platform, phase, producer: result.producer, keyboard: result.keyboard, nodes: result.nodes?.length, fields: result.nodes?.filter(n => /textfield|edittext|searchfield/i.test(n.type ?? '')).map(n=>({type:n.type,label:n.label,identifier:n.identifier,rect:n.rect})) }));
    return result;
  };
  let before = await report('before');
  const search = before.nodes?.find(n => /search/i.test(n.label ?? n.identifier ?? '') && n.rect && n.rect.width > 0 && n.rect.height > 0);
  const field = before.nodes?.find(n => /textfield|edittext|searchfield/i.test(n.type ?? '') && n.rect && n.rect.width > 0);
  const target = field ?? search;
  if (!target?.rect) throw new Error('No text/search target available');
  await interactor.tap(target.rect.x+target.rect.width/2, target.rect.y+target.rect.height/2);
  await new Promise(r=>setTimeout(r,1000));
  let up = await report('keyboard-up');
  const nextField=up.nodes?.find(n=>/edittext/i.test(n.type??'') && n.rect);
  if(platform==='android' && nextField?.rect) { await interactor.tap(nextField.rect.x+nextField.rect.width/2,nextField.rect.y+nextField.rect.height/2); await new Promise(r=>setTimeout(r,1000)); up=await report('focused-keyboard'); await client.screenshot('/private/tmp/3593-android-keyboard.png'); await fetch(`http://127.0.0.1:4739/session/${session.sessionId}/appium/settings`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({settings:{enableMultiWindows:true}})}); await fs.writeFile('/private/tmp/3593-android-multiwindow.xml',await client.source()); console.log(JSON.stringify({platform,unboundedVisibility:await client.isKeyboardShown(15_000)})); }
  if (platform === 'android') {
    const tapNamed = async (label) => {
      const facts=parseWebDriverSourceFacts(await client.source(),'android');
      const node=facts.nodes.find(n=>n.label===label && n.rect);
      if(!node?.rect) return false;
      await interactor.tap(node.rect.x+node.rect.width/2,node.rect.y+node.rect.height/2);
      await new Promise(r=>setTimeout(r,1000));return true;
    };
    if(await tapNamed('Open features menu')) {
      if(await tapNamed('Floating')) {
        await report('floating-keyboard');
        await client.screenshot('/private/tmp/3593-android-floating.png');
      } else console.log('Floating option was not available');
    }
  }
  await client.hideKeyboard().catch(()=>undefined);
  await report('after');
} finally { if (created) await client.deleteSession(); }
Exact Limrun reproduction script
import fs from 'node:fs/promises';
import Limrun from '/Users/michal/.t3/worktrees/agent-device/t3-f028aae4/node_modules/@limrun/api/index.mjs';
import { createInstanceClient } from '/Users/michal/.t3/worktrees/agent-device/t3-f028aae4/node_modules/@limrun/api/ios-client.mjs';
import { captureLimrunIosSnapshot } from '/Users/michal/.t3/worktrees/agent-device/t3-f028aae4/packages/provider-limrun/src/ios-snapshot-adapter.ts';
import { presentIosSnapshotAcquisition } from '/Users/michal/.t3/worktrees/agent-device/t3-f028aae4/packages/capture-kit/src/snapshot/ios-snapshot-runtime.ts';
const api = new Limrun({ apiKey: process.env.LIMRUN_API_KEY });
let instance, client;
try {
 instance = await api.iosInstances.create({ wait: true, metadata: { displayName:'issue-3529 keyboard carriage', labels:{issue:'3529'} }, spec:{} });
 console.log(JSON.stringify({allocated:instance.metadata.id}));
 client = await createInstanceClient({apiUrl:instance.status.apiUrl,token:instance.status.token,logLevel:'warn'});
 await client.launchApp('com.apple.Preferences');
 await new Promise(r=>setTimeout(r,1500));
 const report = async (phase) => {
  const result = presentIosSnapshotAcquisition(await captureLimrunIosSnapshot({client,instanceId:instance.metadata.id}));
  console.log(JSON.stringify({phase,producer:result.producer,keyboard:result.keyboard,nodes:result.nodes.length, targets:result.nodes.filter(n=>/search|textfield/i.test((n.type??'')+' '+(n.label??''))).map(n=>({type:n.type,label:n.label,rect:n.rect}))}));
  return result;
 };
 const before = await report('before');
 const field = before.nodes.find(n=>/textfield|searchfield/i.test(n.type??'') && n.rect && n.rect.width>0);
 if (field) {await client.tap(field.rect.x+field.rect.width/2,field.rect.y+field.rect.height/2);await new Promise(r=>setTimeout(r,1500)); await report('keyboard-up'); const shot=await client.screenshot(); await fs.writeFile('/private/tmp/3593-limrun-keyboard.jpg',Buffer.from(shot.base64,'base64')); }
 else console.log('No text field was available for keyboard-up check');
} catch(error) { console.log(JSON.stringify({error: error instanceof Error ? error.message : String(error)}));process.exitCode=1; }
finally { client?.disconnect(); if(instance){await api.iosInstances.delete(instance.metadata.id); console.log('Deleted private Limrun instance');} }

@thymikee
thymikee force-pushed the feat/provider-keyboard-carriage branch from 5d537cb to 0b5e645 Compare October 13, 2026 17:38
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-3593/

Built to branch gh-pages at 2026-10-13 18:28 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.20 MB 5.20 MB +1.9 kB
Package (unpacked) 5.20 MB 5.20 MB +1.9 kB
Package (download) 1.56 MB 1.56 MB +669 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 25.2 ms 25.1 ms -0.1 ms
CLI --help 70.5 ms 71.0 ms +0.5 ms

@thymikee

Copy link
Copy Markdown
Member Author

Android Appium taps near a docked keyboard stop being refused with this change, so I can't recommend merging commit 0b5e645 yet. Android Appium acquisition calls observeProviderKeyboard with viewport:null (webdriver-interactor.ts:341), so the tree check in tap-keyboard-occlusion.ts:413 never runs and the probe alone decides the fact. isKeyboardShown=true gives visibility-only, and keyboardFactDisallowsTreeDerivation (line 375) then makes the tap guard return undetermined. A failed probe with IME anchors in the tree gives keyboard-tree-geometry-unusable (line 365), which switches tree derivation off the same way. isKeyboardShown=false gives no-keyboard even when the tree holds a docked IME. Before this change these captures had no fact, so the tap guard derived the band from the tree. The golden row "Gboard reports its region down to the physical display bottom" still expects occluded with no fact. The same mislabel hits iOS Appium when the roots give no viewport. User impact: on Android Appium (BrowserStack, AWS, local) with a docked Gboard, click and fill on a control behind the keyboard no longer return tap_keyboard_occludes_target, and the touch lands on a key, which is the #2589 misfire the guard exists to stop. The PR body and docs do not mention this, and the negative-probe path contradicts the PR's own rule that docked tree evidence outweighs the probe. The rule to satisfy: an acquisition fact may stop the tap-time tree rule only when acquisition evaluated the same tree against a resolvable viewport and rejected it. A boolean probe gathered without that evaluation may establish presence. It may establish absence only when the tap-time tree also resolves no docked band. Emit keyboard-tree-geometry-unusable only when a viewport was present. Please add a golden row "Gboard docked tree + visibility-only:true -> occluded", and an Android interactor test that runs resolveKeyboardTapOcclusion with the tree-resolved viewport (the production call), not viewport:null.

The change also alters tap decisions for existing producers, and no run shows that behavior. That covers Android Appium, and the new ownership rule in keyboard-occlusion.ts:54 for every iOS producer, the Apple runner included. The live runs used direct interactor and presenter scripts at a commit I cannot find in the repository (c13634284), so the "policy sources identical" claim is unverified. They show snapshot facts only, and none reached the daemon tap guard. Please run through the daemon CLI on this head, or on a commit in the repository. (a) Android Appium with a docked Gboard: snapshot --json should show the keyboard fact, then click @ref on an app control whose tap point is inside the IME window should return tap_keyboard_occludes_target with keyboardFrame. (b) iOS Appium Safari: the same, with a derived:docked-tree frame. (c) Apple runner: a tap on an app inputAccessory or inputAssistantItem control, and on a QuickType cell, showing the refuse or clear outcome the new marker rule claims.

Would it be simpler to keep the probe additive? visibility-only:true would never suppress tap-time tree derivation, and visibility-only:false would give no-keyboard only when the tree resolves no docked band. The rejected-geometry state would be emitted only when acquisition had a viewport, as its own fact variant (for example {kind:'unmeasurable', reason, treeRejected:true}) and not as a magic reason string. That deletes the probe branch of keyboardFactDisallowsTreeDerivation, fixes the defect above without new guards, and keeps derived and keyboardOwned for the projection-loss problem they solve. The three observe and annotate call sites could then go through one capture-kit acquisition helper. What would have to change first is recording the issue's two open decisions on #3529: the provenance vocabulary, and whether a positive probe may override tree derivation. If the answer is that Android Appium gives up keyboard refusal, please say so in the docs and the CHANGELOG.

Not blocking, take or leave: tap-keyboard-occlusion.ts:147 now treats app nodes under _UIRemoteKeyboardPlaceholderView or TUISystemInputAssistantView as not keyboard-owned, so a tap point in the band reads as occluded, while android-input-ownership.ts:169 says those hosts carry the app's own buttons drawn on the keyboard surface, so a real tree should confirm this and the occlusion rule may need its own "drawn on top" predicate; every Appium snapshot, including each wait poll and fill pre-capture, now pays a sequential isKeyboardShown round trip of up to TEXT_ENTRY_PROBE_TIMEOUT_MS, so a named snapshot probe budget and a CHANGELOG line would help; tap-time behavior keys on the single string keyboard-tree-geometry-unusable in the open unmeasurable.reason, which a wire producer can also send, so a typed variant is safer; keyboardOwned may reach public snapshot --json nodes undocumented (I did not check); and the two open decisions on #3529 should be recorded there.

Checks are still running on this commit (Smoke Tests, Analyze, Compatibility & Provenance, Typecheck & Package), and none has failed yet. The diff touches typecheck and package budgets and the provider-scenario routes (cloud-webdriver-ios-text-entry now expects two keyboard probes), so watch those. I know of no conflicts. I did not run the tests; I judged the golden rows by reading the pre-change code. Before merge, fix the Android Appium viewport case and supply the daemon-route tap evidence.

Two earlier inline threads still apply: the viewport:null thread and the marker ancestry thread, and the probe-before-tree thread holds only where acquisition has a viewport, not on Android. The lower-priority threads are addressed at this head and can be resolved: tap-keyboard-occlusion.test.ts:240 now compares against the tree-only fact, cli-help-workflows.ts:55 says "dispatched tap point", snapshots.md states the presence and absence rule, snapshot-unchanged.test.ts:85 checks both directions, record.ts:93-96 drops the trust order, and interactor-types.ts:269 covers every variant.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Carry provider keyboard observations through snapshot acquisition

1 participant