diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxModels.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxModels.ts index f010011550..825353b858 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxModels.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxModels.ts @@ -16,6 +16,7 @@ export interface AtxPlanStep { Status: PlanStepStatus Children: AtxPlanStep[] HasCheckpoint?: boolean + IsStatusOnly?: boolean } /** diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxTransformHandler.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxTransformHandler.ts index 04de8649da..f7e569f669 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxTransformHandler.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxTransformHandler.ts @@ -512,6 +512,7 @@ export class ATXTransformHandler { jobName?: string targetFramework?: string interactiveMode?: InteractiveMode + generateUnitTests?: boolean }): Promise<{ jobId: string; status: string } | null> { try { this.logging.log(`ATX: Starting CreateJob for workspace: ${request.workspaceId}`) @@ -534,6 +535,13 @@ export class ATXTransformHandler { interactive_mode: interactiveModeValue, } + // The customer's up-front unit-test choice. Only a real boolean counts as a choice: + // clients that cannot express one omit the field, and the backend keeps legacy behavior. + // Do NOT default this to false here - an explicit false is a decline, not "no choice". + if (typeof request.generateUnitTests === 'boolean') { + objective.generate_unit_tests = request.generateUnitTests + } + const orchestratorAgent = getAtxOrchestratorAgent() if (process.env.ATX_ORCHESTRATOR_AGENT) { this.logging.log( @@ -1163,6 +1171,7 @@ export class ATXTransformHandler { jobName: request.jobName || 'Transform Job', targetFramework: (request.startTransformRequest as any).TargetFramework, interactiveMode: request.interactiveMode, + generateUnitTests: (request.startTransformRequest as any).GenerateUnitTests, }) if (!createJobResponse?.jobId) { @@ -3653,6 +3662,14 @@ export class ATXTransformHandler { const parent = stepMap.get(step.ParentStepId) if (parent) { parent.Children.push(step) + // Substeps of the unit-test-generation step render status-only in the IDE + // (no checkpoint toggle / "View Results" button / checkpoint checkbox); the + // parent keeps its normal affordance. The service does not send a machine + // label, so we key off the parent's name. Only direct children are marked, + // so the parent "Generate Unit Tests" step itself stays interactive. + if (this.isUnitTestGenerationStep(parent.StepName)) { + step.IsStatusOnly = true + } } else { // Orphan step - treat as root level rootChildren.push(step) @@ -3675,6 +3692,15 @@ export class ATXTransformHandler { * Maps an API step response to AtxPlanStep. * Converts from FES camelCase to C#-compatible PascalCase. */ + /** + * True when a step's name identifies it as the unit-test-generation parent step, whose + * direct substeps (plan / generate / merge / coverage) should render status-only in the IDE. + * Matches on normalized name because the service sends no machine-readable step label. + */ + private isUnitTestGenerationStep(stepName: string | undefined): boolean { + return typeof stepName === 'string' && stepName.trim().toLowerCase() === 'generate unit tests' + } + private mapApiStepToNode(apiStep: any): AtxPlanStep & { score?: number } { return { StepId: apiStep.stepId || '', @@ -3683,6 +3709,12 @@ export class ATXTransformHandler { Description: apiStep.description || '', Status: this.mapApiStatus(apiStep.status), Children: [], + // Defaults false here; the value is assigned structurally during tree assembly + // (buildTreeFromFlatList) for substeps of the unit-test-generation step. The service + // does not send a machine-readable step label, so parent identity — not a label — + // drives this. PascalCase matches the other fields (StepId/HasCheckpoint/...) so it + // binds onto the C# AtxPlanStep.IsStatusOnly. + IsStatusOnly: false, // Keep score for sorting (not sent to C#) score: apiStep.score || 0, } diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/models.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/models.ts index f7580b14f9..990680a232 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/models.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/models.ts @@ -23,6 +23,9 @@ export interface StartTransformRequest extends ExecuteCommandParams { TransformNetStandardProjects: boolean EnableRazorViewTransform: boolean EnableWebFormsTransform: boolean + // Customer's up-front unit-test choice, forwarded to the ATX job objective as + // `generate_unit_tests`. Optional: absent means "no choice sent" (legacy behavior). + GenerateUnitTests?: boolean PackageReferences?: PackageReferenceMetadata[] DmsArn?: string DatabaseSettings?: DatabaseSettings diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/atxTransformHandler.test.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/atxTransformHandler.test.ts index b8a0894b5c..76dfc8bd49 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/atxTransformHandler.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/atxTransformHandler.test.ts @@ -778,6 +778,58 @@ describe('ATXTransformHandler - getTransformationPlan & helpers', () => { expect(node.Status).to.equal('NOT_STARTED') expect(node.score).to.equal(0) }) + + it('mapApiStepToNode defaults IsStatusOnly to false (structural pass assigns it)', () => { + // The service sends no machine-readable step label, so the per-node mapper never + // sets IsStatusOnly; it is assigned during tree assembly based on parent identity. + const node = (handler as any).mapApiStepToNode({ + stepId: 's1', + stepName: 'Merge Tests', + status: 'IN_PROGRESS', + }) + expect(node.IsStatusOnly).to.equal(false) + }) + }) + + describe('buildTreeFromFlatList - IsStatusOnly (unit-test-generation substeps)', () => { + // A realistic flat plan: a "Generate Unit Tests" parent with 4 substeps, plus a + // sibling "Transform Projects" parent with its own substep, all under root. + const flatPlan = () => [ + { stepId: 'gut', parentStepId: 'root', stepName: 'Generate Unit Tests', status: 'NOT_STARTED' }, + { stepId: 'plan', parentStepId: 'gut', stepName: 'Plan Unit Test Generation', status: 'NOT_STARTED' }, + { stepId: 'gen', parentStepId: 'gut', stepName: 'Generate Unit Tests', status: 'NOT_STARTED' }, + { stepId: 'merge', parentStepId: 'gut', stepName: 'Merge Tests', status: 'NOT_STARTED' }, + { stepId: 'cov', parentStepId: 'gut', stepName: 'Get Coverage', status: 'NOT_STARTED' }, + { stepId: 'tp', parentStepId: 'root', stepName: 'Transform Projects', status: 'NOT_STARTED' }, + { stepId: 'build', parentStepId: 'tp', stepName: 'Solution Build', status: 'NOT_STARTED' }, + ] + + const findById = (nodes: any[], id: string): any => { + for (const n of nodes) { + if (n.StepId === id) return n + const hit = findById(n.Children || [], id) + if (hit) return hit + } + return null + } + + it('marks direct substeps of "Generate Unit Tests" as IsStatusOnly=true', () => { + const roots = (handler as any).buildTreeFromFlatList(flatPlan()) + for (const id of ['plan', 'gen', 'merge', 'cov']) { + expect(findById(roots, id).IsStatusOnly, id).to.equal(true) + } + }) + + it('leaves the parent "Generate Unit Tests" step interactive (IsStatusOnly=false)', () => { + const roots = (handler as any).buildTreeFromFlatList(flatPlan()) + expect(findById(roots, 'gut').IsStatusOnly).to.equal(false) + }) + + it('does not mark transformation substeps (different parent)', () => { + const roots = (handler as any).buildTreeFromFlatList(flatPlan()) + expect(findById(roots, 'tp').IsStatusOnly).to.equal(false) + expect(findById(roots, 'build').IsStatusOnly).to.equal(false) + }) }) describe('findCompletedSteps', () => { @@ -2027,6 +2079,47 @@ describe('ATXTransformHandler - lifecycle (startTransform & helpers)', () => { const objective = JSON.parse(command.input.objective) expect(objective.interactive_mode).to.equal('auto') }) + + it('should include generate_unit_tests:true in objective when opted in', async () => { + sendStub.resolves({ jobId: 'j', status: 'CREATED' }) + + await handler.createJob({ workspaceId: 'ws-1', generateUnitTests: true }) + + const command = sendStub.firstCall.args[0] + const objective = JSON.parse(command.input.objective) + expect(objective.generate_unit_tests).to.equal(true) + }) + + it('should include generate_unit_tests:false in objective on explicit decline', async () => { + sendStub.resolves({ jobId: 'j', status: 'CREATED' }) + + await handler.createJob({ workspaceId: 'ws-1', generateUnitTests: false }) + + const command = sendStub.firstCall.args[0] + const objective = JSON.parse(command.input.objective) + expect(objective.generate_unit_tests).to.equal(false) + }) + + it('should omit generate_unit_tests from objective when no choice is sent', async () => { + sendStub.resolves({ jobId: 'j', status: 'CREATED' }) + + await handler.createJob({ workspaceId: 'ws-1' }) + + const command = sendStub.firstCall.args[0] + const objective = JSON.parse(command.input.objective) + expect(objective).to.not.have.property('generate_unit_tests') + }) + + it('should omit generate_unit_tests when the value is not a real boolean', async () => { + sendStub.resolves({ jobId: 'j', status: 'CREATED' }) + + // A mistyped/non-boolean value must read as "no choice sent", not a decision. + await handler.createJob({ workspaceId: 'ws-1', generateUnitTests: 'true' as any }) + + const command = sendStub.firstCall.args[0] + const objective = JSON.parse(command.input.objective) + expect(objective).to.not.have.property('generate_unit_tests') + }) }) describe('createArtifactUploadUrl', () => {