From 3486d3d4abf9261e8b94c19bba1c5dffbb2490e0 Mon Sep 17 00:00:00 2001 From: mleku Date: Thu, 30 Oct 2025 19:32:45 +0000 Subject: [PATCH] added simple websocket test - bump to v0.21.1 --- cmd/relay-tester/README.md | 71 ++++++++++++++++ cmd/relay-tester/main.go | 160 +++++++++++++++++++++++++++++++++++++ pkg/version/version | 2 +- relay-tester/client.go | 5 ++ relay-tester/test.go | 19 +++++ relay-tester/tests.go | 90 +++++++++++++++++++++ 6 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 cmd/relay-tester/README.md create mode 100644 cmd/relay-tester/main.go diff --git a/cmd/relay-tester/README.md b/cmd/relay-tester/README.md new file mode 100644 index 0000000..1b25ab9 --- /dev/null +++ b/cmd/relay-tester/README.md @@ -0,0 +1,71 @@ +# relay-tester + +A command-line tool for testing Nostr relay implementations against the NIP-01 specification and related NIPs. + +## Usage + +```bash +relay-tester -url [options] +``` + +## Options + +- `-url` (required): Relay websocket URL (e.g., `ws://127.0.0.1:3334` or `wss://relay.example.com`) +- `-test `: Run a specific test by name (default: run all tests) +- `-json`: Output results in JSON format +- `-v`: Verbose output (shows additional info for each test) +- `-list`: List all available tests and exit + +## Examples + +### Run all tests against a local relay: +```bash +relay-tester -url ws://127.0.0.1:3334 +``` + +### Run all tests with verbose output: +```bash +relay-tester -url ws://127.0.0.1:3334 -v +``` + +### Run a specific test: +```bash +relay-tester -url ws://127.0.0.1:3334 -test "Publishes basic event" +``` + +### Output results as JSON: +```bash +relay-tester -url ws://127.0.0.1:3334 -json +``` + +### List all available tests: +```bash +relay-tester -list +``` + +## Exit Codes + +- `0`: All required tests passed +- `1`: One or more required tests failed, or an error occurred + +## Test Categories + +The relay-tester runs tests covering: + +- **Basic Event Operations**: Publishing, finding by ID/author/kind/tags +- **Filtering**: Time ranges, limits, multiple filters, scrape queries +- **Replaceable Events**: Metadata and contact list replacement +- **Parameterized Replaceable Events**: Addressable events with `d` tags +- **Event Deletion**: Deletion events (NIP-09) +- **Ephemeral Events**: Event handling for ephemeral kinds +- **EOSE Handling**: End of stored events signaling +- **Event Validation**: Signature verification, ID hash verification +- **JSON Compliance**: NIP-01 JSON escape sequences + +## Notes + +- Tests are run in dependency order (some tests depend on others) +- Required tests must pass for the relay to be considered compliant +- Optional tests may fail without affecting overall compliance +- The tool connects to the relay using WebSocket and runs tests sequentially + diff --git a/cmd/relay-tester/main.go b/cmd/relay-tester/main.go new file mode 100644 index 0000000..f27154c --- /dev/null +++ b/cmd/relay-tester/main.go @@ -0,0 +1,160 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "lol.mleku.dev/log" + relaytester "next.orly.dev/relay-tester" +) + +func main() { + var ( + relayURL = flag.String("url", "", "relay websocket URL (required, e.g., ws://127.0.0.1:3334)") + testName = flag.String("test", "", "run specific test by name (default: run all tests)") + jsonOut = flag.Bool("json", false, "output results in JSON format") + verbose = flag.Bool("v", false, "verbose output") + listTests = flag.Bool("list", false, "list all available tests and exit") + ) + flag.Parse() + + if *listTests { + listAllTests() + return + } + + if *relayURL == "" { + log.E.F("required flag: -url (relay websocket URL)") + flag.Usage() + os.Exit(1) + } + + // Validate URL format + if !strings.HasPrefix(*relayURL, "ws://") && !strings.HasPrefix(*relayURL, "wss://") { + log.E.F("URL must start with ws:// or wss://") + os.Exit(1) + } + + // Create test suite + if *verbose { + log.I.F("Creating test suite for %s...", *relayURL) + } + suite, err := relaytester.NewTestSuite(*relayURL) + if err != nil { + log.E.F("failed to create test suite: %v", err) + os.Exit(1) + } + + // Run tests + var results []relaytester.TestResult + if *testName != "" { + if *verbose { + log.I.F("Running test: %s", *testName) + } + result, err := suite.RunTest(*testName) + if err != nil { + log.E.F("failed to run test %s: %v", *testName, err) + os.Exit(1) + } + results = []relaytester.TestResult{result} + } else { + if *verbose { + log.I.F("Running all tests...") + } + if results, err = suite.Run(); err != nil { + log.E.F("failed to run tests: %v", err) + os.Exit(1) + } + } + + // Output results + if *jsonOut { + jsonOutput, err := relaytester.FormatJSON(results) + if err != nil { + log.E.F("failed to format JSON: %v", err) + os.Exit(1) + } + fmt.Println(jsonOutput) + } else { + outputResults(results, *verbose) + } + + // Check exit code + hasRequiredFailures := false + for _, result := range results { + if result.Required && !result.Pass { + hasRequiredFailures = true + break + } + } + + if hasRequiredFailures { + os.Exit(1) + } +} + +func outputResults(results []relaytester.TestResult, verbose bool) { + passed := 0 + failed := 0 + requiredFailed := 0 + + for _, result := range results { + if result.Pass { + passed++ + if verbose { + fmt.Printf("PASS: %s", result.Name) + if result.Info != "" { + fmt.Printf(" - %s", result.Info) + } + fmt.Println() + } else { + fmt.Printf("PASS: %s\n", result.Name) + } + } else { + failed++ + if result.Required { + requiredFailed++ + fmt.Printf("FAIL (required): %s", result.Name) + } else { + fmt.Printf("FAIL (optional): %s", result.Name) + } + if result.Info != "" { + fmt.Printf(" - %s", result.Info) + } + fmt.Println() + } + } + + fmt.Println() + fmt.Println("Test Summary:") + fmt.Printf(" Total: %d\n", len(results)) + fmt.Printf(" Passed: %d\n", passed) + fmt.Printf(" Failed: %d\n", failed) + fmt.Printf(" Required Failed: %d\n", requiredFailed) +} + +func listAllTests() { + // Create a dummy test suite to get the list of tests + suite, err := relaytester.NewTestSuite("ws://127.0.0.1:0") + if err != nil { + log.E.F("failed to create test suite: %v", err) + os.Exit(1) + } + + fmt.Println("Available tests:") + fmt.Println() + + testNames := suite.ListTests() + testInfo := suite.GetTestNames() + + for _, name := range testNames { + required := "" + if testInfo[name] { + required = " (required)" + } + fmt.Printf(" - %s%s\n", name, required) + } +} + diff --git a/pkg/version/version b/pkg/version/version index fcc9d59..7a5ca36 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.21.0 \ No newline at end of file +v0.21.1 \ No newline at end of file diff --git a/relay-tester/client.go b/relay-tester/client.go index f2caaa1..80baff5 100644 --- a/relay-tester/client.go +++ b/relay-tester/client.go @@ -56,6 +56,11 @@ func (c *Client) Close() error { return c.conn.Close() } +// URL returns the relay URL. +func (c *Client) URL() string { + return c.url +} + // Send sends a JSON message to the relay. func (c *Client) Send(msg interface{}) (err error) { c.mu.Lock() diff --git a/relay-tester/test.go b/relay-tester/test.go index cc8fcdb..aa50205 100644 --- a/relay-tester/test.go +++ b/relay-tester/test.go @@ -291,6 +291,11 @@ func (s *TestSuite) registerTests() { Required: true, Func: testSupportsEose, }, + { + Name: "Subscription receives event after ping period", + Required: true, + Func: testSubscriptionReceivesEventAfterPingPeriod, + }, { Name: "Closes complete subscriptions after EOSE", Required: false, @@ -420,6 +425,20 @@ func (s *TestSuite) GetResults() map[string]TestResult { return s.results } +// ListTests returns a list of all test names in execution order. +func (s *TestSuite) ListTests() []string { + return s.order +} + +// GetTestNames returns all registered test names as a map (name -> required). +func (s *TestSuite) GetTestNames() map[string]bool { + result := make(map[string]bool) + for name, tc := range s.tests { + result[name] = tc.Required + } + return result +} + // FormatJSON formats results as JSON. func FormatJSON(results []TestResult) (output string, err error) { var data []byte diff --git a/relay-tester/tests.go b/relay-tester/tests.go index 653b8c7..d492f48 100644 --- a/relay-tester/tests.go +++ b/relay-tester/tests.go @@ -1632,6 +1632,96 @@ func testSupportsEose(client *Client, key1, key2 *KeyPair) (result TestResult) { } } +func testSubscriptionReceivesEventAfterPingPeriod(client *Client, key1, key2 *KeyPair) (result TestResult) { + // Create a second client for publishing + publisherClient, err := NewClient(client.URL()) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create publisher client: %v", err)} + } + defer publisherClient.Close() + + // Subscribe to events from key1 + filter := map[string]interface{}{ + "authors": []string{hex.Enc(key1.Pubkey)}, + "kinds": []int{int(kind.TextNote.K)}, + } + ch, err := client.Subscribe("test-ping-period", []interface{}{filter}) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to subscribe: %v", err)} + } + defer client.Unsubscribe("test-ping-period") + + // Wait for EOSE to ensure subscription is established + eoseTimeout := time.After(3 * time.Second) + gotEose := false + for !gotEose { + select { + case msg, ok := <-ch: + if !ok { + return TestResult{Pass: false, Info: "channel closed before EOSE"} + } + var raw []interface{} + if err = json.Unmarshal(msg, &raw); err != nil { + continue + } + if len(raw) >= 2 { + if typ, ok := raw[0].(string); ok && typ == "EOSE" { + gotEose = true + break + } + } + case <-eoseTimeout: + return TestResult{Pass: false, Info: "timeout waiting for EOSE"} + } + } + + // Wait for at least one ping period (30 seconds) to ensure connection is idle + // and has been pinged at least once + pingPeriod := 35 * time.Second // Slightly longer than 30s to ensure at least one ping + time.Sleep(pingPeriod) + + // Now publish an event from the publisher client that matches the subscription + ev, err := CreateEvent(key1.Secret, kind.TextNote.K, "event after ping period", nil) + if err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to create event: %v", err)} + } + if err = publisherClient.Publish(ev); err != nil { + return TestResult{Pass: false, Info: fmt.Sprintf("failed to publish: %v", err)} + } + accepted, _, err := publisherClient.WaitForOK(ev.ID, 5*time.Second) + if err != nil || !accepted { + return TestResult{Pass: false, Info: "event not accepted"} + } + + // Wait for event to come through subscription (should work even after ping period) + eventTimeout := time.After(5 * time.Second) + for { + select { + case msg, ok := <-ch: + if !ok { + return TestResult{Pass: false, Info: "subscription closed"} + } + var raw []interface{} + if err = json.Unmarshal(msg, &raw); err != nil { + continue + } + if len(raw) >= 3 && raw[0] == "EVENT" { + if evData, ok := raw[2].(map[string]interface{}); ok { + evJSON, _ := json.Marshal(evData) + receivedEv := event.New() + if _, err = receivedEv.Unmarshal(evJSON); err == nil { + if string(receivedEv.ID) == string(ev.ID) { + return TestResult{Pass: true} + } + } + } + } + case <-eventTimeout: + return TestResult{Pass: false, Info: "timeout waiting for event after ping period"} + } + } +} + func testClosesCompleteSubscriptionsAfterEose(client *Client, key1, key2 *KeyPair) (result TestResult) { // Create a filter that fetches a specific event by ID (complete subscription) fakeID := "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"