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
10 changes: 10 additions & 0 deletions src/__tests__/hermetic-env-setup.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os from 'node:os';
import path from 'node:path';
import { afterEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';
import vitestConfig from '../../vitest.config.ts';
Expand All @@ -7,6 +9,7 @@ const AMBIENT_DAEMON_VARS = [
'AGENT_DEVICE_DAEMON_BASE_URL',
'AGENT_DEVICE_DAEMON_AUTH_TOKEN',
] as const;
const VITEST_CLAIMS_DIR = path.join(os.tmpdir(), `agent-device-vitest-claims-${process.pid}`);

type ProjectShape = { test?: { name?: string; setupFiles?: readonly string[] } };

Expand Down Expand Up @@ -39,3 +42,10 @@ test('importing hermetic-env-setup scrubs the ambient daemon connection vars', a
assert.equal(process.env[name], undefined, `${name} must be scrubbed when the setup loads`);
}
});

test('importing hermetic-env-setup isolates advisory claims from the host and other workers', async () => {
process.env.AGENT_DEVICE_CLAIMS_DIR = '/host/device-claims';
vi.resetModules();
await import('./hermetic-env-setup.ts');
assert.equal(process.env.AGENT_DEVICE_CLAIMS_DIR, VITEST_CLAIMS_DIR);
});
15 changes: 15 additions & 0 deletions src/__tests__/hermetic-env-setup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import os from 'node:os';
import path from 'node:path';

