Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cddd16bcdc | |||
| c1616b3162 | |||
| e1ba7fdabb | |||
| 2ac1f0c507 | |||
| e93e79c1bb |
@@ -50,6 +50,15 @@ Backend (`cd backend`):
|
|||||||
|
|
||||||
Local stack: `docker compose up` (bookmark-api + postgres only; `postgres-data` named volume, `restart: unless-stopped`). No browser — without `BROWSER_WS_URL` the poller logs and skips kagane and comix. To run one: `cd chrome && BROWSER_BIND_ADDR=172.17.0.1 docker compose up -d --build`, then `BROWSER_WS_URL=ws://172.17.0.1:9222` in the root `.env` (bridge gateway, so the API container can name it by IP).
|
Local stack: `docker compose up` (bookmark-api + postgres only; `postgres-data` named volume, `restart: unless-stopped`). No browser — without `BROWSER_WS_URL` the poller logs and skips kagane and comix. To run one: `cd chrome && BROWSER_BIND_ADDR=172.17.0.1 docker compose up -d --build`, then `BROWSER_WS_URL=ws://172.17.0.1:9222` in the root `.env` (bridge gateway, so the API container can name it by IP).
|
||||||
|
|
||||||
|
**Clean up Docker after testing.** Storage on the dev machine is scarce, so
|
||||||
|
anything you started for a test you also tear down before calling the work
|
||||||
|
done: `docker compose down -v` for a stack you brought up, `docker rm -f -v`
|
||||||
|
for a container you ran by hand (`-v`, or the image's anonymous data volume
|
||||||
|
survives). Then check `docker volume ls` and `docker system df` for leftovers
|
||||||
|
and reclaim them — a dangling volume nobody notices is the leak that fills the
|
||||||
|
disk. Never remove the `postgres-data` volume of a stack the user is actually
|
||||||
|
running, and never blanket-`docker system prune` their images or build cache.
|
||||||
|
|
||||||
Live CDP proof (needs that browser and network, skipped otherwise):
|
Live CDP proof (needs that browser and network, skipped otherwise):
|
||||||
`SMOKE_BROWSER_WS_URL=ws://<ip>:<port> go test -run 'TestSmokeKagane|TestSmokeComix' ./internal/latest`
|
`SMOKE_BROWSER_WS_URL=ws://<ip>:<port> go test -run 'TestSmokeKagane|TestSmokeComix' ./internal/latest`
|
||||||
— fetches a real kagane and comix cover and chapter list. A red run means the challenge is
|
— fetches a real kagane and comix cover and chapter list. A red run means the challenge is
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ func Main(m *testing.M) int {
|
|||||||
fmt.Println("pgtest:", err)
|
fmt.Println("pgtest:", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
defer exec.Command("docker", "rm", "-f", id).Run()
|
// -v: the postgres image declares a VOLUME, so an explicit rm without it
|
||||||
|
// leaves the anonymous data volume behind on every test run.
|
||||||
|
defer exec.Command("docker", "rm", "-f", "-v", id).Run()
|
||||||
|
|
||||||
adminURL = url
|
adminURL = url
|
||||||
return m.Run()
|
return m.Run()
|
||||||
@@ -85,7 +87,7 @@ func start() (id, url string, err error) {
|
|||||||
|
|
||||||
port, err := exec.Command("docker", "port", id, "5432/tcp").Output()
|
port, err := exec.Command("docker", "port", id, "5432/tcp").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exec.Command("docker", "rm", "-f", id).Run()
|
exec.Command("docker", "rm", "-f", "-v", id).Run()
|
||||||
return "", "", fmt.Errorf("docker port: %w", err)
|
return "", "", fmt.Errorf("docker port: %w", err)
|
||||||
}
|
}
|
||||||
// "0.0.0.0:32768" (and possibly a second, IPv6 line); the port is all we want.
|
// "0.0.0.0:32768" (and possibly a second, IPv6 line); the port is all we want.
|
||||||
@@ -94,7 +96,7 @@ func start() (id, url string, err error) {
|
|||||||
first[strings.LastIndex(first, ":")+1:])
|
first[strings.LastIndex(first, ":")+1:])
|
||||||
|
|
||||||
if err := waitReady(url); err != nil {
|
if err := waitReady(url); err != nil {
|
||||||
exec.Command("docker", "rm", "-f", id).Run()
|
exec.Command("docker", "rm", "-f", "-v", id).Run()
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
return id, url, nil
|
return id, url, nil
|
||||||
|
|||||||
@@ -505,10 +505,13 @@ func (h *Handler) adminSeriesRemove(w http.ResponseWriter, r *http.Request) {
|
|||||||
band = 1
|
band = 1
|
||||||
}
|
}
|
||||||
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
|
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
|
||||||
|
// The heading is an out-of-band append to a response whose status line has
|
||||||
|
// already gone out with the row, so it is executed straight onto w —
|
||||||
|
// h.render would send a second WriteHeader.
|
||||||
if head, err := h.seriesListHeadView(r); err != nil {
|
if head, err := h.seriesListHeadView(r); err != nil {
|
||||||
log.Printf("series remove %s: %v", site+":"+seriesID, err)
|
log.Printf("series remove %s: %v", site+":"+seriesID, err)
|
||||||
} else {
|
} else if err := h.tmpl.ExecuteTemplate(w, "series-list-head", head); err != nil {
|
||||||
h.render(w, http.StatusOK, "series-list-head", head)
|
log.Printf("series remove %s: render series-list-head oob: %v", site+":"+seriesID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -4285,6 +4287,52 @@ func TestRemoveFromListAnswersRowAndFreshHeading(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The removal answers two fragments on one response. The second is an
|
||||||
|
// out-of-band append, so it must not send a second status line: net/http
|
||||||
|
// answers a double WriteHeader with "superfluous response.WriteHeader call"
|
||||||
|
// on the server's error log, which a ResponseRecorder never sees. Hence a
|
||||||
|
// real server here.
|
||||||
|
func TestRemoveFromListSendsOneStatusLine(t *testing.T) {
|
||||||
|
st, dsn := newTestStoreURL(t)
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", dsn, err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "u", checkedAt: 9000, bookmarks: 0})
|
||||||
|
|
||||||
|
var errLog bytes.Buffer
|
||||||
|
srv := httptest.NewUnstartedServer(newRouter(st, testConfig()))
|
||||||
|
srv.Config.ErrorLog = log.New(&errLog, "", 0)
|
||||||
|
srv.Start()
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, srv.URL+"/admin/series/asura:a/remove",
|
||||||
|
strings.NewReader("filter=no_readers&band=0"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build request: %v", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(sessionCookie(t, st))
|
||||||
|
resp, err := srv.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("removal request: %v", err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("removal status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// Both fragments still travel: the fix must not have dropped the heading.
|
||||||
|
if !strings.Contains(string(body), "Title of asura:a") ||
|
||||||
|
!strings.Contains(string(body), `hx-swap-oob="true"`) {
|
||||||
|
t.Errorf("answer lost a fragment:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(errLog.String(), "superfluous") {
|
||||||
|
t.Errorf("removal wrote the status line twice: %s", errLog.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A removal from the detail page navigates to the No-Readers list: htmx gets
|
// A removal from the detail page navigates to the No-Readers list: htmx gets
|
||||||
// a full navigation (HX-Redirect — a 303 would be followed by the request
|
// a full navigation (HX-Redirect — a 303 would be followed by the request
|
||||||
// and the list page swapped into the press's target), plain clients the 303
|
// and the list page swapped into the press's target), plain clients the 303
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ services:
|
|||||||
# Host header is an IP address or "localhost" — confirmed 2026-08-03,
|
# Host header is an IP address or "localhost" — confirmed 2026-08-03,
|
||||||
# independent of chromedp's own dial logic. The same trap that used to
|
# independent of chromedp's own dial logic. The same trap that used to
|
||||||
# force a pinned Docker IP now forbids the tailnet name.
|
# force a pinned Docker IP now forbids the tailnet name.
|
||||||
|
BROWSER_WS_URL: ${BROWSER_WS_URL:-}
|
||||||
|
#
|
||||||
# Owner-notice webhook (issue #171). Empty default, never a
|
# Owner-notice webhook (issue #171). Empty default, never a
|
||||||
# required-guard: unset means the whole path is off, so a local stack
|
# required-guard: unset means the whole path is off, so a local stack
|
||||||
# runs exactly as it does today. An env var not listed here never
|
# runs exactly as it does today. An env var not listed here never
|
||||||
|
|||||||
Reference in New Issue
Block a user