diff --git a/bin/types.ts b/bin/types.ts index f4a1b96..7872dd8 100644 --- a/bin/types.ts +++ b/bin/types.ts @@ -90,12 +90,17 @@ export interface GithubCommit { export interface ActorConfigFileEntry { folder: string; actorFullName: string; - tokenEnvVar: string; + tokenEnvVar?: string; overrideActorContext?: string[]; } +export type ActorGlobConfigEntry = + | ({ folder: string; actorFullName?: never } & Partial>) + | ({ actorFullName: string; folder?: never } & Partial>); + export interface ActorConfigFile { actors: ActorConfigFileEntry[]; + configs?: ActorGlobConfigEntry[]; } export interface BuildData { diff --git a/bin/utils.ts b/bin/utils.ts index a1e7967..47339e9 100644 --- a/bin/utils.ts +++ b/bin/utils.ts @@ -3,12 +3,13 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { ActorVersionSourceFile } from 'apify-client'; +import { minimatch } from 'minimatch'; import { SOURCE_FILE_FORMATS } from '@apify/consts'; import { selectActors } from './actor-filtering.js'; import { isPathWithinScope } from './path-utils.js'; -import type { ActorConfig, ActorConfigFile } from './types.js'; +import type { ActorConfig, ActorConfigFile, ActorConfigFileEntry, ActorGlobConfigEntry } from './types.js'; // Returns true when `childPath` is not inside `parentPath`. // Used to detect monorepo actors whose dockerContextDir escapes the actor directory. @@ -114,6 +115,62 @@ const findOverlappingContextPaths = (contextPaths: string[]): [string, string] | return undefined; }; +const validateGlobConfigEntries = (configs: unknown[]): ActorGlobConfigEntry[] => { + for (const [index, configEntry] of configs.entries()) { + const { folder, actorFullName } = configEntry as ActorGlobConfigEntry; + + // TODO: Allow for combined filtering? + if (folder !== undefined && actorFullName !== undefined) { + throw new Error( + `Invalid "configs" entry at index ${index} in "${CONFIG_FILE_NAME}". ` + + `Must not have both "folder" and "actorFullName" set.`, + ); + } + + if (folder === undefined && actorFullName === undefined) { + throw new Error( + `Invalid "configs" entry at index ${index} in "${CONFIG_FILE_NAME}". ` + + `Must have exactly one of "folder" or "actorFullName" set.`, + ); + } + + if (typeof (folder ?? actorFullName) !== 'string') { + throw new Error( + `Invalid "configs" entry at index ${index} in "${CONFIG_FILE_NAME}". ` + + `"folder"/"actorFullName" must be a string.`, + ); + } + } + + return configs as ActorGlobConfigEntry[]; +}; + +// Precedence, lowest to highest: matching folder-glob entries, matching actorFullName-glob +// entries, the actor's own literal entry. +const mergeGlobConfigs = ( + actorEntry: ActorConfigFileEntry, + folder: string, + configs: ActorGlobConfigEntry[], +): ActorConfigFileEntry => { + const matchingFolderConfigs = configs.filter( + (configEntry) => configEntry.folder !== undefined && minimatch(folder, configEntry.folder), + ); + const matchingActorFullNameConfigs = configs.filter( + (configEntry) => + configEntry.actorFullName !== undefined && + typeof actorEntry.actorFullName === 'string' && + minimatch(actorEntry.actorFullName, configEntry.actorFullName), + ); + + let overlay: Partial = {}; + for (const configEntry of [...matchingFolderConfigs, ...matchingActorFullNameConfigs]) { + const { folder: matchedFolder, actorFullName: matchedActorFullName, ...rest } = configEntry; + overlay = { ...overlay, ...rest }; + } + + return { ...overlay, ...actorEntry }; +}; + export const readConfigFile = async (selection: { actors: string[]; ignore: string[] }): Promise => { let raw: string; try { @@ -136,18 +193,24 @@ export const readConfigFile = async (selection: { actors: string[]; ignore: stri throw new Error(`Config file "${CONFIG_FILE_NAME}" must have an "actors" array at the top level.`); } + if (config.configs !== undefined && !Array.isArray(config.configs)) { + throw new Error(`Config file "${CONFIG_FILE_NAME}" "configs" must be an array.`); + } + const globConfigs = config.configs ? validateGlobConfigEntries(config.configs) : []; + const seenFolders = new Set(); const actorConfigs: ActorConfig[] = []; - for (const [index, entry] of config.actors.entries()) { - if (typeof entry.folder !== 'string') { + for (const [index, rawEntry] of config.actors.entries()) { + if (typeof rawEntry.folder !== 'string') { throw new Error( `Invalid "folder" for actor entry at index ${index} in "${CONFIG_FILE_NAME}". ` + `Must be a string (use "." for a single-actor repo).`, ); } - const folder = entry.folder === '.' ? '' : stripTrailingSlash(entry.folder); + const folder = rawEntry.folder === '.' ? '' : stripTrailingSlash(rawEntry.folder); + const entry = mergeGlobConfigs(rawEntry, folder, globConfigs); if (seenFolders.has(folder)) { throw new Error( @@ -217,6 +280,13 @@ export const readConfigFile = async (selection: { actors: string[]; ignore: stri ); } + if (entry.tokenEnvVar === undefined) { + throw new Error( + `Missing "tokenEnvVar" for folder "${entry.folder}" (actor "${entry.actorFullName}") in "${CONFIG_FILE_NAME}". ` + + `Set it directly on the actor entry or via a matching "configs" entry.`, + ); + } + actorConfigs.push({ actorFullName: entry.actorFullName, folder, diff --git a/package-lock.json b/package-lock.json index b636c52..1b00895 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" }, "bin": { @@ -1823,29 +1824,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", @@ -1859,22 +1837,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", @@ -1990,29 +1952,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", @@ -2026,22 +1965,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", @@ -2511,6 +2434,27 @@ "node": ">=10.0.0" } }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "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", @@ -5209,6 +5153,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", @@ -7005,29 +6964,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", @@ -7041,22 +6977,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 3119743..b48244a 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" }, "devDependencies": { diff --git a/test/unit/bin/utils.test.ts b/test/unit/bin/utils.test.ts index 893dcac..f3c29a4 100644 --- a/test/unit/bin/utils.test.ts +++ b/test/unit/bin/utils.test.ts @@ -343,6 +343,238 @@ describe('readConfigFile', () => { expect(result[0].contextPaths).toEqual(['actors/shopify', 'code', 'shared']); }); + it('throws when no tokenEnvVar can be resolved for an actor', async () => { + mockFiles({ + [CONFIG_FILE_NAME]: validConfig([{ folder: 'actors/shopify', actorFullName: 'myteam/shopify' }]), + 'actors/shopify/.actor/actor.json': actorJson({}), + }); + + await expect(readConfigFile(emptyActorSelection)).rejects.toThrow(/tokenEnvVar/); + }); + + describe('configs (glob-based overrides)', () => { + it('fills in a property from a matching folder-glob configs entry', async () => { + mockFiles({ + [CONFIG_FILE_NAME]: JSON.stringify({ + actors: [{ folder: 'actors/shopify', actorFullName: 'myteam/shopify' }], + configs: [{ folder: 'actors/*', tokenEnvVar: 'GLOB_TOKEN' }], + }), + 'actors/shopify/.actor/actor.json': actorJson({}), + }); + + const result = await readConfigFile(emptyActorSelection); + expect(result[0].tokenEnvVar).toBe('GLOB_TOKEN'); + }); + + it('an actorFullName-glob configs entry wins over a folder-glob configs entry', async () => { + mockFiles({ + [CONFIG_FILE_NAME]: JSON.stringify({ + actors: [{ folder: 'actors/shopify', actorFullName: 'myteam/shopify' }], + configs: [ + { folder: 'actors/*', tokenEnvVar: 'FOLDER_TOKEN' }, + { actorFullName: 'myteam/*', tokenEnvVar: 'ACTOR_TOKEN' }, + ], + }), + 'actors/shopify/.actor/actor.json': actorJson({}), + }); + + const result = await readConfigFile(emptyActorSelection); + expect(result[0].tokenEnvVar).toBe('ACTOR_TOKEN'); + }); + + it('among matching folder-glob entries, array position decides the winner, not pattern specificity', async () => { + mockFiles({ + [CONFIG_FILE_NAME]: JSON.stringify({ + actors: [{ folder: 'actors/shopify', actorFullName: 'myteam/shopify' }], + configs: [ + { folder: 'actors/*', tokenEnvVar: 'BROADER_TOKEN' }, + { folder: 'actors/shopify', tokenEnvVar: 'SPECIFIC_TOKEN' }, + ], + }), + 'actors/shopify/.actor/actor.json': actorJson({}), + }); + + const resultBroaderLast = await readConfigFile(emptyActorSelection); + expect(resultBroaderLast[0].tokenEnvVar).toBe('SPECIFIC_TOKEN'); + + mockFiles({ + [CONFIG_FILE_NAME]: JSON.stringify({ + actors: [{ folder: 'actors/shopify', actorFullName: 'myteam/shopify' }], + configs: [ + { folder: 'actors/shopify', tokenEnvVar: 'SPECIFIC_TOKEN' }, + { folder: 'actors/*', tokenEnvVar: 'BROADER_TOKEN' }, + ], + }), + 'actors/shopify/.actor/actor.json': actorJson({}), + }); + + const resultSpecificLast = await readConfigFile(emptyActorSelection); + expect(resultSpecificLast[0].tokenEnvVar).toBe('BROADER_TOKEN'); + }); + + it('among matching actorFullName-glob entries, array position decides the winner, not pattern specificity', async () => { + mockFiles({ + [CONFIG_FILE_NAME]: JSON.stringify({ + actors: [{ folder: 'actors/shopify', actorFullName: 'myteam/shopify' }], + configs: [ + { actorFullName: 'myteam/*', tokenEnvVar: 'BROADER_TOKEN' }, + { actorFullName: 'myteam/shopify', tokenEnvVar: 'SPECIFIC_TOKEN' }, + ], + }), + 'actors/shopify/.actor/actor.json': actorJson({}), + }); + + const resultBroaderLast = await readConfigFile(emptyActorSelection); + expect(resultBroaderLast[0].tokenEnvVar).toBe('SPECIFIC_TOKEN'); + + mockFiles({ + [CONFIG_FILE_NAME]: JSON.stringify({ + actors: [{ folder: 'actors/shopify', actorFullName: 'myteam/shopify' }], + configs: [ + { actorFullName: 'myteam/shopify', tokenEnvVar: 'SPECIFIC_TOKEN' }, + { actorFullName: 'myteam/*', tokenEnvVar: 'BROADER_TOKEN' }, + ], + }), + 'actors/shopify/.actor/actor.json': actorJson({}), + }); + + const resultSpecificLast = await readConfigFile(emptyActorSelection); + expect(resultSpecificLast[0].tokenEnvVar).toBe('BROADER_TOKEN'); + }); + + it.each([ + { + patternKey: 'folder' as const, + folder: '.', + actorFullName: 'apify/my-actor', + actorJsonPath: '.actor/actor.json', + }, + { + patternKey: 'actorFullName' as const, + folder: 'actors/shopify', + actorFullName: 'myteam/shopify', + actorJsonPath: 'actors/shopify/.actor/actor.json', + }, + ])( + '"**" as a $patternKey pattern matches every actor (including a single-actor/root repo for folder)', + async ({ patternKey, folder, actorFullName, actorJsonPath }) => { + mockFiles({ + [CONFIG_FILE_NAME]: JSON.stringify({ + actors: [{ folder, actorFullName }], + configs: [{ [patternKey]: '**', tokenEnvVar: 'GLOB_TOKEN' }], + }), + [actorJsonPath]: actorJson({}), + }); + + const result = await readConfigFile(emptyActorSelection); + expect(result[0].tokenEnvVar).toBe('GLOB_TOKEN'); + }, + ); + + it.each([{ patternKey: 'folder' as const }, { patternKey: 'actorFullName' as const }])( + 'a single-segment "*" does not match a multi-segment $patternKey', + async ({ patternKey }) => { + mockFiles({ + [CONFIG_FILE_NAME]: JSON.stringify({ + actors: [{ folder: 'actors/shopify', actorFullName: 'myteam/shopify' }], + configs: [{ [patternKey]: '*', tokenEnvVar: 'GLOB_TOKEN' }], + }), + 'actors/shopify/.actor/actor.json': actorJson({}), + }); + + await expect(readConfigFile(emptyActorSelection)).rejects.toThrow(/tokenEnvVar/); + }, + ); + + it('throws when a configs entry has both folder and actorFullName set', async () => { + mockFiles({ + [CONFIG_FILE_NAME]: JSON.stringify({ + actors: [{ folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'APIFY_TOKEN' }], + configs: [{ folder: 'actors/*', actorFullName: 'myteam/*', tokenEnvVar: 'GLOB_TOKEN' }], + }), + 'actors/shopify/.actor/actor.json': actorJson({}), + }); + + await expect(readConfigFile(emptyActorSelection)).rejects.toThrow(/Invalid "configs" entry/); + }); + + it('throws when the non-selected discriminant is present with a wrong type', async () => { + mockFiles({ + [CONFIG_FILE_NAME]: JSON.stringify({ + actors: [{ folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'APIFY_TOKEN' }], + configs: [{ folder: 'actors/*', actorFullName: 123, tokenEnvVar: 'GLOB_TOKEN' }], + }), + 'actors/shopify/.actor/actor.json': actorJson({}), + }); + + await expect(readConfigFile(emptyActorSelection)).rejects.toThrow(/Invalid "configs" entry/); + }); + + it('a literal value wins over both a folder-glob and an actorFullName-glob match on the same actor', async () => { + mockFiles({ + [CONFIG_FILE_NAME]: JSON.stringify({ + actors: [ + { + folder: 'actors/shopify', + actorFullName: 'myteam/shopify', + tokenEnvVar: 'LITERAL_TOKEN', + }, + ], + configs: [ + { folder: 'actors/*', tokenEnvVar: 'FOLDER_TOKEN' }, + { actorFullName: 'myteam/*', tokenEnvVar: 'ACTOR_TOKEN' }, + ], + }), + 'actors/shopify/.actor/actor.json': actorJson({}), + }); + + const result = await readConfigFile(emptyActorSelection); + expect(result[0].tokenEnvVar).toBe('LITERAL_TOKEN'); + }); + + it('an actorFullName-glob match wins over a folder-glob match when no literal is set', async () => { + mockFiles({ + [CONFIG_FILE_NAME]: JSON.stringify({ + actors: [{ folder: 'actors/shopify', actorFullName: 'myteam/shopify' }], + configs: [ + { folder: 'actors/*', tokenEnvVar: 'FOLDER_TOKEN' }, + { actorFullName: 'myteam/*', tokenEnvVar: 'ACTOR_TOKEN' }, + ], + }), + 'actors/shopify/.actor/actor.json': actorJson({}), + }); + + const result = await readConfigFile(emptyActorSelection); + expect(result[0].tokenEnvVar).toBe('ACTOR_TOKEN'); + }); + + it('throws a friendly error when "configs" is present but not an array', async () => { + mockFiles({ + [CONFIG_FILE_NAME]: JSON.stringify({ + actors: [{ folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'APIFY_TOKEN' }], + configs: {}, + }), + 'actors/shopify/.actor/actor.json': actorJson({}), + }); + + await expect(readConfigFile(emptyActorSelection)).rejects.toThrow(/"configs" must be an array/); + }); + + it('merges in an unrecognized property from a matching configs entry with no effect', async () => { + mockFiles({ + [CONFIG_FILE_NAME]: JSON.stringify({ + actors: [{ folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'APIFY_TOKEN' }], + configs: [{ folder: 'actors/*', tokenEnvVarr: 'MISSPELLED' }], + }), + 'actors/shopify/.actor/actor.json': actorJson({}), + }); + + const result = await readConfigFile(emptyActorSelection); + expect(result[0]).not.toHaveProperty('tokenEnvVarr'); + expect(result[0].tokenEnvVar).toBe('APIFY_TOKEN'); + }); + }); + describe('actor selection', () => { const twoActors = () => mockFiles({