Skip to content
64 changes: 64 additions & 0 deletions cmd/megaport/help_wasm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//go:build js && wasm

package megaport

import (
"testing"

"github.com/megaport/megaport-cli/internal/wasm"
"github.com/stretchr/testify/assert"
)

// ESD-1650: the command tree persists across WASM invocations in a browser
// session, so a naive SetHelpFunc that rebuilds a command's help text from its
// own (already mutated) cmd.Long re-colors and re-suffixes it on every call.
// These tests drive the real --help path (ExecuteWithArgs) so they fail if
// that regresses.

// TestWasmHelp_RepeatedInvocationsProduceIdenticalOutput covers the
// accumulation bug directly: calling --help on the same subcommand twice must
// yield byte-identical output, not growing/duplicated help text.
func TestWasmHelp_RepeatedInvocationsProduceIdenticalOutput(t *testing.T) {
wasm.ResetOutputBuffers()
_ = ExecuteWithArgs([]string{"megaport-cli", "ports", "list", "--help"})
first := wasm.WasmOutputBuffer.String()
assert.NotEmpty(t, first)

wasm.ResetOutputBuffers()
_ = ExecuteWithArgs([]string{"megaport-cli", "ports", "list", "--help"})
second := wasm.WasmOutputBuffer.String()

assert.Equal(t, first, second, "repeated --help calls must produce identical output, not accumulate coloring/suffixes")
}

// TestWasmHelp_RepeatedInvocationsAcrossManyCalls guards against slow growth
// that a two-call comparison might miss.
func TestWasmHelp_RepeatedInvocationsAcrossManyCalls(t *testing.T) {
wasm.ResetOutputBuffers()
_ = ExecuteWithArgs([]string{"megaport-cli", "ports", "list", "--help"})
baseline := wasm.WasmOutputBuffer.String()
assert.NotEmpty(t, baseline)

for i := 0; i < 5; i++ {
wasm.ResetOutputBuffers()
_ = ExecuteWithArgs([]string{"megaport-cli", "ports", "list", "--help"})
out := wasm.WasmOutputBuffer.String()
assert.Equal(t, baseline, out, "help output must not drift across repeated invocations")
}
}

// TestWasmHelp_RootCommandRepeatedInvocationsProduceIdenticalOutput covers the
// same guarantee for the root command's help, which is rebuilt from a static
// LongDesc string each time rather than a cached original.
func TestWasmHelp_RootCommandRepeatedInvocationsProduceIdenticalOutput(t *testing.T) {
wasm.ResetOutputBuffers()
_ = ExecuteWithArgs([]string{"megaport-cli", "--help"})
first := wasm.WasmOutputBuffer.String()
assert.NotEmpty(t, first)

wasm.ResetOutputBuffers()
_ = ExecuteWithArgs([]string{"megaport-cli", "--help"})
second := wasm.WasmOutputBuffer.String()

assert.Equal(t, first, second, "repeated root --help calls must produce identical output")
}
12 changes: 11 additions & 1 deletion cmd/megaport/megaport_wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,18 +183,28 @@ func init() {
}
}

// The command tree persists across WASM invocations in a browser session, so cache each
// command's pristine Long text the first time it's seen. Without this, repeated --help
// calls would feed the already-colored, already-suffixed cmd.Long back into the builder.
originalLongs := make(map[*cobra.Command]string)

