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
76 changes: 75 additions & 1 deletion src/daemon/handlers/__tests__/session-test-discovery.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test } from 'vitest';
import { test, vi } from 'vitest';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
Expand Down Expand Up @@ -84,3 +84,77 @@ test('discoverReplayTestEntries includes Maestro yaml flows for Maestro test sui
assert.equal(entries[0].title, 'Bottom Tabs - Dynamic');
}
});

test('discoverReplayTestEntries sorts Maestro directory flows by extension group then path', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-discovery-maestro-sort-'));
const flowFiles = ['10-legacy.ad', '30-zeta.yaml', '05-compat.ad', '20-beta.yml'];
for (const fileName of flowFiles) {
const body = fileName.endsWith('.ad') ? 'open "Demo"\n' : 'appId: demo\n---\n- launchApp\n';
fs.writeFileSync(path.join(root, fileName), body);
}

const globSync = vi.spyOn(fs, 'globSync').mockImplementation((pattern, options) => {
assert.equal((options as { cwd?: string } | undefined)?.cwd, root);
if (pattern === '**/*.yaml') return ['30-zeta.yaml'];
if (pattern === '**/*.yml') return ['20-beta.yml'];
if (pattern === '**/*.ad') return ['10-legacy.ad', '05-compat.ad'];
return [];
});

try {
const entries = discoverReplayTestEntries({
inputs: [root],
cwd: root,
replayBackend: 'maestro',
});

assert.deepEqual(
entries.map((entry) => path.basename(entry.path)),
['20-beta.yml', '30-zeta.yaml', '05-compat.ad', '10-legacy.ad'],
);
} finally {
globSync.mockRestore();
}
});

test('discoverReplayTestEntries preserves explicit Maestro file order', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-discovery-maestro-order-'));
const second = path.join(root, '02-second.yaml');
const first = path.join(root, '01-first.yaml');
fs.writeFileSync(first, 'appId: demo\n---\n- launchApp\n');
fs.writeFileSync(second, 'appId: demo\n---\n- launchApp\n');

const entries = discoverReplayTestEntries({
inputs: [second, first],
cwd: root,
replayBackend: 'maestro',
});

assert.deepEqual(
entries.map((entry) => path.basename(entry.path)),
['02-second.yaml', '01-first.yaml'],
);
});

test('discoverReplayTestEntries orders Maestro file inputs before expanded flows', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-discovery-maestro-files-'));
const suite = path.join(root, 'suite');
const globSuite = path.join(root, 'glob-suite');
fs.mkdirSync(suite);
fs.mkdirSync(globSuite);
const explicit = path.join(root, '99-explicit.yaml');
fs.writeFileSync(explicit, 'appId: demo\n---\n- launchApp\n');
fs.writeFileSync(path.join(suite, '01-directory.yaml'), 'appId: demo\n---\n- launchApp\n');
fs.writeFileSync(path.join(globSuite, '02-glob.yaml'), 'appId: demo\n---\n- launchApp\n');

const entries = discoverReplayTestEntries({
inputs: [suite, path.join(globSuite, '*.yaml'), explicit],
cwd: root,
replayBackend: 'maestro',
});

assert.deepEqual(
entries.map((entry) => path.basename(entry.path)),
['99-explicit.yaml', '01-directory.yaml', '02-glob.yaml'],
);
});
75 changes: 64 additions & 11 deletions src/daemon/handlers/session-test-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export type ReplayTestDiscoveryEntry =

export type ReplayTestRunEntry = Extract<ReplayTestDiscoveryEntry, { kind: 'run' }>;

type ReplayTestInputSource = 'directory' | 'file' | 'glob';

