Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions docs/specs/test-reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ specific runs, and share the results with others.

## Requirements

- [x] The user can generate a test report by running `ie test <scenario> //report=<path>`
- [x] The user can generate a test report by running `ie test <scenario> --report=<path>`
- [x] Reports capture the yaml metadata of the scenario.
- [x] Reports store the variables declared in the scenario and their values.
- [x] The report is generated in JSON format.
Expand All @@ -43,7 +43,7 @@ specific runs, and share the results with others.
- The report will be generated in JSON format, but in the future we may consider
other formats like yaml or HTML. JSON format was chosen for v1 because it is
easy to parse, and it is a common format for sharing data.
- Users must specify `//report=<path>` to generate a report. If the path is not
- Users must specify `--report=<path>` to generate a report. If the path is not
specified, the report will not be generated.

### Report schema
Expand Down Expand Up @@ -109,8 +109,8 @@ documentation about each field until then.
"stepName": "First step",
// The step number
"stepNumber": 0,
// Whether the step was successful or not
"success": true,
// The status of the codeblock (success, failure, or pending if never executed).
"status": "success",
// The computed similarity score of the output (between 0 - 1)
"similarityScore": 0
},
Expand All @@ -133,7 +133,7 @@ documentation about each field until then.
"stdOut": "",
"stepName": "Second step",
"stepNumber": 1,
"success": true,
"status": "success",
"similarityScore": 0
}
]
Expand Down Expand Up @@ -186,7 +186,7 @@ The output of the command above should look like this:
"stdOut": "Hello, world!\n",
"stepName": "First step",
"stepNumber": 0,
"success": true,
"status": "success",
"similarityScore": 1
},
{
Expand All @@ -208,7 +208,7 @@ The output of the command above should look like this:
"stdOut": "",
"stepName": "Second step",
"stepNumber": 1,
"success": true,
"status": "success",
"similarityScore": 1
}
]
Expand Down
18 changes: 16 additions & 2 deletions internal/engine/common/codeblock.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ package common

import "github.com/Azure/InnovationEngine/internal/parsers"

const (
STATUS_SUCCESS = "success"
STATUS_FAILURE = "failure"
STATUS_PENDING = "pending"
)

// State for the codeblock in interactive mode. Used to keep track of the
// state of each codeblock.
type StatefulCodeBlock struct {
Expand All @@ -12,12 +18,20 @@ type StatefulCodeBlock struct {
StdOut string `json:"stdOut"`
StepName string `json:"stepName"`
StepNumber int `json:"stepNumber"`
Success bool `json:"success"`
Status string `json:"status"`
SimilarityScore float64 `json:"similarityScore"`
}

// Checks if a codeblock was executed by looking at the
// output, errors, and if success is true.
func (s StatefulCodeBlock) WasExecuted() bool {
return s.StdOut != "" || s.StdErr != "" || s.Error != nil || s.Success
return s.Status != STATUS_PENDING
}

func (s StatefulCodeBlock) Succeeded() bool {
return s.Status == STATUS_SUCCESS
}

func (s StatefulCodeBlock) Failed() bool {
return s.Status == STATUS_FAILURE
}
88 changes: 88 additions & 0 deletions internal/engine/interactive/components.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package interactive

import (
"github.com/charmbracelet/bubbles/paginator"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)

type interactiveModeComponents struct {
paginator paginator.Model
stepViewport viewport.Model
outputViewport viewport.Model
azureCLIViewport viewport.Model
}

// Initializes the viewports for the interactive mode model.
func initializeComponents(model InteractiveModeModel, width, height int) interactiveModeComponents {
// paginator setup
p := paginator.New()
p.TotalPages = len(model.codeBlockState)
p.Type = paginator.Dots
// Dots
p.ActiveDot = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"}).
Render("•")
p.InactiveDot = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "238"}).
Render("•")

p.KeyMap.PrevPage = model.commands.previous
p.KeyMap.NextPage = model.commands.next

stepViewport := viewport.New(width, 4)
outputViewport := viewport.New(width, 2)
azureCLIViewport := viewport.New(width, height)

components := interactiveModeComponents{
paginator: p,
stepViewport: stepViewport,
outputViewport: outputViewport,
azureCLIViewport: azureCLIViewport,
}

components.updateViewportHeight(height)
return components
}

func (components *interactiveModeComponents) updateViewportHeight(terminalHeight int) {
stepViewportPercent := 0.4
outputViewportPercent := 0.2
stepViewportHeight := int(float64(terminalHeight) * stepViewportPercent)
outputViewportHeight := int(float64(terminalHeight) * outputViewportPercent)

if stepViewportHeight < 4 {
stepViewportHeight = 4
}

if outputViewportHeight < 2 {
outputViewportHeight = 2
}

components.stepViewport.Height = stepViewportHeight
components.outputViewport.Height = outputViewportHeight
components.azureCLIViewport.Height = terminalHeight - 1
}

func updateComponents(
components interactiveModeComponents,
currentBlock int,
message tea.Msg,
) (interactiveModeComponents, []tea.Cmd) {
var commands []tea.Cmd
var command tea.Cmd

components.paginator.Page = currentBlock

components.stepViewport, command = components.stepViewport.Update(message)
commands = append(commands, command)

components.outputViewport, command = components.outputViewport.Update(message)
commands = append(commands, command)

components.azureCLIViewport, command = components.azureCLIViewport.Update(message)
commands = append(commands, command)

return components, commands
}
198 changes: 198 additions & 0 deletions internal/engine/interactive/input.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package interactive

