Files
callstack__agent-device/test/integration/smoke-linux-coverage.test.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

124 lines
4.6 KiB
TypeScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { parseReplayScriptDetailed } from '@agent-device/ad-script';
import { LINUX_DEVICE } from '../../src/__tests__/test-utils/device-fixtures.ts';
import { PUBLIC_COMMANDS } from '../../src/command-catalog.ts';
import { isCommandSupportedOnDevice } from '../../src/core/capabilities.ts';
import {
LINUX_COVERAGE_GAP_ISSUE,
LINUX_PLATFORM_COVERAGE,
LINUX_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY,
LINUX_REPLAY_EVIDENCE,
liveCommandsForLinuxReplay,
} from './linux-e2e/coverage-manifest.ts';
const publicCommands = Object.values(PUBLIC_COMMANDS).sort();
test('Linux coverage exhaustively classifies the public catalog', () => {
assert.deepEqual(Object.keys(LINUX_PLATFORM_COVERAGE).sort(), publicCommands);
for (const command of publicCommands) {
const entry = LINUX_PLATFORM_COVERAGE[command];
assert.ok(entry.assertion.trim().length > 0, `${command} needs an observable assertion`);
if (entry.level === 'live' || entry.level === 'command-contract') {
assert.ok(entry.owner.path.trim().length > 0, `${command} needs an evidence path`);
assert.ok(entry.owner.test.trim().length > 0, `${command} needs named evidence`);
}
if (entry.level === 'known-gap') {
assert.equal(
entry.trackingIssue,
LINUX_COVERAGE_GAP_ISSUE,
`${command} has the wrong Linux gap issue`,
);
}
}
});
test('Linux coverage report has the expected classification counts', () => {
assert.deepEqual(LINUX_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, {
capabilityDenial: 11,
// focus (#1925), click, and type are live via the replay: click resolves and lands on a
// digit button, and the typed calculation's result is now selectable (see the manifest
// entries for the platform defects this fixed).
contract: 17,
gap: 18,
live: 8,
total: 54,
});
const { capabilityDenial, contract, gap, live, total } =
LINUX_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY;
assert.equal(capabilityDenial + contract + gap + live, total);
});
test('Linux live claims reference commands in the existing smoke replay', () => {
const replaySource = fs.readFileSync(path.resolve(LINUX_REPLAY_EVIDENCE.path), 'utf8');
assert.ok(replaySource.includes(LINUX_REPLAY_EVIDENCE.test));
const replayCommands = new Set(
parseReplayScriptDetailed(replaySource).actions.map((action) => action.command),
);
const liveCommands = liveCommandsForLinuxReplay();
assert.deepEqual(liveCommands.length, 8);
for (const command of liveCommands) {
assert.equal(
replayCommands.has(command),
true,
`${command} is not invoked by ${LINUX_REPLAY_EVIDENCE.path}`,
);
}
});
test('Linux contract claims name existing executable evidence', () => {
for (const [command, entry] of Object.entries(LINUX_PLATFORM_COVERAGE)) {
if (entry.level !== 'command-contract') continue;
const evidencePath = path.resolve(entry.owner.path);
assert.equal(fs.existsSync(evidencePath), true, `${command} owner does not exist`);
assert.equal(
fs.readFileSync(evidencePath, 'utf8').includes(entry.owner.test),
true,
`${command} owner does not contain named evidence`,
);
}
});
test('Linux capability denials match the owning descriptor matrix', () => {
const deniedByCapabilities = publicCommands.filter(
(command) => !isCommandSupportedOnDevice(command, LINUX_DEVICE),
);
const deniedByManifest = Object.entries(LINUX_PLATFORM_COVERAGE)
.filter(([, entry]) => entry.level === 'capability-denial')
.map(([command]) => command)
.sort();
assert.deepEqual(deniedByManifest, deniedByCapabilities);
for (const [command, entry] of Object.entries(LINUX_PLATFORM_COVERAGE)) {
if (entry.level !== 'capability-denial') continue;
const declarationPath = path.resolve(entry.owner.path);
const declarationSource = fs.readFileSync(declarationPath, 'utf8');
assert.equal(
declarationSource.includes(entry.owner.test),
true,
`${command} capability owner does not name its descriptor`,
);
assert.equal(
declarationSource.includes(entry.owner.declaration),
true,
`${command} capability owner does not cite its Linux declaration`,
);
}
});
test('Linux known gaps use one grouped tracking issue', () => {
const gapIssues = new Set(
Object.values(LINUX_PLATFORM_COVERAGE)
.filter((entry) => entry.level === 'known-gap')
.map((entry) => entry.trackingIssue),
);
assert.deepEqual([...gapIssues], [LINUX_COVERAGE_GAP_ISSUE]);
});