// Unit tests must be hermetic with respect to the host's daemon-connection
// environment. A machine actually running agent-device — including this repo's
// own remote dev containers — exports AGENT_DEVICE_DAEMON_BASE_URL and
Expand All @@ -21,3 +24,15 @@ const AMBIENT_DAEMON_ENV_VARS = [
for (const name of AMBIENT_DAEMON_ENV_VARS) {
delete process.env[name];
}

// Provider-backed scenarios intentionally use local device identities so their
// request path covers advisory-claim ownership. Each Vitest fork, however,
// mocks the same identities (for example `sim-1`). Keeping claims under the
// host-global default makes unrelated workers poll one process lock and can
// push otherwise instant scenarios past Vitest's timeout. Scope claims to the
// worker process: the production claim mechanism still runs, while workers no
// longer contend for mocked devices or inherit a host's real claims.
process.env.AGENT_DEVICE_CLAIMS_DIR = path.join(
os.tmpdir(),
`agent-device-vitest-claims-${process.pid}`,
);
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { test } from 'vitest';
import type { InteractionGuarantee } from '../../../src/contracts/interaction-guarantees.ts';
import { AppError } from '@agent-device/kernel/errors';
import { assertRpcError, assertRpcOk } from '../provider-scenarios/assertions.ts';
import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from '../provider-scenarios/test-timeouts.ts';
import { scenarioName } from './coverage-manifest.ts';
import { DIRECT_IOS_SELECTOR_COVERAGE } from './direct-ios-selector.coverage.ts';
import {
Expand Down Expand Up @@ -54,25 +55,32 @@ const RECORDING_TARGET_NODES = [
},
] as const;

test(scenario('responseConstruction'), async () => {
await withIosContractDaemon([runnerTapEntry({ x: 150, y: 200 })], async (daemon, transcript) => {
const click = await daemon.callCommand('click', ['label=Continue']);
const data = assertRpcOk(click);
test(
scenario('responseConstruction'),
async () => {
await withIosContractDaemon(
[runnerTapEntry({ x: 150, y: 200 })],
async (daemon, transcript) => {
const click = await daemon.callCommand('click', ['label=Continue']);
const data = assertRpcOk(click);

// The direct path really ran: the single runner call is a selector-keyed
// tap, with no snapshot capture before it.
const tapRequest = transcript.calls[0]?.request as Record<string, unknown> | undefined;
assert.equal(transcript.calls[0]?.command, 'ios.runner.tap');
assert.equal(tapRequest?.selectorKey, 'label');
assert.equal(tapRequest?.selectorValue, 'Continue');
// The direct path really ran: the single runner call is a selector-keyed
// tap, with no snapshot capture before it.
const tapRequest = transcript.calls[0]?.request as Record<string, unknown> | undefined;
assert.equal(transcript.calls[0]?.command, 'ios.runner.tap');
assert.equal(tapRequest?.selectorKey, 'label');
assert.equal(tapRequest?.selectorValue, 'Continue');

// Canonical runner-payload response set from the shared construction site.
assert.equal(data.x, 150);
assert.equal(data.y, 200);
assert.equal(data.selector, 'label=Continue');
assert.match(String(data.message), /Tapped label=Continue/);
});
});
// Canonical runner-payload response set from the shared construction site.
assert.equal(data.x, 150);
assert.equal(data.y, 200);
assert.equal(data.selector, 'label=Continue');
assert.match(String(data.message), /Tapped label=Continue/);
},
);
},
PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS,
);

test(scenario('resolutionDisclosure'), async () => {
await withIosContractDaemon([runnerTapEntry({ x: 150, y: 200 })], async (daemon) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { test } from 'vitest';
import type { InteractionGuarantee } from '../../../src/contracts/interaction-guarantees.ts';
import { AppError } from '@agent-device/kernel/errors';
import { assertRpcError, assertRpcOk } from '../provider-scenarios/assertions.ts';
import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from '../provider-scenarios/test-timeouts.ts';
import { scenarioName } from './coverage-manifest.ts';
import { MAESTRO_FALLBACK_COVERAGE } from './maestro-fallback.coverage.ts';
import {
Expand All @@ -22,37 +23,41 @@ const scenario = (guarantee: InteractionGuarantee): string =>

const MAESTRO_FLAGS = { maestro: { allowNonHittableCoordinateFallback: true } };

test(scenario('responseConstruction'), async () => {
await withIosContractDaemon(
[
runnerTapEntry({
x: 50,
y: 60,
message: 'tapped via non-hittable coordinate fallback',
maestroNonHittableCoordinateFallbackUsed: true,
}),
],
async (daemon, transcript) => {
const click = await daemon.callCommand('click', ['label=Pin'], MAESTRO_FLAGS);
const data = assertRpcOk(click);

// The runner received the fallback permission on the selector tap.
const tapRequest = transcript.calls[0]?.request as Record<string, unknown> | undefined;
assert.equal(tapRequest?.selectorValue, 'Pin');
assert.equal(tapRequest?.allowNonHittableCoordinateFallback, true);

// Canonical field set plus the fallback markers the replay layer keys on.
assert.equal(data.x, 50);
assert.equal(data.y, 60);
assert.equal(data.selector, 'label=Pin');
assert.equal(data.maestroNonHittableCoordinateFallbackAllowed, true);
assert.equal(data.maestroNonHittableCoordinateFallbackUsed, true);
assert.equal(data.maestroFallbackReason, 'non-hittable-coordinate');
// Fallback actually TAKEN: the inapplicable maestro cell, no resolution field.
assert.equal(data.resolution, undefined);
},
);
});
test(
scenario('responseConstruction'),
async () => {
await withIosContractDaemon(
[
runnerTapEntry({
x: 50,
y: 60,
message: 'tapped via non-hittable coordinate fallback',
maestroNonHittableCoordinateFallbackUsed: true,
}),
],
async (daemon, transcript) => {
const click = await daemon.callCommand('click', ['label=Pin'], MAESTRO_FLAGS);
const data = assertRpcOk(click);

// The runner received the fallback permission on the selector tap.
const tapRequest = transcript.calls[0]?.request as Record<string, unknown> | undefined;
assert.equal(tapRequest?.selectorValue, 'Pin');
assert.equal(tapRequest?.allowNonHittableCoordinateFallback, true);

// Canonical field set plus the fallback markers the replay layer keys on.
assert.equal(data.x, 50);
assert.equal(data.y, 60);
assert.equal(data.selector, 'label=Pin');
assert.equal(data.maestroNonHittableCoordinateFallbackAllowed, true);
assert.equal(data.maestroNonHittableCoordinateFallbackUsed, true);
assert.equal(data.maestroFallbackReason, 'non-hittable-coordinate');
// Fallback actually TAKEN: the inapplicable maestro cell, no resolution field.
assert.equal(data.resolution, undefined);
},
);
},
PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS,
);

// Permission is not usage: with the fallback allowed but the runner hitting
// the element normally ("tapped"), the dispatch is the direct-ios path and
Expand Down Expand Up @@ -125,16 +130,20 @@ test('maestro-non-hittable-fallback fill resolutionDisclosure: allowed-but-not-t
);
});

test(scenario('offscreen'), async () => {
await withIosContractDaemon(
[
// The runner refuses empty/out-of-app frames. Maestro replay preserves
// this typed result so the compat runtime can own fresh-geometry fallback.
runnerTapErrorEntry(new AppError('ELEMENT_OFFSCREEN', 'Element has no tappable frame')),
],
async (daemon) => {
const click = await daemon.callCommand('click', ['label=Explore'], MAESTRO_FLAGS);
assertRpcError(click, 'ELEMENT_OFFSCREEN', /no tappable frame/);
},
);
});
test(
scenario('offscreen'),
async () => {
await withIosContractDaemon(
[
// The runner refuses empty/out-of-app frames. Maestro replay preserves
// this typed result so the compat runtime can own fresh-geometry fallback.
runnerTapErrorEntry(new AppError('ELEMENT_OFFSCREEN', 'Element has no tappable frame')),
],
async (daemon) => {
const click = await daemon.callCommand('click', ['label=Explore'], MAESTRO_FLAGS);
assertRpcError(click, 'ELEMENT_OFFSCREEN', /no tappable frame/);
},
);
},
PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS,
);
41 changes: 24 additions & 17 deletions test/integration/interaction-contract/runtime-ref.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { InteractionGuarantee } from '../../../src/contracts/interaction-gu
import type { Point } from '@agent-device/kernel/snapshot';
import { ref } from '../../../src/commands/interaction/runtime/selector-read.ts';
import { assertRpcOk } from '../provider-scenarios/assertions.ts';
import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from '../provider-scenarios/test-timeouts.ts';
import { scenarioName, scenarioNames } from './coverage-manifest.ts';
import { RUNTIME_REF_COVERAGE } from './runtime-ref.coverage.ts';
import {
Expand Down Expand Up @@ -146,23 +147,29 @@ test(scenario('responseIdentity'), async () => {
assert.ok(Array.isArray(result.selectorChain) && result.selectorChain.length > 0);
});

test(scenario('responseConstruction'), async () => {
await withIosContractDaemon(
[runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerTapEntry({ x: 200, y: 322 })],
async (daemon) => {
const snapshot = await daemon.callCommand('snapshot', [], { snapshotInteractiveOnly: true });
assertRpcOk(snapshot);

const press = await daemon.callCommand('press', ['@e2']);
const data = assertRpcOk(press);
// Canonical ref response set from the shared construction site.
assert.equal(data.ref, 'e2');
assert.equal(data.x, 200);
assert.equal(data.y, 322);
assert.match(String(data.message), /Tapped @e2/);
},
);
});
test(
scenario('responseConstruction'),
async () => {
await withIosContractDaemon(
[runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerTapEntry({ x: 200, y: 322 })],
async (daemon) => {
const snapshot = await daemon.callCommand('snapshot', [], {
snapshotInteractiveOnly: true,
});
assertRpcOk(snapshot);

const press = await daemon.callCommand('press', ['@e2']);
const data = assertRpcOk(press);
// Canonical ref response set from the shared construction site.
assert.equal(data.ref, 'e2');
assert.equal(data.x, 200);
assert.equal(data.y, 322);
assert.match(String(data.message), /Tapped @e2/);
},
);
},
PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS,
);

test(scenarioNames(RUNTIME_REF_COVERAGE, 'resolutionDisclosure')[0]!, async () => {
const device = createContractDevice(continueButtonSnapshot(), {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { InteractionGuarantee } from '../../../src/contracts/interaction-gu
import type { Point } from '@agent-device/kernel/snapshot';
import { selector } from '../../../src/commands/interaction/runtime/selector-read.ts';
import { assertRpcOk } from '../provider-scenarios/assertions.ts';
import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from '../provider-scenarios/test-timeouts.ts';
import { scenarioName, scenarioNames } from './coverage-manifest.ts';
import { RUNTIME_SELECTOR_COVERAGE } from './runtime-selector.coverage.ts';
import {
Expand Down Expand Up @@ -197,20 +198,24 @@ test(scenario('responseIdentity'), async () => {
assert.ok(Array.isArray(result.selectorChain) && result.selectorChain.length > 0);
});

test(scenario('responseConstruction'), async () => {
await withIosContractDaemon(
[runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerTapEntry({ x: 200, y: 322 })],
async (daemon) => {
const press = await daemon.callCommand('press', ['label=Continue']);
const data = assertRpcOk(press);
// Canonical selector response set from the shared construction site.
assert.equal(data.x, 200);
assert.equal(data.y, 322);
assert.equal(data.selector, 'label=Continue');
assert.ok(Array.isArray(data.selectorChain));
},
);
});
test(
scenario('responseConstruction'),
async () => {
await withIosContractDaemon(
[runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerTapEntry({ x: 200, y: 322 })],
async (daemon) => {
const press = await daemon.callCommand('press', ['label=Continue']);
const data = assertRpcOk(press);
// Canonical selector response set from the shared construction site.
assert.equal(data.x, 200);
assert.equal(data.y, 322);
assert.equal(data.selector, 'label=Continue');
assert.ok(Array.isArray(data.selectorChain));
},
);
},
PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS,
);

test(scenarioNames(RUNTIME_SELECTOR_COVERAGE, 'resolutionDisclosure')[0]!, async () => {
const device = createContractDevice(continueButtonSnapshot(), {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import type { IncomingHttpHeaders, ServerResponse } from 'node:http';
import type { IncomingHttpHeaders } from 'node:http';
import path from 'node:path';
import { test } from 'vitest';
import {
Expand All @@ -24,9 +24,9 @@ import { withProviderScenarioResource, withProviderScenarioTempDir } from './har
import {
CloudWebDriverTestServer,
type CloudWebDriverHttpCall,
cloudWebDriverTestJson,
startCloudWebDriverTestServer,
type StartedCloudWebDriverTestServer,
writeCloudWebDriverTestJson,
} from './cloud-webdriver-test-server.ts';

test('BrowserStack adapter prepares App Automate capabilities and uploads install artifacts', async () => {
Expand Down Expand Up @@ -520,22 +520,19 @@ class FakeCloudProviderServer extends CloudWebDriverTestServer {
return await startCloudWebDriverTestServer(new FakeCloudProviderServer());
}

protected respond(call: CloudWebDriverHttpCall, res: ServerResponse): void {
protected respond(call: CloudWebDriverHttpCall) {
if (call.method === 'POST' && call.path === '/wd/hub/session') {
if (this.sessionFailuresRemaining > 0) {
this.sessionFailuresRemaining -= 1;
writeCloudWebDriverTestJson(res, { value: { message: 'transient provider failure' } }, 503);
return;
return cloudWebDriverTestJson({ value: { message: 'transient provider failure' } }, 503);
}
writeCloudWebDriverTestJson(res, { value: { sessionId: 'wd-1', capabilities: {} } });
return;
return cloudWebDriverTestJson({ value: { sessionId: 'wd-1', capabilities: {} } });
}
if (call.method === 'POST' && call.path === '/app-automate/upload') {
writeCloudWebDriverTestJson(res, { app_url: 'bs://uploaded-app' });
return;
return cloudWebDriverTestJson({ app_url: 'bs://uploaded-app' });
}
if (call.method === 'GET' && call.path === '/app-automate/sessions/wd-1.json') {
writeCloudWebDriverTestJson(res, {
return cloudWebDriverTestJson({
automation_session: {
video_url: 'https://browserstack.example/video.mp4',
appium_logs_url: 'https://browserstack.example/appium.log',
Expand All @@ -544,9 +541,8 @@ class FakeCloudProviderServer extends CloudWebDriverTestServer {
public_url: 'https://browserstack.example/public',
},
});
return;
}
writeCloudWebDriverTestJson(res, { value: null });
return cloudWebDriverTestJson({ value: null });
}
}

Expand Down
Loading
Loading