diff --git a/bin/utils/config/parser.ts b/bin/utils/config/parser.ts index 7ec1c1a..73ff14c 100644 --- a/bin/utils/config/parser.ts +++ b/bin/utils/config/parser.ts @@ -1,6 +1,8 @@ import z from 'zod'; import { CONFIG_FILE_STRATEGY, type ResolvedActorConfig, type StrategyParser } from './structures/base.js'; +import { GLOBS_PARSER } from './structures/globs.js'; +import { GROUPED_PARSER } from './structures/grouped.js'; import { LEGACY_PARSER } from './structures/legacy.js'; const CONFIG_FILE_STRATEGIES: { @@ -8,9 +10,11 @@ const CONFIG_FILE_STRATEGIES: { [key in CONFIG_FILE_STRATEGY]: StrategyParser & { mode: key }; } = { [CONFIG_FILE_STRATEGY.LEGACY]: LEGACY_PARSER, + [CONFIG_FILE_STRATEGY.GROUPED]: GROUPED_PARSER, + [CONFIG_FILE_STRATEGY.GLOBS]: GLOBS_PARSER, } as const; -const ModeSelectionSchema = z.enum(CONFIG_FILE_STRATEGY).default(CONFIG_FILE_STRATEGY.LEGACY); +const ModeSelectionSchema = z.object({ mode: z.enum(CONFIG_FILE_STRATEGY).default(CONFIG_FILE_STRATEGY.LEGACY) }); function selectStrategy(mode: unknown): StrategyParser { const parsed = ModeSelectionSchema.safeParse(mode); @@ -18,7 +22,7 @@ function selectStrategy(mode: unknown): StrategyParser { throw new Error(z.prettifyError(parsed.error)); } - return CONFIG_FILE_STRATEGIES[parsed.data]; + return CONFIG_FILE_STRATEGIES[parsed.data.mode]; } // Strips a trailing slash so config-declared paths ("actors/shopify/" vs "actors/shopify") compare equal. @@ -79,10 +83,9 @@ export const _privates = { }; export function parseConfigFile(body: Record): ResolvedActorConfig[] { - const { mode, ...rest } = body; - const strategy = selectStrategy(mode); + const strategy = selectStrategy(body); - const resolved = strategy.parse(rest); + const resolved = strategy.parse(body); const validated = verifyConfiguration(resolved); return validated; } diff --git a/bin/utils/config/structures/base.ts b/bin/utils/config/structures/base.ts index afe8264..d0f6ada 100644 --- a/bin/utils/config/structures/base.ts +++ b/bin/utils/config/structures/base.ts @@ -20,6 +20,8 @@ export interface ResolvedActorConfig { export enum CONFIG_FILE_STRATEGY { LEGACY = 'legacy', + GROUPED = 'grouped', + GLOBS = 'globs', } export type StrategyParser = { diff --git a/bin/utils/config/structures/globs.ts b/bin/utils/config/structures/globs.ts new file mode 100644 index 0000000..d37e3f7 --- /dev/null +++ b/bin/utils/config/structures/globs.ts @@ -0,0 +1,97 @@ +import { minimatch } from 'minimatch'; +import z from 'zod'; + +import { ACTOR_FULL_NAME_REGEX, CONFIG_FILE_STRATEGY, defineStrategy, type ResolvedActorConfig } from './base.js'; + +const SETTING_KEYS = ['tokenEnvVar', 'overrideActorContext'] as const; + +const schema = z.object({ + actors: z + .array( + z.object({ + folder: z.string(), + actorFullName: z.string().regex(ACTOR_FULL_NAME_REGEX), + tokenEnvVar: z.string().optional(), + overrideActorContext: z.array(z.string()).optional(), + }), + ) + .min(1), + configs: z + .array( + z.object({ + match: z + .object({ + folderGlob: z.string().optional(), + actorFullNameGlob: z.string().optional(), + }) + .refine((data) => Object.keys(data).length > 0, 'Invalid input: At least one glob is required.'), + + set: z + .object({ + tokenEnvVar: z.string().optional(), + overrideActorContext: z.array(z.string()).optional(), + }) + .refine((data) => Object.keys(data).length > 0, 'Invalid input: At least one setting is required.'), + }), + ) + .min(1), +}); + +type GlobsConfig = z.infer; +type Actor = GlobsConfig['actors'][number]; +type Match = GlobsConfig['configs'][number]['match']; + +// folders are matched as written (no normalization) and dotfolders are skipped +const matches = (actor: Actor, { folderGlob, actorFullNameGlob }: Match) => + (folderGlob === undefined || minimatch(actor.folder, folderGlob)) && + (actorFullNameGlob === undefined || minimatch(actor.actorFullName, actorFullNameGlob)); + +export const GLOBS_PARSER = defineStrategy(CONFIG_FILE_STRATEGY.GLOBS, schema, (body: GlobsConfig) => { + const errors: string[] = []; + + const resolved = body.actors.flatMap((actor): ResolvedActorConfig[] => { + const result = { ...actor }; + // keep tracks of who set the key, the actor definition or the index of the config + const owners = new Map<(typeof SETTING_KEYS)[number], 'actor' | number>(); + for (const key of SETTING_KEYS) { + if (actor[key] !== undefined) owners.set(key, 'actor'); + } + + for (const [index, config] of body.configs.entries()) { + if (!matches(actor, config.match)) continue; + + for (const key of SETTING_KEYS) { + if (config.set[key] === undefined) continue; + + const owner = owners.get(key); + if (owner === undefined) { + owners.set(key, index); + Object.assign(result, { [key]: config.set[key] }); + } else if (owner === 'actor') { + errors.push( + `Actor "${actor.actorFullName}": "${key}" is set on the actor and by configs[${index}].`, + ); + } else { + errors.push( + `Actor "${actor.actorFullName}": "${key}" is set by both configs[${owner}] and configs[${index}].`, + ); + } + } + } + + const { tokenEnvVar } = result; + if (tokenEnvVar === undefined) { + errors.push( + `Actor "${actor.actorFullName}" has no tokenEnvVar: set it on the actor or through a matching config.`, + ); + return []; + } + return [{ ...result, tokenEnvVar }]; + }); + + if (errors.length > 0) { + throw new Error(errors.join('\n')); + } + + return resolved; +}); diff --git a/bin/utils/config/structures/grouped.ts b/bin/utils/config/structures/grouped.ts new file mode 100644 index 0000000..c518909 --- /dev/null +++ b/bin/utils/config/structures/grouped.ts @@ -0,0 +1,40 @@ +import z from 'zod'; + +import { ACTOR_FULL_NAME_REGEX, CONFIG_FILE_STRATEGY, defineStrategy } from './base.js'; + +const schema = z.object({ + groups: z + .record( + z.string(), + z.object({ + actors: z + .array( + z.object({ + folder: z.string(), + actorFullName: z.string().regex(ACTOR_FULL_NAME_REGEX), + // these allow overrides from stuff set at the group level + tokenEnvVar: z.string().optional(), + overrideActorContext: z.array(z.string()).optional(), + }), + ) + .min(1), + tokenEnvVar: z.string(), + overrideActorContext: z.array(z.string()).optional(), + }), + ) + .refine((data) => Object.keys(data).length > 0, 'Invalid input: At least one configuration group is required.'), +}); + +type GroupedConfig = z.infer; + +export const GROUPED_PARSER = defineStrategy(CONFIG_FILE_STRATEGY.GROUPED, schema, (body: GroupedConfig) => { + const entries = Object.entries(body.groups); + return entries.flatMap(([_folder, entry]) => + entry.actors.map((actor) => ({ + tokenEnvVar: entry.tokenEnvVar, + overrideActorContext: entry.overrideActorContext, + // last so actor can override stuff from the group + ...actor, + })), + ); +}); diff --git a/package-lock.json b/package-lock.json index 7e9c4eb..801d5b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@slack/web-api": "^7.9.2", "apify-client": "^2.22.2", "ignore": "^7.0.5", + "minimatch": "^10.2.6", "yargs": "^18.0.0", "zod": "^4.6.5" }, @@ -1824,29 +1825,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", @@ -1860,22 +1838,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/type-utils/node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -1991,29 +1953,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/utils/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", @@ -2027,22 +1966,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@typescript-eslint/utils/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils/node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2512,6 +2435,27 @@ "node": ">=10.0.0" } }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -5210,6 +5154,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -7006,29 +6965,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/typescript-eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/typescript-eslint/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", @@ -7042,22 +6978,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/typescript-eslint/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/typescript-eslint/node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", diff --git a/package.json b/package.json index 84f1293..2468d74 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@slack/web-api": "^7.9.2", "apify-client": "^2.22.2", "ignore": "^7.0.5", + "minimatch": "^10.2.6", "yargs": "^18.0.0", "zod": "^4.6.5" }, diff --git a/test/unit/bin/utils/config/parser.test.ts b/test/unit/bin/utils/config/parser.test.ts index 5daa7bf..e0ca011 100644 --- a/test/unit/bin/utils/config/parser.test.ts +++ b/test/unit/bin/utils/config/parser.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { _privates, parseConfigFile } from '../../../../../bin/utils/config/parser.js'; import { CONFIG_FILE_STRATEGY } from '../../../../../bin/utils/config/structures/base.js'; +import { GLOBS_PARSER } from '../../../../../bin/utils/config/structures/globs.js'; +import { GROUPED_PARSER } from '../../../../../bin/utils/config/structures/grouped.js'; import { LEGACY_PARSER } from '../../../../../bin/utils/config/structures/legacy.js'; const { selectStrategy, verifyConfiguration } = _privates; @@ -15,15 +17,19 @@ const actor = (fields: Record = {}) => ({ describe('selectStrategy', () => { it('falls back to the legacy strategy when no mode is declared', () => { - expect(selectStrategy(undefined)).toBe(LEGACY_PARSER); + expect(selectStrategy({})).toBe(LEGACY_PARSER); }); - it('honours an explicit mode', () => { - expect(selectStrategy(CONFIG_FILE_STRATEGY.LEGACY)).toBe(LEGACY_PARSER); + it.each([ + [CONFIG_FILE_STRATEGY.LEGACY, LEGACY_PARSER], + [CONFIG_FILE_STRATEGY.GROUPED, GROUPED_PARSER], + [CONFIG_FILE_STRATEGY.GLOBS, GLOBS_PARSER], + ])('honours an explicit "%s" mode', (mode, expected) => { + expect(selectStrategy({ mode })).toBe(expected); }); it('throws on a mode no strategy is registered for', () => { - expect(() => selectStrategy('some-nonexistent-mode')).toThrow(/Invalid input/); + expect(() => selectStrategy('some-nonexistent-mode')).toThrow(); }); }); @@ -124,6 +130,22 @@ describe('parseConfigFile', () => { ]); }); + it('parses a grouped config file, which needs mode to be stripped', () => { + expect( + parseConfigFile({ + mode: CONFIG_FILE_STRATEGY.GROUPED, + groups: { + myteam: { + actors: [{ folder: 'actors/shopify/', actorFullName: 'myteam/shopify' }], + tokenEnvVar: 'APIFY_TOKEN', + }, + }, + }), + ).toEqual([ + actor({ folder: 'actors/shopify', actorFullName: 'myteam/shopify', overrideActorContext: undefined }), + ]); + }); + it('surfaces cross-entry violations from verifyConfiguration', () => { expect(() => parseConfigFile({ diff --git a/test/unit/bin/utils/config/structures/globs.test.ts b/test/unit/bin/utils/config/structures/globs.test.ts new file mode 100644 index 0000000..5685930 --- /dev/null +++ b/test/unit/bin/utils/config/structures/globs.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { GLOBS_PARSER } from '../../../../../../bin/utils/config/structures/globs.js'; + +const actor = (fields: Record = {}) => ({ + folder: 'actors/shopify', + actorFullName: 'myteam/shopify', + ...fields, +}); + +const config = (fields: Record = {}) => ({ + match: { folderGlob: 'actors/*' }, + set: { tokenEnvVar: 'APIFY_TOKEN' }, + ...fields, +}); + +const errorMessage = (body: Record) => { + try { + GLOBS_PARSER.parse(body); + throw new Error('Function should have thrown'); + } catch (err) { + return (err as Error).message; + } +}; + +describe('GLOBS_PARSER', () => { + it('applies the settings of every matching config, leaving other actors untouched', () => { + expect( + GLOBS_PARSER.parse({ + actors: [actor(), actor({ folder: 'other/email', actorFullName: 'other/email', tokenEnvVar: 'TOKEN' })], + configs: [config(), config({ set: { overrideActorContext: ['packages'] } })], + }), + ).toEqual([ + { + folder: 'actors/shopify', + actorFullName: 'myteam/shopify', + tokenEnvVar: 'APIFY_TOKEN', + overrideActorContext: ['packages'], + }, + { folder: 'other/email', actorFullName: 'other/email', tokenEnvVar: 'TOKEN' }, + ]); + }); + + it('requires both globs to match if both are specified', () => { + expect( + GLOBS_PARSER.parse({ + actors: [ + actor({ tokenEnvVar: 'TOKEN' }), + actor({ folder: 'actors/email', actorFullName: 'other/email' }), + ], + configs: [config({ match: { folderGlob: 'actors/*', actorFullNameGlob: 'other/*' } })], + }), + ).toEqual([ + { folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'TOKEN' }, + { folder: 'actors/email', actorFullName: 'other/email', tokenEnvVar: 'APIFY_TOKEN' }, + ]); + }); + + it('rejects two configs setting the same key, even to the same value', () => { + expect(errorMessage({ actors: [actor()], configs: [config(), config(), config()] }).split('\n')).toEqual([ + 'Actor "myteam/shopify": "tokenEnvVar" is set by both configs[0] and configs[1].', + 'Actor "myteam/shopify": "tokenEnvVar" is set by both configs[0] and configs[2].', + ]); + }); + + it('rejects a config setting a key the actor already defines', () => { + expect(() => + GLOBS_PARSER.parse({ actors: [actor({ tokenEnvVar: 'APIFY_TOKEN' })], configs: [config()] }), + ).toThrow('Actor "myteam/shopify": "tokenEnvVar" is set on the actor and by configs[0].'); + }); + + it('rejects an actor left without a tokenEnvVar', () => { + expect(() => + GLOBS_PARSER.parse({ actors: [actor()], configs: [config({ set: { overrideActorContext: ['shared'] } })] }), + ).toThrow('Actor "myteam/shopify" has no tokenEnvVar: set it on the actor or through a matching config.'); + }); + + it('reports problems from every actor at once instead of only the first', () => { + expect( + errorMessage({ + actors: [ + actor({ overrideActorContext: ['packages'] }), + actor({ folder: 'other/email', actorFullName: 'other/email' }), + ], + configs: [config({ set: { tokenEnvVar: 'APIFY_TOKEN', overrideActorContext: ['packages'] } })], + }).split('\n'), + ).toEqual([ + 'Actor "myteam/shopify": "overrideActorContext" is set on the actor and by configs[0].', + 'Actor "other/email" has no tokenEnvVar: set it on the actor or through a matching config.', + ]); + }); + + it.each([ + ['match', { match: {} }], + ['set', { set: {} }], + ])('rejects a config with an empty %s', (_name, fields) => { + expect(() => GLOBS_PARSER.parse({ actors: [actor()], configs: [config(fields)] })).toThrow(); + }); +}); diff --git a/test/unit/bin/utils/config/structures/grouped.test.ts b/test/unit/bin/utils/config/structures/grouped.test.ts new file mode 100644 index 0000000..5a2767c --- /dev/null +++ b/test/unit/bin/utils/config/structures/grouped.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { GROUPED_PARSER } from '../../../../../../bin/utils/config/structures/grouped.js'; + +const actor = (fields: Record = {}) => ({ + folder: 'actors/shopify', + actorFullName: 'myteam/shopify', + ...fields, +}); + +const group = (fields: Record = {}) => ({ + actors: [actor()], + tokenEnvVar: 'APIFY_TOKEN', + ...fields, +}); + +describe('GROUPED_PARSER', () => { + it('flattens every group into one list, handing each actor its group’s settings', () => { + expect( + GROUPED_PARSER.parse({ + groups: { + myteam: { + actors: [actor(), actor({ folder: 'actors/email', actorFullName: 'myteam/email' })], + tokenEnvVar: 'APIFY_TOKEN_MYTEAM', + overrideActorContext: ['packages'], + }, + other: { actors: [actor({ folder: 'actors/x', actorFullName: 'other/x' })], tokenEnvVar: 'TOKEN' }, + }, + }), + ).toEqual([ + { + folder: 'actors/shopify', + actorFullName: 'myteam/shopify', + tokenEnvVar: 'APIFY_TOKEN_MYTEAM', + overrideActorContext: ['packages'], + }, + { + folder: 'actors/email', + actorFullName: 'myteam/email', + tokenEnvVar: 'APIFY_TOKEN_MYTEAM', + overrideActorContext: ['packages'], + }, + { folder: 'actors/x', actorFullName: 'other/x', tokenEnvVar: 'TOKEN', overrideActorContext: undefined }, + ]); + }); + + it('ignores the group key entirely', () => { + expect(GROUPED_PARSER.parse({ groups: { 'not-a-folder-at-all': group() } })).toEqual([ + { folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'APIFY_TOKEN' }, + ]); + }); + + it('allows actors to override some fields', () => { + expect( + GROUPED_PARSER.parse({ groups: { myteam: group({ actors: [actor({ tokenEnvVar: 'FROM_ACTOR' })] }) } }), + ).toEqual([{ folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'FROM_ACTOR' }]); + }); + + it("returns the entries as written — normalizing is not this layer's job", () => { + const raw = actor({ folder: 'actors/shopify/' }); + + expect( + GROUPED_PARSER.parse({ groups: { myteam: group({ actors: [raw], overrideActorContext: ['packages/'] }) } }), + ).toEqual([{ ...raw, tokenEnvVar: 'APIFY_TOKEN', overrideActorContext: ['packages/'] }]); + }); + + it('rejects a config with no groups (and thus no actors)', () => { + expect(() => GROUPED_PARSER.parse({})).toThrow(); + }); + + it('reports problems from every group at once instead of only the first', () => { + const message = (() => { + try { + GROUPED_PARSER.parse({ + groups: { + myteam: { actors: [actor({ folder: 123 })], tokenEnvVar: 'APIFY_TOKEN' }, + other: { actors: [actor({ actorFullName: 'nope' })] }, + }, + }); + throw new Error('Function should have thrown'); + } catch (err) { + return (err as Error).message; + } + })(); + + expect(message).toContain('myteam.actors[0].folder'); + expect(message).toContain('other.actors[0].actorFullName'); + expect(message).toContain('other.tokenEnvVar'); + }); +});