* Read the collapsed-threads key through getJsonItem `getFromLocalStorage` parsed the stored string directly, so anything malformed under `__remarkCollapsed` threw out of `restoreCollapsedThreads`. That call sits in `remark.tsx` ahead of the `render`, so the throw took the whole widget with it: the reader was left on the preloader, over a view preference. `getJsonItem` in `common/local-storage.ts` already wraps a parse of a localStorage key and returns null on failure, and null is a shape the check below already reads as empty. The rest of that function is total against whatever the browser holds, and the bare parse was the one way in. * Stop retrying a failed e2e test in CI The suite went in with one gotestsum rerun. It has no failures on record to justify that: 31 CI runs since it landed, all green, and no rerun report has ever been produced. A retry is what turns an intermittent regression into a green build, and while the suite is this young its own failures are the evidence worth keeping. `E2E_RUN_ID` stays. It stamps the threads a run works on with the CI run id, so a thread url in a trace or a log names the run it came from. It carries no data across: the stack is disposable, and a local run under the same id gets those urls on an empty database. * Stop two chooseUnusedPort comments claiming collisions cannot happen All four copies listen on :0, read the assigned port, close the listener and bind later, so nothing holds the number across that gap and another binary can take it. The copies in app/cmd and app/rest/api call a collision very unlikely, which is accurate; the ones in app and the example module said binaries never land on the same number, which is not, and a comment ruling out a port collision is what would send the next person chasing one somewhere else. All four now read the same. Closing the window rather than describing it means the server binding :0 itself and reporting the address it got, which is a larger change.
174 lines
5.2 KiB
Go
174 lines
5.2 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/goleak"
|
|
)
|
|
|
|
func Test_Main(t *testing.T) {
|
|
dir, err := os.MkdirTemp(os.TempDir(), "remark42")
|
|
require.NoError(t, err)
|
|
defer os.RemoveAll(dir)
|
|
|
|
port := chooseUnusedPort(t)
|
|
os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=" + dir, "--backup=/tmp",
|
|
"--avatar.fs.path=" + dir, "--port=" + strconv.Itoa(port), "--url=https://demo.remark42.com", "--dbg", "--notify.type=none"}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
<-done
|
|
e := syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
|
|
require.NoError(t, e)
|
|
}()
|
|
|
|
finished := make(chan struct{})
|
|
go func() {
|
|
main()
|
|
close(finished)
|
|
}()
|
|
|
|
// defer cleanup because require check below can fail
|
|
defer func() {
|
|
close(done)
|
|
<-finished
|
|
}()
|
|
|
|
waitForHTTPServerStart(t, port)
|
|
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
body, err := io.ReadAll(resp.Body)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "pong", string(body))
|
|
}
|
|
|
|
func TestMain_WithWebhook(t *testing.T) {
|
|
dir, err := os.MkdirTemp(os.TempDir(), "remark42")
|
|
require.NoError(t, err)
|
|
defer os.RemoveAll(dir)
|
|
|
|
var webhookSent atomic.Int32
|
|
ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
|
webhookSent.Store(1)
|
|
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
|
|
b, e := io.ReadAll(r.Body)
|
|
defer r.Body.Close()
|
|
|
|
assert.Nil(t, e)
|
|
assert.Equal(t, "Comment: env test", string(b))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
port := chooseUnusedPort(t)
|
|
os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=" + dir, "--backup=/tmp",
|
|
"--avatar.fs.path=" + dir, "--port=" + strconv.Itoa(port), "--url=https://demo.remark42.com", "--dbg",
|
|
"--admin-passwd=password", "--site=remark", "--notify.admins=webhook"}
|
|
|
|
err = os.Setenv("NOTIFY_WEBHOOK_URL", ts.URL)
|
|
assert.NoError(t, err)
|
|
err = os.Setenv("NOTIFY_WEBHOOK_TEMPLATE", "Comment: {{.Orig}}")
|
|
assert.NoError(t, err)
|
|
err = os.Setenv("NOTIFY_WEBHOOK_HEADERS", "Content-Type:application/json")
|
|
assert.NoError(t, err)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
<-done
|
|
e := syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
|
|
require.NoError(t, e)
|
|
}()
|
|
|
|
finished := make(chan struct{})
|
|
go func() {
|
|
main()
|
|
close(finished)
|
|
}()
|
|
|
|
// defer cleanup because require check below can fail
|
|
defer func() {
|
|
close(done)
|
|
<-finished
|
|
}()
|
|
|
|
waitForHTTPServerStart(t, port)
|
|
|
|
resp, err := http.Post(fmt.Sprintf("http://admin:password@localhost:%d/api/v1/comment", port), "",
|
|
strings.NewReader(`{"text": "env test", "locator":{"url": "https://radio-t.com", "site": "remark"}}`))
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
|
|
// wait for webhook to be sent before shutting down
|
|
assert.Eventually(t, func() bool {
|
|
return webhookSent.Load() == int32(1)
|
|
}, 30*time.Second, 10*time.Millisecond, "webhook was not sent")
|
|
}
|
|
|
|
func TestGetDump(t *testing.T) {
|
|
dump := getDump()
|
|
assert.Contains(t, dump, "goroutine")
|
|
assert.Contains(t, dump, "[running]")
|
|
assert.Contains(t, dump, "backend/app/main.go")
|
|
t.Logf("\n dump: %s", dump)
|
|
}
|
|
|
|
// chooseUnusedPort asks the kernel for a free port from the ephemeral range, which makes a
|
|
// collision between concurrently running package test binaries very unlikely
|
|
func chooseUnusedPort(t *testing.T) int {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", ":0")
|
|
require.NoError(t, err, "no free port available")
|
|
port := ln.Addr().(*net.TCPAddr).Port
|
|
require.NoError(t, ln.Close())
|
|
return port
|
|
}
|
|
|
|
// waitForHTTPServerStart blocks until the server on port answers, failing the test naming the
|
|
// port if it never does
|
|
func waitForHTTPServerStart(t *testing.T, port int) {
|
|
t.Helper()
|
|
client := http.Client{Timeout: time.Second}
|
|
defer client.CloseIdleConnections()
|
|
require.Eventually(t, func() bool {
|
|
resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_ = resp.Body.Close()
|
|
return true
|
|
}, 30*time.Second, 10*time.Millisecond, "http server on port %d didn't start", port)
|
|
}
|
|
|
|
func TestMain(m *testing.M) {
|
|
// both ignores are for leaks which are detected locally
|
|
goleak.VerifyTestMain(
|
|
m,
|
|
// the shutdown goroutine in serverApp.run is not joined by Wait, and Rest.Shutdown gives
|
|
// httpServer.Shutdown a second, which can outlast goleak's retry budget on a loaded runner
|
|
goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"),
|
|
goleak.IgnoreTopFunction("github.com/umputun/remark42/backend/app.init.0.func1"),
|
|
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
|
|
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
|
|
// regexp2, pulled in by chroma for syntax highlighting, keeps one shared clock goroutine
|
|
// alive for up to a second after the last match with a timeout, sleeping in 100ms ticks.
|
|
// it ends on its own, but a binary that finishes inside that window is reported as leaking
|
|
goleak.IgnoreAnyFunction("github.com/dlclark/regexp2/v2.runClock"),
|
|
)
|
|
}
|