diff --git a/.agents/skills/jaws/SKILL.md b/.agents/skills/jaws/SKILL.md index 11711a36..c9cd927c 100644 --- a/.agents/skills/jaws/SKILL.md +++ b/.agents/skills/jaws/SKILL.md @@ -94,10 +94,16 @@ an outer HTTP handler to load the data and invoke a newly constructed the page handler when initial rendering needs a Session; `AutoSession` runs at WebSocket upgrade and is too late for initial page state. -`ui.Handler` owns `NewRequest` and exposes no Request setup hook. If a design -depends on `SetConnectFn`, either move that lifecycle into supported HTTP/session -setup or consciously build a custom page handler. A full-document Template is -not a supported workaround. +`ui.Handler` owns `NewRequest` and recognizes `jaws.ConnectHandler` in its +top-level Dot's method set, including promoted methods. It installs `JawsConnect` +before page template execution; the plain GET does not invoke it. An +implementation available only on a nested Template Dot is ignored without a +diagnostic. The bundled client connects after parsing the document. A custom +client can invoke the hook during rendering once flushed response bytes expose +the request key. Other Request setup requires a custom page handler. A +full-document Template is not a supported workaround. A connection identifies a +JaWS-capable client, not affirmative human intent; use a semantic click action +when that distinction matters. A retained Template update keeps its wrapper Element and Jid, sends new inner HTML, and unregisters/recreates managed descendants. It does not preserve diff --git a/AI.md b/AI.md index 66ad8e25..803050bb 100644 --- a/AI.md +++ b/AI.md @@ -116,6 +116,21 @@ The normal page flow has two related HTTP requests: key, claims the pending Request through `UseRequest`, upgrades the connection, and begins event and DOM-update processing. +When the top-level dot passed to `ui.Handler` implements `ConnectHandler`, the +handler installs its `JawsConnect` method on the Request before page template +execution. The page GET only installs the callback; an accepted WebSocket +invokes it with the `ConnectFn` lifecycle. Only the top-level dot's method set is +considered, including promoted methods. An implementation available only on a +nested `ui.Template` dot is ignored without a diagnostic. The bundled client +connects after parsing the document, while a custom client can dial once flushed +response bytes expose the request key and overlap initial template execution. +Because `ui.Handler` reuses the dot, its state and callbacks must be +concurrency-safe. + +After changing state, use the exact-Element or dependency-tag scope described +above. A connection identifies a JaWS-capable client, not affirmative human +intent; use a semantic click action when that distinction matters. + `HeadHTML` does not manage response headers. The bundled client reloads pages restored from the bfcache. diff --git a/contracts.go b/contracts.go index 6b409990..646f1a59 100644 --- a/contracts.go +++ b/contracts.go @@ -111,6 +111,16 @@ type Updater interface { JawsUpdate(elem *Element) } +// ConnectHandler initializes or validates a [Request] after its WebSocket is accepted. +// +// [github.com/linkdata/jaws/lib/ui.Handler] discovers this optional capability +// only on its top-level page dot. JawsConnect has the lifecycle and permitted +// operations described by [ConnectFn]. +type ConnectHandler interface { + // JawsConnect initializes or validates rq. + JawsConnect(rq *Request) error +} + // ClickHandler handles click events sent from the browser. type ClickHandler interface { // JawsClick is called for non-input-origin browser clicks. diff --git a/lib/ui/AI.md b/lib/ui/AI.md index 488ef4cf..a76ee5a8 100644 --- a/lib/ui/AI.md +++ b/lib/ui/AI.md @@ -85,6 +85,16 @@ should use Go's native template action: {{template "partial" .Dot}} ``` +After creating each Request, `ui.Handler` checks the top-level Dot's method set, +including promoted methods, for `jaws.ConnectHandler` and installs `JawsConnect` +before page template execution. A plain GET only installs the callback; the +accepted WebSocket invokes it with the `jaws.ConnectFn` lifecycle. An +implementation available only on a nested Template Dot is ignored without a +diagnostic. Handler reuses its Dot across Requests, so its state and callbacks +must support concurrent execution. The bundled client connects after parsing +the document, while a custom client can dial once flushed response bytes expose +the request key and overlap initial rendering. + The Template's Dot contributes both identity and tags. It must be nil or comparable at runtime, equal to itself, and usable under `tag.TagExpand`. Implementing `JawsGetTag` does not repair a non-comparable Dot because tag @@ -158,9 +168,14 @@ Dirty only the output that actually changed. The bundled client forwards input, click, and context-menu events only while its WebSocket is open and does not replay earlier interaction. When early input -matters, render controls disabled or make the region inert. Use a Request -`ConnectFn` to update a request-local readiness value and dirty its tag or the -exact Element whose updater removes the gate. +matters, render controls disabled or make the region inert. In a custom page +handler, install a Request `ConnectFn` that updates synchronized request-local +readiness and dirties the request-specific readiness tag registered by the +gate, or the exact Element whose updater removes it. A reused `ui.Handler` +shares its Dot across Requests. Its `ConnectHandler` can validate the callback +Request or update synchronized shared state, but a scalar Dot field cannot serve +as a request-local readiness gate. Ordinary tag dirtying updates matching +Elements on every live Request. Native form reset is unsupported for managed inputs and Select. A reset button or `form.reset()` changes browser state without the per-control events JaWS diff --git a/lib/ui/example_test.go b/lib/ui/example_test.go index 4b1fb11b..4e645fae 100644 --- a/lib/ui/example_test.go +++ b/lib/ui/example_test.go @@ -6,15 +6,65 @@ import ( "errors" "fmt" "html/template" + "log/slog" + "net/http" "net/http/httptest" "strings" "sync" "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/bind" "github.com/linkdata/jaws/lib/tag" "github.com/linkdata/jaws/lib/ui" ) +const exampleConnectionsHTML = ` +{{$.HeadHTML}} +{{$.Span .Dot.Count}}{{$.TailHTML}} +` + +type exampleConnections struct { + mu sync.RWMutex + count int +} + +// Count returns the accepted connection count as a direct field binding. +func (state *exampleConnections) Count() bind.Binder[int] { + return bind.New(&state.mu, &state.count) +} + +// JawsConnect records an accepted JaWS client connection. +func (state *exampleConnections) JawsConnect(rq *jaws.Request) error { + state.mu.Lock() + state.count++ + state.mu.Unlock() + rq.Dirty(&state.count) + return nil +} + +var _ jaws.ConnectHandler = (*exampleConnections)(nil) + +func ExampleHandler_connectHandler() { + jw, err := jaws.New() + if err != nil { + panic(err) + } + defer jw.Close() + jw.Logger = slog.Default() + + templates := template.Must(template.New("connections").Parse(exampleConnectionsHTML)) + if err = jw.AddTemplateLookuper(templates); err != nil { + panic(err) + } + + go jw.Serve() + mux := http.NewServeMux() + mux.Handle("GET /jaws/", jw) + mux.Handle("GET /", ui.Handler(jw, "connections", new(exampleConnections))) + + _ = mux // serve mux with an HTTP server +} + type examplePathState struct { Title string `json:"title"` Items []string `json:"items"` diff --git a/lib/ui/handler.go b/lib/ui/handler.go index 54fde7c4..36ecc69b 100644 --- a/lib/ui/handler.go +++ b/lib/ui/handler.go @@ -90,6 +90,9 @@ func (sr *statusRecorder) WriteHeader(code int) { func (h uiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { rq := h.NewRequest(w, r) + if handler, ok := h.dot.(jaws.ConnectHandler); ok { + rq.SetConnectFn(handler.JawsConnect) + } sr := &statusRecorder{ResponseWriter: w} rw := RequestWriter{Request: rq, Writer: sr} // Build a fresh per-request pointer so the UI is comparable as a map key @@ -123,8 +126,17 @@ func (h uiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // response. // // Handler renders without a generated wrapper and does not use dot as a tag. -// Dot may be arbitrary template data. Handler reuses dot across requests; dot -// and its callbacks must support concurrent execution. +// Dot may be arbitrary template data. When dot implements [jaws.ConnectHandler], +// Handler installs its JawsConnect method on each Request before executing the +// page template. The page GET does not invoke JawsConnect. Only the top-level +// dot's method set is considered, including promoted methods. Implementations +// available only through non-promoted fields or nested [Template] dots are +// ignored without a diagnostic. +// +// Handler reuses dot across requests, so dot and its callbacks must support +// concurrent execution. The bundled client connects after parsing the document, +// while a custom client can dial once flushed response bytes expose the request +// key and overlap the initial page render. func Handler(jw *jaws.Jaws, name string, dot any) http.Handler { return uiHandler{Jaws: jw, name: name, dot: dot} } diff --git a/lib/ui/handler_377_test.go b/lib/ui/handler_377_test.go new file mode 100644 index 00000000..2ce24d8f --- /dev/null +++ b/lib/ui/handler_377_test.go @@ -0,0 +1,506 @@ +package ui + +import ( + "context" + "errors" + "fmt" + "html/template" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/what" + "github.com/linkdata/jaws/lib/wire" +) + +const handlerWebSocketTestTimeout = 5 * time.Second + +type handlerWebSocketServer struct { + server *httptest.Server + requests chan *jaws.Request +} + +func newHandlerWebSocketServer(t *testing.T, source string, dot any, funcs template.FuncMap) (ts *handlerWebSocketServer) { + t.Helper() + + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + jw.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + + requests := make(chan *jaws.Request, 64) + templateFuncs := template.FuncMap{ + "captureHandlerRequest": func(with With) string { + requests <- with.RequestWriter.Request + return "" + }, + } + for name, fn := range funcs { + templateFuncs[name] = fn + } + tmpl, err := template.New("page").Funcs(templateFuncs).Parse(source) + if err != nil { + jw.Close() + t.Fatal(err) + } + if err = jw.AddTemplateLookuper(tmpl); err != nil { + jw.Close() + t.Fatal(err) + } + + serveDone := make(chan struct{}) + go func() { + defer close(serveDone) + jw.Serve() + }() + + mux := http.NewServeMux() + mux.Handle("GET /jaws/", jw) + mux.Handle("GET /", Handler(jw, "page", dot)) + server := httptest.NewServer(mux) + ts = &handlerWebSocketServer{server: server, requests: requests} + t.Cleanup(func() { + jw.Close() + server.Close() + <-serveDone + }) + return +} + +func (ts *handlerWebSocketServer) get(ctx context.Context) (body string, err error) { + var req *http.Request + if req, err = http.NewRequestWithContext(ctx, http.MethodGet, ts.server.URL+"/", nil); err == nil { + var resp *http.Response + if resp, err = ts.server.Client().Do(req); err == nil { + var data []byte + var readErr error + data, readErr = io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + err = errors.Join(readErr, closeErr) + body = string(data) + if err == nil && resp.StatusCode != http.StatusOK { + err = fmt.Errorf("GET status = %d, want %d; body %q", resp.StatusCode, http.StatusOK, body) + } + } + } + return +} + +func (ts *handlerWebSocketServer) dial(ctx context.Context, rq *jaws.Request) (conn *websocket.Conn, err error) { + header := http.Header{} + header.Set("Origin", ts.server.URL) + var resp *http.Response + wsURL := "ws" + strings.TrimPrefix(ts.server.URL, "http") + "/jaws/" + rq.JawsKeyString() + conn, resp, err = websocket.Dial(ctx, wsURL, &websocket.DialOptions{ + HTTPHeader: header, + }) + if err == nil { + if resp == nil { + err = errors.New("WebSocket handshake returned no response") + } else if resp.StatusCode != http.StatusSwitchingProtocols { + err = fmt.Errorf("WebSocket status = %d, want %d", resp.StatusCode, http.StatusSwitchingProtocols) + } + } + if err != nil && conn != nil { + err = errors.Join(err, conn.CloseNow()) + conn = nil + } + return +} + +func receiveHandlerWebSocketValue[T any](t *testing.T, ctx context.Context, ch <-chan T, description string) (value T) { + t.Helper() + select { + case value = <-ch: + case <-ctx.Done(): + t.Fatalf("waiting for %s: %v", description, context.Cause(ctx)) + } + return +} + +func waitHandlerWebSocketDone(t *testing.T, ctx context.Context, done <-chan struct{}, description string) { + t.Helper() + select { + case <-done: + case <-ctx.Done(): + t.Fatalf("waiting for %s: %v", description, context.Cause(ctx)) + } +} + +func closeHandlerWebSocket(t *testing.T, conn *websocket.Conn) { + t.Helper() + if conn != nil { + if err := conn.CloseNow(); err != nil { + t.Errorf("closing WebSocket: %v", err) + } + } +} + +type connectHandlerRecorder struct { + mu sync.Mutex + calls []*jaws.Request + connectErr error + called chan *jaws.Request +} + +func (rec *connectHandlerRecorder) JawsConnect(rq *jaws.Request) (err error) { + rec.mu.Lock() + rec.calls = append(rec.calls, rq) + err = rec.connectErr + rec.mu.Unlock() + if rec.called != nil { + rec.called <- rq + } + return +} + +func (rec *connectHandlerRecorder) snapshot() (calls []*jaws.Request) { + rec.mu.Lock() + calls = append(calls, rec.calls...) + rec.mu.Unlock() + return +} + +type connectBeforeClickDot struct { + mu sync.Mutex + order []string + connectEntered chan struct{} + releaseConnect <-chan struct{} + clickCalled chan struct{} + enterOnce sync.Once + clickOnce sync.Once +} + +func (dot *connectBeforeClickDot) JawsConnect(*jaws.Request) error { + dot.mu.Lock() + dot.order = append(dot.order, "connect start") + dot.mu.Unlock() + dot.enterOnce.Do(func() { close(dot.connectEntered) }) + <-dot.releaseConnect + dot.mu.Lock() + dot.order = append(dot.order, "connect return") + dot.mu.Unlock() + return nil +} + +func (dot *connectBeforeClickDot) JawsClick(*jaws.Element, jaws.Click) error { + dot.mu.Lock() + dot.order = append(dot.order, "click") + dot.mu.Unlock() + dot.clickOnce.Do(func() { close(dot.clickCalled) }) + return nil +} + +func (dot *connectBeforeClickDot) snapshot() (order []string) { + dot.mu.Lock() + order = append(order, dot.order...) + dot.mu.Unlock() + return +} + +func TestHandler_DotWithoutConnectHandlerUnchanged(t *testing.T) { + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}hello {{.Dot}}`, "world", nil) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) + defer cancel() + + body, err := ts.get(ctx) + if err != nil { + t.Fatal(err) + } + if body != "hello world" { + t.Fatalf("body = %q, want %q", body, "hello world") + } + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") + if rq.GetConnectFn() != nil { + t.Fatal("plain page dot installed a ConnectFn") + } +} + +func TestHandler_GETDoesNotInvokeConnectHandler(t *testing.T) { + dot := new(connectHandlerRecorder) + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}{{$.HeadHTML}}`, dot, nil) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) + defer cancel() + + if _, err := ts.get(ctx); err != nil { + t.Fatal(err) + } + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") + if calls := dot.snapshot(); len(calls) != 0 { + t.Fatalf("JawsConnect calls after GET = %v, want none", calls) + } + if rq.GetConnectFn() == nil { + t.Fatal("GET did not install the page dot ConnectFn") + } +} + +func TestHandler_WebSocketInvokesConnectHandlerOnceForSameRequest(t *testing.T) { + dot := &connectHandlerRecorder{called: make(chan *jaws.Request, 2)} + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}{{$.HeadHTML}}`, dot, nil) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) + defer cancel() + + if _, err := ts.get(ctx); err != nil { + t.Fatal(err) + } + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") + rqCtx := rq.Context() + conn, err := ts.dial(ctx, rq) + if err != nil { + t.Fatal(err) + } + if got := receiveHandlerWebSocketValue(t, ctx, dot.called, "JawsConnect call"); got != rq { + t.Fatalf("JawsConnect Request = %p, want page Request %p", got, rq) + } + closeHandlerWebSocket(t, conn) + waitHandlerWebSocketDone(t, ctx, rqCtx.Done(), "Request shutdown") + + calls := dot.snapshot() + if len(calls) != 1 || calls[0] != rq { + t.Fatalf("JawsConnect calls = %v, want [%p]", calls, rq) + } +} + +func TestHandler_ConnectHandlerRunsBeforeBrowserMessages(t *testing.T) { + releaseConnect := make(chan struct{}) + release := sync.OnceFunc(func() { close(releaseConnect) }) + defer release() + dot := &connectBeforeClickDot{ + connectEntered: make(chan struct{}), + releaseConnect: releaseConnect, + clickCalled: make(chan struct{}), + } + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}{{$.HeadHTML}}{{$.Button "run" .Dot}}`, dot, nil) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) + defer cancel() + + if _, err := ts.get(ctx); err != nil { + t.Fatal(err) + } + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") + elems := rq.GetElements(dot) + if len(elems) != 1 { + t.Fatalf("button Elements = %d, want 1", len(elems)) + } + conn, err := ts.dial(ctx, rq) + if err != nil { + t.Fatal(err) + } + defer func() { closeHandlerWebSocket(t, conn) }() + waitHandlerWebSocketDone(t, ctx, dot.connectEntered, "JawsConnect entry") + + click := wire.WsMsg{Jid: elems[0].Jid(), What: what.Click, Data: "0 0 0 run"} + if err = conn.Write(ctx, websocket.MessageText, click.Append(nil)); err != nil { + t.Fatal(err) + } + select { + case <-dot.clickCalled: + t.Fatal("browser click ran while JawsConnect was blocked") + default: + } + release() + waitHandlerWebSocketDone(t, ctx, dot.clickCalled, "browser click") + + want := []string{"connect start", "connect return", "click"} + if got := dot.snapshot(); !slices.Equal(got, want) { + t.Fatalf("callback order = %v, want %v", got, want) + } +} + +func TestHandler_ConnectHandlerErrorClosesWebSocket(t *testing.T) { + connectErr := errors.New("connect rejected") + dot := &connectHandlerRecorder{ + connectErr: connectErr, + called: make(chan *jaws.Request, 2), + } + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}{{$.HeadHTML}}`, dot, nil) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) + defer cancel() + + if _, err := ts.get(ctx); err != nil { + t.Fatal(err) + } + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") + rqCtx := rq.Context() + conn, err := ts.dial(ctx, rq) + if err != nil { + t.Fatal(err) + } + defer func() { closeHandlerWebSocket(t, conn) }() + if got := receiveHandlerWebSocketValue(t, ctx, dot.called, "JawsConnect call"); got != rq { + t.Fatalf("JawsConnect Request = %p, want page Request %p", got, rq) + } + waitHandlerWebSocketDone(t, ctx, rqCtx.Done(), "failed Request shutdown") + if !errors.Is(context.Cause(rqCtx), connectErr) { + t.Fatalf("Request cause = %v, want %v", context.Cause(rqCtx), connectErr) + } + if _, _, err = conn.Read(ctx); err == nil { + t.Fatal("ConnectHandler error left WebSocket open") + } else if ctx.Err() != nil { + t.Fatalf("WebSocket remained open until timeout: %v", context.Cause(ctx)) + } +} + +func TestHandler_ConnectHandlerInstalledBeforeTemplateExecution(t *testing.T) { + type observation struct { + rq *jaws.Request + installed bool + } + observed := make(chan observation, 1) + dot := new(connectHandlerRecorder) + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}{{exposeHandlerRequestKey $}}`, dot, template.FuncMap{ + "exposeHandlerRequestKey": func(with With) string { + rq := with.RequestWriter.Request + observed <- observation{rq: rq, installed: rq.GetConnectFn() != nil} + return rq.JawsKeyString() + }, + }) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) + defer cancel() + + body, err := ts.get(ctx) + if err != nil { + t.Fatal(err) + } + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") + got := receiveHandlerWebSocketValue(t, ctx, observed, "template observation") + if got.rq != rq { + t.Fatalf("template Request = %p, want page Request %p", got.rq, rq) + } + if !got.installed { + t.Fatal("template exposed the Request key before ConnectFn was installed") + } + if body != rq.JawsKeyString() { + t.Fatalf("exposed key = %q, want %q", body, rq.JawsKeyString()) + } +} + +func TestHandler_NestedTemplateConnectHandlerIgnored(t *testing.T) { + nested := &connectHandlerRecorder{called: make(chan *jaws.Request, 2)} + page := struct { + Nested *connectHandlerRecorder + }{Nested: nested} + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}{{$.HeadHTML}}{{$.Template "div" "nested" .Dot.Nested}}{{define "nested"}}nested{{end}}`, page, nil) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) + defer cancel() + + if _, err := ts.get(ctx); err != nil { + t.Fatal(err) + } + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") + rqCtx := rq.Context() + if rq.GetConnectFn() != nil { + t.Fatal("nested Template dot installed the Request ConnectFn") + } + conn, err := ts.dial(ctx, rq) + if err != nil { + t.Fatal(err) + } + closeHandlerWebSocket(t, conn) + waitHandlerWebSocketDone(t, ctx, rqCtx.Done(), "Request shutdown") + if calls := nested.snapshot(); len(calls) != 0 { + t.Fatalf("nested JawsConnect calls = %v, want none", calls) + } +} + +func TestHandler_SharedHandlerSupportsConcurrentRequests(t *testing.T) { + const requestCount = 8 + dot := &connectHandlerRecorder{called: make(chan *jaws.Request, requestCount*2)} + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}{{$.HeadHTML}}`, dot, nil) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) + defer cancel() + + getResults := make(chan error, requestCount) + startGET := make(chan struct{}) + for range requestCount { + go func() { + <-startGET + _, err := ts.get(ctx) + getResults <- err + }() + } + close(startGET) + for range requestCount { + if err := receiveHandlerWebSocketValue(t, ctx, getResults, "concurrent GET"); err != nil { + t.Fatal(err) + } + } + + requests := make([]*jaws.Request, 0, requestCount) + requestSet := make(map[*jaws.Request]struct{}, requestCount) + requestContexts := make([]context.Context, 0, requestCount) + for range requestCount { + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") + if _, duplicate := requestSet[rq]; duplicate { + t.Fatalf("duplicate page Request %p", rq) + } + requestSet[rq] = struct{}{} + requests = append(requests, rq) + requestContexts = append(requestContexts, rq.Context()) + } + + type dialResult struct { + conn *websocket.Conn + err error + } + dialResults := make(chan dialResult, requestCount) + startDial := make(chan struct{}) + for _, rq := range requests { + go func() { + <-startDial + conn, err := ts.dial(ctx, rq) + dialResults <- dialResult{conn: conn, err: err} + }() + } + close(startDial) + + conns := make([]*websocket.Conn, 0, requestCount) + defer func() { + for _, conn := range conns { + closeHandlerWebSocket(t, conn) + } + }() + var dialErr error + for range requestCount { + result := receiveHandlerWebSocketValue(t, ctx, dialResults, "concurrent WebSocket dial") + if result.conn != nil { + conns = append(conns, result.conn) + } + dialErr = errors.Join(dialErr, result.err) + } + if dialErr != nil { + t.Fatal(dialErr) + } + + calledSet := make(map[*jaws.Request]struct{}, requestCount) + for range requestCount { + rq := receiveHandlerWebSocketValue(t, ctx, dot.called, "JawsConnect call") + if _, ok := requestSet[rq]; !ok { + t.Errorf("JawsConnect received unknown Request %p", rq) + } + if _, duplicate := calledSet[rq]; duplicate { + t.Errorf("JawsConnect called more than once for Request %p", rq) + } + calledSet[rq] = struct{}{} + } + if calls := dot.snapshot(); len(calls) != requestCount { + t.Fatalf("JawsConnect calls = %d, want %d", len(calls), requestCount) + } + + for _, conn := range conns { + closeHandlerWebSocket(t, conn) + } + conns = nil + for i, rqCtx := range requestContexts { + waitHandlerWebSocketDone(t, ctx, rqCtx.Done(), fmt.Sprintf("Request %d shutdown", i)) + } +}