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 @@ -7,6 +7,16 @@ import { test, vi } from 'vitest';
// (slow/hanging) runner dispatch path. Reject fast so failure-path tests keep
// exercising `divergence.screen: unavailable` deterministically, exactly like
// a real capture failure would.
//
// This file carries the Maestro-heavy majority of what used to live in
// session-replay-vars.test.ts, plus a handful of generic (non-Maestro) `.ad`
// runReplayScriptFile tests that happen to share the same runReplayFixture
// helper and mock configuration below. It is a sibling of
// session-replay-runtime.test.ts rather than a merge into it because that
// file mocks '../../../core/dispatch.ts' differently (dispatchCommand
// resolves `{}`, not throws) — vitest allows only one vi.mock per module per
// file, so reconciling the two configurations was out of scope for a pure
// test-file split (see #1460).
vi.mock('../../../core/dispatch.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../core/dispatch.ts')>();
return {
Expand Down Expand Up @@ -40,25 +50,14 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { AppError } from '../../../kernel/errors.ts';
import { PNG } from '../../../utils/png.ts';
import { runCmdBackground, type ExecBackgroundResult } from '../../../utils/exec.ts';
import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionAction } from '../../types.ts';
import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../types.ts';
import type { CommandFlags } from '../../../core/dispatch.ts';
import { SessionStore } from '../../session-store.ts';
import { makeAndroidSession, makeIosSession } from '../../../__tests__/test-utils/index.ts';
import {
buildReplayVarScope,
collectReplayShellEnv,
parseReplayCliEnvEntries,
resolveReplayAction,
resolveReplayString,
} from '../../../replay/vars.ts';
import { parseReplayScriptDetailed, readReplayScriptMetadata } from '../../../replay/script.ts';
import { runReplayScriptFile } from '../session-replay-runtime.ts';

const LOC = { file: 'test.ad', line: 1 };

type CapturedInvocation = {
command: string;
positionals?: string[];
Expand Down Expand Up @@ -178,206 +177,6 @@ async function readFirstStdoutLine(process: ExecBackgroundResult): Promise<strin
});
}

test('resolveReplayString substitutes variables', () => {
const scope = buildReplayVarScope({ fileEnv: { APP: 'settings' } });
assert.equal(resolveReplayString('open ${APP}', scope, LOC), 'open settings');
});

test('resolveReplayString supports fallback with :-default', () => {
const scope = buildReplayVarScope({});
assert.equal(resolveReplayString('wait ${WAIT_SHORT:-500}', scope, LOC), 'wait 500');
});

test('resolveReplayString prefers scope value over fallback', () => {
const scope = buildReplayVarScope({ fileEnv: { WAIT_SHORT: '1000' } });
assert.equal(resolveReplayString('wait ${WAIT_SHORT:-500}', scope, LOC), 'wait 1000');
});

test('resolveReplayString fallback preserves embedded braces via escapes', () => {
const scope = buildReplayVarScope({});
assert.equal(resolveReplayString('x ${A:-one\\}two}', scope, LOC), 'x one}two');
});

test('resolveReplayString throws on unresolved variable with file:line', () => {
const scope = buildReplayVarScope({ fileEnv: { OTHER: 'x' } });
assert.throws(
() => resolveReplayString('open ${MISSING}', scope, { file: 'a.ad', line: 7 }),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
/Unresolved variable \$\{MISSING\} at a\.ad:7/.test(error.message),
);
});

test('resolveReplayString is case-sensitive', () => {
const scope = buildReplayVarScope({ fileEnv: { APP: 'settings' } });
assert.throws(() => resolveReplayString('${app}', scope, LOC), AppError);
});

test('resolveReplayString substitutes multiple vars on one line', () => {
const scope = buildReplayVarScope({ fileEnv: { A: '1', B: '2' } });
assert.equal(resolveReplayString('${A}-${B}-${A}', scope, LOC), '1-2-1');
});

test('buildReplayVarScope precedence: cli > shell > file > builtin', () => {
const scope = buildReplayVarScope({
builtins: { K: 'builtin' },
fileEnv: { K: 'file' },
shellEnv: { K: 'shell' },
cliEnv: { K: 'cli' },
});
assert.equal(scope.values.K, 'cli');

const shellWinsOverFile = buildReplayVarScope({
fileEnv: { K: 'file' },
shellEnv: { K: 'shell' },
});
assert.equal(shellWinsOverFile.values.K, 'shell');
});

test('collectReplayShellEnv strips AD_VAR_ prefix and ignores other vars', () => {
const result = collectReplayShellEnv({
AD_VAR_APP_ID: 'settings',
PATH: '/bin',
AD_VAR_123: 'x',
AD_VAR_: 'empty',
OTHER_VAR: 'y',
AD_APP_ID: 'no-legacy-prefix',
});
assert.equal(result.APP_ID, 'settings');
assert.equal(result.PATH, undefined);
assert.equal(result['123'], undefined);
assert.equal(result[''], undefined);
// legacy AD_* (non AD_VAR_*) is no longer auto-imported.
assert.equal(Object.prototype.hasOwnProperty.call(result, 'AD_APP_ID'), false);
});

