1
0

main_test.go 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798
  1. package main
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "fmt"
  7. "image"
  8. "image/jpeg"
  9. _ "image/png"
  10. "net/http"
  11. "net/http/httptest"
  12. "net/url"
  13. "os"
  14. "path/filepath"
  15. "strings"
  16. "sync/atomic"
  17. "testing"
  18. "git.monoexp.com/bajdev/rekopub/internal/epubtest"
  19. "git.monoexp.com/bajdev/rekopub/internal/pipeline"
  20. )
  21. // fakeConverter returns a fixed book and counts how often it was called, so
  22. // tests can check that nothing is fetched when arguments are rejected.
  23. type fakeConverter struct {
  24. title string
  25. err error
  26. calls int
  27. kepub bool // the KEPUB option of the last call
  28. }
  29. func (f *fakeConverter) convert(ctx context.Context, u *url.URL, opts pipeline.Options) (*pipeline.Book, error) {
  30. f.calls++
  31. f.kepub = opts.KEPUB
  32. if f.err != nil {
  33. return nil, f.err
  34. }
  35. if opts.BeforeImages != nil {
  36. if err := opts.BeforeImages(f.title); err != nil {
  37. return nil, err
  38. }
  39. }
  40. return &pipeline.Book{Title: f.title, Content: strings.NewReader("epub:" + f.title)}, nil
  41. }
  42. type result struct {
  43. code int
  44. stdout, stderr string
  45. }
  46. func runCLI(t *testing.T, conv *fakeConverter, args ...string) result {
  47. t.Helper()
  48. var stdout, stderr bytes.Buffer
  49. code := run(context.Background(), args, &stdout, &stderr, conv.convert)
  50. return result{code, stdout.String(), stderr.String()}
  51. }
  52. const testURL = "https://example.com/article"
  53. func TestUsageErrors(t *testing.T) {
  54. dir := t.TempDir()
  55. out := filepath.Join(dir, "a.epub")
  56. tests := []struct {
  57. name string
  58. args []string
  59. want string // expected in stderr
  60. }{
  61. {"no arguments", nil, "-URL is required"},
  62. {"missing -SAVE", []string{"-URL", testURL}, "-SAVE is required"},
  63. {"missing -URL", []string{"-SAVE", out}, "-URL is required"},
  64. {"lowercase flag", []string{"-url", testURL, "-SAVE", out}, "flag provided but not defined: -url"},
  65. {"extra argument", []string{"-URL", testURL, "-SAVE", out, "extra"}, `unexpected argument "extra"`},
  66. {"ftp URL", []string{"-URL", "ftp://example.com/a", "-SAVE", out}, "must start with http:// or https://"},
  67. {"no scheme", []string{"-URL", "example.com/a", "-SAVE", out}, "must start with http:// or https://"},
  68. {"file URL", []string{"-URL", "file:///etc/passwd", "-SAVE", out}, "must start with http:// or https://"},
  69. {"local path", []string{"-URL", "/etc/passwd", "-SAVE", out}, "must start with http:// or https://"},
  70. {"Windows path", []string{"-URL", `C:\Users\me\notes.html`, "-SAVE", out}, "-URL"},
  71. {"UNC path", []string{"-URL", `\\server\share\page.html`, "-SAVE", out}, "must start with http:// or https://"},
  72. {"no host", []string{"-URL", "https://", "-SAVE", out}, "missing host name"},
  73. {"invalid URL", []string{"-URL", "https://exa mple.com/%zz", "-SAVE", out}, "not a valid URL"},
  74. {"kepub name without -KEPUB", []string{"-URL", testURL, "-SAVE", filepath.Join(dir, "a.kepub.epub")}, "needs -KEPUB"},
  75. }
  76. for _, tt := range tests {
  77. t.Run(tt.name, func(t *testing.T) {
  78. conv := &fakeConverter{title: "T"}
  79. r := runCLI(t, conv, tt.args...)
  80. if r.code != exitUsage {
  81. t.Errorf("exit code = %d, want %d", r.code, exitUsage)
  82. }
  83. if !strings.Contains(r.stderr, tt.want) {
  84. t.Errorf("stderr = %q, want it to contain %q", r.stderr, tt.want)
  85. }
  86. if !strings.Contains(r.stderr, "Usage: rekopub") {
  87. t.Errorf("stderr does not show usage: %q", r.stderr)
  88. }
  89. if conv.calls != 0 {
  90. t.Errorf("converter called %d times, want 0", conv.calls)
  91. }
  92. if r.stdout != "" {
  93. t.Errorf("stdout = %q, want empty", r.stdout)
  94. }
  95. })
  96. }
  97. }
  98. func TestVersion(t *testing.T) {
  99. conv := &fakeConverter{}
  100. r := runCLI(t, conv, "-VERSION")
  101. if r.code != exitOK || !strings.HasPrefix(r.stdout, "rekopub ") || len(strings.TrimSpace(r.stdout)) <= len("rekopub") {
  102. t.Errorf("exit code = %d, stdout = %q", r.code, r.stdout)
  103. }
  104. if conv.calls != 0 {
  105. t.Errorf("-VERSION ran a conversion")
  106. }
  107. defer func(v string) { version = v }(version)
  108. version = "v1.2.3"
  109. if r := runCLI(t, conv, "-VERSION"); r.stdout != "rekopub v1.2.3\n" {
  110. t.Errorf("stdout = %q, want the version set at build time", r.stdout)
  111. }
  112. }
  113. func TestInterrupted(t *testing.T) {
  114. dir := t.TempDir()
  115. conv := &fakeConverter{err: fmt.Errorf("%w: %w", pipeline.ErrFetch, context.Canceled)}
  116. r := runCLI(t, conv, "-URL", testURL, "-SAVE", dir)
  117. if r.code != exitInterrupted || strings.TrimSpace(r.stderr) != "error: interrupted" {
  118. t.Errorf("exit code = %d, stderr = %q; want %d and \"error: interrupted\"", r.code, r.stderr, exitInterrupted)
  119. }
  120. assertEmptyDir(t, dir)
  121. }
  122. func TestHelp(t *testing.T) {
  123. r := runCLI(t, &fakeConverter{}, "-h")
  124. if r.code != exitOK {
  125. t.Errorf("exit code = %d, want %d", r.code, exitOK)
  126. }
  127. if !strings.Contains(r.stderr, "Usage: rekopub") {
  128. t.Errorf("stderr = %q, want usage", r.stderr)
  129. }
  130. }
  131. func TestSaveToFile(t *testing.T) {
  132. path := filepath.Join(t.TempDir(), "a.epub")
  133. r := runCLI(t, &fakeConverter{title: "Title"}, "-URL", testURL, "-SAVE", path)
  134. if r.code != exitOK {
  135. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  136. }
  137. if strings.TrimSpace(r.stdout) != path {
  138. t.Errorf("stdout = %q, want %q", r.stdout, path)
  139. }
  140. assertFile(t, path, "epub:Title")
  141. }
  142. func TestSaveToFolderCreatesIt(t *testing.T) {
  143. dir := filepath.Join(t.TempDir(), "Books", "Articles")
  144. r := runCLI(t, &fakeConverter{title: "My Great Article"}, "-URL", testURL, "-SAVE", dir+"/")
  145. if r.code != exitOK {
  146. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  147. }
  148. want := filepath.Join(dir, "my-great-article.epub")
  149. if strings.TrimSpace(r.stdout) != want {
  150. t.Errorf("stdout = %q, want %q", r.stdout, want)
  151. }
  152. assertFile(t, want, "epub:My Great Article")
  153. }
  154. func TestExistingFileStopsBeforeFetching(t *testing.T) {
  155. path := filepath.Join(t.TempDir(), "a.epub")
  156. writeFile(t, path, "old")
  157. conv := &fakeConverter{title: "Title"}
  158. r := runCLI(t, conv, "-URL", testURL, "-SAVE", path)
  159. if r.code != exitExists {
  160. t.Errorf("exit code = %d, want %d", r.code, exitExists)
  161. }
  162. if !strings.Contains(r.stderr, "already exists (use -FORCE to overwrite)") {
  163. t.Errorf("stderr = %q", r.stderr)
  164. }
  165. if conv.calls != 0 {
  166. t.Errorf("converter called %d times, want 0", conv.calls)
  167. }
  168. assertFile(t, path, "old")
  169. }
  170. func TestExistingFileInFolder(t *testing.T) {
  171. dir := t.TempDir()
  172. path := filepath.Join(dir, "title.epub")
  173. writeFile(t, path, "old")
  174. r := runCLI(t, &fakeConverter{title: "Title"}, "-URL", testURL, "-SAVE", dir)
  175. if r.code != exitExists {
  176. t.Errorf("exit code = %d, want %d; stderr = %q", r.code, exitExists, r.stderr)
  177. }
  178. assertFile(t, path, "old")
  179. }
  180. func TestForceOverwrites(t *testing.T) {
  181. dir := t.TempDir()
  182. path := filepath.Join(dir, "a.epub")
  183. writeFile(t, path, "old")
  184. r := runCLI(t, &fakeConverter{title: "Title"}, "-URL", testURL, "-SAVE", path, "-FORCE")
  185. if r.code != exitOK {
  186. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  187. }
  188. assertFile(t, path, "epub:Title")
  189. // -FORCE also applies to folder targets.
  190. writeFile(t, filepath.Join(dir, "title.epub"), "old")
  191. r = runCLI(t, &fakeConverter{title: "Title"}, "-URL", testURL, "-SAVE", dir, "-FORCE")
  192. if r.code != exitOK {
  193. t.Fatalf("folder: exit code = %d, stderr = %q", r.code, r.stderr)
  194. }
  195. assertFile(t, filepath.Join(dir, "title.epub"), "epub:Title")
  196. }
  197. func TestConversionFailureWritesNothing(t *testing.T) {
  198. dir := t.TempDir()
  199. conv := &fakeConverter{err: errors.New("boom")}
  200. r := runCLI(t, conv, "-URL", testURL, "-SAVE", filepath.Join(dir, "a.epub"))
  201. if r.code != exitBuild {
  202. t.Errorf("exit code = %d, want %d", r.code, exitBuild)
  203. }
  204. if !strings.Contains(r.stderr, "error: boom") {
  205. t.Errorf("stderr = %q", r.stderr)
  206. }
  207. assertEmptyDir(t, dir)
  208. }
  209. func TestSavePathThroughFile(t *testing.T) {
  210. dir := t.TempDir()
  211. writeFile(t, filepath.Join(dir, "Books"), "not a folder")
  212. r := runCLI(t, &fakeConverter{title: "Title"}, "-URL", testURL, "-SAVE", filepath.Join(dir, "Books", "a.epub"))
  213. if r.code != exitBuild {
  214. t.Errorf("exit code = %d, want %d; stderr = %q", r.code, exitBuild, r.stderr)
  215. }
  216. }
  217. func TestKEPUBExtensionNote(t *testing.T) {
  218. dir := t.TempDir()
  219. writeFile(t, filepath.Join(dir, "a.kepub.epub"), "old")
  220. // The overwrite check uses the adjusted name, before anything is fetched.
  221. conv := &fakeConverter{title: "Title"}
  222. r := runCLI(t, conv, "-URL", testURL, "-SAVE", filepath.Join(dir, "a.epub"), "-KEPUB")
  223. if !strings.Contains(r.stderr, "note: saving as a.kepub.epub") {
  224. t.Errorf("stderr = %q, want a note about the new name", r.stderr)
  225. }
  226. if r.code != exitExists || conv.calls != 0 {
  227. t.Errorf("exit code = %d, converter calls = %d; want %d and 0", r.code, conv.calls, exitExists)
  228. }
  229. }
  230. func TestKEPUBOption(t *testing.T) {
  231. dir := t.TempDir()
  232. for _, kepub := range []bool{false, true} {
  233. conv := &fakeConverter{title: "Title"}
  234. args := []string{"-URL", testURL, "-SAVE", dir, "-FORCE"}
  235. want := filepath.Join(dir, "title.epub")
  236. if kepub {
  237. args = append(args, "-KEPUB")
  238. want = filepath.Join(dir, "title.kepub.epub")
  239. }
  240. r := runCLI(t, conv, args...)
  241. if r.code != exitOK {
  242. t.Fatalf("kepub=%v: exit code = %d, stderr = %q", kepub, r.code, r.stderr)
  243. }
  244. if conv.kepub != kepub {
  245. t.Errorf("kepub=%v: pipeline got KEPUB=%v", kepub, conv.kepub)
  246. }
  247. if strings.TrimSpace(r.stdout) != want {
  248. t.Errorf("kepub=%v: saved to %q, want %q", kepub, r.stdout, want)
  249. }
  250. }
  251. }
  252. // assertCover checks the book has a 1404x1872 grayscale cover, listed
  253. // first in the spine. With photo, the cover's upper half must show the
  254. // photo (mid-grays) rather than the plain cover's black band and white
  255. // space.
  256. func assertCover(t *testing.T, book *epubtest.Book, photo bool) {
  257. t.Helper()
  258. opf := book.File(t, ".opf")
  259. if !strings.Contains(opf, `href="images/cover.jpg" media-type="image/jpeg" properties="cover-image"`) {
  260. t.Fatalf("OPF has no cover image:\n%s", opf)
  261. }
  262. if strings.Index(opf, `<itemref idref="cover.xhtml">`) > strings.Index(opf, `<itemref idref="chapter.xhtml">`) {
  263. t.Errorf("cover isn't first in the spine:\n%s", opf)
  264. }
  265. img, err := jpeg.Decode(strings.NewReader(book.Files["EPUB/images/cover.jpg"]))
  266. if err != nil {
  267. t.Fatal(err)
  268. }
  269. gray, ok := img.(*image.Gray)
  270. if !ok || gray.Bounds().Dx() != 1404 || gray.Bounds().Dy() != 1872 {
  271. t.Fatalf("cover is %T %v, want 1404x1872 grayscale", img, img.Bounds())
  272. }
  273. mid := 0
  274. for y := 300; y < 900; y += 10 {
  275. for x := 0; x < 1404; x += 10 {
  276. if v := gray.GrayAt(x, y).Y; v > 40 && v < 215 {
  277. mid++
  278. }
  279. }
  280. }
  281. if hasPhoto := mid > 2000; hasPhoto != photo {
  282. t.Errorf("cover photo = %v (%d mid-gray samples), want %v", hasPhoto, mid, photo)
  283. }
  284. }
  285. func writeFile(t *testing.T, path, content string) {
  286. t.Helper()
  287. if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
  288. t.Fatal(err)
  289. }
  290. }
  291. func assertFile(t *testing.T, path, want string) {
  292. t.Helper()
  293. got, err := os.ReadFile(path)
  294. if err != nil {
  295. t.Fatal(err)
  296. }
  297. if string(got) != want {
  298. t.Errorf("%s contains %q, want %q", path, got, want)
  299. }
  300. }
  301. func assertEmptyDir(t *testing.T, dir string) {
  302. t.Helper()
  303. entries, err := os.ReadDir(dir)
  304. if err != nil {
  305. t.Fatal(err)
  306. }
  307. if len(entries) != 0 {
  308. t.Errorf("%s is not empty: %v", dir, entries)
  309. }
  310. }
  311. // TestEndToEnd runs the real pipeline against a local web server.
  312. func TestEndToEnd(t *testing.T) {
  313. blog, err := os.ReadFile("../../testdata/pages/blog.html")
  314. if err != nil {
  315. t.Fatal(err)
  316. }
  317. docs, err := os.ReadFile("../../testdata/pages/docs.html")
  318. if err != nil {
  319. t.Fatal(err)
  320. }
  321. essay, err := os.ReadFile("../../testdata/pages/photo-essay.html")
  322. if err != nil {
  323. t.Fatal(err)
  324. }
  325. lightbox, err := os.ReadFile("../../testdata/pages/lightbox.html")
  326. if err != nil {
  327. t.Fatal(err)
  328. }
  329. wiki, err := os.ReadFile("../../testdata/pages/wiki.html")
  330. if err != nil {
  331. t.Fatal(err)
  332. }
  333. images := map[string]string{ // served path → file in testdata/images
  334. "/img/press.jpg": "press.jpg",
  335. "/img/diagram.png": "diagram.png",
  336. "/img/type.webp": "gradient.webp",
  337. // lightbox.html; srcset should select the 1536w file.
  338. "/wp-content/uploads/2026/10/upgrade-dialog-1536x864.png": "diagram.png",
  339. "/wp-content/uploads/2026/10/settings-640x480.jpg": "press.jpg",
  340. "/wp-content/uploads/2026/10/hero.jpg": "press.jpg", // og:image, for the cover
  341. // wiki.html; served whatever the query string, as Wikipedia does.
  342. "/upload/a/a6/Walking_Robots_logo.png": "diagram.png",
  343. "/thumb/9/99/Painted_miniature.jpg/500px-Painted_miniature.jpg": "press.jpg",
  344. "/media/record-sheet": "gradient.webp",
  345. }
  346. var imageRequests atomic.Int32
  347. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  348. if strings.HasPrefix(r.URL.Path, "/img/") || strings.HasPrefix(r.URL.Path, "/wp-content/") ||
  349. strings.HasPrefix(r.URL.Path, "/upload/") || strings.HasPrefix(r.URL.Path, "/thumb/") || strings.HasPrefix(r.URL.Path, "/media/") {
  350. imageRequests.Add(1)
  351. name, ok := images[r.URL.Path]
  352. if !ok {
  353. http.NotFound(w, r)
  354. return
  355. }
  356. http.ServeFile(w, r, filepath.Join("../../testdata/images", name))
  357. return
  358. }
  359. switch r.URL.Path {
  360. case "/wiki/Walking_Robots":
  361. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  362. w.Write(wiki)
  363. case "/news/2026/10/kde-turns-30/":
  364. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  365. w.Write(lightbox)
  366. case "/essays/print-shop":
  367. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  368. w.Write(essay)
  369. case "/posts/eink":
  370. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  371. w.Write(blog)
  372. case "/docs/config":
  373. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  374. w.Write(docs)
  375. case "/app":
  376. w.Header().Set("Content-Type", "text/html")
  377. w.Write([]byte(`<html><body><div id="root"></div><script src="/app.js"></script></body></html>`))
  378. default:
  379. http.NotFound(w, r)
  380. }
  381. }))
  382. defer srv.Close()
  383. realRun := func(args ...string) result {
  384. var stdout, stderr bytes.Buffer
  385. code := run(context.Background(), args, &stdout, &stderr, pipeline.Run)
  386. return result{code, stdout.String(), stderr.String()}
  387. }
  388. t.Run("article", func(t *testing.T) {
  389. dir := t.TempDir()
  390. r := realRun("-URL", srv.URL+"/posts/eink", "-SAVE", dir)
  391. if r.code != exitOK {
  392. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  393. }
  394. path := filepath.Join(dir, "why-e-ink-is-easier-on-the-eyes.epub")
  395. if strings.TrimSpace(r.stdout) != path {
  396. t.Errorf("stdout = %q, want %q", r.stdout, path)
  397. }
  398. data, err := os.ReadFile(path)
  399. if err != nil {
  400. t.Fatal(err)
  401. }
  402. book := epubtest.Open(t, data)
  403. book.AssertWellFormed(t)
  404. chapter := book.File(t, "chapter.xhtml")
  405. for _, want := range []string{
  406. "<h1>Why E-Ink Is Easier on the Eyes</h1>",
  407. "Ada Lovelace · The Reading Desk · 14 March 2025",
  408. "Electronic paper does not shine light into your eyes.",
  409. `▶ Video: <a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">How E Ink works</a>`,
  410. } {
  411. if !strings.Contains(chapter, want) {
  412. t.Errorf("chapter is missing %q", want)
  413. }
  414. }
  415. if strings.Contains(chapter, "Great article, thanks!") {
  416. t.Errorf("chapter contains page comments")
  417. }
  418. assertCover(t, book, false)
  419. epubtest.EPUBCheck(t, path)
  420. })
  421. t.Run("messy documentation page", func(t *testing.T) {
  422. dir := t.TempDir()
  423. r := realRun("-URL", srv.URL+"/docs/config", "-SAVE", dir)
  424. if r.code != exitOK {
  425. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  426. }
  427. path := filepath.Join(dir, "configuring-the-sync-daemon.epub")
  428. data, err := os.ReadFile(path)
  429. if err != nil {
  430. t.Fatal(err)
  431. }
  432. book := epubtest.Open(t, data)
  433. book.AssertWellFormed(t)
  434. chapter := book.File(t, "chapter.xhtml")
  435. for _, want := range []string{
  436. `<div lang="en" xml:lang="en">`,
  437. `<h2 id="sec-1">File location</h2>`,
  438. `<h3 id="sec-3">Durations</h3>`,
  439. "<pre><code>interval = ",
  440. "<td><code>wifi_only</code></td>",
  441. `<a href="mailto:docs@example.com">`,
  442. `<a href="` + srv.URL + `/faq#restart">`,
  443. "recipes — including", // &mdash; as a character, not an entity
  444. } {
  445. if !strings.Contains(chapter, want) {
  446. t.Errorf("chapter is missing %q", want)
  447. }
  448. }
  449. for _, unwanted := range []string{"onclick", "style=", "javascript:", "analytics", "<form", "<button", "Was this page helpful"} {
  450. if strings.Contains(chapter, unwanted) {
  451. t.Errorf("chapter contains %q", unwanted)
  452. }
  453. }
  454. assertCover(t, book, false)
  455. squash := func(s string) string { return strings.Join(strings.Fields(s), " ") }
  456. nav := squash(book.File(t, "nav.xhtml"))
  457. for _, want := range []string{
  458. `<a href="xhtml/chapter.xhtml#sec-1">File location</a>`,
  459. `<a href="xhtml/chapter.xhtml#sec-2">Options</a> <ol> <li><a href="xhtml/chapter.xhtml#sec-3">Durations</a></li> </ol>`,
  460. `<a href="xhtml/chapter.xhtml#sec-4">Example</a>`,
  461. } {
  462. if !strings.Contains(nav, want) {
  463. t.Errorf("nav is missing %q:\n%s", want, nav)
  464. }
  465. }
  466. if strings.Contains(nav, "Edge cases") {
  467. t.Errorf("nav lists an <h4>; only sections and subsections belong in it")
  468. }
  469. epubtest.EPUBCheck(t, path)
  470. })
  471. t.Run("photo essay", func(t *testing.T) {
  472. dir := t.TempDir()
  473. r := realRun("-URL", srv.URL+"/essays/print-shop", "-SAVE", dir)
  474. if r.code != exitOK {
  475. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  476. }
  477. if !strings.Contains(r.stderr, "warning: image "+srv.URL+"/img/missing.png skipped: server returned 404 Not Found") {
  478. t.Errorf("stderr = %q, want a warning about the missing image", r.stderr)
  479. }
  480. path := filepath.Join(dir, "a-walk-through-the-print-shop.epub")
  481. data, err := os.ReadFile(path)
  482. if err != nil {
  483. t.Fatal(err)
  484. }
  485. book := epubtest.Open(t, data)
  486. book.AssertWellFormed(t)
  487. var stored []string
  488. for _, name := range book.Order {
  489. if strings.HasPrefix(name, "EPUB/images/") && name != "EPUB/images/cover.jpg" {
  490. stored = append(stored, name)
  491. }
  492. }
  493. if len(stored) != 3 {
  494. t.Fatalf("book has images %v, want 3 (photo, diagram, WebP picture)", stored)
  495. }
  496. for _, name := range stored {
  497. img, format, err := image.Decode(strings.NewReader(book.Files[name]))
  498. if err != nil {
  499. t.Fatalf("%s: %v", name, err)
  500. }
  501. if format != "jpeg" && format != "png" {
  502. t.Errorf("%s is %s, want jpeg or png", name, format)
  503. }
  504. if b := img.Bounds(); b.Dx() > 1404 || b.Dy() > 1872 {
  505. t.Errorf("%s is %dx%d, larger than the screen", name, b.Dx(), b.Dy())
  506. }
  507. switch img.(type) {
  508. case *image.Gray, *image.Paletted:
  509. default:
  510. t.Errorf("%s decodes as %T, want grayscale", name, img)
  511. }
  512. }
  513. chapter := book.File(t, "chapter.xhtml")
  514. for _, want := range []string{
  515. `alt="The old printing press"/><figcaption>The 1890s press still runs every Friday.</figcaption></figure>`,
  516. `alt="How ink reaches the paper"/></figure>`,
  517. `alt="Drawers of wooden type"/></figure>`,
  518. `<span class="missing-image">[Image: small press icon]</span>`,
  519. `<p class="byline">Grace Hopper · Letterpress Weekly</p>`, // no invented date
  520. } {
  521. if !strings.Contains(chapter, want) {
  522. t.Errorf("chapter is missing %q", want)
  523. }
  524. }
  525. if strings.Contains(chapter, "tracker.example") || strings.Contains(chapter, `<img src="http`) {
  526. t.Errorf("chapter references remote images:\n%s", chapter)
  527. }
  528. assertCover(t, book, true)
  529. if opf := book.File(t, ".opf"); !strings.Contains(opf, "<dc:publisher>Letterpress Weekly</dc:publisher>") ||
  530. !strings.Contains(opf, "<dc:source>"+srv.URL+"/essays/print-shop</dc:source>") {
  531. t.Errorf("OPF is missing the publisher or source:\n%s", opf)
  532. }
  533. epubtest.EPUBCheck(t, path)
  534. })
  535. t.Run("images inside lightbox zoom links", func(t *testing.T) {
  536. // Regression test: go-trafilatura dropped every image on pages that
  537. // wrap images in zoom links, such as Ars Technica articles.
  538. dir := t.TempDir()
  539. r := realRun("-URL", srv.URL+"/news/2026/10/kde-turns-30/", "-SAVE", dir)
  540. if r.code != exitOK {
  541. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  542. }
  543. if strings.Contains(r.stderr, "warning") {
  544. t.Errorf("unexpected warnings: %q", r.stderr)
  545. }
  546. path := filepath.Join(dir, "kde-turns-30-with-plasma-6-8.epub")
  547. data, err := os.ReadFile(path)
  548. if err != nil {
  549. t.Fatal(err)
  550. }
  551. book := epubtest.Open(t, data)
  552. book.AssertWellFormed(t)
  553. var stored int
  554. for _, name := range book.Order {
  555. if strings.HasPrefix(name, "EPUB/images/") && name != "EPUB/images/cover.jpg" {
  556. stored++
  557. }
  558. }
  559. if stored != 2 {
  560. t.Errorf("book has %d images, want 2", stored)
  561. }
  562. chapter := book.File(t, "chapter.xhtml")
  563. for _, want := range []string{
  564. `<figcaption>The new remote upgrade dialog. Credit: KDE</figcaption></figure>`,
  565. `alt="System Settings in Plasma 6.8"/><figcaption>System Settings has been reorganised.</figcaption></figure>`,
  566. } {
  567. if !strings.Contains(chapter, want) {
  568. t.Errorf("chapter is missing %q", want)
  569. }
  570. }
  571. if n := strings.Count(chapter, "The new remote upgrade dialog."); n != 1 {
  572. t.Errorf("caption appears %d times, want once", n)
  573. }
  574. assertCover(t, book, true) // the page's og:image, as on Ars Technica
  575. epubtest.EPUBCheck(t, path)
  576. })
  577. t.Run("image URLs with query strings or no extension", func(t *testing.T) {
  578. // Regression test: go-trafilatura dropped images whose URL doesn't end
  579. // in a file extension, such as every image on Wikipedia.
  580. dir := t.TempDir()
  581. r := realRun("-URL", srv.URL+"/wiki/Walking_Robots", "-SAVE", dir)
  582. if r.code != exitOK {
  583. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  584. }
  585. if strings.Contains(r.stderr, "warning") {
  586. t.Errorf("unexpected warnings: %q", r.stderr)
  587. }
  588. path := filepath.Join(dir, "walking-robots-game.epub")
  589. data, err := os.ReadFile(path)
  590. if err != nil {
  591. t.Fatal(err)
  592. }
  593. book := epubtest.Open(t, data)
  594. book.AssertWellFormed(t)
  595. var stored int
  596. for _, name := range book.Order {
  597. if strings.HasPrefix(name, "EPUB/images/") && name != "EPUB/images/cover.jpg" {
  598. stored++
  599. }
  600. }
  601. if stored != 3 {
  602. t.Errorf("book has %d images, want 3 (logo, miniature, record sheet)", stored)
  603. }
  604. chapter := book.File(t, "chapter.xhtml")
  605. for _, want := range []string{
  606. `<figcaption>The game&#39;s original logo</figcaption></figure>`,
  607. `<figcaption>A painted miniature on a hex map</figcaption></figure>`,
  608. `<figcaption>A machine&#39;s record sheet</figcaption></figure>`,
  609. } {
  610. if !strings.Contains(chapter, want) {
  611. t.Errorf("chapter is missing %q", want)
  612. }
  613. }
  614. if strings.Contains(chapter, "rekopub.invalid") {
  615. t.Errorf("chapter still contains stand-in image URLs:\n%s", chapter)
  616. }
  617. epubtest.EPUBCheck(t, path)
  618. })
  619. t.Run("KEPUB for Kobo", func(t *testing.T) {
  620. dir := t.TempDir()
  621. r := realRun("-URL", srv.URL+"/essays/print-shop", "-SAVE", filepath.Join(dir, "essay.epub"), "-KEPUB")
  622. if r.code != exitOK {
  623. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  624. }
  625. if !strings.Contains(r.stderr, "note: saving as essay.kepub.epub") {
  626. t.Errorf("stderr = %q, want a note about the .kepub.epub name", r.stderr)
  627. }
  628. path := filepath.Join(dir, "essay.kepub.epub")
  629. if strings.TrimSpace(r.stdout) != path {
  630. t.Errorf("stdout = %q, want %q", r.stdout, path)
  631. }
  632. data, err := os.ReadFile(path)
  633. if err != nil {
  634. t.Fatal(err)
  635. }
  636. book := epubtest.Open(t, data)
  637. book.AssertWellFormed(t)
  638. chapter := book.File(t, "chapter.xhtml")
  639. for _, want := range []string{`class="koboSpan"`, "The print shop on Mill Street has been running since 1894.", "<figcaption>"} {
  640. if !strings.Contains(chapter, want) {
  641. t.Errorf("chapter is missing %q", want)
  642. }
  643. }
  644. assertCover(t, book, true)
  645. epubtest.EPUBCheck(t, path)
  646. })
  647. t.Run("existing file stops before images are downloaded", func(t *testing.T) {
  648. dir := t.TempDir()
  649. existing := filepath.Join(dir, "a-walk-through-the-print-shop.epub")
  650. writeFile(t, existing, "old")
  651. before := imageRequests.Load()
  652. r := realRun("-URL", srv.URL+"/essays/print-shop", "-SAVE", dir)
  653. if r.code != exitExists {
  654. t.Errorf("exit code = %d, want %d; stderr = %q", r.code, exitExists, r.stderr)
  655. }
  656. if n := imageRequests.Load() - before; n != 0 {
  657. t.Errorf("%d images downloaded, want 0", n)
  658. }
  659. assertFile(t, existing, "old")
  660. })
  661. t.Run("not found", func(t *testing.T) {
  662. dir := t.TempDir()
  663. r := realRun("-URL", srv.URL+"/missing", "-SAVE", dir)
  664. if r.code != exitFetch || !strings.Contains(r.stderr, "returned 404 Not Found") {
  665. t.Errorf("exit code = %d, stderr = %q", r.code, r.stderr)
  666. }
  667. assertEmptyDir(t, dir)
  668. })
  669. t.Run("no content", func(t *testing.T) {
  670. dir := t.TempDir()
  671. r := realRun("-URL", srv.URL+"/app", "-SAVE", dir)
  672. if r.code != exitNoContent || !strings.Contains(r.stderr, "no readable article content") {
  673. t.Errorf("exit code = %d, stderr = %q", r.code, r.stderr)
  674. }
  675. assertEmptyDir(t, dir)
  676. })
  677. }
  678. // TestLocalFilesNeverRead checks that a page can't pull files from the
  679. // reader's computer into the book, whichever way it refers to them.
  680. func TestLocalFilesNeverRead(t *testing.T) {
  681. const marker = "TOP-SECRET-MARKER-7f3a"
  682. secrets := t.TempDir()
  683. secretText := filepath.Join(secrets, "secret.txt")
  684. if err := os.WriteFile(secretText, []byte(marker), 0o644); err != nil {
  685. t.Fatal(err)
  686. }
  687. png, err := os.ReadFile("../../testdata/images/diagram.png")
  688. if err != nil {
  689. t.Fatal(err)
  690. }
  691. secretPNG := filepath.Join(secrets, "secret.png")
  692. if err := os.WriteFile(secretPNG, png, 0o644); err != nil {
  693. t.Fatal(err)
  694. }
  695. fileURL := func(path string) string {
  696. p := filepath.ToSlash(path)
  697. if !strings.HasPrefix(p, "/") {
  698. p = "/" + p // a Windows path such as C:/Users/...
  699. }
  700. return "file://" + p
  701. }
  702. blog, err := os.ReadFile("../../testdata/pages/blog.html")
  703. if err != nil {
  704. t.Fatal(err)
  705. }
  706. page := strings.Replace(string(blog), "</head>",
  707. `<meta property="og:image" content="`+fileURL(secretPNG)+`"></head>`, 1)
  708. page = strings.Replace(page, "</p>", "</p>"+
  709. `<p><img src="`+fileURL(secretPNG)+`" alt="one">`+
  710. `<img src="file://localhost`+filepath.ToSlash(secretPNG)+`" alt="two">`+
  711. `<img src="`+filepath.ToSlash(secretPNG)+`" alt="three">`+
  712. `<img src="`+secretPNG+`" alt="four">`+
  713. `<img src="`+fileURL(secretText)+`" alt="five">`+
  714. `<picture><source srcset="`+fileURL(secretPNG)+` 2x"><img src="`+fileURL(secretPNG)+`" alt="six"></picture>`+
  715. `<a href="`+fileURL(secretText)+`">notes</a></p>`, 1)
  716. var requests []string
  717. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  718. requests = append(requests, r.URL.Path)
  719. if r.URL.Path != "/posts/eink" {
  720. http.NotFound(w, r) // relative paths resolve to this server
  721. return
  722. }
  723. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  724. w.Write([]byte(page))
  725. }))
  726. defer srv.Close()
  727. out := filepath.Join(t.TempDir(), "book.epub")
  728. var stdout, stderr bytes.Buffer
  729. code := run(context.Background(), []string{"-URL", srv.URL + "/posts/eink", "-SAVE", out}, &stdout, &stderr, pipeline.Run)
  730. if code != exitOK {
  731. t.Fatalf("exit %d: %s", code, stderr.String())
  732. }
  733. data, err := os.ReadFile(out)
  734. if err != nil {
  735. t.Fatal(err)
  736. }
  737. book := epubtest.Open(t, data)
  738. for name, content := range book.Files {
  739. if strings.Contains(content, marker) {
  740. t.Errorf("%s contains the secret file's content", name)
  741. }
  742. if strings.HasSuffix(name, ".png") || (strings.HasSuffix(name, ".jpg") && !strings.HasSuffix(name, "cover.jpg")) {
  743. t.Errorf("book contains image %s; the only image should be the typographic cover", name)
  744. }
  745. }
  746. if chapter := book.File(t, "chapter.xhtml"); strings.Contains(chapter, "file:") {
  747. t.Errorf("chapter still refers to a local file:\n%s", chapter)
  748. }
  749. t.Logf("server requests: %v\nwarnings:\n%s", requests, stderr.String())
  750. }