Skip to content
This repository was archived by the owner on Mar 29, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 5 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
88 changes: 88 additions & 0 deletions apps/sotto/internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@ package app
import (
"bytes"
"context"
"errors"
"log/slog"
"net"
"os"
"path/filepath"
"syscall"
"testing"
"time"

"github.com/rbright/sotto/internal/fsm"
"github.com/rbright/sotto/internal/ipc"
"github.com/rbright/sotto/internal/session"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -201,6 +207,88 @@ func TestRunnerDevicesCommandDispatches(t *testing.T) {
require.Contains(t, stderr.String(), "error:")
}

func TestRunnerToggleOwnerPathReturnsErrorWhenCaptureStartupFails(t *testing.T) {
paths := setupRunnerEnv(t)
t.Setenv("PULSE_SERVER", "unix:/tmp/definitely-missing-pulse-server")

var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Stdout: &stdout, Stderr: &stderr}

exitCode := runner.Execute(context.Background(), []string{"--config", paths.configPath, "toggle"})
require.Equal(t, 1, exitCode)
require.Contains(t, stderr.String(), "error:")

// owner path should clean up runtime socket on exit
_, statErr := os.Stat(filepath.Join(paths.runtimeDir, "sotto.sock"))
require.ErrorIs(t, statErr, os.ErrNotExist)
}

func TestRunnerStatusFallsBackToIdleWhenServerStateEmpty(t *testing.T) {
paths := setupRunnerEnv(t)

shutdown := startIPCServerForRunnerTest(t, filepath.Join(paths.runtimeDir, "sotto.sock"), func(_ context.Context, req ipc.Request) ipc.Response {
require.Equal(t, "status", req.Command)
return ipc.Response{OK: true, State: ""}
})
defer shutdown()

var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Stdout: &stdout, Stderr: &stderr}

exitCode := runner.Execute(context.Background(), []string{"--config", paths.configPath, "status"})
require.Equal(t, 0, exitCode)
require.Equal(t, "idle\n", stdout.String())
require.Empty(t, stderr.String())
}

func TestSocketErrorHelpers(t *testing.T) {
require.False(t, isSocketMissing(nil))
require.False(t, isConnectionRefused(nil))

require.True(t, isSocketMissing(os.ErrNotExist))
require.True(t, isSocketMissing(errors.New("dial unix /tmp/sotto.sock: no such file or directory")))
require.False(t, isSocketMissing(errors.New("other error")))

require.True(t, isConnectionRefused(syscall.ECONNREFUSED))
require.False(t, isConnectionRefused(errors.New("other error")))
}

func TestLogSessionResultWritesFailureAndSuccess(t *testing.T) {
var logBuf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&logBuf, nil))

started := time.Now()
finished := started.Add(1500 * time.Millisecond)

logSessionResult(logger, session.Result{
State: fsm.StateIdle,
Cancelled: false,
StartedAt: started,
FinishedAt: finished,
AudioDevice: "Mic",
BytesCaptured: 123,
Transcript: "hello",
GRPCLatency: 20 * time.Millisecond,
})

require.Contains(t, logBuf.String(), "session complete")
require.Contains(t, logBuf.String(), "\"transcript_length\":5")

logBuf.Reset()
logSessionResult(logger, session.Result{
State: fsm.StateIdle,
StartedAt: started,
FinishedAt: finished,
Transcript: "",
Err: errors.New("boom"),
GRPCLatency: 2 * time.Millisecond,
})
require.Contains(t, logBuf.String(), "session failed")
require.Contains(t, logBuf.String(), "boom")
}

