@@ -9,12 +9,14 @@ import (
99 "io"
1010 "log/slog"
1111 "net/http"
12+ "net/http/httptest"
1213 "reflect"
1314 "regexp"
1415 "runtime"
1516 "slices"
1617 "strings"
1718 "testing"
19+ "testing/synctest"
1820 "time"
1921
2022 "github.com/linkdata/deadlock"
@@ -113,7 +115,7 @@ func (rw testRequestWriter) UI(ui UI, params ...any) error {
113115}
114116
115117func (rw testRequestWriter ) Write (p []byte ) (n int , err error ) {
116- rw .rq .Rendering . Store ( true )
118+ rw .rq .MarkWritten ( )
117119 return rw .Writer .Write (p )
118120}
119121
@@ -623,3 +625,240 @@ func (ts *testSetter[T]) JawsGetHTML(elem *Element) (value T) {
623625 value = ts .val
624626 return
625627}
628+
629+ // closeRequestInBubble shuts a test request and its Jaws down from inside a
630+ // synctest bubble, then waits for every bubbled goroutine (the request process
631+ // loop, its event caller, and the Jaws Serve loop) to exit. synctest.Test
632+ // requires the bubble to be free of live goroutines before it returns, so the
633+ // usual t.Cleanup-based teardown (which runs outside the bubble) is too late.
634+ func closeRequestInBubble (rq * testRequest ) {
635+ rq .Close ()
636+ rq .Jaws .Close ()
637+ synctest .Wait ()
638+ }
639+
640+ // TestRequest is a request harness intended for the jaws package's own tests.
641+ // The importable harness for other packages lives in
642+ // github.com/linkdata/jaws/jawstest.
643+ type TestRequest struct {
644+ * Request
645+ * requestHarness
646+ }
647+
648+ type requestHarness struct {
649+ Req * Request
650+ * httptest.ResponseRecorder
651+ ReadyCh chan struct {}
652+ DoneCh chan struct {}
653+ InCh chan wire.WsMsg
654+ OutCh chan wire.WsMsg
655+ BcastCh chan wire.Message
656+ ExpectPanic bool
657+ Panicked bool
658+ PanicVal any
659+ }
660+
661+ func newRequestHarness (jw * Jaws , r * http.Request ) (rh * requestHarness ) {
662+ if r == nil {
663+ r = httptest .NewRequest (http .MethodGet , "/" , nil )
664+ }
665+ rr := httptest .NewRecorder ()
666+ rr .Body = & bytes.Buffer {}
667+ rq := jw .NewRequest (r )
668+ if rq == nil || jw .UseRequest (rq .JawsKey , r ) != rq {
669+ return nil
670+ }
671+ rh = & requestHarness {
672+ Req : rq ,
673+ ResponseRecorder : rr ,
674+ }
675+ // The subscribe/process/recycle dance lives in jw.TestServe. The onPanic
676+ // callback adds this harness's panic-expectation support: an expected panic is
677+ // captured for inspection while an unexpected one is re-raised exactly as
678+ // before. It reads ExpectPanic lazily so tests may set it after construction.
679+ rh .InCh , rh .OutCh , rh .BcastCh , rh .ReadyCh , rh .DoneCh = jw .TestServe (rq , func (recovered any ) {
680+ if recovered == nil {
681+ return
682+ }
683+ if rh .ExpectPanic {
684+ rh .PanicVal = recovered
685+ rh .Panicked = true
686+ return
687+ }
688+ panic (recovered )
689+ })
690+ return
691+ }
692+
693+ // Close stops the test request's processing loop.
694+ func (rh * requestHarness ) Close () {
695+ close (rh .InCh )
696+ }
697+
698+ // BodyString returns the recorded response body with surrounding whitespace removed.
699+ func (rh * requestHarness ) BodyString () string {
700+ return strings .TrimSpace (rh .Body .String ())
701+ }
702+
703+ // BodyHTML returns the recorded response body as trusted HTML.
704+ func (rh * requestHarness ) BodyHTML () template.HTML {
705+ return template .HTML (rh .BodyString ()) /* #nosec G203 */
706+ }
707+
708+ // NewTestRequest creates a TestRequest for use when testing.
709+ // Passing nil for r creates a GET / request with no body.
710+ func NewTestRequest (jw * Jaws , r * http.Request ) (tr * TestRequest ) {
711+ rh := newRequestHarness (jw , r )
712+ if rh != nil {
713+ tr = & TestRequest {
714+ Request : rh .Req ,
715+ requestHarness : rh ,
716+ }
717+ }
718+ return
719+ }
720+
721+ func TestErrEventUnhandled_Error (t * testing.T ) {
722+ if got := ErrEventUnhandled .Error (); got != "event unhandled" {
723+ t .Fatalf ("ErrEventUnhandled.Error() = %q, want %q" , got , "event unhandled" )
724+ }
725+ }
726+
727+ func TestNewRequestHarness_ReturnsNilOnClaimFailure (t * testing.T ) {
728+ jw , err := New ()
729+ if err != nil {
730+ t .Fatal (err )
731+ }
732+ t .Cleanup (jw .Close )
733+
734+ jw .reqPool .New = func () any {
735+ rq := (& Request {
736+ Jaws : jw ,
737+ tagMap : make (map [any ][]* Element ),
738+ }).clearLocked ()
739+ rq .claimed .Store (true )
740+ return rq
741+ }
742+
743+ hr := httptest .NewRequest (http .MethodGet , "/" , nil )
744+ if rh := newRequestHarness (jw , hr ); rh != nil {
745+ t .Fatal ("expected nil harness when claim fails" )
746+ }
747+ }
748+
749+ func TestNewTestRequest_PanicsWhenJawsClosed (t * testing.T ) {
750+ jw , err := New ()
751+ if err != nil {
752+ t .Fatal (err )
753+ }
754+ jw .Close ()
755+ defer func () {
756+ if recover () == nil {
757+ t .Fatal ("expected panic when the Jaws instance is closed" )
758+ }
759+ }()
760+ NewTestRequest (jw , nil )
761+ }
762+
763+ func TestTestServe_TimesOutWhenServeNotRunning (t * testing.T ) {
764+ // Without a running Serve/ServeWithTimeout loop nothing drains subCh, so the
765+ // subscription rendezvous in TestServe can neither complete nor see Done, and
766+ // it must panic after its 5s timeout. Run in a synctest bubble so that timeout
767+ // elapses in fake time rather than stalling the test for five real seconds.
768+ synctest .Test (t , func (t * testing.T ) {
769+ jw , err := New ()
770+ if err != nil {
771+ t .Fatal (err )
772+ }
773+ defer jw .Close ()
774+ rq := jw .NewRequest (httptest .NewRequest (http .MethodGet , "/" , nil ))
775+ defer func () {
776+ s , ok := recover ().(string )
777+ if ! ok || ! strings .Contains (s , "timed out subscribing" ) {
778+ t .Fatalf ("expected timeout panic, got %v" , s )
779+ }
780+ }()
781+ jw .TestServe (rq , func (any ) {})
782+ t .Fatal ("expected TestServe to panic" )
783+ })
784+ }
785+
786+ func TestTestServe_PanicsWhenClosedAfterSubscribing (t * testing.T ) {
787+ jw , err := New ()
788+ if err != nil {
789+ t .Fatal (err )
790+ }
791+ rq := jw .NewRequest (httptest .NewRequest (http .MethodGet , "/" , nil ))
792+ subCh := make (chan subscription )
793+ jw .subCh = subCh
794+ readyCh := make (chan struct {})
795+ go func () {
796+ close (readyCh )
797+ <- subCh
798+ jw .Close ()
799+ }()
800+ <- readyCh
801+
802+ defer func () {
803+ s , ok := recover ().(string )
804+ if ! ok || ! strings .Contains (s , "Jaws instance is closed" ) {
805+ t .Fatalf ("expected closed panic, got %v" , s )
806+ }
807+ }()
808+ jw .TestServe (rq , func (any ) {})
809+ t .Fatal ("expected TestServe to panic" )
810+ }
811+
812+ func TestTestServe_TimesOutAfterSubscribing (t * testing.T ) {
813+ synctest .Test (t , func (t * testing.T ) {
814+ jw , err := New ()
815+ if err != nil {
816+ t .Fatal (err )
817+ }
818+ defer jw .Close ()
819+ rq := jw .NewRequest (httptest .NewRequest (http .MethodGet , "/" , nil ))
820+ subCh := make (chan subscription )
821+ jw .subCh = subCh
822+ readyCh := make (chan struct {})
823+ go func () {
824+ close (readyCh )
825+ <- subCh
826+ }()
827+ <- readyCh
828+
829+ defer func () {
830+ s , ok := recover ().(string )
831+ if ! ok || ! strings .Contains (s , "timed out subscribing" ) {
832+ t .Fatalf ("expected timeout panic, got %v" , s )
833+ }
834+ }()
835+ jw .TestServe (rq , func (any ) {})
836+ t .Fatal ("expected TestServe to panic" )
837+ })
838+ }
839+
840+ func TestNewTestRequest_SuccessPathAndClose (t * testing.T ) {
841+ jw , err := New ()
842+ if err != nil {
843+ t .Fatal (err )
844+ }
845+ t .Cleanup (jw .Close )
846+
847+ go jw .Serve ()
848+
849+ tr := NewTestRequest (jw , nil )
850+ if tr == nil {
851+ t .Fatal ("expected test request" )
852+ }
853+
854+ if tr .Initial () == nil {
855+ t .Fatal ("expected initial request" )
856+ }
857+
858+ tr .Close ()
859+ select {
860+ case <- tr .DoneCh :
861+ case <- time .After (time .Second ):
862+ t .Fatal ("timeout waiting for test request shutdown" )
863+ }
864+ }
0 commit comments