Resolve conditional steps in benchmark wizard#1961
Conversation
| } | ||
|
|
||
| async function hasMultipleAccounts(_context: WizardContext): Promise<boolean> { | ||
| // TODO: Evaluate from connection (e.g. roles.length > 1) and return false when no connection in context. For now always show the accounts step. |
There was a problem hiding this comment.
Will implement this in the follow up PR
| method: string, | ||
| context: WizardContext, | ||
| ): Promise<BenchmarkWizardOption[]>; | ||
| evaluateConditional(when: string, context: WizardContext): Promise<boolean>; |
There was a problem hiding this comment.
im indecisive between evaluateConditional and evaluateConditionalStep
There was a problem hiding this comment.
evaluateCondition or resolveCondition
| if (!skipToStepId) { | ||
| return nextStepIdFromConfig; | ||
| } | ||
| return resolveNextStepId(skipToStepId, config, providerAdapter, context); |
There was a problem hiding this comment.
This is recursive because what if the resolved step is a conditional step?
But I don't think it is a common scenario; I can change it after inputs.
| step: WizardConfigStep, | ||
| request: BenchmarkWizardRequest, | ||
| projectId: string, | ||
| context: WizardContext, |
There was a problem hiding this comment.
instead we build the context and pass it as a whole
| projectId, | ||
| provider, | ||
| }); | ||
| // Returns true until we implement real evaluation (see hasMultipleAccounts in aws-conditional-step-resolver). |
There was a problem hiding this comment.
Will modify it once the real evaluation functions is available in the next PR
There was a problem hiding this comment.
Pull request overview
Implements support for conditional wizard steps in the benchmark flow (OPS-3650), allowing provider-specific logic to decide whether a step should be shown and enabling conditional option sources / skip behavior.
Changes:
- Add
ProviderAdapter.evaluateConditional()and implement AWS conditional evaluation (hasMultipleAccounts). - Update wizard navigation resolution to asynchronously skip conditional steps based on evaluated conditions.
- Update AWS wizard config to use
conditional.onSuccess.optionsSourceandconditional.onFailure.skipToStep, and add unit tests for AWS conditional resolver.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/server/api/src/app/benchmark/wizard.service.ts | Adds async conditional-step resolution and conditional optionsSource selection. |
| packages/server/api/src/app/benchmark/provider-adapter.ts | Extends conditional schema and ProviderAdapter with evaluateConditional. |
| packages/server/api/src/app/benchmark/providers/aws/aws-conditional-step-resolver.ts | Adds AWS conditional evaluation entrypoint (stubbed for now). |
| packages/server/api/src/app/benchmark/providers/aws/index.ts | Wires AWS evaluateConditional into the provider adapter. |
| packages/server/api/src/app/benchmark/providers/aws/aws.json | Updates AWS wizard step conditionals and options source behavior. |
| packages/server/api/test/unit/benchmark/wizard.service.test.ts | Updates wizard service test adapter mock to include evaluateConditional. |
| packages/server/api/test/unit/benchmark/providers/aws/aws-conditional-step-resolver.test.ts | Adds unit tests for AWS conditional evaluation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| mockResolveOptions.mockResolvedValue([]); | ||
| mockEvaluateConditional.mockResolvedValue(true); | ||
| }); |
There was a problem hiding this comment.
The updated conditional behavior isn’t covered by unit tests: mockEvaluateConditional is always set to resolve true, so there’s no assertion that a conditional step is actually skipped (e.g., when it resolves false and onFailure.skipToStep is set). Add a test case where mockEvaluateConditional returns false and verify that navigation skips step2 to step3 and that evaluateConditional is called with the expected when and context.
| const stepsWithOptions = config.steps.filter( | ||
| (s) => s.optionsSource || s.conditional?.onSuccess?.optionsSource, | ||
| ); | ||
| const totalSteps = stepsWithOptions.length; | ||
| const stepIndex = | ||
| stepsWithOptions.findIndex((s) => s.id === stepToReturn.id) + 1; | ||
| return { totalSteps, stepIndex }; |
There was a problem hiding this comment.
getStepProgress counts steps based on presence of an optionsSource (including conditional.onSuccess.optionsSource), but it doesn’t account for conditionals that are evaluated false and skipped. This can make totalSteps/stepIndex incorrect when a conditional step is not shown. Consider computing progress based on the resolved navigation path for the current context (i.e., evaluate conditionals and exclude skipped steps from the count/index).
| const skipToStepId = step.conditional.onFailure?.skipToStep; | ||
| if (!skipToStepId) { | ||
| return nextStepIdFromConfig; | ||
| } | ||
| return resolveNextStepId(skipToStepId, config, providerAdapter, context); | ||
| } |
There was a problem hiding this comment.
resolveNextStepId recursively follows conditional.onFailure.skipToStep without any cycle/depth protection. A misconfigured wizard (e.g., A skips to B and B skips back to A) will cause infinite recursion/stack overflow. Add cycle detection (visited set) or a max-depth guard and throw a validation error when a loop is detected.
| const optionsSource = | ||
| step.conditional?.onSuccess?.optionsSource ?? | ||
| (step.optionsSource as WizardStepOptionsSource | undefined); |
There was a problem hiding this comment.
resolveOptions always prefers step.conditional.onSuccess.optionsSource even though the conditional may evaluate to false (and the step may still be shown if there’s no onFailure.skipToStep). This makes the meaning of onSuccess inconsistent and can return the wrong options source. Consider passing the evaluated conditional result into resolveOptions (or evaluating the conditional there) so the onSuccess options source is only used when the condition actually succeeds.
| const optionsSource = | |
| step.conditional?.onSuccess?.optionsSource ?? | |
| (step.optionsSource as WizardStepOptionsSource | undefined); | |
| const optionsSource = step.optionsSource as | |
| | WizardStepOptionsSource | |
| | undefined; |
| "title": "Which accounts should we include in the report?", | ||
| "selectionType": "multi-select", | ||
| "optionsSource": { "type": "dynamic", "method": "getConnectionAccounts" }, | ||
| "optionsSource": "", |
There was a problem hiding this comment.
optionsSource is set to an empty string, but optionsSource is expected to be an object (or omitted). This relies on falsy behavior and type assertions and may break any runtime validation/parsing of the wizard config. Prefer removing optionsSource entirely for this step (and relying on conditional.onSuccess.optionsSource), or set it to null and explicitly handle null in the loader/types.
| "optionsSource": "", |
| context, | ||
| ); | ||
| const nextStepDef = steps.find((s) => s.id === resolvedNextStepId); | ||
| if (!nextStepDef) { |
There was a problem hiding this comment.
you already throw in getNextStepIdFromConfig
| "title": "Which accounts should we include in the report?", | ||
| "selectionType": "multi-select", | ||
| "optionsSource": { "type": "dynamic", "method": "getConnectionAccounts" }, | ||
| "optionsSource": "", |
There was a problem hiding this comment.
remove this, you should only have it on steps without the conditionals
There was a problem hiding this comment.
I think we decided keep it empty string for conditional steps? But I can remove it
There was a problem hiding this comment.
no, for conditional steps you have it inside the condition, for non conditional steps you have it outside
| providerAdapter: ProviderAdapter, | ||
| context: WizardContext, | ||
| ): Promise<string | null> { | ||
| if (!nextStepIdFromConfig) { |
There was a problem hiding this comment.
why even allow to add in no next stepId? just make nextStepIdFromConfig: string mandatory
|
|
||
| const nextStep = resolveNextStepId(stepToShow, config); | ||
| const nextStepIdFromConfig = getNextStepIdFromConfig(stepToShow, config); | ||
| const nextStep = await resolveNextStepId( |
There was a problem hiding this comment.
This is now wrong.
Imagine this:
User is on step1 and clicks next
you look up step1s nextStep which is → step2
step2 has a conditional → evaluates it → condition is FALSE → step2 should not be shown, you recursively check if step3 should be shown (this is correct in theory but honestly the recursion will just confuse us all more)
stepToShow= step3
nextStepId = step4
step2 has a conditional → evaluates it → condition is TRUE → step2 should be shown with its options
stepToShow = step2
nextStepId = step3
BUT what happens now is you go in resolveNextStepId() with step3.id, and you go through the whole loop. no matter whats the case if step2 conditon evaluates to false it means you HAVE to skip to the next step.
There was a problem hiding this comment.
if the condition is TRUE, we show the step.
I've named the method as hasMultipleAccounts and the success path would be to resolve options for the step.
There was a problem hiding this comment.
you are right, i wrote my comment the wrong we around, i edited to fix it. The point I made is still correct though
Like maybe write a test case with 4 steps, where step 2 evaluates to TRUE and step 3 evaluates to FALSE. See what you get back from this method matches what you expect.
I suspect you get {stepToShow: step2, nextStepId: step4 }
| }; | ||
|
|
||
| const { stepToShow, nextStep } = computeWizardStepResponse( | ||
| const { stepToShow, nextStep } = await computeWizardStepResponse( |
There was a problem hiding this comment.
im sorry i missed it earlier, but it led me to be confused in some places now.
in here nextStep seems to be nextStepID
whereas stepToShow is an actual step.
There was a problem hiding this comment.
yeah, here nextStep is the ID. I'm using nextStep here to match the usage in json file. I will see if I can make is more clear to read.
| return resolveNextStepId(skipToStepId, config, providerAdapter, context); | ||
| } | ||
|
|
||
| async function computeWizardStepResponse( |
There was a problem hiding this comment.
i think the problem is that we are doing 2 things here.
- we navigate to the next step
- we look ahead at whats the next step id for the response to the frontend
in one case nextStep is the next step to navigate to and then later the next step is the nextsteps.nextstep to return
| } | ||
| const step = config.steps.find((s) => s.id === nextStepIdFromConfig); |
There was a problem hiding this comment.
this is a bit funny, its needed bc of the recursion probably but in essence what you do is
- you get a stepId
- you call getNextStepIdFromConfig - which gets the steps[stepId]
- you throw if there is no step
- you return the stepId
- you call this function with the ID to then get the step AGAIN from steps
- you throw again if there is no step
| onSuccess?: { | ||
| optionsSource?: WizardStepOptionsSource; | ||
| }; | ||
| onFailure?: { | ||
| skipToStep?: string; | ||
| }; |
There was a problem hiding this comment.
Aren't these fields mandatory?
| method: string, | ||
| context: WizardContext, | ||
| ): Promise<BenchmarkWizardOption[]>; | ||
| evaluateConditional(when: string, context: WizardContext): Promise<boolean>; |
There was a problem hiding this comment.
evaluateCondition or resolveCondition
|



Part of OPS-3650.
Additional Notes