Skip to content

Commit a6ccb94

Browse files
avoid config values
1 parent c455a65 commit a6ccb94

File tree

2 files changed

+226
-20
lines changed

2 files changed

+226
-20
lines changed

src/providers/maestro.ts

Lines changed: 129 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
194194
const maestroOptions = this.options.getMaestroOptions();
195195
const metadata = this.options.metadata;
196196

197+
// Process flows to show actual zip structure
198+
const flowResult = await this.collectFlows();
199+
197200
this.printDryRunSummary({
198201
provider: 'Maestro',
199202
apiUrl: this.URL,
@@ -219,6 +222,21 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
219222
},
220223
});
221224

225+
// Show zip structure details
226+
if (flowResult) {
227+
const { allFlowFiles, baseDir } = flowResult;
228+
const effectiveBase =
229+
baseDir || this.computeCommonDirectory(allFlowFiles);
230+
logger.info('Zip structure (files as they will appear in the archive):');
231+
for (const file of allFlowFiles) {
232+
const archiveName = path.relative(effectiveBase, path.resolve(file));
233+
logger.info(` ${archiveName}`);
234+
}
235+
logger.info(` Base directory: ${path.resolve(effectiveBase)}`);
236+
} else {
237+
logger.info('Flows: single .zip file (uploaded as-is)');
238+
}
239+
222240
return { success: true, runs: [] };
223241
}
224242

@@ -413,26 +431,28 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
413431
}
414432
}
415433

