module Test.PagesProgram exposing ( ProgramTest, Step , Test, test, describe, toTest, toNamedSnapshots , expect, snapshots, runSteps, applyStep , clickButton, clickButtonWith, clickLink, fillIn, fillInTextarea, check , selectOption , pressEnter, pressKey , simulateDomEvent , ensureViewHas, ensureViewHasNot, ensureView , withinFind , group , represent , navigateTo, ensureBrowserUrl , ensureBrowserHistory , simulateHttpGet, simulateHttpPost, simulateHttpPostWith, simulateHttpError , simulateCustom, simulateCustomWith , ensureHttpGet, ensureHttpPost, ensureHttpPostWith, ensureCustom, ensureCustomWith , withSimulatedSubscriptions, simulateIncomingPort , Snapshot, withModelInspector , start, fromConfig ) {-| Write pure tests for your elm-pages Route Modules. These tests will not actually perform any HTTP requests or outside interactions (from BackendTask, or from a Route's `init`/`update` function). For any `BackendTask` that can't be simulated (i.e. outside world interactions like HTTP requests), you pass in the fake data to simulate the real world result using the [`Test.BackendTask`](Test-BackendTask) API. Because the test framework simulates the core pieces of the browser and elm-pages runtime, including cookies, URL state, and navigation, your tests realistically check end-to-end behavior deterministically and efficiently: - [`BackendTask`](BackendTask) data loading and actions, with simulated responses for [`BackendTask.Http`](BackendTask-Http) and [`BackendTask.Custom`](BackendTask-Custom) - [`Pages.Form`](Pages-Form) submissions, including [concurrent submissions](Pages-Form#withConcurrent) - Optimistic UI via [`Pages.ConcurrentSubmission`](Pages-ConcurrentSubmission) - assert on the view while fetchers are still in-flight - [`Server.Session`](Server-Session) and [`Server.SetCookie`](Server-SetCookie) - cookies persist across navigations and form submissions within a test - [`Server.Response`](Server-Response) redirects are followed automatically, with cookie propagation - Server-rendered [Error Page](https://elm-pages.com/docs/error-pages) responses are rendered and can be tested like any other page - Client-side TEA interactions via [`SimulatedEffect`](Test-PagesProgram-SimulatedEffect) - `Effect.sendMsg` dispatches through the update cycle ## Quick start Say you have a route that fetches GitHub stars via a BackendTask: -- app/Route/Index.elm type alias Data = { stars : Int } data : BackendTask FatalError Data data = BackendTask.Http.getJson "https://api.github.com/repos/dillonkearns/elm-pages" (Decode.field "stargazers_count" Decode.int) |> BackendTask.allowFatal |> BackendTask.map Data view app _ _ = { title = "Home" , body = [ Html.text ("Stars: " ++ String.fromInt app.data.stars) ] } Like a regular `elm-test` suite, you build named tests with [`test`](#test) and group them with [`describe`](#describe). Each test takes a starting `TestApp.ProgramTest` and a `List` of [`Step`](#Step) values; the framework runs the steps and finalizes the test for you. The exposed value is a `Test.PagesProgram.Test`, and `elm-pages` discovers it by name (typically `suite`). module MyTests exposing (suite) import Json.Encode as Encode import Test.BackendTask as BackendTaskTest import Test.Html.Selector as Selector import Test.PagesProgram as PagesProgram import TestApp suite : PagesProgram.Test suite = PagesProgram.describe "GitHub stars" [ PagesProgram.test "renders the star count from the API" (TestApp.start "/" BackendTaskTest.init) [ PagesProgram.simulateHttpGet "https://api.github.com/repos/dillonkearns/elm-pages" (Encode.object [ ( "stargazers_count", Encode.int 9999 ) ]) , PagesProgram.ensureViewHas [ Selector.text "Stars: 9999" ] ] ] The `TestApp` module is generated by `elm-pages`, you just import it and then use it to begin your test program pipelines. Tests can be run via the `elm-pages test` CLI or viewed in the [visual test runner](#visual-test-runner) through the dev server. You can run tests headlessly (via elm-test) or view them in the visual test runner in the browser. ```sh npx elm-pages test ## Or view it in your dev server npx elm-pages dev open http://localhost:1234/_tests ``` ## Visual Test Runner The visual test runner lets you time travel through each step of a test in the browser. While the test run itself is pure (no HTTP requests to the outside world, etc.), the view is rendered with your full Vite configuration so any styles are applied as they would be in your dev server or production build. The visual test runner also includes inspectors for the network requests, form fetcher state, model, and other tools that let you inspect what's going on with your app during any given step. ![Visual Test Runner](https://raw.githubusercontent.com/dillonkearns/elm-pages/visual-test-runner/docs/visual-test-runner.png) @docs ProgramTest, Step ## Building test suites Build a named test tree just like `elm-test`. [`test`](#test) names a single test from a starting `ProgramTest` and a list of steps; [`describe`](#describe) groups related tests under a shared heading. Use [`toTest`](#toTest) when you want to run the same tree through plain `elm-test` without the `elm-pages` wrapper CLI; [`toNamedSnapshots`](#toNamedSnapshots) flattens the tree for the visual runner. @docs Test, test, describe, toTest, toNamedSnapshots ## Other runners Use these when you don't want to wrap a single test in [`test`](#test) - for example, in plain `elm-test` `\() -> ...` bodies or when feeding the visual runner directly. [`runSteps`](#runSteps) is the lower-level primitive used to thread a list of steps through a `ProgramTest` without finalizing it; the generated `TestApp` module uses it to bake extras like a model inspector into its `start`. @docs expect, snapshots, runSteps, applyStep ## Simulating user input `Test.ProgramTest` follows a philosophy (inspired by `elm-program-test`) of driving the browser in a way that mirrors how a user would interact with your elm-pages app. That gives us confidence that the scenarios in our automated tests match the real user experience. It also helps keep us honest about making our apps accessible and usable. @docs clickButton, clickButtonWith, clickLink, fillIn, fillInTextarea, check @docs selectOption @docs pressEnter, pressKey @docs simulateDomEvent ## View assertions @docs ensureViewHas, ensureViewHasNot, ensureView ## Scoping @docs withinFind ## Grouping @docs group ## Suite overview thumbnails @docs represent ## Navigation @docs navigateTo, ensureBrowserUrl @docs ensureBrowserHistory ## Simulating BackendTask responses When a `BackendTask` makes an HTTP request or calls a custom port, the test pauses until you provide a simulated response. These functions work regardless of where the `BackendTask` originated -- route data loading, form actions, or BackendTask effects returned from `update`. @docs simulateHttpGet, simulateHttpPost, simulateHttpPostWith, simulateHttpError @docs simulateCustom, simulateCustomWith ## Asserting on pending requests @docs ensureHttpGet, ensureHttpPost, ensureHttpPostWith, ensureCustom, ensureCustomWith ## Subscriptions and incoming ports @docs withSimulatedSubscriptions, simulateIncomingPort ## Snapshots Snapshots record the rendered view at each step of the test pipeline. The browser visual test runner (`elm-pages dev` at `/_tests`) uses them to let you step through test execution in the browser. @docs Snapshot, withModelInspector ## Starting a test The generated `TestApp` module in your project wraps [`start`](#start) with your app's config, so a typical test file writes: suite : PagesProgram.Test suite = PagesProgram.describe "Home" [ PagesProgram.test "renders the greeting" (TestApp.start "/my-page" BackendTaskTest.init) [ PagesProgram.ensureViewHas [ Selector.text "Hello" ] ] ] For tests that don't need a full elm-pages route module, use [`fromConfig`](#fromConfig) with a lightweight `{ data, init, update, view }` record instead. @docs start, fromConfig -} import BackendTask exposing (BackendTask) import Browser import Bytes import Bytes.Decode import Bytes.Encode import Dict import Expect exposing (Expectation) import FatalError exposing (FatalError) import Form import Form.Validation import Html exposing (Html) import Html.Attributes import Http import Internal.Request import Json.Decode import Json.Encode as Encode import PageServerResponse exposing (PageServerResponse(..)) import Pages.ConcurrentSubmission import Pages.Internal.FatalError import Pages.Internal.Msg import Pages.Internal.Platform as Platform import Pages.Internal.ResponseSketch as ResponseSketch import Pages.Internal.StaticHttpBody as StaticHttpBody import Pages.StaticHttp.Request as StaticHttpRequest import Regex import Test as ElmTest import Test.BackendTask exposing (HttpError(..)) import Test.BackendTask.Internal as BackendTaskTest import Test.Html.Event as Event import Test.Html.Query as Query import Test.Html.Selector as Selector import Test.PagesProgram.CookieJar as CookieJar exposing (CookieEntry, CookieJar) import Test.PagesProgram.Internal as Internal exposing ( AdvanceResult(..) , AssertionSelector(..) , FetcherStatus(..) , NetworkSource(..) , NetworkStatus(..) , Phase(..) , ProgramTest(..) , ReadyState , Resolver(..) , ResolverKind(..) , Simulation(..) , State , StepKind(..) , TargetSelector(..) , bodyToString , crashNever , describeEffects , describeHttpRequest , initialProgramTest , initialProgramTestWithEffects , mapViewToSnapshot , requestDetailsFromRequests , requestToDetails , resolveDataPhase , stillRunningDescription , unsafeCoerceHtmlList ) import Test.PagesProgram.SelectorLabel as SelectorLabel import Test.PagesProgram.SimulatedEffect as SimulatedEffect import Test.PagesProgram.SimulatedSub as SimulatedSub exposing (SimulatedSub) import Test.Runner import Test.Runner.Failure import Time import Url exposing (Url) import UrlPath {-| An in-progress elm-pages program test. Create one with [`start`](#start) (or [`fromConfig`](#fromConfig) for ad-hoc programs) and pass it to [`test`](#test) along with a list of [`Step`](#Step) values describing the user interactions and assertions. -} type alias ProgramTest model msg = Internal.ProgramTest model msg {-| A single step in a test pipeline - a click, a form fill, a simulated HTTP response, an assertion, and so on. Steps are opaque values; build them with the helpers in this module ([`clickButton`](#clickButton), [`ensureViewHas`](#ensureViewHas), [`simulateHttpGet`](#simulateHttpGet), and friends). Use [`group`](#group) and [`withinFind`](#withinFind) to nest a list of steps inside a labelled scope. incrementSteps : List (PagesProgram.Step model msg) incrementSteps = [ PagesProgram.ensureViewHas [ Selector.text "Count: 0" ] , PagesProgram.clickButton "+" , PagesProgram.ensureViewHas [ Selector.text "Count: 1" ] ] -} type alias Step model msg = Internal.Step model msg {-| Apply a single [`Step`](#Step) to a `ProgramTest`. Equivalent to [`runSteps`](#runSteps) with a singleton list. Useful when threading a `Step` value through a chain that already returns `ProgramTest`. End-user tests should prefer [`test`](#test) / [`expect`](#expect) over chaining directly. PagesProgram.expect (TestApp.start "/" BackendTaskTest.init |> PagesProgram.applyStep (PagesProgram.withModelInspector Debug.toString) ) [ PagesProgram.ensureViewHas [ Selector.text "Hello" ] ] -} applyStep : Step model msg -> ProgramTest model msg -> ProgramTest model msg applyStep (Internal.Step fn) = fn {-| Run a list of [`Step`](#Step)s against a `ProgramTest`, returning the new `ProgramTest`. Use this when you need to apply steps without finalizing the test (e.g., to bake setup into the result of a custom `start`); end-of-test finalization is what [`test`](#test), [`expect`](#expect), and [`snapshots`](#snapshots) do for you. PagesProgram.runSteps [ PagesProgram.simulateHttpGet "https://api.example.com/counter" counterDataResponse , PagesProgram.ensureViewHas [ Selector.text "Count: 0" ] ] (TestApp.start "/counter" BackendTaskTest.init) -} runSteps : List (Step model msg) -> ProgramTest model msg -> ProgramTest model msg runSteps steps initial = List.foldl (\(Internal.Step fn) pt -> fn pt) initial steps {-| A snapshot of the program state at a point in the test pipeline. You don't need to inspect this directly -- pass snapshots to [`Test.PagesProgram.Viewer.app`](Test-PagesProgram-Viewer#app) to view them in the browser. -} type alias Snapshot = Internal.Snapshot type alias StepKind = Internal.StepKind type alias NetworkEntry = Internal.NetworkEntry type alias NetworkStatus = Internal.NetworkStatus type alias TargetSelector = Internal.TargetSelector type alias NetworkSource = Internal.NetworkSource type alias FetcherEntry = Internal.FetcherEntry type alias FetcherStatus = Internal.FetcherStatus type alias RequestDefaults = { requestTime : Time.Posix , headers : Dict.Dict String String } -- START {-| Start a full-fidelity elm-pages test by driving `Pages.Internal.Platform` directly. The generated `TestApp` module wraps this with your app's `Main.config`, so the typical usage is: PagesProgram.expect (TestApp.start "/" BackendTaskTest.init) [ PagesProgram.ensureViewHas [ Selector.text "Hello" ] ] Where the generated `TestApp.start` is `PagesProgram.start Main.config`. You can seed the initial incoming request through [`Test.BackendTask`](Test-BackendTask) using [`withRequestCookie`](Test-BackendTask#withRequestCookie), [`withRequestHeader`](Test-BackendTask#withRequestHeader), and [`withRequestTime`](Test-BackendTask#withRequestTime). BackendTask resolution uses the `Test.BackendTask` virtual filesystem, so file reads, env vars, time, and other auto-resolvable BackendTasks work out of the box. File writes in actions automatically update the virtual FS, and subsequent data resolution sees the updated files. -} start : (effect -> SimulatedEffect.SimulatedEffect userMsg) -> Platform.ProgramConfig userMsg userModel route pageData actionData sharedData effect (Platform.Msg userMsg pageData actionData sharedData errorPage) errorPage -> String -> BackendTaskTest.TestSetup -> ProgramTest { platformModel : Platform.Model userModel pageData actionData sharedData , virtualFs : BackendTaskTest.VirtualFS , cookieJar : CookieJar , pendingDataError : Maybe String , pendingDataPath : Maybe String , pendingActionBody : Maybe { body : String, path : String } } (Platform.Msg userMsg pageData actionData sharedData errorPage) start simulateEffect config initialPath testSetup = let baseUrl = "https://localhost:1234" setup = case testSetup of BackendTaskTest.TestSetup wrappedSetup -> wrappedSetup initialUrl = makeTestUrl baseUrl initialPath requestDefaults : RequestDefaults requestDefaults = { requestTime = setup.requestTime |> Maybe.withDefault (Time.millisToPosix 0) , headers = setup.requestHeaders } initialCookieJar : CookieJar initialCookieJar = setup.requestCookies |> Dict.foldl CookieJar.set CookieJar.init initialVirtualFs = setup.virtualFS in case resolveInitialData config requestDefaults initialUrl initialPath initialCookieJar initialVirtualFs of InitialDataError errMsg -> ProgramTest { phase = Resolving (Resolver { kind = BackendResolver , advance = \_ _ -> AdvanceError errMsg , pendingDescription = errMsg , pendingUrls = [] , pendingRequestDetails = [] } ) , error = Just errMsg , snapshots = [] , modelToString = Nothing , fetcherExtractor = Nothing , cookieExtractor = Nothing , pendingFetcherEffects = [] , lastReadyModel = Nothing , networkLog = [] , subscriptions = Nothing } resolvedOrPending -> let pendingDescription fallback bt = case bt of BackendTaskTest.Running runningState -> stillRunningDescription runningState.pendingRequests _ -> fallback pendingInitialPhase fallback bt continue = Resolving (Resolver { kind = BackendResolver , advance = \_ sim -> continue (applySimToBt sim bt) , pendingDescription = pendingDescription fallback bt , pendingUrls = btPendingUrls bt , pendingRequestDetails = btPendingRequestDetails bt } ) continueResolvedInitial vfs updatedCookieJar pageDataBytes = let ( readyModel, readyEffect ) = platformUpdateClean config (Platform.FrozenViewsReady (Just pageDataBytes)) initModel ( wrapped, _, _ ) = processEffectsWrapped config baseUrl requestDefaults makeReady makePlatformResolver handleUserCmd { platformModel = readyModel, virtualFs = vfs, cookieJar = updatedCookieJar, pendingDataError = Nothing, pendingDataPath = Nothing, pendingActionBody = Nothing } readyEffect 100 in Advanced (Ready (makeReady wrapped)) Nothing [] continueInitialResult initialResult = case initialResult of InitialDataResolved vfs updatedCookieJar pageDataBytes -> continueResolvedInitial vfs updatedCookieJar pageDataBytes InitialSharedDataPending _ sharedBt -> Advanced (pendingInitialPhase "Initial shared data pending HTTP" sharedBt continueInitialSharedData) Nothing [] InitialHandleRoutePending _ sharedData handleBt -> Advanced (pendingInitialPhase "Initial handleRoute pending HTTP" handleBt (continueInitialHandleRoute sharedData)) Nothing [] InitialRouteDataPending _ sharedData dataBt -> Advanced (pendingInitialPhase "Initial data pending HTTP" dataBt (continueInitialData sharedData)) Nothing [] InitialDataError errMsg -> AdvanceError errMsg continueInitialSharedData sharedBt = case sharedBt of BackendTaskTest.Done doneState -> case doneState.result of Ok sharedData -> resolveInitialAfterSharedData config requestDefaults initialUrl initialPath initialCookieJar doneState.virtualFS sharedData |> continueInitialResult Err fatalErr -> AdvanceError ("Failed to resolve Shared.template.data: " ++ fatalErrorToString fatalErr) BackendTaskTest.Running _ -> Advanced (pendingInitialPhase "Initial shared data pending HTTP" sharedBt continueInitialSharedData) Nothing [] BackendTaskTest.TestError errMsg -> AdvanceError ("Failed to resolve Shared.template.data: " ++ errMsg) continueInitialHandleRoute sharedData handleBt = case handleBt of BackendTaskTest.Done doneState -> case doneState.result of Ok maybeNotFoundReason -> resolveInitialAfterHandleRoute config requestDefaults initialUrl initialPath initialCookieJar doneState.virtualFS sharedData maybeNotFoundReason |> continueInitialResult Err fatalErr -> AdvanceError ("Failed to resolve handleRoute: " ++ fatalErrorToString fatalErr) BackendTaskTest.Running _ -> Advanced (pendingInitialPhase "Initial handleRoute pending HTTP" handleBt (continueInitialHandleRoute sharedData)) Nothing [] BackendTaskTest.TestError errMsg -> AdvanceError ("Failed to resolve handleRoute: " ++ errMsg) continueInitialData sharedData bt = case bt of BackendTaskTest.Done doneState -> let updatedCookieJar : CookieJar updatedCookieJar = cookieJarAfterPageResponse initialCookieJar doneState.result in case extractPageData config doneState.result of Just pageData -> let encodedBytes = ResponseSketch.HotUpdate pageData sharedData Nothing |> encodeResponseWithPrefix config in continueResolvedInitial doneState.virtualFS updatedCookieJar encodedBytes Nothing -> case doneState.result of Ok (ServerResponse serverResponse) -> case PageServerResponse.toRedirect serverResponse of Just { location } -> let updatedJar = updatedCookieJar redirectUrl = makeTestUrl baseUrl (normalizePath location) redirectRoute = config.urlToRoute redirectUrl ( _, redirectDataBt ) = BackendTaskTest.resolveWithVirtualFsPartial doneState.virtualFS (config.data (platformTestRequest requestDefaults (Url.toString redirectUrl) updatedJar) redirectRoute) continueRedirectTargetData rdBt = case rdBt of BackendTaskTest.Done rdDoneState -> case extractPageData config rdDoneState.result of Just pageData -> let encodedBytes = ResponseSketch.HotUpdate pageData sharedData Nothing |> encodeResponseWithPrefix config redirectPlatformModel = { initModel | pendingFrozenViewsUrl = Just redirectUrl } ( readyModel, readyEffect ) = platformUpdateClean config (Platform.FrozenViewsReady (Just encodedBytes)) redirectPlatformModel ( processedWrapped, _, _ ) = processEffectsWrapped config baseUrl requestDefaults makeReady makePlatformResolver handleUserCmd { platformModel = readyModel, virtualFs = rdDoneState.virtualFS, cookieJar = updatedJar, pendingDataError = Nothing, pendingDataPath = Nothing, pendingActionBody = Nothing } readyEffect 100 in Advanced (Ready (makeReady processedWrapped)) Nothing [] Nothing -> AdvanceError "Failed to extract page data for redirect target" BackendTaskTest.Running _ -> Advanced (pendingInitialPhase "Initial redirect target data pending HTTP" rdBt continueRedirectTargetData) Nothing [] BackendTaskTest.TestError rdErrMsg -> AdvanceError rdErrMsg in continueRedirectTargetData redirectDataBt Nothing -> AdvanceError ("Unexpected server response with status " ++ String.fromInt serverResponse.statusCode) Err fatalErr -> let errorPageData = config.errorPageToData (config.internalError (fatalErrorToString fatalErr)) encodedBytes = ResponseSketch.HotUpdate errorPageData sharedData Nothing |> encodeResponseWithPrefix config in continueResolvedInitial doneState.virtualFS initialCookieJar encodedBytes _ -> AdvanceError "Failed to extract page data after initial HTTP simulation" BackendTaskTest.Running _ -> Advanced (pendingInitialPhase "Initial data pending HTTP" bt (continueInitialData sharedData)) Nothing [] BackendTaskTest.TestError errMsg -> AdvanceError errMsg initialPhase = case continueInitialResult resolvedOrPending of Advanced phase _ _ -> phase AdvanceError errMsg -> Resolving (Resolver { kind = BackendResolver , advance = \_ _ -> AdvanceError errMsg , pendingDescription = errMsg , pendingUrls = [] , pendingRequestDetails = [] } ) flags = Encode.object [] ( initModel, _ ) = Platform.init config flags initialUrl Nothing updateFn msg wrappedModel = let ( newPlatformModel, effectFromUpdate ) = platformUpdateClean config msg wrappedModel.platformModel ( processedWrapped, _, fetcherResolvers ) = processEffectsWrapped config baseUrl requestDefaults makeReady makePlatformResolver handleUserCmd { wrappedModel | platformModel = newPlatformModel } effectFromUpdate 100 in case processedWrapped.pendingDataError of Just _ -> { model = processedWrapped , effects = [] , pendingPhase = Just (makePlatformResolver config baseUrl processedWrapped makeReady) , fetcherResolvers = fetcherResolvers } Nothing -> { model = processedWrapped , effects = [] , pendingPhase = Nothing , fetcherResolvers = fetcherResolvers } makeReady m = { model = m , getView = viewFn , update = updateFn , pendingEffects = [] , onNavigate = Just (\href -> let targetUrl = makeTestUrl baseUrl href in Platform.LinkClicked (if isExternalNavigation baseUrl targetUrl then Browser.External href else Browser.Internal targetUrl ) ) , getBrowserUrl = Just (\m_ -> Url.toString m_.platformModel.url) , onFormSubmit = Just (\{ formId, action, fields, method, useFetcher } -> Platform.UserMsg (Pages.Internal.Msg.Submit { useFetcher = useFetcher , action = action , method = method , fields = fields , msg = Nothing , id = formId , valid = True } ) ) , getFormFields = Just (\m_ -> m_.platformModel.pageFormState |> Dict.values |> List.concatMap (\formState -> formState.fields |> Dict.toList |> List.map (\( k, v ) -> ( k, v.value )) ) ) , viewScope = identity , scopeLabels = [] , scopeSelectors = [] , getModelError = \m_ -> m_.pendingDataError } viewFn wrappedModel = let doc = Platform.view config wrappedModel.platformModel in { title = doc.title, body = doc.body } handleUserCmd wm userEffect md = processSimulatedEffect config baseUrl requestDefaults makeReady makePlatformResolver handleUserCmd wm (simulateEffect userEffect) md -- Create a Resolving phase for a platform model that paused on HTTP. -- Re-resolves the pending BackendTask to get the BackendTaskTest, -- then wraps it in a Resolver that uses Test.BackendTask's -- simulation mechanism (simulateHttpPost etc.) to advance. makePlatformResolver config_ baseUrl_ wrappedModel makeReady_ = let makePhase m = Ready (makeReady_ m) in case wrappedModel.pendingActionBody of Just { body, path } -> -- Action paused on HTTP let fetchUrl = makeTestUrl baseUrl_ path route = config_.urlToRoute fetchUrl actionRequest = Internal.Request.Request { time = requestDefaults.requestTime , method = "POST" , body = Just body , rawUrl = Url.toString fetchUrl , rawHeaders = requestHeaders requestDefaults [ ( "content-type", "application/x-www-form-urlencoded" ) ] , cookies = CookieJar.toDict wrappedModel.cookieJar } ( _, bt ) = BackendTaskTest.resolveWithVirtualFsPartial wrappedModel.virtualFs (config_.action actionRequest route) in Resolving (Resolver { kind = BackendResolver , advance = \_ sim -> continueActionWithBt config_ baseUrl_ requestDefaults makeReady_ makePlatformResolver handleUserCmd continueDataWithBt wrappedModel fetchUrl makePhase (applySimToBt sim bt) , pendingDescription = wrappedModel.pendingDataError |> Maybe.withDefault "Pending action HTTP" , pendingUrls = btPendingUrls bt , pendingRequestDetails = btPendingRequestDetails bt } ) Nothing -> case wrappedModel.pendingDataPath of Just path -> -- Data paused on HTTP let fetchUrl = makeTestUrl baseUrl_ path route = config_.urlToRoute fetchUrl ( _, bt ) = BackendTaskTest.resolveWithVirtualFsPartial wrappedModel.virtualFs (config_.data (platformTestRequest requestDefaults (Url.toString fetchUrl) wrappedModel.cookieJar) route) in Resolving (Resolver { kind = BackendResolver , advance = \_ sim -> continueDataWithBt wrappedModel makePhase (applySimToBt sim bt) , pendingDescription = wrappedModel.pendingDataError |> Maybe.withDefault "Pending data HTTP" , pendingUrls = btPendingUrls bt , pendingRequestDetails = btPendingRequestDetails bt } ) Nothing -> -- Shouldn't happen, but fall back to Ready makePhase wrappedModel -- Continue a data navigation once the BackendTaskTest resolves. -- makePhase converts a new model into the appropriate Phase. -- When Done, encodes the page data and dispatches FrozenViewsReady. -- When still Running, creates a Resolver that captures the current -- BackendTaskTest directly (no replay of previously-applied sims). continueDataWithBt wrappedModel makePhase bt = case bt of BackendTaskTest.Done doneState -> let vfsAfterData = doneState.virtualFS dataResult = doneState.result in case extractPageData config dataResult of Just pageData -> let encodedBytes = case wrappedModel.platformModel.pageData of Ok prevData -> ResponseSketch.HotUpdate pageData prevData.sharedData Nothing |> encodeResponseWithPrefix config Err _ -> ResponseSketch.RenderPage pageData Nothing |> encodeResponseWithPrefix config ( newPlatformModel, newEffect ) = platformUpdateClean config (Platform.FrozenViewsReady (Just encodedBytes)) wrappedModel.platformModel cleanedModel = { newPlatformModel | notFound = Nothing } ( processedWrapped, _, _ ) = processEffectsWrapped config baseUrl requestDefaults makeReady makePlatformResolver handleUserCmd { platformModel = cleanedModel, virtualFs = vfsAfterData, cookieJar = wrappedModel.cookieJar, pendingDataError = Nothing, pendingDataPath = Nothing, pendingActionBody = Nothing } newEffect 100 in case processedWrapped.pendingDataPath of Just dataPath -> let dataFetchUrl = makeTestUrl baseUrl dataPath dataRoute = config.urlToRoute dataFetchUrl ( _, dataBt ) = BackendTaskTest.resolveWithVirtualFsPartial processedWrapped.virtualFs (config.data (platformTestRequest requestDefaults (Url.toString dataFetchUrl) processedWrapped.cookieJar) dataRoute) dataMakePhase m = makePhase m in case dataBt of BackendTaskTest.Running _ -> Advanced (Resolving (Resolver { kind = BackendResolver , advance = \_ sim -> continueDataWithBt processedWrapped dataMakePhase (applySimToBt sim dataBt) , pendingDescription = processedWrapped.pendingDataError |> Maybe.withDefault "Pending data HTTP after navigation redirect" , pendingUrls = btPendingUrls dataBt , pendingRequestDetails = btPendingRequestDetails dataBt } ) ) Nothing [] BackendTaskTest.Done _ -> continueDataWithBt processedWrapped dataMakePhase dataBt BackendTaskTest.TestError errMsg -> AdvanceError errMsg Nothing -> Advanced (makePhase processedWrapped) Nothing [] Nothing -> -- Redirect or non-renderable response case dataResult of Ok (ServerResponse serverResponse) -> case PageServerResponse.toRedirect serverResponse of Just { location } -> let updatedJar = wrappedModel.cookieJar |> CookieJar.withSetCookieHeaders (extractSetCookieHeaders (ServerResponse serverResponse)) redirectUrl = makeTestUrl baseUrl (normalizePath location) redirectRoute = config.urlToRoute redirectUrl continueRedirectTargetData redirectBt = case redirectBt of BackendTaskTest.Done redirectDoneState -> case extractPageData config redirectDoneState.result of Just pageData -> let encodedBytes = case wrappedModel.platformModel.pageData of Ok previousPageData -> ResponseSketch.HotUpdate pageData previousPageData.sharedData Nothing |> encodeResponseWithPrefix config Err _ -> ResponseSketch.RenderPage pageData Nothing |> encodeResponseWithPrefix config redirectPlatformModel = let platformModel = wrappedModel.platformModel in { platformModel | pendingFrozenViewsUrl = Just redirectUrl } ( newPlatformModel, newEffect ) = platformUpdateClean config (Platform.FrozenViewsReady (Just encodedBytes)) redirectPlatformModel ( processedWrapped, _, _ ) = processEffectsWrapped config baseUrl requestDefaults makeReady makePlatformResolver handleUserCmd { platformModel = newPlatformModel, virtualFs = redirectDoneState.virtualFS, cookieJar = updatedJar, pendingDataError = Nothing, pendingDataPath = Nothing, pendingActionBody = Nothing } newEffect 100 in Advanced (makePhase processedWrapped) Nothing [] Nothing -> AdvanceError "Failed to extract page data for redirect target" BackendTaskTest.Running redirectRunningState -> Advanced (Resolving (Resolver { kind = BackendResolver , advance = \_ sim -> continueRedirectTargetData (applySimToBt sim redirectBt) , pendingDescription = stillRunningDescription redirectRunningState.pendingRequests , pendingUrls = List.map .url redirectRunningState.pendingRequests , pendingRequestDetails = requestDetailsFromRequests redirectRunningState.pendingRequests } ) ) Nothing [] BackendTaskTest.TestError errMsg -> AdvanceError errMsg ( _, redirectDataBt ) = BackendTaskTest.resolveWithVirtualFsPartial vfsAfterData (config.data (platformTestRequest requestDefaults (Url.toString redirectUrl) updatedJar) redirectRoute) in continueRedirectTargetData redirectDataBt Nothing -> AdvanceError ("Unexpected server response: " ++ String.fromInt serverResponse.statusCode) Err fatalErr -> -- Render the error page for FatalErrors (matching server behavior) let errorPageData = config.errorPageToData (config.internalError (fatalErrorToString fatalErr)) encodedBytes = case wrappedModel.platformModel.pageData of Ok prevData -> ResponseSketch.HotUpdate errorPageData prevData.sharedData Nothing |> encodeResponseWithPrefix config Err _ -> ResponseSketch.RenderPage errorPageData Nothing |> encodeResponseWithPrefix config ( newPlatformModel, newEffect ) = platformUpdateClean config (Platform.FrozenViewsReady (Just encodedBytes)) wrappedModel.platformModel ( processedWrapped, _, _ ) = processEffectsWrapped config baseUrl requestDefaults makeReady makePlatformResolver handleUserCmd { platformModel = newPlatformModel, virtualFs = vfsAfterData, cookieJar = wrappedModel.cookieJar, pendingDataError = Nothing, pendingDataPath = Nothing, pendingActionBody = Nothing } newEffect 100 in Advanced (makePhase processedWrapped) Nothing [] _ -> AdvanceError "Failed to resolve route data after HTTP simulation" BackendTaskTest.Running runningState -> Advanced (Resolving (Resolver { kind = BackendResolver , advance = \_ sim -> continueDataWithBt wrappedModel makePhase (applySimToBt sim bt) , pendingDescription = stillRunningDescription runningState.pendingRequests , pendingUrls = List.map .url runningState.pendingRequests , pendingRequestDetails = requestDetailsFromRequests runningState.pendingRequests } ) ) Nothing [] BackendTaskTest.TestError errMsg -> AdvanceError errMsg platformModelToString wrappedModel = case wrappedModel.platformModel.pageData of Ok pd -> config.pageModelToString pd.userModel Err err -> "(error: " ++ err ++ ")" initSnapshots = case initialPhase of Ready readyState -> let viewResult = readyState.getView readyState.model in [ { label = "start" , title = viewResult.title , body = (mapViewToSnapshot viewResult).body , rerender = \() -> mapViewToSnapshot (readyState.getView readyState.model) , hasPendingEffects = False , modelState = Just (platformModelToString readyState.model) , stepKind = Start , browserUrl = Just (Url.toString readyState.model.platformModel.url) , errorMessage = Nothing , pendingEffects = [] , networkLog = [] , targetElement = Nothing , assertionSelectors = [] , scopeSelectors = [] , fetcherLog = [] , cookieLog = CookieJar.entries readyState.model.cookieJar , groupLabel = Nothing , representative = False } ] Resolving _ -> [] extractFetchers wrappedModel = wrappedModel.platformModel.inFlightFetchers |> Dict.toList |> List.map (\( fetcherId, ( _, fetcher ) ) -> { id = fetcherId , status = case fetcher.status of Pages.ConcurrentSubmission.Submitting -> FetcherSubmitting Pages.ConcurrentSubmission.Reloading _ -> FetcherReloading Pages.ConcurrentSubmission.Complete _ -> FetcherComplete , fields = fetcher.payload.fields , action = fetcher.payload.action , method = case fetcher.payload.method of Form.Get -> "GET" Form.Post -> "POST" } ) extractCookies wrappedModel = CookieJar.entries wrappedModel.cookieJar in ProgramTest { phase = initialPhase , error = Nothing , snapshots = initSnapshots , modelToString = Just platformModelToString , fetcherExtractor = Just extractFetchers , cookieExtractor = Just extractCookies , pendingFetcherEffects = [] , lastReadyModel = Nothing , networkLog = [] , subscriptions = Nothing } -- SIMULATION {-| Provide a simulated JSON response for a pending HTTP GET request. Works for any pending `BackendTask.Http` request -- route data loading, form actions, or BackendTask effects returned from `update`. TestApp.start "/" BackendTaskTest.init |> PagesProgram.simulateHttpGet "https://api.example.com/user" (Encode.object [ ( "name", Encode.string "Alice" ) ]) |> PagesProgram.ensureViewHas [ Selector.text "Alice" ] -} simulateHttpGet : String -> Encode.Value -> Step model msg simulateHttpGet url jsonResponse = Internal.Step (applySimulation (SimHttpGet url jsonResponse)) {-| Simulate a pending `BackendTask.Custom.run` call resolving with the given JSON response. Provide the port name and the JSON value the port would return. TestApp.start "/" setup |> PagesProgram.simulateCustom "getTodos" (Encode.list todoEncoder myTodos) |> PagesProgram.ensureViewHas [ Selector.text "Buy milk" ] -} simulateCustom : String -> Encode.Value -> Step model msg simulateCustom portName jsonResponse = Internal.Step (applySimulation (SimCustom portName jsonResponse)) {-| Assert against the input args of a pending `BackendTask.Custom.run` call, and resolve it with the given response - in one step. Equivalent to [`ensureCustom`](#ensureCustom) followed by [`simulateCustom`](#simulateCustom), but reads as a single intent and only names the port once. import Expect import Json.Decode as Decode TestApp.start "/" BackendTaskTest.init |> PagesProgram.simulateCustomWith "hashPassword" (\args -> Decode.decodeValue Decode.string args |> Expect.equal (Ok "secret123") ) (Encode.string "hashed") If you want to assert without consuming the request (e.g. to peek at multiple pending calls before resolving any), use [`ensureCustomWith`](#ensureCustomWith) separately. If you don't care about the input, use the simpler [`simulateCustom`](#simulateCustom). -} simulateCustomWith : String -> (Encode.Value -> Expectation) -> Encode.Value -> Step model msg simulateCustomWith portName argsAssertion jsonResponse = Internal.Step (\pt -> pt |> applyStep (ensureCustomWith portName argsAssertion) |> applyStep (simulateCustom portName jsonResponse) ) {-| Simulate a pending HTTP POST request resolving with the given JSON response body. -} simulateHttpPost : String -> Encode.Value -> Step model msg simulateHttpPost url jsonResponse = Internal.Step (applySimulation (SimHttpPost url jsonResponse)) {-| Assert against the body of a pending HTTP POST request, and resolve it with the given response - in one step. Equivalent to [`ensureHttpPost`](#ensureHttpPost) followed by [`simulateHttpPost`](#simulateHttpPost), but reads as a single intent and only names the URL once. import Expect import Json.Decode as Decode TestApp.start "/" BackendTaskTest.init |> PagesProgram.simulateHttpPostWith "https://api.example.com/save" (\body -> Decode.decodeValue (Decode.field "name" Decode.string) body |> Expect.equal (Ok "Alice") ) (Encode.string "ok") If you want to assert without consuming the request, use [`ensureHttpPostWith`](#ensureHttpPostWith) separately. If you don't care about the body, use the simpler [`simulateHttpPost`](#simulateHttpPost). -} simulateHttpPostWith : String -> (Encode.Value -> Expectation) -> Encode.Value -> Step model msg simulateHttpPostWith url bodyAssertion jsonResponse = Internal.Step (\pt -> pt |> applyStep (ensureHttpPostWith url bodyAssertion) |> applyStep (simulateHttpPost url jsonResponse) ) {-| Simulate an HTTP error (network error or timeout) on a pending request. Use with `Test.BackendTask.HttpError`: import Test.BackendTask as BackendTaskTest exposing (HttpError(..)) TestApp.start "/" BackendTaskTest.init |> PagesProgram.simulateHttpError "GET" "https://api.example.com/data" NetworkError -} simulateHttpError : String -> String -> HttpError -> Step model msg simulateHttpError method url error = let errorString = case error of NetworkError -> "NetworkError" Timeout -> "Timeout" in Internal.Step (applySimulation (SimHttpError method url errorString)) -- PENDING REQUEST ASSERTIONS {-| Assert that a GET request to the given URL is currently pending. Use this before `simulateHttpGet` to verify the right request was made. TestApp.start "/" BackendTaskTest.init |> PagesProgram.ensureHttpGet "https://api.example.com/user" |> PagesProgram.simulateHttpGet "https://api.example.com/user" response -} ensureHttpGet : String -> Step model msg ensureHttpGet url = Internal.Step (ensurePendingRequest "ensureHttpGet" (\r -> r.method == "GET" && r.url == url) url) {-| Assert that a POST request to the given URL is currently pending. TestApp.start "/" BackendTaskTest.init |> PagesProgram.ensureHttpPost "https://api.example.com/submit" |> PagesProgram.simulateHttpPost "https://api.example.com/submit" response To also assert on the request body, use [`ensureHttpPostWith`](#ensureHttpPostWith) or the combined [`simulateHttpPostWith`](#simulateHttpPostWith). -} ensureHttpPost : String -> Step model msg ensureHttpPost url = Internal.Step (ensurePendingRequest "ensureHttpPost" (\r -> r.method == "POST" && r.url == url) url) {-| Assert that a POST request to the given URL is currently pending, and run an assertion on the request body. Does not resolve the request. import Expect import Json.Decode as Decode TestApp.start "/" BackendTaskTest.init |> PagesProgram.ensureHttpPostWith "https://api.example.com/submit" (\body -> Decode.decodeValue (Decode.field "name" Decode.string) body |> Expect.equal (Ok "Alice") ) |> PagesProgram.simulateHttpPost "https://api.example.com/submit" response The body is presented as a `Json.Encode.Value`. JSON request bodies decode faithfully; non-JSON bodies (binary, multipart) are passed through as `Encode.null`. -} ensureHttpPostWith : String -> (Encode.Value -> Expectation) -> Step model msg ensureHttpPostWith url bodyAssertion = Internal.Step (ensurePendingRequestWith "ensureHttpPostWith" (\r -> r.method == "POST" && r.url == url) (\r -> bodyAssertion (decodePendingBody r.body)) url ) {-| Assert that a `BackendTask.Custom.run` call with the given port name is currently pending. TestApp.start "/" BackendTaskTest.init |> PagesProgram.ensureCustom "getTodos" |> PagesProgram.simulateCustom "getTodos" response To also assert on the input arguments, use [`ensureCustomWith`](#ensureCustomWith) or the combined [`simulateCustomWith`](#simulateCustomWith). -} ensureCustom : String -> Step model msg ensureCustom portName = Internal.Step (ensurePendingRequest "ensureCustom" (\r -> r.url == "elm-pages-internal://port" && pendingPortName r == Just portName) portName ) {-| Assert that a `BackendTask.Custom.run` call with the given port name is currently pending, and run an assertion on the input arguments. Does not resolve the request. import Expect import Json.Decode as Decode TestApp.start "/" BackendTaskTest.init |> PagesProgram.ensureCustomWith "hashPassword" (\args -> Decode.decodeValue Decode.string args |> Expect.equal (Ok "secret123") ) |> PagesProgram.simulateCustom "hashPassword" (Encode.string "hashed") -} ensureCustomWith : String -> (Encode.Value -> Expectation) -> Step model msg ensureCustomWith portName argsAssertion = Internal.Step (ensurePendingRequestWith "ensureCustomWith" (\r -> r.url == "elm-pages-internal://port" && pendingPortName r == Just portName) (\r -> argsAssertion (decodePendingPortInput r.body)) portName ) decodePendingBody : Maybe String -> Encode.Value decodePendingBody body = case body of Just raw -> Json.Decode.decodeString Json.Decode.value raw |> Result.withDefault Encode.null Nothing -> Encode.null decodePendingPortInput : Maybe String -> Encode.Value decodePendingPortInput body = case body of Just raw -> Json.Decode.decodeString (Json.Decode.field "input" Json.Decode.value) raw |> Result.withDefault Encode.null Nothing -> Encode.null ensurePendingRequest : String -> ({ url : String, method : String, headers : List ( String, String ), body : Maybe String } -> Bool) -> String -> ProgramTest model msg -> ProgramTest model msg ensurePendingRequest callerName predicate target (ProgramTest state) = case state.error of Just _ -> ProgramTest state Nothing -> let allPending = gatherAllPendingRequestDetails state found = List.any predicate allPending in if found then ProgramTest state else ProgramTest { state | error = Just (noMatchingPendingRequestError callerName target allPending) } ensurePendingRequestWith : String -> ({ url : String, method : String, headers : List ( String, String ), body : Maybe String } -> Bool) -> ({ url : String, method : String, headers : List ( String, String ), body : Maybe String } -> Expectation) -> String -> ProgramTest model msg -> ProgramTest model msg ensurePendingRequestWith callerName predicate assertion target (ProgramTest state) = case state.error of Just _ -> ProgramTest state Nothing -> let allPending = gatherAllPendingRequestDetails state in case List.filter predicate allPending |> List.head of Just matched -> case Test.Runner.getFailureReason (assertion matched) of Nothing -> ProgramTest state Just failure -> ProgramTest { state | error = Just (callerName ++ " \"" ++ target ++ "\" assertion failed.\n\n" ++ formatFailureReason failure ) } Nothing -> ProgramTest { state | error = Just (noMatchingPendingRequestError callerName target allPending) } formatFailureReason : { given : Maybe String, description : String, reason : Test.Runner.Failure.Reason } -> String formatFailureReason { description, reason } = case reason of Test.Runner.Failure.Equality expected actual -> description ++ "\n\n" ++ actual ++ "\n╷\n│ " ++ description ++ "\n╵\n" ++ expected Test.Runner.Failure.Comparison expected actual -> description ++ "\n\n" ++ actual ++ "\n╷\n│ " ++ description ++ "\n╵\n" ++ expected Test.Runner.Failure.ListDiff expected actual -> description ++ "\n\nExpected: " ++ String.join ", " expected ++ "\nActual: " ++ String.join ", " actual Test.Runner.Failure.CollectionDiff diff -> description ++ "\n\nExpected: " ++ diff.expected ++ "\nActual: " ++ diff.actual Test.Runner.Failure.Custom -> description Test.Runner.Failure.TODO -> description Test.Runner.Failure.Invalid _ -> description noMatchingPendingRequestError : String -> String -> List { url : String, method : String, headers : List ( String, String ), body : Maybe String } -> String noMatchingPendingRequestError callerName target allPending = let pendingList = allPending |> List.map (\r -> " " ++ r.method ++ " " ++ r.url) |> String.join "\n" pendingMsg = if List.isEmpty allPending then "No requests are currently pending." else "Currently pending requests:\n" ++ pendingList in callerName ++ " \"" ++ target ++ "\" failed: no matching request is pending.\n\n" ++ pendingMsg {-| Render the list of currently pending requests as a hint appended to "cannot X while data is still resolving" errors. Returns an empty string when nothing is pending, so callers can append unconditionally. -} pendingRequestsHint : State model msg -> String pendingRequestsHint state = let pending = gatherAllPendingRequestDetails state in if List.isEmpty pending then "" else "\n\nCurrently pending:\n" ++ (pending |> List.map (\r -> " " ++ r.method ++ " " ++ r.url) |> String.join "\n" ) ++ "\n\nResolve these with simulateHttpGet, simulateHttpPost, simulateHttpError, or simulateCustom before asserting on the view." gatherAllPendingRequestDetails : State model msg -> List { url : String, method : String, headers : List ( String, String ), body : Maybe String } gatherAllPendingRequestDetails state = let phaseDetails = case state.phase of Resolving (Resolver r) -> r.pendingRequestDetails Ready ready -> ready.pendingEffects |> List.concatMap (\bt -> case BackendTaskTest.fromBackendTask bt of BackendTaskTest.Running runningState -> List.map requestToDetails runningState.pendingRequests _ -> [] ) fetcherDetails = state.pendingFetcherEffects |> List.concatMap (\(Resolver r) -> r.pendingRequestDetails ) in phaseDetails ++ fetcherDetails pendingPortName : { url : String, method : String, headers : List ( String, String ), body : Maybe String } -> Maybe String pendingPortName request = case request.body of Just body -> body |> Json.Decode.decodeString (Json.Decode.field "portName" Json.Decode.string) |> Result.toMaybe Nothing -> Nothing {-| Select an option from a dropdown `` by its label text and the `