diff --git a/src/daemon/handlers/__tests__/session-test-discovery.test.ts b/src/daemon/handlers/__tests__/session-test-discovery.test.ts index e5e0f0d7a..eb54f4702 100644 --- a/src/daemon/handlers/__tests__/session-test-discovery.test.ts +++ b/src/daemon/handlers/__tests__/session-test-discovery.test.ts @@ -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'; @@ -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'], + ); +}); diff --git a/src/daemon/handlers/session-test-discovery.ts b/src/daemon/handlers/session-test-discovery.ts index acf5df1d1..3846d312d 100644 --- a/src/daemon/handlers/session-test-discovery.ts +++ b/src/daemon/handlers/session-test-discovery.ts @@ -27,6 +27,8 @@ export type ReplayTestDiscoveryEntry = export type ReplayTestRunEntry = Extract; +type ReplayTestInputSource = 'directory' | 'file' | 'glob'; + export function discoverReplayTestEntries(params: { inputs: string[]; cwd?: string; @@ -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) { @@ -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[] { +function discoverReplayTestFilePaths( + inputs: string[], + cwd: string, + extensions: Set, + 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, +): { 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)) { @@ -165,14 +196,15 @@ function expandReplayTestInput(input: string, cwd: string, extensions: Set (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 { return isMaestroReplayBackend(replayBackend) - ? new Set(['.ad', '.yaml', '.yml']) + ? new Set(['.yaml', '.yml', '.ad']) : new Set(['.ad']); } @@ -180,6 +212,27 @@ function replayTestGlobPatterns(extensions: Set): 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'; }