Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,33 @@ test('runReplayTestAttempt keeps a passing replay passed when finalization fails
expect(cleanupSession).toHaveBeenCalledWith('default:test:pass');
});

test('runReplayTestAttempt marks a failed cleanup as infrastructure so the scheduler cannot retry into its session', async () => {
const cleanupSession = vi.fn(async () => {
throw new Error('macOS session still owns host-macos-local');
});

const result = await runReplayTestAttempt({
filePath: '01-cleanup-failure.ad',
sessionName: 'default:test:cleanup-failure',
requestId: 'req-cleanup-failure',
runReplay: async () => ({
status: 'failed',
error: { code: 'COMMAND_FAILED', message: 'open "System Settings" failed' },
artifactPaths: [],
infrastructure: false,
}),
cleanupSession,
...trackCancellation(),
});

expect(result.status).toBe('failed');
if (result.status === 'failed') {
expect(result.error.message).toBe('open "System Settings" failed');
expect(result.infrastructure).toBe(true);
}
expect(cleanupSession).toHaveBeenCalledWith('default:test:cleanup-failure');
});

// #1478 P3 characterization: attempt finalization happens before cleanup, always, and the
// timing trace is the durable evidence of that order. P3 moves this `finally` orchestration
// into replay-test, so the order and the recorded event names are pinned here as shipped.
Expand Down
22 changes: 22 additions & 0 deletions packages/replay-test/src/internal/session-test-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export async function runReplayTestAttempt(
error: appErr.message,
},
});
outcome = markReplayTestCleanupFailure(outcome, appErr, artifactPaths);
}
}
return (
Expand All @@ -200,6 +201,27 @@ export async function runReplayTestAttempt(
);
}

/**
* Cleanup is the ownership handoff between attempts. A failed cleanup leaves the prior attempt's
* device/session state unknown, so the scheduler must stop instead of retrying into a live claim.
* Keep an action failure as the user-facing cause when one already exists; the infrastructure tag
* is the scheduler-only fact that prevents a contended follow-up attempt.
*/
function markReplayTestCleanupFailure(
outcome: ReplayTestAttemptOutcome | undefined,
cleanupError: ReturnType<typeof normalizeError>,
artifactPaths: Set<string>,
): ReplayTestAttemptOutcome {
if (outcome?.status === 'failed') {
return { ...outcome, infrastructure: true };
}
return replayTestAttemptFailure({
error: cleanupError,
artifactPaths: [...artifactPaths],
infrastructure: true,
});
}

async function waitForReplayAfterTimeout(
replayPromise: Promise<ReplayTestAttemptOutcome>,
): Promise<boolean> {
Expand Down
57 changes: 55 additions & 2 deletions src/daemon/handlers/__tests__/session-test-suite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ vi.mock('../snapshot-interactor-capture.ts', () => ({
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { handleSessionCommands } from './session-command-harness.ts';
import { handleSessionCommands, mockInspectDeviceRuntimeFacts } from './session-command-harness.ts';
import { SessionStore } from '../../session-store.ts';
import type { DaemonRequest, DaemonResponse, DaemonResponseData } from '../../types.ts';
import { withRequestProgressSink } from '../../../request/progress.ts';
Expand All @@ -29,7 +29,10 @@ import {
} from '../../../request/cancel.ts';
import { withTestDeviceInventoryProvider as withDeviceInventoryProvider } from '../../../__tests__/test-utils/device-inventory-gateways.ts';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { makeAndroidSession } from '../../../__tests__/test-utils/session-factories.ts';
import {
makeAndroidSession,
makeMacOsSession,
} from '../../../__tests__/test-utils/session-factories.ts';

function makeSessionStore(): SessionStore {
const root = mkdtempForTestSync('agent-device-session-test-suite-');
Expand Down Expand Up @@ -324,6 +327,56 @@ test('test emits progress when attempts retry and pass', async () => {
});
});

test('test stops before retrying when a rejected close leaves the prior macOS session owning its device', async () => {
const sessionStore = makeSessionStore();
const root = mkdtempForTestSync('agent-device-test-suite-macos-cleanup-failure-');
fs.writeFileSync(
path.join(root, '01-system-settings.ad'),
'context platform=macos\nopen "System Settings"\n',
);

let replayAttempts = 0;
const response = await handleSessionCommands({
req: {
token: 't',
session: 'default',
command: 'test',
positionals: [root],
meta: { cwd: root, requestId: 'suite-macos-cleanup-failure' },
flags: { retries: 2 },
},
sessionName: 'default',
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
inspectFacts: async (device) => {
const facts = await mockInspectDeviceRuntimeFacts(device);
return {
...facts,
operations: {
...facts.operations,
closeApplication: { available: false, reason: 'owner-capability-missing' },
},
};
},
invoke: async (req) => {
replayAttempts += 1;
sessionStore.set(req.session, makeMacOsSession(req.session));
return {
ok: false,
error: { code: 'COMMAND_FAILED', message: 'open "System Settings" failed' },
};
},
});

const data = expectOkData(response);
expect(replayAttempts).toBe(1);
expect(data.failed).toBe(1);
expect((data.tests as Array<Record<string, unknown>>)[0]).toMatchObject({
status: 'failed',
attempts: 1,
});
});

test('test stops retrying after maxAttempts when every attempt fails', async () => {
const sessionStore = makeSessionStore();
const root = mkdtempForTestSync('agent-device-test-suite-retry-exhaust-');
Expand Down
8 changes: 7 additions & 1 deletion src/daemon/handlers/session-test-suite-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ export async function runReplayTestSuiteCommand(
resolveShardTargets: buildReplayTestShardTargetResolver(req.flags),
cleanupSession: async (testSessionName) => {
if (!sessionStore.get(testSessionName)) return;
await handleCloseCommand({
const closeResponse = await handleCloseCommand({
req: {
token: req.token,
session: testSessionName,
Expand All @@ -320,6 +320,12 @@ export async function runReplayTestSuiteCommand(
inspectFacts: params.inspectFacts,
bindDevice: params.bindDevice,
});
if (!closeResponse.ok) {
throw new AppError(closeResponse.error.code, closeResponse.error.message, {
...(closeResponse.error.details ?? {}),
...(closeResponse.error.hint ? { hint: closeResponse.error.hint } : {}),
});
}
},
});
return outcome.status === 'completed'
Expand Down
Loading