- github.com/chromedp/chromedp v0.21.0 (decision note 2026-10-28-chromedp-rendered-crawl.md): with golem15.seo.crawl_chrome_path the crawl renders up to golem15.seo.crawl_render_max pages (200) and compares them with the raw HTML; without a path it records skipped - guard: site host and www twin resolved and checked with fetchguard.IsPrivateAddr, pinned through --host-resolver-rules with a final MAP * ~NOTFOUND; Fetch-domain interception fails every request outside the site (third-party scripts never load); fresh temporary profile removed on Close; downloads denied; one tab, 20 s, 5 MiB DOM - a relative or non-executable path, a private address or a Chrome that does not start records render_status failed; the crawl still finishes - config keys documented; README Rendered comparison section - TestChromeRenderer (runs with a local Chromium) and TestRendererGuards
208 lines
7.4 KiB
Go
208 lines
7.4 KiB
Go
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 := `<!doctype html><html lang="en"><head><title>JS page</title><link rel="canonical" href="/page">
|
|
<script src="https://cdn.example.test/x.js"></script></head>
|
|
<body><main><h1>JS page</h1><p>Raw text.</p></main>
|
|
<script>
|
|
try { var x = new XMLHttpRequest(); x.open("GET", "http://169.254.169.254/latest/meta-data", false); x.send(); } catch (e) {}
|
|
var main = document.querySelector("main");
|
|
var a = document.createElement("a"); a.href = "/js-only"; a.textContent = "Only after JavaScript"; main.appendChild(a);
|
|
for (var i = 0; i < 2; i++) { var p = document.createElement("p"); p.textContent = "Generated paragraph text ".repeat(20); main.appendChild(p); }
|
|
</script></body></html>`
|
|
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, `<!doctype html><html lang="en"><head><title>Home</title><link rel="canonical" href="/"></head><body><main><h1>Home</h1><a href="/page">The page</a></main></body></html>`)
|
|
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", "/")
|
|
}
|