diff --git a/src/codegen/codegen.test.ts b/src/codegen/codegen.test.ts index 02f06f44..bdc7339f 100644 --- a/src/codegen/codegen.test.ts +++ b/src/codegen/codegen.test.ts @@ -69,7 +69,7 @@ describe('Code generation', () => { generateScript({ recording: [], generator: { - version: '0', + version: '1.0', recordingPath: 'test', options: { loadProfile: { diff --git a/src/schemas/generator.ts b/src/schemas/generator.ts deleted file mode 100644 index 0c52690f..00000000 --- a/src/schemas/generator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from 'zod' -import { TestRuleSchema } from '@/schemas/rules' -import { TestDataSchema } from '@/schemas/testData' -import { TestOptionsSchema } from '@/schemas/testOptions' - -export const GeneratorFileDataSchema = z.object({ - version: z.string(), - recordingPath: z.string(), - options: TestOptionsSchema, - testData: TestDataSchema, - rules: TestRuleSchema.array(), - allowlist: z.string().array(), - includeStaticAssets: z.boolean(), - scriptName: z.string().default('my-script.js'), -}) diff --git a/src/schemas/generator/README.md b/src/schemas/generator/README.md new file mode 100644 index 00000000..cc87ba1e --- /dev/null +++ b/src/schemas/generator/README.md @@ -0,0 +1,25 @@ +# Generator migration + +Migrations are needed when changing the structure of existing generators file such as renaming or deleting keys. + +If you're simply adding new options to the generator file, a migration may not be needed. In this case, extend the latest schema available. + +## Creating a new migration + +For when a new migration is needed: + +1. Create a directory for the new version (e.g. `/v2`). +2. Copy files from the previous schema into this directory and make appropriate changes. +3. In `/v1/index.ts`, implement a `migrate` function that takes a `v1` schema and returns a `v2` schema. +4. Update `/schemas/generators/index.ts` to use the new version: + +```ts +function migrate(generator: z.infer) { + +case '1.0': + return migrate(v1.migrate(generator)) + +} +``` + +5. Make changes to the remaining implementation to use the new schema. diff --git a/src/schemas/generator/index.test.ts b/src/schemas/generator/index.test.ts new file mode 100644 index 00000000..80698590 --- /dev/null +++ b/src/schemas/generator/index.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { migrate } from '.' +import * as v0 from './v0' + +describe('Generator migration', () => { + it('should migrate from v0 to latest', () => { + const v0Generator: v0.GeneratorSchema = { + version: '0', + recordingPath: 'test', + options: { + loadProfile: { + executor: 'shared-iterations', + vus: 1, + iterations: 1, + }, + thinkTime: { + sleepType: 'iterations', + timing: { + type: 'fixed', + value: 1, + }, + }, + }, + testData: { + variables: [], + }, + rules: [], + allowlist: [], + includeStaticAssets: false, + scriptName: 'my-script.js', + } + + expect(migrate(v0Generator).version).toBe('1.0') + }) +}) diff --git a/src/schemas/generator/index.ts b/src/schemas/generator/index.ts new file mode 100644 index 00000000..98b64a94 --- /dev/null +++ b/src/schemas/generator/index.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import * as v0 from './v0' +import * as v1 from './v1' +import { exhaustive } from '../../utils/typescript' + +const AnyGeneratorSchema = z.discriminatedUnion('version', [ + v0.GeneratorFileDataSchema, + v1.GeneratorFileDataSchema, +]) + +export function migrate(generator: z.infer) { + switch (generator.version) { + case '0': + return migrate(v0.migrate(generator)) + case '1.0': + return generator + default: + return exhaustive(generator) + } +} + +export const GeneratorFileDataSchema = AnyGeneratorSchema.transform(migrate) + +export * from './v1/rules' +export * from './v1/testData' +export * from './v1/testOptions' diff --git a/src/schemas/generator/v0/index.ts b/src/schemas/generator/v0/index.ts new file mode 100644 index 00000000..4c85a097 --- /dev/null +++ b/src/schemas/generator/v0/index.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' +import { TestRuleSchema } from './rules' +import { TestDataSchema } from './testData' +import { TestOptionsSchema } from './testOptions' +import * as v1 from '../v1' + +export const GeneratorFileDataSchema = z.object({ + version: z.literal('0'), + recordingPath: z.string(), + options: TestOptionsSchema, + testData: TestDataSchema, + rules: TestRuleSchema.array(), + allowlist: z.string().array(), + includeStaticAssets: z.boolean(), + scriptName: z.string().default('my-script.js'), +}) + +export type GeneratorSchema = z.infer + +// Migrate generator to the next version +export function migrate(generator: GeneratorSchema): v1.GeneratorSchema { + return { + version: '1.0', + allowlist: generator.allowlist, + includeStaticAssets: generator.includeStaticAssets, + options: generator.options, + recordingPath: generator.recordingPath, + rules: generator.rules, + scriptName: generator.scriptName, + testData: generator.testData, + } +} diff --git a/src/schemas/rules.ts b/src/schemas/generator/v0/rules.ts similarity index 100% rename from src/schemas/rules.ts rename to src/schemas/generator/v0/rules.ts diff --git a/src/schemas/testData.ts b/src/schemas/generator/v0/testData.ts similarity index 100% rename from src/schemas/testData.ts rename to src/schemas/generator/v0/testData.ts diff --git a/src/schemas/testOptions.ts b/src/schemas/generator/v0/testOptions.ts similarity index 100% rename from src/schemas/testOptions.ts rename to src/schemas/generator/v0/testOptions.ts diff --git a/src/schemas/generator/v1/index.ts b/src/schemas/generator/v1/index.ts new file mode 100644 index 00000000..b535b1e1 --- /dev/null +++ b/src/schemas/generator/v1/index.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { TestRuleSchema } from './rules' +import { TestDataSchema } from './testData' +import { TestOptionsSchema } from './testOptions' + +export const GeneratorFileDataSchema = z.object({ + version: z.literal('1.0'), + recordingPath: z.string(), + options: TestOptionsSchema, + testData: TestDataSchema, + rules: TestRuleSchema.array(), + allowlist: z.string().array(), + includeStaticAssets: z.boolean(), + scriptName: z.string().default('my-script.js'), +}) + +export type GeneratorSchema = z.infer + +// TODO: Migrate generator to the next version +export function migrate(generator: z.infer) { + return { ...generator } +} diff --git a/src/schemas/generator/v1/rules.ts b/src/schemas/generator/v1/rules.ts new file mode 100644 index 00000000..40c09eae --- /dev/null +++ b/src/schemas/generator/v1/rules.ts @@ -0,0 +1,146 @@ +import { z } from 'zod' + +export const VariableValueSchema = z.object({ + type: z.literal('variable'), + variableName: z.string(), +}) + +export const ArrayValueSchema = z.object({ + type: z.literal('array'), + arrayName: z.string(), +}) + +export const CustomCodeValueSchema = z.object({ + type: z.literal('customCode'), + code: z.string(), +}) + +export const RecordedValueSchema = z.object({ + type: z.literal('recordedValue'), +}) + +export const StringValueSchema = z.object({ + type: z.literal('string'), + value: z.string(), +}) + +export const FilterSchema = z.object({ + path: z.string(), +}) + +export const BeginEndSelectorSchema = z.object({ + type: z.literal('begin-end'), + from: z.enum(['headers', 'body', 'url']), + begin: z.string(), + end: z.string(), +}) + +export const HeaderNameSelectorSchema = z.object({ + type: z.literal('header-name'), + from: z.enum(['headers']), + name: z.string(), +}) + +export const RegexSelectorSchema = z.object({ + type: z.literal('regex'), + from: z.enum(['headers', 'body', 'url']), + regex: z.string().refine( + (value) => { + try { + new RegExp(value) + return true + } catch { + return false + } + }, + { message: 'Invalid regular expression' } + ), +}) + +export const JsonSelectorSchema = z.object({ + type: z.literal('json'), + from: z.literal('body'), + path: z.string(), +}) + +export const CustomCodeSelectorSchema = z.object({ + type: z.literal('custom-code'), + snippet: z.string(), +}) + +export const StatusCodeSelectorSchema = z.object({ + type: z.literal('status-code'), +}) + +export const SelectorSchema = z.discriminatedUnion('type', [ + BeginEndSelectorSchema, + RegexSelectorSchema, + JsonSelectorSchema, + HeaderNameSelectorSchema, +]) + +export const VerificationRuleSelectorSchema = z.discriminatedUnion('type', [ + BeginEndSelectorSchema, + RegexSelectorSchema, + JsonSelectorSchema, +]) + +export const CorrelationExtractorSchema = z.object({ + filter: FilterSchema, + selector: SelectorSchema, + variableName: z.string().optional(), +}) + +export const CorrelationReplacerSchema = z.object({ + filter: FilterSchema, + selector: SelectorSchema.optional(), +}) + +export const RuleBaseSchema = z.object({ + id: z.string(), + enabled: z.boolean().default(true), +}) + +export const ParameterizationRuleSchema = RuleBaseSchema.extend({ + type: z.literal('parameterization'), + filter: FilterSchema, + selector: SelectorSchema, + value: z.discriminatedUnion('type', [ + VariableValueSchema, + ArrayValueSchema, + CustomCodeValueSchema, + StringValueSchema, + ]), +}) + +export const CorrelationRuleSchema = RuleBaseSchema.extend({ + type: z.literal('correlation'), + extractor: CorrelationExtractorSchema, + replacer: CorrelationReplacerSchema.optional(), +}) + +export const VerificationRuleSchema = RuleBaseSchema.extend({ + type: z.literal('verification'), + filter: FilterSchema, + selector: VerificationRuleSelectorSchema.optional(), + value: z.discriminatedUnion('type', [ + VariableValueSchema, + ArrayValueSchema, + CustomCodeValueSchema, + RecordedValueSchema, + ]), +}) + +export const CustomCodeRuleSchema = RuleBaseSchema.extend({ + type: z.literal('customCode'), + filter: FilterSchema, + placement: z.enum(['before', 'after']), + snippet: z.string(), +}) + +export const TestRuleSchema = z.discriminatedUnion('type', [ + ParameterizationRuleSchema, + CorrelationRuleSchema, + VerificationRuleSchema, + CustomCodeRuleSchema, +]) diff --git a/src/schemas/generator/v1/testData.ts b/src/schemas/generator/v1/testData.ts new file mode 100644 index 00000000..e81e3733 --- /dev/null +++ b/src/schemas/generator/v1/testData.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' + +export const VariableSchema = z.object({ + name: z + .string() + .min(1, { message: 'Required' }) + .regex(/^[a-zA-Z0-9_]*$/, { message: 'Invalid name' }) + // Don't allow native object properties, like __proto__, valueOf, etc. + .refine((val) => !(val in {}), { message: 'Invalid name' }), + value: z.string(), +}) + +export const TestDataSchema = z.object({ + variables: VariableSchema.array().superRefine((variables, ctx) => { + const names = variables.map((variable) => variable.name) + + const duplicateIndex = variables.findIndex( + (item, index) => names.indexOf(item.name) !== index + ) + + if (duplicateIndex !== -1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Variable names must be unique', + path: [duplicateIndex, 'name'], + }) + } + }), +}) diff --git a/src/schemas/generator/v1/testOptions.ts b/src/schemas/generator/v1/testOptions.ts new file mode 100644 index 00000000..b5c73f73 --- /dev/null +++ b/src/schemas/generator/v1/testOptions.ts @@ -0,0 +1,69 @@ +import { z } from 'zod' + +export const SleepTypeSchema = z.enum(['groups', 'requests', 'iterations']) + +export const FixedTimingSchema = z.object({ + type: z.literal('fixed'), + value: z.number().nonnegative().nullable(), +}) + +export const RangeTimingSchema = z.object({ + type: z.literal('range'), + value: z + .object({ + min: z.number().nonnegative(), + max: z.number().nonnegative(), + }) + .refine(({ min, max }) => max > min, { + message: 'Max must be greater than min', + path: ['max'], + }), +}) + +export const TimingSchema = z.discriminatedUnion('type', [ + FixedTimingSchema, + RangeTimingSchema, +]) + +export const ThinkTimeSchema = z.object({ + sleepType: SleepTypeSchema, + timing: TimingSchema, +}) + +export const CommonOptionsSchema = z.object({ + executor: z.enum(['shared-iterations', 'ramping-vus']), +}) + +export const SharedIterationsOptionsSchema = CommonOptionsSchema.extend({ + executor: z.literal('shared-iterations'), + vus: z.number().nonnegative().int().optional(), + iterations: z.number().nonnegative().int().optional(), +}) + +export const RampingStageSchema = z.object({ + id: z.string().optional(), + target: z.number().nonnegative().int(), + duration: z + .string() + .regex( + /^(\d+([hms]))$|^(\d+h)(\d+m)(\d+s)$|^(\d+h)(\d+m)$|^(\d+m)(\d+s)$/, + { + message: 'Must be in format 1m30s', + } + ), +}) + +export const RampingVUsOptionsSchema = CommonOptionsSchema.extend({ + executor: z.literal('ramping-vus'), + stages: RampingStageSchema.array(), +}) + +export const LoadProfileExecutorOptionsSchema = z.discriminatedUnion( + 'executor', + [SharedIterationsOptionsSchema, RampingVUsOptionsSchema] +) + +export const TestOptionsSchema = z.object({ + loadProfile: LoadProfileExecutorOptionsSchema, + thinkTime: ThinkTimeSchema, +}) diff --git a/src/store/generator/selectors.ts b/src/store/generator/selectors.ts index fa2cf374..3248b20e 100644 --- a/src/store/generator/selectors.ts +++ b/src/store/generator/selectors.ts @@ -48,7 +48,7 @@ export function selectGeneratorData(state: GeneratorStore): GeneratorFileData { } = state return { - version: '0', + version: '1.0', recordingPath, options: { loadProfile, diff --git a/src/test/factories/generator.ts b/src/test/factories/generator.ts index 63c566a7..a9d2db9a 100644 --- a/src/test/factories/generator.ts +++ b/src/test/factories/generator.ts @@ -27,7 +27,7 @@ export function createGeneratorData( testData: { variables: [], }, - version: '1.0.0', + version: '1.0', ...data, } } diff --git a/src/types/rules.ts b/src/types/rules.ts index 4f7c1eb5..bd3e2133 100644 --- a/src/types/rules.ts +++ b/src/types/rules.ts @@ -22,7 +22,7 @@ import { VariableValueSchema, VerificationRuleSchema, VerificationRuleSelectorSchema, -} from '@/schemas/rules' +} from '@/schemas/generator' export interface CorrelationState { extractedValue?: string diff --git a/src/types/testData.ts b/src/types/testData.ts index 8223497a..22adcdf3 100644 --- a/src/types/testData.ts +++ b/src/types/testData.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { VariableSchema, TestDataSchema } from '@/schemas/testData' +import { VariableSchema, TestDataSchema } from '@/schemas/generator' export type Variable = z.infer export type TestData = z.infer diff --git a/src/types/testOptions.ts b/src/types/testOptions.ts index 0c9d5175..5349c4f8 100644 --- a/src/types/testOptions.ts +++ b/src/types/testOptions.ts @@ -11,7 +11,7 @@ import { RampingStageSchema, LoadProfileExecutorOptionsSchema, TestOptionsSchema, -} from '@/schemas/testOptions' +} from '@/schemas/generator' export type SleepType = z.infer export type FixedTiming = z.infer diff --git a/src/utils/generator.ts b/src/utils/generator.ts index 9e9d9f71..0969a971 100644 --- a/src/utils/generator.ts +++ b/src/utils/generator.ts @@ -4,7 +4,7 @@ import { createEmptyRule } from './rules' export function createNewGeneratorFile(recordingPath = ''): GeneratorFileData { return { - version: '0', + version: '1.0', recordingPath, options: { loadProfile: { diff --git a/src/views/Generator/RuleEditor/RuleEditor.tsx b/src/views/Generator/RuleEditor/RuleEditor.tsx index b754d947..728c385e 100644 --- a/src/views/Generator/RuleEditor/RuleEditor.tsx +++ b/src/views/Generator/RuleEditor/RuleEditor.tsx @@ -9,7 +9,7 @@ import { exhaustive } from '@/utils/typescript' import { CorrelationEditor } from './CorrelationEditor' import { CustomCodeEditor } from './CustomCodeEditor' import { TestRule } from '@/types/rules' -import { TestRuleSchema } from '@/schemas/rules' +import { TestRuleSchema } from '@/schemas/generator' import { ParameterizationEditor } from './ParameterizationEditor/ParameterizationEditor' export function RuleEditorSwitch() { diff --git a/src/views/Generator/TestOptions/LoadProfile/LoadProfile.tsx b/src/views/Generator/TestOptions/LoadProfile/LoadProfile.tsx index 4519c86f..5ca4172e 100644 --- a/src/views/Generator/TestOptions/LoadProfile/LoadProfile.tsx +++ b/src/views/Generator/TestOptions/LoadProfile/LoadProfile.tsx @@ -2,7 +2,7 @@ import { useGeneratorStore } from '@/store/generator' import { ExecutorOptions } from './components/ExecutorOptions' import { Executor } from './components/Executor' import { FormProvider, useForm } from 'react-hook-form' -import { LoadProfileExecutorOptionsSchema } from '@/schemas/testOptions' +import { LoadProfileExecutorOptionsSchema } from '@/schemas/generator' import { zodResolver } from '@hookform/resolvers/zod' import { useCallback, useEffect } from 'react' import { LoadProfileExecutorOptions } from '@/types/testOptions' diff --git a/src/views/Generator/TestOptions/ThinkTime.tsx b/src/views/Generator/TestOptions/ThinkTime.tsx index 5564ab28..94d1c31d 100644 --- a/src/views/Generator/TestOptions/ThinkTime.tsx +++ b/src/views/Generator/TestOptions/ThinkTime.tsx @@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { Box, Flex, TextField, Text, Grid } from '@radix-ui/themes' import { selectHasGroups, useGeneratorStore } from '@/store/generator' -import { ThinkTimeSchema } from '@/schemas/testOptions' +import { ThinkTimeSchema } from '@/schemas/generator' import type { ThinkTime } from '@/types/testOptions' import { stringAsNullableNumber, stringAsOptionalNumber } from '@/utils/form' import { FieldGroup } from '@/components/Form' diff --git a/src/views/Generator/TestOptions/VariablesEditor.tsx b/src/views/Generator/TestOptions/VariablesEditor.tsx index 93f5f3f4..147d777e 100644 --- a/src/views/Generator/TestOptions/VariablesEditor.tsx +++ b/src/views/Generator/TestOptions/VariablesEditor.tsx @@ -19,7 +19,7 @@ import { } from 'react-hook-form' import { TestData } from '@/types/testData' import { zodResolver } from '@hookform/resolvers/zod' -import { TestDataSchema } from '@/schemas/testData' +import { TestDataSchema } from '@/schemas/generator' import { FieldGroup } from '@/components/Form' import { Table } from '@/components/Table'