Skip to content

Commit

Permalink
Add e2e tests for live navigation and guards
Browse files Browse the repository at this point in the history
  • Loading branch information
SteffenDE committed Jan 25, 2024
1 parent c034c38 commit 2dcfee7
Show file tree
Hide file tree
Showing 3 changed files with 539 additions and 3 deletions.
30 changes: 27 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()
</script>
<style>
Expand All @@ -45,7 +47,10 @@ defmodule Phoenix.LiveViewTest.E2E.Router do
import Phoenix.LiveView.Router

pipeline :browser do
plug(:accepts, ["html"])
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :protect_from_forgery
end

live_session :default, layout: {Phoenix.LiveViewTest.E2E.Layout, :live} do
Expand All @@ -71,6 +76,17 @@ 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
live "/c", Phoenix.LiveViewTest.E2E.Navigation.CLive, :index
live "/c/:id", Phoenix.LiveViewTest.E2E.Navigation.CLive, :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 @@ -83,14 +99,22 @@ 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"
plug Plug.Static, from: System.tmp_dir!(), at: "/tmp"

plug :health_check

plug Plug.Session, @session_options
plug Phoenix.LiveViewTest.E2E.Router

defp health_check(%{request_path: "/health"} = conn, _opts) do
Expand Down
220 changes: 220 additions & 0 deletions test/e2e/tests/navigation.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
const { test, expect, request } = require("@playwright/test");

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

View workflow job for this annotation

GitHub Actions / e2e test (1.16.0, 26.2)

[firefox] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation

1) [firefox] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation Test finished within timeout of 30000ms, but tearing down "context" ran out of time. Please allow more time for the test, since teardown is attributed towards the test timeout budget.

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

View workflow job for this annotation

GitHub Actions / e2e test (1.16.0, 26.2)

[firefox] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation

1) [firefox] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Test finished within timeout of 30000ms, but tearing down "context" ran out of time. Please allow more time for the test, since teardown is attributed towards the test timeout budget.

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

View workflow job for this annotation

GitHub Actions / e2e test (1.16.0, 26.2)

[firefox] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation

1) [firefox] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Test finished within timeout of 30000ms, but tearing down "context" ran out of time. Please allow more time for the test, since teardown is attributed towards the test timeout budget.
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
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") }),
]);

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("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("can prevent navigation with navigation guard", async ({ page }) => {
await page.goto("/navigation/a");
await syncLV(page);

await page.getByRole("link", { name: "LiveView B" }).click();
await syncLV(page);

await expect(page.locator("form")).toBeVisible();
await page.locator("input").fill("my text");
await syncLV(page);

networkEvents = [];
webSocketEvents = [];

// we expect a confirmation dialog
page.once("dialog", dialog => dialog.dismiss());
await page.getByRole("link", { name: "LiveView A" }).click();

// we should not have navigated
await expect(page).toHaveURL("/navigation/b");
await expect(webSocketEvents).toEqual([]);
await expect(page.locator("input")).toHaveValue("my text");

// now we accept the dialog
page.once("dialog", dialog => dialog.accept());
await page.getByRole("link", { name: "LiveView A" }).click();
await syncLV(page);

// navigation should succeed
await expect(page).toHaveURL("/navigation/a");
await expect(networkEvents).toEqual([]);
});

test("history triggers navigation guard", async ({ page }) => {
await page.goto("/navigation/a");
await syncLV(page);

await page.getByRole("link", { name: "LiveView B" }).click();
await syncLV(page);

await expect(page.locator("form")).toBeVisible();
await page.locator("input").fill("my text");
await syncLV(page);

networkEvents = [];
webSocketEvents = [];

// we expect a confirmation dialog
page.once("dialog", dialog => dialog.dismiss());
await page.goBack();

// we should not have navigated
await expect(page).toHaveURL("/navigation/b");
await expect(webSocketEvents).toEqual([]);
await expect(page.locator("input")).toHaveValue("my text");

// when we submitted the form navigation should succeed
await page.getByRole("button", { name: "Submit" }).click();
await syncLV(page);

await page.goBack();
await syncLV(page);
await expect(page).toHaveURL("/navigation/a");
await expect(networkEvents).toEqual([]);
});

test("navigation guard is triggered before and after navigation", async ({ page }) => {
await page.goto("/navigation/a");
await syncLV(page);

await page.getByRole("link", { name: "LiveView B" }).click();
await syncLV(page);

const result = await page.evaluate(() => JSON.stringify(window.navigationEvents));
await expect(JSON.parse(result)).toEqual([
{ type: "before", to: "http://localhost:4000/navigation/b", from: "http://localhost:4000/navigation/a" },
{ type: "after", to: "http://localhost:4000/navigation/b", from: "http://localhost:4000/navigation/a" },
])
});

test("restores scroll position after navigation", async ({ page }) => {
await page.goto("/navigation/c");
await syncLV(page);

await expect(page.locator("#items")).toContainText("Item 42");

await expect(await page.evaluate(() => document.body.scrollTop)).toEqual(0);
await page.evaluate(() => window.scrollTo(0, 1200));
// 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(1200);
});

test("restores scroll position on custom container after navigation", async ({ page }) => {
await page.goto("/navigation/c?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);
await page.locator("#my-scroll-container").evaluate((el) => el.scrollTo(0, 1000));

await page.getByRole("link", { name: "Item 42" }).click();
await syncLV(page);

await page.goBack();
await syncLV(page);

// scroll position is restored
await expect(async () => {
// in CI the scrolled value is somehow 1007 and not 1000
// I'm not sure why, but it's consistent, so we just check for a range
const position = await page.locator("#my-scroll-container").evaluate((el) => el.scrollTop)
expect(position).toBeGreaterThanOrEqual(990);
expect(position).toBeLessThanOrEqual(1010);
},
{ message: 'scrollTop not restored', timeout: 5000 }
).toPass();

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

View workflow job for this annotation

GitHub Actions / e2e test (1.16.0, 26.2)

[firefox] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation

1) [firefox] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation Error: scrollTop not restored expect(received).toBeLessThanOrEqual(expected) Expected: <= 1010 Received: 1303 Call Log: - Test timeout of 30000ms exceeded 217 | }, 218 | { message: 'scrollTop not restored', timeout: 5000 } > 219 | ).toPass(); | ^ 220 | }); 221 | at /__w/phoenix_live_view/phoenix_live_view/test/e2e/tests/navigation.spec.js:219:5

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

View workflow job for this annotation

GitHub Actions / e2e test (1.16.0, 26.2)

[firefox] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation

1) [firefox] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: scrollTop not restored expect(received).toBeLessThanOrEqual(expected) Expected: <= 1010 Received: 1303 Call Log: - Test timeout of 30000ms exceeded 217 | }, 218 | { message: 'scrollTop not restored', timeout: 5000 } > 219 | ).toPass(); | ^ 220 | }); 221 | at /__w/phoenix_live_view/phoenix_live_view/test/e2e/tests/navigation.spec.js:219:5

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

View workflow job for this annotation

GitHub Actions / e2e test (1.16.0, 26.2)

[firefox] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation

1) [firefox] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: scrollTop not restored expect(received).toBeLessThanOrEqual(expected) Expected: <= 1010 Received: 1303 Call Log: - Test timeout of 30000ms exceeded 217 | }, 218 | { message: 'scrollTop not restored', timeout: 5000 } > 219 | ).toPass(); | ^ 220 | }); 221 | at /__w/phoenix_live_view/phoenix_live_view/test/e2e/tests/navigation.spec.js:219:5
});
Loading

0 comments on commit 2dcfee7

Please sign in to comment.