Skip to content

Commit cd54661

Browse files
improve --include-tags and --exclude-tags
1 parent 49ae9bd commit cd54661

2 files changed

Lines changed: 196 additions & 0 deletions

File tree

src/providers/maestro.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,14 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
549549
}
550550
}
551551

552+
// Apply --include-tags / --exclude-tags filtering: drop flow files whose
553+
// frontmatter tags don't match, and drop dependencies orphaned as a result.
554+
const filtered = await this.filterFlowsByTags(allFlowFiles, baseDir);
555+
if (filtered !== allFlowFiles) {
556+
allFlowFiles.length = 0;
557+
allFlowFiles.push(...filtered);
558+
}
559+
552560
if (!this.options.quiet) {
553561
this.logIncludedFiles(allFlowFiles, baseDir);
554562

@@ -751,6 +759,85 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
751759
return basename === 'config.yaml' || basename === 'config.yml';
752760
}
753761

762+
private async readFlowTags(flowFile: string): Promise<string[]> {
763+
try {
764+
const content = await fs.promises.readFile(flowFile, 'utf-8');
765+
const documents: unknown[] = [];
766+
yaml.loadAll(content, (doc) => documents.push(doc));
767+
for (const doc of documents) {
768+
if (doc !== null && typeof doc === 'object' && !Array.isArray(doc)) {
769+
const tags = (doc as Record<string, unknown>).tags;
770+
if (Array.isArray(tags)) {
771+
return tags.filter((t): t is string => typeof t === 'string');
772+
}
773+
return [];
774+
}
775+
}
776+
} catch {
777+
// ignore
778+
}
779+
return [];
780+
}
781+
782+
private async filterFlowsByTags(
783+
allFlowFiles: string[],
784+
baseDir: string | undefined,
785+
): Promise<string[]> {
786+
const includeTags = this.options.includeTags ?? [];
787+
const excludeTags = this.options.excludeTags ?? [];
788+
const hasInclude = includeTags.length > 0;
789+
const hasExclude = excludeTags.length > 0;
790+
if (!hasInclude && !hasExclude) {
791+
return allFlowFiles;
792+
}
793+
794+
const yamlFlows: string[] = [];
795+
const configFiles: string[] = [];
796+
for (const f of allFlowFiles) {
797+
const ext = path.extname(f).toLowerCase();
798+
if (ext === '.yaml' || ext === '.yml') {
799+
if (this.isConfigFile(f)) {
800+
configFiles.push(f);
801+
} else {
802+
yamlFlows.push(f);
803+
}
804+
}
805+
}
806+
807+
const keptYamlFlows: string[] = [];
808+
for (const flowFile of yamlFlows) {
809+
const tags = await this.readFlowTags(flowFile);
810+
if (hasInclude && !tags.some((t) => includeTags.includes(t))) {
811+
continue;
812+
}
813+
if (hasExclude && tags.some((t) => excludeTags.includes(t))) {
814+
continue;
815+
}
816+
keptYamlFlows.push(flowFile);
817+
}
818+
819+
if (keptYamlFlows.length === 0) {
820+
throw new TestingBotError(
821+
`No flow files match the provided tag filters (--include-tags / --exclude-tags)`,
822+
);
823+
}
824+
825+
const keptResolved = new Set<string>(
826+
[...keptYamlFlows, ...configFiles].map((f) => path.resolve(f)),
827+
);
828+
for (const flowFile of keptYamlFlows) {
829+
const deps = await this.discoverDependencies(
830+
flowFile,
831+
baseDir || path.dirname(flowFile),
832+
);
833+
for (const dep of deps) {
834+
keptResolved.add(path.resolve(dep));
835+
}
836+
}
837+
838+
return allFlowFiles.filter((f) => keptResolved.has(path.resolve(f)));
839+
}
840+
754841
/**
755842
* Check if a string looks like a file path (relative path with extension)
756843
*/

tests/providers/maestro.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3006,6 +3006,115 @@ flows:
30063006
});
30073007
});
30083008

