Skip to content

Commit e3387ef

Browse files
committed
fix(maestro): corroborate same-app foreground read and pin no-session close input
- app-lifecycle: settle and re-read a same-app foreground sample before refusing killApp, so a stale mCurrentFocus right after Home does not spuriously refuse a legal kill; unreadable confirm keeps the refusal - session-close test: record the full closeApplication input and assert positionals, surface, outPath, and ensureReady alongside mode kill - kill tests: expect two reads on refusal; cover stale same-app settling to background and unreadable confirm failing closed
1 parent e676ade commit e3387ef

3 files changed

Lines changed: 116 additions & 15 deletions

File tree

‎packages/platform-android/src/__tests__/app-lifecycle-kill.test.ts‎

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,96 @@ test('killAndroidApp refuses a foreground target before killing', async () => {
136136
},
137137
);
138138

139-
assert.deepEqual(calls, [['shell', 'dumpsys', 'window', 'windows']]);
139+
assert.deepEqual(calls, [
140+
['shell', 'dumpsys', 'window', 'windows'],
141+
['shell', 'dumpsys', 'window', 'windows'],
142+
]);
143+
});
144+
145+
test('killAndroidApp proceeds when a stale same-app read settles to background', async () => {
146+
const device: DeviceInfo = {
147+
platform: 'android',
148+
id: 'emulator-5554',
149+
name: 'Pixel',
150+
kind: 'emulator',
151+
booted: true,
152+
};
153+
let foregroundReads = 0;
154+
const calls: (readonly string[])[] = [];
155+
156+
await withAndroidAdbProvider(
157+
{
158+
exec: async (args) => {
159+
calls.push(args);
160+
if (args.join(' ') === 'shell dumpsys window windows') {
161+
foregroundReads += 1;
162+
// First read still names the target (stale `mCurrentFocus` right after
163+
// `pressKey: Home`); later reads see the launcher so the kill completes.
164+
const stdout =
165+
foregroundReads === 1
166+
? 'mCurrentFocus=Window{42 u0 com.example.app/.MainActivity}\n'
167+
: 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n';
168+
return { stdout, stderr: '', exitCode: 0 };
169+
}
170+
return { stdout: '', stderr: '', exitCode: 0 };
171+
},
172+
reverse: {
173+
ensure: async () => {},
174+
remove: async () => {},
175+
removeAllOwned: async () => {},
176+
},
177+
},
178+
{ serial: 'emulator-5554' },
179+
async () => await killAndroidApp(device, 'com.example.app'),
180+
);
181+
182+
assert.equal(foregroundReads >= 2, true);
183+
assert.ok(calls.some((args) => args.join(' ') === 'shell am kill com.example.app'));
184+
});
185+
186+
test('killAndroidApp fails closed when the confirmatory read cannot answer', async () => {
187+
const device: DeviceInfo = {
188+
platform: 'android',
189+
id: 'emulator-5554',
190+
name: 'Pixel',
191+
kind: 'emulator',
192+
booted: true,
193+
};
194+
let foregroundReads = 0;
195+
196+
await withAndroidAdbProvider(
197+
{
198+
exec: async (args) => {
199+
if (args.join(' ') === 'shell dumpsys window windows') {
200+
foregroundReads += 1;
201+
if (foregroundReads === 1) {
202+
return {
203+
stdout: 'mCurrentFocus=Window{42 u0 com.example.app/.MainActivity}\n',
204+
stderr: '',
205+
exitCode: 0,
206+
};
207+
}
208+
return { stdout: '', stderr: '', exitCode: 0 };
209+
}
210+
return { stdout: '', stderr: '', exitCode: 0 };
211+
},
212+
reverse: {
213+
ensure: async () => {},
214+
remove: async () => {},
215+
removeAllOwned: async () => {},
216+
},
217+
},
218+
{ serial: 'emulator-5554' },
219+
async () => {
220+
await assertRejectsAppError(() => killAndroidApp(device, 'com.example.app'), {
221+
code: 'COMMAND_FAILED',
222+
hint: /Background the app before killApp/,
223+
details: { reason: 'android-kill-requires-background-app' },
224+
});
225+
},
226+
);
227+
228+
assert.equal(foregroundReads, 2);
140229
});
141230

