mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
e5cebcd8e3
* 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.
161 lines
6.0 KiB
TypeScript
161 lines
6.0 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import type { CliJsonResult } from './cli-json.ts';
|
|
import type { LiveContext } from './ios-simulator-e2e/live-harness.ts';
|
|
import { retryCleanupStep } from './ios-simulator-e2e/live-harness.ts';
|
|
import { finalizeSessionCleanup } from './ios-simulator-e2e/live-runner.ts';
|
|
|
|
// Deterministic regression for the retry/guard policy behind #1548: a full-tier iOS
|
|
// e2e run's cleanup must tolerate a dead session and the known appless mic-permission
|
|
// reset without swallowing an unrelated failure. `retryCleanupStep` runs the exact
|
|
// per-step policy `cleanupSession` uses, driven here by a scripted `runAttempt` instead
|
|
// of the real CLI subprocess, so these cases run in milliseconds with no simulator.
|
|
|
|
const MIC_STEP = 'reset microphone permission';
|
|
const OTHER_STEP = 'restore portrait orientation';
|
|
const MIC_APPLESS_MESSAGE = 'permission setting requires an active app in session';
|
|
|
|
function invalidArgsResult(message: string): CliJsonResult {
|
|
return { json: { error: { code: 'INVALID_ARGS', message } }, status: 1, stderr: '', stdout: '' };
|
|
}
|
|
|
|
function sessionNotFoundResult(): CliJsonResult {
|
|
return {
|
|
json: { error: { code: 'SESSION_NOT_FOUND', message: 'No active session' } },
|
|
status: 1,
|
|
stderr: '',
|
|
stdout: '',
|
|
};
|
|
}
|
|
|
|
// retryCleanupStep sleeps 500ms between attempts. Drive fake timers instead of waiting
|
|
// real time: repeatedly flush the microtask queue and advance the mocked clock until
|
|
// the retry promise settles.
|
|
async function drainRetry(
|
|
t: { mock: { timers: { tick: (ms: number) => void } } },
|
|
promise: Promise<unknown>,
|
|
): Promise<unknown> {
|
|
let settled = false;
|
|
promise.finally(() => {
|
|
settled = true;
|
|
});
|
|
while (!settled) {
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
t.mock.timers.tick(500);
|
|
}
|
|
return promise;
|
|
}
|
|
|
|
test('a dead session (SESSION_NOT_FOUND) skips cleanup without error, on any step', async () => {
|
|
let attempts = 0;
|
|
const failure = await retryCleanupStep(MIC_STEP, async () => {
|
|
attempts += 1;
|
|
return sessionNotFoundResult();
|
|
});
|
|
assert.equal(failure, undefined);
|
|
assert.equal(attempts, 1, 'should not retry once the session is confirmed gone');
|
|
});
|
|
|
|
test('the known mic-permission appless response stops retrying immediately', async () => {
|
|
let attempts = 0;
|
|
const failure = await retryCleanupStep(MIC_STEP, async () => {
|
|
attempts += 1;
|
|
return invalidArgsResult(MIC_APPLESS_MESSAGE);
|
|
});
|
|
assert.equal(failure, undefined);
|
|
assert.equal(attempts, 1, 'should not retry the known appless response');
|
|
});
|
|
|
|
test('a different INVALID_ARGS still fails after exhausting retries', async (t) => {
|
|
// Same message, wrong step: proves the guard is scoped to the mic-permission reset
|
|
// and does not tolerate the identical string on another step.
|
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
|
let attempts = 0;
|
|
const failure = await drainRetry(
|
|
t,
|
|
retryCleanupStep(OTHER_STEP, async (attempt) => {
|
|
attempts += 1;
|
|
if (attempt < 3) return invalidArgsResult(MIC_APPLESS_MESSAGE);
|
|
// Mirrors runStep(..., { allowFailure: false }) on the final attempt: it throws
|
|
// instead of returning a failed result.
|
|
throw new Error(`cleanup: ${OTHER_STEP} (attempt 3) failed`);
|
|
}),
|
|
);
|
|
assert.ok(failure instanceof Error, `expected a propagated failure, got ${String(failure)}`);
|
|
assert.equal(attempts, 3, 'should exhaust all three attempts');
|
|
});
|
|
|
|
test('a different INVALID_ARGS message on the mic-permission step still fails after retries', async (t) => {
|
|
// Same step, a message sharing the "requires an active app in session" suffix with
|
|
// the location-setting call site (app-settings.ts): proves the match is the exact
|
|
// known string, not any INVALID_ARGS message that happens to overlap it.
|
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
|
let attempts = 0;
|
|
const failure = await drainRetry(
|
|
t,
|
|
retryCleanupStep(MIC_STEP, async (attempt) => {
|
|
attempts += 1;
|
|
if (attempt < 3)
|
|
return invalidArgsResult('location setting requires an active app in session');
|
|
throw new Error(`cleanup: ${MIC_STEP} (attempt 3) failed`);
|
|
}),
|
|
);
|
|
assert.ok(failure instanceof Error, `expected a propagated failure, got ${String(failure)}`);
|
|
assert.equal(attempts, 3, 'should exhaust all three attempts');
|
|
});
|
|
|
|
// Deterministic regression for finalizeSessionCleanup: the other half of #1548, which
|
|
// decides whether cleanup runs at all. A minimal LiveContext fixture; only sessionOpen
|
|
// is read by the decision, the rest exists to satisfy the type.
|
|
function fixtureContext(sessionOpen: boolean): LiveContext {
|
|
return {
|
|
appId: 'com.example.fixture',
|
|
appPath: '/fixture.app',
|
|
artifactDir: '/tmp/fixture-artifacts',
|
|
behaviorEvidence: {},
|
|
commandEvidence: {},
|
|
completedScenarios: [],
|
|
currentScenario: 'full:device-lifecycle',
|
|
env: {},
|
|
session: 'fixture-session',
|
|
sessionOpen,
|
|
stateDir: '/tmp/fixture-state',
|
|
startedAtMs: Date.now(),
|
|
stepHistory: [],
|
|
tier: 'full',
|
|
timings: [],
|
|
udid: 'fixture-udid',
|
|
};
|
|
}
|
|
|
|
test('sessionOpen=true, final sessionExists=false: cleanup is never invoked', async () => {
|
|
let cleanupCalls = 0;
|
|
const context = fixtureContext(true);
|
|
const cleanupError = await finalizeSessionCleanup(
|
|
context,
|
|
async () => false,
|
|
async () => {
|
|
cleanupCalls += 1;
|
|
},
|
|
);
|
|
assert.equal(cleanupCalls, 0, 'cleanupSession must not run once the session is confirmed gone');
|
|
assert.equal(context.sessionOpen, false, 'sessionOpen should reflect the re-check, not the flag');
|
|
assert.equal(cleanupError, undefined);
|
|
});
|
|
|
|
test('sessionOpen=true, final sessionExists=true: cleanup is invoked (the live path)', async () => {
|
|
let cleanupCalls = 0;
|
|
const context = fixtureContext(true);
|
|
const cleanupError = await finalizeSessionCleanup(
|
|
context,
|
|
async () => true,
|
|
async () => {
|
|
cleanupCalls += 1;
|
|
},
|
|
);
|
|
assert.equal(cleanupCalls, 1, 'cleanupSession must run while the session is still live');
|
|
assert.equal(context.sessionOpen, true);
|
|
assert.equal(cleanupError, undefined);
|
|
});
|