forked from phoenixframework/phoenix_live_view
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
337 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
|
||
{ 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); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
|
||
|
||
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 |