Skip to content

Commit

Permalink
Add tests for live navigation
Browse files Browse the repository at this point in the history
  • Loading branch information
SteffenDE committed Feb 17, 2024
1 parent 4939fb8 commit a1bc43a
Show file tree
Hide file tree
Showing 3 changed files with 337 additions and 3 deletions.
28 changes: 25 additions & 3 deletions test/e2e/test_helper.exs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ defmodule Phoenix.LiveViewTest.E2E.Layout do

def render("live.html", assigns) do
~H"""
<meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} />
<script src="/assets/phoenix/phoenix.min.js"></script>
<script src="/assets/phoenix_live_view/phoenix_live_view.js"></script>
<script>
let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket, {params: {_csrf_token: csrfToken}})
liveSocket.connect()
window.liveSocket = liveSocket
</script>
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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"
Expand All @@ -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
Expand Down
155 changes: 155 additions & 0 deletions test/e2e/tests/navigation.spec.js
Original file line number Diff line number Diff line change
@@ -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([

Check failure on line 58 in test/e2e/tests/navigation.spec.js

View workflow job for this annotation

GitHub Actions / e2e test (1.16.0, 26.2)

[webkit] › tests/navigation.spec.js:20:1 › can navigate between LiveViews in the same live session over websocket

1) [webkit] › tests/navigation.spec.js:20:1 › can navigate between LiveViews in the same live session over websocket Error: expect(received).toEqual(expected) // deep equality Expected: ArrayContaining [{"payload": StringContaining "phx_leave", "type": "sent"}, {"payload": StringContaining "phx_join", "type": "sent"}, {"payload": StringContaining "phx_close", "type": "received"}, {"payload": StringContaining "phx_reply", "type": "received"}, {"payload": StringContaining "phx_reply", "type": "received"}] Received: [[Object], [Object], [Object]] 56 | await expect(networkEvents).toEqual([]); 57 | // we don't assert the order of the events here, because they are not deterministic > 58 | await expect(webSocketEvents).toEqual(expect.arrayContaining([ | ^ 59 | { type: "sent", payload: expect.stringContaining("phx_leave") }, 60 | { type: "sent", payload: expect.stringContaining("phx_join") }, 61 | { type: "received", payload: expect.stringContaining("phx_close") }, at /__w/phoenix_live_view/phoenix_live_view/test/e2e/tests/navigation.spec.js:58:33
{ 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);
});
157 changes: 157 additions & 0 deletions test/support/e2e/navigation.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
defmodule Phoenix.LiveViewTest.E2E.Navigation.Layout do
use Phoenix.LiveView

alias Phoenix.LiveView.JS

Check warning on line 4 in test/support/e2e/navigation.ex

View workflow job for this annotation

GitHub Actions / mix test (OTP 24.3 | Elixir 1.13.4)

unused alias JS

Check warning on line 4 in test/support/e2e/navigation.ex

View workflow job for this annotation

GitHub Actions / mix test (OTP 25.3 | Elixir 1.15.4)

unused alias JS

Check warning on line 4 in test/support/e2e/navigation.ex

View workflow job for this annotation

GitHub Actions / mix test (OTP 26 | Elixir 1.15.5)

unused alias JS

def render("live.html", assigns) do
~H"""
<meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} />
<script src="/assets/phoenix/phoenix.min.js"></script>
<script src="/assets/phoenix_live_view/phoenix_live_view.js"></script>
<script>
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket, {params: {_csrf_token: csrfToken}})
liveSocket.connect()
window.liveSocket = liveSocket
</script>
<style>
html, body {
margin: 0;
padding: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, "Helvetica Neue", sans-serif;
font-size: 1rem;
}
</style>
<div style="display: flex; width: 100%; height: 100vh;">
<div style="position: fixed; height: 100vh; background-color: #f8fafc; border-right: 1px solid; width: 20rem; display: flex; flex-direction: column; padding: 1rem; gap: 0.5rem;">
<h1 style="margin-bottom: 1rem; font-size: 1.125rem; line-height: 1.75rem;">Navigation</h1>
<.link navigate="/navigation/a" style="background-color: #f1f5f9; padding: 0.5rem;">
LiveView A
</.link>
<.link navigate="/navigation/b" style="background-color: #f1f5f9; padding: 0.5rem;">
LiveView B
</.link>
<.link navigate="/stream" style="background-color: #f1f5f9; padding: 0.5rem;">
LiveView (other session)
</.link>
</div>
<div style="margin-left: 22rem; flex: 1; padding: 2rem;">
<%= @inner_content %>
</div>
</div>
"""
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"""
<h1>This is page A</h1>
<p>Current param: <%= @param_current %></p>
<.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>
<.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)
</.link>
"""
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"""
<h1>This is page D</h1>
<div
:if={@live_action == :index}
id="my-scroll-container"
style={"#{if @container, do: "height: 85vh; overflow-y: scroll; "}width: 100%; border: 1px solid #e2e8f0; border-radius: 0.375rem; position: relative;"}
>
<ul id="items" style="padding: 1rem; list-style: none;" phx-update="stream">
<%= for {id, item} <- @streams.items do %>
<li id={id} style="padding: 0.5rem; border-bottom: 1px solid #e2e8f0;">
<.link
patch={"/navigation/b/#{item.id}"}
style="display: inline-flex; align-items: center; gap: 0.5rem;"
>
Item <%= item.name %>
</.link>
</li>
<% end %>
</ul>
</div>
<div :if={@live_action == :show}>
<p>Item <%= @id %></p>
</div>
"""
end
end

0 comments on commit a1bc43a

Please sign in to comment.