mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
3bf3ff130a
* 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.
124 lines
4.6 KiB
TypeScript
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]);
|
|
});
|