From a1bc43ab869e185db422810c8314ee66e1829d30 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Thu, 25 Jan 2024 19:13:17 +0100 Subject: [PATCH] Add tests for live navigation --- test/e2e/test_helper.exs | 28 +++++- test/e2e/tests/navigation.spec.js | 155 +++++++++++++++++++++++++++++ test/support/e2e/navigation.ex | 157 ++++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 test/e2e/tests/navigation.spec.js create mode 100644 test/support/e2e/navigation.ex diff --git a/test/e2e/test_helper.exs b/test/e2e/test_helper.exs index d2acc8bde8..7514135172 100644 --- a/test/e2e/test_helper.exs +++ b/test/e2e/test_helper.exs @@ -26,10 +26,12 @@ defmodule Phoenix.LiveViewTest.E2E.Layout do def render("live.html", assigns) do ~H""" + @@ -46,7 +48,9 @@ defmodule Phoenix.LiveViewTest.E2E.Router do import Phoenix.LiveView.Router pipeline :browser do - plug(:accepts, ["html"]) + plug :accepts, ["html"] + plug :fetch_session + plug :protect_from_forgery end live_session :default, layout: {Phoenix.LiveViewTest.E2E.Layout, :live} do @@ -76,6 +80,16 @@ defmodule Phoenix.LiveViewTest.E2E.Router do end end + live_session :navigation, layout: {Phoenix.LiveViewTest.E2E.Navigation.Layout, :live} do + scope "/navigation" do + pipe_through(:browser) + + live "/a", Phoenix.LiveViewTest.E2E.Navigation.ALive + live "/b", Phoenix.LiveViewTest.E2E.Navigation.BLive, :index + live "/b/:id", Phoenix.LiveViewTest.E2E.Navigation.BLive, :show + end + end + # these routes use a custom layout and therefore cannot be in the live_session scope "/issues" do pipe_through(:browser) @@ -88,7 +102,14 @@ end defmodule Phoenix.LiveViewTest.E2E.Endpoint do use Phoenix.Endpoint, otp_app: :phoenix_live_view - socket("/live", Phoenix.LiveView.Socket) + @session_options [ + store: :cookie, + key: "_lv_e2e_key", + signing_salt: "1gk/d8ms", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] plug Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix" plug Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view" @@ -101,6 +122,7 @@ defmodule Phoenix.LiveViewTest.E2E.Endpoint do pass: ["*/*"], json_decoder: Phoenix.json_library() + plug Plug.Session, @session_options plug Phoenix.LiveViewTest.E2E.Router defp health_check(%{request_path: "/health"} = conn, _opts) do diff --git a/test/e2e/tests/navigation.spec.js b/test/e2e/tests/navigation.spec.js new file mode 100644 index 0000000000..ab3063e42b --- /dev/null +++ b/test/e2e/tests/navigation.spec.js @@ -0,0 +1,155 @@ +const { test, expect, request } = require("@playwright/test"); +const { syncLV } = require("../utils"); + +let webSocketEvents = []; +let networkEvents = []; + +test.beforeEach(async ({ page }) => { + networkEvents = []; + webSocketEvents = []; + + page.on("request", request => networkEvents.push({ method: request.method(), url: request.url() })); + + page.on("websocket", ws => { + ws.on("framesent", event => webSocketEvents.push({ type: "sent", payload: event.payload })); + ws.on("framereceived", event => webSocketEvents.push({ type: "received", payload: event.payload })); + ws.on("close", () => webSocketEvents.push({ type: "close" })); + }); +}); + +test("can navigate between LiveViews in the same live session over websocket", async ({ page }) => { + await page.goto("/navigation/a"); + await syncLV(page); + + await expect(networkEvents).toEqual([ + { method: "GET", url: "http://localhost:4000/navigation/a" }, + { method: "GET", url: "http://localhost:4000/assets/phoenix/phoenix.min.js" }, + { method: "GET", url: "http://localhost:4000/assets/phoenix_live_view/phoenix_live_view.js" }, + ]); + + await expect(webSocketEvents).toEqual([ + expect.objectContaining({ type: "sent", payload: expect.stringContaining("phx_join") }), + expect.objectContaining({ type: "received", payload: expect.stringContaining("phx_reply") }), + ]); + + // clear events + networkEvents = []; + webSocketEvents = []; + + // patch the LV + const length = await page.evaluate(() => window.history.length); + await page.getByRole("link", { name: "Patch this LiveView" }).click(); + await syncLV(page); + await expect(networkEvents).toEqual([]); + await expect(webSocketEvents).toEqual([ + expect.objectContaining({ type: "sent", payload: expect.stringContaining("live_patch") }), + expect.objectContaining({ type: "received", payload: expect.stringContaining("phx_reply") }), + ]); + await expect(await page.evaluate(() => window.history.length)).toEqual(length + 1); + + webSocketEvents = []; + + // live navigation to other LV + await page.getByRole("link", { name: "LiveView B" }).click(); + await syncLV(page); + + await expect(networkEvents).toEqual([]); + // we don't assert the order of the events here, because they are not deterministic + await expect(webSocketEvents).toEqual(expect.arrayContaining([ + { type: "sent", payload: expect.stringContaining("phx_leave") }, + { type: "sent", payload: expect.stringContaining("phx_join") }, + { type: "received", payload: expect.stringContaining("phx_close") }, + { type: "received", payload: expect.stringContaining("phx_reply") }, + { type: "received", payload: expect.stringContaining("phx_reply") }, + ])); +}); + +test("patch with replace replaces history", async ({ page }) => { + await page.goto("/navigation/a"); + await syncLV(page); + const url = page.url(); + + const length = await page.evaluate(() => window.history.length); + + await page.getByRole("link", { name: "Patch (Replace)" }).click(); + await syncLV(page); + + await expect(await page.evaluate(() => window.history.length)).toEqual(length); + await expect(page.url()).not.toEqual(url); +}); + +test("falls back to http navigation when navigating between live sessions", async ({ page, browserName }) => { + await page.goto("/navigation/a"); + await syncLV(page); + + networkEvents = []; + webSocketEvents = []; + + // live navigation to page in another live session + await page.getByRole("link", { name: "LiveView (other session)" }).click(); + await syncLV(page); + + await expect(networkEvents).toEqual(expect.arrayContaining([{ method: "GET", url: "http://localhost:4000/stream" }])); + await expect(webSocketEvents).toEqual(expect.arrayContaining([ + { type: "sent", payload: expect.stringContaining("phx_leave") }, + { type: "sent", payload: expect.stringContaining("phx_join") }, + { type: "received", payload: expect.stringContaining("phx_close") }, + { type: "received", payload: expect.stringContaining("phx_reply") }, + { type: "received", payload: expect.stringMatching(/error.*unauthorized/) }, + { type: "sent", payload: expect.stringContaining("phx_join") }, + { type: "received", payload: expect.stringContaining("phx_reply") }, + ].concat(browserName === "webkit" ? [] : [{ type: "close" }]))); + // ^ webkit doesn't always seem to emit websocket close events +}); + +test("restores scroll position after navigation", async ({ page }) => { + await page.goto("/navigation/b"); + await syncLV(page); + + await expect(page.locator("#items")).toContainText("Item 42"); + + await expect(await page.evaluate(() => document.body.scrollTop)).toEqual(0); + const offset = (await page.locator("#items-item-42").evaluate((el) => el.offsetTop)) - 200; + await page.evaluate((offset) => window.scrollTo(0, offset), offset); + // LiveView only updates the scroll position every 100ms + await page.waitForTimeout(150); + + await page.getByRole("link", { name: "Item 42" }).click(); + await syncLV(page); + + await page.goBack(); + await syncLV(page); + + // scroll position is restored + await expect.poll( + async () => { + return await page.evaluate(() => document.body.scrollTop); + }, + { message: 'scrollTop not restored', timeout: 5000 } + ).toBe(offset); +}); + +test("does not restore scroll position on custom container after navigation", async ({ page }) => { + await page.goto("/navigation/b?container=1"); + await syncLV(page); + + await expect(page.locator("#items")).toContainText("Item 42"); + + await expect(await page.locator("#my-scroll-container").evaluate((el) => el.scrollTop)).toEqual(0); + const offset = (await page.locator("#items-item-42").evaluate((el) => el.offsetTop)) - 200; + await page.locator("#my-scroll-container").evaluate((el, offset) => el.scrollTo(0, offset), offset); + + await page.getByRole("link", { name: "Item 42" }).click(); + await syncLV(page); + + await page.goBack(); + await syncLV(page); + + // scroll position is not restored + await expect.poll( + async () => { + return await page.locator("#my-scroll-container").evaluate((el) => el.scrollTop); + }, + { message: 'scrollTop not restored', timeout: 5000 } + ).toBe(0); +}); diff --git a/test/support/e2e/navigation.ex b/test/support/e2e/navigation.ex new file mode 100644 index 0000000000..538ec75424 --- /dev/null +++ b/test/support/e2e/navigation.ex @@ -0,0 +1,157 @@ +defmodule Phoenix.LiveViewTest.E2E.Navigation.Layout do + use Phoenix.LiveView + + alias Phoenix.LiveView.JS + + def render("live.html", assigns) do + ~H""" + + + + + + + +
+
+

