Skip to content

Commit ecf35c7

Browse files
process include/exclude tags from config
1 parent cd54661 commit ecf35c7

2 files changed

Lines changed: 179 additions & 18 deletions

File tree

src/providers/maestro.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,19 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
551551

552552
// Apply --include-tags / --exclude-tags filtering: drop flow files whose
553553
// frontmatter tags don't match, and drop dependencies orphaned as a result.
554-
const filtered = await this.filterFlowsByTags(allFlowFiles, baseDir);
554+
const configTags = baseDir
555+
? await this.loadConfigTags(baseDir)
556+
: { includeTags: undefined, excludeTags: undefined };
557+
const effectiveIncludeTags =
558+
this.options.includeTags ?? configTags.includeTags;
559+
const effectiveExcludeTags =
560+
this.options.excludeTags ?? configTags.excludeTags;
561+
const filtered = await this.filterFlowsByTags(
562+
allFlowFiles,
563+
baseDir,
564+
effectiveIncludeTags,
565+
effectiveExcludeTags,
566+
);
555567
if (filtered !== allFlowFiles) {
556568
allFlowFiles.length = 0;
557569
allFlowFiles.push(...filtered);
@@ -779,12 +791,48 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
779791
return [];
780792
}
781793

794+
/**
795+
* Load includeTags / excludeTags declared in the Maestro project's
796+
* config.yaml (or config.yml). Returns undefined fields when no config
797+
* exists or the values are not arrays.
798+
*/
799+
private async loadConfigTags(
800+
baseDir: string,
801+
): Promise<{ includeTags?: string[]; excludeTags?: string[] }> {
802+
for (const configName of ['config.yaml', 'config.yml']) {
803+
const candidate = path.join(baseDir, configName);
804+
try {
805+
const content = await fs.promises.readFile(candidate, 'utf-8');
806+
const parsed = yaml.load(content) as MaestroConfig | null;
807+
if (parsed && typeof parsed === 'object') {
808+
const include = Array.isArray(parsed.includeTags)
809+
? parsed.includeTags.filter(
810+
(t): t is string => typeof t === 'string',
811+
)
812+
: undefined;
813+
const exclude = Array.isArray(parsed.excludeTags)
814+
? parsed.excludeTags.filter(
815+
(t): t is string => typeof t === 'string',
816+
)
817+
: undefined;
818+
return { includeTags: include, excludeTags: exclude };
819+
}
820+
return {};
821+
} catch {
822+
// try next candidate
823+
}
824+
}
825+
return {};
826+
}
827+
782828
private async filterFlowsByTags(
783829
allFlowFiles: string[],
784830
baseDir: string | undefined,
831+
includeTagsArg?: string[],
832+
excludeTagsArg?: string[],
785833
): Promise<string[]> {
786-
const includeTags = this.options.includeTags ?? [];
787-
const excludeTags = this.options.excludeTags ?? [];
834+
const includeTags = includeTagsArg ?? [];
835+
const excludeTags = excludeTagsArg ?? [];
788836
const hasInclude = includeTags.length > 0;
789837
const hasExclude = excludeTags.length > 0;
790838
if (!hasInclude && !hasExclude) {

tests/providers/maestro.test.ts

Lines changed: 128 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3026,15 +3026,12 @@ flows:
30263026
[flowWithOrphanDep]: `appId: com.example\ntags:\n - flaky\n---\n- runScript: orphan.js`,
30273027
};
30283028

3029-
const buildMaestro = (
3030-
includeTags?: string[],
3031-
excludeTags?: string[],
3032-
): Maestro => {
3029+
const buildMaestro = (): Maestro => {
30333030
const opts = new MaestroOptions(
30343031
'app.apk',
30353032
path.join(projectDir, 'smoke.yaml'),
30363033
'Pixel 6',
3037-
{ includeTags, excludeTags, quiet: true },
3034+
{ quiet: true },
30383035
);
30393036
return new Maestro(mockCredentials, opts);
30403037
};
@@ -3057,49 +3054,56 @@ flows:
30573054
expect(result).toBe(input);
30583055
});
30593056

