Skip to content

Commit a830ac8

Browse files
authored
feat: add Linux command evidence lane (#2017)
* feat: add Linux command evidence lane * fix: assert Linux find result shape * fix: read Linux find result envelope * fix: reset Linux calculator before diff * fix: release Linux session before reset * fix: guard Linux evidence session reset * fix: forward Linux evidence timeout * fix: tighten Linux evidence assertions * fix: preserve Linux replay session identity * fix: close Linux replay session before reset * fix: share Linux evidence daemon state * fix: keep Linux swipe evidence in bounds * fix: keep Linux artifact gap honest
1 parent d97a628 commit a830ac8

13 files changed

Lines changed: 423 additions & 37 deletions

File tree

.github/workflows/linux.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ jobs:
136136
--report-junit
137137
test/artifacts/replays-linux.junit.xml
138138
139+
- name: Reset daemon before Linux command evidence
140+
run: pnpm clean:daemon
141+
142+
- name: Execute Linux command evidence
143+
uses: ./.github/actions/run-gate
144+
with:
145+
gate: linux-command-evidence
146+
139147
- name: Upload Linux artifacts
140148
if: always()
141149
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,8 @@
193193
"test:replay:ios-device": "node --experimental-strip-types src/bin.ts test test/integration/replays/ios/device",
194194
"test:replay:android": "node --experimental-strip-types src/bin.ts test test/integration/replays/android/emulator",
195195
"test:replay:macos": "node --experimental-strip-types src/bin.ts test test/integration/replays/macos",
196-
"test:replay:linux": "node --experimental-strip-types src/bin.ts test test/integration/replays/linux"
196+
"test:replay:linux": "node --experimental-strip-types src/bin.ts test test/integration/replays/linux",
197+
"test:linux:command-evidence": "node --experimental-strip-types test/integration/linux-e2e/live-runner.ts"
197198
},
198199
"files": [
199200
"bin",

scripts/check-affected/checks.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [
146146
gate('replay-ios-device', 'iOS physical device replay suite', 'test:replay:ios-device', false),
147147
gate('replay-macos', 'macOS replay suite', 'test:replay:macos', false),
148148
gate('replay-linux', 'Linux replay suite', 'test:replay:linux', false),
149+
gate(
150+
'linux-command-evidence',
151+
'Linux command evidence suite',
152+
'test:linux:command-evidence',
153+
false,
154+
),
149155
gate('replay-android', 'Android replay suite', 'test:replay:android', false),
150156
];
151157

scripts/check-affected/device-lanes.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ function lanes(file: string): CheckId[] {
1313
const plan = selectChecks({ changedFiles: [file] });
1414
assert.equal(plan.failOpen, false, `${file} must not fail open`);
1515
return plan.checks.filter((id) =>
16-
/^macos-coverage$|^replay-|^web-smoke$|^swift-runner-|^macos-helper$|^android-helpers$/.test(
16+
/^macos-coverage$|^replay-|^linux-command-evidence$|^web-smoke$|^swift-runner-|^macos-helper$|^android-helpers$/.test(
1717
id,
1818
),
1919
);
@@ -84,6 +84,7 @@ test('shared runtime surface owns every device lane', () => {
8484
'replay-ios-device',
8585
'replay-macos',
8686
'replay-linux',
87+
'linux-command-evidence',
8788
'replay-android',
8889
]);
8990
}
@@ -103,8 +104,11 @@ test('another family owns only its own lanes, so an Android-only change carries
103104
assert.deepEqual(lanes('test/integration/android-emulator-e2e/live-runner.ts'), [
104105
'replay-android',
105106
]);
106-
assert.deepEqual(lanes('src/platforms/linux/snapshot.ts'), ['replay-linux']);
107-
assert.deepEqual(lanes('linux/atspi-dump.py'), ['replay-linux']);
107+
assert.deepEqual(lanes('src/platforms/linux/snapshot.ts'), [
108+
'replay-linux',
109+
'linux-command-evidence',
110+
]);
111+
assert.deepEqual(lanes('linux/atspi-dump.py'), ['replay-linux', 'linux-command-evidence']);
108112
assert.deepEqual(lanes('src/platforms/web/provider.ts'), ['web-smoke']);
109113
assert.deepEqual(lanes('test/integration/smoke-web-platform.test.ts'), ['web-smoke']);
110114
// Families with no CI lane fall through to the static gates only.

scripts/check-affected/device-lanes.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Device-lane ownership: which live device lanes a changed path can break (#1781 A9-2).
22
//
33
// The live lanes (`replay-ios`, `replay-ios-device`, `replay-macos`, `replay-android`,
4-
// `replay-linux`, `web-smoke`) drive the CLI and daemon against a real simulator, emulator,
5-
// desktop, or browser. Before this rule the selector could reach them only through a full
4+
// `replay-linux`, `linux-command-evidence`, `web-smoke`) drive the CLI and daemon against a real
5+
// simulator, emulator, desktop, or browser. Before this rule the selector could reach them only through a full
66
// fail-open, so a TypeScript-only Apple change (`src/platforms/apple/**`) produced a plan
77
// with no iOS check in it at all, and nothing could route `ios.yml` on the plan.
88
//
@@ -42,7 +42,7 @@ const LEAF_LANES: Readonly<Record<Leaf, readonly CheckId[]>> = {
4242
macos: ['replay-macos'],
4343
apple: ['replay-ios', 'replay-ios-device', 'replay-macos'],
4444
android: ['replay-android'],
45-
linux: ['replay-linux'],
45+
linux: ['replay-linux', 'linux-command-evidence'],
4646
web: ['web-smoke'],
4747
harmonyos: [],
4848
vega: [],

scripts/check-affected/model.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export type CheckId =
8585
| 'replay-ios-device'
8686
| 'replay-macos'
8787
| 'replay-linux'
88+
| 'linux-command-evidence'
8889
| 'replay-android';
8990

9091
// The complete local check universe. A fail-open plan selects all of these;
@@ -146,6 +147,7 @@ export const ALL_CHECKS: readonly CheckId[] = [
146147
'replay-ios-device',
147148
'replay-macos',
148149
'replay-linux',
150+
'linux-command-evidence',
149151
'replay-android',
150152
];
151153

test/integration/cli-json.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,19 @@ export type CliJsonResult = {
99
stderr: string;
1010
};
1111

12-
export function runSourceCliJsonSync(
13-
args: string[],
14-
options?: { env?: NodeJS.ProcessEnv },
15-
): CliJsonResult {
12+
export type CliJsonOptions = {
13+
env?: NodeJS.ProcessEnv;
14+
timeoutMs?: number;
15+
};
16+
17+
export function runSourceCliJsonSync(args: string[], options?: CliJsonOptions): CliJsonResult {
1618
const result = runCmdSync(
1719
process.execPath,
1820
['--experimental-strip-types', 'src/bin.ts', ...args],
1921
{
2022
allowFailure: true,
2123
env: options?.env,
22-
timeoutMs: CLI_TIMEOUT_MS,
24+
timeoutMs: options?.timeoutMs ?? CLI_TIMEOUT_MS,
2325
},
2426
);
2527
return cliJsonResult(result);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# This script is intentionally outside replays/linux: the existing Linux replay smoke scope is
2+
# kept unchanged. It owns the lane's test and replay evidence; the runner owns the other direct
3+
# command-specific evidence.
4+
context platform=linux target=desktop
5+
open gnome-calculator --relaunch
6+
wait "appname=gnome-calculator || windowtitle=Calculator || label=Calculator || label=0 || label=1 || label=5" 15000
7+
snapshot -i
8+
find role "button" exists
9+
close
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts';
2+
3+
/** Commands exercised by the separate Linux/Xvfb command-evidence lane. */
4+
export const LINUX_COMMAND_EVIDENCE_COMMANDS = [
5+
PUBLIC_COMMANDS.capabilities,
6+
PUBLIC_COMMANDS.doctor,
7+
PUBLIC_COMMANDS.events,
8+
PUBLIC_COMMANDS.replay,
9+
PUBLIC_COMMANDS.test,
10+
PUBLIC_COMMANDS.batch,
11+
PUBLIC_COMMANDS.diff,
12+
PUBLIC_COMMANDS.find,
13+
PUBLIC_COMMANDS.swipe,
14+
] as const;
15+
16+
export const LINUX_COMMAND_EVIDENCE_SCRIPT =
17+
'test/integration/linux-e2e/command-evidence.ad' as const;

test/integration/linux-e2e/coverage-manifest.ts

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ export const LINUX_REPLAY_EVIDENCE: RepositoryEvidence = {
3636
test: '# Smoke test for Linux desktop automation on CI.',
3737
};
3838

39+
export const LINUX_COMMAND_EVIDENCE: RepositoryEvidence = {
40+
path: 'test/integration/linux-e2e/live-runner.ts',
41+
test: 'runLinuxCommandEvidence',
42+
};
43+
3944
const LINUX_PROVIDER_EVIDENCE: RepositoryEvidence = {
4045
path: 'test/integration/provider-scenarios/linux-desktop.test.ts',
4146
test: 'Provider-backed integration Linux desktop flow uses semantic desktop and input providers',
@@ -50,11 +55,16 @@ const LINUX_CAPABILITY_DECLARATION_PATH = 'src/core/command-descriptor/registry.
5055
const LINUX_CAPABILITY_DECLARATION = 'linux: LINUX_NONE';
5156

5257
const C = PUBLIC_COMMANDS;
53-
const live = (assertion: string): LinuxPlatformCoverageEntry => ({
58+
const live = (
59+
assertion: string,
60+
owner: RepositoryEvidence = LINUX_REPLAY_EVIDENCE,
61+
): LinuxPlatformCoverageEntry => ({
5462
assertion,
5563
level: 'live',
56-
owner: LINUX_REPLAY_EVIDENCE,
64+
owner,
5765
});
66+
const commandEvidenceLive = (assertion: string): LinuxPlatformCoverageEntry =>
67+
live(assertion, LINUX_COMMAND_EVIDENCE);
5868
const contract = (path: string, test: string, assertion: string): LinuxPlatformCoverageEntry => ({
5969
assertion,
6070
level: 'command-contract',
@@ -78,21 +88,26 @@ const gap = (assertion: string): LinuxPlatformCoverageEntry => ({
7888
/**
7989
* One primary, observable owner for every public command on the Linux desktop.
8090
*
81-
* Live rows are limited to the existing Linux replay. Contract rows cite the
82-
* existing provider scenario or dedicated Linux unit/runtime evidence; they do
83-
* not turn mocked provider calls into live desktop claims. Capability denials
84-
* are derived from the owning command-descriptor matrix. Known gaps are
85-
* explicit follow-up work, not an implicit claim that a generic command works.
91+
* Live rows cite either the existing Linux replay or the separate command-evidence
92+
* lane. The existing replay scope stays unchanged. Contract rows cite the existing
93+
* provider scenario or dedicated Linux unit/runtime evidence; they do not turn
94+
* mocked provider calls into live desktop claims. Capability denials are derived
95+
* from the owning command-descriptor matrix. Known gaps are explicit follow-up
96+
* work, not an implicit claim that a generic command works.
8697
*/
8798
export const LINUX_PLATFORM_COVERAGE = {
88-
[C.artifacts]: gap('No Linux-specific artifact inventory command evidence exists yet'),
99+
[C.artifacts]: gap(
100+
'No Linux live command creates a downloadable daemon artifact for inventory yet',
101+
),
89102
[C.devices]: contract(
90103
LINUX_PROVIDER_EVIDENCE.path,
91104
LINUX_PROVIDER_EVIDENCE.test,
92105
'Linux provider scenario inventories the selected desktop device through the daemon client',
93106
),
94-
[C.capabilities]: gap('No Linux-specific capabilities command evidence exists yet'),
95-
[C.doctor]: gap('No Linux-specific doctor command evidence exists yet'),
107+
[C.capabilities]: commandEvidenceLive(
108+
'the command-evidence lane reads capabilities for the selected Linux desktop',
109+
),
110+
[C.doctor]: commandEvidenceLive('the command-evidence lane reads Linux doctor diagnostics'),
96111
[C.apps]: contract(
97112
LINUX_RUNTIME_EVIDENCE.path,
98113
LINUX_RUNTIME_EVIDENCE.test,
@@ -111,15 +126,21 @@ export const LINUX_PLATFORM_COVERAGE = {
111126
),
112127
[C.perf]: denial('perf', 'Linux capability declaration rejects native performance inspection'),
113128
[C.logs]: gap('No Linux-specific app-log command evidence exists yet'),
114-
[C.events]: gap('No Linux-specific session event command evidence exists yet'),
129+
[C.events]: commandEvidenceLive(
130+
'the command-evidence lane reads the event timeline produced by its Linux session',
131+
),
115132
[C.network]: contract(
116133
LINUX_RUNTIME_EVIDENCE.path,
117134
LINUX_RUNTIME_EVIDENCE.test,
118135
'Linux runtime facts explicitly report network capture unavailable',
119136
),
120137
[C.audio]: denial('audio', 'Linux capability declaration rejects native audio probing'),
121-
[C.replay]: gap('No Linux-specific replay command evidence exists yet'),
122-
[C.test]: gap('No Linux-specific test-suite command evidence exists yet'),
138+
[C.replay]: commandEvidenceLive(
139+
'the command-evidence lane replays a dedicated Linux script with a live session',
140+
),
141+
[C.test]: commandEvidenceLive(
142+
'the command-evidence lane runs a dedicated Linux script as a test suite',
143+
),
123144
[C.clipboard]: contract(
124145
'src/platforms/linux/__tests__/clipboard.test.ts',
125146
'writeLinuxClipboard uses xclip with stdin on X11',
@@ -143,14 +164,16 @@ export const LINUX_PLATFORM_COVERAGE = {
143164
LINUX_RUNTIME_EVIDENCE.test,
144165
'Linux runtime facts explicitly report Apple runner preparation unavailable',
145166
),
146-
[C.batch]: gap('No Linux-specific batch command evidence exists yet'),
167+
[C.batch]: commandEvidenceLive('the command-evidence lane executes two live Linux read steps'),
147168
[C.close]: contract(
148169
LINUX_PROVIDER_EVIDENCE.path,
149170
LINUX_PROVIDER_EVIDENCE.test,
150171
'Linux provider scenario closes the calculator and observes the desktop close call',
151172
),
152173
[C.snapshot]: live('the existing Linux replay captures the calculator accessibility tree'),
153-
[C.diff]: gap('No Linux-specific snapshot diff command evidence exists yet'),
174+
[C.diff]: commandEvidenceLive(
175+
'the command-evidence lane observes a non-empty calculator snapshot mutation',
176+
),
154177
[C.wait]: live('the existing Linux replay waits for an observable calculator landmark'),
155178
[C.alert]: denial('alert', 'Linux capability declaration rejects native alert operations'),
156179
[C.settings]: denial(
@@ -163,7 +186,7 @@ export const LINUX_PLATFORM_COVERAGE = {
163186
),
164187
[C.record]: gap('No Linux-specific recording command evidence exists yet'),
165188
[C.trace]: gap('No Linux-specific trace command evidence exists yet'),
166-
[C.find]: gap('No Linux-specific find command evidence exists yet'),
189+
[C.find]: commandEvidenceLive('the command-evidence lane resolves a live AT-SPI role match'),
167190
// Promoted from command-contract to live: the desktop replay now clicks a resolved digit
168191
// button on real Linux hardware and the downstream wait only passes if the click landed
169192
// (formerly missed — AT-SPI extents were computed screen-absolute-wrong under GTK4; see
@@ -230,7 +253,7 @@ export const LINUX_PLATFORM_COVERAGE = {
230253
'scrollLinux uses ydotool mousemove --wheel for vertical scroll',
231254
'Linux scroll dispatch uses the Wayland ydotool wheel primitive',
232255
),
233-
[C.swipe]: gap('No Linux-specific public swipe command evidence exists yet'),
256+
[C.swipe]: commandEvidenceLive('the command-evidence lane dispatches a coordinate swipe'),
234257
// Promoted from command-contract to live by #1925: the desktop replay now runs a coordinate
235258
// focus on real Linux hardware, so the migrated `focusPoint` path has live changed-path
236259
// evidence rather than only the provider scenario at LINUX_PROVIDER_EVIDENCE.
@@ -254,6 +277,16 @@ export const LINUX_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY = buildCoverageClass
254277

255278
export function liveCommandsForLinuxReplay(): PublicCommand[] {
256279
return Object.entries(LINUX_PLATFORM_COVERAGE)
257-
.filter(([, entry]) => entry.level === 'live')
280+
.filter(
281+
([, entry]) => entry.level === 'live' && entry.owner.path === LINUX_REPLAY_EVIDENCE.path,
282+
)
283+
.map(([command]) => command as PublicCommand);
284+
}
285+
286+
export function liveCommandsForLinuxCommandEvidence(): PublicCommand[] {
287+
return Object.entries(LINUX_PLATFORM_COVERAGE)
288+
.filter(
289+
([, entry]) => entry.level === 'live' && entry.owner.path === LINUX_COMMAND_EVIDENCE.path,
290+
)
258291
.map(([command]) => command as PublicCommand);
259292
}

0 commit comments

Comments
 (0)