type runnerPaths struct {
configPath string
runtimeDir string
Expand Down
26 changes: 26 additions & 0 deletions apps/sotto/internal/audio/pulse_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package audio

import (
"context"
"io"
"reflect"
"testing"
Expand Down Expand Up @@ -59,6 +60,18 @@ func TestDeviceMatchesByIDAndDescription(t *testing.T) {
require.False(t, deviceMatches(dev, "missing"))
}

func TestListDevicesFailsWhenPulseUnavailable(t *testing.T) {
t.Setenv("PULSE_SERVER", "unix:/tmp/definitely-missing-pulse-server")
_, err := ListDevices(context.Background())
require.Error(t, err)
}

func TestSelectDeviceFailsWhenPulseUnavailable(t *testing.T) {
t.Setenv("PULSE_SERVER", "unix:/tmp/definitely-missing-pulse-server")
_, err := SelectDevice(context.Background(), "default", "default")
require.Error(t, err)
}

func TestSourceStateString(t *testing.T) {
require.Equal(t, "running", sourceStateString(0))
require.Equal(t, "idle", sourceStateString(1))
Expand Down Expand Up @@ -136,6 +149,19 @@ func TestCaptureOnPCMReturnsEOFWhenStopped(t *testing.T) {
require.Equal(t, int64(0), capture.BytesCaptured())
}

func TestCaptureDeviceAndCloseAlias(t *testing.T) {
capture := &Capture{
device: Device{ID: "mic-1", Description: "Mic"},
chunks: make(chan []byte, 1),
stopCh: make(chan struct{}),
}
require.Equal(t, "mic-1", capture.Device().ID)

capture.Close()
_, ok := <-capture.Chunks()
require.False(t, ok)
}

type sourcePort struct {
name string
available uint32
Expand Down
2 changes: 1 addition & 1 deletion apps/sotto/internal/config/parser_jsonc.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ func isJSONWhitespace(ch byte) bool {
}

func ensureSingleJSONValue(decoder *json.Decoder) error {
var extra struct{}
var extra json.RawMessage
err := decoder.Decode(&extra)
if errors.Is(err, io.EOF) {
return nil
Expand Down
142 changes: 142 additions & 0 deletions apps/sotto/internal/config/parser_jsonc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package config

import (
"encoding/json"
"strings"
"testing"

"github.com/stretchr/testify/require"
)

func TestNormalizeJSONCRemovesCommentsAndTrailingCommas(t *testing.T) {
input := `
{
// line comment
"items": [
"one", /* block comment */
"two",
],
"nested": {
"enabled": true,
},
}
`

normalized, err := normalizeJSONC(input)
require.NoError(t, err)
require.NotContains(t, normalized, "//")
require.NotContains(t, normalized, "/*")
require.NotContains(t, normalized, ",]")
require.NotContains(t, normalized, ",}")
}

func TestNormalizeJSONCRetainsCommentLikeTextInsideStrings(t *testing.T) {
input := `{"value":"contains // and /* comment-like */ text",}`
normalized, err := normalizeJSONC(input)
require.NoError(t, err)
require.Contains(t, normalized, "// and /* comment-like */")
}

func TestNormalizeJSONCUnterminatedBlockCommentFails(t *testing.T) {
_, err := normalizeJSONC("{ /* unterminated ")
require.Error(t, err)
require.Contains(t, err.Error(), "unterminated block comment")
}

func TestEnsureSingleJSONValueRejectsExtraPayload(t *testing.T) {
decoder := json.NewDecoder(strings.NewReader(`{"one":1}{"two":2}`))
var payload map[string]any
require.NoError(t, decoder.Decode(&payload))

err := ensureSingleJSONValue(decoder)
require.Error(t, err)
require.Contains(t, err.Error(), "multiple JSON values")
}

func TestOffsetToLineCol(t *testing.T) {
content := "line1\nline2\nline3"
line, col := offsetToLineCol(content, 1)
require.Equal(t, 1, line)
require.Equal(t, 1, col)

line, col = offsetToLineCol(content, 8) // line2, col2
require.Equal(t, 2, line)
require.Equal(t, 2, col)

line, col = offsetToLineCol(content, 999)
require.Equal(t, 3, line)
require.Equal(t, 5, col)
}

func TestJSONCStringListUnmarshal(t *testing.T) {
var list jsoncStringList
require.NoError(t, list.UnmarshalJSON([]byte(`["a","b"]`)))
require.Equal(t, []string{"a", "b"}, []string(list))

require.NoError(t, list.UnmarshalJSON([]byte(`"a, b, , c"`)))
require.Equal(t, []string{"a", "b", "c"}, []string(list))

err := list.UnmarshalJSON([]byte(`123`))
require.Error(t, err)
require.Contains(t, err.Error(), "expected string array")
}

func TestParseJSONCRejectsInvalidCommandArgv(t *testing.T) {
_, _, err := parseJSONC(`{"clipboard_cmd":"unterminated ' quote"}`, Default())
require.Error(t, err)
require.Contains(t, err.Error(), "invalid clipboard_cmd")

_, _, err = parseJSONC(`{"paste_cmd":"unterminated ' quote"}`, Default())
require.Error(t, err)
require.Contains(t, err.Error(), "invalid paste_cmd")
}

func TestParseJSONCVocabRejectsEmptySetName(t *testing.T) {
_, _, err := parseJSONC(`{"vocab":{"sets":{" ":{"phrases":["x"]}}}}`, Default())
require.Error(t, err)
require.Contains(t, err.Error(), "empty set name")
}

func TestParseJSONCTrimsIndicatorAndPasteFields(t *testing.T) {
cfg, _, err := parseJSONC(`{
"paste": {"shortcut": " CTRL,V "},
"indicator": {
"backend": " desktop ",
"desktop_app_name": " sotto-indicator "
}
}`, Default())
require.NoError(t, err)
require.Equal(t, "CTRL,V", cfg.Paste.Shortcut)
require.Equal(t, "desktop", cfg.Indicator.Backend)
require.Equal(t, "sotto-indicator", cfg.Indicator.DesktopAppName)
}

func TestParseJSONCRejectsMultipleTopLevelValues(t *testing.T) {
_, _, err := parseJSONC(`{"paste":{"enable":false}}{"paste":{"enable":true}}`, Default())
require.Error(t, err)
require.Contains(t, err.Error(), "multiple JSON values")
}

func TestParseJSONCTypeErrorIncludesLocation(t *testing.T) {
_, _, err := parseJSONC(`{
"riva": {"grpc": 123}
}`, Default())
require.Error(t, err)
require.Contains(t, err.Error(), "line")
require.Contains(t, err.Error(), "column")
}

func TestParseJSONCVocabGlobalSupportsCommaString(t *testing.T) {
cfg, _, err := parseJSONC(`{
"vocab": {
"global": "one, two, , three",
"sets": {
"one": {"phrases": ["one"]},
"two": {"phrases": ["two"]},
"three": {"phrases": ["three"]}
}
}
}`, Default())
require.NoError(t, err)
require.Equal(t, []string{"one", "two", "three"}, cfg.Vocab.GlobalSets)
}
62 changes: 62 additions & 0 deletions apps/sotto/internal/doctor/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,65 @@ func TestCheckAudioSelectionFailureWithInvalidPulseServer(t *testing.T) {
require.False(t, check.Pass)
require.Contains(t, check.Name, "audio.device")
}

func TestReportOKAllPassing(t *testing.T) {
report := Report{Checks: []Check{{Name: "one", Pass: true}, {Name: "two", Pass: true}}}
require.True(t, report.OK())
}

func TestRunUsesPasteCmdOverrideCheck(t *testing.T) {
binDir := t.TempDir()
fakePaste := filepath.Join(binDir, "fake-paste")
require.NoError(t, os.WriteFile(fakePaste, []byte("#!/usr/bin/env sh\nexit 0\n"), 0o755))
t.Setenv("PATH", binDir+":"+os.Getenv("PATH"))
t.Setenv("PULSE_SERVER", "unix:/tmp/definitely-missing-pulse-server")
t.Setenv("XDG_SESSION_TYPE", "wayland")
t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", "abc123")

cfg := config.Default()
cfg.Paste.Enable = true
cfg.PasteCmd = config.CommandConfig{Raw: fakePaste, Argv: []string{"fake-paste"}}
cfg.RivaHTTP = ""

report := Run(config.Loaded{Path: "/tmp/config.jsonc", Config: cfg})
require.NotEmpty(t, report.Checks)

var sawPasteCmd, sawHypr bool
for _, check := range report.Checks {
if check.Name == "fake-paste" {
sawPasteCmd = true
}
if check.Name == "hyprctl" {
sawHypr = true
}
}
require.True(t, sawPasteCmd)
require.False(t, sawHypr)
}

func TestRunUsesHyprctlWhenPasteCmdUnset(t *testing.T) {
binDir := t.TempDir()
fakeHypr := filepath.Join(binDir, "hyprctl")
require.NoError(t, os.WriteFile(fakeHypr, []byte("#!/usr/bin/env sh\nexit 0\n"), 0o755))
t.Setenv("PATH", binDir+":"+os.Getenv("PATH"))
t.Setenv("PULSE_SERVER", "unix:/tmp/definitely-missing-pulse-server")
t.Setenv("XDG_SESSION_TYPE", "wayland")
t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", "abc123")

cfg := config.Default()
cfg.Paste.Enable = true
cfg.PasteCmd = config.CommandConfig{}
cfg.RivaHTTP = ""

report := Run(config.Loaded{Path: "/tmp/config.jsonc", Config: cfg})
require.NotEmpty(t, report.Checks)

var sawHypr bool
for _, check := range report.Checks {
if check.Name == "hyprctl" {
sawHypr = true
break
}
}
require.True(t, sawHypr)
}
Loading
Loading