test('collectReplayShellEnv skips keys that land in reserved AD_* namespace after strip', () => {
const result = collectReplayShellEnv({
AD_VAR_AD_SESSION: 'evil',
AD_VAR_AD_FOO: 'evil',
});
assert.equal(Object.prototype.hasOwnProperty.call(result, 'AD_SESSION'), false);
assert.equal(Object.prototype.hasOwnProperty.call(result, 'AD_FOO'), false);
});

test('parseReplayCliEnvEntries splits KEY=VALUE and rejects invalid keys', () => {
assert.deepEqual(parseReplayCliEnvEntries(['APP=settings', 'FOO=bar=baz']), {
APP: 'settings',
FOO: 'bar=baz',
});
assert.throws(() => parseReplayCliEnvEntries(['NOEQUAL']), AppError);
assert.throws(() => parseReplayCliEnvEntries(['lower=x']), AppError);
assert.throws(() => parseReplayCliEnvEntries(['=value']), AppError);
});

test('resolveReplayAction walks positionals and string flags', () => {
const action: SessionAction = {
ts: 0,
command: 'snapshot',
positionals: ['${FOO}'],
flags: {
snapshotScope: '${SCOPE}',
snapshotInteractiveOnly: true,
snapshotDepth: 3,
},
};
const scope = buildReplayVarScope({ fileEnv: { FOO: 'bar', SCOPE: 'app' } });
const resolved = resolveReplayAction(action, scope, LOC);
assert.deepEqual(resolved.positionals, ['bar']);
assert.equal(resolved.flags?.snapshotScope, 'app');
assert.equal(resolved.flags?.snapshotInteractiveOnly, true);
assert.equal(resolved.flags?.snapshotDepth, 3);
});

test('resolveReplayAction walks runtime hints', () => {
const action: SessionAction = {
ts: 0,
command: 'open',
positionals: [],
runtime: { platform: 'android', metroHost: '${HOST}' },
flags: {},
};
const scope = buildReplayVarScope({ fileEnv: { HOST: '10.0.0.1' } });
const resolved = resolveReplayAction(action, scope, LOC);
assert.equal(resolved.runtime?.metroHost, '10.0.0.1');
});

test('parseReplayScriptDetailed tracks line numbers', () => {
const script = [
'# comment',
'context platform=android',
'env APP=settings',
'',
'open ${APP}',
'wait 500',
].join('\n');
const parsed = parseReplayScriptDetailed(script);
assert.equal(parsed.actions.length, 2);
assert.deepEqual(parsed.actionLines, [5, 6]);
});

test('readReplayScriptMetadata parses env KEY=VALUE directives', () => {
const metadata = readReplayScriptMetadata(
'context platform=android\nenv APP=settings\nenv WAIT=500\nopen ${APP}\n',
);
assert.equal(metadata.env?.APP, 'settings');
assert.equal(metadata.env?.WAIT, '500');
});

test('readReplayScriptMetadata accepts env before context', () => {
const metadata = readReplayScriptMetadata(
'env APP=settings\ncontext platform=ios target=mobile\n',
);
assert.equal(metadata.platform, 'ios');
assert.equal(metadata.target, 'mobile');
assert.equal(metadata.env?.APP, 'settings');
});

test('readReplayScriptMetadata parses quoted env values with spaces', () => {
const metadata = readReplayScriptMetadata(
'context platform=android\nenv SEL="label=Wait || label=Apps"\n',
);
assert.equal(metadata.env?.SEL, 'label=Wait || label=Apps');
});

test('readReplayScriptMetadata rejects invalid env key', () => {
assert.throws(
() => readReplayScriptMetadata('context platform=android\nenv lower=settings\n'),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
/Invalid env key "lower"/.test(error.message),
);
});

test('readReplayScriptMetadata rejects duplicate env key', () => {
assert.throws(
() => readReplayScriptMetadata('context platform=android\nenv APP=a\nenv APP=b\n'),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
/Duplicate env directive "APP"/.test(error.message),
);
});

// ADR 0012 decision 1 / migration step 6: `--update` retirement removed the
// env/interpolation/compat-flow refusal guards below — they existed only to
// protect the now-deleted heal-and-rewrite path, which could not safely
// round-trip `env`/`${VAR}` or serialize Maestro's in-memory flow controls
// back to `.ad`. With no rewrite, `--update` no longer needs to refuse
// anything; it runs exactly like a plain replay.

test('--update no longer rejects scripts with env directives (the guard existed only for rewrite safety)', async () => {
const { response, calls } = await runReplayFixture({
label: 'env-heal',
script: 'context platform=android\nenv APP=settings\nopen ${APP}\n',
flags: { replayUpdate: true },
});
assert.equal(response.ok, true);
assert.deepEqual(calls[0]?.positionals, ['settings']);
});

