diff --git a/sdk/tests/.gitignore b/sdk/tests/.gitignore
new file mode 100644
index 000000000..d975dd2d3
--- /dev/null
+++ b/sdk/tests/.gitignore
@@ -0,0 +1,16 @@
+# Test output files
+**/**/norm*.txt
+**/**/testOutput.txt
+
+# Test output directory created on failures
+testdata/
+
+# Go test binary
+*.test
+
+# Go build output
+test-runner
+compare-sse
+
+# sdk tests
+.docker-build-timestamp
\ No newline at end of file
diff --git a/sdk/tests/README.md b/sdk/tests/README.md
new file mode 100644
index 000000000..6f11c0de2
--- /dev/null
+++ b/sdk/tests/README.md
@@ -0,0 +1,142 @@
+# Datastar SDK Test Suite
+
+A comprehensive test suite for validating Datastar SDK implementations across different languages.
+
+## Installation
+
+### As a Go Library
+
+```bash
+go get github.com/starfederation/datastar/sdk/tests
+```
+
+## Usage
+
+### Running Tests
+
+```bash
+# Run all tests against default server (localhost:7331)
+go run github.com/starfederation/datastar/sdk/tests/cmd/datastar-sdk-tests@latest
+
+# Run with custom server
+go run github.com/starfederation/datastar/sdk/tests/cmd/datastar-sdk-tests@latest -server http://localhost:8080
+
+# Run only GET tests
+go run github.com/starfederation/datastar/sdk/tests/cmd/datastar-sdk-tests@latest -type get
+
+# Run only POST tests
+go run github.com/starfederation/datastar/sdk/tests/cmd/datastar-sdk-tests@latest -type post
+
+# Verbose output
+go run github.com/starfederation/datastar/sdk/tests/cmd/datastar-sdk-tests@latest -v
+```
+
+### Using go test directly
+
+```bash
+# Clone the repository and navigate to tests directory
+cd sdk/tests
+
+# Run all tests
+go test -v
+
+# Run with custom server
+TEST_SERVER_URL=http://localhost:8080 go test -v
+```
+
+## Test Structure
+
+The test suite includes:
+
+- **GET Tests** (`golden/get/`): Test cases for GET endpoints
+- **POST Tests** (`golden/post/`): Test cases for POST endpoints
+
+Each test case contains:
+- `input.json`: The request payload
+- `output.txt`: The expected SSE response
+
+## Features
+
+- **HTML Normalization**: Automatically handles HTML attribute ordering differences
+- **Embedded Test Data**: All test cases are embedded in the binary for portability
+- **Flexible Runner**: Can be used as a CLI tool or Go library
+- **Detailed Output**: Clear error messages and debug information
+
+## For SDK Implementers
+
+To validate your SDK implementation:
+
+1. Start your test server on port 7331 (or specify a different port)
+2. Implement the `/test` endpoint that:
+ - For GET: reads the `datastar` query parameter
+ - For POST: reads the JSON body
+ - Returns appropriate SSE responses
+3. Run `go run github.com/starfederation/datastar/sdk/tests/cmd/datastar-sdk-tests@latest` to validate your implementation
+
+## Test Endpoint Requirements
+
+The `/test` endpoint should:
+
+1. Use ReadSignals to extract the `events` array from the request
+2. Loop through the array of events
+3. Use `event.type` to decide which server sent event to generate
+4. Return the appropriate SSE response
+
+### Input Format
+
+```json
+{
+ "events": [
+ {
+ "type": "executeScript",
+ "script": "console.log('hello');",
+ "eventId": "event1",
+ "retryDuration": 2000,
+ "attributes": {
+ "type": "text/javascript",
+ "blocking": "false"
+ },
+ "autoRemove": false
+ }
+ ]
+}
+```
+
+### Output Format
+
+```
+event: datastar-patch-elements
+id: event1
+retry: 2000
+data: mode append
+data: selector body
+data: elements
+
+```
+
+## Adding New Test Cases
+
+To add a new test case:
+
+1. Create a folder in `golden/get/` or `golden/post/` named after your test
+2. Add an `input.json` file with the request payload
+3. Add an `output.txt` file with the expected SSE response
+
+### Special Cases
+
+#### Multiline Signals
+
+For `patchSignals` events, if you need multiline output:
+- Use `signals-raw` as a string with `\n` characters instead of `signals` as a JSON object
+- The server should check for `signals-raw` first, then fall back to `signals`
+
+## Test Cases
+
+The test suite covers:
+
+- Element patching (single and multiline)
+- Signal patching
+- Script execution
+- Element/signal removal
+- Various SSE formatting scenarios
+- Edge cases and error conditions
diff --git a/sdk/tests/cmd/datastar-sdk-tests/main.go b/sdk/tests/cmd/datastar-sdk-tests/main.go
new file mode 100644
index 000000000..9fcf4aec2
--- /dev/null
+++ b/sdk/tests/cmd/datastar-sdk-tests/main.go
@@ -0,0 +1,75 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "testing"
+
+ sdktests "github.com/starfederation/datastar/sdk/tests"
+)
+
+func main() {
+ var (
+ verbose = flag.Bool("v", false, "Verbose output")
+ testType = flag.String("type", "all", "Test type: get, post, or all")
+ help = flag.Bool("h", false, "Show help")
+ )
+ flag.Parse()
+
+ if *help {
+ fmt.Println("datastar-sdk-tests - Datastar SDK test suite")
+ fmt.Println("\nUsage:")
+ fmt.Println(" datastar-sdk-tests [options]")
+ fmt.Println("\nOptions:")
+ flag.PrintDefaults()
+ fmt.Println("\nExamples:")
+ fmt.Println(" datastar-sdk-tests # Run all tests")
+ fmt.Println(" datastar-sdk-tests -type get # Run only GET tests")
+ fmt.Println(" datastar-sdk-tests -server http://localhost:8080")
+ os.Exit(0)
+ }
+
+ // Create a testing.M to run tests
+ var tests []testing.InternalTest
+
+ switch *testType {
+ case "get":
+ tests = append(tests, testing.InternalTest{
+ Name: "TestSSEGetEndpoints",
+ F: sdktests.TestSSEGetEndpoints,
+ })
+ case "post":
+ tests = append(tests, testing.InternalTest{
+ Name: "TestSSEPostEndpoints",
+ F: sdktests.TestSSEPostEndpoints,
+ })
+ case "all":
+ tests = append(tests,
+ testing.InternalTest{
+ Name: "TestSSEGetEndpoints",
+ F: sdktests.TestSSEGetEndpoints,
+ },
+ testing.InternalTest{
+ Name: "TestSSEPostEndpoints",
+ F: sdktests.TestSSEPostEndpoints,
+ },
+ )
+ default:
+ fmt.Fprintf(os.Stderr, "Unknown test type: %s\n", *testType)
+ os.Exit(1)
+ }
+
+ // Set verbose flag if requested
+ if *verbose {
+ os.Args = append(os.Args, "-test.v")
+ }
+
+ // Run tests using testing.Main
+ testing.Main(
+ func(pat, str string) (bool, error) { return true, nil }, // matchString
+ tests, // tests
+ nil, // benchmarks
+ nil, // examples
+ )
+}
diff --git a/sdk/tests/go.mod b/sdk/tests/go.mod
new file mode 100644
index 000000000..ab83b19f0
--- /dev/null
+++ b/sdk/tests/go.mod
@@ -0,0 +1,14 @@
+module github.com/starfederation/datastar/sdk/tests
+
+go 1.21
+
+require (
+ github.com/stretchr/testify v1.8.4
+ golang.org/x/net v0.19.0
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/sdk/tests/go.sum b/sdk/tests/go.sum
new file mode 100644
index 000000000..deabe9d2b
--- /dev/null
+++ b/sdk/tests/go.sum
@@ -0,0 +1,12 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
+golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/sdk/tests/golden/get/executeScriptWithAllOptions/input.json b/sdk/tests/golden/get/executeScriptWithAllOptions/input.json
new file mode 100644
index 000000000..268cad47b
--- /dev/null
+++ b/sdk/tests/golden/get/executeScriptWithAllOptions/input.json
@@ -0,0 +1,15 @@
+{
+ "events": [
+ {
+ "type": "executeScript",
+ "script": "console.log('hello');",
+ "eventId": "event1",
+ "retryDuration": 2000,
+ "attributes": {
+ "type": "text/javascript",
+ "blocking": "false"
+ },
+ "autoRemove": false
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/executeScriptWithAllOptions/output.txt b/sdk/tests/golden/get/executeScriptWithAllOptions/output.txt
new file mode 100644
index 000000000..7c22e58ac
--- /dev/null
+++ b/sdk/tests/golden/get/executeScriptWithAllOptions/output.txt
@@ -0,0 +1,7 @@
+event: datastar-patch-elements
+id: event1
+retry: 2000
+data: mode append
+data: selector body
+data: elements
+
diff --git a/sdk/tests/golden/get/executeScriptWithDefaults/input.json b/sdk/tests/golden/get/executeScriptWithDefaults/input.json
new file mode 100644
index 000000000..d8b1cd428
--- /dev/null
+++ b/sdk/tests/golden/get/executeScriptWithDefaults/input.json
@@ -0,0 +1,9 @@
+{
+ "events": [
+ {
+ "type": "executeScript",
+ "script": "console.log('hello');",
+ "autoRemove": true
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/executeScriptWithDefaults/output.txt b/sdk/tests/golden/get/executeScriptWithDefaults/output.txt
new file mode 100644
index 000000000..080d0864d
--- /dev/null
+++ b/sdk/tests/golden/get/executeScriptWithDefaults/output.txt
@@ -0,0 +1,5 @@
+event: datastar-patch-elements
+data: mode append
+data: selector body
+data: elements
+
diff --git a/sdk/tests/golden/get/executeScriptWithMultilineScript/input.json b/sdk/tests/golden/get/executeScriptWithMultilineScript/input.json
new file mode 100644
index 000000000..77cfde1e8
--- /dev/null
+++ b/sdk/tests/golden/get/executeScriptWithMultilineScript/input.json
@@ -0,0 +1,8 @@
+{
+ "events": [
+ {
+ "type": "executeScript",
+ "script": "if (true) {\n console.log('hello');\n}"
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/executeScriptWithMultilineScript/output.txt b/sdk/tests/golden/get/executeScriptWithMultilineScript/output.txt
new file mode 100644
index 000000000..582bb8940
--- /dev/null
+++ b/sdk/tests/golden/get/executeScriptWithMultilineScript/output.txt
@@ -0,0 +1,7 @@
+event: datastar-patch-elements
+data: mode append
+data: selector body
+data: elements
+
diff --git a/sdk/tests/golden/get/executeScriptWithoutDefaults/input.json b/sdk/tests/golden/get/executeScriptWithoutDefaults/input.json
new file mode 100644
index 000000000..4232523cb
--- /dev/null
+++ b/sdk/tests/golden/get/executeScriptWithoutDefaults/input.json
@@ -0,0 +1,8 @@
+{
+ "events": [
+ {
+ "type": "executeScript",
+ "script": "console.log('hello');"
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/executeScriptWithoutDefaults/output.txt b/sdk/tests/golden/get/executeScriptWithoutDefaults/output.txt
new file mode 100644
index 000000000..080d0864d
--- /dev/null
+++ b/sdk/tests/golden/get/executeScriptWithoutDefaults/output.txt
@@ -0,0 +1,5 @@
+event: datastar-patch-elements
+data: mode append
+data: selector body
+data: elements
+
diff --git a/sdk/tests/golden/get/patchElementsWithAllOptions/input.json b/sdk/tests/golden/get/patchElementsWithAllOptions/input.json
new file mode 100644
index 000000000..b4ba5a4c0
--- /dev/null
+++ b/sdk/tests/golden/get/patchElementsWithAllOptions/input.json
@@ -0,0 +1,13 @@
+{
+ "events": [
+ {
+ "type": "patchElements",
+ "elements": "
Merge
",
+ "eventId": "event1",
+ "retryDuration": 2000,
+ "selector": "div",
+ "mode": "append",
+ "useViewTransition": true
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/patchElementsWithAllOptions/output.txt b/sdk/tests/golden/get/patchElementsWithAllOptions/output.txt
new file mode 100644
index 000000000..c2106830c
--- /dev/null
+++ b/sdk/tests/golden/get/patchElementsWithAllOptions/output.txt
@@ -0,0 +1,8 @@
+event: datastar-patch-elements
+id: event1
+retry: 2000
+data: selector div
+data: mode append
+data: useViewTransition true
+data: elements Merge
+
diff --git a/sdk/tests/golden/get/patchElementsWithDefaults/input.json b/sdk/tests/golden/get/patchElementsWithDefaults/input.json
new file mode 100644
index 000000000..2bd742818
--- /dev/null
+++ b/sdk/tests/golden/get/patchElementsWithDefaults/input.json
@@ -0,0 +1,10 @@
+{
+ "events": [
+ {
+ "type": "patchElements",
+ "elements": "Merge
",
+ "mode": "outer",
+ "useViewTransition": false
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/patchElementsWithDefaults/output.txt b/sdk/tests/golden/get/patchElementsWithDefaults/output.txt
new file mode 100644
index 000000000..fbbe8f7d4
--- /dev/null
+++ b/sdk/tests/golden/get/patchElementsWithDefaults/output.txt
@@ -0,0 +1,3 @@
+event: datastar-patch-elements
+data: elements Merge
+
diff --git a/sdk/tests/golden/get/patchElementsWithMultilineElements/input.json b/sdk/tests/golden/get/patchElementsWithMultilineElements/input.json
new file mode 100644
index 000000000..97f724749
--- /dev/null
+++ b/sdk/tests/golden/get/patchElementsWithMultilineElements/input.json
@@ -0,0 +1,8 @@
+{
+ "events": [
+ {
+ "type": "patchElements",
+ "elements": "\n Merge\n
"
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/patchElementsWithMultilineElements/output.txt b/sdk/tests/golden/get/patchElementsWithMultilineElements/output.txt
new file mode 100644
index 000000000..378b9e610
--- /dev/null
+++ b/sdk/tests/golden/get/patchElementsWithMultilineElements/output.txt
@@ -0,0 +1,5 @@
+event: datastar-patch-elements
+data: elements
+data: elements Merge
+data: elements
+
diff --git a/sdk/tests/golden/get/patchElementsWithoutDefaults/input.json b/sdk/tests/golden/get/patchElementsWithoutDefaults/input.json
new file mode 100644
index 000000000..1126c3130
--- /dev/null
+++ b/sdk/tests/golden/get/patchElementsWithoutDefaults/input.json
@@ -0,0 +1,8 @@
+{
+ "events": [
+ {
+ "type": "patchElements",
+ "elements": "Merge
"
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/patchElementsWithoutDefaults/output.txt b/sdk/tests/golden/get/patchElementsWithoutDefaults/output.txt
new file mode 100644
index 000000000..fbbe8f7d4
--- /dev/null
+++ b/sdk/tests/golden/get/patchElementsWithoutDefaults/output.txt
@@ -0,0 +1,3 @@
+event: datastar-patch-elements
+data: elements Merge
+
diff --git a/sdk/tests/golden/get/patchSignalsWithAllOptions/input.json b/sdk/tests/golden/get/patchSignalsWithAllOptions/input.json
new file mode 100644
index 000000000..33633e2c2
--- /dev/null
+++ b/sdk/tests/golden/get/patchSignalsWithAllOptions/input.json
@@ -0,0 +1,14 @@
+{
+ "events": [
+ {
+ "type": "patchSignals",
+ "signals": {
+ "one": 1,
+ "two": 2
+ },
+ "eventId": "event1",
+ "retryDuration": 2000,
+ "onlyIfMissing": true
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/patchSignalsWithAllOptions/output.txt b/sdk/tests/golden/get/patchSignalsWithAllOptions/output.txt
new file mode 100644
index 000000000..bc8174ba2
--- /dev/null
+++ b/sdk/tests/golden/get/patchSignalsWithAllOptions/output.txt
@@ -0,0 +1,6 @@
+event: datastar-patch-signals
+id: event1
+retry: 2000
+data: onlyIfMissing true
+data: signals {"one":1,"two":2}
+
diff --git a/sdk/tests/golden/get/patchSignalsWithDefaults/input.json b/sdk/tests/golden/get/patchSignalsWithDefaults/input.json
new file mode 100644
index 000000000..89bc186fc
--- /dev/null
+++ b/sdk/tests/golden/get/patchSignalsWithDefaults/input.json
@@ -0,0 +1,12 @@
+{
+ "events": [
+ {
+ "type": "patchSignals",
+ "signals": {
+ "one": 1,
+ "two": 2
+ },
+ "onlyIfMissing": false
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/patchSignalsWithDefaults/output.txt b/sdk/tests/golden/get/patchSignalsWithDefaults/output.txt
new file mode 100644
index 000000000..160863ceb
--- /dev/null
+++ b/sdk/tests/golden/get/patchSignalsWithDefaults/output.txt
@@ -0,0 +1,3 @@
+event: datastar-patch-signals
+data: signals {"one":1,"two":2}
+
diff --git a/sdk/tests/golden/get/patchSignalsWithMultilineJson/input.json b/sdk/tests/golden/get/patchSignalsWithMultilineJson/input.json
new file mode 100644
index 000000000..0eb515dc0
--- /dev/null
+++ b/sdk/tests/golden/get/patchSignalsWithMultilineJson/input.json
@@ -0,0 +1,8 @@
+{
+ "events": [
+ {
+ "type": "patchSignals",
+ "signals-raw": "{\n\"one\": \"first signal\",\n\"two\": \n\"second signal\"}"
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/patchSignalsWithMultilineJson/output.txt b/sdk/tests/golden/get/patchSignalsWithMultilineJson/output.txt
new file mode 100644
index 000000000..8dc251585
--- /dev/null
+++ b/sdk/tests/golden/get/patchSignalsWithMultilineJson/output.txt
@@ -0,0 +1,6 @@
+event: datastar-patch-signals
+data: signals {
+data: signals "one": "first signal",
+data: signals "two":
+data: signals "second signal"}
+
diff --git a/sdk/tests/golden/get/patchSignalsWithMultilineSignals/input.json b/sdk/tests/golden/get/patchSignalsWithMultilineSignals/input.json
new file mode 100644
index 000000000..8bf6c4491
--- /dev/null
+++ b/sdk/tests/golden/get/patchSignalsWithMultilineSignals/input.json
@@ -0,0 +1,11 @@
+{
+ "events": [
+ {
+ "type": "patchSignals",
+ "signals": {
+ "one": "first\n signal",
+ "two": "second signal"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/sdk/tests/golden/get/patchSignalsWithMultilineSignals/output.txt b/sdk/tests/golden/get/patchSignalsWithMultilineSignals/output.txt
new file mode 100644
index 000000000..d62a6a154
--- /dev/null
+++ b/sdk/tests/golden/get/patchSignalsWithMultilineSignals/output.txt
@@ -0,0 +1,3 @@
+event: datastar-patch-signals
+data: signals {"one":"first\n signal","two":"second signal"}
+
diff --git a/sdk/tests/golden/get/patchSignalsWithoutDefaults/input.json b/sdk/tests/golden/get/patchSignalsWithoutDefaults/input.json
new file mode 100644
index 000000000..b093dcb64
--- /dev/null
+++ b/sdk/tests/golden/get/patchSignalsWithoutDefaults/input.json
@@ -0,0 +1,11 @@
+{
+ "events": [
+ {
+ "type": "patchSignals",
+ "signals": {
+ "one": 1,
+ "two": 2
+ }
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/patchSignalsWithoutDefaults/output.txt b/sdk/tests/golden/get/patchSignalsWithoutDefaults/output.txt
new file mode 100644
index 000000000..160863ceb
--- /dev/null
+++ b/sdk/tests/golden/get/patchSignalsWithoutDefaults/output.txt
@@ -0,0 +1,3 @@
+event: datastar-patch-signals
+data: signals {"one":1,"two":2}
+
diff --git a/sdk/tests/golden/get/removeElementsWithAllOptions/input.json b/sdk/tests/golden/get/removeElementsWithAllOptions/input.json
new file mode 100644
index 000000000..2d8a7fe18
--- /dev/null
+++ b/sdk/tests/golden/get/removeElementsWithAllOptions/input.json
@@ -0,0 +1,12 @@
+{
+ "events": [
+ {
+ "type": "patchElements",
+ "selector": "#target",
+ "eventId": "event1",
+ "mode": "remove",
+ "retryDuration": 2000,
+ "useViewTransition": true
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/removeElementsWithAllOptions/output.txt b/sdk/tests/golden/get/removeElementsWithAllOptions/output.txt
new file mode 100644
index 000000000..395347fde
--- /dev/null
+++ b/sdk/tests/golden/get/removeElementsWithAllOptions/output.txt
@@ -0,0 +1,7 @@
+event: datastar-patch-elements
+id: event1
+retry: 2000
+data: selector #target
+data: mode remove
+data: useViewTransition true
+
diff --git a/sdk/tests/golden/get/removeElementsWithDefaults/input.json b/sdk/tests/golden/get/removeElementsWithDefaults/input.json
new file mode 100644
index 000000000..cda52aa1d
--- /dev/null
+++ b/sdk/tests/golden/get/removeElementsWithDefaults/input.json
@@ -0,0 +1,10 @@
+{
+ "events": [
+ {
+ "type": "patchElements",
+ "selector": "#target",
+ "mode": "remove",
+ "useViewTransition": false
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/removeElementsWithDefaults/output.txt b/sdk/tests/golden/get/removeElementsWithDefaults/output.txt
new file mode 100644
index 000000000..3f3985d41
--- /dev/null
+++ b/sdk/tests/golden/get/removeElementsWithDefaults/output.txt
@@ -0,0 +1,4 @@
+event: datastar-patch-elements
+data: mode remove
+data: selector #target
+
diff --git a/sdk/tests/golden/get/removeElementsWithoutDefaults/input.json b/sdk/tests/golden/get/removeElementsWithoutDefaults/input.json
new file mode 100644
index 000000000..651776ad4
--- /dev/null
+++ b/sdk/tests/golden/get/removeElementsWithoutDefaults/input.json
@@ -0,0 +1,9 @@
+{
+ "events": [
+ {
+ "type": "patchElements",
+ "selector": "#target",
+ "mode": "remove"
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/removeElementsWithoutDefaults/output.txt b/sdk/tests/golden/get/removeElementsWithoutDefaults/output.txt
new file mode 100644
index 000000000..3f3985d41
--- /dev/null
+++ b/sdk/tests/golden/get/removeElementsWithoutDefaults/output.txt
@@ -0,0 +1,4 @@
+event: datastar-patch-elements
+data: mode remove
+data: selector #target
+
diff --git a/sdk/tests/golden/get/removeSignalsWithAllOptions/input.json b/sdk/tests/golden/get/removeSignalsWithAllOptions/input.json
new file mode 100644
index 000000000..595f1203c
--- /dev/null
+++ b/sdk/tests/golden/get/removeSignalsWithAllOptions/input.json
@@ -0,0 +1,13 @@
+{
+ "events": [
+ {
+ "type": "patchSignals",
+ "signals": {
+ "one": null,
+ "two": {"alpha": null}
+ },
+ "eventId": "event1",
+ "retryDuration": 2000
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/removeSignalsWithAllOptions/output.txt b/sdk/tests/golden/get/removeSignalsWithAllOptions/output.txt
new file mode 100644
index 000000000..2f8bef9c0
--- /dev/null
+++ b/sdk/tests/golden/get/removeSignalsWithAllOptions/output.txt
@@ -0,0 +1,5 @@
+event: datastar-patch-signals
+id: event1
+retry: 2000
+data: signals {"one":null,"two":{"alpha":null}}
+
diff --git a/sdk/tests/golden/get/removeSignalsWithDefaults/input.json b/sdk/tests/golden/get/removeSignalsWithDefaults/input.json
new file mode 100644
index 000000000..0701457f5
--- /dev/null
+++ b/sdk/tests/golden/get/removeSignalsWithDefaults/input.json
@@ -0,0 +1,10 @@
+{
+ "events": [
+ {
+ "type": "patchSignals",
+ "signals": {
+ "one": null
+ }
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/removeSignalsWithDefaults/output.txt b/sdk/tests/golden/get/removeSignalsWithDefaults/output.txt
new file mode 100644
index 000000000..8ad856248
--- /dev/null
+++ b/sdk/tests/golden/get/removeSignalsWithDefaults/output.txt
@@ -0,0 +1,3 @@
+event: datastar-patch-signals
+data: signals {"one":null}
+
diff --git a/sdk/tests/golden/get/sendTwoEvents/input.json b/sdk/tests/golden/get/sendTwoEvents/input.json
new file mode 100644
index 000000000..cbfc610ef
--- /dev/null
+++ b/sdk/tests/golden/get/sendTwoEvents/input.json
@@ -0,0 +1,12 @@
+{
+ "events": [
+ {
+ "type": "patchElements",
+ "elements": "Merge
"
+ },
+ {
+ "type": "patchElements",
+ "elements": "Merge 2
"
+ }
+ ]
+}
diff --git a/sdk/tests/golden/get/sendTwoEvents/output.txt b/sdk/tests/golden/get/sendTwoEvents/output.txt
new file mode 100644
index 000000000..8bd550139
--- /dev/null
+++ b/sdk/tests/golden/get/sendTwoEvents/output.txt
@@ -0,0 +1,6 @@
+event: datastar-patch-elements
+data: elements Merge
+
+event: datastar-patch-elements
+data: elements Merge 2
+
diff --git a/sdk/tests/golden/post/readSignalsFromBody/input.json b/sdk/tests/golden/post/readSignalsFromBody/input.json
new file mode 100644
index 000000000..1126c3130
--- /dev/null
+++ b/sdk/tests/golden/post/readSignalsFromBody/input.json
@@ -0,0 +1,8 @@
+{
+ "events": [
+ {
+ "type": "patchElements",
+ "elements": "Merge
"
+ }
+ ]
+}
diff --git a/sdk/tests/golden/post/readSignalsFromBody/output.txt b/sdk/tests/golden/post/readSignalsFromBody/output.txt
new file mode 100644
index 000000000..fbbe8f7d4
--- /dev/null
+++ b/sdk/tests/golden/post/readSignalsFromBody/output.txt
@@ -0,0 +1,3 @@
+event: datastar-patch-elements
+data: elements Merge
+
diff --git a/sdk/tests/runner.go b/sdk/tests/runner.go
new file mode 100644
index 000000000..b41ce78b9
--- /dev/null
+++ b/sdk/tests/runner.go
@@ -0,0 +1,140 @@
+package sdktests
+
+import (
+ "bytes"
+ "embed"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "time"
+)
+
+// TestRunner provides methods to run SDK tests programmatically
+type TestRunner struct {
+ ServerURL string
+ Client *http.Client
+}
+
+// NewTestRunner creates a new test runner
+func NewTestRunner(serverURL string) *TestRunner {
+ if serverURL == "" {
+ serverURL = "http://localhost:7331"
+ }
+ return &TestRunner{
+ ServerURL: serverURL,
+ Client: &http.Client{
+ Timeout: 10 * time.Second,
+ },
+ }
+}
+
+// RunGetTest runs a single GET test case
+func (tr *TestRunner) RunGetTest(inputData []byte) ([]byte, error) {
+ u, err := url.Parse(tr.ServerURL + "/test")
+ if err != nil {
+ return nil, err
+ }
+
+ q := u.Query()
+ q.Set("datastar", string(inputData))
+ u.RawQuery = q.Encode()
+
+ req, err := http.NewRequest("GET", u.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Accept", "text/event-stream")
+ req.Header.Set("datastar-request", "true")
+
+ return tr.makeRequest(req)
+}
+
+// RunPostTest runs a single POST test case
+func (tr *TestRunner) RunPostTest(inputData []byte) ([]byte, error) {
+ req, err := http.NewRequest("POST", tr.ServerURL+"/test", bytes.NewReader(inputData))
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Accept", "text/event-stream")
+ req.Header.Set("datastar-request", "true")
+ req.Header.Set("Content-Type", "application/json")
+
+ return tr.makeRequest(req)
+}
+
+func (tr *TestRunner) makeRequest(req *http.Request) ([]byte, error) {
+ resp, err := tr.Client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, body)
+ }
+
+ return io.ReadAll(resp.Body)
+}
+
+// GetTestData returns the embedded test data filesystem
+func GetTestData() embed.FS {
+ return testData
+}
+
+// TestCase represents a single test case
+type TestCase struct {
+ Name string
+ Input []byte
+ Expected []byte
+}
+
+// GetTestCases returns all test cases for a given type
+func GetTestCases(testType string) ([]TestCase, error) {
+ var dir string
+ switch testType {
+ case "get":
+ dir = "golden/get"
+ case "post":
+ dir = "golden/post"
+ default:
+ return nil, fmt.Errorf("unknown test type: %s", testType)
+ }
+
+ entries, err := testData.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+
+ var cases []TestCase
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+
+ inputPath := filepath.Join(dir, entry.Name(), "input.json")
+ outputPath := filepath.Join(dir, entry.Name(), "output.txt")
+
+ input, err := testData.ReadFile(inputPath)
+ if err != nil {
+ continue
+ }
+
+ expected, err := testData.ReadFile(outputPath)
+ if err != nil {
+ continue
+ }
+
+ cases = append(cases, TestCase{
+ Name: entry.Name(),
+ Input: input,
+ Expected: expected,
+ })
+ }
+
+ return cases, nil
+}
\ No newline at end of file
diff --git a/sdk/tests/testdata.go b/sdk/tests/testdata.go
new file mode 100644
index 000000000..566427877
--- /dev/null
+++ b/sdk/tests/testdata.go
@@ -0,0 +1,341 @@
+package sdktests
+
+import (
+ "bytes"
+ "embed"
+ "flag"
+ "fmt"
+ "io"
+ "io/fs"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/net/html"
+)
+
+//go:embed golden
+var testData embed.FS
+
+var serverURL string
+
+func init() {
+ flag.StringVar(&serverURL, "server", "http://localhost:7331", "Server URL to test against")
+}
+
+// TestSSEGetEndpoints is an exported version of the GET endpoint tests
+func TestSSEGetEndpoints(t *testing.T) {
+ runTestCases(t, testData, "golden/get", runGetTest)
+}
+
+// TestSSEPostEndpoints is an exported version of the POST endpoint tests
+func TestSSEPostEndpoints(t *testing.T) {
+ runTestCases(t, testData, "golden/post", runPostTest)
+}
+
+func runTestCases(t *testing.T, embedFS embed.FS, casesDir string, runTest func(string, []byte) ([]byte, error)) {
+ entries, err := fs.ReadDir(embedFS, casesDir)
+ require.NoError(t, err, "Failed to read %s directory", casesDir)
+
+ // Get unique test case names
+ testCases := make(map[string]bool)
+ for _, entry := range entries {
+ if entry.IsDir() {
+ testCases[entry.Name()] = true
+ } else {
+ // Extract test case name from file path
+ dir := filepath.Dir(entry.Name())
+ if dir != "." && dir != casesDir {
+ testName := filepath.Base(dir)
+ testCases[testName] = true
+ }
+ }
+ }
+
+ // Run each test case
+ for testName := range testCases {
+ testName := testName // capture for closure
+ t.Run(testName, func(t *testing.T) {
+ inputPath := path.Join(casesDir, testName, "input.json")
+ outputPath := path.Join(casesDir, testName, "output.txt")
+
+ // Read input from embedded FS
+ inputData, err := embedFS.ReadFile(inputPath)
+ require.NoError(t, err, "Failed to read input")
+
+ // Read expected output from embedded FS
+ expectedData, err := embedFS.ReadFile(outputPath)
+ require.NoError(t, err, "Failed to read expected output")
+
+ // Run test
+ actualData, err := runTest(serverURL, inputData)
+ require.NoError(t, err, "Request failed")
+
+ // Compare
+ err = compareSSE(t, expectedData, actualData)
+ if err != nil {
+ // Save actual output for debugging
+ debugDir := filepath.Join("testdata", casesDir, testName)
+ os.MkdirAll(debugDir, 0755)
+ actualPath := filepath.Join(debugDir, "testOutput.txt")
+ os.WriteFile(actualPath, actualData, 0644)
+
+ t.Logf("Test case: %s", testName)
+ t.Logf("Actual output saved to: %s", actualPath)
+ }
+ })
+ }
+}
+
+func runGetTest(serverURL string, inputData []byte) ([]byte, error) {
+ u, err := url.Parse(serverURL + "/test")
+ if err != nil {
+ return nil, err
+ }
+
+ q := u.Query()
+ q.Set("datastar", string(inputData))
+ u.RawQuery = q.Encode()
+
+ req, err := http.NewRequest("GET", u.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Accept", "text/event-stream")
+ req.Header.Set("datastar-request", "true")
+
+ return makeRequest(req)
+}
+
+func runPostTest(serverURL string, inputData []byte) ([]byte, error) {
+ req, err := http.NewRequest("POST", serverURL+"/test", bytes.NewReader(inputData))
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Accept", "text/event-stream")
+ req.Header.Set("datastar-request", "true")
+ req.Header.Set("Content-Type", "application/json")
+
+ return makeRequest(req)
+}
+
+func makeRequest(req *http.Request) ([]byte, error) {
+ client := &http.Client{
+ Timeout: 10 * time.Second,
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, body)
+ }
+
+ return io.ReadAll(resp.Body)
+}
+
+// SSE comparison functions
+
+type SSEEvent struct {
+ Fields map[string][]string
+}
+
+func compareSSE(t *testing.T, expected, actual []byte) error {
+ expectedEvents, err := parseSSE(expected)
+ require.NoError(t, err, "Failed to parse expected SSE")
+
+ actualEvents, err := parseSSE(actual)
+ require.NoError(t, err, "Failed to parse actual SSE")
+
+ compareEvents(t, expectedEvents, actualEvents)
+ return nil
+}
+
+func parseSSE(data []byte) ([]SSEEvent, error) {
+ var events []SSEEvent
+ var currentEvent *SSEEvent
+
+ lines := strings.Split(string(data), "\n")
+ for _, line := range lines {
+ // Empty line marks end of event
+ if line == "" {
+ if currentEvent != nil && len(currentEvent.Fields) > 0 {
+ events = append(events, *currentEvent)
+ currentEvent = nil
+ }
+ continue
+ }
+
+ // Parse field
+ colonIndex := strings.Index(line, ":")
+ if colonIndex == -1 {
+ continue
+ }
+
+ if currentEvent == nil {
+ currentEvent = &SSEEvent{Fields: make(map[string][]string)}
+ }
+
+ fieldName := line[:colonIndex]
+ fieldValue := strings.TrimSpace(line[colonIndex+1:])
+
+ currentEvent.Fields[fieldName] = append(currentEvent.Fields[fieldName], fieldValue)
+ }
+
+ // Handle last event if file doesn't end with empty line
+ if currentEvent != nil && len(currentEvent.Fields) > 0 {
+ events = append(events, *currentEvent)
+ }
+
+ return events, nil
+}
+
+func compareEvents(t *testing.T, expected, actual []SSEEvent) {
+ require.Equal(t, len(expected), len(actual), "Event count mismatch")
+
+ for i := range expected {
+ compareEvent(t, i+1, &expected[i], &actual[i])
+ }
+}
+
+func compareEvent(t *testing.T, eventNum int, expected, actual *SSEEvent) {
+ // Compare all non-data fields first
+ for fieldName, expectedValues := range expected.Fields {
+ if fieldName == "data" {
+ continue
+ }
+
+ actualValues, ok := actual.Fields[fieldName]
+ assert.True(t, ok, "Event %d: missing field '%s' in actual", eventNum, fieldName)
+ assert.Equal(t, expectedValues, actualValues, "Event %d: mismatch in '%s' field", eventNum, fieldName)
+ }
+
+ // Check for extra fields in actual
+ for fieldName := range actual.Fields {
+ if fieldName == "data" {
+ continue
+ }
+ _, ok := expected.Fields[fieldName]
+ assert.True(t, ok, "Event %d: unexpected field '%s' in actual", eventNum, fieldName)
+ }
+
+ // Compare data fields with special handling
+ expectedData := expected.Fields["data"]
+ actualData := actual.Fields["data"]
+
+ if len(expectedData) == 0 && len(actualData) == 0 {
+ return
+ }
+
+ require.Equal(t, len(expectedData) > 0, len(actualData) > 0, "Event %d: data field presence mismatch", eventNum)
+
+ // Parse and group data fields
+ expectedGroups := parseDataFields(expectedData)
+ actualGroups := parseDataFields(actualData)
+
+ // Compare groups
+ compareDataGroups(t, eventNum, expectedGroups, actualGroups)
+}
+
+func parseDataFields(fields []string) map[string][]string {
+ groups := make(map[string][]string)
+
+ for _, field := range fields {
+ parts := strings.SplitN(field, " ", 2)
+ if len(parts) >= 1 {
+ subgroup := parts[0]
+ content := ""
+ if len(parts) > 1 {
+ content = parts[1]
+ }
+ groups[subgroup] = append(groups[subgroup], content)
+ }
+ }
+
+ return groups
+}
+
+func compareDataGroups(t *testing.T, eventNum int, expected, actual map[string][]string) {
+ // Check all expected groups exist in actual
+ for subgroup, expectedLines := range expected {
+ actualLines, ok := actual[subgroup]
+ assert.True(t, ok, "Event %d: missing data subgroup '%s' in actual", eventNum, subgroup)
+
+ // Special handling for "elements" subgroup - normalize HTML
+ if subgroup == "elements" {
+ expectedHTML := strings.Join(expectedLines, "\n")
+ actualHTML := strings.Join(actualLines, "\n")
+
+ normalizedExpected := normalizeHTML(expectedHTML)
+ normalizedActual := normalizeHTML(actualHTML)
+
+ assert.Equal(t, normalizedExpected, normalizedActual,
+ "Event %d: mismatch in data 'elements' content\nExpected:\n%s\nActual:\n%s",
+ eventNum, expectedHTML, actualHTML)
+ } else {
+ // For non-elements, exact match
+ assert.Equal(t, expectedLines, actualLines,
+ "Event %d: mismatch in data '%s' content", eventNum, subgroup)
+ }
+ }
+
+ // Check for extra subgroups in actual
+ for subgroup := range actual {
+ _, ok := expected[subgroup]
+ assert.True(t, ok, "Event %d: unexpected data subgroup '%s' in actual", eventNum, subgroup)
+ }
+}
+
+func normalizeHTML(htmlStr string) string {
+ doc, err := html.Parse(strings.NewReader(htmlStr))
+ if err != nil {
+ // If parsing fails, return original
+ return htmlStr
+ }
+
+ // Normalize attributes in all element nodes
+ var normalize func(*html.Node)
+ normalize = func(n *html.Node) {
+ if n.Type == html.ElementNode && n.Attr != nil && len(n.Attr) > 1 {
+ // Sort attributes by key
+ sort.Slice(n.Attr, func(i, j int) bool {
+ return n.Attr[i].Key < n.Attr[j].Key
+ })
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ normalize(c)
+ }
+ }
+ normalize(doc)
+
+ // Render back to string
+ var buf bytes.Buffer
+ html.Render(&buf, doc)
+
+ // The parser adds ..., so extract just the body content
+ result := buf.String()
+
+ // Extract content between and
+ bodyStart := strings.Index(result, "")
+ bodyEnd := strings.Index(result, "")
+
+ if bodyStart != -1 && bodyEnd != -1 {
+ result = result[bodyStart+6 : bodyEnd]
+ }
+
+ return strings.TrimSpace(result)
+}
diff --git a/sdk/tests/tests_test.go b/sdk/tests/tests_test.go
new file mode 100644
index 000000000..99e62c5d8
--- /dev/null
+++ b/sdk/tests/tests_test.go
@@ -0,0 +1,13 @@
+package sdktests
+
+import (
+ "testing"
+)
+
+func TestGetEndpoints(t *testing.T) {
+ TestSSEGetEndpoints(t)
+}
+
+func TestPostEndpoints(t *testing.T) {
+ TestSSEPostEndpoints(t)
+}
\ No newline at end of file