added simple websocket test
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled

- bump to v0.21.1
This commit is contained in:
2025-10-30 19:32:45 +00:00
parent 0ba555c6a8
commit 3486d3d4ab
6 changed files with 346 additions and 1 deletions

View File

@@ -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 <relay-url> [options]
```
## Options
- `-url` (required): Relay websocket URL (e.g., `ws://127.0.0.1:3334` or `wss://relay.example.com`)
- `-test <name>`: 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

160
cmd/relay-tester/main.go Normal file
View File

@@ -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)
}
}

View File

@@ -1 +1 @@
v0.21.0
v0.21.1

View File

@@ -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()

View File

@@ -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

View File

@@ -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"