142231
test('killAndroidApp fails when the process survives the kill', async () => {

‎packages/platform-android/src/app-lifecycle.ts‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -563,23 +563,24 @@ async function readAndroidForegroundApp(device: DeviceInfo): Promise<AppStateRun
563563
}
564564

565565
/**
566-
* `killAndroidPackage`'s precondition needs the opposite corroboration from
567-
* `waitForAndroidPackageProcessGone`'s "gone twice in a row counts": here a read naming a
568-
* different app is not enough on its own to trust "safe to kill". Live evidence (2026-09-25,
569-
* Android 16 emulator): immediately after `launchApp`, `dumpsys window`'s `mCurrentFocus` still
570-
* named the previous foreground app for one read while `dumpsys activity activities` already
571-
* showed the launched app resumed — `getAndroidAppState` takes whichever dump answers first, so
572-
* that stale window-focus read alone would have skipped the `android-kill-requires-background-app`
573-
* refusal against a target still genuinely in the foreground. A same-app read is trusted
574-
* immediately (refusing early is always safe); a different-app or unreadable read is corroborated
575-
* once after a short settle before this lets `killAndroidPackage` proceed.
566+
* `killAndroidPackage`'s precondition corroborates either foreground read once:
567+
* `mCurrentFocus` can lag one sample behind the resumed activity both after
568+
* `launchApp` (stale different-app read would skip a required refusal) and
569+
* after `pressKey: Home` (stale same-app read would spuriously refuse a legal
570+
* kill). A confirmatory read after a short settle decides; an unreadable
571+
* confirm keeps the first same-app refusal.
576572
*/
577573
async function readAndroidForegroundAppSettled(
578574
device: DeviceInfo,
579575
packageName: string,
580576
): Promise<AppStateRuntimeResult | null> {
581577
const first = await readAndroidForegroundApp(device);
582-
if (first?.package === packageName) return first;
578+
if (first?.package === packageName) {
579+
await sleep(ANDROID_KILL_FOREGROUND_STABLE_MS);
580+
const confirmed = await readAndroidForegroundApp(device);
581+
if (confirmed && confirmed.package !== packageName) return confirmed;
582+
return first;
583+
}
583584
await sleep(ANDROID_KILL_FOREGROUND_STABLE_MS);
584585
return await readAndroidForegroundApp(device);
585586
}

‎src/daemon/session-lifecycle/internal/__tests__/session-close-without-session-mode.test.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,13 @@ function makeSessionStore(): SessionStore {
4848
test('an app-only kill close with no stored session still carries mode kill to the platform close', async () => {
4949
const sessionStore = makeSessionStore();
5050
mockResolveTargetDevice.mockResolvedValue(ANDROID_DEVICE);
51-
const seenModes: Array<unknown> = [];
51+
const seenInputs: Array<{
52+
mode?: unknown;
53+
positionals?: unknown;
54+
surface?: unknown;
55+
outPath?: unknown;
56+
ensureReady?: unknown;
57+
}> = [];
5258
const baseBind = mockBindDeviceRuntime.getMockImplementation();
5359
mockBindDeviceRuntime.mockImplementationOnce(async (device, use) => {
5460
const binding = await baseBind!(device, use);
@@ -59,7 +65,7 @@ test('an app-only kill close with no stored session still carries mode kill to t
5965
operations: {
6066
...binding.operations,
6167
closeApplication: async (input: Parameters<typeof innerClose>[0]) => {
62-
seenModes.push(input.mode);
68+
seenInputs.push(input);
6369
},
6470
},
6571
};
@@ -81,5 +87,10 @@ test('an app-only kill close with no stored session still carries mode kill to t
8187
});
8288

8389
expect(response?.ok).toBe(true);
84-
expect(seenModes).toEqual(['kill']);
90+
expect(seenInputs).toHaveLength(1);
91+
expect(seenInputs[0]?.mode).toBe('kill');
92+
expect(seenInputs[0]?.positionals).toEqual(['com.example.app']);
93+
expect(seenInputs[0]?.surface).toBe('app');
94+
expect(seenInputs[0]?.outPath).toBeUndefined();
95+
expect(seenInputs[0]?.ensureReady).toBe(true);
8596
});

0 commit comments

Comments
 (0)