diff --git a/.changeset/wicked-feet-vanish.md b/.changeset/wicked-feet-vanish.md new file mode 100644 index 0000000..23772d2 --- /dev/null +++ b/.changeset/wicked-feet-vanish.md @@ -0,0 +1,5 @@ +--- +'@vercel/flags': patch +--- + +generatePermutations: infer options of boolean flags diff --git a/packages/flags/src/next/precompute.test.ts b/packages/flags/src/next/precompute.test.ts new file mode 100644 index 0000000..2802f13 --- /dev/null +++ b/packages/flags/src/next/precompute.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; +import { deserialize, type Flag, flag, generatePermutations } from './index'; +import crypto from 'node:crypto'; +import type { JsonValue } from '../types'; + +/** + * Helper function to assert the generated permutations. + * + * @param group the group of flags to generate permutations for + * @param expected the expected permutations + */ +async function expectPermutations( + group: Flag[], + expected: any[], + filter?: ((permutation: Record) => boolean) | null, +) { + const permutationsPromise = generatePermutations(group, filter); + await expect(permutationsPromise).resolves.toHaveLength(expected.length); + + const permutations = await permutationsPromise; + await expect( + Promise.all(permutations.map((p) => deserialize(group, p))), + ).resolves.toEqual(expected); +} + +describe('generatePermutations', () => { + describe('when flag declares no options', () => { + it('should infer boolean options', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = flag({ key: 'a', decide: () => false }); + await expectPermutations([flagA], [{ a: false }, { a: true }]); + }); + }); + + describe('when flag declares empty options', () => { + it('should not infer any options', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = flag({ key: 'a', decide: () => false, options: [] }); + await expectPermutations([flagA], []); + }); + }); + + describe('when flag declares options', () => { + it('should generate permutations', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = flag({ + key: 'a', + decide: () => 'two', + options: ['one', 'two', 'three'], + }); + + await expectPermutations( + [flagA], + [{ a: 'one' }, { a: 'two' }, { a: 'three' }], + ); + }); + }); + + describe('when flag declares options with a filter', () => { + it('should generate permutations', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = flag({ + key: 'a', + decide: () => 'two', + options: ['one', 'two', 'three'], + }); + + await expectPermutations( + [flagA], + [{ a: 'two' }], + // the filter passed to generatePermutations() + (permutation) => permutation.a === 'two', + ); + }); + }); + + describe('multiple flags with inferred options', () => { + it('should generate permutations', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = flag({ + key: 'a', + decide: () => false, + }); + + const flagB = flag({ + key: 'b', + decide: () => false, + }); + + await expectPermutations( + [flagA, flagB], + [ + { a: false, b: false }, + { a: true, b: false }, + { a: false, b: true }, + { a: true, b: true }, + ], + ); + }); + }); + + describe('multiple flags with a mix of inferred and declared options', () => { + it('should generate permutations', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = flag({ + key: 'a', + decide: () => false, + }); + + const flagB = flag({ + key: 'b', + decide: () => false, + }); + + const flagC = flag({ + key: 'c', + decide: () => 'two', + options: ['one', 'two', 'three'], + }); + + await expectPermutations( + [flagA, flagB, flagC], + [ + { a: false, b: false, c: 'one' }, + { a: true, b: false, c: 'one' }, + { a: false, b: true, c: 'one' }, + { a: true, b: true, c: 'one' }, + + { a: false, b: false, c: 'two' }, + { a: true, b: false, c: 'two' }, + { a: false, b: true, c: 'two' }, + { a: true, b: true, c: 'two' }, + + { a: false, b: false, c: 'three' }, + { a: true, b: false, c: 'three' }, + { a: false, b: true, c: 'three' }, + { a: true, b: true, c: 'three' }, + ], + ); + }); + + describe('multiple flags with a mix of inferred and declared options, filtered', () => { + it('should generate permutations', async () => { + process.env.FLAGS_SECRET = crypto.randomBytes(32).toString('base64url'); + + const flagA = flag({ + key: 'a', + decide: () => false, + }); + + const flagB = flag({ + key: 'b', + decide: () => false, + }); + + const flagC = flag({ + key: 'c', + decide: () => 'two', + options: ['one', 'two', 'three'], + }); + + await expectPermutations( + [flagA, flagB, flagC], + [ + { a: false, b: true, c: 'two' }, + { a: true, b: true, c: 'two' }, + ], + (permutation) => permutation.c === 'two' && permutation.b, + ); + }); + }); + }); +}); diff --git a/packages/flags/src/next/precompute.ts b/packages/flags/src/next/precompute.ts index 0984a3d..0304208 100644 --- a/packages/flags/src/next/precompute.ts +++ b/packages/flags/src/next/precompute.ts @@ -175,8 +175,10 @@ export async function generatePermutations( } const options = flags.map((flag) => { - // no permutations if you don't declare any options - if (!flag.options) return []; + // infer boolean permutations if you don't declare any options. + // + // to explicitly opt out you need to use "filter" + if (!flag.options) return [false, true]; return flag.options.map((option) => option.value); });