added simple websocket test
- bump to v0.21.1
This commit is contained in:
71
cmd/relay-tester/README.md
Normal file
71
cmd/relay-tester/README.md
Normal 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
160
cmd/relay-tester/main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1 +1 @@
|
|||||||
v0.21.0
|
v0.21.1
|
||||||
@@ -56,6 +56,11 @@ func (c *Client) Close() error {
|
|||||||
return c.conn.Close()
|
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.
|
// Send sends a JSON message to the relay.
|
||||||
func (c *Client) Send(msg interface{}) (err error) {
|
func (c *Client) Send(msg interface{}) (err error) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
|||||||
@@ -291,6 +291,11 @@ func (s *TestSuite) registerTests() {
|
|||||||
Required: true,
|
Required: true,
|
||||||
Func: testSupportsEose,
|
Func: testSupportsEose,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "Subscription receives event after ping period",
|
||||||
|
Required: true,
|
||||||
|
Func: testSubscriptionReceivesEventAfterPingPeriod,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "Closes complete subscriptions after EOSE",
|
Name: "Closes complete subscriptions after EOSE",
|
||||||
Required: false,
|
Required: false,
|
||||||
@@ -420,6 +425,20 @@ func (s *TestSuite) GetResults() map[string]TestResult {
|
|||||||
return s.results
|
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.
|
// FormatJSON formats results as JSON.
|
||||||
func FormatJSON(results []TestResult) (output string, err error) {
|
func FormatJSON(results []TestResult) (output string, err error) {
|
||||||
var data []byte
|
var data []byte
|
||||||
|
|||||||
@@ -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) {
|
func testClosesCompleteSubscriptionsAfterEose(client *Client, key1, key2 *KeyPair) (result TestResult) {
|
||||||
// Create a filter that fetches a specific event by ID (complete subscription)
|
// Create a filter that fetches a specific event by ID (complete subscription)
|
||||||
fakeID := "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
|
fakeID := "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
|
||||||
|
|||||||
Reference in New Issue
Block a user