{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Felix.Test.Unit.HtmlOutput (unitTests) where
import Base
import Felix.Parse qualified as Parse
import Felix.Source
import Felix.Source.Graph qualified as SourceGraph
import Felix.Render.Html qualified as Html
import Felix.Render.Html.Export
import Felix.Render.Html.Output
import Control.Exception (bracket)
import Data.ByteString qualified as ByteString
import Data.List qualified as List
import Data.Text qualified as Text
import Data.Text.Encoding qualified as TextEncoding
import System.Directory qualified as Directory
import System.FilePath.Posix ((>))
import System.Posix.Files qualified as PosixFiles
import Test.Tasty
import Test.Tasty.HUnit
unitTests :: TestTree
unitTests =
testGroup "HTML output"
[ testCase
"publishes a mounted multi-page UTF-8 export"
publishesMountedExport
, testCase
"writes prepared strict bytes"
writesPreparedBytes
, testCase
"attaches bytes only to reserved routes"
attachesBytesToReservedRoutes
, testCase
"rejects a final symlink before publication"
rejectsFinalSymlink
, testCase
"replaces hard-linked targets without changing peers"
replacesHardLinkedTarget
, testCase
"rejects a FIFO before publication"
rejectsFifo
, testCase
"reports and cleans the source-order publication prefix"
reportsIncompletePublication
]
publishesMountedExport :: Assertion
publishesMountedExport =
withTemporaryDirectory "felix-html-export" \temp -> do
let projectRoot = temp > "project"
libraryRoot = temp > "library"
projectDirectory = projectRoot > "docs"
libraryDirectory = libraryRoot > "shared"
rootSource = projectDirectory > "über #.tex"
importedSource =
libraryDirectory > "sets #.tex"
outputRoot = temp > "html"
rootPage = outputRoot > "docs" > "über #.html"
importedPage =
outputRoot
> "library"
> "shared"
> "sets #.html"
supportAsset =
outputRoot
> "_static"
> "naproche-html.js"
configuration =
[ (sourceMountId "project", [])
, (sourceMountId "library", ["library"])
]
hints =
"relation\teq\t0\t
library:shared/sets #.tex"
rootText
assertContains
"root support route"
"src=\"../_static/naproche-html.js\""
rootText
assertContains
"imported support route"
"src=\"../../_static/naproche-html.js\""
importedText
for_ [rootText, importedText] \document ->
assertContains
"UTF-8 declaration"
""
document
assertEqual
"support asset"
Html.supportScriptAssetContents
supportText
rejectsArtifactEscape
:: FilePath
-> [PreparedHtmlArtifact]
-> Assertion
rejectsArtifactEscape temp artifacts = do
let outputRoot = temp > "escape-html"
outsideRoot = temp > "outside"
outsideMarker = outsideRoot > "unchanged"
Directory.createDirectory outputRoot
Directory.createDirectory outsideRoot
ByteString.writeFile outsideMarker "outside"
Directory.createDirectoryLink
outsideRoot
(outputRoot > "docs")
result <- planHtmlOutput outputRoot artifacts
case result of
Left
(HtmlOutputParentEscapesRoot
_parent
canonicalParent) -> do
expectedOutside <-
Directory.canonicalizePath outsideRoot
assertEqual
"escaping route target"
expectedOutside
canonicalParent
other ->
assertFailure
("expected output-root escape rejection, got "
<> showPlanResult other)
outsideBytes <- ByteString.readFile outsideMarker
assertEqual
"escape planning changed the outside tree"
"outside"
outsideBytes
supportExists <-
Directory.doesPathExist
(outputRoot > "_static")
assertBool
"escape preflight created another destination"
(not supportExists)
writesPreparedBytes :: Assertion
writesPreparedBytes =
withTemporaryDirectory "felix-html-output-bytes" \temp -> do
let outputRoot = temp > "html"
pageText = "∀ café"
supportText = "const π = 3;"
expectedPageBytes =
ByteString.pack
[ 0xe2, 0x88, 0x80
, 0x20
, 0x63, 0x61, 0x66
, 0xc3, 0xa9
]
artifacts <-
makeArtifacts
[ ( "nested/über.html"
, TextEncoding.encodeUtf8 pageText
)
, ( "_static/naproche-html.js"
, TextEncoding.encodeUtf8 supportText
)
]
publishArtifacts outputRoot artifacts
pageBytes <-
ByteString.readFile
(outputRoot > "nested" > "über.html")
supportBytes <-
ByteString.readFile
(outputRoot
> "_static"
> "naproche-html.js")
assertEqual
"exact page UTF-8 bytes"
expectedPageBytes
pageBytes
assertEqual
"exact support bytes"
(TextEncoding.encodeUtf8 supportText)
supportBytes
attachesBytesToReservedRoutes :: Assertion
attachesBytesToReservedRoutes =
withTemporaryDirectory "felix-html-output-routes" \temp -> do
let outputRoot = temp > "html"
reserved <- expectRight
(traverse safeRelativePath
["page.html", "_static/naproche-html.js"])
routes <- requireRoutePlan =<<
planHtmlRoutes outputRoot reserved
matching <- makeArtifacts
[ ("page.html", "page")
, ("_static/naproche-html.js", "support")
]
case planHtmlOutputAgainst routes matching of
Right _ ->
pure ()
Left failure ->
assertFailure (show failure)
mismatched <- makeArtifacts
[ ("other.html", "other")
, ("_static/naproche-html.js", "support")
]
case planHtmlOutputAgainst routes mismatched of
Left HtmlOutputRouteMismatch{} ->
pure ()
Left failure ->
assertFailure
("unexpected route mismatch: " <> show failure)
Right _ ->
assertFailure "unreserved HTML route was accepted"
rejectsFinalSymlink :: Assertion
rejectsFinalSymlink =
withTemporaryDirectory "felix-html-output-final-link" \temp -> do
let outputRoot = temp > "html"
supportDirectory = outputRoot > "_static"
page = outputRoot > "page.html"
support =
supportDirectory > "naproche-html.js"
outsideAsset = temp > "outside.js"
Directory.createDirectory outputRoot
Directory.createDirectory supportDirectory
ByteString.writeFile page "old page"
ByteString.writeFile outsideAsset "outside asset"
Directory.createFileLink outsideAsset support
artifacts <-
makeArtifacts
[ ("page.html", "new page")
, ("_static/naproche-html.js", "new support")
]
result <- planHtmlOutput outputRoot artifacts
case result of
Left (HtmlOutputTargetIsSymbolicLink target) ->
assertEqual "rejected target" support target
other ->
assertFailure
("expected final symlink rejection, got "
<> showPlanResult other)
pageBytes <- ByteString.readFile page
outsideBytes <- ByteString.readFile outsideAsset
supportIsLink <-
Directory.pathIsSymbolicLink support
assertEqual
"page changed before complete preflight"
"old page"
pageBytes
assertEqual
"symlink referent changed"
"outside asset"
outsideBytes
assertBool "final symlink was replaced" supportIsLink
replacesHardLinkedTarget :: Assertion
replacesHardLinkedTarget =
withTemporaryDirectory "felix-html-output-hard-link" \temp -> do
let outputRoot = temp > "html"
page = outputRoot > "page.html"
outsidePage = temp > "outside.html"
Directory.createDirectory outputRoot
ByteString.writeFile outsidePage "outside page"
PosixFiles.createLink outsidePage page
artifacts <-
makeArtifacts [("page.html", "new page")]
publishArtifacts outputRoot artifacts
outsideBytes <- ByteString.readFile outsidePage
pageBytes <- ByteString.readFile page
assertEqual
"outside hard-link peer changed"
"outside page"
outsideBytes
assertEqual "page was not replaced" "new page" pageBytes
rejectsFifo :: Assertion
rejectsFifo =
withTemporaryDirectory "felix-html-output-fifo" \temp -> do
let outputRoot = temp > "html"
page = outputRoot > "page.html"
Directory.createDirectory outputRoot
PosixFiles.createNamedPipe page PosixFiles.ownerModes
artifacts <-
makeArtifacts [("page.html", "page")]
result <- planHtmlOutput outputRoot artifacts
case result of
Left (HtmlOutputTargetNotRegularFile target) ->
assertEqual "rejected target" page target
other ->
assertFailure
("expected FIFO rejection, got "
<> showPlanResult other)
pageStatus <-
PosixFiles.getSymbolicLinkStatus page
assertBool
"FIFO target was replaced"
(PosixFiles.isNamedPipe pageStatus)
reportsIncompletePublication :: Assertion
reportsIncompletePublication =
withTemporaryDirectory "felix-html-output-incomplete" \temp -> do
let outputRoot = temp > "html"
first = outputRoot > "z.html"
blocked = outputRoot > "b.html"
unpublished = outputRoot > "a.html"
artifacts <-
makeArtifacts
[ ("z.html", "first")
, ("b.html", "blocked")
, ("a.html", "unpublished")
]
plan <-
requirePlan =<<
planHtmlOutput outputRoot artifacts
Directory.createDirectory outputRoot
Directory.createDirectory blocked
result <- writeHtmlOutput plan
case result of
Left
IncompleteHtmlPublication
{ committedHtmlDestinations
, failedHtmlDestination
} -> do
expectedFirst <-
expectRight
(safeRelativePath "z.html")
expectedBlocked <-
expectRight
(safeRelativePath "b.html")
assertEqual
"committed destinations"
[expectedFirst]
committedHtmlDestinations
assertEqual
"failed destination"
expectedBlocked
failedHtmlDestination
Right () ->
assertFailure
"expected incomplete publication"
firstBytes <- ByteString.readFile first
unpublishedExists <-
Directory.doesPathExist unpublished
blockedIsDirectory <-
Directory.doesDirectoryExist blocked
outputEntries <-
Directory.listDirectory outputRoot
assertEqual "first artifact" "first" firstBytes
assertBool
"later artifact was published"
(not unpublishedExists)
assertBool
"injected blocker was replaced"
blockedIsDirectory
assertBool
"unpublished temporary files remain"
(not
(any
(List.isInfixOf ".tmp")
outputEntries))
publishArtifacts
:: FilePath
-> [PreparedHtmlArtifact]
-> IO ()
publishArtifacts outputRoot artifacts = do
plan <-
requirePlan =<< planHtmlOutput outputRoot artifacts
requirePublication =<< writeHtmlOutput plan
makeArtifacts
:: [(FilePath, ByteString.ByteString)]
-> IO [PreparedHtmlArtifact]
makeArtifacts artifacts =
for artifacts \(path, bytes) -> do
relative <- expectRight (safeRelativePath path)
pure (preparedHtmlArtifact relative (Right bytes))
requirePlan
:: Either HtmlOutputError HtmlOutputPlan
-> IO HtmlOutputPlan
requirePlan =
expectRight
requireRoutePlan
:: Either HtmlOutputError HtmlRoutePlan
-> IO HtmlRoutePlan
requireRoutePlan =
expectRight
requirePublication
:: Either HtmlPublicationError ()
-> IO ()
requirePublication =
expectRight
showPlanResult
:: Either HtmlOutputError HtmlOutputPlan
-> String
showPlanResult = \case
Left err ->
show err
Right _plan ->
"successful output plan"
readUtf8 :: FilePath -> IO Text
readUtf8 path = do
bytes <- ByteString.readFile path
case TextEncoding.decodeUtf8' bytes of
Left err ->
assertFailure
("invalid UTF-8 output: " <> show err)
Right text ->
pure text
prepareTestHtmlExport
:: [(SourceMountId, [Text])]
-> SourceMounts
-> RootRequest
-> Text
-> IO (Either HtmlExportError [PreparedHtmlArtifact])
prepareTestHtmlExport configuration mounts request hints = do
graph <-
expectRight =<<
SourceGraph.buildResolvedSourceGraph mounts request
workspace <-
expectRight =<< Parse.parseResolvedSourceGraph graph
pure
(prepareHtmlExport
configuration
(htmlPresentationFromParsedWorkspace workspace)
hints)
writeImportedTheory :: FilePath -> IO ()
writeImportedTheory path =
writeFile path
(unlines
[ "\\begin{proposition}\\label{imported_prop}"
, " $i = i$."
, "\\end{proposition}"
])
writeRootTheory :: FilePath -> IO ()
writeRootTheory path =
writeFile path
(unlines
[ "\\import{shared/sets #.tex}"
, "\\begin{proposition}\\label{local_prop}"
, " $a = a$."
, "\\end{proposition}"
, "\\begin{proposition}\\label{uses_import}"
, " $b = b$."
, "\\end{proposition}"
, "\\begin{proof}"
, " Follows by \\cref{imported_prop}."
, "\\end{proof}"
])
assertContains
:: String
-> Text
-> Text
-> Assertion
assertContains description needle haystack =
assertBool
(description <> ": missing " <> show needle)
(needle `Text.isInfixOf` haystack)
expectRight
:: (Show e, HasCallStack)
=> Either e a
-> IO a
expectRight = \case
Left err ->
assertFailure
("expected Right, got Left " <> show err)
Right value ->
pure value
withTemporaryDirectory
:: String
-> (FilePath -> IO a)
-> IO a
withTemporaryDirectory template =
bracket create Directory.removePathForcibly
where
create = do
systemTemp <-
Directory.getTemporaryDirectory
(path, handle) <-
openTempFile systemTemp template
hClose handle
Directory.removeFile path
Directory.createDirectory path
pure path