Navigation

+ + <.link navigate="/navigation/a" style="background-color: #f1f5f9; padding: 0.5rem;"> + LiveView A + + + <.link navigate="/navigation/b" style="background-color: #f1f5f9; padding: 0.5rem;"> + LiveView B + + + <.link navigate="/stream" style="background-color: #f1f5f9; padding: 0.5rem;"> + LiveView (other session) + +
+ +
+ <%= @inner_content %> +
+
+ """ + end +end + +defmodule Phoenix.LiveViewTest.E2E.Navigation.ALive do + use Phoenix.LiveView + + @impl Phoenix.LiveView + def mount(_params, _session, socket) do + socket + |> assign(:param_current, nil) + |> then(&{:ok, &1}) + end + + @impl Phoenix.LiveView + def handle_params(params, _uri, socket) do + param = Map.get(params, "param") + + socket + |> assign(:param_current, param) + |> assign(:param_next, System.unique_integer()) + |> then(&{:noreply, &1}) + end + + @impl Phoenix.LiveView + def render(assigns) do + ~H""" +

This is page A

+ +

Current param: <%= @param_current %>

+ + <.link + patch={"/navigation/a?param=#{@param_next}"} + style="padding-left: 1rem; padding-right: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem; background-color: #e2e8f0; display: inline-flex; align-items: center; border-radius: 0.375rem; cursor: pointer;" + > + Patch this LiveView + + + <.link + patch={"/navigation/a?param=#{@param_next}"} + replace + style="padding-left: 1rem; padding-right: 1rem; padding-top: 0.5rem; padding-bottom: 0.5rem; background-color: #e2e8f0; display: inline-flex; align-items: center; border-radius: 0.375rem; cursor: pointer;" + > + Patch (Replace) + + """ + end +end + +defmodule Phoenix.LiveViewTest.E2E.Navigation.BLive do + use Phoenix.LiveView + + @impl Phoenix.LiveView + def mount(_params, _session, socket) do + socket + |> then(&{:ok, &1}) + end + + @impl Phoenix.LiveView + def handle_params(params, _uri, socket) do + socket + |> assign(:container, not is_nil(params["container"])) + |> apply_action(socket.assigns.live_action, params) + |> then(&{:noreply, &1}) + end + + def apply_action(socket, :index, _params) do + items = + for i <- 1..100 do + %{id: "item-#{i}", name: i} + end + + stream(socket, :items, items) + end + + def apply_action(socket, :show, %{"id" => id}) do + assign(socket, :id, id) + end + + @impl Phoenix.LiveView + def render(assigns) do + ~H""" +

This is page D

+ +
+ +
+ +
+

Item <%= @id %>

+
+ """ + end +end