3060-
it('keeps only flows matching --include-tags', async () => {
3061-
const m = buildMaestro(['smoke']);
3057+
it('keeps only flows matching include tags', async () => {
3058+
const m = buildMaestro();
30623059
const result = await m['filterFlowsByTags'](
30633060
[smokeFlow, flakyFlow, untaggedFlow],
30643061
projectDir,
3062+
['smoke'],
30653063
);
30663064
expect(result).toEqual([smokeFlow]);
30673065
});
30683066

3069-
it('drops flows matching --exclude-tags', async () => {
3070-
const m = buildMaestro(undefined, ['flaky']);
3067+
it('drops flows matching exclude tags', async () => {
3068+
const m = buildMaestro();
30713069
const result = await m['filterFlowsByTags'](
30723070
[smokeFlow, flakyFlow, untaggedFlow],
30733071
projectDir,
3072+
undefined,
3073+
['flaky'],
30743074
);
30753075
expect(result).toEqual([smokeFlow, untaggedFlow]);
30763076
});
30773077

3078-
it('combines --include-tags and --exclude-tags', async () => {
3079-
const m = buildMaestro(['smoke'], ['flaky']);
3078+
it('combines include and exclude tags', async () => {
3079+
const m = buildMaestro();
30803080
const result = await m['filterFlowsByTags'](
30813081
[smokeFlow, flakyFlow, untaggedFlow],
30823082
projectDir,
3083+
['smoke'],
3084+
['flaky'],
30833085
);
30843086
expect(result).toEqual([smokeFlow]);
30853087
});
30863088

30873089
it('always preserves config files', async () => {
3088-
const m = buildMaestro(['smoke']);
3090+
const m = buildMaestro();
30893091
const result = await m['filterFlowsByTags'](
30903092
[smokeFlow, flakyFlow, configFile],
30913093
projectDir,
3094+
['smoke'],
30923095
);
30933096
expect(result).toContain(configFile);
30943097
expect(result).toContain(smokeFlow);
30953098
expect(result).not.toContain(flakyFlow);
30963099
});
30973100

30983101
it('drops dependencies orphaned by filtered-out flows', async () => {
3099-
const m = buildMaestro(['smoke']);
3102+
const m = buildMaestro();
31003103
const result = await m['filterFlowsByTags'](
31013104
[flowWithDep, flowWithOrphanDep, helperScript, orphanScript],
31023105
projectDir,
3106+
['smoke'],
31033107
);
31043108
expect(result).toContain(flowWithDep);
31053109
expect(result).toContain(helperScript);
@@ -3108,13 +3112,122 @@ flows:
31083112
});
31093113

31103114
it('throws TestingBotError when no flows match the filters', async () => {
3111-
const m = buildMaestro(['nonexistent']);
3115+
const m = buildMaestro();
31123116
await expect(
3113-
m['filterFlowsByTags']([smokeFlow, flakyFlow], projectDir),
3117+
m['filterFlowsByTags'](
3118+
[smokeFlow, flakyFlow],
3119+
projectDir,
3120+
['nonexistent'],
3121+
),
31143122
).rejects.toThrow(TestingBotError);
31153123
});
31163124
});
31173125

3126+
describe('loadConfigTags', () => {
3127+
const projectDir = path.resolve(path.sep, 'project');
3128+
const buildMaestro = () =>
3129+
new Maestro(
3130+
mockCredentials,
3131+
new MaestroOptions('app.apk', projectDir, 'Pixel 6', { quiet: true }),
3132+
);
3133+
3134+
it('returns includeTags / excludeTags from config.yaml', async () => {
3135+
fs.promises.readFile = jest
3136+
.fn()
3137+
.mockResolvedValueOnce(
3138+
`flows:\n - "*.yaml"\nincludeTags:\n - smoke\nexcludeTags:\n - flaky`,
3139+
);
3140+
const m = buildMaestro();
3141+
const tags = await m['loadConfigTags'](projectDir);
3142+
expect(tags.includeTags).toEqual(['smoke']);
3143+
expect(tags.excludeTags).toEqual(['flaky']);
3144+
});
3145+
3146+
it('falls back to config.yml when config.yaml is missing', async () => {
3147+
fs.promises.readFile = jest
3148+
.fn()
3149+
.mockRejectedValueOnce(new Error('ENOENT'))
3150+
.mockResolvedValueOnce(`includeTags:\n - smoke`);
3151+
const m = buildMaestro();
3152+
const tags = await m['loadConfigTags'](projectDir);
3153+
expect(tags.includeTags).toEqual(['smoke']);
3154+
expect(tags.excludeTags).toBeUndefined();
3155+
});
3156+
3157+
it('returns empty object when no config exists', async () => {
3158+
fs.promises.readFile = jest
3159+
.fn()
3160+
.mockRejectedValue(new Error('ENOENT'));
3161+
const m = buildMaestro();
3162+
const tags = await m['loadConfigTags'](projectDir);
3163+
expect(tags).toEqual({});
3164+
});
3165+
});
3166+
3167+
describe('collectFlows tag precedence', () => {
3168+
const projectDir = path.resolve(path.sep, 'project');
3169+
const smokeFlow = path.join(projectDir, 'smoke.yaml');
3170+
const flakyFlow = path.join(projectDir, 'flaky.yaml');
3171+
const configFile = path.join(projectDir, 'config.yaml');
3172+
3173+
const yamlByPath: Record<string, string> = {
3174+
[smokeFlow]: `appId: com.example\ntags:\n - smoke\n---\n- launchApp`,
3175+
[flakyFlow]: `appId: com.example\ntags:\n - flaky\n---\n- launchApp`,
3176+
[configFile]: `includeTags:\n - smoke`,
3177+
};
3178+
3179+
const setupFs = () => {
3180+
fs.promises.stat = jest.fn().mockImplementation((p: string) => {
3181+
if (p === projectDir) {
3182+
return Promise.resolve({
3183+
isFile: () => false,
3184+
isDirectory: () => true,
3185+
});
3186+
}
3187+
return Promise.reject(new Error(`ENOENT: ${p}`));
3188+
});
3189+
fs.promises.readdir = jest.fn().mockResolvedValue([
3190+
{ name: 'smoke.yaml', isFile: () => true },
3191+
{ name: 'flaky.yaml', isFile: () => true },
3192+
{ name: 'config.yaml', isFile: () => true },
3193+
]);
3194+
fs.promises.readFile = jest
3195+
.fn()
3196+
.mockImplementation((p: string) =>
3197+
yamlByPath[p] !== undefined
3198+
? Promise.resolve(yamlByPath[p])
3199+
: Promise.reject(new Error(`ENOENT: ${p}`)),
3200+
);
3201+
fs.promises.access = jest.fn().mockResolvedValue(undefined);
3202+
};
3203+
3204+
it('uses includeTags from config.yaml when CLI flag is absent', async () => {
3205+
setupFs();
3206+
const opts = new MaestroOptions('app.apk', projectDir, 'Pixel 6', {
3207+
quiet: true,
3208+
});
3209+
const m = new Maestro(mockCredentials, opts);
3210+
const result = await m.collectFlows();
3211+
expect(result).not.toBeNull();
3212+
expect(result!.allFlowFiles).toContain(smokeFlow);
3213+
expect(result!.allFlowFiles).not.toContain(flakyFlow);
3214+
expect(result!.allFlowFiles).toContain(configFile);
3215+
});
3216+
3217+
it('CLI --include-tags overrides config.yaml includeTags', async () => {
3218+
setupFs();
3219+
const opts = new MaestroOptions('app.apk', projectDir, 'Pixel 6', {
3220+
includeTags: ['flaky'],
3221+
quiet: true,
3222+
});
3223+
const m = new Maestro(mockCredentials, opts);
3224+
const result = await m.collectFlows();
3225+
expect(result).not.toBeNull();
3226+
expect(result!.allFlowFiles).toContain(flakyFlow);
3227+
expect(result!.allFlowFiles).not.toContain(smokeFlow);
3228+
});
3229+
});
3230+
31183231
describe('Flow Status Display', () => {
31193232
describe('getFlowStatusDisplay', () => {
31203233
it('should return white WAITING for WAITING status', () => {

0 commit comments

Comments
 (0)