Add merge test coverage

Adds test coverage for the CTS merge command, which was recently updated
to support dependency injection.

Bug: 344014313
Change-Id: I0edd4ec0aa27b5d5880e0a946c09f2af310c4a79
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/280217
Reviewed-by: Ryan Harrison <rharrison@chromium.org>
Commit-Queue: Ryan Harrison <rharrison@chromium.org>
Auto-Submit: Brian Sheedy <bsheedy@google.com>
diff --git a/tools/src/cmd/cts/merge/merge.go b/tools/src/cmd/cts/merge/merge.go
index be877d8..2cf867b 100644
--- a/tools/src/cmd/cts/merge/merge.go
+++ b/tools/src/cmd/cts/merge/merge.go
@@ -35,6 +35,7 @@
 
 	"dawn.googlesource.com/dawn/tools/src/cmd/cts/common"
 	"dawn.googlesource.com/dawn/tools/src/cts/result"
+	"dawn.googlesource.com/dawn/tools/src/oswrapper"
 )
 
 func init() {
@@ -72,10 +73,10 @@
 	}
 
 	// Open output file
-	output := os.Stdout
+	var output oswrapper.File = os.Stdout
 	if c.flags.output != "-" {
 		var err error
-		output, err = os.Create(c.flags.output)
+		output, err = cfg.OsWrapper.Create(c.flags.output)
 		if err != nil {
 			return fmt.Errorf("failed to open output file '%v': %w", c.flags.output, err)
 		}
diff --git a/tools/src/cmd/cts/merge/merge_test.go b/tools/src/cmd/cts/merge/merge_test.go
new file mode 100644
index 0000000..2066124
--- /dev/null
+++ b/tools/src/cmd/cts/merge/merge_test.go
@@ -0,0 +1,174 @@
+// Copyright 2025 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+package merge
+
+import (
+	"context"
+	"flag"
+	"strings"
+	"testing"
+
+	"dawn.googlesource.com/dawn/tools/src/cmd/cts/common"
+	"dawn.googlesource.com/dawn/tools/src/cts/result"
+	"dawn.googlesource.com/dawn/tools/src/oswrapper"
+	"github.com/stretchr/testify/require"
+)
+
+// NOTE: All of the tests in this file temporarily change global state due to
+// changing the command line. As such, they cannot be run in parallel.
+
+func TestRun_OutputFile(t *testing.T) {
+	oldCommandLine := flag.CommandLine
+	defer func() { flag.CommandLine = oldCommandLine }()
+
+	osWrapper := oswrapper.CreateFSTestOSWrapper()
+
+	file1Content := strings.TrimSpace(`
+query1 tag=a Pass 1s false
+query2 tag=a Fail 1s false
+core
+`)
+	file2Content := strings.TrimSpace(`
+query1 tag=a Pass 2s false
+query3 tag=b Pass 1s false
+core
+`)
+	require.NoError(t, osWrapper.WriteFile("in1.txt", []byte(file1Content), 0644))
+	require.NoError(t, osWrapper.WriteFile("in2.txt", []byte(file2Content), 0644))
+
+	cfg := common.Config{
+		OsWrapper: osWrapper,
+		Tests: []common.TestConfig{
+			{ExecutionMode: "core"},
+		},
+	}
+
+	flag.CommandLine = flag.NewFlagSet("merge", flag.ContinueOnError)
+	require.NoError(t, flag.CommandLine.Parse([]string{"in1.txt", "in2.txt"}))
+
+	c := &cmd{}
+	c.flags.output = "out.txt"
+	ctx := context.Background()
+	require.NoError(t, c.Run(ctx, cfg))
+
+	outBytes, err := osWrapper.ReadFile("out.txt")
+	require.NoError(t, err)
+	outStr := string(outBytes)
+
+	// Expected output:
+	// query1: Pass (merged)
+	// query2: Fail
+	// query3: Pass
+	// duration for query1 should be (1s+2s)/2 = 1.5s
+	expectedStr := strings.TrimSpace(`
+query1 tag=a Pass 1.5s false
+query2 tag=a Fail 1s false
+query3 tag=b Pass 1s false
+core
+`)
+
+	compareResults(require.New(t), outStr, expectedStr)
+}
+
+// Testing stdout is not currently done since there is no good way to capture
+// while os.Stdout is used directly by the function.
+
+func TestRun_MissingInput(t *testing.T) {
+	oldCommandLine := flag.CommandLine
+	defer func() { flag.CommandLine = oldCommandLine }()
+
+	osWrapper := oswrapper.CreateFSTestOSWrapper()
+	cfg := common.Config{
+		OsWrapper: osWrapper,
+		Tests: []common.TestConfig{
+			{ExecutionMode: "core"},
+		},
+	}
+
+	flag.CommandLine = flag.NewFlagSet("merge", flag.ContinueOnError)
+	require.NoError(t, flag.CommandLine.Parse([]string{"missing.txt"}))
+
+	c := &cmd{}
+	c.flags.output = "out.txt"
+
+	ctx := context.Background()
+	err := c.Run(ctx, cfg)
+	require.Error(t, err)
+	require.ErrorContains(t, err, "while reading 'missing.txt'")
+	require.ErrorContains(t, err, "file does not exist")
+}
+
+func TestRun_CreateFailure(t *testing.T) {
+	oldCommandLine := flag.CommandLine
+	defer func() { flag.CommandLine = oldCommandLine }()
+
+	osWrapper := oswrapper.CreateFSTestOSWrapper()
+	require.NoError(t, osWrapper.WriteFile("in.txt", []byte("core\n"), 0644))
+
+	cfg := common.Config{
+		OsWrapper: osWrapper,
+		Tests: []common.TestConfig{
+			{ExecutionMode: "core"},
+		},
+	}
+
+	flag.CommandLine = flag.NewFlagSet("merge", flag.ContinueOnError)
+	require.NoError(t, flag.CommandLine.Parse([]string{"in.txt"}))
+
+	c := &cmd{}
+	// Use a path in a non-existent directory to trigger a creation error.
+	// fstestoswrapper.Create (via OpenFile) fails if the parent directory does not exist.
+	c.flags.output = "subdir/out.txt"
+
+	ctx := context.Background()
+	err := c.Run(ctx, cfg)
+	require.Error(t, err)
+	require.ErrorContains(t, err, "failed to open output file 'subdir/out.txt'")
+}
+
+func compareResults(r *require.Assertions, gotStr, expectedStr string) {
+	parse := func(s string) result.ResultsByExecutionMode {
+		reader := strings.NewReader(s)
+		res, err := result.Read(reader)
+		r.NoError(err, "Failed to parse result string:\n%v", s)
+		return res
+	}
+
+	got := parse(gotStr)
+	expected := parse(expectedStr)
+
+	// Sort lists for deterministic comparison
+	for _, l := range got {
+		l.Sort()
+	}
+	for _, l := range expected {
+		l.Sort()
+	}
+
+	r.Equal(expected, got)
+}