Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3fb3da4
Adopt shoenig/test and go-snaps in output package tests
skyrpex Aug 10, 2026
f3cd58b
Replace test assertion/snapshot deps with in-house must and snap helpers
skyrpex Aug 11, 2026
f22dd0d
Add snap.MatchJSON with dotted-path value masking (#447)
skyrpex Aug 11, 2026
da28187
Fix stale go-snaps reference in sanitizeSnapshot comment
skyrpex Aug 11, 2026
6ae644e
Migrate integration tests to in-house must/snap helpers
skyrpex Aug 12, 2026
f92e94e
Pad snapshot mask placeholders to original value width
skyrpex Aug 12, 2026
3482b5f
Merge origin/main into claude/go-testing-tools-11f5d0
skyrpex Aug 12, 2026
6471f67
Fix snapshot token leak and Windows phantom extension listing
skyrpex Aug 12, 2026
d9e5aa2
Guarantee snapshot files end with exactly one trailing newline
skyrpex Aug 12, 2026
a74d968
Convert remaining deterministic Contains clusters to snapshots
skyrpex Aug 12, 2026
d9702b2
Strip ambient auth token in envWithPath for deterministic extension o…
skyrpex Aug 12, 2026
09b4870
Restore testify assertions and delete internal/must
skyrpex Aug 12, 2026
1ebf5bd
Prototype txtar per-test-file snapshot archives
skyrpex Aug 12, 2026
0ffc708
Restore original assert vs require split from main
skyrpex Aug 12, 2026
7837941
Separate snapshot archive sections with blank lines
skyrpex Aug 12, 2026
79c91c9
Switch snapshot archives to bracket-header format with --- terminator
skyrpex Aug 12, 2026
3f07786
Test that near-terminator lines round-trip in snapshot archives
skyrpex Aug 12, 2026
4c1de7f
Convert output assertions to per-stream snapshots in integration tests
skyrpex Aug 12, 2026
cc9025e
Strip ambient AWS env vars from integration test environments
skyrpex Aug 12, 2026
6149b48
Finish xpty migration of license ESC-decline PTY test
skyrpex Aug 12, 2026
de2384b
Merge remote-tracking branch 'origin/main' into pr443
skyrpex Aug 12, 2026
8a896fb
Merge branch 'main' into claude/go-testing-tools-11f5d0
skyrpex Aug 13, 2026
be4fbca
Mask environment-dependent MACHINE_ID in extension snapshot output
skyrpex Aug 13, 2026
91e5db1
Merge remote-tracking branch 'origin/main' into pr443
skyrpex Aug 14, 2026
56b055b
Give sam validate e2e test headroom over cold-runner startup
skyrpex Aug 14, 2026
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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.snap -text
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ When drafting Slack messages, PR descriptions, review replies, release notes, or
# Testing

- Prefer integration tests to cover most cases. Use unit tests when integration tests are not practical.
- Assertions use `github.com/stretchr/testify` (`require` for fatal checks). Snapshots use the in-house `internal/snap` helper: `snap.Match(t, s)` stores snapshots in one archive per test file (`__snapshots__/<test_file>.snap`): a `[TestName_N]` header line per Match call, the value verbatim, and a `---` terminator on its own line (so a value and the same value plus one final newline are indistinguishable; other newlines are preserved exactly). Missing snapshots are created locally but fail in CI; `UPDATE_SNAPS=true go test` rewrites them. The format has no escaping, so a value containing a line that is exactly `---` is rejected (round-trip guard) — sanitize such values before matching. `snap.MatchJSON(t, raw, "data.currentVersion", ...)` snapshots a JSON document in canonical pretty-printed form, masking the values at the given dotted paths with `<any>` (a path that doesn't resolve fails the test; objects only, no array indexing). A package using snapshots must wire `func TestMain(m *testing.M) { os.Exit(snap.Clean(m)) }` — obsolete snapshots then fail the run (or are deleted under `UPDATE_SNAPS=true`); cleanup is skipped on filtered (`-run`/`-skip`) or failed runs. Sanitize volatile values before matching with label-anchored regexes, not value-shaped ones (see `sanitizeSnapshot` in `internal/output`; RE2 has no lookahead, so a bare semver pattern corrupts IPv4 strings).
- **When fixing a bug, always add an integration test** that fails before the fix and passes after. This prevents regressions and documents the exact scenario that was broken.
- Integration tests that run the CLI binary with Bubble Tea must use a PTY since Bubble Tea requires a terminal. Use the cross-platform helpers in `test/integration/pty_helpers_test.go` — `runLstkInPTY` for run-to-completion, or `startLstkInPTY`/`startCmdInPTY` returning a `ptyProc` with `waitForOutput`/`write`/`wait`/`kill` for interactive prompts — never `creack/pty` directly. The helpers wrap `charmbracelet/x/xpty` (Unix PTY on macOS/Linux, ConPTY on Windows), so PTY tests run on Windows too; output from `wait()`/`output()` is ANSI-stripped because ConPTY injects its own repaint sequences. `creack/pty` remains only in `signal_forwarding_test.go` (build-tagged `!windows`; POSIX signal semantics don't port).
- Never fake an external CLI (`aws`, `az`, `terraform`, `cdk`, `sam`, `aws_completer`, browser openers) with a shell script — scripts don't run on Windows. Use `writeFakeTool`/`installFakeTool` (`test/integration/faketool_test.go`), which copy a compiled Go stand-in (`test-samples/faketool`) onto PATH configured via a JSON sidecar (echo lines with `{argN}`/`{args}`/`{env:NAME}` placeholders, arg-prefix cases like `--version`, sleep, exit codes, record-to-file).
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
github.com/containerd/errdefs v1.0.0
github.com/creack/pty v1.1.24
github.com/docker/go-units v0.5.0
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/hashicorp/hcl/v2 v2.24.0
github.com/moby/moby/api v1.55.0
Expand Down Expand Up @@ -67,7 +68,6 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
Expand Down
11 changes: 10 additions & 1 deletion internal/extension/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os/exec"
"path/filepath"
goruntime "runtime"
"slices"
"sort"
"strings"

Expand Down Expand Up @@ -128,7 +129,15 @@ func scanDir(dir string) []string {
}
name := strings.TrimPrefix(fileName, NamePrefix)
if goruntime.GOOS == "windows" {
name = strings.TrimSuffix(name, filepath.Ext(name))
// isExecutableFile accepts any regular file on Windows, so
// executability must be decided here by PATHEXT — otherwise a
// data file like lstk-extensions.toml lists as a phantom
// "extensions" extension.
ext := strings.ToLower(filepath.Ext(name))
if !slices.Contains(windowsExts(), ext) {
continue
}
name = strings.TrimSuffix(name, ext)
}
if name == "" {
continue
Expand Down
17 changes: 17 additions & 0 deletions internal/output/__snapshots__/envelope_sink_test.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Snapshots created by internal/snap. UPDATE_SNAPS=true go test rewrites
this file.

[TestEnvelopeSink_UpdateCheckedEnvelopeJSON_1]
{
"command": "update",
"data": {
"currentVersion": "<any>",
"latestVersion": "<any>",
"updateAvailable": true
},
"error": null,
"schemaVersion": 1,
"status": "ok",
"warnings": []
}
---
66 changes: 66 additions & 0 deletions internal/output/__snapshots__/plain_sink_test.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
Snapshots created by internal/snap. UPDATE_SNAPS=true go test rewrites
this file.

[TestPlainSink_EmitsErrorEvent_1]
Error: Connection failed
Cannot connect to Docker
==> Start Docker: open -a Docker
---

[TestPlainSink_EmitsInstanceInfoEvent_full_1]
✔︎ LocalStack AWS Emulator is running
• Endpoint: localhost.localstack.cloud:4566
• Persistence: Enabled
• Container: localstack-aws
• Version: <version>
• Uptime: <time>
---

[TestPlainSink_EmitsInstanceInfoEvent_minimal_1]
✔︎ LocalStack AWS Emulator is running
• Endpoint: 127.0.0.1:4566
---

[TestPlainSink_EmitsLogLineEvent_1]
2024-01-01 hello from container
---

[TestPlainSink_EmitsMessageEventInfo_1]
hello
---

[TestPlainSink_EmitsMessageEventWarning_1]
> Warning: something went wrong
---

[TestPlainSink_EmitsSpinnerEvent_active_spinner_1]
Loading...
---

[TestPlainSink_EmitsStatusEvent_pulling_phase_1]
Preparing LocalStack...
---

[TestPlainSink_EmitsStatusEvent_ready_phase_with_detail_1]
✔︎ LocalStack is running (abc123)
---

[TestPlainSink_EmitsStatusEvent_ready_phase_without_detail_1]
✔︎ LocalStack is running
---

[TestPlainSink_EmitsStatusEvent_starting_phase_1]
Starting LocalStack...
---

[TestPlainSink_EmitsStatusEvent_unknown_phase_with_detail_1]
LocalStack: custom (info)
---

[TestPlainSink_EmitsStatusEvent_unknown_phase_without_detail_1]
LocalStack: custom
---

[TestPlainSink_EmitsStatusEvent_waiting_phase_1]
Waiting for LocalStack to be ready...
---
80 changes: 33 additions & 47 deletions internal/output/envelope_sink_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package output

import (
"encoding/json"
"errors"
"testing"

"github.com/localstack/lstk/internal/snap"
"github.com/stretchr/testify/require"
)

func TestEnvelopeSink_SuccessWithNoEvents(t *testing.T) {
Expand Down Expand Up @@ -40,30 +44,13 @@ func TestEnvelopeSink_EmulatorStoppedEventAccumulates(t *testing.T) {

envelope := sink.Result("stop", nil)
data, ok := envelope.Data.(map[string]any)
if !ok {
t.Fatalf("expected map[string]any data, got %T", envelope.Data)
}
require.True(t, ok)
entries, ok := data["emulators"].([]JsonEmulatorEntry)
if !ok {
t.Fatalf("expected []JsonEmulatorEntry emulators, got %T", data["emulators"])
}
if len(entries) != 2 {
t.Fatalf("expected 2 emulators, got %d", len(entries))
}
emulators := make([]JsonStoppedEmulator, len(entries))
for i, entry := range entries {
stopped, ok := entry.(JsonStoppedEmulator)
if !ok {
t.Fatalf("expected entry %d to be a JsonStoppedEmulator, got %T", i, entry)
}
emulators[i] = stopped
}
if emulators[0].Type != "aws" || emulators[0].Name != "localstack-aws" || !emulators[0].WasRunning {
t.Fatalf("unexpected first emulator entry: %+v", emulators[0])
}
if emulators[1].Type != "snowflake" {
t.Fatalf("unexpected second emulator entry: %+v", emulators[1])
}
require.True(t, ok)
require.Equal(t, []JsonEmulatorEntry{
JsonStoppedEmulator{JsonEmulatorRef: JsonEmulatorRef{Type: "aws", Name: "localstack-aws"}, WasRunning: true},
JsonStoppedEmulator{JsonEmulatorRef: JsonEmulatorRef{Type: "snowflake", Name: "localstack-snowflake"}, WasRunning: true},
}, entries)
}

func TestEnvelopeSink_EmulatorResetEvent(t *testing.T) {
Expand Down Expand Up @@ -102,6 +89,19 @@ func TestEnvelopeSink_UpdateCheckedEvent(t *testing.T) {
}
}

// TestEnvelopeSink_UpdateCheckedEnvelopeJSON pins the full serialized
// envelope shape (schemaVersion, status, data, warnings) as a snapshot,
// masking the version fields that vary in real runs.
func TestEnvelopeSink_UpdateCheckedEnvelopeJSON(t *testing.T) {
sink := NewEnvelopeSink(FormatJSON)
sink.Emit(UpdateCheckedEvent{CurrentVersion: "2.2.1", LatestVersion: "2.3.0", Available: true})

envelope := sink.Result("update", nil)
raw, err := json.Marshal(envelope)
require.NoError(t, err)
snap.MatchJSON(t, raw, "data.currentVersion", "data.latestVersion")
}

func TestEnvelopeSink_UpdateAppliedEvent(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -153,30 +153,16 @@ func TestEnvelopeSink_ErrorEventSetsClassifiedError(t *testing.T) {
})

envelope := sink.Result("reset", errors.New("LocalStack is not running"))
if envelope.Status != StatusError {
t.Fatalf("expected status %q, got %q", StatusError, envelope.Status)
}
if envelope.Data != nil {
t.Fatalf("expected nil data on error, got %+v", envelope.Data)
}
if envelope.Error == nil {
t.Fatal("expected non-nil error")
}
if envelope.Error.Code != ErrEmulatorNotRunning {
t.Fatalf("expected code %q, got %q", ErrEmulatorNotRunning, envelope.Error.Code)
}
if envelope.Error.Category != CategoryEmulator {
t.Fatalf("expected category %q, got %q", CategoryEmulator, envelope.Error.Category)
}
if envelope.Error.Retryable != ErrEmulatorNotRunning.Retryable() {
t.Fatalf("expected retryable %v to match the code's static classification", ErrEmulatorNotRunning.Retryable())
}
if envelope.Error.Message != "LocalStack is not running" {
t.Fatalf("expected message %q, got %q", "LocalStack is not running", envelope.Error.Message)
}
if len(envelope.Error.Actions) != 1 || envelope.Error.Actions[0].ID != "start-localstack" || envelope.Error.Actions[0].Command != "lstk" {
t.Fatalf("unexpected actions: %+v", envelope.Error.Actions)
}
require.Equal(t, StatusError, envelope.Status)
require.Nil(t, envelope.Data)
require.NotNil(t, envelope.Error)
require.Equal(t, &EnvelopeError{
Code: ErrEmulatorNotRunning,
Category: CategoryEmulator,
Message: "LocalStack is not running",
Retryable: ErrEmulatorNotRunning.Retryable(),
Actions: []EnvelopeAction{{ID: "start-localstack", Command: "lstk"}},
}, envelope.Error)
}

// TestEnvelopeSink_ErrorEventIncludesSummaryInDetails covers PR #374's report
Expand Down
14 changes: 14 additions & 0 deletions internal/output/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package output

import (
"os"
"testing"

"github.com/localstack/lstk/internal/snap"
)

// TestMain wires snapshot cleanup: snapshots no test uses anymore fail the
// run (or are deleted with UPDATE_SNAPS=true).
func TestMain(m *testing.M) {
os.Exit(snap.Clean(m))
}
Loading
Loading