diff --git a/packages/app/src/cli/api/graphql/business-platform-organizations/generated/organization_exp_flags.ts b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/organization_exp_flags.ts new file mode 100644 index 0000000000..c953c83d46 --- /dev/null +++ b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/organization_exp_flags.ts @@ -0,0 +1,74 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type OrganizationExpFlagsQueryVariables = Types.Exact<{ + organizationId: Types.Scalars['OrganizationID']['input'] + flagHandles: Types.Scalars['String']['input'][] | Types.Scalars['String']['input'] +}> + +export type OrganizationExpFlagsQuery = {organization?: {id: string; enabledFlags: boolean[]} | null} + +export const OrganizationExpFlags = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'OrganizationExpFlags'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'organizationId'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'OrganizationID'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'flagHandles'}}, + type: { + kind: 'NonNullType', + type: { + kind: 'ListType', + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'organization'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'organizationId'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'organizationId'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'enabledFlags'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'flagHandles'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'flagHandles'}}, + }, + ], + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/business-platform-organizations/queries/organization_exp_flags.graphql b/packages/app/src/cli/api/graphql/business-platform-organizations/queries/organization_exp_flags.graphql new file mode 100644 index 0000000000..add09011bb --- /dev/null +++ b/packages/app/src/cli/api/graphql/business-platform-organizations/queries/organization_exp_flags.graphql @@ -0,0 +1,6 @@ +query OrganizationExpFlags($organizationId: OrganizationID!, $flagHandles: [String!]!) { + organization(organizationId: $organizationId) { + id + enabledFlags(flagHandles: $flagHandles) + } +} diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index 65db3b6b62..aa70c7eece 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -8,6 +8,7 @@ import { versionDeepLink, } from './app-management-client.js' import {OrganizationBetaFlagsQuerySchema} from './app-management-client/graphql/organization_beta_flags.js' +import {OrganizationExpFlagsQuery} from '../../api/graphql/business-platform-organizations/generated/organization_exp_flags.js' import { testUIExtension, testRemoteExtensionTemplates, @@ -187,7 +188,6 @@ describe('templateSpecifications', () => { organization: { id: encodedGidFromOrganizationIdForBP(orgApp.organizationId), flag_allowedFlag: true, - flag_notAllowedFlag: false, }, } vi.mocked(businessPlatformOrganizationsRequest).mockResolvedValueOnce(mockedFetchFlagsResponse) @@ -200,17 +200,12 @@ describe('templateSpecifications', () => { // Then expect(vi.mocked(businessPlatformOrganizationsRequest)).toHaveBeenCalledWith({ - query: ` - query OrganizationBetaFlags($organizationId: OrganizationID!) { - organization(organizationId: $organizationId) { - id - flag_allowedFlag: hasFeatureFlag(handle: "allowedFlag") - flag_notAllowedFlag: hasFeatureFlag(handle: "notAllowedFlag") - } - }`, + query: expect.stringContaining('flag_allowedFlag: hasFeatureFlag(handle: "allowedFlag")'), token: 'business-platform-token', organizationId: orgApp.organizationId, - variables: {organizationId: encodedGidFromOrganizationIdForBP(orgApp.organizationId)}, + variables: { + organizationId: encodedGidFromOrganizationIdForBP(orgApp.organizationId), + }, unauthorizedHandler: { type: 'token_refresh', handler: expect.any(Function), @@ -249,6 +244,64 @@ describe('templateSpecifications', () => { expect(groupOrder).toEqual(['GroupA', 'GroupB', 'GroupC']) }) + test('fetches and filters templates by exp flags using enabledFlags', async () => { + // Given + const orgApp = testOrganizationApp() + const templateWithExpFlag: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[1]!, + organizationExpFlags: ['hash_allowed'], + } + const templateWithDisallowedExpFlag: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[2]!, + organizationExpFlags: ['hash_not_allowed'], + } + const templates: GatedExtensionTemplate[] = [ + templateWithoutRules, + templateWithExpFlag, + templateWithDisallowedExpFlag, + ] + const mockedFetch = vi.fn().mockResolvedValueOnce(Response.json(templates)) + vi.mocked(fetch).mockImplementation(mockedFetch) + + const mockedBetaFlagsResponse: OrganizationBetaFlagsQuerySchema = { + organization: { + id: encodedGidFromOrganizationIdForBP(orgApp.organizationId), + }, + } + vi.mocked(businessPlatformOrganizationsRequest).mockResolvedValueOnce(mockedBetaFlagsResponse) + + const mockedExpFlagsResponse: OrganizationExpFlagsQuery = { + organization: { + id: encodedGidFromOrganizationIdForBP(orgApp.organizationId), + enabledFlags: [true, false], + }, + } + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce(mockedExpFlagsResponse) + + // When + const client = AppManagementClient.getInstance() + client.businessPlatformToken = () => Promise.resolve('business-platform-token') + const {templates: got} = await client.templateSpecifications(orgApp) + const gotLabels = got.map((template) => template.name) + + // Then + expect(vi.mocked(businessPlatformOrganizationsRequestDoc)).toHaveBeenCalledWith({ + query: expect.objectContaining({kind: 'Document'}), + token: 'business-platform-token', + organizationId: orgApp.organizationId, + variables: { + organizationId: encodedGidFromOrganizationIdForBP(orgApp.organizationId), + flagHandles: ['hash_allowed', 'hash_not_allowed'], + }, + unauthorizedHandler: { + type: 'token_refresh', + handler: expect.any(Function), + }, + }) + const expectedAllowedTemplates = [templateWithoutRules, templateWithExpFlag] + expect(gotLabels).toEqual(expectedAllowedTemplates.map((template) => template.name)) + }) + test('fails with an error message when fetching the specifications list fails', async () => { // Given vi.mocked(fetch).mockRejectedValueOnce(new Error('Failed to fetch')) @@ -274,7 +327,11 @@ describe('allowedTemplates', () => { ] // When - const got = await allowedTemplates(templates, () => Promise.resolve({allowedFlag: true, notAllowedFlag: false})) + const got = await allowedTemplates( + templates, + () => Promise.resolve({allowedFlag: true, notAllowedFlag: false}), + () => Promise.resolve({}), + ) // Then expect(got.length).toEqual(2) @@ -293,6 +350,7 @@ describe('allowedTemplates', () => { const got = await allowedTemplates( templates, () => Promise.resolve({allowedFlag: true, notAllowedFlag: false}), + () => Promise.resolve({}), '0.0.0-nightly', ) @@ -300,6 +358,110 @@ describe('allowedTemplates', () => { expect(got.length).toEqual(2) expect(got).toEqual([allowedTemplate, templateDisallowedByMinimumCliVersion]) }) + + test('filters templates by exp flags', async () => { + // Given + const templateWithExpFlag: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[1]!, + organizationExpFlags: ['hash_allowed'], + } + const templateWithDisallowedExpFlag: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[2]!, + organizationExpFlags: ['hash_not_allowed'], + } + const templates: GatedExtensionTemplate[] = [ + templateWithoutRules, + templateWithExpFlag, + templateWithDisallowedExpFlag, + ] + + // When + const got = await allowedTemplates( + templates, + () => Promise.resolve({}), + () => Promise.resolve({hash_allowed: true, hash_not_allowed: false}), + ) + + // Then + expect(got.length).toEqual(2) + expect(got).toEqual([templateWithoutRules, templateWithExpFlag]) + }) + + test('filters templates requiring both beta and exp flags', async () => { + // Given + const templateWithBothFlags: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[1]!, + organizationBetaFlags: ['betaFlag'], + organizationExpFlags: ['hash_exp'], + } + const templateWithOnlyBeta: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[2]!, + organizationBetaFlags: ['betaFlag'], + } + const templateWithOnlyExp: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[3]!, + organizationExpFlags: ['hash_exp'], + } + const templates: GatedExtensionTemplate[] = [ + templateWithoutRules, + templateWithBothFlags, + templateWithOnlyBeta, + templateWithOnlyExp, + ] + + // When + const got = await allowedTemplates( + templates, + () => Promise.resolve({betaFlag: true}), + () => Promise.resolve({hash_exp: true}), + ) + + // Then + expect(got.length).toEqual(4) + expect(got).toEqual([templateWithoutRules, templateWithBothFlags, templateWithOnlyBeta, templateWithOnlyExp]) + }) + + test('excludes template when beta flag is satisfied but exp flag is not', async () => { + // Given + const templateWithBothFlags: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[1]!, + organizationBetaFlags: ['betaFlag'], + organizationExpFlags: ['hash_exp'], + } + const templates: GatedExtensionTemplate[] = [templateWithoutRules, templateWithBothFlags] + + // When + const got = await allowedTemplates( + templates, + () => Promise.resolve({betaFlag: true}), + () => Promise.resolve({hash_exp: false}), + ) + + // Then + expect(got.length).toEqual(1) + expect(got).toEqual([templateWithoutRules]) + }) + + test('excludes template when exp flag is satisfied but beta flag is not', async () => { + // Given + const templateWithBothFlags: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[1]!, + organizationBetaFlags: ['betaFlag'], + organizationExpFlags: ['hash_exp'], + } + const templates: GatedExtensionTemplate[] = [templateWithoutRules, templateWithBothFlags] + + // When + const got = await allowedTemplates( + templates, + () => Promise.resolve({betaFlag: false}), + () => Promise.resolve({hash_exp: true}), + ) + + // Then + expect(got.length).toEqual(1) + expect(got).toEqual([templateWithoutRules]) + }) }) describe('versionDeepLink', () => { diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index 1c044c65a6..239532606e 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -4,6 +4,10 @@ import { OrganizationBetaFlagsQueryVariables, organizationBetaFlagsQuery, } from './app-management-client/graphql/organization_beta_flags.js' +import { + OrganizationExpFlagsQueryVariables, + OrganizationExpFlags, +} from '../../api/graphql/business-platform-organizations/generated/organization_exp_flags.js' import {environmentVariableNames} from '../../constants.js' import {RemoteSpecification} from '../../api/graphql/extension_specifications.js' import { @@ -178,6 +182,7 @@ type ShopEdge = NonNullable type ShopNode = Exclude export interface GatedExtensionTemplate extends ExtensionTemplate { organizationBetaFlags?: string[] + organizationExpFlags?: string[] minimumCliVersion?: string deprecatedFromCliVersion?: string } @@ -491,8 +496,10 @@ export class AppManagementClient implements DeveloperPlatformClient { // uses sortPriority, is gone. let counter = 0 const filteredTemplates = ( - await allowedTemplates(templates, async (betaFlags: string[]) => - this.organizationBetaFlags(organizationId, betaFlags), + await allowedTemplates( + templates, + async (betaFlags: string[]) => this.organizationBetaFlags(organizationId, betaFlags), + async (expFlags: string[]) => this.organizationExpFlags(organizationId, expFlags), ) ).map((template) => ({...template, sortPriority: counter++})) @@ -1082,6 +1089,29 @@ export class AppManagementClient implements DeveloperPlatformClient { return result } + private async organizationExpFlags( + organizationId: string, + allExpFlags: string[], + ): Promise<{[flag: (typeof allExpFlags)[number]]: boolean}> { + const variables: OrganizationExpFlagsQueryVariables = { + organizationId: encodedGidFromOrganizationIdForBP(organizationId), + flagHandles: allExpFlags, + } + const flagsResult = await businessPlatformOrganizationsRequestDoc({ + query: OrganizationExpFlags, + token: await this.businessPlatformToken(), + organizationId, + variables, + unauthorizedHandler: this.createUnauthorizedHandler(), + }) + const result: {[flag: (typeof allExpFlags)[number]]: boolean} = {} + const enabledFlags = flagsResult.organization?.enabledFlags ?? [] + allExpFlags.forEach((flag, index) => { + result[flag] = Boolean(enabledFlags[index]) + }) + return result + } + private async appManagementRequest( options: Omit, 'unauthorizedHandler' | 'token'>, ): Promise { @@ -1282,19 +1312,34 @@ export function diffAppModules({currentModules, selectedVersionModules}: DiffApp export async function allowedTemplates( templates: GatedExtensionTemplate[], betaFlagsFetcher: (betaFlags: string[]) => Promise<{[key: string]: boolean}>, + expFlagsFetcher: (expFlags: string[]) => Promise<{[key: string]: boolean}>, version: string = CLI_KIT_VERSION, ): Promise { + // Extract both types of flags from templates const allBetaFlags = Array.from(new Set(templates.map((ext) => ext.organizationBetaFlags ?? []).flat())) - const enabledBetaFlags = await betaFlagsFetcher(allBetaFlags) + const allExpFlags = Array.from(new Set(templates.map((ext) => ext.organizationExpFlags ?? []).flat())) + + // Fetch both flag types in parallel + const [enabledBetaFlags, enabledExpFlags] = await Promise.all([ + allBetaFlags.length > 0 ? betaFlagsFetcher(allBetaFlags) : Promise.resolve({} as {[key: string]: boolean}), + allExpFlags.length > 0 ? expFlagsFetcher(allExpFlags) : Promise.resolve({} as {[key: string]: boolean}), + ]) + return templates.filter((ext) => { - const hasAnyNeededBetas = + // Check beta flags + const hasNeededBetaFlags = !ext.organizationBetaFlags || ext.organizationBetaFlags.every((flag) => enabledBetaFlags[flag]) + // Check exp flags + const hasNeededExpFlags = + !ext.organizationExpFlags || ext.organizationExpFlags.every((flag) => enabledExpFlags[flag]) + // Version checks const satisfiesMinCliVersion = !ext.minimumCliVersion || versionSatisfies(version, `>=${ext.minimumCliVersion}`) const satisfiesDeprecatedFromCliVersion = !ext.deprecatedFromCliVersion || versionSatisfies(version, `<${ext.deprecatedFromCliVersion}`) const satisfiesVersion = satisfiesMinCliVersion && satisfiesDeprecatedFromCliVersion const satisfiesPreReleaseVersion = isPreReleaseVersion(version) && ext.deprecatedFromCliVersion === undefined - return hasAnyNeededBetas && (satisfiesVersion || satisfiesPreReleaseVersion) + // Must satisfy both flag types AND version requirements + return hasNeededBetaFlags && hasNeededExpFlags && (satisfiesVersion || satisfiesPreReleaseVersion) }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b183faf400..9fc1b20697 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14384,15 +14384,6 @@ snapshots: msw: 2.8.7(@types/node@24.7.0)(typescript@5.8.3) vite: 6.4.1(@types/node@18.19.70)(jiti@2.4.2)(sass@1.89.1)(yaml@2.8.1) - '@vitest/mocker@3.2.1(msw@2.8.7(@types/node@24.7.0)(typescript@5.8.3))(vite@6.4.1(@types/node@24.7.0)(jiti@2.4.2)(sass@1.89.1)(yaml@2.8.1))': - dependencies: - '@vitest/spy': 3.2.1 - estree-walker: 3.0.3 - magic-string: 0.30.17 - optionalDependencies: - msw: 2.8.7(@types/node@24.7.0)(typescript@5.8.3) - vite: 6.4.1(@types/node@24.7.0)(jiti@2.4.2)(sass@1.89.1)(yaml@2.8.1) - '@vitest/pretty-format@3.2.1': dependencies: tinyrainbow: 2.0.0 @@ -19992,7 +19983,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.1 - '@vitest/mocker': 3.2.1(msw@2.8.7(@types/node@24.7.0)(typescript@5.8.3))(vite@6.4.1(@types/node@24.7.0)(jiti@2.4.2)(sass@1.89.1)(yaml@2.8.1)) + '@vitest/mocker': 3.2.1(msw@2.8.7(@types/node@24.7.0)(typescript@5.8.3))(vite@6.4.1(@types/node@18.19.70)(jiti@2.4.2)(sass@1.89.1)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.1 '@vitest/runner': 3.2.1 '@vitest/snapshot': 3.2.1