// Create a help function that runs the help.CommandHelpBuilder with the current noColor setting
rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
// For the root command, regenerate the help text completely
if cmd == rootCmd {
rootHelp := getRootHelpBuilder(noColor)
cmd.Long = rootHelp.Build(rootCmd)
} else if cmd.Long != "" {
original, cached := originalLongs[cmd]
if !cached {
original = cmd.Long
originalLongs[cmd] = original
}
// For non-root commands, modify the existing help text only if there is a Long description
helpBuilder := &help.CommandHelpBuilder{
CommandName: cmd.UseLine(),
ShortDesc: cmd.Short,
LongDesc: cmd.Long,
LongDesc: original,
DisableColor: noColor,
}
cmd.Long = helpBuilder.Build(rootCmd)
Expand Down
6 changes: 6 additions & 0 deletions internal/base/output/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,17 @@ func ResetErrorEmitted() {
errorEmittedMu.Unlock()
}

// resetWasmStructuredBuffers clears the WASM structured-output buffers
// (JSON/CSV/XML/table) between command invocations. It is a no-op on native
// builds; output_wasm.go's init overrides it.
var resetWasmStructuredBuffers = func() {}

// ResetState clears all output configuration back to defaults.
// Intended for the WASM entry point to prevent state bleed between invocations.
func ResetState() {
ApplyOutputConfig(defaultOutputConfig())
ResetErrorEmitted()
resetWasmStructuredBuffers()
}

// printOptions is the per-call snapshot of the field/query/header/template
Expand Down
83 changes: 63 additions & 20 deletions internal/base/output/output_wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,27 @@ var WasmCSVWriter = &bytes.Buffer{}
// WasmXMLWriter is a global buffer for capturing XML output in WASM
var WasmXMLWriter = &bytes.Buffer{}

// printJSON is the WASM-specific implementation that properly captures JSON output
func printJSON[T OutputFields](data []T, opts printOptions) error {
func init() {
resetWasmStructuredBuffers = func() {
wasmBufMu.Lock()
defer wasmBufMu.Unlock()
WasmJSONWriter.Reset()
WasmCSVWriter.Reset()
WasmXMLWriter.Reset()
WasmTableWriter.Reset()
}
}

// printJSON is the WASM-specific implementation that properly captures JSON output.
// A command may call this more than once (multiple PrintOutput calls emitting
// several structured documents); each call appends to WasmJSONWriter rather than
// overwriting it, so the global reflects every document emitted so far. The
// buffer is cleared between WASM invocations by resetWasmStructuredBuffers, so
// callers must not invoke this per data item in a loop; it accumulates without
// bound within an invocation.
func printJSON[T OutputFields](data []T, opts printOptions) (err error) {
wasmBufMu.Lock()
defer wasmBufMu.Unlock()
WasmJSONWriter.Reset()

if data == nil {
data = []T{}
Expand All @@ -45,6 +61,13 @@ func printJSON[T OutputFields](data []T, opts printOptions) error {
return err
}

before := WasmJSONWriter.Len()
defer func() {
if err != nil {
WasmJSONWriter.Truncate(before)
}
}()

encoder := json.NewEncoder(WasmJSONWriter)
encoder.SetIndent("", " ")
if err := encoder.Encode(toEncode); err != nil {
Expand All @@ -56,20 +79,30 @@ func printJSON[T OutputFields](data []T, opts printOptions) error {
// GetCapturedOutput/GetCompletionOutput and written to xterm without
// escaping, and a field value can carry an API-controlled control byte
// that Go's JSON encoder does not escape (e.g. the C1 range). See
// wasm.SanitizeTerminalOutput.
jsonOutput := wasm.SanitizeTerminalOutput(WasmJSONWriter.String())
fmt.Print(jsonOutput)
// wasm.SanitizeTerminalOutput. Sanitize the new slice on its own rather
// than slicing the sanitized whole buffer by a raw-byte offset: sanitizing
// drops bytes, so a raw offset no longer lines up with the sanitized string.
raw := WasmJSONWriter.String()
fmt.Print(wasm.SanitizeTerminalOutput(raw[before:]))
jsonOutput := wasm.SanitizeTerminalOutput(raw)
js.Global().Set("wasmJSONOutput", jsonOutput)
return nil
}

// printCSV is the WASM-specific implementation that properly captures CSV output
func printCSV[T OutputFields](data []T, opts printOptions) error {
// printCSV is the WASM-specific implementation that properly captures CSV
// output. Like printJSON, it appends to WasmCSVWriter so a command emitting
// multiple CSV documents has all of them captured, not just the last.
func printCSV[T OutputFields](data []T, opts printOptions) (err error) {
wasmBufMu.Lock()
defer wasmBufMu.Unlock()
WasmCSVWriter.Reset()

before := WasmCSVWriter.Len()
w := csv.NewWriter(WasmCSVWriter)
defer func() {
if err != nil {
WasmCSVWriter.Truncate(before)
}
}()
defer w.Flush()

var sample T
Expand Down Expand Up @@ -185,17 +218,29 @@ func printCSV[T OutputFields](data []T, opts printOptions) error {
w.Flush()

// See the sanitize comment in printJSON above; the same applies to CSV.
csvOutput := wasm.SanitizeTerminalOutput(WasmCSVWriter.String())
fmt.Print(csvOutput)
raw := WasmCSVWriter.String()
fmt.Print(wasm.SanitizeTerminalOutput(raw[before:]))
csvOutput := wasm.SanitizeTerminalOutput(raw)
js.Global().Set("wasmCSVOutput", csvOutput)
return nil
}

// printXML is the WASM-specific implementation that properly captures XML output
func printXML[T OutputFields](data []T, opts printOptions) error {
// printXML is the WASM-specific implementation that properly captures XML
// output. Like printJSON, it appends to WasmXMLWriter so a command emitting
// multiple XML documents has all of them captured, not just the last.
func printXML[T OutputFields](data []T, opts printOptions) (err error) {
wasmBufMu.Lock()
defer wasmBufMu.Unlock()
WasmXMLWriter.Reset()

before := WasmXMLWriter.Len()
encoder := xml.NewEncoder(WasmXMLWriter)
encoder.Indent("", " ")
defer func() {
if err != nil {
_ = encoder.Flush()
WasmXMLWriter.Truncate(before)
}
}()

if data == nil {
data = []T{}
Expand All @@ -204,7 +249,7 @@ func printXML[T OutputFields](data []T, opts printOptions) error {
writeEmpty := func() {
WasmXMLWriter.WriteString(xml.Header + "<items></items>\n")
xmlOutput := WasmXMLWriter.String()
fmt.Print(xmlOutput)
fmt.Print(xmlOutput[before:])
js.Global().Set("wasmXMLOutput", xmlOutput)
}

Expand Down Expand Up @@ -297,9 +342,6 @@ func printXML[T OutputFields](data []T, opts printOptions) error {
fields = filtered
}

encoder := xml.NewEncoder(WasmXMLWriter)
encoder.Indent("", " ")

WasmXMLWriter.WriteString(xml.Header)
start := xml.StartElement{Name: xml.Name{Local: "items"}}
if err := encoder.EncodeToken(start); err != nil {
Expand Down Expand Up @@ -356,8 +398,9 @@ func printXML[T OutputFields](data []T, opts printOptions) error {
WasmXMLWriter.WriteString("\n")

// See the sanitize comment in printJSON above; the same applies to XML.
xmlOutput := wasm.SanitizeTerminalOutput(WasmXMLWriter.String())
fmt.Print(xmlOutput)
raw := WasmXMLWriter.String()
fmt.Print(wasm.SanitizeTerminalOutput(raw[before:]))
xmlOutput := wasm.SanitizeTerminalOutput(raw)
js.Global().Set("wasmXMLOutput", xmlOutput)
return nil
}
Expand Down
115 changes: 115 additions & 0 deletions internal/base/output/output_wasm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,121 @@ func TestPrintCSV_WASM_SpecialCharacters(t *testing.T) {
assert.Contains(t, output, "Item with")
}

// TestPrintJSON_WASM_AccumulatesMultipleCalls verifies that a command emitting
// more than one JSON document in a single invocation has all of them captured,
// not just the last (ESD-1650).
func TestPrintJSON_WASM_AccumulatesMultipleCalls(t *testing.T) {
first := []SimpleStruct{{ID: 1, Name: "First", Active: true}}
second := []SimpleStruct{{ID: 2, Name: "Second", Active: false}}

WasmJSONWriter.Reset()
js.Global().Delete("wasmJSONOutput")

assert.NoError(t, printJSON(first, currentPrintOptions()))
assert.NoError(t, printJSON(second, currentPrintOptions()))

output := WasmJSONWriter.String()
assert.Contains(t, output, "First", "first document must survive a second call")
assert.Contains(t, output, "Second", "second document must also be present")

global := js.Global().Get("wasmJSONOutput")
assert.Equal(t, output, global.String(), "global must reflect the full accumulated buffer")

WasmJSONWriter.Reset()
js.Global().Delete("wasmJSONOutput")
}

// TestPrintCSV_WASM_AccumulatesMultipleCalls mirrors the JSON accumulation
// test for CSV output.
func TestPrintCSV_WASM_AccumulatesMultipleCalls(t *testing.T) {
first := []SimpleStruct{{ID: 1, Name: "First", Active: true}}
second := []SimpleStruct{{ID: 2, Name: "Second", Active: false}}

WasmCSVWriter.Reset()
js.Global().Delete("wasmCSVOutput")

assert.NoError(t, printCSV(first, currentPrintOptions()))
assert.NoError(t, printCSV(second, currentPrintOptions()))

output := WasmCSVWriter.String()
assert.Contains(t, output, "First", "first document must survive a second call")
assert.Contains(t, output, "Second", "second document must also be present")

global := js.Global().Get("wasmCSVOutput")
assert.Equal(t, output, global.String(), "global must reflect the full accumulated buffer")

WasmCSVWriter.Reset()
js.Global().Delete("wasmCSVOutput")
}

// TestPrintXML_WASM verifies basic XML output capture in WASM, a case not
// previously covered by this suite.
func TestPrintXML_WASM(t *testing.T) {
data := []SimpleStruct{
{ID: 1, Name: "Item 1", Active: true},
}

WasmXMLWriter.Reset()
js.Global().Delete("wasmXMLOutput")

err := printXML(data, currentPrintOptions())
assert.NoError(t, err)

output := WasmXMLWriter.String()
assert.NotEmpty(t, output)
assert.Contains(t, output, "<items>")
assert.Contains(t, output, "<item>")
assert.Contains(t, output, "<name>Item 1</name>")

global := js.Global().Get("wasmXMLOutput")
assert.False(t, global.IsUndefined())
assert.Equal(t, output, global.String())

WasmXMLWriter.Reset()
js.Global().Delete("wasmXMLOutput")
}

// TestPrintXML_WASM_AccumulatesMultipleCalls mirrors the JSON/CSV
// accumulation tests for XML output.
func TestPrintXML_WASM_AccumulatesMultipleCalls(t *testing.T) {
first := []SimpleStruct{{ID: 1, Name: "First", Active: true}}
second := []SimpleStruct{{ID: 2, Name: "Second", Active: false}}

WasmXMLWriter.Reset()
js.Global().Delete("wasmXMLOutput")

assert.NoError(t, printXML(first, currentPrintOptions()))
assert.NoError(t, printXML(second, currentPrintOptions()))

output := WasmXMLWriter.String()
assert.Contains(t, output, "<name>First</name>", "first document must survive a second call")
assert.Contains(t, output, "<name>Second</name>", "second document must also be present")

global := js.Global().Get("wasmXMLOutput")
assert.Equal(t, output, global.String(), "global must reflect the full accumulated buffer")

WasmXMLWriter.Reset()
js.Global().Delete("wasmXMLOutput")
}

// TestResetWasmStructuredBuffers_ClearsAllBuffers verifies the between-
// invocation reset hook (wired through output.ResetState) clears every
// structured-output buffer, so state never bleeds from one WASM command
// invocation into the next.
func TestResetWasmStructuredBuffers_ClearsAllBuffers(t *testing.T) {
WasmJSONWriter.WriteString("stale json")
WasmCSVWriter.WriteString("stale csv")
WasmXMLWriter.WriteString("stale xml")
WasmTableWriter.WriteString("stale table")

ResetState()

assert.Empty(t, WasmJSONWriter.String())
assert.Empty(t, WasmCSVWriter.String())
assert.Empty(t, WasmXMLWriter.String())
assert.Empty(t, WasmTableWriter.String())
}

// TestPrintOutput_WASM_Formats verifies all output formats
func TestPrintOutput_WASM_Formats(t *testing.T) {
data := []SimpleStruct{
Expand Down
Loading
Loading