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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules/
examples/test-app/.env.local
scripts/perf/.results/
.pnpm-store/
.fallow/
Expand Down
27 changes: 27 additions & 0 deletions examples/test-app/app.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const buildRunCacheDir =
process.env.AGENT_DEVICE_EXPO_BUILD_CACHE_DIR?.trim() || './.expo/build-run-cache';

module.exports = {
expo: {
name: 'Agent Device Tester',
slug: 'agent-device-test-app',
version: '1.0.0',
orientation: 'default',
userInterfaceStyle: 'automatic',
buildCacheProvider: {
plugin: 'expo-build-disk-cache',
options: {
cacheDir: buildRunCacheDir,
},
},
plugins: ['expo-router'],
ios: {
supportsTablet: true,
bundleIdentifier: 'com.callstack.agentdevicelab',
},
android: {
package: 'com.callstack.agentdevicelab',
predictiveBackGestureEnabled: false,
},
},
};
21 changes: 0 additions & 21 deletions examples/test-app/app.json

This file was deleted.

1 change: 1 addition & 0 deletions examples/test-app/maestro/checkout-form.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
appId: com.callstack.agentdevicelab
name: Checkout form
env:
CHECKOUT_NAME: Ada Lovelace
CHECKOUT_EMAIL: ada@example.com
Expand Down
4 changes: 4 additions & 0 deletions src/daemon/handlers/__tests__/session-test-suite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ test('test emits progress when attempts retry and pass', async () => {
maxAttempts: 2,
stepIndex: 1,
stepTotal: 1,
stepCommand: 'open',
stepValue: 'Demo',
});
expect(testEvents[2]).toMatchObject({
type: 'replay-test',
Expand All @@ -211,6 +213,8 @@ test('test emits progress when attempts retry and pass', async () => {
maxAttempts: 2,
stepIndex: 1,
stepTotal: 1,
stepCommand: 'open',
stepValue: 'Demo',
});
expect(testEvents[4]).toMatchObject({
type: 'replay-test',
Expand Down
56 changes: 54 additions & 2 deletions src/daemon/handlers/session-replay-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import { type CommandFlags } from '../../core/dispatch.ts';
import { parseReplayInput } from '../../compat/replay-input.ts';
import { asAppError } from '../../kernel/errors.ts';
import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionAction } from '../types.ts';
import { emitRequestProgress, readReplayTestActionProgress } from '../request-progress.ts';
import {
emitRequestProgress,
readReplayTestActionProgress,
type ReplayTestProgressEvent,
} from '../request-progress.ts';
import { SessionStore } from '../session-store.ts';
import { type ReplayScriptMetadata, writeReplayScript } from '../../replay/script.ts';
import { healReplayAction } from './session-replay-heal.ts';
import { formatScriptActionSummary } from '../../replay/script-utils.ts';
import { errorResponse } from './response.ts';
import { invokeReplayAction } from './session-replay-action-runtime.ts';
import { tryParseSelectorChain } from '../selectors.ts';
import {
buildReplayVarScope,
collectReplayShellEnv,
Expand Down Expand Up @@ -93,7 +98,7 @@ export async function runReplayScriptFile(params: {
for (let index = 0; index < actions.length; index += 1) {
const action = actions[index];
if (!action || action.command === 'replay') continue;
emitReplayTestActionProgress(resolved, index, actions.length);
emitReplayTestActionProgress(resolved, index, actions.length, action);

const sampleStart = readSessionSnapshotSampleCount(sessionStore, sessionName);
let response = await invokeReplayAction({
Expand Down Expand Up @@ -239,6 +244,7 @@ function emitReplayTestActionProgress(
file: string,
actionIndex: number,
actionTotal: number,
action: SessionAction,
): void {
const progress = readReplayTestActionProgress();
if (!progress) return;
Expand All @@ -249,9 +255,55 @@ function emitReplayTestActionProgress(
status: 'progress',
stepIndex: actionIndex + 1,
stepTotal: actionTotal,
...formatReplayTestActionProgress(action),
});
}

function formatReplayTestActionProgress(
action: SessionAction,
): Pick<ReplayTestProgressEvent, 'stepCommand' | 'stepValue'> {
return {
stepCommand: formatReplayTestProgressCommand(action.command),
...formatReplayTestProgressValue(action),
};
}

function formatReplayTestProgressCommand(command: string): string {
if (!command.startsWith('__maestro')) return command;
const name = command.slice('__maestro'.length);
return name.length > 0 ? name[0]!.toLowerCase() + name.slice(1) : command;
}

function formatReplayTestProgressValue(
action: SessionAction,
): Pick<ReplayTestProgressEvent, 'stepValue'> {
const positionals = action.positionals ?? [];
const selectorValue = readSelectorDisplayValue(positionals[0]);
if (selectorValue) return { stepValue: selectorValue };
if (action.command === '__maestroTapPointPercent' && positionals.length >= 2) {
return { stepValue: `${positionals[0]},${positionals[1]}%` };
}
if (positionals.length === 0) return {};
return { stepValue: positionals.join(' ') };
}

function readSelectorDisplayValue(selector: string | undefined): string | undefined {
if (!selector) return undefined;
const parsed = tryParseSelectorChain(selector);
if (!parsed) return undefined;
const values = parsed.selectors.flatMap((entry) =>
entry.terms.flatMap((term) =>
(term.key === 'label' || term.key === 'text' || term.key === 'id') &&
typeof term.value === 'string'
? [term.value]
: [],
),
);
if (values.length === 0) return undefined;
const first = values[0];
return first && values.every((value) => value === first) ? first : undefined;
}

function buildReplayMetadataFlags(
flags: CommandFlags | undefined,
metadata: ReplayScriptMetadata,
Expand Down
12 changes: 11 additions & 1 deletion src/daemon/request-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export type ReplayTestProgressEvent = {
total: number;
stepIndex?: number;
stepTotal?: number;
stepCommand?: string;
stepValue?: string;
attempt?: number;
maxAttempts?: number;
durationMs?: number;
Expand Down Expand Up @@ -47,7 +49,15 @@ export type RequestProgressEvent =
export type RequestProgressSink = (event: RequestProgressEvent) => void;
export type ReplayTestActionProgressContext = Omit<
ReplayTestProgressEvent,
'type' | 'status' | 'stepIndex' | 'stepTotal' | 'durationMs' | 'retrying' | 'message'
| 'type'
| 'status'
| 'stepIndex'
| 'stepTotal'
| 'stepCommand'
| 'stepValue'
| 'durationMs'
| 'retrying'
| 'message'
>;

const requestProgress = new AsyncLocalStorage<RequestProgressSink | undefined>();
Expand Down
91 changes: 67 additions & 24 deletions src/replay/test/__tests__/progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@ function renderTestResult(
?.text;
}

function withForcedColor<T>(run: () => T): T {
const originalForceColor = process.env.FORCE_COLOR;
const originalNoColor = process.env.NO_COLOR;
process.env.FORCE_COLOR = '1';
delete process.env.NO_COLOR;
try {
return run();
} finally {
restoreEnvValue('FORCE_COLOR', originalForceColor);
restoreEnvValue('NO_COLOR', originalNoColor);
}
}

function restoreEnvValue(name: 'FORCE_COLOR' | 'NO_COLOR', value: string | undefined): void {
if (typeof value === 'string') process.env[name] = value;
else delete process.env[name];
}

test('createReplayTestProgressRenderer renders pass, retry, fail, and skip cases', () => {
const cases: Array<{ event: ReplayTestResult; expected: RegExp }> = [
{
Expand Down Expand Up @@ -130,12 +148,8 @@ test('createReplayTestProgressRenderer colors stderr progress rows when stdout i
}
});

test('createReplayTestProgressRenderer dims live step progress when color is enabled', () => {
const originalForceColor = process.env.FORCE_COLOR;
const originalNoColor = process.env.NO_COLOR;
process.env.FORCE_COLOR = '1';
delete process.env.NO_COLOR;
try {
test('createReplayTestProgressRenderer renders live step progress with spinner and action detail', () => {
withForcedColor(() => {
const renderer = createReplayTestProgressRenderer({ liveProgress: true });
const rendered = renderer.render({
type: 'test-step',
Expand All @@ -146,27 +160,61 @@ test('createReplayTestProgressRenderer dims live step progress when color is ena
total: 1,
stepIndex: 3,
stepTotal: 20,
stepCommand: 'tapOn',
stepValue: 'Sign in',
},
});

assert.deepEqual(rendered, {
text: '\r\u001B[2K Checkout flow\u001B[2m [3/20]\u001B[22m',
text: '\r\u001B[2K\u001B[34m⠋\u001B[39m Checkout flow \u001B[2m[\u001B[22m\u001B[2m3/20\u001B[22m \u001B[35mtapOn\u001B[39m \u001B[32mSign in\u001B[39m\u001B[2m]\u001B[22m',
newline: false,
});
} finally {
if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor;
else delete process.env.FORCE_COLOR;
if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor;
else delete process.env.NO_COLOR;
}
});
});

test('createReplayTestProgressRenderer trims live step progress by visible columns', () => {
withForcedColor(() => {
const renderer = createReplayTestProgressRenderer({ liveProgress: true, columns: 56 });
const rendered = renderer.render({
type: 'test-step',
test: {
file: '/tmp/checkout-form.yaml',
index: 1,
total: 1,
stepIndex: 2,
stepTotal: 20,
stepCommand: 'assertVisible',
stepValue: 'Agent',
},
});

assert.deepEqual(rendered, {
text: '\r\u001B[2K\u001B[34m⠋\u001B[39m checkout-form.yaml \u001B[2m[\u001B[22m\u001B[2m2/20\u001B[22m \u001B[35massertVisible\u001B[39m \u001B[32mAgent\u001B[39m\u001B[2m]\u001B[22m',
newline: false,
});

const truncatingRenderer = createReplayTestProgressRenderer({
liveProgress: true,
columns: 36,
});
const truncated = truncatingRenderer.render({
type: 'test-step',
test: {
file: '/tmp/checkout-form.yaml',
index: 1,
total: 1,
stepIndex: 2,
stepTotal: 20,
stepCommand: 'assertVisible',
stepValue: 'Agent Login',
},
});
assert.ok(truncated?.text.endsWith('...\u001B[0m'));
});
});

test('createReplayTestProgressRenderer colors completed result markers when color is enabled', () => {
const originalForceColor = process.env.FORCE_COLOR;
const originalNoColor = process.env.NO_COLOR;
process.env.FORCE_COLOR = '1';
delete process.env.NO_COLOR;
try {
withForcedColor(() => {
assert.equal(
renderTestResult({
file: '/tmp/01-pass.ad',
Expand Down Expand Up @@ -201,10 +249,5 @@ test('createReplayTestProgressRenderer colors completed result markers when colo
message: 'boom',
});
assert.ok(failedLine?.startsWith('\u001B[31m⨯\u001B[39m Checkout failure'));
} finally {
if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor;
else delete process.env.FORCE_COLOR;
if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor;
else delete process.env.NO_COLOR;
}
});
});
Loading
Loading