diff --git a/default.nix b/default.nix index 307629f6e..74485147d 100644 --- a/default.nix +++ b/default.nix @@ -3,7 +3,7 @@ with (import ./nix { inherit overlays; }); with pkgs.haskell.lib; -{ +rec { inherit pkgs legacyPkgs; # hackage release @@ -24,7 +24,7 @@ with pkgs.haskell.lib; # miso x86 miso-ghc = legacyPkgs.haskell.packages.ghc865.miso; miso-ghc-9122 = pkgs.haskell.packages.ghc9122.miso; - miso-tests-ghc = pkgs.haskell.packages.ghc9122.miso; + miso-tests-ghc = pkgs.haskell.packages.ghc9122.miso-tests; # sample app legacy build inherit (legacyPkgs.haskell.packages.ghc865) @@ -79,28 +79,59 @@ with pkgs.haskell.lib; playwright-js = pkgs.writeScriptBin "playwright" '' #!${pkgs.stdenv.shell} + set -e export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers} export PATH="${pkgs.lib.makeBinPath [ pkgs.http-server pkgs.bun ]}:$PATH" bun install playwright@1.53 - http-server ${pkgs.pkgsCross.ghcjs.haskell.packages.ghc9122.miso-tests}/bin/component-tests.jsexe & - cd tests - bun run ../ts/playwright.ts + http-server -p 8061 ${miso-tests}/bin/component-tests.jsexe & + HTTP_SERVER_PID=$! + echo $HTTP_SERVER_PID + PW_API_PORT=8060 + PORT=$PW_API_PORT bun run ./ts/playwright.ts & + PLAYWRIGHT_SERVER_PID=$! + echo $PLAYWRIGHT_SERVER_PID + until curl -sf "http://localhost:$PW_API_PORT/ready"; do sleep 0.1; done + echo "Playwright server ready, asking it to start test." + curl --fail "http://localhost:$PW_API_PORT/test?port=8061&wait=true" + kill $HTTP_SERVER_PID + PORT=8062 \ + PLAYWRIGHT_PORT=$PW_API_PORT \ + STATIC_DIR="${miso-tests}/bin/integration-tests-client.jsexe/" \ + BACKEND=GHCJS \ + ${miso-tests-ghc}/bin/integration-tests-server exit_code=$? - pkill http-server + kill $PLAYWRIGHT_SERVER_PID exit "$exit_code" ''; playwright-wasm = pkgs.writeScriptBin "playwright" '' #!${pkgs.stdenv.shell} + set -e export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers} export PATH="${pkgs.lib.makeBinPath [ pkgs.http-server pkgs.bun ]}:$PATH" bun install playwright@1.53 - cd tests + pushd tests nix develop .#wasm --command bash -c 'make' - http-server ./public & - bun run ../ts/playwright.ts + http-server -p 8061 ./public & + HTTP_SERVER_PID=$! + echo $HTTP_SERVER_PID + PW_API_PORT=8060 + echo pwd: + pwd + PORT=$PW_API_PORT bun run ../ts/playwright.ts & + PLAYWRIGHT_SERVER_PID=$! + echo $PLAYWRIGHT_SERVER_PID + until curl -sf "http://localhost:$PW_API_PORT/ready"; do sleep 0.1; done + echo "Playwright server ready, asking it to start test." + curl --fail "http://localhost:$PW_API_PORT/test?port=8061&wait=true" + kill $HTTP_SERVER_PID + PORT=8062 \ + PLAYWRIGHT_PORT=$PW_API_PORT \ + STATIC_DIR=./public \ + BACKEND=WASM \ + ${miso-tests-ghc}/bin/integration-tests-server exit_code=$? - pkill http-server + kill $PLAYWRIGHT_SERVER_PID exit "$exit_code" ''; diff --git a/nix/haskell/packages/ghc/default.nix b/nix/haskell/packages/ghc/default.nix index 0501f0daf..f91e13713 100644 --- a/nix/haskell/packages/ghc/default.nix +++ b/nix/haskell/packages/ghc/default.nix @@ -4,13 +4,21 @@ let in with pkgs.haskell.lib; self: super: -{ +rec { /* miso */ - miso = self.callCabal2nixWithOptions "miso" source.miso "-ftemplate-haskell" {}; - miso-tests = self.callCabal2nix "miso-tests" source.miso-tests {}; + miso = self.callCabal2nixWithOptions "miso" source.miso "-ftemplate-haskell -fssr" {}; /* miso utils */ miso-from-html = self.callCabal2nix "miso-from-html" source.miso-from-html {}; + servant-miso-html = self.callCabal2nix "servant-miso-html" source.servant-miso-html {}; + servant-miso-router = self.callCabal2nix "servant-miso-router" source.servant-miso-router { + servant-miso-html = servant-miso-html; + }; + + miso-tests = self.callCabal2nix "miso-tests" source.miso-tests { + servant-miso-router = servant-miso-router; + servant-miso-html = servant-miso-html; + }; /* examples */ sample-app = self.callCabal2nix "app" source.sample-app {}; diff --git a/nix/source.nix b/nix/source.nix index bf42711c7..20940c51a 100644 --- a/nix/source.nix +++ b/nix/source.nix @@ -43,6 +43,20 @@ in sha256 = "0s6kzqxbshsnqbqfj7rblqkrr5mzkjxknb6k8m8z4h10mcv1zh7j"; }; + servant-miso-html = fetchFromGitHub { + owner = "haskell-miso"; + repo = "servant-miso-html"; + rev = "00781d1920795b67e0476b67ed6840c388f29810"; + sha256 = "sha256-dYPlwSbQ+QXvMeS5tonBVnT9zQGADtohmD/ZAiY/cXA="; + }; + + servant-miso-router = fetchFromGitHub { + owner = "haskell-miso"; + repo = "servant-miso-router"; + rev = "0c828e0ba30ee7a446ce8999288b32b7f6425dd1"; + sha256 = "sha256-2Vkheb2iNDFWNAToO+r8rMY3OAA6LlUtgxiCWRm0wAY="; + }; + ghcjs-base = fetchFromGitHub { owner = "ghcjs"; repo = "ghcjs-base"; diff --git a/src/Miso/Html/Render.hs b/src/Miso/Html/Render.hs index 6ca09c6a2..cc663c83d 100644 --- a/src/Miso/Html/Render.hs +++ b/src/Miso/Html/Render.hs @@ -107,7 +107,7 @@ renderBuilder (VNode ns tag attrs children) = mconcat , mconcat [ " " <> intercalate " " (renderAttrs <$> attrs) | not (Prelude.null attrs) ] - , if tag `elem` selfClosing then "/>" else ">" + , ">" , mconcat [ mconcat [ foldMap renderBuilder (collapseSiblingTextNodes children) diff --git a/src/Miso/JSON.hs b/src/Miso/JSON.hs index 48411b412..2c42a881b 100644 --- a/src/Miso/JSON.hs +++ b/src/Miso/JSON.hs @@ -104,7 +104,7 @@ import Data.Char import qualified Data.Map.Strict as M import Data.Map.Strict (Map) import Data.Int -import GHC.Natural (naturalToInteger, naturalFromInteger) +import GHC.Natural (naturalToInteger, naturalFromInteger ) import GHC.TypeLits import Data.Kind #ifndef VANILLA diff --git a/src/Miso/Runtime.hs b/src/Miso/Runtime.hs index fc10658ce..b5d8b137f 100644 --- a/src/Miso/Runtime.hs +++ b/src/Miso/Runtime.hs @@ -171,12 +171,10 @@ initialize events _componentParentId hydrate isRoot comp@Component {..} getCompo (Hydrate, Just m) -> m (Draw, _) -> do IM.lookup _componentId <$> readIORef components >>= \case - Nothing -> - pure model - Just cs -> - -- hot reload scenario, let it flow - pure (cs ^. componentModel) - _ -> pure model + Nothing -> applyParentBindings _componentParentId model bindings + Just cs -> pure (cs ^. componentModel) + _ -> applyParentBindings _componentParentId model bindings + _componentScripts <- (++) <$> renderScripts scripts <*> renderStyles styles _componentDOMRef <- getComponentMountPoint _componentIsDirty <- pure False @@ -328,12 +326,30 @@ propagate -> IntMap (ComponentState p m a) -> (IntMap (ComponentState p m a), ComponentIds) propagate vcompId vcomps = - let dfsState = execState synch (dfs vcomps vcompId) + let dfsState = execState sync (dfs vcomps vcompId) in (_state dfsState, _visited dfsState) ----------------------------------------------------------------------------- -- | Create an empty DFS state dfs :: IntMap (ComponentState p m a) -> ComponentId -> DFS p m a -dfs cs vcompId = DFS cs mempty (pure vcompId) +dfs cs vcompId = DFS cs mempty (pure vcompId) vcompId +----------------------------------------------------------------------------- +-- | Applies ParentToChild & Bidirectional bindings from the parent's current model +-- to the child's initial model. Safe to call during mount. +applyParentBindings + :: ComponentId + -> model + -> [Binding parent model] + -> IO model +applyParentBindings pId mdl bindings = do + mParent <- IM.lookup pId <$> readIORef components + pure $ case mParent of + Nothing -> mdl + Just parentState -> + foldr (applyBinding parentState) mdl bindings + where + applyBinding parentState (ParentToChild from into) acc = into (from (parentState ^. componentModel)) acc + applyBinding parentState (Bidirectional from _ _ into) acc = into (from (parentState ^. componentModel)) acc + applyBinding _ _ acc = acc ----------------------------------------------------------------------------- type ComponentIds = IntSet ----------------------------------------------------------------------------- @@ -345,9 +361,11 @@ data DFS p m a -- ^ visited set , _stack :: [ComponentId] -- ^ neighbors queue + , _triggeredComponent :: ComponentId + -- ^ start of the traverse } ----------------------------------------------------------------------------- -type Synch p m a x = State (DFS p m a) x +type Sync p m a x = State (DFS p m a) x ----------------------------------------------------------------------------- visited :: Lens (DFS p m a) (ComponentIds) visited = lens _visited $ \r x -> r { _visited = x } @@ -358,40 +376,50 @@ state = lens _state $ \r x -> r { _state = x } stack :: Lens (DFS p m a) [ComponentId] stack = lens _stack $ \r x -> r { _stack = x } ----------------------------------------------------------------------------- -synch :: Synch p m a () -synch = mapM_ go =<< pop +triggeredComponent :: Lens (DFS p m a) ComponentId +triggeredComponent = lens _triggeredComponent $ \r x -> r { _triggeredComponent = x } +----------------------------------------------------------------------------- +sync :: Sync p m a () +sync = mapM_ go =<< pop where - go :: ComponentState p m a -> Synch p m a () + go :: ComponentState p m a -> Sync p m a () go cs = do - seen <- IS.member (cs ^. componentId) <$> use visited + visited_ <- use visited + let seen = IS.member (cs ^. componentId) visited_ when (not seen) $ do - propagateParent cs (cs ^. parentId) + let parentSeen = IS.member (cs ^. parentId) visited_ + when (not parentSeen) $ + propagateParent cs (cs ^. parentId) propagateChildren cs (cs ^. children) markVisited (cs ^. componentId) - synch + sync ----------------------------------------------------------------------------- propagateChildren :: forall p m a . ComponentState p m a -> ComponentIds - -> Synch p m a () + -> Sync p m a () propagateChildren currentState childComponents = do forM_ (IS.toList childComponents) $ \childId -> do - childState <- unsafeCoerce (IM.! childId) <$> use state - updatedChild <- unsafeCoerce <$> - foldM process childState (childState ^. componentBindings) - let isChildDirty = - (_componentModelDirty childState) - (_componentModel childState) - (_componentModel updatedChild) - when isChildDirty $ do - state.at childId ?= updatedChild { _componentIsDirty = True } - visit childId + triggeredComponent_ <- use triggeredComponent + + when (childId /= triggeredComponent_) $ do + childState <- unsafeCoerce (IM.! childId) <$> use state + updatedChild <- unsafeCoerce <$> + foldM process childState (childState ^. componentBindings) + let isChildDirty = + (_componentModelDirty childState) + (_componentModel childState) + (_componentModel updatedChild) + when isChildDirty $ do + state.at childId ?= updatedChild { _componentIsDirty = True } + visit childId + where process :: ComponentState m child a -> Binding m child - -> Synch p m a (ComponentState m child a) + -> Sync p m a (ComponentState m child a) process childState = \case ParentToChild getCurrentField setChildField -> do let currentChildModel = childState ^. componentModel @@ -410,10 +438,11 @@ propagateParent :: forall p m a . ComponentState p m a -> ComponentId - -> Synch p m a () + -> Sync p m a () propagateParent currentState parentId_ = - IM.lookup parentId_ <$> use state >>= mapM_ \case - parentState -> do + IM.lookup parentId_ <$> use state >>= \case + Nothing -> pure () + Just parentState -> do updatedParent <- unsafeCoerce <$> foldM process (unsafeCoerce parentState) (currentState ^. componentBindings) let isParentDirty = @@ -427,7 +456,7 @@ propagateParent currentState parentId_ = process :: ComponentState x p a -> Binding p m - -> Synch p m a (ComponentState x p a) + -> Sync p m a (ComponentState x p a) process parentState = \case ChildToParent setParentField getCurrentField -> do let currentParentModel = parentState ^. componentModel @@ -442,13 +471,13 @@ propagateParent currentState parentId_ = _ -> pure parentState ----------------------------------------------------------------------------- -markVisited :: ComponentId -> Synch p m a () +markVisited :: ComponentId -> Sync p m a () markVisited vcompId = visited.at vcompId ?= () ----------------------------------------------------------------------------- -visit :: ComponentId -> Synch p m a () +visit :: ComponentId -> Sync p m a () visit vcompId = stack %= (vcompId:) ----------------------------------------------------------------------------- -pop :: Synch p m a (Maybe (ComponentState p m a)) +pop :: Sync p m a (Maybe (ComponentState p m a)) pop = use stack >>= \case [] -> pure Nothing @@ -1095,7 +1124,7 @@ setAttrs vnode_@(Object jval) attrs snk logLevel events = ----------------------------------------------------------------------------- -- | Registers components in the global state registerComponent :: MonadIO m => ComponentState parent model action -> m () -registerComponent componentState = liftIO $ +registerComponent componentState = liftIO $ do atomicModifyIORef' components $ \cs -> (IM.insert (_componentId componentState) componentState cs, ()) ----------------------------------------------------------------------------- diff --git a/src/Miso/Types.hs b/src/Miso/Types.hs index 2c4f87ca6..35e1f1a07 100644 --- a/src/Miso/Types.hs +++ b/src/Miso/Types.hs @@ -110,11 +110,7 @@ data Component parent model action = Component { model :: model -- ^ Initial model -#ifdef SSR - , hydrateModel :: Maybe (IO model) -#else , hydrateModel :: Maybe (IO model) -#endif -- ^ Optional 'IO' to load component 'model' state, such as reading data from page. -- The resulting 'model' is only used during initial hydration, not on remounts. , update :: action -> Effect parent model action diff --git a/tests/CHANGELOG.md b/tests/CHANGELOG.md deleted file mode 100644 index f22fdef15..000000000 --- a/tests/CHANGELOG.md +++ /dev/null @@ -1,5 +0,0 @@ -# Revision history for miso-tests - -## 0.1.0.0 -- YYYY-mm-dd - -* First version. Released on an unsuspecting world. diff --git a/tests/Makefile b/tests/Makefile index 124e60103..2d4fff904 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -6,12 +6,19 @@ update: wasm32-wasi-cabal update build: - wasm32-wasi-cabal build component-tests + wasm32-wasi-cabal build --allow-newer rm -rf public cp -r static public - $(eval my_wasm=$(shell wasm32-wasi-cabal list-bin component-tests | tail -n 1)) - $(shell wasm32-wasi-ghc --print-libdir)/post-link.mjs --input $(my_wasm) --output public/ghc_wasm_jsffi.js - cp -v $(my_wasm) public/ + bash -c '\ + libdir=$$(wasm32-wasi-ghc --print-libdir); \ + my_wasm=$$(wasm32-wasi-cabal list-bin component-tests --allow-newer); \ + $$libdir/post-link.mjs -i "$$my_wasm" -o public/ghc_wasm_jsffi.js; \ + cp -v "$$my_wasm" public/; \ + more_wasm=$$(wasm32-wasi-cabal list-bin integration-tests-client --allow-newer); \ + echo "Processing integration-tests-client: $$more_wasm"; \ + $$libdir/post-link.mjs -i "$$more_wasm" -o public/wasm.js; \ + cp -v "$$more_wasm" public/integration-client.wasm \ + ' optim: wasm-opt -all -O2 public/component-tests.wasm -o public/component-tests.wasm diff --git a/tests/app/HtmlGen.hs b/tests/app/HtmlGen.hs new file mode 100644 index 000000000..eadeccccb --- /dev/null +++ b/tests/app/HtmlGen.hs @@ -0,0 +1,597 @@ +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE OverloadedStrings #-} +{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} +{-# HLINT ignore "Use <&>" #-} + +module HtmlGen where + +import Miso hiding (on, Src, Checked, Object) +import Miso.Html.Element hiding (title_, data_) +import Miso.Html.Property hiding (label_, form_) +import Miso.JSON + ( FromJSON (..) + , ToJSON (..) + , object + , (.:) + , (.=) + , Value (..) + , Parser + ) +import Test.QuickCheck +import Data.Char (isControl, isSpace) +import Control.Monad (replicateM) + +maxDepth :: Int +maxDepth = 20 + +type HtmlAttributeValue = MisoString + +type HtmlAttribute = (HtmlAttributeType, HtmlAttributeValue) + +data HtmlAttributeType + = Class + | Id + | Title + | Colspan + | Rowspan + | Method + | Action + | Alt + | Src + | Value + | Type + | Checked + deriving (Eq, Show) + +instance ToJSON HtmlAttributeType where + toJSON Class = String "Class" + toJSON Id = String "Id" + toJSON Title = String "Title" + toJSON Colspan = String "Colspan" + toJSON Rowspan = String "Rowspan" + toJSON Method = String "Method" + toJSON Action = String "Action" + toJSON Alt = String "Alt" + toJSON Src = String "Src" + toJSON Value = String "Value" + toJSON Type = String "Type" + toJSON Checked = String "Checked" + +instance FromJSON HtmlAttributeType where + parseJSON (String "Class") = pure Class + parseJSON (String "Id") = pure Id + parseJSON (String "Title") = pure Title + parseJSON (String "Colspan") = pure Colspan + parseJSON (String "Rowspan") = pure Rowspan + parseJSON (String "Method") = pure Method + parseJSON (String "Action") = pure Action + parseJSON (String "Alt") = pure Alt + parseJSON (String "Src") = pure Src + parseJSON (String "Value") = pure Value + parseJSON (String "Type") = pure Type + parseJSON (String "Checked") = pure Checked + + parseJSON _ = fail "Expected JSON String for HtmlAttributeType deserialization" + +data ChildHavingHtmlTag + = Div + | Span + | P + | Pre + | Ul + | Ol + | Li + | Section + | Header + | Footer + | Nav + | Article + | H1 + | H2 + | H3 + | H4 + | Strong + | Em + | Table + | Thead + | Tbody + | Tr + | Td + | Th + | Form + | Label + | Button + | Fieldset + | Legend + | Dl + | Dt + | Dd + | Figure + | Figcaption + | A + deriving (Eq, Enum, Bounded, Show) + +instance ToJSON ChildHavingHtmlTag where + toJSON Div = String "Div" + toJSON Span = String "Span" + toJSON P = String "P" + toJSON Pre = String "Pre" + toJSON Ul = String "Ul" + toJSON Ol = String "Ol" + toJSON Li = String "Li" + toJSON Section = String "Section" + toJSON Header = String "Header" + toJSON Footer = String "Footer" + toJSON Nav = String "Nav" + toJSON Article = String "Article" + toJSON H1 = String "H1" + toJSON H2 = String "H2" + toJSON H3 = String "H3" + toJSON H4 = String "H4" + toJSON Strong = String "Strong" + toJSON Em = String "Em" + toJSON Table = String "Table" + toJSON Thead = String "Thead" + toJSON Tbody = String "Tbody" + toJSON Tr = String "Tr" + toJSON Td = String "Td" + toJSON Th = String "Th" + toJSON Form = String "Form" + toJSON Label = String "Label" + toJSON Button = String "Button" + toJSON Fieldset = String "Fieldset" + toJSON Legend = String "Legend" + toJSON Dl = String "Dl" + toJSON Dt = String "Dt" + toJSON Dd = String "Dd" + toJSON Figure = String "Figure" + toJSON Figcaption = String "Figcaption" + toJSON A = String "A" + +instance FromJSON ChildHavingHtmlTag where + parseJSON (String "Div") = pure Div + parseJSON (String "Span") = pure Span + parseJSON (String "P") = pure P + parseJSON (String "Pre") = pure Pre + parseJSON (String "Ul") = pure Ul + parseJSON (String "Ol") = pure Ol + parseJSON (String "Li") = pure Li + parseJSON (String "Section") = pure Section + parseJSON (String "Header") = pure Header + parseJSON (String "Footer") = pure Footer + parseJSON (String "Nav") = pure Nav + parseJSON (String "Article") = pure Article + parseJSON (String "H1") = pure H1 + parseJSON (String "H2") = pure H2 + parseJSON (String "H3") = pure H3 + parseJSON (String "H4") = pure H4 + parseJSON (String "Strong") = pure Strong + parseJSON (String "Em") = pure Em + parseJSON (String "Table") = pure Table + parseJSON (String "Thead") = pure Thead + parseJSON (String "Tbody") = pure Tbody + parseJSON (String "Tr") = pure Tr + parseJSON (String "Td") = pure Td + parseJSON (String "Th") = pure Th + parseJSON (String "Form") = pure Form + parseJSON (String "Label") = pure Label + parseJSON (String "Button") = pure Button + parseJSON (String "Fieldset") = pure Fieldset + parseJSON (String "Legend") = pure Legend + parseJSON (String "Dl") = pure Dl + parseJSON (String "Dt") = pure Dt + parseJSON (String "Dd") = pure Dd + parseJSON (String "Figure") = pure Figure + parseJSON (String "Figcaption") = pure Figcaption + parseJSON (String "A") = pure A + + parseJSON _ = fail "Expected JSON String for ChildHavingHtmlTag deserialization" + +data ChildlessHtmlTag + = Hr + | Br + | Img + | Input + | Wbr + deriving (Eq, Enum, Bounded, Show) + +instance ToJSON ChildlessHtmlTag where + toJSON Hr = String "Hr" + toJSON Br = String "Br" + toJSON Img = String "Img" + toJSON Input = String "Input" + toJSON Wbr = String "Wbr" + +instance FromJSON ChildlessHtmlTag where + parseJSON (String "Hr") = pure Hr + parseJSON (String "Br") = pure Br + parseJSON (String "Img") = pure Img + parseJSON (String "Input") = pure Input + parseJSON (String "Wbr") = pure Wbr + + parseJSON _ = fail "Expected JSON String for ChildlessHtmlTag deserialization" + +instance Arbitrary ChildlessHtmlTag where + arbitrary = chooseBoundedEnum + + +data HTML + = Elem ChildHavingHtmlTag [ HtmlAttribute ] [ HTML ] + | VoidElem ChildlessHtmlTag [ HtmlAttribute ] + | Text MisoString + | UserSuppliedElement + deriving (Eq, Show) + +instance ToJSON HTML where + toJSON (Text s) = + object + [ "type" .= ("Text" :: MisoString) + , "value" .= s + ] + + toJSON (Elem tag attrs children) = + object + [ "type" .= ("Elem" :: MisoString) + , "tag" .= tag + , "attrs" .= attrs + , "children" .= children + ] + + toJSON (VoidElem tag attrs) = + object + [ "type" .= ("VoidElem" :: MisoString) + , "tag" .= tag + , "attrs" .= attrs + ] + + toJSON UserSuppliedElement = + object + [ "type" .= ("UserSuppliedElement" :: MisoString) + ] + + +instance FromJSON HTML where + parseJSON (Object o) = do + typ <- (o .: "type") :: Parser MisoString + + case typ of + "Text" -> Text <$> o .: "value" + + "Elem" -> Elem <$> o .: "tag" + <*> o .: "attrs" + <*> o .: "children" + + "VoidElem" -> VoidElem <$> o .: "tag" + <*> o .: "attrs" + + "UserSuppliedElement" -> pure UserSuppliedElement + _ -> fail $ "Unknown HTML constructor type: " ++ show typ + + parseJSON _ = fail "Expected JSON Object for HTML deserialization" + + +instance Arbitrary HTML where + arbitrary = genHtml + + +type MkTag model action = [ Attribute action ] -> [ View model action ] -> View model action +type MkTag2 model action = [ Attribute action ] -> View model action + + +nextGenerator :: ChildHavingHtmlTag -> Gen ChildHavingHtmlTag +nextGenerator Ul = return Li +nextGenerator Ol = return Li +nextGenerator Table = return Tbody +nextGenerator Thead = return Tr +nextGenerator Tbody = return Tr +nextGenerator Tr = elements [ Th, Td ] +nextGenerator Th = safeInlineElem +nextGenerator Td = anyElem +nextGenerator Dl = elements [ Dt, Dd ] +nextGenerator Dt = safeInlineElem +nextGenerator Dd = safeInlineElem +nextGenerator H1 = safeInlineElem +nextGenerator H2 = safeInlineElem +nextGenerator H3 = safeInlineElem +nextGenerator H4 = safeInlineElem +nextGenerator Span = safeInlineElem +nextGenerator Strong = safeInlineElem +nextGenerator Em = safeInlineElem +nextGenerator Label = safeInlineElem +nextGenerator Button = safeInlineElem +nextGenerator Legend = safeInlineElem +nextGenerator A = safeInlineElem +nextGenerator P = safeInlineElem +nextGenerator Pre = safeInlineElem +nextGenerator _ = anyElem + + +render :: View model action -> HTML -> View model action +render v (Elem tag attrs children) + = t tag (renderAttrs attrs) (map (render v) children) +render _ (VoidElem tag attrs) + = vt tag (renderAttrs attrs) +render _ (Text s) = text s +render v UserSuppliedElement = v + + +renderAttrs :: [ HtmlAttribute ] -> [ Attribute action ] +renderAttrs = map renderAttr + + +renderAttr :: HtmlAttribute -> Attribute action +renderAttr = uncurry mkAttr + + +mkAttr :: HtmlAttributeType -> MisoString -> Attribute action +mkAttr Class = class_ +mkAttr Id = id_ +mkAttr Title = title_ +mkAttr Colspan = colspan_ +mkAttr Rowspan = rowspan_ +mkAttr Action = action_ +mkAttr Method = method_ +mkAttr Src = src_ +mkAttr Alt = alt_ +mkAttr Value = value_ +mkAttr Type = type_ +mkAttr Checked = checked_ . read . fromMisoString + + +-- get appropriate miso constructor for element having children +t :: ChildHavingHtmlTag -> MkTag model action +t Div = div_ +t Span = span_ +t P = p_ +t Pre = pre_ +t Ul = ul_ +t Ol = ol_ +t Li = li_ +t Section = section_ +t Header = header_ +t Footer = footer_ +t Nav = nav_ +t Article = article_ +t H1 = h1_ +t H2 = h2_ +t H3 = h3_ +t H4 = h4_ +t Strong = strong_ +t Em = em_ +t Table = table_ +t Thead = thead_ +t Tbody = tbody_ +t Tr = tr_ +t Td = td_ +t Th = th_ +t Form = form_ +t Label = label_ +t Button = button_ +t Fieldset = fieldset_ +t Legend = legend_ +t Dl = dl_ +t Dt = dt_ +t Dd = dd_ +t Figure = figure_ +t Figcaption = figcaption_ +t A = a_ + + +safeBlockTags :: [ ChildHavingHtmlTag ] +-- Header, Footer, H2-H4 +safeBlockTags = [ Div, P, Pre, Ul, Ol, Section, Nav, Article, + H1, Table, Fieldset, Figure] + + +inlineTags :: [ ChildHavingHtmlTag ] +-- inlineTags = [Span, Strong, Em, Label, Button, A] +inlineTags = [Span, Strong, Em, Label] + + +safeInlineTags :: [ ChildHavingHtmlTag ] +safeInlineTags = [Span, Strong, Em] + + +anyElem :: Gen ChildHavingHtmlTag +anyElem = frequency $ [ (10, elements safeBlockTags), (1, elements inlineTags) ] + + +safeInlineElem :: Gen ChildHavingHtmlTag +safeInlineElem = elements safeInlineTags + + +tagRequiresChildren :: [ ChildHavingHtmlTag ] +tagRequiresChildren = [ Table, Ol, Ul, Dl, Tbody, Thead, Tr ] + + +-- get appropriate miso constructor for childless elem +vt :: ChildlessHtmlTag -> MkTag2 model action +vt Hr = hr_ +vt Br = br_ +vt Img = img_ +vt Input = input_ +vt Wbr = wbr_ + + +genHtml :: Gen HTML +genHtml = sized $ \n -> genSubtree n False (elements safeBlockTags) + + +genSubtree :: Int -> Bool -> Gen ChildHavingHtmlTag -> Gen HTML +genSubtree depth genSiblings gen + | depth <= 1 && genSiblings = genLeaf + | depth <= 1 = return UserSuppliedElement + | otherwise = do + nonVoidTag <- gen + siblingCount <- choose (0, depth) + siblings <- replicateM siblingCount $ + do + siblingTag <- nextGenerator nonVoidTag + siblingAttrs <- getAttributeGen siblingTag + siblingContent <- + if elem siblingTag tagRequiresChildren then + genSubtree 2 True (nextGenerator siblingTag) + else + genLeaf + return $ Elem siblingTag siblingAttrs [ siblingContent ] + + attrs <- getAttributeGen nonVoidTag + + let j = if elem nonVoidTag tagRequiresChildren then 0 else 1 + children <- genSubtree (depth - j) False (nextGenerator nonVoidTag) + + return $ Elem nonVoidTag attrs $ siblings ++ [ children ] + + +genLeaf :: Gen HTML +genLeaf = oneof + [ genText + , do + voidTag <- arbitrary `suchThat` (/= Hr) -- hr tag causes issues + VoidElem voidTag <$> getVoidAttributeGen voidTag + ] + + +getAttributeGen :: ChildHavingHtmlTag -> Gen [ HtmlAttribute ] +getAttributeGen Table = tableAttributes +getAttributeGen Td = tableAttributes +getAttributeGen Th = tableAttributes +getAttributeGen _ = baseAttributes + + +getVoidAttributeGen :: ChildlessHtmlTag -> Gen [ HtmlAttribute ] +getVoidAttributeGen Img = imageAttributes +getVoidAttributeGen Input = inputAttributes +getVoidAttributeGen _ = baseAttributes + + +baseAttributes :: Gen [ HtmlAttribute ] +baseAttributes = do + attrs <- sublistOf + [ (Class,) <$> genCssIdent + -- , (Id,) <$> genCssIdent + , (Title,) <$> genSafeMisoString + ] + sequence attrs + + +tableAttributes :: Gen [ HtmlAttribute ] +tableAttributes = baseAttributes + + +tableCellAttributes :: Gen [ HtmlAttribute ] +tableCellAttributes = do + base <- baseAttributes + col <- frequency [(3, pure []), (1, pure [(Colspan, "2")])] + row <- frequency [(3, pure []), (1, pure [(Rowspan, "2")])] + return (base ++ col ++ row) + + +formAttributes :: Gen [ HtmlAttribute ] +formAttributes = do + base <- baseAttributes + return (base ++ [(Action, "/submit"), (Method, "post")]) + + +imageAttributes :: Gen [ HtmlAttribute ] +imageAttributes = baseAttributes >>= + return . + (++ + [ (Src, placeholderImage) + , (Alt, placeholderAltText) + ]) + + +inputAttributes :: Gen [ HtmlAttribute ] +inputAttributes = do + typ <- elements ["text", "password", "checkbox", "radio", "submit", "number", "email", "tel"] + base <- baseAttributes + return $ (Type, typ) : base ++ (addValue typ) + + where + -- addValue "checkbox" = [(Value, "on"), (Checked, "True")] + -- addValue "radio" = [(Value, "option1"), (Checked, "True")] + addValue "checkbox" = [(Value, "on")] + addValue "radio" = [(Value, "option1")] + addValue "submit" = [(Value, "Submit")] + addValue "email" = [(Value, "email@example.com")] + addValue "tel" = [(Value, "+1 (555)-5555")] + addValue "number" = [(Value, "12345")] + addValue _ = [(Value, "test-value")] + + +chooseBoundedEnum :: (Bounded a, Enum a) => Gen a +chooseBoundedEnum = elements [minBound .. maxBound] + + +genText :: Gen HTML +genText = Text . toMisoString <$> genUnicodeString + + +-- genUnicodeString :: Gen String +-- genUnicodeString = listOf1 $ elements ">\"" + + +-- | Generate Unicode strings for text nodes +genUnicodeString :: Gen String +genUnicodeString = listOf1 $ oneof + [ choose ('a','z') + , choose ('A','Z') + , choose ('0','9') + , elements " .,!?-_@#$%^&*()[]{}<>|\\/:;\"'" + , choose ('\192','\255') -- Latin-1 supplement + , choose ('\1024','\1279') -- Cyrillic + , choose ('\1280','\1327') -- Greek + , choose ('\2304','\2431') -- Devanagari + -- Emojis and pictographs + , choose ('\x1F600','\x1F64F') -- Emoticons + , choose ('\x1F300','\x1F5FF') -- Miscellaneous Symbols and Pictographs + , choose ('\x1F680','\x1F6FF') -- Transport and Map Symbols + , choose ('\x1F900','\x1F9FF') -- Supplemental Symbols and Pictographs + -- Additional language blocks + , choose ('\x0400','\x04FF') -- Cyrillic (extended) + , choose ('\x0530','\x058F') -- Armenian + , choose ('\x0590','\x05FF') -- Hebrew + , choose ('\x0600','\x06FF') -- Arabic + , choose ('\x0900','\x097F') -- Devanagari (extended) + , choose ('\x3040','\x309F') -- Hiragana + , choose ('\x30A0','\x30FF') -- Katakana + , choose ('\x4E00','\x9FFF') -- CJK Unified Ideographs (common Chinese/Japanese characters) + -- Symbols and special characters + , choose ('\x2100','\x214F') -- Letterlike Symbols + , choose ('\x2190','\x21FF') -- Arrows + , choose ('\x2200','\x22FF') -- Mathematical Operators + , choose ('\x25A0','\x25FF') -- Geometric Shapes + , choose ('\x2600','\x26FF') -- Miscellaneous Symbols + , choose ('\x2700','\x27BF') -- Dingbats + , choose ('\x20A0','\x20CF') -- Currency Symbols + ] `suchThat` (\c -> not (isControl c) && c /= '\0' && c /= '\x200B' && c /= '\xFEFF') + + +genCssIdent :: Gen MisoString +genCssIdent = toMisoString <$> do + len <- choose (1, 15) + first <- elements $ ['a'..'z'] ++ ['A'..'Z'] ++ ['_'] + rest <- replicateM (len - 1) $ elements $ ['a'..'z'] ++ ['A'..'Z'] ++ ['0'..'9'] ++ "_-" + return (first:rest) + + +genSafeMisoString :: Gen MisoString +genSafeMisoString = toMisoString <$> genSafeString + + +genSafeString :: Gen String +genSafeString = listOf1 $ oneof + [ choose ('a','z') + , choose ('A','Z') + , choose ('0','9') + , elements " .,!?-_" + ] `suchThat` (not . isSpace) + + +placeholderImage :: MisoString +placeholderImage = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" + + +placeholderAltText :: MisoString +placeholderAltText = "Test image" diff --git a/tests/app/TestApp.hs b/tests/app/TestApp.hs new file mode 100644 index 000000000..0dacbc4ed --- /dev/null +++ b/tests/app/TestApp.hs @@ -0,0 +1,61 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE OverloadedStrings #-} + +module TestApp where + +import Miso + ( App + , component + , View + , consoleLog + , io_ + , LogLevel (..) + , Effect + , ROOT + , MisoString + ) +import Miso.DSL ((#)) +import qualified Miso as M +import qualified Miso.Html as M +import qualified Miso.Html.Property as M +import Miso.JSON (FromJSON, ToJSON) +import GHC.Generics +import Control.Monad (void) + +import HtmlGen (HTML, render) + +data Action = Initialize | Clicked + +type MainComponent = App Model Action + +type MainView = View Model Action + +newtype Model = Model + { randomHtml :: HTML + } deriving (Generic, ToJSON, FromJSON, Eq) + +app :: Model -> MainComponent +app td = + (component td update view) + { M.mount = Just Initialize + , M.logLevel = DebugAll + } + + where + update :: Action -> Effect ROOT Model Action + update Clicked = + io_ $ consoleLog "SUCCESS" + + update Initialize = io_ $ do + mElem <- M.getElementById "CLICKME_CLICKME" + void $ mElem # ("click" :: MisoString) $ ([] :: [ MisoString ]) + + view :: Model -> MainView + view Model { randomHtml = html } = render aElem html + + aElem = M.a_ + [ M.id_ "CLICKME_CLICKME" + , M.onClick Clicked + ] + [ M.text "Click on this Element" ] diff --git a/tests/app/TestBindingsApp.hs b/tests/app/TestBindingsApp.hs new file mode 100644 index 000000000..be002ac8d --- /dev/null +++ b/tests/app/TestBindingsApp.hs @@ -0,0 +1,216 @@ +{- + - + - + - Generate a cyclical grpah: A -> B -> C -> A + - Let's say A gets clicked, changes A.a, + - B is bound to A.a, C is bound to B.a, + - C is bound to A.b. + - - does binding C and A happen in A or in C? + - + - Click gets caught in A's update + - - after state is changed, use notify to schedule an Action + - - in that update (for the next action, still in A), + - assert that A.b has been changed to A.a + -} + +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE OverloadedStrings #-} + +module TestBindingsApp where + +import Miso + ( View + , App + , Effect + , component + , text + , Component + , toMisoString + , mount_ + , io + , io_ + , consoleLog + , MisoString + , fromMisoString + ) +import qualified Miso as M +import qualified Miso.Html as M +import qualified Miso.Html.Property as M +import Miso.Lens (Lens, lens, (%=)) +import Miso.Binding ((-->), (<--)) +import GHC.Generics +import Miso.JSON (FromJSON, ToJSON) +import Miso.DSL ((#), (!)) + +data Action + = Initialize + | ClickButtons + | Assert + | OnClick + | AddB + +data Model = Model + { valueA :: Int + , valueB :: Int + } deriving (Generic, ToJSON, FromJSON, Eq) + + +valueALens :: Lens Model Int +valueALens = lens valueA (\m x -> m { valueA = x }) + + +valueBLens :: Lens Model Int +valueBLens = lens valueB (\m x -> m { valueB = x }) + + +type AppComponent = Component Model Model Action + + +rootApp :: Int -> App Model Action +rootApp depth = + (component initialModel update (rootView depth)) + { M.logLevel = M.DebugAll + , M.mount = Just Initialize + } + + +rootView :: Int -> Model -> View Model Action +rootView depth m = + M.div_ [] + ( + M.button_ + [ M.id_ "assert" + , M.onClick Assert + ] + [ text "Assert" ] + : M.button_ + [ M.id_ "CLICKME_CLICKME" + , M.onClick OnClick + ] + [ text "Click Me" ] + : modelElems (-1) m + ++ [ mount_ $ innerApp depth ] + ) + + +innerApp :: Int -> AppComponent +innerApp 0 = + (component initialModel update (view 0)) + { M.logLevel = M.DebugAll + , M.bindings = + [ valueALens --> valueALens + , valueBLens <-- valueALens + ] + } + +innerApp idx = + (component initialModel update (view idx)) + { M.logLevel = M.DebugAll + , M.bindings = + [ valueALens --> valueALens + , valueBLens <-- valueBLens + ] + } + + +initialModel :: Model +initialModel = Model 0 0 + + +clickById :: MisoString -> IO () +clickById elemId = do + e <- M.getElementById elemId + _ <- e # ("click" :: MisoString) $ ([] :: [ MisoString ]) + pure () + + +getTextContent :: MisoString -> IO (Maybe MisoString) +getTextContent elemId = do + e <- M.getElementById elemId + e ! "textContent" >>= M.fromJSVal + + +update :: Action -> Effect a Model Action +update OnClick = do + io_ $ consoleLog "CLICKED" + valueALens %= (+ 1) + +update AddB = do + io_ $ consoleLog "CLICKED B" + valueBLens %= (+ 1) + +update Initialize = io $ do + consoleLog "TestBindingsApp INITALIZE" + return ClickButtons + +update ClickButtons = io_ $ do + consoleLog "TestBindingsApp ClickButtons" + clickById "CLICKME_CLICKME" + clickById "click_innermost" + clickById "assert" + +update Assert = io_ $ do + mValueA0Text <- getTextContent "valueA-0" + mValueB0Text <- getTextContent "valueB-0" + + let + result = do + valueA0Text <- mValueA0Text + valueB0Text <- mValueB0Text + + let valueA0 = read $ fromMisoString valueA0Text + valueB0 = read $ fromMisoString valueB0Text + + return $ (valueA0, valueB0) == (expectedA, expectedB) + + case result of + Just True -> + consoleLog "SUCCESS" + x -> do + consoleLog "ERROR - TestBindingsApp Assertion FAIL" + consoleLog $ toMisoString $ show x + + where + expectedA = 1 :: Int + expectedB = 2 :: Int + + +view :: Int -> Model -> View Model Action +view 0 m = M.div_ [] + ( + M.button_ + [ M.onClick OnClick + , M.id_ "click_innermost" + ] + [ text "Clickme (innermost)" ] + : + modelElems 0 m + ) + +view idx m = + M.div_ [] + ( M.button_ [ M.onClick OnClick ] [ text "add a" ] + : M.button_ [ M.onClick AddB ] [ text "add b" ] + : modelElems idx m ++ [ mount_ $ innerApp (idx - 1) ] + ) + + +modelElems :: Int -> Model -> [ View Model Action ] +modelElems i m = + [ M.div_ [] + [ M.span_ [] [ "a:" ] + , M.span_ + [ M.id_ ("valueA-" <> toMisoString j) ] + [ text (toMisoString $ show $ valueA m) ] + ] + , M.div_ [] + [ M.span_ [] [ "b:" ] + , M.span_ + [ M.id_ ("valueB-" <> toMisoString j) ] + [ text (toMisoString $ show $ valueB m) ] + ] + ] + + where + j = i + 1 diff --git a/tests/app/TestClient.hs b/tests/app/TestClient.hs new file mode 100644 index 000000000..b1a850945 --- /dev/null +++ b/tests/app/TestClient.hs @@ -0,0 +1,85 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE OverloadedStrings #-} + +import Miso + ( consoleLog + , miso + , MisoString + , fromMisoString + , toMisoString + , defaultEvents + ) +import Miso.JSON (decode) +import Miso.DSL + ( jsg + , (#) + , fromJSVal + , JSVal + , isNull + , isUndefined + , (!) + ) + +import qualified TestApp as App +import qualified TestBindingsApp as AppB +import TestTypes + +import qualified Miso.JSON as MJ + +#ifdef WASM +foreign export javascript "hs_start" main :: IO () +#endif + +newtype Document = Document JSVal +newtype Element = Element JSVal +newtype ParentNode = ParentNode JSVal + +getDocument :: IO Document +getDocument = Document <$> jsg ("document" :: MisoString) + +querySelector :: ParentNode -> MisoString -> IO (Maybe Element) +querySelector (ParentNode n) s = + (Element <$>) <$> ((n # "querySelector" $ [s]) >>= maybeNullOrUndefined) + + where + maybeNullOrUndefined x = do + nullYes <- isNull x + + if nullYes then + return Nothing + else do + undefYes <- isUndefined x + + if undefYes then + return Nothing + else + return $ Just x + + +textContent :: Element -> IO (Maybe MisoString) +textContent (Element e) = e ! "textContent" >>= fromJSVal + +getScriptContents :: MisoString -> IO (Maybe MisoString) +getScriptContents className = do + doc <- (\(Document d) -> ParentNode d) <$> getDocument + + mElem <- querySelector doc $ "." <> (fromMisoString className) + + case mElem of + Nothing -> return Nothing + Just e -> (toMisoString <$>) <$> textContent e + +main :: IO () +main = do + rawTestData <- getScriptContents "initial-data" + + let mTestData = decode =<< rawTestData + + case mTestData of + Nothing -> consoleLog "ERROR - client couldn't load initial-data" + Just (TestAppModel appData) -> do + consoleLog "TestClient - load App TestAppModel" + miso defaultEvents $ const $ App.app appData + Just (TestBindingsModel depth) -> do + consoleLog $ "TestClient - load AppB TestBindingsModel " <> toMisoString (show depth) + miso defaultEvents $ const $ AppB.rootApp depth diff --git a/tests/app/TestServer.hs b/tests/app/TestServer.hs new file mode 100644 index 000000000..3528c352c --- /dev/null +++ b/tests/app/TestServer.hs @@ -0,0 +1,361 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE ExistentialQuantification #-} +{-# LANGUAGE TypeOperators #-} + +import Prelude hiding (writeFile, readFile) +import System.Directory (getCurrentDirectory) +import Data.Proxy +import Servant.Server + ( Server + , serve + , Handler + ) +import qualified Network.Wai as Wai +import Miso.Html + ( ToHtml (..) + , doctype_ + , html_ + , head_ + , meta_ + , body_ + , script_ + ) +import Miso.Html.Property + ( charset_ + , name_ + , content_ + , type_ + , class_ + , src_ + , language_ + , defer_ + ) +import Miso.Html.Element (title_) +import qualified Servant +import Servant.API +import Miso.String (toMisoString, fromMisoString) +import Servant.Miso.Html (HTML) +import Miso + ( MisoString + , mount_ + , App + ) +import qualified Network.Wai.Handler.Warp as Wai +import qualified Network.Wai.Middleware.RequestLogger as Wai +import System.Environment (lookupEnv) +import Test.QuickCheck + ( forAll + , arbitrary + , ioProperty + , Gen + , Property + , NonNegative (..), isSuccess, quickCheckResult + ) +import Miso.JSON (ToJSON, encode) +import Control.Concurrent (forkIO) +import Network.HTTP.Client + ( defaultManagerSettings + , newManager + , httpLbs + , parseRequest + , Response (responseStatus) + , responseBody + ) +import Network.HTTP.Types (statusCode) +import Control.Exception (bracket) +import Network.Socket + ( Socket + , openSocket + , defaultHints + , close + , AddrInfoFlag (AI_PASSIVE) + , SocketType (Stream) + , getAddrInfo + , AddrInfo (..) + , SocketOption (ReuseAddr) + , setSocketOption, bind, listen + ) +import System.Exit (exitSuccess, exitFailure) + +import qualified HtmlGen as Html +import qualified TestApp as App +import qualified TestBindingsApp as AppB +import TestTypes (TestData (..)) + +-- Misnomer - this is the backend the front-end was compiled with +data Backend = GHCJS | WASM deriving (Show, Read) + +data EnvSettings = EnvSettings + { serve_static_dir_path :: FilePath + , port :: Int + , playwrightPort :: Int + , backend :: Backend + } deriving Show + +type ServerRoutes model action = + Routes (Get '[HTML] (IndexPageData model action)) + +data IndexPageData model action = + (ToJSON model, Eq model) => + IndexPageData (Backend, TestData, App model action) + +type RouteIndexPage a = a +type Routes a = RouteIndexPage a + +type StaticRoute = "static" :> Servant.Raw + +type API model action = StaticRoute :<|> ServerRoutes model action + +instance ToHtml (IndexPageData model action) where + toHtml (IndexPageData (backend_, initial_data, app)) = toHtml + [ doctype_ + , html_ + [] + [ head_ + [] + [ meta_ [ charset_ "utf-8" ] + , meta_ + [ name_ "viewport" + , content_ "width=device-width, initial-scale=1.0" + ] + , script_ + [ class_ "initial-data" + , type_ "application/json" + ] + (encode initial_data) + --(toMisoString $ toStrict $ encodeToLazyText initial_data) + + , title_ [] [ "Miso Tests" ] + , js backend_ + ] + , body_ [] [ mount_ app ] + ] + ] + + where + static_root :: MisoString + static_root = "/static" + + js WASM = js_wasm $ static_root <> "/init_integration_wasm_client.js" + js GHCJS = js_js $ static_root <> "/all.js" + + js_wasm href = + script_ + [ type_ "module" + , src_ $ toMisoString href + ] + "" + + js_js href = + script_ + [ language_ "javascript" + , src_ $ toMisoString href + , defer_ True + ] + "" + + +server + :: (ToJSON model, Eq model) + => EnvSettings + -> Proxy (API model action) + -> App model action + -> TestData + -> Wai.Application +server envSettings apiProxy app testData = + serve + apiProxy + (staticHandler :<|> mainHandler envSettings app testData) + + where + staticHandler :: Server StaticRoute + staticHandler = Servant.serveDirectoryFileServer (serve_static_dir_path envSettings) + + +mainHandler + :: (ToJSON model, Eq model) + => EnvSettings + -> App model action + -> TestData + -> Handler (IndexPageData model action) +mainHandler envSettings app testData = pure $ + IndexPageData (backend envSettings, testData, app) + + +httpGet :: String -> IO Int +httpGet url = do + httpManager <- newManager defaultManagerSettings + request <- parseRequest url + response <- httpLbs request httpManager + print $ responseBody response + return $ statusCode $ responseStatus response + + +openTCPSocketOnPort :: Int -> IO Socket +openTCPSocketOnPort p = do + addrInfo:_ <- getAddrInfo (Just hints) Nothing (Just $ show p) + sock <- openSocket addrInfo + setSocketOption sock ReuseAddr 1 + bind sock (addrAddress addrInfo) + listen sock 2048 + return sock + + where + hints = + defaultHints + { addrFlags = [ AI_PASSIVE ] + , addrSocketType = Stream + } + + +ok :: Int -> Bool +ok responseCode = 200 <= responseCode && responseCode <= 300 + + +browserVsServer :: EnvSettings -> Wai.Application -> IO Bool +browserVsServer envSettings appServer = ok <$> + bracket + ( do + putStrLn $ "Beginning to listen on " <> show port_ + sock <- openTCPSocketOnPort port_ + + let settings = Wai.setPort port_ Wai.defaultSettings + + _ <- forkIO $ Wai.runSettingsSocket + settings + sock $ + Wai.logStdout + appServer + + putStrLn "forked thread, returning sock" + return sock + ) + (\sock -> do + putStrLn "Closing socket" + close sock + ) + ( const $ do + -- threadDelay 30_000_000 -- one second + httpGet $ + "http://localhost:" + ++ show (playwrightPort envSettings) + ++ "/test?port=" ++ show port_ + ++ "&wait=true" + ) + + where + port_ = port envSettings + + +prop_testHtmlHydration :: EnvSettings -> Property +prop_testHtmlHydration envSettings = forAll (arbitrary :: Gen Html.HTML) $ + \html -> ioProperty $ do + let appData = App.Model { App.randomHtml = html } :: App.Model + browserVsServer envSettings + ( server + envSettings + (Proxy @(API App.Model App.Action)) + (App.app appData) + (TestAppModel appData) + ) + + +-- testHtmlHydration :: EnvSettings -> IO () +-- testHtmlHydration envSettings = do +-- let +-- -- html = Elem H1 [] [Elem Span [] [Elem Span [(Title,"6-H")] [Elem Em [(Class,"LmLO4Czl")] [VoidElem Wbr [(Title,"-.QVYNE")]],Elem Span [(Class,"ePZ-YyeK")] [Text "\1334\8362_"],Elem Span [(Class,"kBVC7Ik0zQu058"),(Title,"Os-,_4mmpl")] [Text "\1510&\242\128710\8691\12506\10089\8359"],Elem Strong [] [Text "\2356\2421d\8522"],Elem Span [(Title,"Pn2rg1l!B9")] [Text "\128206\127984\1301\12398\1093\1375\128719"],Elem Strong [(Title,"9_DMUaQ")] [VoidElem Br [(Class,"xy_hH6VhVaL5Ms2"),(Title,"0zy")]],Elem Span [(Class,"QnuOsYmAAoQfs5"),(Title,"P")] [Elem Span [] [Text "\9664\1291"],Elem Em [] [VoidElem Img [(Class,"i0Tx9eGMFM6"),(Title,"W"),(Src,"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),(Alt,"Test image")]],Elem Span [(Class,"ur7v-S"),(Title,"1y!6q,")] [VoidElem Img [(Class,"a3HmfHAa6Hclz1"),(Title,"2!hwS"),(Src,"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),(Alt,"Test image")]],Elem Strong [] [Elem Strong [] [VoidElem Br []],Elem Em [(Class,"EyeNkCWBpM_i5")] [Text "\8807\9648\128503\2425\9633\10110\8501\9893l"],Elem Strong [(Class,"JHiTcqsJkM")] [Elem Strong [(Class,"rtHRO"),(Title,"gthM9Wh")] [Text "\127819\1724IC"],Elem Em [(Class,"E")] [Text "L\128710\1303\12452\1782\1193A\9632"],Elem Em [(Title,"gl_JbHBn33")] [Elem Em [] [Elem Em [(Class,"aP-RGU"),(Title,"6")] [VoidElem Br []],Elem Strong [(Class,"KeuGxrRq")] [VoidElem Br []],Elem Strong [(Class,"oe1Ie_W-P"),(Title,"o,I")] [Elem Strong [(Title,"Zg")] [VoidElem Input [(Type,"password"),(Title,"31TJ"),(Value,"test-value")]],Elem Em [(Title,"0")] [Text "\9707a\40653\\\1366\128577\1312\8378\12441"],UserSuppliedElement]]]]]]]]] +-- html = Elem Em [] [Text "\\"] +-- -- Elem P [(Title,"rua,y_0o")] [Elem Span [] [Text "\22301\128565\206\1151\8886\10101\9770\9638\10120\&0\2390\8759\1083"],Elem Em [] [Text "\128733\128683\8617\128039\8495\1515\1171"],Elem Strong [] [Text "\128548\129319\9749\127764\2424\8371"],Elem Strong [] [Text "\1180\8393G\10129"],Elem Span [(Title,".")] [VoidElem Br [(Class,"XXy_qbW")]],Elem Strong [(Class,"o1AkUGVJnCWQn")] [Text "j3\128085\10051\8379D"],Elem Span [(Title,"?gq02?")] [VoidElem Input [(Type,"submit"),(Title,"FMIYqzU?"),(Value,"Submit")]],Elem Strong [(Class,"TFh"),(Title,"o209t.R0,L")] [VoidElem Input [(Type,"tel"),(Value,"+1 (555)-5555")]],Elem Strong [(Class,"T2lt")] [VoidElem Br []],Elem Span [] [Text "\10098"],Elem Span [] [Elem Span [(Title,"q?o0E")] [VoidElem Br [(Title,"!0Hw9_Alhr6v")]],Elem Span [(Class,"oOwYA6hu")] [VoidElem Input [(Type,"tel"),(Class,"bCji"),(Title,"?H,?2"),(Value,"+1 (555)-5555")]],Elem Strong [] [VoidElem Img [(Title,"?_p?-o7im03FP5"),(Src,"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),(Alt,"Test image")]],Elem Span [(Title,"I_77N?I")] [VoidElem Br [(Class,"i2TJt8"),(Title,"-??!VJ4")]],Elem Span [(Title,"0yWM73-340ZA")] [VoidElem Img [(Class,"ax9TN7"),(Title,"Ir"),(Src,"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),(Alt,"Test image")]],Elem Em [(Class,"kR8mXB-U")] [Text "\1319\129470\8365x\12429\9665\128670\1322\8647\8487\1043\1636\204\1049"],Elem Em [] [VoidElem Br [(Class,"g0qUbb1Ofy7kW")]],Elem Span [(Class,"moLy")] [VoidElem Br [(Title,"5_aoFy.hVA66!d")]],Elem Em [(Title,"8v.J")] [Elem Span [(Title,"O4")] [VoidElem Wbr [(Class,"zy2uv9NaRaWIB"),(Title,"E?f")]],Elem Strong [(Class,"jHTG8pX"),(Title,"B,QnO")] [Text "\8513\128519\&1j*\8927,\128655"],Elem Span [(Title,"W_we0W")] [VoidElem Input [(Type,"text"),(Class,"pCP93yjtiV"),(Title,"7f-R8!Am-ZRAq"),(Value,"test-value")]],Elem Em [] [Text "\8457\12509\&0\1223\1322\8735b\1207"],Elem Em [] [Text "\1324c\2326\1252\8655\8389\9763\8366\10011\1243\36401"],Elem Span [(Title,"xNpY.780izW.s77")] [VoidElem Input [(Type,"submit"),(Value,"Submit")]],Elem Strong [(Class,"tjE3o40zyEbAXRu")] [VoidElem Br [(Title,"_M9em")]],Elem Strong [(Class,"VF"),(Title,"X6ivn!wD2T!yO2")] [Elem Strong [(Class,"MKhACiUAgQ")] [Text "\1122\25205\20866\1707\12491"],Elem Em [(Class,"abDeu0ApaAnL1E")] [Text "\37407"],Elem Em [(Class,"Hrj-E")] [VoidElem Img [(Class,"AZTw-YC-ng0b"),(Src,"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),(Alt,"Test image")]],Elem Strong [(Class,"c")] [Elem Em [(Class,"qwBETfK9")] [Text "\12484\128667u\8378\127747\12447\2407\8653\2335\128659"],Elem Strong [(Title,"9n4J?_Q-?U132R-")] [Elem Strong [] [VoidElem Img [(Class,"XjjuiEqhrViO"),(Title,".5SdlJ_pl5?G7g"),(Src,"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),(Alt,"Test image")]],Elem Em [] [Elem Strong [(Title,"p_r__RHQ!-")] [VoidElem Wbr [(Class,"eCi")]],Elem Em [] [Text "\2390F\8384\128641\21103\2366\&2\9921\12360"],Elem Em [] [Text "\10115\9684\1205\128562\128557\2425\129481\1668\8610\1395\8744\9779T\2370\1131"],Elem Strong [] [Text "\128541\128554\2328\8499\2379\128719\9762m\8635\8522e\12404\&2"],Elem Span [(Title,"4!Z")] [VoidElem Wbr [(Title,"LVF0.m02O?1W")]],Elem Em [] [Text "\8462\1315\12524\9687\9904\8676\9651\10073\129440"],Elem Strong [] [Elem Em [(Title,"4eZ.97i!?vg-!")] [VoidElem Wbr [(Class,"kv_p_f"),(Title,"2?I-.i1L.5")]],Elem Strong [(Class,"mel")] [Text "\213\9723\9982\1302\8864\9647\8504\1175u\10015\1317\9954"],Elem Strong [] [VoidElem Img [(Class,"gLIA"),(Title,"_,!DT8FI84llS3"),(Src,"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),(Alt,"Test image")]],Elem Em [(Class,"StAN")] [Text "\9637\1328j\1474\1226\23929\1307\\\12391\8355q\1121"],Elem Em [] [VoidElem Wbr [(Class,"CuCu"),(Title,"eqqTwq1")]],Elem Span [(Title,"y")] [VoidElem Wbr [(Title,",,3G_qW,uL")]],Elem Span [(Class,"Xjz")] [Text "\1607\12414\1409\10051\128068\128726\9922"],Elem Strong [(Title,"F-9jWcc?")] [Elem Strong [(Title,"4fIdmc.a4zH_w")] [Text "\8780\12524\8370\1733\1206\9718\1666\1317"],Elem Strong [] [Text "\127950\128298O\12419\128573\1741"],Elem Span [] [Text "\2378\9688\128733\2336\&3\9691\128545C"],Elem Strong [(Class,"L1Hk")] [VoidElem Br []],Elem Span [(Class,"WBxSfTycC"),(Title,"2ep7XJ")] [Text "k\9691\1135\1303\2325\12532"],Elem Strong [(Class,"wRt6")] [VoidElem Wbr [(Class,"Col6SMGYqEk-")]],Elem Em [(Class,"RfzoV5cjq7i"),(Title,".P59vX")] [VoidElem Input [(Type,"tel"),(Value,"+1 (555)-5555")]],Elem Span [(Class,"N")] [Elem Strong [(Class,"HRPs8Ca")] [Text "\9639B\1162\10032\8698\128057\12493\2313\8658\1521"],Elem Strong [(Class,"eGX"),(Title,"?5-cdAD!p")] [Text "\9922"],Elem Strong [] [Elem Strong [(Class,"bwe"),(Title,"S.")] [Text "\128724\8736\1076\10073\8515\10016\12396\12360\1153\12475\2391\128532\1333"],Elem Span [(Class,"ux")] [Elem Em [(Class,"am"),(Title,"y,17L!3BOqB-")] [VoidElem Wbr []],Elem Em [(Title,"2D")] [Elem Em [(Class,"s")] [VoidElem Br [(Class,"UtRyGO91ZF")]],Elem Strong [(Class,"xWzzA1"),(Title,"67py05EvO3L")] [Text "\2408\8517s\129518"],Elem Span [(Title,"ZN")] [Elem Span [(Class,"x-Tfp8mO6R")] [Text "\1202\1223\128572"],UserSuppliedElement]]]]]]]]]]]]]] +-- -- uncaught exception in Haskell main thread: SyntaxError: Unexpected token 'で', ...":"▥԰jׂӊ嵹ԛ\で₣qѡ"}],"t"... is not valid JSON +-- appData = App.Model { App.randomHtml = html } :: App.Model +-- +-- putStrLn "testHtmlHydration" +-- print html +-- print $ ((eitherDecode $ encode html) :: Either MisoString Html.HTML) +-- -- putStrLn $ fromMisoString $ encode $ toMisoString v +-- +-- putStrLn $ "Beginning to listen on " <> show port_ +-- Wai.run port_ $ Wai.logStdout +-- ( server +-- envSettings +-- (Proxy @(API App.Model App.Action)) +-- (App.app appData) +-- (TestAppModel appData) +-- ) +-- +-- where +-- port_ = port envSettings + + +prop_testBindings :: EnvSettings -> Property +prop_testBindings envSettings = forAll (arbitrary :: Gen (NonNegative Int)) $ + \(NonNegative depth) -> ioProperty $ do + -- putStrLn $ "Beginning to listen on " <> show port_ + -- Wai.run port_ $ Wai.logStdout + -- ( server + -- envSettings + -- (Proxy @(API AppB.Model AppB.Action)) + -- (AppB.rootApp depth) + -- (TestBindingsModel depth) + -- ) + -- return $ 1 == (1 :: Int) + + -- where + -- port_ = port envSettings + + browserVsServer envSettings + ( server + envSettings + (Proxy @(API AppB.Model AppB.Action)) + (AppB.rootApp depth) + (TestBindingsModel depth) + ) + + +runProps :: [Property] -> IO () +runProps props = do + results <- mapM quickCheckResult props + if all isSuccess results + then exitSuccess + else exitFailure + + +main :: IO () +main = do + let s = "\9707a\40653\\\1366\128577\1312\8378\12441" :: MisoString + let v = "\9707a\40653\\\1366\128577\1312\8378\12441" :: String + putStrLn v + putStrLn $ fromMisoString $ encode s + putStrLn $ fromMisoString $ encode $ toMisoString v + --exitFailure + + staticDir_ <- lookupEnv "STATIC_DIR" + staticDir <- + case staticDir_ of + Nothing -> do + cwd <- getCurrentDirectory + return $ cwd <> "/static" + Just d -> return d + + portStr <- lookupEnv "PORT" + playwrightPortStr <- lookupEnv "PLAYWRIGHT_PORT" + backendStr <- lookupEnv "BACKEND" + + let envSettings = EnvSettings + { serve_static_dir_path = staticDir + , port = maybe 8888 read portStr + , playwrightPort = maybe 8889 read playwrightPortStr + , backend = maybe GHCJS read backendStr + } + + putStrLn $ "TestServer settings: " ++ show envSettings + + putStrLn "Begin Quickchecks" + -- testHtmlHydration envSettings + runProps + [ prop_testHtmlHydration envSettings + , prop_testBindings envSettings + ] diff --git a/tests/app/TestTypes.hs b/tests/app/TestTypes.hs new file mode 100644 index 000000000..63bfc1fa6 --- /dev/null +++ b/tests/app/TestTypes.hs @@ -0,0 +1,44 @@ +{-# LANGUAGE OverloadedStrings #-} + +module TestTypes where + +import Miso (MisoString) +import Miso.JSON + ( FromJSON (..) + , ToJSON (..) + , Value (..) + , (.:) + , (.=) + , object + , Parser + ) + +import qualified TestApp as TA + +data TestData + = TestAppModel TA.Model + | TestBindingsModel Int + deriving Eq + +instance ToJSON TestData where + toJSON (TestAppModel x) = + object + [ "tag" .= ("TestAppModel" :: MisoString) + , "contents" .= x + ] + toJSON (TestBindingsModel n) = + object + [ "tag" .= ("TestBindingsModel" :: MisoString) + , "contents" .= n + ] + +instance FromJSON TestData where + parseJSON (Object obj) = do + tag <- (obj .: "tag") :: Parser MisoString + + case tag of + "TestAppModel" -> TestAppModel <$> ((obj .: "contents") :: Parser TA.Model) + "TestBindingsModel" -> TestBindingsModel <$> obj .: "contents" + _ -> fail "Unknown constructor tag for TestData" + + parseJSON _ = fail "Expected JSON object for TestData" diff --git a/tests/miso-tests.cabal b/tests/miso-tests.cabal index 92a3832a0..ea460ddf1 100644 --- a/tests/miso-tests.cabal +++ b/tests/miso-tests.cabal @@ -54,7 +54,15 @@ common options executable component-tests import: - options, cpp + warnings, + options, + cpp + + if impl(ghcjs) || arch(javascript) || arch(wasm32) + buildable: True + else + buildable: False + main-is: Main.hs build-depends: @@ -66,8 +74,84 @@ executable component-tests if arch(javascript) || impl(ghcjs) cpp-options: - -DGHCJS_BROWSER + -dghcjs_browser + + default-language: + Haskell2010 + +executable integration-tests-client + import: + warnings, + options, + cpp + if impl(ghcjs) || arch(javascript) || arch(wasm32) + buildable: True + else + buildable: False + + hs-source-dirs: + app + + build-depends: + base, + text, + miso, + QuickCheck, + hashable, + lens + + main-is: + TestClient.hs + + other-modules: + TestApp + HtmlGen + TestBindingsApp + TestTypes + + default-language: + Haskell2010 + +executable integration-tests-server + import: + warnings, + cpp + + if impl(ghcjs) || arch(javascript) || arch(wasm32) + buildable: False + else + buildable: True + + main-is: + TestServer.hs + + other-modules: + TestApp + HtmlGen + TestBindingsApp + TestTypes + + build-depends: + base, + text, + miso, + warp, + wai-extra, + wai, + servant, + servant-server, + servant-miso-router, + servant-miso-html, + directory, + QuickCheck, + hashable, + http-client, + http-types, + bytestring, + network + hs-source-dirs: + app default-language: Haskell2010 diff --git a/tests/static/init_integration_wasm_client.js b/tests/static/init_integration_wasm_client.js new file mode 100644 index 000000000..39aeadc0f --- /dev/null +++ b/tests/static/init_integration_wasm_client.js @@ -0,0 +1,25 @@ +import { WASI, OpenFile, File, ConsoleStdout } from "https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.3.0/dist/index.js"; +import ghc_wasm_jsffi from "./wasm.js"; +import "./miso.js"; + +const args = []; +const env = ["GHCRTS=-H64m"]; +const fds = [ + new OpenFile(new File([])), // stdin + ConsoleStdout.lineBuffered((msg) => console.log(`[WASI stdout] ''${msg}`)), + ConsoleStdout.lineBuffered((msg) => console.warn(`[WASI stderr] ''${msg}`)), +]; +const options = { debug: false }; +const wasi = new WASI(args, env, fds, options); + +const instance_exports = {}; +const wasm_response = await fetch("/static/integration-client.wasm"); +const wasm_bytes = await wasm_response.arrayBuffer(); +const { instance } = await WebAssembly.instantiate(wasm_bytes, { + wasi_snapshot_preview1: wasi.wasiImport, + ghc_wasm_jsffi: ghc_wasm_jsffi(instance_exports), +}); +Object.assign(instance_exports, instance.exports); + +wasi.initialize(instance); +await instance.exports.hs_start(globalThis.example); diff --git a/ts/playwright.ts b/ts/playwright.ts index cb8e294c1..f6d5d8d0d 100644 --- a/ts/playwright.ts +++ b/ts/playwright.ts @@ -1,21 +1,412 @@ +const http = require('http'); +const url = require('url'); const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); -(async () => { - const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage(); - - // Capture console output - page.on('console', (msg) => { - if (msg.text() === "SUCCESS") { - browser.close (); - process.exit(0); - } else if (msg.text() === "ERROR") { - browser.close (); - process.exit(1); - } else { - console.log(msg.text()); +let browser; +let server; +let isShuttingDown = false; +let isReady = false; +const TEST_TIMEOUT = 10000; +const WAIT_TIMEOUT = 15000; +const NAVIGATION_RETRY_COUNT = 3; // Max retries for navigation errors +const NAVIGATION_RETRY_DELAY = 30; // Delay between retries (ms) + +// ====================== +// READINESS MANAGEMENT +// ====================== +const READY_FILE = process.env.READY_FILE || '/tmp/playwright-server-ready'; +const PORT = parseInt(process.env.PORT || '3000'); + +function markAsReady() { + isReady = true; + + // Create ready file if requested + if (process.argv.includes('--write-ready-file')) { + try { + fs.writeFileSync(READY_FILE, `${Date.now()}`); + console.log(`✅ Ready file created at: ${READY_FILE}`); + } catch (e) { + console.error('❌ Failed to write ready file:', e.message); + } + } + + console.log(`✅ Server is READY and accepting connections on port ${PORT}`); +} + +function cleanupReadyFile() { + if (fs.existsSync(READY_FILE)) { + try { + fs.unlinkSync(READY_FILE); + console.log(`🧹 Cleaned up ready file: ${READY_FILE}`); + } catch (e) { + console.error('❌ Failed to clean up ready file:', e.message); + } + } +} + +// ====================== +// BROWSER MANAGEMENT +// ====================== +async function initializeBrowser() { + browser = await chromium.launch({ headless: true }); + console.log('✅ Browser launched successfully'); +} + +async function closeBrowser() { + if (!browser) return; + try { + await browser.close(); + console.log('✅ Browser closed successfully'); + } catch (e) { + console.error('❌ Error closing browser:', e.message); + } +} + +// ====================== +// TEST EXECUTION CORE +// ====================== +async function executeTestCore(page, port, timeout) { + return new Promise(async (resolve) => { + let testCompleted = false; + let hardTimeoutId; + + const finish = (status, error = null) => { + if (testCompleted) return; + testCompleted = true; + clearTimeout(hardTimeoutId); + page.removeListener('console', consoleHandler); + page.removeListener('pageerror', errorHandler); + resolve({ status, error }); + }; + + const consoleHandler = (msg) => { + if (testCompleted) return; + let text = msg.text(); + console.log(text); + + // Normalize known debug noise + if (text === '[DEBUG_HYDRATE] Could not copy DOM into virtual DOM, falling back to diff') { + text = 'ERROR'; + } + + if (text === 'SUCCESS' || text === 'ERROR') { + finish(text); + } + }; + + const errorHandler = (err) => { + if (testCompleted) return; + console.error(`[PORT ${port}] Page error:`, err.message); + finish('ERROR', err.message); + }; + + page.on('console', consoleHandler); + page.on('pageerror', errorHandler); + + // Hard timeout: fail fast if nothing happens + hardTimeoutId = setTimeout(() => { + finish('TIMEOUT', `No console signal received within ${timeout}ms`); + }, timeout); + + try { + await page.goto(`http://127.0.0.1:${port}`, { + timeout: 5000, // Short timeout to get initial response + waitUntil: 'commit' // Return as soon as HTML starts arriving + }); + + // Optional: If your app sets a global flag when ready, wait for that instead + // await page.waitForFunction(() => window.TEST_READY === true, { timeout: 8000 }).catch(() => {}); + + } catch (navError) { + // Navigation failed entirely (e.g., connection refused) + finish('NAVIGATION_ERROR', navError.message); } }); - await page.goto('http://127.0.0.1:8080'); +} + +async function runTestInBackground(port) { + if (isShuttingDown) { + console.log(`[PORT ${port}] ❌ Skipped - Server shutting down`); + return; + } + + let page; + try { + page = await browser.newPage(); + console.log(`[PORT ${port}] 🧪 Background test started`); + + const result = await executeTestCore(page, port, TEST_TIMEOUT); + + console.log(`[PORT ${port}] ✅ Background test result: ${result.status}`); + await safePageClose(page, port, 'background_test'); + } catch (e) { + console.error(`[PORT ${port}] ❌ Background test failed:`, e.message); + if (page) await safePageClose(page, port, 'background_error'); + } +} + +async function runTestAndWait(port) { + let page; + try { + page = await browser.newPage(); + console.log(`[PORT ${port}] ⏳ Synchronous test started`); + + const result = await executeTestCore(page, port, WAIT_TIMEOUT); + + await safePageClose(page, port, 'synchronous_test'); + console.log(`[PORT ${port}] ✅ Synchronous test completed with: ${result.status}`); + return result; + } catch (e) { + console.error(`[PORT ${port}] ❌ Synchronous test failed:`, e.message); + if (page) await safePageClose(page, port, 'sync_error'); + throw e; + } +} + +async function safePageClose(page, port, reason) { + if (!page) return; + try { + await page.close(); + console.log(`[PORT ${port}] 📄 Page closed (${reason})`); + } catch (e) { + console.error(`[PORT ${port}] ❌ Error closing page (${reason}):`, e.message); + } +} + +// ====================== +// ENDPOINT HANDLERS +// ====================== +function handleReadinessCheck(req, res) { + if (isReady) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ready', + message: 'Server is fully initialized and ready', + port: PORT, + timestamp: new Date().toISOString() + })); + } else { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'initializing', + message: 'Server is still starting up', + port: PORT + })); + } +} + +async function handleTestRequest(req, res, port, waitForResult = false) { + if (!isReady) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ + error: 'Service Unavailable', + message: 'Server is still initializing' + })); + } + + if (waitForResult) { + try { + const result = await runTestAndWait(port); + + switch (result.status) { + case 'SUCCESS': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'success', + port, + message: 'Test passed successfully' + })); + break; + case 'ERROR': + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'error', + port, + message: 'Test failed with ERROR console message' + })); + break; + case 'TIMEOUT': + res.writeHead(504, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'timeout', + port, + message: `Test timed out after ${WAIT_TIMEOUT/1000} seconds` + })); + break; + case 'NAVIGATION_ERROR': + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'navigation_error', + port, + message: result.error, + details: 'Failed to load test page' + })); + break; + default: + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'unknown', + port, + message: 'Unknown test result' + })); + } + } catch (e) { + console.error(`[PORT ${port}] Synchronous test crashed:`, e.message); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'server_error', + port, + message: 'Test execution crashed', + error: e.message + })); + } + } else { + console.log(`[PORT ${port}] 🚀 Test triggered (background)`); + setImmediate(() => runTestInBackground(port).catch(e => + console.error(`[PORT ${port}] Background test error:`, e.message) + )); + + res.writeHead(202, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'accepted', + message: `Test started for port ${port} (running in background)`, + port + })); + } +} + +async function handleShutdownRequest(req, res) { + console.log('🛑 Shutdown requested'); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'shutting_down', + message: 'Server shutdown initiated' + })); + + setImmediate(initiateShutdown); +} + +// ====================== +// SERVER MANAGEMENT +// ====================== +async function initiateShutdown() { + if (isShuttingDown) return; + isShuttingDown = true; + + console.log('⏳ Initiating graceful shutdown...'); + cleanupReadyFile(); + + server.close(() => { + console.log('🔌 HTTP server closed'); + }); + + try { + await closeBrowser(); + console.log('👋 Server shutdown complete'); + process.exit(0); + } catch (e) { + console.error('💥 Critical shutdown error:', e.message); + process.exit(1); + } +} + +function validatePort(portParam) { + if (!portParam || isNaN(portParam)) { + return { valid: false, error: 'Missing or invalid port parameter' }; + } + + const port = parseInt(portParam, 10); + if (port < 1 || port > 65535) { + return { valid: false, error: 'Port must be between 1 and 65535' }; + } + + return { valid: true, port }; +} + +function parseBooleanParam(param) { + if (!param) return false; + return ['true', '1', 'yes', 'on'].includes(param.toLowerCase()); +} + +// ====================== +// SERVER SETUP +// ====================== +async function startServer() { + try { + // Cleanup any stale ready file from previous runs + cleanupReadyFile(); + + await initializeBrowser(); + + server = http.createServer(async (req, res) => { + const parsedUrl = url.parse(req.url, true); + const { pathname, query } = parsedUrl; + + // Global shutdown guard + if (isShuttingDown && pathname !== '/shutdown') { + res.writeHead(503, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ + error: 'Service Unavailable', + message: 'Server is shutting down' + })); + } + + // Handle readiness check first + if (req.method === 'GET' && pathname === '/ready') { + return handleReadinessCheck(req, res); + } + + // Route other requests + if (req.method === 'GET' && pathname === '/test') { + const portValidation = validatePort(query.port); + if (!portValidation.valid) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ error: portValidation.error })); + } + + const waitForResult = parseBooleanParam(query.wait); + return handleTestRequest(req, res, portValidation.port, waitForResult); + } + + if (req.method === 'POST' && pathname === '/shutdown') { + return handleShutdownRequest(req, res); + } + + // Default 404 + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'Not Found', + endpoints: { + ready: 'GET /ready - Check server readiness', + test: 'GET /test?port=&wait=', + shutdown: 'POST /shutdown' + } + })); + }); + + server.listen(PORT, () => { + markAsReady(); + }); + + // Handle process signals + process.on('SIGINT', initiateShutdown); + process.on('SIGTERM', initiateShutdown); + + // Cleanup on exit + process.on('exit', cleanupReadyFile); + } catch (e) { + console.error('💥 Fatal startup error:', e.message); + cleanupReadyFile(); + process.exit(1); + } +} -})(); +// Start everything +startServer().catch(e => { + console.error('💥 Server startup failed:', e.message); + cleanupReadyFile(); + process.exit(1); +});