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 fe61197
Show file tree
Hide file tree
Showing 3 changed files with 536 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
217 changes: 217 additions & 0 deletions test/e2e/tests/navigation.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
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
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.poll(
async () => {
return await page.locator("#my-scroll-container").evaluate((el) => el.scrollTop)
},
{ message: 'scrollTop not restored', timeout: 5000 }
).toBe(1000);

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

View workflow job for this annotation

GitHub Actions / e2e test (1.16.0, 26.2)

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

1) [chromium] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation Error: scrollTop not restored expect(received).toBe(expected) // Object.is equality Expected: 1000 Received: 1007 Call Log: - Timeout 5000ms exceeded while waiting on the predicate 214 | }, 215 | { message: 'scrollTop not restored', timeout: 5000 } > 216 | ).toBe(1000); | ^ 217 | }); 218 | at /__w/phoenix_live_view/phoenix_live_view/test/e2e/tests/navigation.spec.js:216:5

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

View workflow job for this annotation

GitHub Actions / e2e test (1.16.0, 26.2)

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

1) [chromium] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: scrollTop not restored expect(received).toBe(expected) // Object.is equality Expected: 1000 Received: 1007 Call Log: - Timeout 5000ms exceeded while waiting on the predicate 214 | }, 215 | { message: 'scrollTop not restored', timeout: 5000 } > 216 | ).toBe(1000); | ^ 217 | }); 218 | at /__w/phoenix_live_view/phoenix_live_view/test/e2e/tests/navigation.spec.js:216:5

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

View workflow job for this annotation

GitHub Actions / e2e test (1.16.0, 26.2)

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

1) [chromium] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: scrollTop not restored expect(received).toBe(expected) // Object.is equality Expected: 1000 Received: 1007 Call Log: - Timeout 5000ms exceeded while waiting on the predicate 214 | }, 215 | { message: 'scrollTop not restored', timeout: 5000 } > 216 | ).toBe(1000); | ^ 217 | }); 218 | at /__w/phoenix_live_view/phoenix_live_view/test/e2e/tests/navigation.spec.js:216:5

Check failure on line 216 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

2) [firefox] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation Error: scrollTop not restored expect(received).toBe(expected) // Object.is equality Expected: 1000 Received: 1303 Call Log: - Timeout 5000ms exceeded while waiting on the predicate 214 | }, 215 | { message: 'scrollTop not restored', timeout: 5000 } > 216 | ).toBe(1000); | ^ 217 | }); 218 | at /__w/phoenix_live_view/phoenix_live_view/test/e2e/tests/navigation.spec.js:216:5

Check failure on line 216 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

2) [firefox] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: scrollTop not restored expect(received).toBe(expected) // Object.is equality Expected: 1000 Received: 1303 Call Log: - Timeout 5000ms exceeded while waiting on the predicate 214 | }, 215 | { message: 'scrollTop not restored', timeout: 5000 } > 216 | ).toBe(1000); | ^ 217 | }); 218 | at /__w/phoenix_live_view/phoenix_live_view/test/e2e/tests/navigation.spec.js:216:5

Check failure on line 216 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

2) [firefox] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: scrollTop not restored expect(received).toBe(expected) // Object.is equality Expected: 1000 Received: 1303 Call Log: - Timeout 5000ms exceeded while waiting on the predicate 214 | }, 215 | { message: 'scrollTop not restored', timeout: 5000 } > 216 | ).toBe(1000); | ^ 217 | }); 218 | at /__w/phoenix_live_view/phoenix_live_view/test/e2e/tests/navigation.spec.js:216:5

Check failure on line 216 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:195:1 › restores scroll position on custom container after navigation

3) [webkit] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation Error: scrollTop not restored expect(received).toBe(expected) // Object.is equality Expected: 1000 Received: 1007 Call Log: - Timeout 5000ms exceeded while waiting on the predicate 214 | }, 215 | { message: 'scrollTop not restored', timeout: 5000 } > 216 | ).toBe(1000); | ^ 217 | }); 218 | at /__w/phoenix_live_view/phoenix_live_view/test/e2e/tests/navigation.spec.js:216:5

Check failure on line 216 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:195:1 › restores scroll position on custom container after navigation

3) [webkit] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: scrollTop not restored expect(received).toBe(expected) // Object.is equality Expected: 1000 Received: 1007 Call Log: - Timeout 5000ms exceeded while waiting on the predicate 214 | }, 215 | { message: 'scrollTop not restored', timeout: 5000 } > 216 | ).toBe(1000); | ^ 217 | }); 218 | at /__w/phoenix_live_view/phoenix_live_view/test/e2e/tests/navigation.spec.js:216:5

Check failure on line 216 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:195:1 › restores scroll position on custom container after navigation

3) [webkit] › tests/navigation.spec.js:195:1 › restores scroll position on custom container after navigation Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: scrollTop not restored expect(received).toBe(expected) // Object.is equality Expected: 1000 Received: 1007 Call Log: - Timeout 5000ms exceeded while waiting on the predicate 214 | }, 215 | { message: 'scrollTop not restored', timeout: 5000 } > 216 | ).toBe(1000); | ^ 217 | }); 218 | at /__w/phoenix_live_view/phoenix_live_view/test/e2e/tests/navigation.spec.js:216:5
});
Loading

0 comments on commit fe61197

Please sign in to comment.