Files
callstack__agent-device/test/integration/linux-e2e/coverage-manifest.ts
Michał Pierzchała 3bf3ff130a fix: Linux input/a11y defects from #1935 (click miss, typed '=', GTK4 text) (#1949)
* diag: instrument Linux CI to gather evidence for #1935 input/a11y defects

Temporary — adds a diagnostic step that dumps raw AT-SPI interfaces/actions
for gnome-calculator's digit buttons, tests a raw xdotool click at a
button's own rect (bypassing our promotion logic), and isolates the typed
'=' character in several configurations. Will be removed once the real
fixes land.

* diag: harden diagnostic step against bash -e and AT-SPI registration races

The prior version crashed 7s in: GH Actions runs steps under bash -e, and
an unguarded python3 heredoc threw (iterating a dict instead of a list
when the app wasn't found yet), aborting the rest of the script silently
under continue-on-error. Guards every fallible command, and replaces the
fixed 2s sleep with inspect.py's own poll-until-found loop.

* diag: test WINDOW coordtype and static Text.get_text call (round 3)

Round 2 proved Component.get_extents(SCREEN) returns (0,0) for every
non-toplevel widget (real click miss confirmed on-screen), and
Text.get_text() throws — a documented PyGObject binding collision with
the deprecated 1-arg Accessible.get_text(). This narrows to the two
candidate fixes before writing them: does CoordType.WINDOW give usable
relative offsets, and does Atspi.Text.get_text(accessible, ...) (static
call) return the real typed text.

* fix(linux): resolve click-miss, dropped '=', and GTK4 text exposure defects

Three defects surfaced by CI on #1935 (Linux Smoke lane), all confirmed
live via instrumented CI runs before being fixed here:

1. Click misses its target: Component.get_extents(Atspi.CoordType.SCREEN)
   returns (0, 0) as the origin for every non-toplevel widget under this
   GTK4 build — confirmed by a raw click at the computed rect center
   landing on the window's own header-bar button instead of the intended
   digit button. CoordType.WINDOW gives correct, distinct per-widget
   offsets, so get_rect() now computes screen-absolute rects as that
   offset plus the enclosing top-level frame's own (correct) screen
   origin, threaded through traverse_node() alongside the existing
   window-title tracking. Complementary hardening: role "label" is now
   excluded from `hittable`, since GTK4 wraps every button's caption in a
   same-rect "label" child, and the shared cross-platform promotion logic
   in interaction-targeting.ts would otherwise retarget a click from the
   button onto that non-interactive label.

2. Typed '=' never arrives: a single isolated synthetic keystroke sent
   right after a focus change is unreliably delivered — confirmed live,
   both `xdotool type -- "="` and `xdotool key equal` sent alone produced
   no character at all, while multi-character bursts always landed in
   full. typeLinux and sendKey now wait a short settle margin before
   dispatching to xdotool/ydotool, absorbing the race regardless of which
   action last changed focus.

3. GTK4 apps expose no editable text: accessible.get_text_iface().get_text()
   throws "Atspi.Accessible.get_text() takes exactly 1 argument (3 given)"
   — a documented PyGObject binding collision between Text.get_text and
   the deprecated 1-argument Accessible.get_text, silently swallowed as
   "no text" by the broad exception handler. get_text_value() now calls
   the unbound Atspi.Text.get_text(accessible, ...) form, which correctly
   returns the real content.

The Linux smoke replay is restored to exercise all three fixes together
(click a resolved digit button, type a full calculation including the
'=' keystroke, wait on the computed result through the tree) instead of
staying at the weakened, contract-tier assertions the defects had forced.
The coverage manifest promotes click and type from command-contract to
live accordingly.

* fix(linux): drop unproven keyboard-settle and hittable changes per review

Addresses thymikee's review on #1949 (both points correct):

P1: the keyboard settle (typeLinux/sendKey) was unjustified. The cited
diagnostic evidence for a dropped '=' actually shows the opposite —
"100+55=" and "5=5" both computed correctly with zero settle, proving
'=' was delivered in every multi-character burst tested. Sending '='
alone to an empty entry showing a blank display is normal calculator
semantics (nothing to evaluate), not a lost keystroke. The likelier
explanation for the original "100+55" screenshot (run 32487868346) is
that its attempt-3 hit the already-fixed mousemove --sync hang, not an
independent keyboard-dispatch defect. Reverted; no keyboard-dispatch
change was needed.

P2: the `role_name != "label"` hittable narrowing was extra surface
beyond what the click-miss fix required. The corrected AT-SPI coordinates
alone fix the observed miss — the button and its same-rect label child
resolve to nearly identical centers, so descendant promotion still lands
inside the button either way, and the replay can't distinguish which
node it actually targeted. Reverted; only the coordinate fix remains.
2026-08-21 20:02:17 +02:00

247 lines
10 KiB
TypeScript

import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts';
import { buildCoverageClassificationSummary } from '../support/coverage-classification.ts';
type PublicCommand = (typeof PUBLIC_COMMANDS)[keyof typeof PUBLIC_COMMANDS];
type RepositoryEvidence = {
path: string;
test: string;
};
type CapabilityDeclarationEvidence = RepositoryEvidence & {
declaration: string;
};
export type LinuxPlatformCoverageEntry =
| {
assertion: string;
level: 'live' | 'command-contract';
owner: RepositoryEvidence;
}
| {
assertion: string;
level: 'capability-denial';
owner: CapabilityDeclarationEvidence;
}
| {
assertion: string;
level: 'known-gap';
trackingIssue: number;
};
export const LINUX_COVERAGE_GAP_ISSUE = 1915;
export const LINUX_REPLAY_EVIDENCE: RepositoryEvidence = {
path: 'test/integration/replays/linux/01-desktop-smoke.ad',
test: '# Smoke test for Linux desktop automation on CI.',
};
const LINUX_PROVIDER_EVIDENCE: RepositoryEvidence = {
path: 'test/integration/provider-scenarios/linux-desktop.test.ts',
test: 'Provider-backed integration Linux desktop flow uses semantic desktop and input providers',
};
const LINUX_RUNTIME_EVIDENCE: RepositoryEvidence = {
path: 'packages/platform-linux/src/runtime.test.ts',
test: 'classifies the Linux $name lifecycle denominator against the legacy dispatch cell',
};
const LINUX_CAPABILITY_DECLARATION_PATH = 'src/core/command-descriptor/registry.ts';
const LINUX_CAPABILITY_DECLARATION = 'linux: LINUX_NONE';
const C = PUBLIC_COMMANDS;
const live = (assertion: string): LinuxPlatformCoverageEntry => ({
assertion,
level: 'live',
owner: LINUX_REPLAY_EVIDENCE,
});
const contract = (path: string, test: string, assertion: string): LinuxPlatformCoverageEntry => ({
assertion,
level: 'command-contract',
owner: { path, test },
});
const denial = (command: string, assertion: string): LinuxPlatformCoverageEntry => ({
assertion,
level: 'capability-denial',
owner: {
path: LINUX_CAPABILITY_DECLARATION_PATH,
test: `name: '${command}'`,
declaration: LINUX_CAPABILITY_DECLARATION,
},
});
const gap = (assertion: string): LinuxPlatformCoverageEntry => ({
assertion,
level: 'known-gap',
trackingIssue: LINUX_COVERAGE_GAP_ISSUE,
});
/**
* One primary, observable owner for every public command on the Linux desktop.
*
* Live rows are limited to the existing Linux replay. Contract rows cite the
* existing provider scenario or dedicated Linux unit/runtime evidence; they do
* not turn mocked provider calls into live desktop claims. Capability denials
* are derived from the owning command-descriptor matrix. Known gaps are
* explicit follow-up work, not an implicit claim that a generic command works.
*/
export const LINUX_PLATFORM_COVERAGE = {
[C.artifacts]: gap('No Linux-specific artifact inventory command evidence exists yet'),
[C.devices]: contract(
LINUX_PROVIDER_EVIDENCE.path,
LINUX_PROVIDER_EVIDENCE.test,
'Linux provider scenario inventories the selected desktop device through the daemon client',
),
[C.capabilities]: gap('No Linux-specific capabilities command evidence exists yet'),
[C.doctor]: gap('No Linux-specific doctor command evidence exists yet'),
[C.apps]: contract(
LINUX_RUNTIME_EVIDENCE.path,
LINUX_RUNTIME_EVIDENCE.test,
'Linux runtime facts explicitly report native app inventory unavailable',
),
[C.boot]: contract(
LINUX_RUNTIME_EVIDENCE.path,
LINUX_RUNTIME_EVIDENCE.test,
'Linux runtime facts explicitly report boot unavailable for the desktop owner',
),
[C.shutdown]: gap('No Linux-specific shutdown command evidence exists yet'),
[C.appState]: contract(
LINUX_RUNTIME_EVIDENCE.path,
LINUX_RUNTIME_EVIDENCE.test,
'Linux runtime facts explicitly report app state unavailable',
),
[C.perf]: denial('perf', 'Linux capability declaration rejects native performance inspection'),
[C.logs]: gap('No Linux-specific app-log command evidence exists yet'),
[C.events]: gap('No Linux-specific session event command evidence exists yet'),
[C.network]: contract(
LINUX_RUNTIME_EVIDENCE.path,
LINUX_RUNTIME_EVIDENCE.test,
'Linux runtime facts explicitly report network capture unavailable',
),
[C.audio]: denial('audio', 'Linux capability declaration rejects native audio probing'),
[C.replay]: gap('No Linux-specific replay command evidence exists yet'),
[C.test]: gap('No Linux-specific test-suite command evidence exists yet'),
[C.clipboard]: contract(
'src/platforms/linux/__tests__/clipboard.test.ts',
'writeLinuxClipboard uses xclip with stdin on X11',
'Linux clipboard writes through the supported X11 host-tool seam',
),
[C.keyboard]: denial('keyboard', 'Linux capability declaration rejects native keyboard control'),
[C.install]: gap('No Linux-specific application installation command evidence exists yet'),
[C.reinstall]: gap('No Linux-specific application reinstallation command evidence exists yet'),
[C.push]: gap('No Linux-specific push delivery command evidence exists yet'),
[C.triggerAppEvent]: denial(
'trigger-app-event',
'Linux capability declaration rejects native application event delivery',
),
[C.open]: live('the existing Linux replay opens gnome-calculator'),
[C.prepare]: contract(
LINUX_RUNTIME_EVIDENCE.path,
LINUX_RUNTIME_EVIDENCE.test,
'Linux runtime facts explicitly report Apple runner preparation unavailable',
),
[C.batch]: gap('No Linux-specific batch command evidence exists yet'),
[C.close]: contract(
LINUX_PROVIDER_EVIDENCE.path,
LINUX_PROVIDER_EVIDENCE.test,
'Linux provider scenario closes the calculator and observes the desktop close call',
),
[C.snapshot]: live('the existing Linux replay captures the calculator accessibility tree'),
[C.diff]: gap('No Linux-specific snapshot diff command evidence exists yet'),
[C.wait]: live('the existing Linux replay waits for an observable calculator landmark'),
[C.alert]: denial('alert', 'Linux capability declaration rejects native alert operations'),
[C.settings]: denial(
'settings',
'Linux capability declaration rejects native device settings operations',
),
[C.reactNative]: denial(
'react-native',
'Linux capability declaration rejects React Native inspection',
),
[C.record]: gap('No Linux-specific recording command evidence exists yet'),
[C.trace]: gap('No Linux-specific trace command evidence exists yet'),
[C.find]: gap('No Linux-specific find command evidence exists yet'),
// Promoted from command-contract to live: the desktop replay now clicks a resolved digit
// button on real Linux hardware and the downstream wait only passes if the click landed
// (formerly missed — AT-SPI extents were computed screen-absolute-wrong under GTK4; see
// linux/atspi-dump.py).
[C.click]: live('the Linux desktop replay clicks a resolved calculator digit button'),
[C.fill]: contract(
LINUX_PROVIDER_EVIDENCE.path,
LINUX_PROVIDER_EVIDENCE.test,
'Linux provider scenario fills both a snapshot ref and coordinate target',
),
[C.longPress]: contract(
LINUX_PROVIDER_EVIDENCE.path,
LINUX_PROVIDER_EVIDENCE.test,
'Linux provider scenario executes a coordinate long press',
),
[C.hover]: denial('hover', 'Linux capability declaration rejects pointer-only hover input'),
[C.press]: contract(
LINUX_PROVIDER_EVIDENCE.path,
LINUX_PROVIDER_EVIDENCE.test,
'Linux provider scenario presses a snapshot ref and coordinate target',
),
// Promoted from command-contract to live: GTK4 gnome-calculator's entry previously exposed no
// Text-interface content to selectors (a PyGObject binding call-pattern bug — see
// linux/atspi-dump.py), so no tree-level assertion could hold. Fixed, so the desktop replay's
// typed calculation now has a real wait assertion on the computed result.
[C.type]: live('the Linux desktop replay types a calculation and its result is selectable'),
[C.get]: contract(
LINUX_PROVIDER_EVIDENCE.path,
LINUX_PROVIDER_EVIDENCE.test,
'Linux provider scenario reads the pressed snapshot ref text',
),
[C.is]: live('the existing Linux replay verifies a calculator landmark exists'),
[C.back]: contract(
LINUX_PROVIDER_EVIDENCE.path,
LINUX_PROVIDER_EVIDENCE.test,
'Linux provider scenario dispatches Alt+Left through the semantic input provider',
),
[C.gesture]: contract(
LINUX_PROVIDER_EVIDENCE.path,
LINUX_PROVIDER_EVIDENCE.test,
'Linux provider scenario executes a single-pointer pan through the semantic drag provider',
),
[C.home]: contract(
LINUX_PROVIDER_EVIDENCE.path,
LINUX_PROVIDER_EVIDENCE.test,
'Linux provider scenario dispatches Super+D through the semantic input provider',
),
[C.tvRemote]: denial('tv-remote', 'Linux capability declaration rejects TV remote input'),
[C.orientation]: denial(
'orientation',
'Linux capability declaration rejects native orientation changes',
),
[C.scroll]: contract(
'src/platforms/linux/__tests__/input-actions.test.ts',
'scrollLinux uses ydotool mousemove --wheel for vertical scroll',
'Linux scroll dispatch uses the Wayland ydotool wheel primitive',
),
[C.swipe]: gap('No Linux-specific public swipe command evidence exists yet'),
// Promoted from command-contract to live by #1925: the desktop replay now runs a coordinate
// focus on real Linux hardware, so the migrated `focusPoint` path has live changed-path
// evidence rather than only the provider scenario at LINUX_PROVIDER_EVIDENCE.
[C.focus]: live('the Linux desktop replay focuses a coordinate on real hardware'),
[C.screenshot]: live('the existing Linux replay creates a screenshot artifact'),
[C.viewport]: contract(
LINUX_RUNTIME_EVIDENCE.path,
LINUX_RUNTIME_EVIDENCE.test,
'Linux runtime facts explicitly report viewport changes unavailable',
),
[C.appSwitcher]: denial(
'app-switcher',
'Linux capability declaration rejects native app-switcher navigation',
),
[C.installFromSource]: gap('No Linux-specific source-install command evidence exists yet'),
} satisfies Record<PublicCommand, LinuxPlatformCoverageEntry>;
export const LINUX_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY = buildCoverageClassificationSummary(
Object.values(LINUX_PLATFORM_COVERAGE),
);
export function liveCommandsForLinuxReplay(): PublicCommand[] {
return Object.entries(LINUX_PLATFORM_COVERAGE)
.filter(([, entry]) => entry.level === 'live')
.map(([command]) => command as PublicCommand);
}