From 786b76ed77e4495934e3ef93ade8a72132ac4adb Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Wed, 17 Jan 2024 15:19:54 +0100 Subject: [PATCH] Proof of concept: playwright e2e tests (#3001) playwright e2e tests references https://github.com/phoenixframework/phoenix_live_view/pull/3009 references https://github.com/phoenixframework/phoenix_live_view/issues/2993 references https://github.com/phoenixframework/phoenix_live_view/issues/1759 * build assets in e2e pipeline We want to run the e2e tests with the latest assets without the need to commit them to the repository. Otherwise we would not see test failures from changes to the js code as early as possible. --- .github/workflows/ci.yml | 78 ++++- .gitignore | 2 + mix.exs | 4 +- mix.lock | 5 + package-lock.json | 75 +++++ package.json | 9 +- test/e2e/README.md | 31 ++ test/e2e/playwright.config.js | 40 +++ test/e2e/test_helper.exs | 95 ++++++ test/e2e/tests/forms.spec.js | 65 ++++ test/e2e/tests/streams.spec.js | 280 ++++++++++++++++++ test/e2e/tests/uploads.spec.js | 137 +++++++++ test/e2e/utils.js | 56 ++++ .../integrations/stream_test.exs | 80 ++++- test/support/e2e/form_live.ex | 33 +++ test/support/e2e/upload_live.ex | 68 +++++ test/support/live_views/streams.ex | 161 +++++++++- test/support/router.ex | 2 + 18 files changed, 1204 insertions(+), 17 deletions(-) create mode 100644 package-lock.json create mode 100644 test/e2e/README.md create mode 100644 test/e2e/playwright.config.js create mode 100644 test/e2e/test_helper.exs create mode 100644 test/e2e/tests/forms.spec.js create mode 100644 test/e2e/tests/streams.spec.js create mode 100644 test/e2e/tests/uploads.spec.js create mode 100644 test/e2e/utils.js create mode 100644 test/support/e2e/form_live.ex create mode 100644 test/support/e2e/upload_live.ex diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 325bbeef5b..620d3f74ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,13 @@ jobs: npm_test: name: npm test + + strategy: + matrix: + include: + - elixir: 1.13.2 + otp: 24.2 + runs-on: ubuntu-latest steps: - name: Checkout @@ -85,8 +92,8 @@ jobs: - name: Set up Elixir uses: erlef/setup-beam@v1 with: - elixir-version: 1.13.2 - otp-version: 24.2 + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} - name: Restore deps and _build cache uses: actions/cache@v2 @@ -119,3 +126,70 @@ jobs: cd assets npm install npm test + + e2e_test: + name: e2e test + + strategy: + matrix: + include: + - elixir: 1.16.0 + otp: 26.2 + + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.40.1-jammy + env: + ImageOS: ubuntu22 + HOME: /root + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: install unzip + run: apt update && apt -y install unzip + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + + - name: Restore deps and _build cache + uses: actions/cache@v2 + with: + path: | + deps + _build + key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} + + - name: Install dependencies + run: mix deps.get --only e2e --only dev + + - name: Restore npm cache + uses: actions/cache@v2 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: npm install (assets) + run: cd assets && npm install && cd .. + + - name: Build assets + run: mix assets.build + + - name: Run e2e tests + run: | + npm install + npm run e2e:test + + - uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 3086aba19a..eeedb80696 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ phoenix_live_view-*.tar node_modules +/test/e2e/test-results/ +/playwright-report/ diff --git a/mix.exs b/mix.exs index 47b10b3c34..e7be07a51d 100644 --- a/mix.exs +++ b/mix.exs @@ -24,6 +24,7 @@ defmodule Phoenix.LiveView.MixProject do ] end + defp elixirc_paths(:e2e), do: ["lib", "test/support"] defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] @@ -49,7 +50,8 @@ defmodule Phoenix.LiveView.MixProject do {:makeup_eex, ">= 0.1.1", only: :docs}, {:makeup_diff, "~> 0.1", only: :docs}, {:html_entities, ">= 0.0.0", only: :test}, - {:phoenix_live_reload, "~> 1.4.1", only: :test} + {:phoenix_live_reload, "~> 1.4.1", only: :test}, + {:plug_cowboy, "~> 2.6", only: :e2e} ] end diff --git a/mix.lock b/mix.lock index fe9b7ae7a4..146e694e53 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,8 @@ %{ "castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"}, + "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, "esbuild": {:hex, :esbuild, "0.7.1", "fa0947e8c3c3c2f86c9bf7e791a0a385007ccd42b86885e8e893bdb6631f5169", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "66661cdf70b1378ee4dc16573fcee67750b59761b2605a0207c267ab9d19f13c"}, "ex_doc": {:hex, :ex_doc, "0.31.1", "8a2355ac42b1cc7b2379da9e40243f2670143721dd50748bf6c3b1184dae2089", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3178c3a407c557d8343479e1ff117a96fd31bafe52a039079593fb0524ef61b0"}, @@ -22,6 +25,8 @@ "phoenix_template": {:hex, :phoenix_template, "1.0.1", "85f79e3ad1b0180abb43f9725973e3b8c2c3354a87245f91431eec60553ed3ef", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "157dc078f6226334c91cb32c1865bf3911686f8bcd6bcff86736f6253e6993ee"}, "phoenix_view": {:hex, :phoenix_view, "2.0.1", "a653e3d9d944aace0a064e4a13ad473ffa68f7bc4ca42dbf83cc1d464f1fb295", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "6c358e2cefc5f341c728914b867c556bbfd239fed9e881bac257d70cb2b8a6f6"}, "plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..fedcf17c2f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "phoenix_live_view", + "version": "0.20.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "phoenix_live_view", + "version": "0.20.3", + "license": "MIT", + "devDependencies": { + "@playwright/test": "^1.40.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.40.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.1.tgz", + "integrity": "sha512-EaaawMTOeEItCRvfmkI9v6rBkF1svM8wjl/YPRrg2N2Wmp+4qJYkWtJsbew1szfKKDm6fPLy4YAanBhIlf9dWw==", + "dev": true, + "dependencies": { + "playwright": "1.40.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.40.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.1.tgz", + "integrity": "sha512-2eHI7IioIpQ0bS1Ovg/HszsN/XKNwEG1kbzSDDmADpclKc7CyqkHw7Mg2JCz/bbCxg25QUPcjksoMW7JcIFQmw==", + "dev": true, + "dependencies": { + "playwright-core": "1.40.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.40.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.1.tgz", + "integrity": "sha512-+hkOycxPiV534c4HhpfX6yrlawqVUzITRKwHAmYfmsVreltEl6fAZJ3DPfLMOODw0H3s1Itd6MDCWmP1fl/QvQ==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + } + } +} diff --git a/package.json b/package.json index 06d3770bdd..83beb70a13 100644 --- a/package.json +++ b/package.json @@ -22,5 +22,12 @@ "package.json", "priv/static/*", "assets/js/phoenix_live_view/*" - ] + ], + "devDependencies": { + "@playwright/test": "^1.40.1" + }, + "scripts": { + "e2e:server": "MIX_ENV=e2e mix run test/e2e/test_helper.exs", + "e2e:test": "cd test/e2e && npx playwright test" + } } diff --git a/test/e2e/README.md b/test/e2e/README.md new file mode 100644 index 0000000000..ebde8ca64d --- /dev/null +++ b/test/e2e/README.md @@ -0,0 +1,31 @@ +# End-to-end tests + +This directory contains end-to-end tests that use the [Playwright](https://playwright.dev/) +test framework. +These tests use all three web engines (Chromium, Firefox, Webkit) and test the interaction +with an actual LiveView server. + +## Running the tests + +To run the tests, ensure that the npm dependencies are installed by running `npm install` in +the root of the repository. Then, run `npm run e2e:test` to run the tests. + +This will execute the `npx playwright test` command in the `test/e2e` directory. Playwright +will start a LiveView server using the `MIX_ENV=e2e mix run test/e2e/test_helper.exs` command. + +Playwright supports an [interactive UI mode](https://playwright.dev/docs/test-ui-mode) that +can be used to debug the tests. To run the tests in this mode, run `npm run e2e:test -- --ui`. + +Tests can also be run in headed mode by passing the `--headed` flag. This is especially useful +in combination with running only specific tests, for example: + +```bash +npm run e2e:test -- tests/streams.spec.js:9 --project chromium --headed +``` + +To step through a single test, pass `--debug`, which will automatically run the test in headed +mode: + +```bash +npm run e2e:test -- tests/streams.spec.js:9 --project chromium --debug +``` diff --git a/test/e2e/playwright.config.js b/test/e2e/playwright.config.js new file mode 100644 index 0000000000..707673a6ad --- /dev/null +++ b/test/e2e/playwright.config.js @@ -0,0 +1,40 @@ +// playwright.config.js +// @ts-check +const { devices } = require("@playwright/test"); + +/** @type {import("@playwright/test").PlaywrightTestConfig} */ +const config = { + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? [["github"], ["html"]] : "list", + use: { + trace: "retain-on-failure", + screenshot: "only-on-failure", + baseURL: "http://localhost:4000/", + ignoreHTTPSErrors: true, + }, + webServer: { + command: "npm run e2e:server", + url: "http://127.0.0.1:4000/health", + reuseExistingServer: !process.env.CI, + stdout: "pipe", + stderr: "pipe", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + } + ], + outputDir: "test-results" +}; + +module.exports = config; diff --git a/test/e2e/test_helper.exs b/test/e2e/test_helper.exs new file mode 100644 index 0000000000..ba0c60ca0d --- /dev/null +++ b/test/e2e/test_helper.exs @@ -0,0 +1,95 @@ +Application.put_env(:phoenix_live_view, Phoenix.LiveViewTest.E2E.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4000], + # TODO: switch to bandit when Phoenix 1.7 is used + # adapter: Bandit.PhoenixAdapter, + server: true, + live_view: [signing_salt: "aaaaaaaa"], + secret_key_base: String.duplicate("a", 64), + render_errors: [ + # TODO: uncomment when LV Phoenix 1.7 is used + # formats: [ + # html: Phoenix.LiveViewTest.E2E.ErrorHTML, + # ], + view: Phoenix.LiveViewTest.E2E.ErrorHTML, + layout: false + ], + pubsub_server: Phoenix.LiveViewTest.E2E.PubSub, + debug_errors: false +) + +defmodule Phoenix.LiveViewTest.E2E.ErrorHTML do + def render(template, _), do: Phoenix.Controller.status_message_from_template(template) +end + +defmodule Phoenix.LiveViewTest.E2E.Layout do + use Phoenix.Component + + def render("live.html", assigns) do + ~H""" + + + + + <%= @inner_content %> + """ + end +end + +defmodule Phoenix.LiveViewTest.E2E.Router do + use Phoenix.Router + import Phoenix.LiveView.Router + + pipeline :browser do + plug(:accepts, ["html"]) + end + + live_session :default, layout: {Phoenix.LiveViewTest.E2E.Layout, :live} do + scope "/" do + pipe_through(:browser) + + live "/stream", Phoenix.LiveViewTest.StreamLive + live "/stream/reset", Phoenix.LiveViewTest.StreamResetLive + live "/stream/reset-lc", Phoenix.LiveViewTest.StreamResetLCLive + live "/healthy/:category", Phoenix.LiveViewTest.HealthyLive + + live "/upload", Phoenix.LiveViewTest.E2E.UploadLive + live "/form", Phoenix.LiveViewTest.E2E.FormLive + end + end +end + +defmodule Phoenix.LiveViewTest.E2E.Endpoint do + use Phoenix.Endpoint, otp_app: :phoenix_live_view + + socket("/live", Phoenix.LiveView.Socket) + + 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 Phoenix.LiveViewTest.E2E.Router + + defp health_check(%{request_path: "/health"} = conn, _opts) do + conn |> Plug.Conn.send_resp(200, "OK") |> Plug.Conn.halt() + end + + defp health_check(conn, _opts), do: conn +end + +{:ok, _} = + Supervisor.start_link( + [ + Phoenix.LiveViewTest.E2E.Endpoint, + {Phoenix.PubSub, name: Phoenix.LiveViewTest.E2E.PubSub} + ], + strategy: :one_for_one + ) + +Process.sleep(:infinity) diff --git a/test/e2e/tests/forms.spec.js b/test/e2e/tests/forms.spec.js new file mode 100644 index 0000000000..ef827dbd3a --- /dev/null +++ b/test/e2e/tests/forms.spec.js @@ -0,0 +1,65 @@ +const { test, expect } = require("@playwright/test"); +const { syncLV, attributeMutations } = require("../utils"); + +test("readonly state is restored after submits", async ({ page }) => { + await page.goto("/form"); + await syncLV(page); + await expect(page.locator("input[name=a]")).toHaveAttribute("readonly"); + let changesA = attributeMutations(page, "input[name=a]"); + let changesB = attributeMutations(page, "input[name=b]"); + // can submit multiple times and readonly input stays readonly + await page.locator("button[type=submit]").click(); + await syncLV(page); + // a is readonly and should stay readonly + await expect(await changesA()).toEqual(expect.arrayContaining([ + { attr: "data-phx-readonly", oldValue: null, newValue: "true" }, + { attr: "readonly", oldValue: "", newValue: "" }, + { attr: "data-phx-readonly", oldValue: "true", newValue: null }, + { attr: "readonly", oldValue: "", newValue: "" }, + ])); + // b is not readonly, but LV will set it to readonly while submitting + await expect(await changesB()).toEqual(expect.arrayContaining([ + { attr: "data-phx-readonly", oldValue: null, newValue: "false" }, + { attr: "readonly", oldValue: null, newValue: "" }, + { attr: "data-phx-readonly", oldValue: "false", newValue: null }, + { attr: "readonly", oldValue: "", newValue: null }, + ])); + await expect(page.locator("input[name=a]")).toHaveAttribute("readonly"); + await page.locator("button[type=submit]").click(); + await syncLV(page); + await expect(page.locator("input[name=a]")).toHaveAttribute("readonly"); +}); + +test("button disabled state is restored after submits", async ({ page }) => { + await page.goto("/form"); + await syncLV(page); + let changes = attributeMutations(page, "button[type=submit]"); + await page.locator("button[type=submit]").click(); + await syncLV(page); + // submit button is disabled while submitting, but then restored + await expect(await changes()).toEqual(expect.arrayContaining([ + { attr: "data-phx-disabled", oldValue: null, newValue: "false" }, + { attr: "disabled", oldValue: null, newValue: "" }, + { attr: "class", oldValue: null, newValue: "phx-submit-loading" }, + { attr: "data-phx-disabled", oldValue: "false", newValue: null }, + { attr: "disabled", oldValue: "", newValue: null }, + { attr: "class", oldValue: "phx-submit-loading", newValue: null }, + ])); +}); + +test("non-form button (phx-disable-with) disabled state is restored after click", async ({ page }) => { + await page.goto("/form"); + await syncLV(page); + let changes = attributeMutations(page, "button[type=button]"); + await page.locator("button[type=button]").click(); + await syncLV(page); + // submit button is disabled while submitting, but then restored + await expect(await changes()).toEqual(expect.arrayContaining([ + { attr: "data-phx-disabled", oldValue: null, newValue: "false" }, + { attr: "disabled", oldValue: null, newValue: "" }, + { attr: "class", oldValue: null, newValue: "phx-click-loading" }, + { attr: "data-phx-disabled", oldValue: "false", newValue: null }, + { attr: "disabled", oldValue: "", newValue: null }, + { attr: "class", oldValue: "phx-click-loading", newValue: null }, + ])); +}); diff --git a/test/e2e/tests/streams.spec.js b/test/e2e/tests/streams.spec.js new file mode 100644 index 0000000000..21b02869cc --- /dev/null +++ b/test/e2e/tests/streams.spec.js @@ -0,0 +1,280 @@ +const { test, expect } = require("@playwright/test"); +const { syncLV } = require("../utils"); + +const usersInDom = async (page, parent) => { + return await page.locator(`#${parent} > *`) + .evaluateAll(list => list.map(el => ({ id: el.id, text: el.childNodes[0].nodeValue.trim() }))); +} + +test("renders properly", async ({ page }) => { + await page.goto("/stream"); + await syncLV(page); + + await expect(await usersInDom(page, "users")).toEqual([ + { id: "users-1", text: "chris" }, + { id: "users-2", text: "callan" } + ]); + await expect(await usersInDom(page, "c_users")).toEqual([ + { id: "c_users-1", text: "chris" }, + { id: "c_users-2", text: "callan" } + ]); + await expect(await usersInDom(page, "admins")).toEqual([ + { id: "admins-1", text: "chris-admin" }, + { id: "admins-2", text: "callan-admin" } + ]); +}); + +test("elements can be updated and deleted (LV)", async ({ page }) => { + await page.goto("/stream"); + await syncLV(page); + + await page.locator("#users-1").getByRole("button", { name: "update" }).click(); + await syncLV(page); + + await expect(await usersInDom(page, "users")).toEqual([ + { id: "users-1", text: "updated" }, + { id: "users-2", text: "callan" } + ]); + await expect(await usersInDom(page, "c_users")).toEqual([ + { id: "c_users-1", text: "chris" }, + { id: "c_users-2", text: "callan" } + ]); + await expect(await usersInDom(page, "admins")).toEqual([ + { id: "admins-1", text: "chris-admin" }, + { id: "admins-2", text: "callan-admin" } + ]); + + await page.locator("#users-2").getByRole("button", { name: "update" }).click(); + await syncLV(page); + + await expect(await usersInDom(page, "users")).toEqual([ + { id: "users-1", text: "updated" }, + { id: "users-2", text: "updated" } + ]); + await expect(await usersInDom(page, "c_users")).toEqual([ + { id: "c_users-1", text: "chris" }, + { id: "c_users-2", text: "callan" } + ]); + await expect(await usersInDom(page, "admins")).toEqual([ + { id: "admins-1", text: "chris-admin" }, + { id: "admins-2", text: "callan-admin" } + ]); + + await page.locator("#users-1").getByRole("button", { name: "delete" }).click(); + await syncLV(page); + + await expect(await usersInDom(page, "users")).toEqual([ + { id: "users-2", text: "updated" } + ]); +}); + +test("elements can be updated and deleted (LC)", async ({ page }) => { + await page.goto("/stream"); + await syncLV(page); + + await page.locator("#c_users-1").getByRole("button", { name: "update" }).click(); + await syncLV(page); + + await expect(await usersInDom(page, "c_users")).toEqual([ + { id: "c_users-1", text: "updated" }, + { id: "c_users-2", text: "callan" } + ]); + await expect(await usersInDom(page, "users")).toEqual([ + { id: "users-1", text: "chris" }, + { id: "users-2", text: "callan" } + ]); + await expect(await usersInDom(page, "admins")).toEqual([ + { id: "admins-1", text: "chris-admin" }, + { id: "admins-2", text: "callan-admin" } + ]); + + await page.locator("#c_users-2").getByRole("button", { name: "update" }).click(); + await syncLV(page); + + await expect(await usersInDom(page, "c_users")).toEqual([ + { id: "c_users-1", text: "updated" }, + { id: "c_users-2", text: "updated" } + ]); + await expect(await usersInDom(page, "users")).toEqual([ + { id: "users-1", text: "chris" }, + { id: "users-2", text: "callan" } + ]); + await expect(await usersInDom(page, "admins")).toEqual([ + { id: "admins-1", text: "chris-admin" }, + { id: "admins-2", text: "callan-admin" } + ]); + + await page.locator("#c_users-1").getByRole("button", { name: "delete" }).click(); + await syncLV(page); + + await expect(await usersInDom(page, "c_users")).toEqual([ + { id: "c_users-2", text: "updated" } + ]); +}); + +test("move-to-first moves the second element to the first position (LV)", async ({ page }) => { + test.fail("currently broken"); + + await page.goto("/stream"); + await syncLV(page); + + await expect(await usersInDom(page, "c_users")).toEqual([ + { id: "users-1", text: "chris" }, + { id: "users-2", text: "callan" } + ]); + + await page.locator("#users-2").getByRole("button", { name: "make first" }).click(); + await expect(await usersInDom(page, "c_users")).toEqual([ + { id: "users-2", text: "updated" }, + { id: "users-1", text: "chris" } + ]); +}); + +test("stream reset removes items", async ({ page }) => { + await page.goto("/stream"); + await syncLV(page); + + await expect(await usersInDom(page, "users")).toEqual([{ id: "users-1", text: "chris" }, { id: "users-2", text: "callan" }]); + + await page.getByRole("button", { name: "Reset" }).click(); + await syncLV(page); + + await expect(await usersInDom(page, "users")).toEqual([]); +}); + +test("stream reset properly reorders items", async ({ page }) => { + await page.goto("/stream"); + await syncLV(page); + + await expect(await usersInDom(page, "users")).toEqual([ + { id: "users-1", text: "chris" }, + { id: "users-2", text: "callan" } + ]); + + await page.getByRole("button", { name: "Reorder" }).click(); + await syncLV(page); + + await expect(await usersInDom(page, "users")).toEqual([ + { id: "users-3", text: "peter" }, + { id: "users-1", text: "chris" }, + { id: "users-4", text: "mona" } + ]); +}); + +test.describe("Issue #2656", () => { + test("stream reset works when patching", async ({ page }) => { + await page.goto("/healthy/fruits"); + await syncLV(page); + + await expect(page.locator("h1")).toContainText("Fruits"); + await expect(page.locator("ul")).toContainText("Apples"); + await expect(page.locator("ul")).toContainText("Oranges"); + + await page.getByRole("link", { name: "Switch" }).click(); + await expect(page).toHaveURL("/healthy/veggies"); + await syncLV(page); + + await expect(page.locator("h1")).toContainText("Veggies"); + + await expect(page.locator("ul")).toContainText("Carrots"); + await expect(page.locator("ul")).toContainText("Tomatoes"); + await expect(page.locator("ul")).not.toContainText("Apples"); + await expect(page.locator("ul")).not.toContainText("Oranges"); + + await page.getByRole("link", { name: "Switch" }).click(); + await expect(page).toHaveURL("/healthy/fruits"); + await syncLV(page); + + await expect(page.locator("ul")).not.toContainText("Carrots"); + await expect(page.locator("ul")).not.toContainText("Tomatoes"); + await expect(page.locator("ul")).toContainText("Apples"); + await expect(page.locator("ul")).toContainText("Oranges"); + }); +}); + +test.describe("Issue #2994", () => { + const listItems = async (page) => page.locator("ul > li").evaluateAll(list => list.map(el => el.id)); + + test("can filter and reset a stream", async ({ page }) => { + await page.goto("/stream/reset"); + await syncLV(page); + + await expect(await listItems(page)).toEqual(["items-a", "items-b", "items-c", "items-d"]); + + await page.getByRole("button", { name: "Filter" }).click(); + await syncLV(page); + + await expect(await listItems(page)).toEqual(["items-b", "items-c", "items-d"]); + + await page.getByRole("button", { name: "Reset" }).click(); + await syncLV(page); + + await expect(await listItems(page)).toEqual(["items-a", "items-b", "items-c", "items-d"]); + }); + + test("can reorder stream", async ({ page }) => { + await page.goto("/stream/reset"); + await syncLV(page); + + await expect(await listItems(page)).toEqual(["items-a", "items-b", "items-c", "items-d"]); + + await page.getByRole("button", { name: "Reorder" }).click(); + await syncLV(page); + + await expect(await listItems(page)).toEqual(["items-b", "items-a", "items-c", "items-d"]); + }); + + test("can filter and then prepend / append stream", async ({ page }) => { + await page.goto("/stream/reset"); + await syncLV(page); + + await expect(await listItems(page)).toEqual(["items-a", "items-b", "items-c", "items-d"]); + + await page.getByRole("button", { name: "Filter" }).click(); + await syncLV(page); + + await expect(await listItems(page)).toEqual(["items-b", "items-c", "items-d"]); + + await page.getByRole("button", { name: "Prepend" }).click(); + await syncLV(page); + + await expect(await listItems(page)).toEqual([ + expect.stringMatching(/items-a-.*/), + "items-b", + "items-c", + "items-d" + ]); + + await page.getByRole("button", { name: "Reset" }).click(); + await syncLV(page); + + await expect(await listItems(page)).toEqual(["items-a", "items-b", "items-c", "items-d"]); + + await page.getByRole("button", { name: "Append" }).click(); + await syncLV(page); + + await expect(await listItems(page)).toEqual([ + "items-a", + "items-b", + "items-c", + "items-d", + expect.stringMatching(/items-a-.*/), + ]); + }); +}); + +test.describe("Issue #2982", () => { + const listItems = async (page) => page.locator("ul > li").evaluateAll(list => list.map(el => el.id)); + + test("can reorder a stream with LiveComponents as direct stream children", async ({ page }) => { + await page.goto("/stream/reset-lc"); + await syncLV(page); + + await expect(await listItems(page)).toEqual(["items-a", "items-b", "items-c", "items-d"]); + + await page.getByRole("button", { name: "Reorder" }).click(); + await syncLV(page); + + await expect(await listItems(page)).toEqual(["items-e", "items-a", "items-f", "items-g"]); + }); +}); diff --git a/test/e2e/tests/uploads.spec.js b/test/e2e/tests/uploads.spec.js new file mode 100644 index 0000000000..bcb3240dfb --- /dev/null +++ b/test/e2e/tests/uploads.spec.js @@ -0,0 +1,137 @@ +const { test, expect } = require("@playwright/test"); +const { syncLV } = require("../utils"); + +// https://stackoverflow.com/questions/10623798/how-do-i-read-the-contents-of-a-node-js-stream-into-a-string-variable +const readStream = (stream) => new Promise((resolve) => { + const chunks = []; + + stream.on("data", function (chunk) { + chunks.push(chunk); + }); + + // Send the buffer or you can put it into a var + stream.on("end", function () { + resolve(Buffer.concat(chunks)); + }); +}); + +test("can upload a file", async ({ page }) => { + await page.goto("/upload"); + await syncLV(page); + + await page.locator("#upload-form input").setInputFiles({ + name: "file.txt", + mimeType: "text/plain", + buffer: Buffer.from("this is a test") + }); + await syncLV(page); + await page.getByRole("button", { name: "Upload" }).click(); + + // we should see one uploaded file in the list + await expect(page.locator("ul li")).toBeVisible(); + + // now download the file to see if it contains the expected content + const downloadPromise = page.waitForEvent("download"); + await page.locator("ul li a").click(); + const download = await downloadPromise; + + await expect(download.createReadStream().then(readStream).then(buf => buf.toString())) + .resolves.toEqual("this is a test"); +}); + +test("can drop a file", async ({ page }) => { + await page.goto("/upload"); + await syncLV(page); + + // https://github.com/microsoft/playwright/issues/10667 + // Create the DataTransfer and File + const dataTransfer = await page.evaluateHandle((data) => { + const dt = new DataTransfer(); + // Convert the buffer to a hex array + const file = new File([data], 'file.txt', { type: 'text/plain' }); + dt.items.add(file); + return dt; + }, "this is a test"); + + // Now dispatch + await page.dispatchEvent("section", 'drop', { dataTransfer }); + + await syncLV(page); + await page.getByRole("button", { name: "Upload" }).click(); + + // we should see one uploaded file in the list + await expect(page.locator("ul li")).toBeVisible(); + + // now download the file to see if it contains the expected content + const downloadPromise = page.waitForEvent("download"); + await page.locator("ul li a").click(); + const download = await downloadPromise; + + await expect(download.createReadStream().then(readStream).then(buf => buf.toString())) + .resolves.toEqual("this is a test"); +}); + +test("can upload multiple files", async ({ page }) => { + await page.goto("/upload"); + await syncLV(page); + + await page.locator("#upload-form input").setInputFiles([ + { + name: "file.txt", + mimeType: "text/plain", + buffer: Buffer.from("this is a test") + }, + { + name: "file.md", + mimeType: "text/markdown", + buffer: Buffer.from("## this is a markdown file") + } + ]); + await syncLV(page); + await page.getByRole("button", { name: "Upload" }).click(); + + // we should see two uploaded files in the list + await expect(page.locator("ul li")).toHaveCount(2); +}); + +test("shows error when there are too many files", async ({ page }) => { + await page.goto("/upload"); + await syncLV(page); + + await page.locator("#upload-form input").setInputFiles([ + { + name: "file.txt", + mimeType: "text/plain", + buffer: Buffer.from("this is a test") + }, + { + name: "file.md", + mimeType: "text/markdown", + buffer: Buffer.from("## this is a markdown file") + }, + { + name: "file2.txt", + mimeType: "text/plain", + buffer: Buffer.from("another file") + } + ]); + await syncLV(page); + + await expect(page.locator(".alert")).toContainText("You have selected too many files"); +}); + +test("shows error for invalid mimetype", async ({ page }) => { + await page.goto("/upload"); + await syncLV(page); + + await page.locator("#upload-form input").setInputFiles([ + { + name: "file.html", + mimeType: "text/html", + buffer: Buffer.from("

Hi

") + } + ]); + await syncLV(page); + + await expect(page.locator(".alert")).toContainText("You have selected an unacceptable file type"); +}); diff --git a/test/e2e/utils.js b/test/e2e/utils.js new file mode 100644 index 0000000000..28df9bea0d --- /dev/null +++ b/test/e2e/utils.js @@ -0,0 +1,56 @@ +const { expect } = require("@playwright/test"); +const Crypto = require("crypto"); + +const randomString = (size = 21) => Crypto.randomBytes(size).toString("base64").slice(0, size); + +// a helper function to wait until the LV has no pending events +const syncLV = async (page) => { + const promises = [ + expect(page.locator(".phx-connected").first()).toBeVisible(), + expect(page.locator(".phx-change-loading")).toHaveCount(0), + expect(page.locator(".phx-click-loading")).toHaveCount(0), + expect(page.locator(".phx-submit-loading")).toHaveCount(0), + ]; + return Promise.all(promises); +}; + +const attributeMutations = (page, selector) => { + // this is a bit of a hack, basically we create a MutationObserver on the page + // that will record any changes to a selector until the promise is awaited + // + // we use a random id to store the resolve function in the window object + const id = randomString(24); + // this promise resolves to the mutation list + const promise = page.locator(selector).evaluate((target, id) => { + return new Promise((resolve) => { + const mutations = []; + let observer; + window[id] = () => { + if (observer) observer.disconnect(); + resolve(mutations); + delete window[id]; + }; + // https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver + observer = new MutationObserver((mutationsList, _observer) => { + mutationsList.forEach(mutation => { + if (mutation.type === "attributes") { + mutations.push({ + attr: mutation.attributeName, + oldValue: mutation.oldValue, + newValue: mutation.target.getAttribute(mutation.attributeName) + }); + } + }); + }).observe(target, { attributes: true, attributeOldValue: true }); + }); + }, id); + + return () => { + // we want to stop observing! + page.locator(selector).evaluate((_target, id) => window[id](), id); + // return the result of the initial promise + return promise; + }; +} + +module.exports = { randomString, syncLV, attributeMutations }; diff --git a/test/phoenix_live_view/integrations/stream_test.exs b/test/phoenix_live_view/integrations/stream_test.exs index ae4b4c4efd..94ea9d5082 100644 --- a/test/phoenix_live_view/integrations/stream_test.exs +++ b/test/phoenix_live_view/integrations/stream_test.exs @@ -104,7 +104,7 @@ defmodule Phoenix.LiveView.StreamTest do assert lv |> render() |> users_in_dom("admins") == [{"admins-2", "updated"}] end - test "should properly reset after a steam has been set after mount", %{conn: conn} do + test "should properly reset after a stream has been set after mount", %{conn: conn} do {:ok, lv, _} = live(conn, "/stream") assert lv |> element("#users div") |> has_element?() @@ -130,17 +130,17 @@ defmodule Phoenix.LiveView.StreamTest do {:ok, lv, _} = live(conn, "/stream") assert lv |> render() |> users_in_dom("users") == [ - {"users-1", "chris"}, - {"users-2", "callan"} - ] + {"users-1", "chris"}, + {"users-2", "callan"} + ] lv |> render_hook("reset-users-reorder", %{}) assert lv |> render() |> users_in_dom("users") == [ - {"users-3", "peter"}, - {"users-1", "chris"}, - {"users-4", "mona"} - ] + {"users-3", "peter"}, + {"users-1", "chris"}, + {"users-4", "mona"} + ] end test "stream reset on patch", %{conn: conn} do @@ -180,6 +180,49 @@ defmodule Phoenix.LiveView.StreamTest do assert has_element?(lv, "li", "Oranges") end + describe "issue #2994" do + test "can filter and reset a stream", %{conn: conn} do + {:ok, lv, html} = live(conn, "/stream/reset") + + assert ids_in_ul_list(html) == ["items-a", "items-b", "items-c", "items-d"] + + html = assert lv |> element("button", "Filter") |> render_click() + assert ids_in_ul_list(html) == ["items-b", "items-c", "items-d"] + + html = assert lv |> element("button", "Reset") |> render_click() + assert ids_in_ul_list(html) == ["items-a", "items-b", "items-c", "items-d"] + end + + test "can reorder stream", %{conn: conn} do + {:ok, lv, html} = live(conn, "/stream/reset") + + assert ids_in_ul_list(html) == ["items-a", "items-b", "items-c", "items-d"] + + html = assert lv |> element("button", "Reorder") |> render_click() + assert ids_in_ul_list(html) == ["items-b", "items-a", "items-c", "items-d"] + end + + test "can filter and then prepend / append stream", %{conn: conn} do + {:ok, lv, html} = live(conn, "/stream/reset") + + assert ids_in_ul_list(html) == ["items-a", "items-b", "items-c", "items-d"] + + html = assert lv |> element("button", "Filter") |> render_click() + assert ids_in_ul_list(html) == ["items-b", "items-c", "items-d"] + + html = assert lv |> element("button", "Prepend") |> render_click() + assert [<<"items-a-", _::binary>>, "items-b", "items-c", "items-d"] = ids_in_ul_list(html) + + html = assert lv |> element("button", "Reset") |> render_click() + assert ids_in_ul_list(html) == ["items-a", "items-b", "items-c", "items-d"] + + html = assert lv |> element("button", "Append") |> render_click() + + assert ["items-a", "items-b", "items-c", "items-d", <<"items-a-", _::binary>>] = + ids_in_ul_list(html) + end + end + describe "within live component" do test "stream operations", %{conn: conn} do {:ok, lv, _html} = live(conn, "/stream") @@ -265,17 +308,25 @@ defmodule Phoenix.LiveView.StreamTest do assert streams.c_users.deletes == [] assert_pruned_stream(lv) end + + test "issue #2982 - can reorder a stream with LiveComponents as direct stream children", %{conn: conn} do + {:ok, lv, html} = live(conn, "/stream/reset-lc") + + assert ids_in_ul_list(html) == ["items-a", "items-b", "items-c", "items-d"] + + html = assert lv |> element("button", "Reorder") |> render_click() + assert ids_in_ul_list(html) == ["items-e", "items-a", "items-f", "items-g"] + end end test "stream raises when attempting to consume ahead of for", %{conn: conn} do {:ok, lv, _html} = live(conn, "/stream") assert Phoenix.LiveViewTest.HooksLive.exits_with(lv, ArgumentError, fn -> - render_click(lv, "consume-stream-invalid", %{}) - end) =~ ~r/streams can only be consumed directly by a for comprehension/ + render_click(lv, "consume-stream-invalid", %{}) + end) =~ ~r/streams can only be consumed directly by a for comprehension/ end - defp assert_pruned_stream(lv) do stream = StreamLive.run(lv, fn socket -> {:reply, socket.assigns.streams.users, socket} end) assert stream.inserts == [] @@ -290,4 +341,11 @@ defmodule Phoenix.LiveView.StreamTest do {DOM.attribute(child, "id"), String.trim(text)} end) end + + defp ids_in_ul_list(html) do + html + |> DOM.parse() + |> DOM.all("ul > li") + |> Enum.map(fn child -> DOM.attribute(child, "id") end) + end end diff --git a/test/support/e2e/form_live.ex b/test/support/e2e/form_live.ex new file mode 100644 index 0000000000..065b02bfb0 --- /dev/null +++ b/test/support/e2e/form_live.ex @@ -0,0 +1,33 @@ +defmodule Phoenix.LiveViewTest.E2E.FormLive do + use Phoenix.LiveView + + @impl Phoenix.LiveView + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl Phoenix.LiveView + def handle_event("validate", _params, socket) do + {:noreply, socket} + end + + def handle_event("save", _params, socket) do + {:noreply, socket} + end + + def handle_event("button-test", _params, socket) do + {:noreply, socket} + end + + @impl Phoenix.LiveView + def render(assigns) do + ~H""" +
+ + + + +
+ """ + end +end diff --git a/test/support/e2e/upload_live.ex b/test/support/e2e/upload_live.ex new file mode 100644 index 0000000000..4e2f890ee5 --- /dev/null +++ b/test/support/e2e/upload_live.ex @@ -0,0 +1,68 @@ +defmodule Phoenix.LiveViewTest.E2E.UploadLive do + use Phoenix.LiveView + + # for end-to-end testing https://hexdocs.pm/phoenix_live_view/uploads.html + + @impl Phoenix.LiveView + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:uploaded_files, []) + |> allow_upload(:avatar, accept: ~w(.txt .md), max_entries: 2)} + end + + @impl Phoenix.LiveView + def handle_event("validate", _params, socket) do + {:noreply, socket} + end + + @impl Phoenix.LiveView + def handle_event("cancel-upload", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :avatar, ref)} + end + + @impl Phoenix.LiveView + def handle_event("save", _params, socket) do + uploaded_files = + consume_uploaded_entries(socket, :avatar, fn %{path: path}, _entry -> + dir = Path.join([System.tmp_dir!(), "lvupload"]) + _ = File.mkdir_p(dir) + dest = Path.join([dir, Path.basename(path)]) + File.cp!(path, dest) + {:ok, "/tmp/lvupload/#{Path.basename(dest)}"} + end) + + {:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded_files))} + end + + @impl Phoenix.LiveView + def render(assigns) do + ~H""" +
+ <.live_file_input upload={@uploads.avatar} /> + +
+ +
+
+
+ <.live_img_preview entry={entry} style="width: 500px" /> +
<%= entry.client_name %>
+
+ <%= entry.progress %>% + +

<%= error_to_string(err) %>

+
+

<%= error_to_string(err) %>

+
+ + + """ + end + + defp error_to_string(:too_large), do: "Too large" + defp error_to_string(:too_many_files), do: "You have selected too many files" + defp error_to_string(:not_accepted), do: "You have selected an unacceptable file type" +end diff --git a/test/support/live_views/streams.ex b/test/support/live_views/streams.ex index e1175e8f0b..f28f615921 100644 --- a/test/support/live_views/streams.ex +++ b/test/support/live_views/streams.ex @@ -33,6 +33,9 @@ defmodule Phoenix.LiveViewTest.StreamLive do <.live_component id="stream-component" module={Phoenix.LiveViewTest.StreamComponent} /> + + + """ end @@ -43,7 +46,7 @@ defmodule Phoenix.LiveViewTest.StreamLive do @append_users [ %{id: 4, name: "foo"}, - %{id: 3, name: "last_user"}, + %{id: 3, name: "last_user"} ] def mount(_params, _session, socket) do @@ -90,7 +93,8 @@ defmodule Phoenix.LiveViewTest.StreamLive do end def handle_event("reset-users-reorder", %{}, socket) do - {:noreply, stream(socket, :users, [user(3, "peter"), user(1, "chris"), user(4, "mona")], reset: true)} + {:noreply, + stream(socket, :users, [user(3, "peter"), user(1, "chris"), user(4, "mona")], reset: true)} end def handle_event("stream-users", _, socket) do @@ -253,3 +257,156 @@ defmodule Phoenix.LiveViewTest.HealthyLive do {:noreply, socket} end end + +defmodule Phoenix.LiveViewTest.StreamResetLive do + use Phoenix.LiveView + + # see https://github.com/phoenixframework/phoenix_live_view/issues/2994 + + def mount(_params, _session, socket) do + socket + |> stream(:items, [ + %{id: "a", name: "A"}, + %{id: "b", name: "B"}, + %{id: "c", name: "C"}, + %{id: "d", name: "D"} + ]) + |> then(&{:ok, &1}) + end + + def render(assigns) do + ~H""" + + + + + + + + """ + end + + def handle_event("filter", _, socket) do + {:noreply, + stream( + socket, + :items, + [ + %{id: "b", name: "B"}, + %{id: "c", name: "C"}, + %{id: "d", name: "D"} + ], + reset: true + )} + end + + def handle_event("reorder", _, socket) do + {:noreply, + stream( + socket, + :items, + [ + %{id: "b", name: "B"}, + %{id: "a", name: "A"}, + %{id: "c", name: "C"}, + %{id: "d", name: "D"} + ], + reset: true + )} + end + + def handle_event("reset", _, socket) do + {:noreply, + stream( + socket, + :items, + [ + %{id: "a", name: "A"}, + %{id: "b", name: "B"}, + %{id: "c", name: "C"}, + %{id: "d", name: "D"} + ], + reset: true + )} + end + + def handle_event("prepend", _, socket) do + {:noreply, + stream_insert( + socket, + :items, + %{id: "a" <> "#{System.unique_integer()}", name: "#{System.unique_integer()}"}, + at: 0 + )} + end + + def handle_event("append", _, socket) do + {:noreply, + stream_insert( + socket, + :items, + %{id: "a" <> "#{System.unique_integer()}", name: "#{System.unique_integer()}"}, + at: -1 + )} + end +end + +defmodule Phoenix.LiveViewTest.StreamResetLCLive do + use Phoenix.LiveView + + # see https://github.com/phoenixframework/phoenix_live_view/issues/2982 + + defmodule InnerComponent do + use Phoenix.LiveComponent + + def render(assigns) do + ~H""" +
  • + <%= @item.name %> +
  • + """ + end + end + + def mount(_params, _session, socket) do + socket + |> stream(:items, [ + %{id: "a", name: "A"}, + %{id: "b", name: "B"}, + %{id: "c", name: "C"}, + %{id: "d", name: "D"} + ]) + |> then(&{:ok, &1}) + end + + def handle_event("reorder", _, socket) do + socket = + stream( + socket, + :items, + [ + %{id: "e", name: "E"}, + %{id: "a", name: "A"}, + %{id: "f", name: "F"}, + %{id: "g", name: "G"} + ], + reset: true + ) + + {:noreply, socket} + end + + def render(assigns) do + ~H""" + + + + """ + end +end diff --git a/test/support/router.ex b/test/support/router.ex index bdefba3f1f..c01781ff79 100644 --- a/test/support/router.ex +++ b/test/support/router.ex @@ -124,6 +124,8 @@ defmodule Phoenix.LiveViewTest.Router do # integration stream live "/stream", StreamLive + live "/stream/reset", StreamResetLive + live "/stream/reset-lc", StreamResetLCLive # healthy live "/healthy/:category", HealthyLive