3009+
describe('filterFlowsByTags', () => {
3010+
const projectDir = path.resolve(path.sep, 'project');
3011+
const smokeFlow = path.join(projectDir, 'smoke.yaml');
3012+
const flakyFlow = path.join(projectDir, 'flaky.yaml');
3013+
const untaggedFlow = path.join(projectDir, 'untagged.yaml');
3014+
const configFile = path.join(projectDir, 'config.yaml');
3015+
const helperScript = path.join(projectDir, 'helper.js');
3016+
const orphanScript = path.join(projectDir, 'orphan.js');
3017+
const flowWithDep = path.join(projectDir, 'with-dep.yaml');
3018+
const flowWithOrphanDep = path.join(projectDir, 'with-orphan.yaml');
3019+
3020+
const fileContents: Record<string, string> = {
3021+
[smokeFlow]: `appId: com.example\ntags:\n - smoke\n---\n- launchApp`,
3022+
[flakyFlow]: `appId: com.example\ntags:\n - flaky\n---\n- launchApp`,
3023+
[untaggedFlow]: `appId: com.example\n---\n- launchApp`,
3024+
[configFile]: `flows:\n - "*.yaml"`,
3025+
[flowWithDep]: `appId: com.example\ntags:\n - smoke\n---\n- runScript: helper.js`,
3026+
[flowWithOrphanDep]: `appId: com.example\ntags:\n - flaky\n---\n- runScript: orphan.js`,
3027+
};
3028+
3029+
const buildMaestro = (
3030+
includeTags?: string[],
3031+
excludeTags?: string[],
3032+
): Maestro => {
3033+
const opts = new MaestroOptions(
3034+
'app.apk',
3035+
path.join(projectDir, 'smoke.yaml'),
3036+
'Pixel 6',
3037+
{ includeTags, excludeTags, quiet: true },
3038+
);
3039+
return new Maestro(mockCredentials, opts);
3040+
};
3041+
3042+
beforeEach(() => {
3043+
fs.promises.readFile = jest
3044+
.fn()
3045+
.mockImplementation((p: string) =>
3046+
fileContents[p] !== undefined
3047+
? Promise.resolve(fileContents[p])
3048+
: Promise.reject(new Error(`ENOENT: ${p}`)),
3049+
);
3050+
fs.promises.access = jest.fn().mockResolvedValue(undefined);
3051+
});
3052+
3053+
it('returns input unchanged when no tag filters are set', async () => {
3054+
const m = buildMaestro();
3055+
const input = [smokeFlow, flakyFlow, untaggedFlow];
3056+
const result = await m['filterFlowsByTags'](input, projectDir);
3057+
expect(result).toBe(input);
3058+
});
3059+
3060+
it('keeps only flows matching --include-tags', async () => {
3061+
const m = buildMaestro(['smoke']);
3062+
const result = await m['filterFlowsByTags'](
3063+
[smokeFlow, flakyFlow, untaggedFlow],
3064+
projectDir,
3065+
);
3066+
expect(result).toEqual([smokeFlow]);
3067+
});
3068+
3069+
it('drops flows matching --exclude-tags', async () => {
3070+
const m = buildMaestro(undefined, ['flaky']);
3071+
const result = await m['filterFlowsByTags'](
3072+
[smokeFlow, flakyFlow, untaggedFlow],
3073+
projectDir,
3074+
);
3075+
expect(result).toEqual([smokeFlow, untaggedFlow]);
3076+
});
3077+
3078+
it('combines --include-tags and --exclude-tags', async () => {
3079+
const m = buildMaestro(['smoke'], ['flaky']);
3080+
const result = await m['filterFlowsByTags'](
3081+
[smokeFlow, flakyFlow, untaggedFlow],
3082+
projectDir,
3083+
);
3084+
expect(result).toEqual([smokeFlow]);
3085+
});
3086+
3087+
it('always preserves config files', async () => {
3088+
const m = buildMaestro(['smoke']);
3089+
const result = await m['filterFlowsByTags'](
3090+
[smokeFlow, flakyFlow, configFile],
3091+
projectDir,
3092+
);
3093+
expect(result).toContain(configFile);
3094+
expect(result).toContain(smokeFlow);
3095+
expect(result).not.toContain(flakyFlow);
3096+
});
3097+
3098+
it('drops dependencies orphaned by filtered-out flows', async () => {
3099+
const m = buildMaestro(['smoke']);
3100+
const result = await m['filterFlowsByTags'](
3101+
[flowWithDep, flowWithOrphanDep, helperScript, orphanScript],
3102+
projectDir,
3103+
);
3104+
expect(result).toContain(flowWithDep);
3105+
expect(result).toContain(helperScript);
3106+
expect(result).not.toContain(flowWithOrphanDep);
3107+
expect(result).not.toContain(orphanScript);
3108+
});
3109+
3110+
it('throws TestingBotError when no flows match the filters', async () => {
3111+
const m = buildMaestro(['nonexistent']);
3112+
await expect(
3113+
m['filterFlowsByTags']([smokeFlow, flakyFlow], projectDir),
3114+
).rejects.toThrow(TestingBotError);
3115+
});
3116+
});
3117+
30093118
describe('Flow Status Display', () => {
30103119
describe('getFlowStatusDisplay', () => {
30113120
it('should return white WAITING for WAITING status', () => {

0 commit comments

Comments
 (0)