diff --git a/packages/core/src/stepFunctions/commands/downloadStateMachineDefinition.ts b/packages/core/src/stepFunctions/commands/downloadStateMachineDefinition.ts index e89dad1ffcd..537e6535241 100644 --- a/packages/core/src/stepFunctions/commands/downloadStateMachineDefinition.ts +++ b/packages/core/src/stepFunctions/commands/downloadStateMachineDefinition.ts @@ -17,7 +17,7 @@ import { Result } from '../../shared/telemetry/telemetry' import { StateMachineNode } from '../explorer/stepFunctionsNodes' import { telemetry } from '../../shared/telemetry/telemetry' import { fs } from '../../shared/fs/fs' -import { WorkflowStudioEditorProvider } from '../workflowStudio/workflowStudioEditorProvider' +import { openWorkflowStudioWithDefinition } from '../utils' export async function downloadStateMachineDefinition(params: { outputChannel: vscode.OutputChannel @@ -35,13 +35,7 @@ export async function downloadStateMachineDefinition(params: { }) if (params.isPreviewAndRender) { - const doc = await vscode.workspace.openTextDocument({ - language: 'asl', - content: stateMachineDetails.definition, - }) - - const textEditor = await vscode.window.showTextDocument(doc) - await WorkflowStudioEditorProvider.openWithWorkflowStudio(textEditor.document.uri, { + await openWorkflowStudioWithDefinition(stateMachineDetails.definition, { preserveFocus: true, viewColumn: vscode.ViewColumn.Beside, }) diff --git a/packages/core/src/stepFunctions/executionDetails/executionDetailProvider.ts b/packages/core/src/stepFunctions/executionDetails/executionDetailProvider.ts index 29710c5357f..8b1aa19be3c 100644 --- a/packages/core/src/stepFunctions/executionDetails/executionDetailProvider.ts +++ b/packages/core/src/stepFunctions/executionDetails/executionDetailProvider.ts @@ -35,7 +35,7 @@ export class ExecutionDetailProvider { const panel = vscode.window.createWebviewPanel( ExecutionDetailProvider.viewType, `Execution: ${executionArn.split(':').pop() || executionArn}`, - vscode.ViewColumn.Beside, + vscode.ViewColumn.One, { enableScripts: true, retainContextWhenHidden: true, @@ -96,6 +96,7 @@ export class ExecutionDetailProvider { * Initializes a WebView panel with execution details. * @param panel The WebView panel to initialize * @param executionArn The ARN of the execution to display + * @param startTime Optional start time for the execution */ public async initializePanel(panel: vscode.WebviewPanel, executionArn: string, startTime?: string): Promise { try { diff --git a/packages/core/src/stepFunctions/executionDetails/handleMessage.ts b/packages/core/src/stepFunctions/executionDetails/handleMessage.ts index 4970889f032..7fba7b77633 100644 --- a/packages/core/src/stepFunctions/executionDetails/handleMessage.ts +++ b/packages/core/src/stepFunctions/executionDetails/handleMessage.ts @@ -17,6 +17,8 @@ import { handleUnsupportedMessage, apiCallMessageHandler, } from '../messageHandlers/handleMessageHelpers' +import { parseExecutionArnForStateMachine, openWorkflowStudio, showExecuteStateMachineWebview } from '../utils' +import { getLogger } from '../../shared/logger/logger' /** * Handles messages received from the ExecutionDetails webview. Depending on the message type and command, @@ -34,6 +36,12 @@ export async function handleMessage(message: Message, context: ExecutionDetailsC case Command.API_CALL: void apiCallMessageHandler(message as ApiCallRequestMessage, context) break + case Command.START_EXECUTION: + void startExecutionMessageHandler(context) + break + case Command.EDIT_STATE_MACHINE: + void editStateMachineMessageHandler(context) + break default: void handleUnsupportedMessage(context, message) break @@ -74,3 +82,29 @@ async function initMessageHandler(context: ExecutionDetailsContext) { } as InitResponseMessage) } } + +async function startExecutionMessageHandler(context: ExecutionDetailsContext) { + const logger = getLogger('stepfunctions') + try { + // Parsing execution ARN to get state machine info + const parsedArn = parseExecutionArnForStateMachine(context.executionArn) + if (!parsedArn) { + throw new Error(`Invalid execution ARN format: ${context.executionArn}`) + } + + const { region, stateMachineName, stateMachineArn } = parsedArn + + await showExecuteStateMachineWebview({ + arn: stateMachineArn, + name: stateMachineName, + region: region, + }) + } catch (error) { + logger.error('Start execution failed: %O', error) + } +} + +async function editStateMachineMessageHandler(context: ExecutionDetailsContext) { + const params = parseExecutionArnForStateMachine(context.executionArn) + await openWorkflowStudio(params!.stateMachineArn, params!.region) +} diff --git a/packages/core/src/stepFunctions/messageHandlers/types.ts b/packages/core/src/stepFunctions/messageHandlers/types.ts index 52e8964b5a7..1e0283a2750 100644 --- a/packages/core/src/stepFunctions/messageHandlers/types.ts +++ b/packages/core/src/stepFunctions/messageHandlers/types.ts @@ -66,6 +66,8 @@ export enum Command { CLOSE_WFS = 'CLOSE_WFS', API_CALL = 'API_CALL', UNSUPPORTED_COMMAND = 'UNSUPPORTED_COMMAND', + START_EXECUTION = 'START_EXECUTION', + EDIT_STATE_MACHINE = 'EDIT_STATE_MACHINE', } export type FileWatchInfo = { diff --git a/packages/core/src/stepFunctions/utils.ts b/packages/core/src/stepFunctions/utils.ts index 93597000f32..e7e8e5173ff 100644 --- a/packages/core/src/stepFunctions/utils.ts +++ b/packages/core/src/stepFunctions/utils.ts @@ -17,12 +17,20 @@ import { } from 'amazon-states-language-service' import { fromExtensionManifest } from '../shared/settings' import { IamRole } from '../shared/clients/iam' +import { WorkflowStudioEditorProvider } from './workflowStudio/workflowStudioEditorProvider' +import { VueWebview } from '../webviews/main' +import { ExecuteStateMachineWebview } from './vue/executeStateMachine/executeStateMachine' +import globals from '../shared/extensionGlobals' const documentSettings: DocumentLanguageSettings = { comments: 'error', trailingCommas: 'error' } const languageService = getLanguageService({}) const arnResourceTypeSegmentIndex = 5 const expressExecutionArnSegmentCount = 9 +const executionArnSegmentCount = 8 +const arnRegionSegmentIndex = 3 +const arnAccountIdSegmentIndex = 4 +const arnStateMachineNameSegmentIndex = 6 export async function* listStateMachines( client: StepFunctionsClient @@ -107,6 +115,91 @@ export const isExpressExecution = (arn: string): boolean => { ) } +/** + * Parses an execution ARN to extract state machine information + * @param executionArn The execution ARN to parse + * @returns Object containing region, state machine name, and state machine ARN + */ +export const parseExecutionArnForStateMachine = (executionArn: string) => { + const arnSegments = executionArn.split(':') + if (arnSegments.length === executionArnSegmentCount || arnSegments.length === expressExecutionArnSegmentCount) { + const region = arnSegments[arnRegionSegmentIndex] + const stateMachineName = arnSegments[arnStateMachineNameSegmentIndex] + const stateMachineArn = `arn:aws:states:${region}:${arnSegments[arnAccountIdSegmentIndex]}:stateMachine:${stateMachineName}` + + return { + region, + stateMachineName, + stateMachineArn, + } + } +} + +/** + * Opens a state machine definition in Workflow Studio + * @param stateMachineArn The ARN of the state machine + * @param region The AWS region + */ +export const openWorkflowStudio = async (stateMachineArn: string, region: string) => { + const client: StepFunctionsClient = new StepFunctionsClient(region) + const stateMachineDetails: StepFunctions.DescribeStateMachineCommandOutput = await client.getStateMachineDetails({ + stateMachineArn, + }) + + await openWorkflowStudioWithDefinition(stateMachineDetails.definition) +} + +/** + * Opens a state machine definition in Workflow Studio using pre-fetched definition content + * @param definition The state machine definition content + * @param options Optional webview configuration options + */ +export const openWorkflowStudioWithDefinition = async ( + definition: string | undefined, + options?: { + preserveFocus?: boolean + viewColumn?: vscode.ViewColumn + } +) => { + const doc = await vscode.workspace.openTextDocument({ + language: 'asl', + content: definition, + }) + + const textEditor = await vscode.window.showTextDocument(doc) + await WorkflowStudioEditorProvider.openWithWorkflowStudio(textEditor.document.uri, { + preserveFocus: options?.preserveFocus ?? false, + viewColumn: options?.viewColumn ?? vscode.ViewColumn.One, + }) +} + +/** + * Shows the Execute State Machine webview with the provided state machine data + * @param extensionContext The extension context + * @param outputChannel The output channel for logging + * @param stateMachineData Object containing arn, name, and region of the state machine + * @returns The webview instance + */ +export const showExecuteStateMachineWebview = async (stateMachineData: { + arn: string + name: string + region: string +}) => { + const Panel = VueWebview.compilePanel(ExecuteStateMachineWebview) + const wv = new Panel(globals.context, globals.outputChannel, { + arn: stateMachineData.arn, + name: stateMachineData.name, + region: stateMachineData.region, + }) + + await wv.show({ + title: localize('AWS.executeStateMachine.title', 'Start Execution'), + cssFiles: ['executeStateMachine.css'], + }) + + return wv +} + const isInvalidJson = (content: string): boolean => { try { JSON.parse(content) diff --git a/packages/core/src/stepFunctions/vue/executeStateMachine/executeStateMachine.ts b/packages/core/src/stepFunctions/vue/executeStateMachine/executeStateMachine.ts index 579005a09a4..dbff1d2cd6c 100644 --- a/packages/core/src/stepFunctions/vue/executeStateMachine/executeStateMachine.ts +++ b/packages/core/src/stepFunctions/vue/executeStateMachine/executeStateMachine.ts @@ -16,6 +16,7 @@ import { VueWebview } from '../../../webviews/main' import * as vscode from 'vscode' import { telemetry } from '../../../shared/telemetry/telemetry' import { ExecutionDetailProvider } from '../../executionDetails/executionDetailProvider' +import { showExecuteStateMachineWebview } from '../../utils' interface StateMachine { arn: string @@ -88,18 +89,11 @@ export class ExecuteStateMachineWebview extends VueWebview { } } -const Panel = VueWebview.compilePanel(ExecuteStateMachineWebview) - export async function executeStateMachine(context: ExtContext, node: StateMachineNode): Promise { - const wv = new Panel(context.extensionContext, context.outputChannel, { + await showExecuteStateMachineWebview({ arn: node.details.stateMachineArn || '', name: node.details.name || '', region: node.regionCode, }) - - await wv.show({ - title: localize('AWS.executeStateMachine.title', 'Start Execution'), - cssFiles: ['executeStateMachine.css'], - }) telemetry.stepfunctions_executeStateMachineView.emit() } diff --git a/packages/core/src/test/stepFunctions/utils.test.ts b/packages/core/src/test/stepFunctions/utils.test.ts index 2616316b6fd..e423088ef2d 100644 --- a/packages/core/src/test/stepFunctions/utils.test.ts +++ b/packages/core/src/test/stepFunctions/utils.test.ts @@ -5,9 +5,78 @@ import assert from 'assert' import * as vscode from 'vscode' -import { isDocumentValid, isStepFunctionsRole } from '../../stepFunctions/utils' +import { + isDocumentValid, + isStepFunctionsRole, + isInvalidJsonFile, + isInvalidYamlFile, + isExpressExecution, + parseExecutionArnForStateMachine, +} from '../../stepFunctions/utils' import { IamRole } from '../../shared/clients/iam' +// Test Helpers +async function createTextDocument(language: string, content: string): Promise { + return await vscode.workspace.openTextDocument({ + language, + content, + }) +} + +function createValidAsl(startAt: string = 'FirstMatchState', customStates?: any): string { + const defaultStates = { + FirstMatchState: { + Type: 'Task', + Resource: 'arn:aws:lambda:us-west-2:000000000000:function:OnFirstMatch', + End: true, + }, + } + + return JSON.stringify({ + StartAt: startAt, + States: customStates || defaultStates, + }) +} + +function createInvalidAsl(): string { + return JSON.stringify({ + StartAt: 'Does not exist', + States: { + FirstMatchState: { + Type: 'Task', + Resource: 'arn:aws:lambda:us-west-2:000000000000:function:OnFirstMatch', + End: true, + }, + }, + }) +} + +function createIamRoleWithPolicy(servicePrincipals: string[]): IamRole { + const baseRole: IamRole = { + Path: '', + RoleName: '', + RoleId: 'myRole', + Arn: 'arn:aws:iam::123456789012:role/myRole', + CreateDate: new Date(), + } + + return { + ...baseRole, + AssumeRolePolicyDocument: JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { + Service: servicePrincipals, + }, + Action: ['sts:AssumeRole'], + }, + ], + }), + } +} + describe('isStepFunctionsRole', function () { const baseIamRole: IamRole = { Path: '', @@ -18,21 +87,7 @@ describe('isStepFunctionsRole', function () { } it('return true if the Step Functions service principal is in the AssumeRolePolicyDocument', function () { - const role: IamRole = { - ...baseIamRole, - AssumeRolePolicyDocument: JSON.stringify({ - Version: '2012-10-17', - Statement: [ - { - Effect: 'Allow', - Principal: { - Service: ['states.amazonaws.com'], - }, - Action: ['sts:AssumeRole'], - }, - ], - }), - } + const role = createIamRoleWithPolicy(['states.amazonaws.com']) assert.ok(isStepFunctionsRole(role)) }) @@ -41,81 +96,173 @@ describe('isStepFunctionsRole', function () { }) it("returns false if the AssumeRolePolicyDocument does not contain Step Functions' service principal", () => { - const role: IamRole = { - ...baseIamRole, - AssumeRolePolicyDocument: JSON.stringify({ - Version: '2012-10-17', - Statement: [ - { - Effect: 'Allow', - Principal: { - Service: ['lambda.amazonaws.com'], - }, - Action: ['sts:AssumeRole'], - }, - ], - }), - } + const role = createIamRoleWithPolicy(['lambda.amazonaws.com']) assert.ok(!isStepFunctionsRole(role)) }) }) describe('isDocumentValid', async function () { it('returns true for valid ASL', async function () { - const aslText = ` - { - "StartAt": "FirstMatchState", - "States": { - "FirstMatchState": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-west-2:000000000000:function:OnFirstMatch", - "End": true - } - } - } ` - - const textDocument = await vscode.workspace.openTextDocument({ language: 'asl' }) + const aslText = createValidAsl() + const textDocument = await createTextDocument('asl', '') const isValid = await isDocumentValid(aslText, textDocument) assert.ok(isValid) }) it('returns true for ASL with invalid arns', async function () { - const aslText = ` - { - "StartAt": "FirstMatchState", - "States": { - "FirstMatchState": { - "Type": "Task", - "Resource": "arn:aws:lambda:REGION:ACCOUNT_ID:function:OnFirstMatch", - "End": true - } - } - } ` - - const textDocument = await vscode.workspace.openTextDocument({ language: 'asl' }) + const aslText = createValidAsl('FirstMatchState', { + FirstMatchState: { + Type: 'Task', + Resource: 'arn:aws:lambda:REGION:ACCOUNT_ID:function:OnFirstMatch', + End: true, + }, + }) + const textDocument = await createTextDocument('asl', '') const isValid = await isDocumentValid(aslText, textDocument) assert.ok(isValid) }) it('returns false for invalid ASL', async function () { - const aslText = ` - { - "StartAt": "Does not exist", - "States": { - "FirstMatchState": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-west-2:000000000000:function:OnFirstMatch", - "End": true - } - } - } ` - - const textDocument = await vscode.workspace.openTextDocument({ language: 'asl' }) + const aslText = createInvalidAsl() + const textDocument = await createTextDocument('asl', '') const isValid = await isDocumentValid(aslText, textDocument) - assert.ok(!isValid) }) }) + +describe('isInvalidJsonFile', function () { + it('returns false for valid JSON with ASL language ID', async function () { + const validJson = '{"StartAt": "Test", "States": {"Test": {"Type": "Pass", "End": true}}}' + const textDocument = await createTextDocument('asl', validJson) + + assert.strictEqual(isInvalidJsonFile(textDocument), false) + }) + + it('returns true for invalid JSON with ASL language ID', async function () { + const invalidJson = '{"StartAt": "Test", "States": {' + const textDocument = await createTextDocument('asl', invalidJson) + + assert.strictEqual(isInvalidJsonFile(textDocument), true) + }) + + it('returns false for empty content with ASL language ID', async function () { + const textDocument = await createTextDocument('asl', '') + + assert.strictEqual(isInvalidJsonFile(textDocument), false) + }) + + it('returns false for whitespace-only content with ASL language ID', async function () { + const textDocument = await createTextDocument('asl', ' \n\t ') + + assert.strictEqual(isInvalidJsonFile(textDocument), false) + }) + + it('returns false for valid JSON with non-ASL language ID', async function () { + const validJson = '{"test": "value"}' + const textDocument = await createTextDocument('json', validJson) + + assert.strictEqual(isInvalidJsonFile(textDocument), false) + }) + + it('returns false for invalid JSON with non-ASL language ID', async function () { + const invalidJson = '{"test": invalid}' + const textDocument = await createTextDocument('json', invalidJson) + + assert.strictEqual(isInvalidJsonFile(textDocument), false) + }) +}) + +describe('isInvalidYamlFile', function () { + it('returns false for valid YAML with ASL-YAML language ID', async function () { + const validYaml = 'StartAt: Test\nStates:\n Test:\n Type: Pass\n End: true' + const textDocument = await createTextDocument('asl-yaml', validYaml) + + assert.strictEqual(isInvalidYamlFile(textDocument), false) + }) + + it('returns true for invalid YAML with ASL-YAML language ID', async function () { + const invalidYaml = 'StartAt: Test\nStates:\n Test:\n Type: Pass\n End: true\n - invalid list item' + const textDocument = await createTextDocument('asl-yaml', invalidYaml) + + assert.strictEqual(isInvalidYamlFile(textDocument), true) + }) + + it('returns false for empty content with ASL-YAML language ID', async function () { + const textDocument = await createTextDocument('asl-yaml', '') + + assert.strictEqual(isInvalidYamlFile(textDocument), false) + }) + + it('returns false for valid YAML with non-ASL-YAML language ID', async function () { + const validYaml = 'key: value\nlist:\n - item1\n - item2' + const textDocument = await createTextDocument('yaml', validYaml) + + assert.strictEqual(isInvalidYamlFile(textDocument), false) + }) + + it('returns false for invalid YAML with non-ASL-YAML language ID', async function () { + const invalidYaml = 'key: value\n - invalid' + const textDocument = await createTextDocument('yaml', invalidYaml) + + assert.strictEqual(isInvalidYamlFile(textDocument), false) + }) +}) + +describe('isExpressExecution', function () { + it('returns true for express execution ARN', function () { + const expressArn = 'arn:aws:states:us-east-1:123456789012:express:stateMachine:MyStateMachine:execution-name' + assert.strictEqual(isExpressExecution(expressArn), true) + }) + + it('returns false for standard execution ARN', function () { + const standardArn = 'arn:aws:states:us-west-2:987654321098:stateMachine:TestMachine:execution-id' + assert.strictEqual(isExpressExecution(standardArn), false) + }) + + it('returns false for ARN with express format but different resource type', function () { + const arnWithDifferentType = + 'arn:aws:states:us-east-1:123456789012:standard:stateMachine:MyStateMachine:execution-name' + assert.strictEqual(isExpressExecution(arnWithDifferentType), false) + }) + + it('returns false for malformed ARN with wrong segment count', function () { + const malformedArn = 'arn:aws:states:us-east-1:123456789012:stateMachine:execution-name' + assert.strictEqual(isExpressExecution(malformedArn), false) + }) + + it('returns false for empty string', function () { + assert.strictEqual(isExpressExecution(''), false) + }) + + it('returns false for ARN with correct segment count but no express type', function () { + const arnWithoutExpress = + 'arn:aws:states:us-east-1:123456789012:other:stateMachine:MyStateMachine:execution-name' + assert.strictEqual(isExpressExecution(arnWithoutExpress), false) + }) +}) + +describe('parseExecutionArnForStateMachine', function () { + it('parses express execution ARN correctly', function () { + const expressArn = + 'arn:aws:states:us-east-1:640351538274:express:testexpress:b46b000d-c25d-4bbe-98c7-ee48978cbc51:99df0098-6f92-468c-9429-a7798be2f8a7' + const result = parseExecutionArnForStateMachine(expressArn) + + assert.ok(result) + assert.strictEqual(result.region, 'us-east-1') + assert.strictEqual(result.stateMachineName, 'testexpress') + assert.strictEqual(result.stateMachineArn, 'arn:aws:states:us-east-1:640351538274:stateMachine:testexpress') + }) + + it('parses standard execution ARN correctly', function () { + const standardArn = 'arn:aws:states:us-west-2:640351538274:execution:test:9738b8de-2433-414c-9182-95dd746b7b9e' + const result = parseExecutionArnForStateMachine(standardArn) + + assert.ok(result) + assert.strictEqual(result.region, 'us-west-2') + assert.strictEqual(result.stateMachineName, 'test') + assert.strictEqual(result.stateMachineArn, 'arn:aws:states:us-west-2:640351538274:stateMachine:test') + }) +})