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