package seo_test
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"git.golem15.com/golem15/sm-seo-plugin/classes/crawl"
"git.golem15.com/golem15/summercms/modules/compass"
)
// chromeBinary is the Chrome or Chromium of the test machine: SEO_TEST_CHROME,
// else chromium, chromium-browser or google-chrome on PATH.
func chromeBinary() string {
if p := strings.TrimSpace(os.Getenv("SEO_TEST_CHROME")); p != "" {
return p
}
for _, name := range []string{"chromium", "chromium-browser", "google-chrome"} {
if p, err := exec.LookPath(name); err == nil {
if abs, err := filepath.EvalSymlinks(p); err == nil {
return abs
}
return p
}
}
return ""
}
// renderConfig is a config with the rendered comparison's keys.
func renderConfig(t *testing.T, env ...string) *compass.Config {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: seo-render-test\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: env})
if err != nil {
t.Fatal(err)
}
return cfg
}
// jsSite is a site whose page /page adds a same-host link and two
// paragraphs after JavaScript, loads a third-party script and asks a
// link-local address; hits counts the requests that reached it.
func jsSite(t *testing.T) (*httptest.Server, *[]string) {
t.Helper()
var hits []string
page := `
JS page
JS page Raw text.
`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits = append(hits, r.URL.Path)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
switch r.URL.Path {
case "/":
fmt.Fprint(w, `Home Home The page `)
case "/page":
fmt.Fprint(w, page)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv, &hits
}
// TestChromeRenderer renders a page in headless Chrome (D-70, P33-55):
// the JavaScript-added link and text are in the DOM and the crawl reports
// them as content_js check rendered naming /js-only; the third-party script
// and the link-local request are blocked and never reach a server; the
// temporary profile is gone after Close.
func TestChromeRenderer(t *testing.T) {
if testing.Short() {
t.Skip("needs Chrome and Docker")
}
chrome := chromeBinary()
if chrome == "" {
t.Skip("no Chrome or Chromium on this machine (set SEO_TEST_CHROME)")
}
srv, hits := jsSite(t)
u, _ := url.Parse(srv.URL)
site := "http://localhost:" + u.Port()
siteURL, _ := url.Parse(site)
cfg := renderConfig(t, "SUMMER_GOLEM15__SEO__CRAWL_CHROME_PATH="+chrome, "SUMMER_GOLEM15__SEO__CRAWL_TRUSTED=true")
r, err := crawl.NewRenderer(t.Context(), cfg, siteURL, true, crawl.UserAgent(site))
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
inspect, ok := r.(interface {
Blocked() []string
ProfileDir() string
})
if !ok {
t.Fatalf("renderer %T has no Blocked and ProfileDir", r)
}
profile := inspect.ProfileDir()
if _, err := os.Stat(profile); err != nil {
t.Fatalf("profile %s: %v", profile, err)
}
dom, err := r.Render(t.Context(), site+"/page")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(dom, `href="/js-only"`) || !strings.Contains(dom, "Generated paragraph text") {
t.Fatalf("rendered DOM lacks the JavaScript content:\n%s", dom)
}
blocked := strings.Join(inspect.Blocked(), "\n")
for _, want := range []string{"https://cdn.example.test/x.js", "http://169.254.169.254/latest/meta-data"} {
if !strings.Contains(blocked, want) {
t.Fatalf("blocked = %q, want %s", blocked, want)
}
}
if slices.ContainsFunc(*hits, func(p string) bool { return strings.Contains(p, "latest") || strings.Contains(p, "x.js") }) {
t.Fatalf("a blocked request reached the site: %v", *hits)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(profile); !os.IsNotExist(err) {
t.Fatalf("profile %s still exists after Close: %v", profile, err)
}
// The crawl with the Chrome path compares the page with its rendering.
env := bootCrawlEnv(t,
"SUMMER_GOLEM15__SEO__SITE_URL="+site,
"SUMMER_GOLEM15__SEO__CRAWL_TRUSTED=true",
"SUMMER_GOLEM15__SEO__CRAWL_CHROME_PATH="+chrome,
)
token := env.admin(t, true)
id := env.crawlOnce(t, token, t.Context())
got := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("/_seo/api/v1/crawls/%d", id), token, nil).Body.String()
if !jsonContains(got, `"render":{"status":"done","pages":2`) {
t.Fatalf("crawl render = %s", got)
}
issue := findIssue(t, env.issues(t, token, ""), "content_js", "/page")
if issue.Details["check"] != "rendered" || !strings.Contains(fmt.Sprint(issue.Details["what"]), "/js-only") {
t.Fatalf("content_js = %+v", issue)
}
}
// TestRendererGuards checks the renderer's refusals (T-33-66, T-33-69):
// an empty Chrome path skips the comparison, a relative or non-executable
// path fails it, and outside trusted mode a site host that resolves to a
// private address refuses before Chrome starts; a crawl with an invalid
// path records render_status failed with the note and still runs its raw
// checks.
func TestRendererGuards(t *testing.T) {
site, _ := url.Parse("https://localhost")
exe, err := exec.LookPath("sh")
if err != nil {
t.Skip("no sh")
}
exe, _ = filepath.EvalSymlinks(exe)
notExec := filepath.Join(t.TempDir(), "chrome")
if err := os.WriteFile(notExec, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
path, want string
}{
{"", ""},
{"bin/chrome", "is not absolute"},
{notExec, "is not an executable file"},
{exe, "private address"},
} {
cfg := renderConfig(t, "SUMMER_GOLEM15__SEO__CRAWL_CHROME_PATH="+tc.path)
r, err := crawl.NewRenderer(t.Context(), cfg, site, false, "test")
if tc.want == "" {
if r != nil || err != nil {
t.Fatalf("empty path: %v %v", r, err)
}
continue
}
if r != nil || err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("path %q: renderer %v, error %v, want %q", tc.path, r, err, tc.want)
}
}
if testing.Short() {
return
}
env := bootCrawlEnv(t, "SUMMER_GOLEM15__SEO__CRAWL_CHROME_PATH=bin/chrome")
token := env.admin(t, true)
fake := newFakeSite()
fake.sitemap("/")
fake.plant(doc{path: "/", title: "Home", h1: []string{"Home", "Again"}})
id := env.crawlOnce(t, token, siteCtx(t, fake))
got := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("/_seo/api/v1/crawls/%d", id), token, nil).Body.String()
if !jsonContains(got, `"status":"finished"`) || !jsonContains(got, `"render":{"status":"failed"`) || !strings.Contains(got, "is not absolute") {
t.Fatalf("crawl with a relative Chrome path = %s", got)
}
findIssue(t, env.issues(t, token, ""), "h1_multiple", "/")
}