test('--update no longer rejects Maestro compat flow controls (the guard existed only for rewrite safety)', async () => {
const { response } = await runReplayFixture({
label: 'maestro-replay-update-flow-control',
Expand All @@ -396,84 +195,14 @@ test('--update no longer rejects Maestro compat flow controls (the guard existed
assert.equal(response.ok, true);
});

test('resolveReplayAction produces dispatch-ready literals for a realistic fixture', () => {
const script = [
'context platform=android',
'env APP_ID=settings',
'env WAIT_SHORT=500',
'env SETTINGS_ITEMS="label=Wait || label=Apps"',
'',
'open ${APP_ID} --relaunch',
'wait ${WAIT_SHORT}',
'click "${SETTINGS_ITEMS}"',
'is exists "${SETTINGS_ITEMS}"',
'snapshot -s "${SNAPSHOT_SCOPE:-app}"',
].join('\n');
const metadata = readReplayScriptMetadata(script);
const parsed = parseReplayScriptDetailed(script);
const scope = buildReplayVarScope({
builtins: { AD_PLATFORM: 'android' },
fileEnv: metadata.env,
shellEnv: { APP_ID: 'shell-wins' },
cliEnv: { APP_ID: 'cli-wins' },
});
const resolved = parsed.actions.map((action, index) =>
resolveReplayAction(action, scope, {
file: 'fixture.ad',
line: parsed.actionLines[index] ?? 0,
}),
);
assert.deepEqual(resolved[0]?.positionals, ['cli-wins']);
assert.equal(resolved[0]?.flags.relaunch, true);
assert.deepEqual(resolved[1]?.positionals, ['500']);
assert.deepEqual(resolved[2]?.positionals, ['label=Wait || label=Apps']);
assert.deepEqual(resolved[3]?.positionals, ['exists', 'label=Wait || label=Apps']);
assert.equal(resolved[4]?.flags.snapshotScope, 'app');
});

test.each([
{
name: 'file env via parseReplayEnvLine',
run: () => readReplayScriptMetadata('context platform=android\nenv AD_FOO=bar\n'),
keyMatch: /AD_FOO/,
},
{
name: 'CLI -e via parseReplayCliEnvEntries',
run: () => parseReplayCliEnvEntries(['AD_FOO=x']),
keyMatch: /AD_FOO/,
},
{
name: 'buildReplayVarScope.fileEnv',
run: () => buildReplayVarScope({ fileEnv: { AD_FOO: 'x' } }),
keyMatch: /AD_FOO/,
},
{
name: 'buildReplayVarScope.cliEnv',
run: () => buildReplayVarScope({ cliEnv: { AD_SESSION: 'x' } }),
keyMatch: /AD_SESSION/,
},
])('rejects AD_* as reserved namespace in $name', ({ run, keyMatch }) => {
assert.throws(
run,
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
/AD_\* namespace is reserved/.test(error.message) &&
keyMatch.test(error.message),
);
});

test('parseReplayCliEnvEntries error wording is user-friendly for invalid keys', () => {
assert.throws(
() => parseReplayCliEnvEntries(['lower=x']),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
/uppercase letters, digits, and underscores/.test(error.message),
);
});
function assertNoUnresolvedInterpolation(calls: CapturedInvocation[]): void {
for (const call of calls) {
for (const pos of call.positionals ?? []) {
assert.equal(pos.includes('${'), false, `unresolved interpolation leaked: ${pos}`);
}
}
}

// fallow-ignore-next-line complexity
test('runReplayScriptFile dispatches resolved literals with file env overridden by CLI', async () => {
const { response, calls } = await runReplayFixture({
label: 'green',
Expand All @@ -490,21 +219,19 @@ test('runReplayScriptFile dispatches resolved literals with file env overridden
flags: { replayEnv: ['APP=cli-app'] },
});
assert.equal(response.ok, true);
const [open, snapshot, click] = calls;
assert.ok(open && snapshot && click);
// open ${APP} -> CLI override wins.
assert.equal(calls[0]?.command, 'open');
assert.deepEqual(calls[0]?.positionals, ['cli-app']);
assert.equal(open.command, 'open');
assert.deepEqual(open.positionals, ['cli-app']);
// snapshot -s ${SCOPE} -> file env fills in.
assert.equal(calls[1]?.command, 'snapshot');
assert.equal(calls[1]?.flags?.snapshotScope, 'file-scope');
assert.equal(snapshot.command, 'snapshot');
assert.equal(snapshot.flags?.snapshotScope, 'file-scope');
// click with ${AD_FILENAME} resolves to the relative script path.
assert.equal(calls[2]?.command, 'click');
assert.deepEqual(calls[2]?.positionals, ['at flow.ad']);
assert.equal(click.command, 'click');
assert.deepEqual(click.positionals, ['at flow.ad']);
// And nothing dispatched still contains a literal ${...} token.
for (const call of calls) {
for (const pos of call.positionals ?? []) {
assert.equal(pos.includes('${'), false, `unresolved interpolation leaked: ${pos}`);
}
}
assertNoUnresolvedInterpolation(calls);
});

test('.ad replay normalizes resolved gesture and swipe syntax into structured daemon input', async () => {
Expand Down
Loading
Loading