416-
private async uploadFlows() {
434+
/**
435+
* Collect and resolve all flow files, their dependencies, and determine the
436+
* base directory for the zip structure. This is shared by both uploadFlows
437+
* and the dry-run path.
438+
*
439+
* Returns null if the input is a single .zip file (direct upload, no processing).
440+
*/
441+
async collectFlows(): Promise<{
442+
allFlowFiles: string[];
443+
baseDir: string | undefined;
444+
} | null> {
417445
const flowsPaths = this.options.flows;
418446

419-
let zipPath: string;
420-
421-
// Special case: single zip file - upload directly
447+
// Special case: single zip file - no processing needed
422448
if (flowsPaths.length === 1) {
423449
const singlePath = flowsPaths[0];
424450
const stat = await fs.promises.stat(singlePath).catch(() => null);
425-
if (stat?.isFile() && path.extname(singlePath).toLowerCase() === '.zip') {
426-
zipPath = singlePath;
427-
await this.upload.upload({
428-
filePath: zipPath,
429-
url: `${this.URL}/${this.appId}/tests`,
430-
credentials: this.credentials,
431-
contentType: 'application/zip',
432-
showProgress: !this.options.quiet,
433-
validateZipFormat: true,
434-
});
435-
return true;
451+
if (
452+
stat?.isFile() &&
453+
path.extname(singlePath).toLowerCase() === '.zip'
454+
) {
455+
return null;
436456
}
437457
}
438458

@@ -489,8 +509,25 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
489509
}
490510

491511
// Determine base directory for zip structure
492-
// If we have a single directory, use it as base; otherwise use common ancestor or flatten
493-
const baseDir = baseDirs.length === 1 ? baseDirs[0] : undefined;
512+
// If we have a single directory, use it as base; otherwise try to find the Maestro project root
513+
let baseDir = baseDirs.length === 1 ? baseDirs[0] : undefined;
514+
515+
// When individual files are passed (not a directory), search ancestor directories
516+
// for config.yaml to find the Maestro project root. This ensures the zip preserves
517+
// the full directory structure needed for relative runFlow paths (e.g., ../../screens/).
518+
if (!baseDir && allFlowFiles.length > 0) {
519+
const projectRoot = await this.findMaestroProjectRoot(allFlowFiles);
520+
if (projectRoot) {
521+
baseDir = projectRoot.dir;
522+
// Include config.yaml and discover its dependencies
523+
const configResolved = path.resolve(projectRoot.configPath);
524+
if (
525+
!allFlowFiles.some((f) => path.resolve(f) === configResolved)
526+
) {
527+
allFlowFiles.push(projectRoot.configPath);
528+
}
529+
}
530+
}
494531

495532
// Discover dependencies (addMedia, runScript, runFlow, etc.) for all flow files
496533
// This ensures referenced files are included even when individual YAML files are passed
@@ -536,7 +573,28 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
536573
this.logMissingReferences(missingReferences, baseDir);
537574
}
538575

539-
zipPath = await this.createFlowsZip(allFlowFiles, baseDir);
576+
return { allFlowFiles, baseDir };
577+
}
578+
579+
private async uploadFlows() {
580+
const result = await this.collectFlows();
581+
582+
if (result === null) {
583+
// Single zip file - upload directly
584+
const zipPath = this.options.flows[0];
585+
await this.upload.upload({
586+
filePath: zipPath,
587+
url: `${this.URL}/${this.appId}/tests`,
588+
credentials: this.credentials,
589+
contentType: 'application/zip',
590+
showProgress: !this.options.quiet,
591+
validateZipFormat: true,
592+
});
593+
return true;
594+
}
595+
596+
const { allFlowFiles, baseDir } = result;
597+
const zipPath = await this.createFlowsZip(allFlowFiles, baseDir);
540598

541599
try {
542600
await this.upload.upload({
@@ -553,6 +611,35 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
553611
return true;
554612
}
555613

614+
/**
615+
* Search ancestor directories of the given flow files for a Maestro config file
616+
* (config.yaml or config.yml). This identifies the project root so the zip
617+
* preserves the directory structure needed for relative paths like ../../screens/.
618+
*/
619+
private async findMaestroProjectRoot(
620+
flowFiles: string[],
621+
): Promise<{ dir: string; configPath: string } | null> {
622+
// Start from the first flow file and walk up
623+
const startDir = path.dirname(path.resolve(flowFiles[0]));
624+
const rootDir = path.parse(startDir).root;
625+
626+
let currentDir = startDir;
627+
while (currentDir !== rootDir) {
628+
for (const configName of ['config.yaml', 'config.yml']) {
629+
const candidatePath = path.join(currentDir, configName);
630+
try {
631+
await fs.promises.access(candidatePath);
632+
return { dir: currentDir, configPath: candidatePath };
633+
} catch {
634+
// Config not found here, keep searching
635+
}
636+
}
637+
currentDir = path.dirname(currentDir);
638+
}
639+
640+
return null;
641+
}
642+
556643
private async discoverFlows(directory: string): Promise<string[]> {
557644
const entries = await fs.promises.readdir(directory, {
558645
withFileTypes: true,
@@ -901,8 +988,19 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
901988
}
902989

903990
// Recursively check remaining object properties for nested structures
991+
// Skip config-only keys that contain path-like strings but aren't runtime
992+
// file dependencies (e.g., executionOrder.flowsOrder, flows glob patterns)
993+
const configOnlyKeys = new Set([
994+
'executionOrder',
995+
'flows',
996+
'tags',
997+
'includeTags',
998+
'excludeTags',
999+
'env',
1000+
]);
1001+
9041002
for (const [key, propValue] of Object.entries(obj)) {
905-
if (!handledKeys.has(key)) {
1003+
if (!handledKeys.has(key) && !configOnlyKeys.has(key)) {
9061004
const deps = await this.extractPathsFromValue(
9071005
propValue,
9081006
flowFile,
@@ -1101,8 +1199,19 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
11011199
}
11021200

11031201
// Recursively check remaining properties
1202+
// Skip config-only keys that contain path-like strings but aren't runtime
1203+
// file dependencies (e.g., executionOrder.flowsOrder, flows glob patterns)
1204+
const configOnlyKeys = new Set([
1205+
'executionOrder',
1206+
'flows',
1207+
'tags',
1208+
'includeTags',
1209+
'excludeTags',
1210+
'env',
1211+
]);
1212+
11041213
for (const [key, propValue] of Object.entries(obj)) {
1105-
if (!handledKeys.has(key)) {
1214+
if (!handledKeys.has(key) && !configOnlyKeys.has(key)) {
11061215
const nestedMissing = await this.findMissingInValue(
11071216
propValue,
11081217
flowFile,

tests/providers/maestro.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4402,4 +4402,101 @@ onFlowStart:
44024402
expect(missingRefs[1].flowFile).toBe(flowPath2);
44034403
});
44044404
});
4405+
4406+
describe('findMaestroProjectRoot', () => {
4407+
beforeEach(() => {
4408+
jest.clearAllMocks();
4409+
});
4410+
4411+
it('should find config.yaml in ancestor directory', async () => {
4412+
const flowFile = path.resolve(
4413+
path.sep,
4414+
'project',
4415+
'e2e',
4416+
'flows',
4417+
'auth',
4418+
'ch-sign-up.yaml',
4419+
);
4420+
// config.yaml exists at /project/e2e/config.yaml
4421+
fs.promises.access = jest.fn().mockImplementation((p: string) => {
4422+
if (
4423+
p === path.resolve(path.sep, 'project', 'e2e', 'config.yaml')
4424+
) {
4425+
return Promise.resolve();
4426+
}
4427+
return Promise.reject(new Error('ENOENT'));
4428+
});
4429+
4430+
const result = await maestro['findMaestroProjectRoot']([flowFile]);
4431+
expect(result).not.toBeNull();
4432+
expect(result!.dir).toBe(
4433+
path.resolve(path.sep, 'project', 'e2e'),
4434+
);
4435+
expect(result!.configPath).toBe(
4436+
path.resolve(path.sep, 'project', 'e2e', 'config.yaml'),
4437+
);
4438+
});
4439+
4440+
it('should return null when no config.yaml exists in ancestors', async () => {
4441+
const flowFile = path.resolve(
4442+
path.sep,
4443+
'project',
4444+
'flows',
4445+
'test.yaml',
4446+
);
4447+
fs.promises.access = jest
4448+
.fn()
4449+
.mockRejectedValue(new Error('ENOENT'));
4450+
4451+
const result = await maestro['findMaestroProjectRoot']([flowFile]);
4452+
expect(result).toBeNull();
4453+
});
4454+
4455+
it('should prefer config.yaml over config.yml', async () => {
4456+
const flowFile = path.resolve(
4457+
path.sep,
4458+
'project',
4459+
'e2e',
4460+
'flows',
4461+
'test.yaml',
4462+
);
4463+
fs.promises.access = jest.fn().mockImplementation((p: string) => {
4464+
if (
4465+
p === path.resolve(path.sep, 'project', 'e2e', 'config.yaml')
4466+
) {
4467+
return Promise.resolve();
4468+
}
4469+
return Promise.reject(new Error('ENOENT'));
4470+
});
4471+
4472+
const result = await maestro['findMaestroProjectRoot']([flowFile]);
4473+
expect(result!.configPath).toBe(
4474+
path.resolve(path.sep, 'project', 'e2e', 'config.yaml'),
4475+
);
4476+
});
4477+
4478+
it('should find config.yml when config.yaml does not exist', async () => {
4479+
const flowFile = path.resolve(
4480+
path.sep,
4481+
'project',
4482+
'e2e',
4483+
'flows',
4484+
'test.yaml',
4485+
);
4486+
fs.promises.access = jest.fn().mockImplementation((p: string) => {
4487+
if (
4488+
p === path.resolve(path.sep, 'project', 'e2e', 'config.yml')
4489+
) {
4490+
return Promise.resolve();
4491+
}
4492+
return Promise.reject(new Error('ENOENT'));
4493+
});
4494+
4495+
const result = await maestro['findMaestroProjectRoot']([flowFile]);
4496+
expect(result).not.toBeNull();
4497+
expect(result!.configPath).toBe(
4498+
path.resolve(path.sep, 'project', 'e2e', 'config.yml'),
4499+
);
4500+
});
4501+
});
44054502
});

0 commit comments

Comments
 (0)