Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0d880e0
feat: add Linux desktop automation support via AT-SPI2 (Phase 1+2)
claude Apr 4, 2026
e6c2e31
refactor: code review cleanup for Linux platform support
claude Apr 4, 2026
a3f3992
feat: Linux input synthesis, screenshots, and app lifecycle (Phase 3+4)
claude Apr 4, 2026
ca22e32
refactor: consolidate Linux env detection, simplify input actions
claude Apr 4, 2026
cb1a368
feat: add Linux CI smoke test with Xvfb and AT-SPI2
claude Apr 4, 2026
4f1ec98
fix: remove pre-session screenshot from Linux replay test
claude Apr 4, 2026
bb049ce
fix: Linux CI — add missing node-gtk build deps and AT-SPI2 env
claude Apr 4, 2026
c97a806
fix: explicitly rebuild node-gtk native module in Linux CI
claude Apr 4, 2026
e51f5f9
fix: use node-pre-gyp directly to build node-gtk from source
claude Apr 4, 2026
4b2a0e3
refactor: replace node-gtk with Python subprocess for AT-SPI2
claude Apr 4, 2026
1dc9637
chore: drop pre-installed packages from Linux CI apt-get
claude Apr 4, 2026
484614d
feat: surface support for Linux, unit tests, stronger CI assertions
claude Apr 4, 2026
1ba6d20
docs: add cross-platform snapshot traversal contract
claude Apr 4, 2026
885bd65
fix: update lockfile after removing node-gtk optional dependency
claude Apr 4, 2026
8df648b
fix: address review findings in Linux platform code
claude Apr 4, 2026
e993073
fix(ci): explicitly install all Linux a11y dependencies
claude Apr 4, 2026
90753b5
fix: quote multi-word role value in Linux smoke test selector
claude Apr 4, 2026
fea873c
fix: use valid selector keys in Linux smoke test
claude Apr 4, 2026
34c6e63
chore: cleanup pass — menubar warning, fix contract doc example
claude Apr 4, 2026
f82feb0
chore: add Python bytecache to gitignore
claude Apr 4, 2026
8b02cbf
feat: harden Linux platform — capability matrix, CI, error handling
claude Apr 4, 2026
b4e89bf
fix: apply depth/interactive filtering to Linux snapshots
claude Apr 4, 2026
86ecc89
fix: address review findings — error reporting, Wayland, timeout
claude Apr 4, 2026
6cb62f8
feat: appName/windowTitle selectors, clipboard, input-action tests
claude Apr 4, 2026
0dbe11f
chore: cache tool detection for screenshot/clipboard, extract get_app…
claude Apr 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@
"dependencies": {
"pngjs": "^7.0.0"
},
"optionalDependencies": {
"node-gtk": "^0.14.0"
},
"devDependencies": {
"@microsoft/api-extractor": "^7.52.10",
"@rslib/core": "0.20.1",
Expand Down
1,032 changes: 1,032 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion src/core/dispatch-resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { listAndroidDevices } from '../platforms/android/devices.ts';
import { ensureAdb } from '../platforms/android/index.ts';
import { findBootableIosSimulator, listAppleDevices } from '../platforms/ios/devices.ts';
import { listLinuxDevices } from '../platforms/linux/devices.ts';
import { withDiagnosticTimer } from '../utils/diagnostics.ts';
import {
resolveAndroidSerialAllowlist,
Expand Down Expand Up @@ -104,10 +105,15 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise<De
if (selector.target && !selector.platform) {
throw new AppError(
'INVALID_ARGS',
'Device target selector requires --platform. Use --platform ios|macos|android|apple with --target mobile|tv|desktop.',
'Device target selector requires --platform. Use --platform ios|macos|android|linux|apple with --target mobile|tv|desktop.',
);
}

if (selector.platform === 'linux') {
const devices = await listLinuxDevices();
return await resolveDevice(devices, selector);
}

if (selector.platform === 'android') {
await ensureAdb();
const devices = await listAndroidDevices({ serialAllowlist: androidSerialAllowlist });
Expand All @@ -122,6 +128,11 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise<De
}

const devices: DeviceInfo[] = [];
try {
devices.push(...(await listLinuxDevices()));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve Android auto-selection before Linux host device

This new discovery order prepends the synthetic local Linux device ahead of Android candidates in the no---platform path, which changes default targeting on Linux hosts. Because resolveDevice keeps discovery order when multiple booted physical devices are equally valid, a connected Android phone (kind: device) is now commonly displaced by the Linux host entry, so existing commands that relied on implicit Android selection start resolving to Linux instead.

Useful? React with 👍 / 👎.

} catch {
// ignore
}
try {
devices.push(...(await listAndroidDevices({ serialAllowlist: androidSerialAllowlist })));
} catch {
Expand Down
20 changes: 20 additions & 0 deletions src/core/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getInteractor, type Interactor, type RunnerContext } from './interactor
import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
import { runMacOsPressAction, runMacOsReadTextAction } from '../platforms/ios/macos-helper.ts';
import { pushIosNotification } from '../platforms/ios/index.ts';
import { snapshotLinux } from '../platforms/linux/index.ts';
import type { SessionSurface } from './session-surface.ts';
import { isDeepLinkTarget } from './open-target.ts';
import { getClickButtonValidationError, resolveClickButton } from './click-button.ts';
Expand Down Expand Up @@ -765,6 +766,25 @@ async function handleSnapshotCommand(
context: DispatchContext | undefined,
_runnerCtx: RunnerContext,
): Promise<Record<string, unknown>> {
if (device.platform === 'linux') {
const linuxResult = await withDiagnosticTimer(
'snapshot_capture',
async () =>
await snapshotLinux(context?.surface, {
interactiveOnly: context?.snapshotInteractiveOnly,
compact: context?.snapshotCompact,
depth: context?.snapshotDepth,
scope: context?.snapshotScope,
raw: context?.snapshotRaw,
}),
{ backend: 'linux-atspi' },
);
return {
nodes: linuxResult.nodes ?? [],
truncated: linuxResult.truncated ?? false,
backend: 'linux-atspi',
};
}
if (device.platform !== 'android') {
const result = (await withDiagnosticTimer(
'snapshot_capture',
Expand Down
61 changes: 61 additions & 0 deletions src/core/interactors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,67 @@ export function getInteractor(device: DeviceInfo, runnerContext: RunnerContext):
setSetting: (setting, state, appId, options) =>
setAndroidSetting(device, setting, state, appId, options),
};
case 'linux':
return {
open: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'open not yet supported on Linux');
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Gate unsupported Linux commands in capability checks

The Linux interactor path is mostly stubbed with UNSUPPORTED_OPERATION throws, but Linux was added as a platform without a Linux-specific capability matrix, so command support checks still treat Linux like Android and allow commands such as open, click, and fill to proceed until they fail at runtime here. This creates false-positive support signals and inconsistent behavior across handlers that rely on isCommandSupportedOnDevice for early rejection.

Useful? React with 👍 / 👎.

openDevice: () => Promise.resolve(),
close: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'close not yet supported on Linux');
},
tap: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'tap not yet supported on Linux');
},
doubleTap: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'doubleTap not yet supported on Linux');
},
swipe: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'swipe not yet supported on Linux');
},
longPress: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'longPress not yet supported on Linux');
},
focus: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'focus not yet supported on Linux');
},
type: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'type not yet supported on Linux');
},
fill: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'fill not yet supported on Linux');
},
scroll: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'scroll not yet supported on Linux');
},
scrollIntoView: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'scrollIntoView not yet supported on Linux');
},
screenshot: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'screenshot not yet supported on Linux');
},
back: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'back not yet supported on Linux');
},
home: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'home not yet supported on Linux');
},
rotate: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'rotate not supported on Linux');
},
appSwitcher: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'appSwitcher not yet supported on Linux');
},
readClipboard: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'readClipboard not yet supported on Linux');
},
writeClipboard: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'writeClipboard not yet supported on Linux');
},
setSetting: () => {
throw new AppError('UNSUPPORTED_OPERATION', 'setSetting not supported on Linux');
},
};
case 'ios':
case 'macos': {
const { overrides, runnerOpts } = iosRunnerOverrides(device, runnerContext);
Expand Down
2 changes: 1 addition & 1 deletion src/daemon/handlers/__tests__/interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ async function emulateCaptureSnapshotForSession(
[],
effectiveFlags.out,
contextFromFlags(effectiveFlags, session.appBundleId, session.trace?.outPath),
)) as { nodes?: never[]; truncated?: boolean; backend?: 'xctest' | 'android' | 'macos-helper' };
)) as { nodes?: never[]; truncated?: boolean; backend?: 'xctest' | 'android' | 'macos-helper' | 'linux-atspi' };
const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags);
session.snapshot = snapshot;
sessionStore.set(session.name, session);
Expand Down
2 changes: 1 addition & 1 deletion src/daemon/handlers/session-replay-heal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ async function captureSnapshotForReplay(
})) as {
nodes?: RawSnapshotNode[];
truncated?: boolean;
backend?: 'xctest' | 'android' | 'macos-helper';
backend?: 'xctest' | 'android' | 'macos-helper' | 'linux-atspi';
};
const rawNodes = data?.nodes ?? [];
const nodes = attachRefs(action.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
Expand Down
23 changes: 19 additions & 4 deletions src/daemon/handlers/snapshot-capture.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { dispatchCommand, type CommandFlags } from '../../core/dispatch.ts';
import { runMacOsSnapshotAction } from '../../platforms/ios/macos-helper.ts';
import { snapshotLinux } from '../../platforms/linux/index.ts';
import type { AndroidSnapshotAnalysis } from '../../platforms/android/ui-hierarchy.ts';
import {
attachRefs,
Expand Down Expand Up @@ -36,7 +37,7 @@ type CaptureSnapshotParams = {
type SnapshotData = {
nodes?: RawSnapshotNode[];
truncated?: boolean;
backend?: 'xctest' | 'android' | 'macos-helper';
backend?: 'xctest' | 'android' | 'macos-helper' | 'linux-atspi';
analysis?: AndroidSnapshotAnalysis;
};

Expand All @@ -61,6 +62,20 @@ export async function captureSnapshot(params: CaptureSnapshotParams): Promise<{

export async function captureSnapshotData(params: CaptureSnapshotParams): Promise<SnapshotData> {
const { device, session, flags, outPath, logPath, snapshotScope } = params;
if (device.platform === 'linux') {
const linuxResult = await snapshotLinux(session?.surface, {
interactiveOnly: flags?.snapshotInteractiveOnly,
compact: flags?.snapshotCompact,
depth: flags?.snapshotDepth,
scope: snapshotScope,
raw: flags?.snapshotRaw,
});
return {
nodes: linuxResult.nodes,
truncated: linuxResult.truncated,
backend: 'linux-atspi',
};
}
if (device.platform === 'macos' && session?.surface && session.surface !== 'app') {
const helperSnapshot = await runMacOsSnapshotAction(session.surface, {
bundleId: session.surface === 'menubar' ? session.appBundleId : undefined,
Expand Down Expand Up @@ -175,7 +190,7 @@ export function buildSnapshotState(
data: {
nodes?: RawSnapshotNode[];
truncated?: boolean;
backend?: 'xctest' | 'android' | 'macos-helper';
backend?: 'xctest' | 'android' | 'macos-helper' | 'linux-atspi';
},
flags:
| (Pick<
Expand All @@ -189,7 +204,7 @@ export function buildSnapshotState(
const snapshotRaw = flags?.snapshotRaw;
const normalizedNodes = normalizeSnapshotTree(snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
const scopedNodes =
flags?.snapshotScope && data?.backend !== 'macos-helper'
flags?.snapshotScope && data?.backend !== 'macos-helper' && data?.backend !== 'linux-atspi'
? scopeSnapshotNodes(normalizedNodes, flags.snapshotScope)
: normalizedNodes;
const nodes = attachRefs(scopedNodes);
Expand All @@ -216,7 +231,7 @@ export function buildSnapshotVisibility(params: {
snapshotRaw?: boolean;
}): SnapshotVisibility {
const { nodes, backend, snapshotRaw } = params;
if (snapshotRaw || backend === 'macos-helper') {
if (snapshotRaw || backend === 'macos-helper' || backend === 'linux-atspi') {
return {
partial: false,
visibleNodeCount: nodes.length,
Expand Down
Loading
Loading