Skip to content

Commit e5cebcd

Browse files
authored
fix(test): tolerate dead session in iOS e2e full-tier cleanup (#1548)
* fix(test): tolerate dead session in iOS e2e cleanup full:device-lifecycle reboots the simulator, and whether the daemon session survives that is environment-sensitive: it does on CI but not locally, so every all-green local full-tier run ended red in cleanup with all three retries of each step failing ('permission setting requires an active app in session' / 'No active session'). Two layers: - finalizeLiveRun re-checks sessionExists instead of short-circuiting on sessionOpen, so a session that died mid-run skips cleanup entirely. - cleanupSession treats SESSION_NOT_FOUND and the appless-session INVALID_ARGS failure as already-clean instead of burning retries. Other cleanup failures still exhaust three attempts and fail loudly. * fix(test): narrow appless-session cleanup guard to the mic-permission step Scope sessionAlreadyClean's INVALID_ARGS tolerance to the microphone- permission reset step and its exact known message instead of matching any cleanup step whose message contains "requires an active app in session" — that substring is also thrown by the unrelated location setting, so the old check could have hidden a real failure there. Extract the per-step retry policy into an exported retryCleanupStep so it's unit-testable without spawning the CLI, and add a deterministic regression (test/integration/ios-simulator-e2e-cleanup.test.ts) covering: a dead session (SESSION_NOT_FOUND) stops retrying on any step, the known mic-permission appless response stops retrying, and a different INVALID_ARGS (wrong step or wrong message) still exhausts all three retries and fails. * test(ios-e2e): cover the finalization cleanup-gate decision Extract the sessionOpen re-check + conditional cleanupSession call out of finalizeLiveRun into an exported finalizeSessionCleanup(context, runSessionExists, runCleanupSession) — same behavior, now driven by injected callbacks instead of the module-level runStep-backed bindings, so it's unit-testable without spawning the CLI. Add two deterministic cases: sessionOpen starts true and the final sessionExists() resolves false -> cleanupSession is never invoked; sessionOpen true and sessionExists() resolves true -> cleanupSession runs (the live path). Counterfactual (reverting the recheck to the old `sessionOpen || sessionExists(...)` form) turns the first case red as expected.
1 parent 2e4825e commit e5cebcd

3 files changed

Lines changed: 231 additions & 20 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
4+
import type { CliJsonResult } from './cli-json.ts';
5+
import type { LiveContext } from './ios-simulator-e2e/live-harness.ts';
6+
import { retryCleanupStep } from './ios-simulator-e2e/live-harness.ts';
7+
import { finalizeSessionCleanup } from './ios-simulator-e2e/live-runner.ts';
8+
9+
// Deterministic regression for the retry/guard policy behind #1548: a full-tier iOS
10+
// e2e run's cleanup must tolerate a dead session and the known appless mic-permission
11+
// reset without swallowing an unrelated failure. `retryCleanupStep` runs the exact
12+
// per-step policy `cleanupSession` uses, driven here by a scripted `runAttempt` instead
13+
// of the real CLI subprocess, so these cases run in milliseconds with no simulator.
14+
15+
const MIC_STEP = 'reset microphone permission';
16+
const OTHER_STEP = 'restore portrait orientation';
17+
const MIC_APPLESS_MESSAGE = 'permission setting requires an active app in session';
18+
19+
function invalidArgsResult(message: string): CliJsonResult {
20+
return { json: { error: { code: 'INVALID_ARGS', message } }, status: 1, stderr: '', stdout: '' };
21+
}
22+
23+
function sessionNotFoundResult(): CliJsonResult {
24+
return {
25+
json: { error: { code: 'SESSION_NOT_FOUND', message: 'No active session' } },
26+
status: 1,
27+
stderr: '',
28+
stdout: '',
29+
};
30+
}
31+
32+
// retryCleanupStep sleeps 500ms between attempts. Drive fake timers instead of waiting
33+
// real time: repeatedly flush the microtask queue and advance the mocked clock until
34+
// the retry promise settles.
35+
async function drainRetry(
36+
t: { mock: { timers: { tick: (ms: number) => void } } },
37+
promise: Promise<unknown>,
38+
): Promise<unknown> {
39+
let settled = false;
40+
promise.finally(() => {
41+
settled = true;
42+
});
43+
while (!settled) {
44+
await new Promise((resolve) => setImmediate(resolve));
45+
t.mock.timers.tick(500);
46+
}
47+
return promise;
48+
}
49+
50+
test('a dead session (SESSION_NOT_FOUND) skips cleanup without error, on any step', async () => {
51+
let attempts = 0;
52+
const failure = await retryCleanupStep(MIC_STEP, async () => {
53+
attempts += 1;
54+
return sessionNotFoundResult();
55+
});
56+
assert.equal(failure, undefined);
57+
assert.equal(attempts, 1, 'should not retry once the session is confirmed gone');
58+
});
59+
60+
test('the known mic-permission appless response stops retrying immediately', async () => {
61+
let attempts = 0;
62+
const failure = await retryCleanupStep(MIC_STEP, async () => {
63+
attempts += 1;
64+
return invalidArgsResult(MIC_APPLESS_MESSAGE);
65+
});
66+
assert.equal(failure, undefined);
67+
assert.equal(attempts, 1, 'should not retry the known appless response');
68+
});
69+
70+
test('a different INVALID_ARGS still fails after exhausting retries', async (t) => {
71+
// Same message, wrong step: proves the guard is scoped to the mic-permission reset
72+
// and does not tolerate the identical string on another step.
73+
t.mock.timers.enable({ apis: ['setTimeout'] });
74+
let attempts = 0;
75+
const failure = await drainRetry(
76+
t,
77+
retryCleanupStep(OTHER_STEP, async (attempt) => {
78+
attempts += 1;
79+
if (attempt < 3) return invalidArgsResult(MIC_APPLESS_MESSAGE);
80+
// Mirrors runStep(..., { allowFailure: false }) on the final attempt: it throws
81+
// instead of returning a failed result.
82+
throw new Error(`cleanup: ${OTHER_STEP} (attempt 3) failed`);
83+
}),
84+
);
85+
assert.ok(failure instanceof Error, `expected a propagated failure, got ${String(failure)}`);
86+
assert.equal(attempts, 3, 'should exhaust all three attempts');
87+
});
88+
89+
test('a different INVALID_ARGS message on the mic-permission step still fails after retries', async (t) => {
90+
// Same step, a message sharing the "requires an active app in session" suffix with
91+
// the location-setting call site (app-settings.ts): proves the match is the exact
92+
// known string, not any INVALID_ARGS message that happens to overlap it.
93+
t.mock.timers.enable({ apis: ['setTimeout'] });
94+
let attempts = 0;
95+
const failure = await drainRetry(
96+
t,
97+
retryCleanupStep(MIC_STEP, async (attempt) => {
98+
attempts += 1;
99+
if (attempt < 3)
100+
return invalidArgsResult('location setting requires an active app in session');
101+
throw new Error(`cleanup: ${MIC_STEP} (attempt 3) failed`);
102+
}),
103+
);
104+
assert.ok(failure instanceof Error, `expected a propagated failure, got ${String(failure)}`);
105+
assert.equal(attempts, 3, 'should exhaust all three attempts');
106+
});
107+
108+
// Deterministic regression for finalizeSessionCleanup: the other half of #1548, which
109+
// decides whether cleanup runs at all. A minimal LiveContext fixture; only sessionOpen
110+
// is read by the decision, the rest exists to satisfy the type.
111+
function fixtureContext(sessionOpen: boolean): LiveContext {
112+
return {
113+
appId: 'com.example.fixture',
114+
appPath: '/fixture.app',
115+
artifactDir: '/tmp/fixture-artifacts',
116+
behaviorEvidence: {},
117+
commandEvidence: {},
118+
completedScenarios: [],
119+
currentScenario: 'full:device-lifecycle',
120+
env: {},
121+
session: 'fixture-session',
122+
sessionOpen,
123+
stateDir: '/tmp/fixture-state',
124+
startedAtMs: Date.now(),
125+
stepHistory: [],
126+
tier: 'full',
127+
timings: [],
128+
udid: 'fixture-udid',
129+
};
130+
}
131+
132+
test('sessionOpen=true, final sessionExists=false: cleanup is never invoked', async () => {
133+
let cleanupCalls = 0;
134+
const context = fixtureContext(true);
135+
const cleanupError = await finalizeSessionCleanup(
136+
context,
137+
async () => false,
138+
async () => {
139+
cleanupCalls += 1;
140+
},
141+
);
142+
assert.equal(cleanupCalls, 0, 'cleanupSession must not run once the session is confirmed gone');
143+
assert.equal(context.sessionOpen, false, 'sessionOpen should reflect the re-check, not the flag');
144+
assert.equal(cleanupError, undefined);
145+
});
146+
147+
test('sessionOpen=true, final sessionExists=true: cleanup is invoked (the live path)', async () => {
148+
let cleanupCalls = 0;
149+
const context = fixtureContext(true);
150+
const cleanupError = await finalizeSessionCleanup(
151+
context,
152+
async () => true,
153+
async () => {
154+
cleanupCalls += 1;
155+
},
156+
);
157+
assert.equal(cleanupCalls, 1, 'cleanupSession must run while the session is still live');
158+
assert.equal(context.sessionOpen, true);
159+
assert.equal(cleanupError, undefined);
160+
});

test/integration/ios-simulator-e2e/live-harness.ts

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from 'node:fs';
33
import path from 'node:path';
44

55
import { resolveDaemonPaths } from '../../../src/daemon/config.ts';
6+
import type { CliJsonResult } from '../cli-json.ts';
67
import {
78
createLiveDeviceContext,
89
createLiveDeviceHarness,
@@ -75,33 +76,66 @@ export function verifyNestedReplayCommand(
7576
harness.verifyNestedCommand(context, command, executedVia, evidence);
7677
}
7778

79+
// Shared between the step list and the guard below so the two can't drift apart.
80+
const MICROPHONE_PERMISSION_RESET_STEP = 'reset microphone permission';
81+
7882
export async function cleanupSession(context: LiveContext): Promise<void> {
7983
const failures: unknown[] = [];
8084
const cleanupSteps: Array<[string, string[]]> = [];
8185
if (context.tier === 'full') {
8286
cleanupSteps.push(
83-
['reset microphone permission', ['settings', 'permission', 'reset', 'microphone']],
87+
[MICROPHONE_PERMISSION_RESET_STEP, ['settings', 'permission', 'reset', 'microphone']],
8488
['restore light appearance', ['settings', 'appearance', 'light']],
8589
['restore portrait orientation', ['orientation', 'portrait']],
8690
);
8791
}
8892
cleanupSteps.push(['close fixture session', ['close']]);
8993
for (const [step, args] of cleanupSteps) {
90-
for (let attempt = 1; attempt <= 3; attempt += 1) {
91-
try {
92-
const result = await runStep(context, `cleanup: ${step} (attempt ${attempt})`, args, {
93-
allowFailure: attempt < 3,
94-
});
95-
if (result.status === 0) break;
96-
await new Promise((resolve) => setTimeout(resolve, 500));
97-
} catch (error) {
98-
failures.push(error);
99-
break;
100-
}
101-
}
94+
const failure = await retryCleanupStep(step, (attempt) =>
95+
runStep(context, `cleanup: ${step} (attempt ${attempt})`, args, {
96+
allowFailure: attempt < 3,
97+
}),
98+
);
99+
if (failure !== undefined) failures.push(failure);
102100
}
103101
if (failures.length === 0) return;
104102
const errorPath = path.join(context.artifactDir, 'cleanup-error.txt');
105103
fs.writeFileSync(errorPath, failures.map(String).join('\n\n'));
106104
throw new AggregateError(failures, `iOS E2E cleanup failed; details: ${errorPath}`);
107105
}
106+
107+
/**
108+
* Runs one cleanup step's 3-attempt retry policy: success or an already-clean session
109+
* stops immediately, anything else waits and retries. `runAttempt` mirrors the real
110+
* `runStep(..., { allowFailure: attempt < 3 })` contract, including that the final
111+
* attempt throws instead of returning a failed result. Exported so the retry/guard
112+
* behavior is unit-testable without spawning the CLI (see
113+
* `test/integration/ios-simulator-e2e-cleanup.test.ts`).
114+
*/
115+
export async function retryCleanupStep(
116+
step: string,
117+
runAttempt: (attempt: number) => Promise<CliJsonResult>,
118+
): Promise<unknown> {
119+
for (let attempt = 1; attempt <= 3; attempt += 1) {
120+
try {
121+
const result = await runAttempt(attempt);
122+
if (result.status === 0 || sessionAlreadyClean(step, result)) return undefined;
123+
await new Promise((resolve) => setTimeout(resolve, 500));
124+
} catch (error) {
125+
return error;
126+
}
127+
}
128+
return undefined;
129+
}
130+
131+
// SESSION_NOT_FOUND: nothing left to reset, any step. INVALID_ARGS: only the
132+
// mic-permission reset needs an app bundle and app-settings.ts has no reason code for
133+
// it, so we match its exact message — scoped to this step so it can't hide another failure.
134+
function sessionAlreadyClean(step: string, result: CliJsonResult): boolean {
135+
if (result.json?.error?.code === 'SESSION_NOT_FOUND') return true;
136+
return (
137+
step === MICROPHONE_PERMISSION_RESET_STEP &&
138+
result.json?.error?.code === 'INVALID_ARGS' &&
139+
result.json?.error?.message === 'permission setting requires an active app in session'
140+
);
141+
}

test/integration/ios-simulator-e2e/live-runner.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,24 +72,41 @@ async function executeLiveScenarios(context: LiveContext): Promise<void> {
7272
}
7373

7474
async function finalizeLiveRun(context: LiveContext): Promise<unknown> {
75+
let cleanupError = await finalizeSessionCleanup(context, sessionExists, cleanupSession);
76+
try {
77+
writeCoverageReport(context);
78+
} catch (error) {
79+
cleanupError = combineErrors(cleanupError, error, 'cleanup and coverage reporting failed');
80+
}
81+
return cleanupError;
82+
}
83+
84+
/**
85+
* Decides whether session-scoped cleanup runs: full:device-lifecycle reboots the
86+
* simulator and the session lease does not survive that in every environment, so this
87+
* re-checks session existence (the daemon's session list is authoritative at finalize
88+
* time) instead of trusting `sessionOpen` accumulated during the run, and only invokes
89+
* cleanup when a session remains. Exported so the decision is unit-testable without
90+
* spawning the CLI (see test/integration/ios-simulator-e2e-cleanup.test.ts).
91+
*/
92+
export async function finalizeSessionCleanup(
93+
context: LiveContext,
94+
runSessionExists: (context: LiveContext) => Promise<boolean>,
95+
runCleanupSession: (context: LiveContext) => Promise<void>,
96+
): Promise<unknown> {
7597
let cleanupError: unknown;
7698
try {
77-
context.sessionOpen = context.sessionOpen || (await sessionExists(context));
99+
context.sessionOpen = await runSessionExists(context);
78100
} catch (error) {
79101
cleanupError = error;
80102
}
81103
if (context.sessionOpen) {
82104
try {
83-
await cleanupSession(context);
105+
await runCleanupSession(context);
84106
} catch (error) {
85107
cleanupError = combineErrors(cleanupError, error, 'session inspection and cleanup failed');
86108
}
87109
}
88-
try {
89-
writeCoverageReport(context);
90-
} catch (error) {
91-
cleanupError = combineErrors(cleanupError, error, 'cleanup and coverage reporting failed');
92-
}
93110
return cleanupError;
94111
}
95112

0 commit comments

Comments
 (0)