diff --git a/cmd/megaport/help_wasm_test.go b/cmd/megaport/help_wasm_test.go
new file mode 100644
index 00000000..d9a93282
--- /dev/null
+++ b/cmd/megaport/help_wasm_test.go
@@ -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")
+}
diff --git a/cmd/megaport/megaport_wasm.go b/cmd/megaport/megaport_wasm.go
index 2f681fbb..596db7e6 100644
--- a/cmd/megaport/megaport_wasm.go
+++ b/cmd/megaport/megaport_wasm.go
@@ -183,6 +183,11 @@ 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
@@ -190,11 +195,16 @@ func init() {
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)
diff --git a/internal/base/output/common.go b/internal/base/output/common.go
index 8e97c56c..2850ee93 100644
--- a/internal/base/output/common.go
+++ b/internal/base/output/common.go
@@ -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
diff --git a/internal/base/output/output_wasm.go b/internal/base/output/output_wasm.go
index 9c2035a7..20f0e543 100644
--- a/internal/base/output/output_wasm.go
+++ b/internal/base/output/output_wasm.go
@@ -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{}
@@ -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 {
@@ -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
@@ -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{}
@@ -204,7 +249,7 @@ func printXML[T OutputFields](data []T, opts printOptions) error {
writeEmpty := func() {
WasmXMLWriter.WriteString(xml.Header + "\n")
xmlOutput := WasmXMLWriter.String()
- fmt.Print(xmlOutput)
+ fmt.Print(xmlOutput[before:])
js.Global().Set("wasmXMLOutput", xmlOutput)
}
@@ -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 {
@@ -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
}
diff --git a/internal/base/output/output_wasm_test.go b/internal/base/output/output_wasm_test.go
index bd2bcc98..c0627cfd 100644
--- a/internal/base/output/output_wasm_test.go
+++ b/internal/base/output/output_wasm_test.go
@@ -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, "")
+ assert.Contains(t, output, "- ")
+ assert.Contains(t, output, "Item 1")
+
+ 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, "First", "first document must survive a second call")
+ assert.Contains(t, output, "Second", "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{
diff --git a/internal/base/output/table_wasm.go b/internal/base/output/table_wasm.go
index 21afe5ba..7142e532 100644
--- a/internal/base/output/table_wasm.go
+++ b/internal/base/output/table_wasm.go
@@ -50,17 +50,29 @@ func calculateDynamicWidth(termWidth int, minWidth, maxPercentage int) int {
return maxWidth
}
-// printTable is the WASM-specific implementation that properly captures table output
-func printTable[T OutputFields](data []T, noColor bool, opts printOptions) error {
+// printTable is the WASM-specific implementation that properly captures table
+// output. In WASM, render into the global WasmTableWriter so the rendered
+// table is available to the JS-accessible wasmTableOutput global that the web
+// UI reads. An empty slice still renders a header-only table (native parity);
+// any "No X found" warning is surfaced above it by GetCapturedOutput, which
+// prepends the direct status buffer to the table output.
+//
+// A command may call this more than once; each call appends to
+// WasmTableWriter rather than overwriting it, so the global reflects every
+// table 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 printTable[T OutputFields](data []T, noColor bool, opts printOptions) (err error) {
wasmBufMu.Lock()
defer wasmBufMu.Unlock()
- // In WASM, render into the global WasmTableWriter so the rendered table is
- // available to the JS-accessible wasmTableOutput global that the web UI reads.
- // An empty slice still renders a header-only table (native parity); any
- // "No X found" warning is surfaced above it by GetCapturedOutput, which
- // prepends the direct status buffer to the table output.
- WasmTableWriter.Reset()
+ before := WasmTableWriter.Len()
+ defer func() {
+ if err != nil {
+ WasmTableWriter.Truncate(before)
+ }
+ }()
+
if err := printTableToWriter(WasmTableWriter, data, noColor, opts); err != nil {
return err
}
@@ -69,14 +81,17 @@ func printTable[T OutputFields](data []T, noColor bool, opts printOptions) error
// GetCapturedOutput/GetCompletionOutput and written to xterm without
// escaping, and a colorized cell value (colorizeValue) can carry a
// resource name or other API field an attacker controls. SGR color codes
- // are preserved; see wasm.SanitizeTerminalOutput.
- tableOutput := wasm.SanitizeTerminalOutput(WasmTableWriter.String())
+ // are preserved; see 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 := WasmTableWriter.String()
- // Write the table output to stdout so it can be captured by wasm buffers.
- fmt.Print(tableOutput)
+ // Write the newly-rendered table to stdout so it can be captured by wasm buffers.
+ fmt.Print(wasm.SanitizeTerminalOutput(raw[before:]))
// Also write to a JavaScript-accessible global variable.
- js.Global().Set("wasmTableOutput", tableOutput)
+ js.Global().Set("wasmTableOutput", wasm.SanitizeTerminalOutput(raw))
return nil
}
diff --git a/internal/base/output/table_wasm_test.go b/internal/base/output/table_wasm_test.go
index 711b23c3..95a9797c 100644
--- a/internal/base/output/table_wasm_test.go
+++ b/internal/base/output/table_wasm_test.go
@@ -8,6 +8,8 @@ import (
"testing"
"github.com/stretchr/testify/assert"
+
+ "github.com/megaport/megaport-cli/internal/wasm"
)
// TestWasmTableWriter verifies the WASM table writer buffer
@@ -143,6 +145,62 @@ func TestPrintTable_WASM_ComplexData(t *testing.T) {
assert.Contains(t, output, "Complex Item")
}
+// TestPrintTable_WASM_AccumulatesMultipleCalls verifies that a command
+// rendering more than one table in a single invocation has all of them
+// captured, not just the last (ESD-1650).
+func TestPrintTable_WASM_AccumulatesMultipleCalls(t *testing.T) {
+ first := []SimpleStruct{{ID: 1, Name: "First", Active: true}}
+ second := []SimpleStruct{{ID: 2, Name: "Second", Active: false}}
+
+ WasmTableWriter.Reset()
+ js.Global().Delete("wasmTableOutput")
+
+ assert.NoError(t, printTable(first, false, currentPrintOptions()))
+ assert.NoError(t, printTable(second, false, currentPrintOptions()))
+
+ output := WasmTableWriter.String()
+ assert.Contains(t, output, "First", "first table must survive a second call")
+ assert.Contains(t, output, "Second", "second table must also be present")
+
+ global := js.Global().Get("wasmTableOutput")
+ assert.Equal(t, output, global.String(), "global must reflect the full accumulated buffer")
+
+ WasmTableWriter.Reset()
+ js.Global().Delete("wasmTableOutput")
+}
+
+// TestPrintTable_WASM_AccumulatesWhenSanitizerDropsBytes guards the raw-offset
+// alignment fixed in ESD-1650. The first table's cell carries injected control
+// bytes the sanitizer strips, so the sanitized whole buffer is shorter than the
+// raw byte offset recorded before the second call; slicing the sanitized string
+// by that raw offset (the pre-fix behavior) reads past its end and panics. The
+// global must still equal the sanitized full buffer and both rows must survive.
+func TestPrintTable_WASM_AccumulatesWhenSanitizerDropsBytes(t *testing.T) {
+ noisy := "acme" + strings.Repeat("\x1b[2K\x1b[H\x7f", 400)
+ first := []SimpleStruct{{ID: 1, Name: noisy, Active: true}}
+ second := []SimpleStruct{{ID: 2, Name: "Second", Active: false}}
+
+ SetTerminalWidthForTesting(0)
+ defer SetTerminalWidthForTesting(0)
+ WasmTableWriter.Reset()
+ js.Global().Delete("wasmTableOutput")
+
+ assert.NotPanics(t, func() {
+ assert.NoError(t, printTable(first, true, currentPrintOptions()))
+ assert.NoError(t, printTable(second, true, currentPrintOptions()))
+ })
+
+ global := js.Global().Get("wasmTableOutput").String()
+ assert.Equal(t, wasm.SanitizeTerminalOutput(WasmTableWriter.String()), global,
+ "global must equal the sanitized full accumulated buffer")
+ assert.NotContains(t, global, "\x1b[2K", "injected control bytes must be stripped")
+ assert.Contains(t, global, "acme")
+ assert.Contains(t, global, "Second")
+
+ WasmTableWriter.Reset()
+ js.Global().Delete("wasmTableOutput")
+}
+
// TestCalculateDynamicWidth verifies column width calculation
func TestCalculateDynamicWidth(t *testing.T) {
tests := []struct {
diff --git a/internal/wasm/wasmhttp/transport.go b/internal/wasm/wasmhttp/transport.go
index ba59c5c6..1649fd58 100644
--- a/internal/wasm/wasmhttp/transport.go
+++ b/internal/wasm/wasmhttp/transport.go
@@ -79,7 +79,11 @@ func (t *WasmHTTPTransport) buildFetchOptions(req *http.Request) (map[string]int
continue
}
if len(values) > 0 {
- headers[key] = values[0] // fetch expects single string values
+ // fetch's Headers init dict accepts one string per key, so combine
+ // multi-value headers the same way HTTP field values are combined
+ // (RFC 7230 3.2.2). Cookie combines with "; " instead, but the SDK
+ // never sets a multi-value Cookie header through this transport.
+ headers[key] = strings.Join(values, ", ")
}
}
@@ -99,7 +103,11 @@ func (t *WasmHTTPTransport) buildFetchOptions(req *http.Request) (map[string]int
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
if len(bodyBytes) > 0 {
- fetchOpts["body"] = string(bodyBytes)
+ // Pass raw bytes as a Uint8Array rather than a Go string, which fetch
+ // would otherwise treat as UTF-8 text and mangle a binary body.
+ jsBody := js.Global().Get("Uint8Array").New(len(bodyBytes))
+ js.CopyBytesToJS(jsBody, bodyBytes)
+ fetchOpts["body"] = jsBody
}
}
diff --git a/internal/wasm/wasmhttp/transport_test.go b/internal/wasm/wasmhttp/transport_test.go
index e6c62288..5efe652d 100644
--- a/internal/wasm/wasmhttp/transport_test.go
+++ b/internal/wasm/wasmhttp/transport_test.go
@@ -3,6 +3,7 @@
package wasmhttp
import (
+ "bytes"
"context"
"errors"
"net/http"
@@ -292,3 +293,39 @@ func TestBuildFetchOptions_NoForbiddenAcceptEncodingHeader(t *testing.T) {
_, exists := headers["Accept-Encoding"]
assert.False(t, exists, "Accept-Encoding is a forbidden fetch header and must not be set")
}
+
+func TestBuildFetchOptions_MultiValueHeaderJoined(t *testing.T) {
+ transport := &WasmHTTPTransport{}
+ req, err := http.NewRequest(http.MethodGet, "https://example.invalid/test", nil)
+ require.NoError(t, err)
+ req.Header.Add("X-Custom", "first")
+ req.Header.Add("X-Custom", "second")
+
+ opts, err := transport.buildFetchOptions(req)
+ require.NoError(t, err)
+
+ headers, ok := opts["headers"].(map[string]interface{})
+ require.True(t, ok)
+
+ assert.Equal(t, "first, second", headers["X-Custom"],
+ "every value of a multi-value header should be forwarded, not just the first")
+}
+
+func TestBuildFetchOptions_BinaryBodyPreservedAsBytes(t *testing.T) {
+ transport := &WasmHTTPTransport{}
+ binary := []byte{0x00, 0xff, 0xfe, 'h', 'i', 0x80, 0x81}
+ req, err := http.NewRequest(http.MethodPost, "https://example.invalid/test", bytes.NewReader(binary))
+ require.NoError(t, err)
+
+ opts, err := transport.buildFetchOptions(req)
+ require.NoError(t, err)
+
+ jsBody, ok := opts["body"].(js.Value)
+ require.True(t, ok, "body should be passed as a js.Value (Uint8Array), not a Go string")
+ assert.Equal(t, "Uint8Array", jsBody.Get("constructor").Get("name").String())
+
+ length := jsBody.Get("length").Int()
+ got := make([]byte, length)
+ js.CopyBytesToGo(got, jsBody)
+ assert.Equal(t, binary, got, "binary body bytes must round-trip without UTF-8 mangling")
+}