export function discoverReplayTestEntries(params: {
inputs: string[];
cwd?: string;
Expand All @@ -36,11 +38,7 @@ export function discoverReplayTestEntries(params: {
const { inputs, cwd, platformFilter, replayBackend } = params;
const extensions = replayTestExtensions(replayBackend);
const resolvedCwd = cwd ?? process.cwd();
const filePaths = [
...new Set(inputs.flatMap((input) => expandReplayTestInput(input, resolvedCwd, extensions))),
]
.map((entry) => path.normalize(entry))
.sort((left, right) => left.localeCompare(right));
const filePaths = discoverReplayTestFilePaths(inputs, resolvedCwd, extensions, replayBackend);

const entries: ReplayTestDiscoveryEntry[] = [];
for (const filePath of filePaths) {
Expand Down Expand Up @@ -136,24 +134,57 @@ export function resolveReplayTestRetries(
return Math.max(0, Math.min(MAX_REPLAY_TEST_RETRIES, resolved));
}

function expandReplayTestInput(input: string, cwd: string, extensions: Set<string>): string[] {
function discoverReplayTestFilePaths(
inputs: string[],
cwd: string,
extensions: Set<string>,
replayBackend: string | undefined,
): string[] {
if (!isMaestroReplayBackend(replayBackend)) {
return [
...new Set(inputs.flatMap((input) => expandReplayTestInput(input, cwd, extensions).paths)),
]
.map((entry) => path.normalize(entry))
.sort((left, right) => left.localeCompare(right));
}

const files: string[] = [];
const expandedGroups: string[][] = [];
for (const input of inputs) {
const expanded = expandReplayTestInput(input, cwd, extensions);
if (expanded.source === 'file') {
files.push(...expanded.paths);
} else {
expandedGroups.push(sortMaestroExpandedReplayTestPaths(expanded.paths));
}
}

return uniqueNormalizedPaths([...files, ...expandedGroups.flat()]);
}

function expandReplayTestInput(
input: string,
cwd: string,
extensions: Set<string>,
): { paths: string[]; source: ReplayTestInputSource } {
const expandedInput = SessionStore.expandHome(input, cwd);
if (fs.existsSync(expandedInput)) {
const stat = fs.statSync(expandedInput);
if (stat.isDirectory()) {
return replayTestGlobPatterns(extensions).flatMap((pattern) =>
const paths = replayTestGlobPatterns(extensions).flatMap((pattern) =>
fs
.globSync(pattern, { cwd: expandedInput })
.map((match) => path.join(expandedInput, match)),
);
return { paths, source: 'directory' };
}
if (stat.isFile()) {
if (!extensions.has(path.extname(expandedInput))) {
throw new AppError('INVALID_ARGS', `test does not support this file type: ${input}`);
}
return [expandedInput];
return { paths: [expandedInput], source: 'file' };
}
return [];
return { paths: [], source: 'file' };
}

if (!looksLikeGlob(input) && !looksLikeGlob(expandedInput)) {
Expand All @@ -165,21 +196,43 @@ function expandReplayTestInput(input: string, cwd: string, extensions: Set<strin
cwd: path.isAbsolute(expandedInput) ? undefined : cwd,
});

return matches
const paths = matches
.map((match) => (path.isAbsolute(match) ? match : path.resolve(cwd, match)))
.filter((match) => extensions.has(path.extname(match)) && isExistingFile(match));
return { paths, source: 'glob' };
}

function replayTestExtensions(replayBackend: string | undefined): Set<string> {
return isMaestroReplayBackend(replayBackend)
? new Set(['.ad', '.yaml', '.yml'])
? new Set(['.yaml', '.yml', '.ad'])
: new Set(['.ad']);
}

function replayTestGlobPatterns(extensions: Set<string>): string[] {
return [...extensions].map((extension) => `**/*${extension}`);
}

function sortMaestroExpandedReplayTestPaths(paths: string[]): string[] {
return paths.map((entry) => path.normalize(entry)).sort(compareMaestroReplayTestPath);
}

function compareMaestroReplayTestPath(left: string, right: string): number {
const leftRank = maestroReplayTestExtensionRank(left);
const rightRank = maestroReplayTestExtensionRank(right);
if (leftRank !== rightRank) {
return leftRank - rightRank;
}
return left.localeCompare(right);
}

function maestroReplayTestExtensionRank(filePath: string): number {
return path.extname(filePath) === '.ad' ? 1 : 0;
}

function uniqueNormalizedPaths(paths: string[]): string[] {
return [...new Set(paths.map((entry) => path.normalize(entry)))];
}

function isMaestroReplayBackend(replayBackend: string | undefined): boolean {
return replayBackend === 'maestro';
}
Expand Down
Loading