1
0

main_test.go 29 KB

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