import (
"strconv"

"github.com/Azure/InnovationEngine/internal/engine/common"
"github.com/Azure/InnovationEngine/internal/engine/environments"
"github.com/Azure/InnovationEngine/internal/lib"
"github.com/Azure/InnovationEngine/internal/logging"
"github.com/Azure/InnovationEngine/internal/patterns"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)

// All interactive mode inputs.
type InteractiveModeCommands struct {
execute key.Binding
executeAll key.Binding
executeMany key.Binding
next key.Binding
pause key.Binding
previous key.Binding
quit key.Binding
}

// NewInteractiveModeCommands creates a new set of interactive mode commands.
func NewInteractiveModeCommands() InteractiveModeCommands {
return InteractiveModeCommands{
execute: key.NewBinding(
key.WithKeys("e"),
key.WithHelp("e", "Execute the current command."),
),
quit: key.NewBinding(
key.WithKeys("q"),
key.WithHelp("q", "Quit the scenario."),
),
previous: key.NewBinding(
key.WithKeys("left"),
key.WithHelp("←", "Go to the previous command."),
),
next: key.NewBinding(
key.WithKeys("right"),
key.WithHelp("→", "Go to the next command."),
),
// Only enabled when in the azure environment.
executeAll: key.NewBinding(
key.WithKeys("a"),
key.WithHelp("a", "Execute all remaining commands."),
),
executeMany: key.NewBinding(
key.WithKeys("m"),
key.WithHelp("m<number><enter>", "Execute the next <number> commands."),
),
pause: key.NewBinding(
key.WithKeys("p"),
key.WithHelp("p", "Pause execution of commands."),
),
}
}

func handleUserInput(
model InteractiveModeModel,
message tea.KeyMsg,
) (InteractiveModeModel, []tea.Cmd) {
var commands []tea.Cmd

// If we're recording input for a multi-char command,
if model.recordingInput {
isNumber := lib.IsNumber(message.String())

// If the input is a number, append it to the recorded input.
if message.Type == tea.KeyRunes && isNumber {
model.recordedInput += message.String()
return model, commands
}

// If the input is not a number, we'll stop recording input and reset
// the commands remaining to the recorded input.
if message.Type == tea.KeyEnter || !isNumber {
commandsRemaining, _ := strconv.Atoi(model.recordedInput)

if commandsRemaining > len(model.codeBlockState)-model.currentCodeBlock {
commandsRemaining = len(model.codeBlockState) - model.currentCodeBlock
}

logging.GlobalLogger.Debugf("Will execute the next %d steps", commandsRemaining)
model.stepsToBeExecuted = commandsRemaining
commands = append(commands, func() tea.Msg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}
})

model.recordingInput = false
model.recordedInput = ""
logging.GlobalLogger.Debugf(
"Recording input stopped and previously recorded input cleared.",
)
return model, commands
}
}

switch {
case key.Matches(message, model.commands.execute):
if model.executingCommand {
logging.GlobalLogger.Info("Command is already executing, ignoring execute command")
break
}

// Prevent the user from executing a command if the previous command has
// not been executed successfully or executed at all.
previousCodeBlock := model.currentCodeBlock - 1
if previousCodeBlock >= 0 {
previousCodeBlockState := model.codeBlockState[previousCodeBlock]
if !previousCodeBlockState.Succeeded() {
logging.GlobalLogger.Info(
"Previous command has not been executed successfully, ignoring execute command",
)
break
}
}

// Prevent the user from executing a command if the current command has
// already been executed successfully.
codeBlockState := model.codeBlockState[model.currentCodeBlock]
if codeBlockState.Succeeded() {
logging.GlobalLogger.Info(
"Command has already been executed successfully, ignoring execute command",
)
break
}

codeBlock := codeBlockState.CodeBlock

model.executingCommand = true

// If we're on the last step and the command is an SSH command, we need
// to report the status before executing the command. This is needed for
// one click deployments and does not affect the normal execution flow.
if model.currentCodeBlock == len(model.codeBlockState)-1 &&
patterns.SshCommand.MatchString(codeBlock.Content) {
model.azureStatus.Status = "Succeeded"
environments.AttachResourceURIsToAzureStatus(
&model.azureStatus,
model.resourceGroupName,
model.environment,
)

commands = append(commands, tea.Sequence(
common.UpdateAzureStatus(model.azureStatus, model.environment),
func() tea.Msg {
return common.ExecuteCodeBlockSync(codeBlock, lib.CopyMap(model.env))
}))

} else {
commands = append(commands, common.ExecuteCodeBlockAsync(
codeBlock,
lib.CopyMap(model.env),
))
}

case key.Matches(message, model.commands.previous):
if model.executingCommand {
logging.GlobalLogger.Info("Command is already executing, ignoring execute command")
break
}
if model.currentCodeBlock > 0 {
model.currentCodeBlock--
}
case key.Matches(message, model.commands.next):
if model.executingCommand {
logging.GlobalLogger.Info("Command is already executing, ignoring execute command")
break
}
if model.currentCodeBlock < len(model.codeBlockState)-1 {
model.currentCodeBlock++
}

case key.Matches(message, model.commands.quit):
commands = append(commands, tea.Quit)

case key.Matches(message, model.commands.executeAll):
model.stepsToBeExecuted = len(model.codeBlockState) - model.currentCodeBlock
commands = append(
commands,
func() tea.Msg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}
},
)
case key.Matches(message, model.commands.executeMany):
model.recordingInput = true
case key.Matches(message, model.commands.pause):
if !model.executingCommand {
logging.GlobalLogger.Info("No command is currently executing, ignoring pause command")
}
model.stepsToBeExecuted = 0
}

return model, commands
}
Loading