diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31f29237d1..d92e5ab692 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,9 @@ jobs: - name: npm install and test run: npm run js:test + - name: eslint + run: cd assets && npx eslint + - uses: actions/upload-artifact@v4 if: always() with: @@ -202,6 +205,9 @@ jobs: - name: Run e2e tests run: npm run e2e:test + - name: eslint + run: npx eslint + - uses: actions/upload-artifact@v4 if: always() with: diff --git a/assets/eslint.config.mjs b/assets/eslint.config.mjs index 61bc8757b3..866baa6b4a 100644 --- a/assets/eslint.config.mjs +++ b/assets/eslint.config.mjs @@ -1,23 +1,18 @@ import jest from "eslint-plugin-jest" import globals from "globals" -import path from "node:path" -import {fileURLToPath} from "node:url" import js from "@eslint/js" -import {FlatCompat} from "@eslint/eslintrc" -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const compat = new FlatCompat({ - baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, - allConfig: js.configs.all -}) +import sharedRules from "../eslint.rules.mjs" + +export default [{ + ...js.configs.recommended, -export default [...compat.extends("eslint:recommended"), { plugins: { jest, }, + ignores: ["coverage/**"], + languageOptions: { globals: { ...globals.browser, @@ -30,63 +25,6 @@ export default [...compat.extends("eslint:recommended"), { }, rules: { - indent: ["error", 2, { - SwitchCase: 1, - }], - - "linebreak-style": ["error", "unix"], - quotes: ["error", "double"], - semi: ["error", "never"], - - "object-curly-spacing": ["error", "never", { - objectsInObjects: false, - arraysInObjects: false, - }], - - "array-bracket-spacing": ["error", "never"], - - "comma-spacing": ["error", { - before: false, - after: true, - }], - - "computed-property-spacing": ["error", "never"], - - "space-before-blocks": ["error", { - functions: "never", - keywords: "never", - classes: "always", - }], - - "keyword-spacing": ["error", { - overrides: { - if: { - after: false, - }, - - for: { - after: false, - }, - - while: { - after: false, - }, - - switch: { - after: false, - }, - }, - }], - - "eol-last": ["error", "always"], - - "no-unused-vars": ["error", { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - }], - - "no-useless-escape": "off", - "no-cond-assign": "off", - "no-case-declarations": "off", + ...sharedRules }, }] diff --git a/assets/js/phoenix_live_view/aria.js b/assets/js/phoenix_live_view/aria.js index e535fd1766..9377c78728 100644 --- a/assets/js/phoenix_live_view/aria.js +++ b/assets/js/phoenix_live_view/aria.js @@ -12,7 +12,7 @@ let ARIA = { }, attemptFocus(el, interactiveOnly){ - if(this.isFocusable(el, interactiveOnly)){ try { el.focus() } catch (e){} } + if(this.isFocusable(el, interactiveOnly)){ try { el.focus() } catch {} } return !!document.activeElement && document.activeElement.isSameNode(el) }, diff --git a/assets/js/phoenix_live_view/dom.js b/assets/js/phoenix_live_view/dom.js index 4e7002deff..478a844581 100644 --- a/assets/js/phoenix_live_view/dom.js +++ b/assets/js/phoenix_live_view/dom.js @@ -21,8 +21,6 @@ import { THROTTLED, } from "./constants" -import JS from "./js" - import { logError } from "./utils" @@ -96,10 +94,10 @@ let DOM = { try { url = new URL(href) - } catch (e){ + } catch { try { url = new URL(href, currentLocation) - } catch (e){ + } catch { // bad URL, fallback to let browser try it as external return true } diff --git a/assets/js/phoenix_live_view/dom_patch.js b/assets/js/phoenix_live_view/dom_patch.js index fe93a977a6..bced1943d0 100644 --- a/assets/js/phoenix_live_view/dom_patch.js +++ b/assets/js/phoenix_live_view/dom_patch.js @@ -1,6 +1,5 @@ import { PHX_COMPONENT, - PHX_DISABLE_WITH, PHX_PRUNE, PHX_ROOT_ID, PHX_SESSION, diff --git a/assets/js/phoenix_live_view/js.js b/assets/js/phoenix_live_view/js.js index 3a6c330b0a..1a59c881ac 100644 --- a/assets/js/phoenix_live_view/js.js +++ b/assets/js/phoenix_live_view/js.js @@ -54,7 +54,7 @@ let JS = { }) }, - exec_dispatch(e, eventType, phxEvent, view, sourceEl, el, {to, event, detail, bubbles}){ + exec_dispatch(e, eventType, phxEvent, view, sourceEl, el, {event, detail, bubbles}){ detail = detail || {} detail.dispatcher = sourceEl DOM.dispatchEvent(el, event, {detail, bubbles}) @@ -101,7 +101,7 @@ let JS = { window.requestAnimationFrame(() => focusStack.push(el || sourceEl)) }, - exec_pop_focus(e, eventType, phxEvent, view, sourceEl, el){ + exec_pop_focus(_e, _eventType, _phxEvent, _view, _sourceEl, _el){ window.requestAnimationFrame(() => { const el = focusStack.pop() if(el){ el.focus() } @@ -116,7 +116,7 @@ let JS = { this.addOrRemoveClasses(el, [], names, transition, time, view, blocking) }, - exec_toggle_class(e, eventType, phxEvent, view, sourceEl, el, {to, names, transition, time, blocking}){ + exec_toggle_class(e, eventType, phxEvent, view, sourceEl, el, {names, transition, time, blocking}){ this.toggleClasses(el, names, transition, time, view, blocking) }, diff --git a/assets/js/phoenix_live_view/live_socket.js b/assets/js/phoenix_live_view/live_socket.js index 03e50b40e7..0d7cdfe9df 100644 --- a/assets/js/phoenix_live_view/live_socket.js +++ b/assets/js/phoenix_live_view/live_socket.js @@ -523,7 +523,7 @@ export default class LiveSocket { if(!dead){ this.bindNav() } this.bindClicks() if(!dead){ this.bindForms() } - this.bind({keyup: "keyup", keydown: "keydown"}, (e, type, view, targetEl, phxEvent, phxTarget) => { + this.bind({keyup: "keyup", keydown: "keydown"}, (e, type, view, targetEl, phxEvent, _phxTarget) => { let matchKey = targetEl.getAttribute(this.binding(PHX_KEY)) let pressedKey = e.key && e.key.toLowerCase() // chrome clicked autocompletes send a keydown without key if(matchKey && matchKey.toLowerCase() !== pressedKey){ return } diff --git a/assets/js/phoenix_live_view/view.js b/assets/js/phoenix_live_view/view.js index 1fca58a0ee..759c18434c 100644 --- a/assets/js/phoenix_live_view/view.js +++ b/assets/js/phoenix_live_view/view.js @@ -54,7 +54,6 @@ import DOMPatch from "./dom_patch" import LiveUploader from "./live_uploader" import Rendered from "./rendered" import ViewHook from "./view_hook" -import JS from "./js" export let prependFormDataKey = (key, prefix) => { let isArray = key.endsWith("[]") @@ -1128,7 +1127,7 @@ export default class View { event: phxEvent, value: this.extractMeta(el, meta, opts.value), cid: this.targetComponentID(el, targetCtx, opts) - }).then(({resp, reply}) => onReply && onReply(reply)) + }).then(({reply}) => onReply && onReply(reply)) } pushFileProgress(fileEl, entryRef, progress, onReply = function (){ }){ @@ -1275,7 +1274,7 @@ export default class View { } else if(LiveUploader.inputsAwaitingPreflight(formEl).length > 0){ let [ref, els] = refGenerator() let proxyRefGen = () => [ref, els, opts] - this.uploadFiles(formEl, phxEvent, targetCtx, ref, cid, (uploads) => { + this.uploadFiles(formEl, phxEvent, targetCtx, ref, cid, (_uploads) => { // if we still having pending preflights it means we have invalid entries // and the phx-submit cannot be completed if(LiveUploader.inputsAwaitingPreflight(formEl).length > 0){ diff --git a/assets/package-lock.json b/assets/package-lock.json index 496100471e..584ddbda64 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -15,7 +15,6 @@ "@babel/cli": "7.25.6", "@babel/core": "7.25.2", "@babel/preset-env": "7.25.4", - "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.10.0", "css.escape": "^1.5.1", "eslint": "9.10.0", diff --git a/assets/package.json b/assets/package.json index eb9d72d871..e011ba1064 100644 --- a/assets/package.json +++ b/assets/package.json @@ -16,7 +16,6 @@ "@babel/cli": "7.25.6", "@babel/core": "7.25.2", "@babel/preset-env": "7.25.4", - "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.10.0", "css.escape": "^1.5.1", "eslint": "9.10.0", diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000000..f41beeab1e --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,16 @@ +import playwright from "eslint-plugin-playwright" +import js from "@eslint/js" + +import sharedRules from "./eslint.rules.mjs" + +export default [{ + ...js.configs.recommended, + ...playwright.configs["flat/recommended"], + ignores: ["test/e2e/test-results/**"], + files: ["*.js", "*.mjs", "test/e2e/**"], + + rules: { + ...playwright.configs["flat/recommended"].rules, + ...sharedRules + }, +}] diff --git a/eslint.rules.mjs b/eslint.rules.mjs new file mode 100644 index 0000000000..c1929dc556 --- /dev/null +++ b/eslint.rules.mjs @@ -0,0 +1,60 @@ +export default { + indent: ["error", 2, { + SwitchCase: 1, + }], + + "linebreak-style": ["error", "unix"], + quotes: ["error", "double"], + semi: ["error", "never"], + + "object-curly-spacing": ["error", "never", { + objectsInObjects: false, + arraysInObjects: false, + }], + + "array-bracket-spacing": ["error", "never"], + + "comma-spacing": ["error", { + before: false, + after: true, + }], + + "computed-property-spacing": ["error", "never"], + + "space-before-blocks": ["error", { + functions: "never", + keywords: "never", + classes: "always", + }], + + "keyword-spacing": ["error", { + overrides: { + if: { + after: false, + }, + + for: { + after: false, + }, + + while: { + after: false, + }, + + switch: { + after: false, + }, + }, + }], + + "eol-last": ["error", "always"], + + "no-unused-vars": ["error", { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }], + + "no-useless-escape": "off", + "no-cond-assign": "off", + "no-case-declarations": "off", +} diff --git a/package-lock.json b/package-lock.json index 29a4e925be..12fb712ff2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,17 @@ { "name": "phoenix_live_view", - "version": "1.0.0-rc.6", + "version": "1.0.0-rc.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "phoenix_live_view", - "version": "1.0.0-rc.6", + "version": "1.0.0-rc.7", "license": "MIT", "devDependencies": { + "@eslint/js": "^9.10.0", "@playwright/test": "^1.47.1", + "eslint-plugin-playwright": "^2.1.0", "monocart-reporter": "^2.8.0" } }, @@ -19,6 +21,195 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "dev": true, + "peer": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "peer": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.0.tgz", + "integrity": "sha512-zdHg2FPIFNKPdcHWtiNT+jEFCHYVplAXRDlQDyqy0zGx/q2parwh7brGJSiTxRk/TSMkbM//zt/f5CHgyTyaSQ==", + "dev": true, + "peer": true, + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.0.tgz", + "integrity": "sha512-7ATR9F0e4W85D/0w7cU0SNj7qkAexMG+bAHEZOjo9akvGuhHE2m7umzWzfnpa0XAg5Kxc1BWmtPMV67jJ+9VUg==", + "dev": true, + "peer": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", + "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "dev": true, + "peer": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.15.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.15.0.tgz", + "integrity": "sha512-tMTqrY+EzbXmKJR5ToI8lxu7jaN5EdmrBFJpQk5JmSlyLsx6o4t27r883K5xsLuCYCpfKBCGswMSWXsM+jB7lg==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, + "peer": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.3.tgz", + "integrity": "sha512-2b/g5hRmpbb1o4GnTZax9N9m0FXzz9OV42ZzI4rDDMDuHUqigAiQCEWChBWCY4ztAGVRjoWT19v0yMmc5/L5kA==", + "dev": true, + "peer": true, + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "peer": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", + "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -40,6 +231,20 @@ "node": ">=18" } }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "peer": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "peer": true + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -54,9 +259,9 @@ } }, "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -65,6 +270,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peer": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn-loose": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.4.0.tgz", @@ -89,6 +304,64 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "peer": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "peer": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/cache-content-type": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", @@ -102,6 +375,33 @@ "node": ">= 6.0.0" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -112,6 +412,26 @@ "node": ">= 0.12.0" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "peer": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "peer": true + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -121,6 +441,13 @@ "node": ">=18" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "peer": true + }, "node_modules/console-grid": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/console-grid/-/console-grid-2.2.2.tgz", @@ -162,9 +489,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "dependencies": { "path-key": "^3.1.0", @@ -198,6 +525,13 @@ "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", "dev": true }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "peer": true + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -250,6 +584,275 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "dev": true }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.15.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.15.0.tgz", + "integrity": "sha512-7CrWySmIibCgT1Os28lUU6upBshZ+GxybLOrmRzi08kS8MBuO8QA7pXEgYgY5W8vK3e74xv0lpjo9DbaGU9Rkw==", + "dev": true, + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.0", + "@eslint/core": "^0.9.0", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "9.15.0", + "@eslint/plugin-kit": "^0.2.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.5", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-playwright": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.1.0.tgz", + "integrity": "sha512-wMbHOehofSB1cBdzz2CLaCYaKNLeTQ0YnOW+7AHa281TJqlpEJUBgTHbRUYOUxiXphfWwOyTPvgr6vvEmArbSA==", + "dev": true, + "dependencies": { + "globals": "^13.23.0" + }, + "engines": { + "node": ">=16.6.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, + "node_modules/eslint-plugin-playwright/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", + "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "dev": true, + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "peer": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "peer": true, + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "peer": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "peer": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "peer": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "peer": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "peer": true + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "peer": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "peer": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "peer": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "dev": true, + "peer": true + }, "node_modules/foreground-child": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", @@ -289,6 +892,32 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "peer": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -369,12 +998,59 @@ "node": ">= 0.6" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "peer": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-generator-function": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", @@ -390,6 +1066,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "peer": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -432,6 +1121,40 @@ "node": ">=8" } }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "peer": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "peer": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "peer": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "peer": true + }, "node_modules/keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -444,6 +1167,16 @@ "node": ">= 0.6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "peer": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/koa": { "version": "2.15.3", "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.3.tgz", @@ -503,6 +1236,43 @@ "integrity": "sha512-ZX5RshSzH8nFn05/vUNQzqw32nEigsPa67AVUr6ZuQxuGdnCcTLcdgr4C81+YbJjpgqKHfacMBd7NmJIbj7fXw==", "dev": true }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "peer": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "peer": true + }, "node_modules/lz-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lz-utils/-/lz-utils-2.1.0.tgz", @@ -554,6 +1324,19 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/monocart-coverage-reports": { "version": "2.10.5", "resolved": "https://registry.npmjs.org/monocart-coverage-reports/-/monocart-coverage-reports-2.10.5.tgz", @@ -610,6 +1393,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "peer": true + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -646,6 +1436,69 @@ "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==", "dev": true }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "peer": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "peer": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "peer": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "peer": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -655,6 +1508,16 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -694,6 +1557,36 @@ "node": ">=18" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -774,6 +1667,19 @@ "node": ">= 0.6" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -804,6 +1710,31 @@ "node": ">=0.6.x" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -817,6 +1748,16 @@ "node": ">= 0.6" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "peer": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -841,6 +1782,16 @@ "node": ">= 8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ylru": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz", @@ -849,6 +1800,19 @@ "engines": { "node": ">= 4.0.0" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 6a35d58ef7..34cecf1c5f 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "assets/js/phoenix_live_view/*" ], "devDependencies": { + "@eslint/js": "^9.10.0", "@playwright/test": "^1.47.1", + "eslint-plugin-playwright": "^2.1.0", "monocart-reporter": "^2.8.0" }, "scripts": { diff --git a/test/e2e/merge-coverage.mjs b/test/e2e/merge-coverage.mjs index 081f11c73a..0978100ef4 100644 --- a/test/e2e/merge-coverage.mjs +++ b/test/e2e/merge-coverage.mjs @@ -1,5 +1,4 @@ -import fs from "fs"; -import { CoverageReport } from "monocart-coverage-reports"; +import {CoverageReport} from "monocart-coverage-reports" const coverageOptions = { name: "Phoenix LiveView JS Coverage", @@ -13,11 +12,11 @@ const coverageOptions = { ["console-summary"] ], sourcePath: (filePath) => { - if (!filePath.startsWith("assets")) { - return "assets/js/phoenix_live_view/" + filePath; + if(!filePath.startsWith("assets")){ + return "assets/js/phoenix_live_view/" + filePath } else { - return filePath; + return filePath } }, -}; -await new CoverageReport(coverageOptions).generate(); +} +await new CoverageReport(coverageOptions).generate() diff --git a/test/e2e/playwright.config.js b/test/e2e/playwright.config.js index 4fcdbb46b1..d0a39eea51 100644 --- a/test/e2e/playwright.config.js +++ b/test/e2e/playwright.config.js @@ -1,14 +1,14 @@ // playwright.config.js // @ts-check -const { devices } = require("@playwright/test"); +const {devices} = require("@playwright/test") /** @type {import("@playwright/test").ReporterDescription} */ const monocartReporter = ["monocart-reporter", { name: "Phoenix LiveView", - outputFile: './test-results/report.html', + outputFile: "./test-results/report.html", coverage: { reports: [ - ["raw", { outputDir: "./raw" }], + ["raw", {outputDir: "./raw"}], ["v8"], ], entryFilter: (entry) => entry.url.indexOf("phoenix_live_view.esm.js") !== -1, @@ -36,19 +36,19 @@ const config = { projects: [ { name: "chromium", - use: { ...devices["Desktop Chrome"] }, + use: {...devices["Desktop Chrome"]}, }, { name: "firefox", - use: { ...devices["Desktop Firefox"] }, + use: {...devices["Desktop Firefox"]}, }, { name: "webkit", - use: { ...devices["Desktop Safari"] }, + use: {...devices["Desktop Safari"]}, } ], outputDir: "test-results", globalTeardown: require.resolve("./teardown") -}; +} -module.exports = config; +module.exports = config diff --git a/test/e2e/teardown.js b/test/e2e/teardown.js index 27836577ab..6a74d85d14 100644 --- a/test/e2e/teardown.js +++ b/test/e2e/teardown.js @@ -1,13 +1,13 @@ -const request = require("@playwright/test").request; +const request = require("@playwright/test").request module.exports = async () => { try { - const context = await request.newContext({ baseURL: "http://localhost:4004" }); + const context = await request.newContext({baseURL: "http://localhost:4004"}) // gracefully stops the e2e script to export coverage await context.post("/halt") - } catch (e) { + } catch { // we expect the request to fail because the request // actually stops the server return } -}; +} diff --git a/test/e2e/test-fixtures.js b/test/e2e/test-fixtures.js index e667c0e27a..fc93057848 100644 --- a/test/e2e/test-fixtures.js +++ b/test/e2e/test-fixtures.js @@ -1,21 +1,21 @@ // see https://github.com/cenfun/monocart-reporter?tab=readme-ov-file#global-coverage-report -import { test as testBase, expect } from "@playwright/test"; -import { addCoverageReport } from "monocart-reporter"; +import {test as testBase, expect} from "@playwright/test" +import {addCoverageReport} from "monocart-reporter" -import fs from "node:fs"; -import path from "node:path"; +import fs from "node:fs" +import path from "node:path" -const liveViewSourceMap = JSON.parse(fs.readFileSync(path.resolve(__dirname + "../../../priv/static/phoenix_live_view.esm.js.map")).toString("utf-8")); +const liveViewSourceMap = JSON.parse(fs.readFileSync(path.resolve(__dirname + "../../../priv/static/phoenix_live_view.esm.js.map")).toString("utf-8")) const test = testBase.extend({ - autoTestFixture: [async ({ page, browserName }, use) => { + autoTestFixture: [async ({page, browserName}, use) => { // NOTE: it depends on your project name - const isChromium = browserName === "chromium"; + const isChromium = browserName === "chromium" // console.log("autoTestFixture setup..."); // coverage API is chromium only - if (isChromium) { + if(isChromium){ await Promise.all([ page.coverage.startJSCoverage({ resetOnNavigation: false @@ -23,31 +23,31 @@ const test = testBase.extend({ page.coverage.startCSSCoverage({ resetOnNavigation: false }) - ]); + ]) } - await use("autoTestFixture"); + await use("autoTestFixture") // console.log("autoTestFixture teardown..."); - if (isChromium) { + if(isChromium){ const [jsCoverage, cssCoverage] = await Promise.all([ page.coverage.stopJSCoverage(), page.coverage.stopCSSCoverage() - ]); + ]) jsCoverage.forEach((entry) => { // read sourcemap for the phoenix_live_view.esm.js manually - if (entry.url.endsWith("phoenix_live_view.esm.js")) { - entry.sourceMap = liveViewSourceMap; + if(entry.url.endsWith("phoenix_live_view.esm.js")){ + entry.sourceMap = liveViewSourceMap } - }); - const coverageList = [...jsCoverage, ...cssCoverage]; + }) + const coverageList = [...jsCoverage, ...cssCoverage] // console.log(coverageList.map((item) => item.url)); - await addCoverageReport(coverageList, test.info()); + await addCoverageReport(coverageList, test.info()) } }, { scope: "test", auto: true }] -}); -export { test, expect }; +}) +export {test, expect} diff --git a/test/e2e/tests/errors.spec.js b/test/e2e/tests/errors.spec.js index 3df6d3e1f1..37d9930bbf 100644 --- a/test/e2e/tests/errors.spec.js +++ b/test/e2e/tests/errors.spec.js @@ -1,38 +1,38 @@ -const { test, expect } = require("../test-fixtures"); -const { syncLV } = require("../utils"); +const {test, expect} = require("../test-fixtures") +const {syncLV} = require("../utils") /** * https://hexdocs.pm/phoenix_live_view/error-handling.html */ test.describe("exception handling", () => { - let webSocketEvents = []; - let networkEvents = []; - let consoleMessages = []; + let webSocketEvents = [] + let networkEvents = [] + let consoleMessages = [] - test.beforeEach(async ({ page }) => { - networkEvents = []; - webSocketEvents = []; - consoleMessages = []; + test.beforeEach(async ({page}) => { + networkEvents = [] + webSocketEvents = [] + consoleMessages = [] - page.on("request", request => networkEvents.push({ method: request.method(), url: request.url() })); + 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" })); - }); + 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"})) + }) - page.on("console", msg => consoleMessages.push(msg.text())); - }); + page.on("console", msg => consoleMessages.push(msg.text())) + }) test.describe("during HTTP mount", () => { - test("500 error when dead mount fails", async ({ page }) => { + test("500 error when dead mount fails", async ({page}) => { page.on("response", response => { - expect(response.status()).toBe(500); - }); - await page.goto("/errors?dead-mount=raise"); - }); - }); + expect(response.status()).toBe(500) + }) + await page.goto("/errors?dead-mount=raise") + }) + }) test.describe("during connected mount", () => { /** @@ -46,22 +46,22 @@ test.describe("exception handling", () => { * the page will be reloaded without giving up, but the duration is set to 30s * by default. */ - test("reloads the page when connected mount fails", async ({ page }) => { - await page.goto("/errors?connected-mount=raise"); + test("reloads the page when connected mount fails", async ({page}) => { + await page.goto("/errors?connected-mount=raise") // the page was loaded once - await expect(networkEvents).toEqual([ - { method: "GET", url: "http://localhost:4004/errors?connected-mount=raise" }, - { method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js" }, - { method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js" }, - ]); + expect(networkEvents).toEqual([ + {method: "GET", url: "http://localhost:4004/errors?connected-mount=raise"}, + {method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js"}, + {method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js"}, + ]) - networkEvents = []; + networkEvents = [] - await page.waitForTimeout(2000); + await page.waitForTimeout(2000) // the page was reloaded 5 times - await expect(networkEvents).toEqual(expect.arrayContaining([ + expect(networkEvents).toEqual(expect.arrayContaining([ { "method": "GET", "url": "http://localhost:4004/errors?connected-mount=raise" @@ -90,196 +90,196 @@ test.describe("exception handling", () => { "method": "GET", "url": "http://localhost:4004/errors?connected-mount=raise" } - ])); + ])) - await expect(consoleMessages).toEqual(expect.arrayContaining([ + expect(consoleMessages).toEqual(expect.arrayContaining([ expect.stringMatching(/consecutive reloads. Entering failsafe mode/) - ])); - }); + ])) + }) /** * TBD: if the connected mount of the main LV succeeds, but a child LV fails * on mount, we only try to rejoin the child LV instead of reloading the page. */ - test("rejoin instead of reload when child LV fails on connected mount", async ({ page }) => { - await page.goto("/errors?connected-child-mount-raise=2"); - await page.waitForTimeout(2000); + test("rejoin instead of reload when child LV fails on connected mount", async ({page}) => { + await page.goto("/errors?connected-child-mount-raise=2") + await page.waitForTimeout(2000) - await expect(consoleMessages).toEqual(expect.arrayContaining([ + expect(consoleMessages).toEqual(expect.arrayContaining([ expect.stringMatching(/mount/), expect.stringMatching(/child error: unable to join/), expect.stringMatching(/child error: unable to join/), // third time's the charm expect.stringMatching(/child mount/), - ])); + ])) // page was not reloaded - await expect(networkEvents).toEqual([ - { method: "GET", url: "http://localhost:4004/errors?connected-child-mount-raise=2" }, - { method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js" }, - { method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js" }, - ]); - }); + expect(networkEvents).toEqual([ + {method: "GET", url: "http://localhost:4004/errors?connected-child-mount-raise=2"}, + {method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js"}, + {method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js"}, + ]) + }) /** * TBD: if the connected mount of the main LV succeeds, but a child LV fails * repeatedly, we reload the page. Maybe we should give up without reloading the page? */ - test("abandons child remount if child LV fails multiple times", async ({ page }) => { - await page.goto("/errors?connected-child-mount-raise=5"); + test("abandons child remount if child LV fails multiple times", async ({page}) => { + await page.goto("/errors?connected-child-mount-raise=5") // maybe we can find a better way than waiting for a fixed amount of time - await page.waitForTimeout(1000); + await page.waitForTimeout(1000) - await expect(consoleMessages.filter(m => m.startsWith("child "))).toEqual([ + expect(consoleMessages.filter(m => m.startsWith("child "))).toEqual([ expect.stringContaining("child error: unable to join"), expect.stringContaining("child error: unable to join"), expect.stringContaining("child error: unable to join"), // maxChildJoinTries is 3, we count from 0, so the 4th try is the last expect.stringContaining("child error: giving up"), expect.stringContaining("child destroyed"), - ]); + ]) // page remained loaded without parent failsafe reload - await expect(networkEvents).toEqual([ + expect(networkEvents).toEqual([ // initial load - { method: "GET", url: "http://localhost:4004/errors?connected-child-mount-raise=5" }, - { method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js" }, - { method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js" }, - ]); - }); - }); + {method: "GET", url: "http://localhost:4004/errors?connected-child-mount-raise=5"}, + {method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js"}, + {method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js"}, + ]) + }) + }) test.describe("after connected mount", () => { /** * When a child LV crashes after the connected mount, the parent LV is not * affected. The child LV is simply remounted. */ - test("page does not reload if child LV crashes (handle_event)", async ({ page }) => { - await page.goto("/errors?child"); - await syncLV(page); + test("page does not reload if child LV crashes (handle_event)", async ({page}) => { + await page.goto("/errors?child") + await syncLV(page) - const parentTime = await page.locator("#render-time").innerText(); - const childTime = await page.locator("#child-render-time").innerText(); + const parentTime = await page.locator("#render-time").innerText() + const childTime = await page.locator("#child-render-time").innerText() // both lvs mounted, no other messages - await expect(consoleMessages).toEqual(expect.arrayContaining([ + expect(consoleMessages).toEqual(expect.arrayContaining([ expect.stringMatching(/mount/), expect.stringMatching(/child mount/), - ])); - consoleMessages = []; + ])) + consoleMessages = [] - await page.getByRole("button", { name: "Crash child" }).click(); - await syncLV(page); + await page.getByRole("button", {name: "Crash child"}).click() + await syncLV(page) // child crashed and re-rendered - const newChildTime = await page.locator("#child-render-time").innerText(); - expect(newChildTime).not.toEqual(childTime); - await expect(consoleMessages).toEqual([ + const newChildTime = page.locator("#child-render-time") + await expect(newChildTime).not.toHaveText(childTime) + expect(consoleMessages).toEqual([ expect.stringMatching(/child error: view crashed/), expect.stringMatching(/child mount/), - ]); + ]) // parent did not re-render - const newParentTiem = await page.locator("#render-time").innerText(); - expect(newParentTiem).toEqual(parentTime); + const newParentTiem = page.locator("#render-time") + await expect(newParentTiem).toHaveText(parentTime) // page was not reloaded - await expect(networkEvents).toEqual([ - { method: "GET", url: "http://localhost:4004/errors?child" }, - { method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js" }, - { method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js" }, - ]); - }); + expect(networkEvents).toEqual([ + {method: "GET", url: "http://localhost:4004/errors?child"}, + {method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js"}, + {method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js"}, + ]) + }) /** * When the main LV crashes after the connected mount, the page is not reloaded. * The main LV is simply remounted over the existing transport. */ - test("page does not reload if main LV crashes (handle_event)", async ({ page }) => { - await page.goto("/errors?child"); - await syncLV(page); + test("page does not reload if main LV crashes (handle_event)", async ({page}) => { + await page.goto("/errors?child") + await syncLV(page) - const parentTime = await page.locator("#render-time").innerText(); - const childTime = await page.locator("#child-render-time").innerText(); + const parentTime = await page.locator("#render-time").innerText() + const childTime = await page.locator("#child-render-time").innerText() // both lvs mounted, no other messages - await expect(consoleMessages).toEqual(expect.arrayContaining([ + expect(consoleMessages).toEqual(expect.arrayContaining([ expect.stringMatching(/mount/), expect.stringMatching(/child mount/), - ])); - consoleMessages = []; + ])) + consoleMessages = [] - await page.getByRole("button", { name: "Crash main" }).click(); - await syncLV(page); + await page.getByRole("button", {name: "Crash main"}).click() + await syncLV(page) // main and child re-rendered (full page refresh) - const newChildTime = await page.locator("#child-render-time").innerText(); - expect(newChildTime).not.toEqual(childTime); - const newParentTiem = await page.locator("#render-time").innerText(); - expect(newParentTiem).not.toEqual(parentTime); + const newChildTime = page.locator("#child-render-time") + await expect(newChildTime).not.toHaveText(childTime) + const newParentTiem = page.locator("#render-time") + await expect(newParentTiem).not.toHaveText(parentTime) - await expect(consoleMessages).toEqual([ + expect(consoleMessages).toEqual([ expect.stringMatching(/child destroyed/), expect.stringMatching(/error: view crashed/), expect.stringMatching(/mount/), expect.stringMatching(/child mount/), - ]); + ]) // page was not reloaded - await expect(networkEvents).toEqual([ - { method: "GET", url: "http://localhost:4004/errors?child" }, - { method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js" }, - { method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js" }, - ]); - }); + expect(networkEvents).toEqual([ + {method: "GET", url: "http://localhost:4004/errors?child"}, + {method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js"}, + {method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js"}, + ]) + }) /** * When the main LV mounts successfully, but a child LV crashes which is linked * to the parent, the parent LV crashed too, triggering a remount of both. */ - test("parent crashes and reconnects when linked child LV crashes", async ({ page }) => { - await page.goto("/errors?connected-child-mount-raise=link"); - await syncLV(page); + test("parent crashes and reconnects when linked child LV crashes", async ({page}) => { + await page.goto("/errors?connected-child-mount-raise=link") + await syncLV(page) // child crashed on mount, linked to parent -> parent crashed too // second mounts are successful - await expect(consoleMessages).toEqual(expect.arrayContaining([ + expect(consoleMessages).toEqual(expect.arrayContaining([ expect.stringMatching(/mount/), expect.stringMatching(/child error: unable to join/), expect.stringMatching(/child destroyed/), expect.stringMatching(/error: view crashed/), expect.stringMatching(/mount/), expect.stringMatching(/child mount/), - ])); - consoleMessages = []; + ])) + consoleMessages = [] - const parentTime = await page.locator("#render-time").innerText(); - const childTime = await page.locator("#child-render-time").innerText(); + const parentTime = await page.locator("#render-time").innerText() + const childTime = await page.locator("#child-render-time").innerText() // the processes are still linked, crashing the child again crashes the parent - await page.getByRole("button", { name: "Crash child" }).click(); - await syncLV(page); + await page.getByRole("button", {name: "Crash child"}).click() + await syncLV(page) // main and child re-rendered (full page refresh) - const newChildTime = await page.locator("#child-render-time").innerText(); - expect(newChildTime).not.toEqual(childTime); - const newParentTiem = await page.locator("#render-time").innerText(); - expect(newParentTiem).not.toEqual(parentTime); + const newChildTime = page.locator("#child-render-time") + await expect(newChildTime).not.toHaveText(childTime) + const newParentTiem = page.locator("#render-time") + await expect(newParentTiem).not.toHaveText(parentTime) - await expect(consoleMessages).toEqual([ + expect(consoleMessages).toEqual([ expect.stringMatching(/child error: view crashed/), expect.stringMatching(/child destroyed/), expect.stringMatching(/error: view crashed/), expect.stringMatching(/mount/), expect.stringMatching(/child mount/), - ]); + ]) // page was not reloaded - await expect(networkEvents).toEqual([ - { method: "GET", url: "http://localhost:4004/errors?connected-child-mount-raise=link" }, - { method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js" }, - { method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js" }, - ]); - }); - }); -}); + expect(networkEvents).toEqual([ + {method: "GET", url: "http://localhost:4004/errors?connected-child-mount-raise=link"}, + {method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js"}, + {method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js"}, + ]) + }) + }) +}) diff --git a/test/e2e/tests/forms.spec.js b/test/e2e/tests/forms.spec.js index eb61da8787..77a4f49490 100644 --- a/test/e2e/tests/forms.spec.js +++ b/test/e2e/tests/forms.spec.js @@ -1,198 +1,198 @@ -const { test, expect } = require("../test-fixtures"); -const { syncLV, evalLV, evalPlug, attributeMutations } = require("../utils"); +const {test, expect} = require("../test-fixtures") +const {syncLV, evalLV, evalPlug, attributeMutations} = require("../utils") -for (let path of ["/form/nested", "/form"]) { +for(let path of ["/form/nested", "/form"]){ // see also https://github.com/phoenixframework/phoenix_live_view/issues/1759 // https://github.com/phoenixframework/phoenix_live_view/issues/2993 test.describe("restores disabled and readonly states", () => { - test(`${path} - readonly state is restored after submits`, async ({ page }) => { - await page.goto(path); - 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]"); + test(`${path} - readonly state is restored after submits`, async ({page}) => { + await page.goto(path) + 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("#submit").click(); - await syncLV(page); + await page.locator("#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: "" }, - ])); + 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("#submit").click(); - await syncLV(page); - await expect(page.locator("input[name=a]")).toHaveAttribute("readonly"); - }); - - test(`${path} - button disabled state is restored after submits`, async ({ page }) => { - await page.goto(path); - await syncLV(page); - let changes = attributeMutations(page, "#submit"); - await page.locator("#submit").click(); - await syncLV(page); + 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("#submit").click() + await syncLV(page) + await expect(page.locator("input[name=a]")).toHaveAttribute("readonly") + }) + + test(`${path} - button disabled state is restored after submits`, async ({page}) => { + await page.goto(path) + await syncLV(page) + let changes = attributeMutations(page, "#submit") + await page.locator("#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(`${path} - non-form button (phx-disable-with) disabled state is restored after click`, async ({ page }) => { - await page.goto(path); - await syncLV(page); - let changes = attributeMutations(page, "button[type=button]"); - await page.locator("button[type=button]").click(); - await syncLV(page); + 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(`${path} - non-form button (phx-disable-with) disabled state is restored after click`, async ({page}) => { + await page.goto(path) + 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 }, - ])); - }); - }); - - for (let additionalParams of ["live-component", ""]) { - let append = additionalParams.length ? ` ${additionalParams}` : ""; + 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}, + ])) + }) + }) + + for(let additionalParams of ["live-component", ""]){ + let append = additionalParams.length ? ` ${additionalParams}` : "" test.describe(`${path}${append} - form recovery`, () => { - test("form state is recovered when socket reconnects", async ({ page }) => { - let webSocketEvents = []; + test("form state is recovered when socket reconnects", async ({page}) => { + let webSocketEvents = [] 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" })); - }); + 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"})) + }) - await page.goto(path + "?" + additionalParams); - await syncLV(page); + await page.goto(path + "?" + additionalParams) + await syncLV(page) - await page.locator("input[name=b]").fill("test"); - await syncLV(page); + await page.locator("input[name=b]").fill("test") + await syncLV(page) - await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))); - await expect(page.locator(".phx-loading")).toHaveCount(1); + await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) + await expect(page.locator(".phx-loading")).toHaveCount(1) - await expect(webSocketEvents).toEqual(expect.arrayContaining([ - { type: "sent", payload: expect.stringContaining("phx_join") }, - { type: "received", payload: expect.stringContaining("phx_reply") }, - { type: "close" }, + expect(webSocketEvents).toEqual(expect.arrayContaining([ + {type: "sent", payload: expect.stringContaining("phx_join")}, + {type: "received", payload: expect.stringContaining("phx_reply")}, + {type: "close"}, ])) - webSocketEvents = []; + webSocketEvents = [] - await page.evaluate(() => window.liveSocket.connect()); - await syncLV(page); - await expect(page.locator(".phx-loading")).toHaveCount(0); + await page.evaluate(() => window.liveSocket.connect()) + await syncLV(page) + await expect(page.locator(".phx-loading")).toHaveCount(0) - await expect(page.locator("input[name=b]")).toHaveValue("test"); + await expect(page.locator("input[name=b]")).toHaveValue("test") - await expect(webSocketEvents).toEqual(expect.arrayContaining([ - { type: "sent", payload: expect.stringContaining("phx_join") }, - { type: "received", payload: expect.stringContaining("phx_reply") }, - { type: "sent", payload: expect.stringMatching(/event.*_unused_a=&a=foo&_unused_b=&b=test/) }, + expect(webSocketEvents).toEqual(expect.arrayContaining([ + {type: "sent", payload: expect.stringContaining("phx_join")}, + {type: "received", payload: expect.stringContaining("phx_reply")}, + {type: "sent", payload: expect.stringMatching(/event.*_unused_a=&a=foo&_unused_b=&b=test/)}, ])) - }); + }) - test("does not recover when form is missing id", async ({ page }) => { - await page.goto(`${path}?no-id&${additionalParams}`); - await syncLV(page); + test("does not recover when form is missing id", async ({page}) => { + await page.goto(`${path}?no-id&${additionalParams}`) + await syncLV(page) - await page.locator("input[name=b]").fill("test"); - await syncLV(page); + await page.locator("input[name=b]").fill("test") + await syncLV(page) - await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))); - await expect(page.locator(".phx-loading")).toHaveCount(1); + await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) + await expect(page.locator(".phx-loading")).toHaveCount(1) - await page.evaluate(() => window.liveSocket.connect()); - await syncLV(page); - await expect(page.locator(".phx-loading")).toHaveCount(0); + await page.evaluate(() => window.liveSocket.connect()) + await syncLV(page) + await expect(page.locator(".phx-loading")).toHaveCount(0) - await expect(page.locator("input[name=b]")).toHaveValue("bar"); - }); + await expect(page.locator("input[name=b]")).toHaveValue("bar") + }) - test("does not recover when form is missing phx-change", async ({ page }) => { - await page.goto(`${path}?no-change-event&${additionalParams}`); - await syncLV(page); + test("does not recover when form is missing phx-change", async ({page}) => { + await page.goto(`${path}?no-change-event&${additionalParams}`) + await syncLV(page) - await page.locator("input[name=b]").fill("test"); - await syncLV(page); + await page.locator("input[name=b]").fill("test") + await syncLV(page) - await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))); - await expect(page.locator(".phx-loading")).toHaveCount(1); + await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) + await expect(page.locator(".phx-loading")).toHaveCount(1) - await page.evaluate(() => window.liveSocket.connect()); - await syncLV(page); - await expect(page.locator(".phx-loading")).toHaveCount(0); + await page.evaluate(() => window.liveSocket.connect()) + await syncLV(page) + await expect(page.locator(".phx-loading")).toHaveCount(0) - await expect(page.locator("input[name=b]")).toHaveValue("bar"); - }); + await expect(page.locator("input[name=b]")).toHaveValue("bar") + }) - test("phx-auto-recover", async ({ page }) => { - await page.goto(`${path}?phx-auto-recover=custom-recovery&${additionalParams}`); - await syncLV(page); + test("phx-auto-recover", async ({page}) => { + await page.goto(`${path}?phx-auto-recover=custom-recovery&${additionalParams}`) + await syncLV(page) - await page.locator("input[name=b]").fill("test"); - await syncLV(page); + await page.locator("input[name=b]").fill("test") + await syncLV(page) - await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))); - await expect(page.locator(".phx-loading")).toHaveCount(1); + await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) + await expect(page.locator(".phx-loading")).toHaveCount(1) - let webSocketEvents = []; + let webSocketEvents = [] 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" })); - }); + 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"})) + }) - await page.evaluate(() => window.liveSocket.connect()); - await syncLV(page); - await expect(page.locator(".phx-loading")).toHaveCount(0); + await page.evaluate(() => window.liveSocket.connect()) + await syncLV(page) + await expect(page.locator(".phx-loading")).toHaveCount(0) - await expect(page.locator("input[name=b]")).toHaveValue("custom value from server"); + await expect(page.locator("input[name=b]")).toHaveValue("custom value from server") - await expect(webSocketEvents).toEqual(expect.arrayContaining([ - { type: "sent", payload: expect.stringContaining("phx_join") }, - { type: "received", payload: expect.stringContaining("phx_reply") }, - { type: "sent", payload: expect.stringMatching(/event.*_unused_a=&a=foo&_unused_b=&b=test/) }, + expect(webSocketEvents).toEqual(expect.arrayContaining([ + {type: "sent", payload: expect.stringContaining("phx_join")}, + {type: "received", payload: expect.stringContaining("phx_reply")}, + {type: "sent", payload: expect.stringMatching(/event.*_unused_a=&a=foo&_unused_b=&b=test/)}, ])) - }); + }) }) } - test(`${path} - can submit form with button that has phx-click`, async ({ page }) => { - await page.goto(`${path}?phx-auto-recover=custom-recovery`); - await syncLV(page); + test(`${path} - can submit form with button that has phx-click`, async ({page}) => { + await page.goto(`${path}?phx-auto-recover=custom-recovery`) + await syncLV(page) - await expect(page.getByText("Form was submitted!")).not.toBeVisible(); + await expect(page.getByText("Form was submitted!")).toBeHidden() - await page.getByRole("button", { name: "Submit with JS" }).click(); - await syncLV(page); + await page.getByRole("button", {name: "Submit with JS"}).click() + await syncLV(page) - await expect(page.getByText("Form was submitted!")).toBeVisible(); - }); + await expect(page.getByText("Form was submitted!")).toBeVisible() + }) - test(`${path} - loading and locked states with latency`, async ({ page, request }) => { - const nested = !!path.match(/nested/); - await page.goto(`${path}?phx-change=validate`); - await syncLV(page); - const { lv_pid } = await evalLV(page, ` + test(`${path} - loading and locked states with latency`, async ({page, request}) => { + const nested = !!path.match(/nested/) + await page.goto(`${path}?phx-change=validate`) + await syncLV(page) + const {lv_pid} = await evalLV(page, ` <<"#PID"::binary, pid::binary>> = inspect(self()) pid_parts = @@ -202,8 +202,8 @@ for (let path of ["/form/nested", "/form"]) { |> String.split(".") %{lv_pid: pid_parts} - `, nested ? "#nested" : undefined); - const ack = (event) => evalPlug(request, `send(IEx.Helpers.pid(${lv_pid[0]}, ${lv_pid[1]}, ${lv_pid[2]}), {:sync, "${event}"}); nil`); + `, nested ? "#nested" : undefined) + const ack = (event) => evalPlug(request, `send(IEx.Helpers.pid(${lv_pid[0]}, ${lv_pid[1]}, ${lv_pid[2]}), {:sync, "${event}"}); nil`) // we serialize the test by letting each event handler wait for a {:sync, event} message await evalLV(page, ` attach_hook(socket, :sync, :handle_event, fn event, _params, socket -> @@ -213,55 +213,55 @@ for (let path of ["/form/nested", "/form"]) { receive do {:sync, ^event} -> {:cont, socket} end end end) - `, nested ? "#nested" : undefined); - await expect(page.getByText("Form was submitted!")).not.toBeVisible(); - let testForm = page.locator("#test-form"); - let submitBtn = page.locator("#test-form #submit"); - await page.locator("#test-form input[name=b]").fill("test"); - await expect(testForm).toHaveClass("myformclass phx-change-loading"); - await expect(testForm).toHaveAttribute("data-phx-ref-loading"); + `, nested ? "#nested" : undefined) + await expect(page.getByText("Form was submitted!")).toBeHidden() + let testForm = page.locator("#test-form") + let submitBtn = page.locator("#test-form #submit") + await page.locator("#test-form input[name=b]").fill("test") + await expect(testForm).toHaveClass("myformclass phx-change-loading") + await expect(testForm).toHaveAttribute("data-phx-ref-loading") // form is locked on phx-change for any changed input - await expect(testForm).toHaveAttribute("data-phx-ref-lock"); - await expect(testForm).toHaveAttribute("data-phx-ref-src"); - await submitBtn.click(); + await expect(testForm).toHaveAttribute("data-phx-ref-lock") + await expect(testForm).toHaveAttribute("data-phx-ref-src") + await submitBtn.click() // change-loading and submit-loading classes exist simultaneously - await expect(testForm).toHaveClass("myformclass phx-change-loading phx-submit-loading"); + await expect(testForm).toHaveClass("myformclass phx-change-loading phx-submit-loading") // phx-change ack arrives and is removed - await ack("validate"); - await expect(testForm).toHaveClass("myformclass phx-submit-loading"); - await expect(submitBtn).toHaveClass("phx-submit-loading"); - await expect(submitBtn).toHaveAttribute("data-phx-disable-with-restore", "Submit"); - await expect(submitBtn).toHaveAttribute("data-phx-ref-loading"); - await expect(testForm).toHaveAttribute("data-phx-ref-loading"); - await expect(testForm).toHaveAttribute("data-phx-ref-src"); - await expect(submitBtn).toHaveAttribute("data-phx-ref-lock"); + await ack("validate") + await expect(testForm).toHaveClass("myformclass phx-submit-loading") + await expect(submitBtn).toHaveClass("phx-submit-loading") + await expect(submitBtn).toHaveAttribute("data-phx-disable-with-restore", "Submit") + await expect(submitBtn).toHaveAttribute("data-phx-ref-loading") + await expect(testForm).toHaveAttribute("data-phx-ref-loading") + await expect(testForm).toHaveAttribute("data-phx-ref-src") + await expect(submitBtn).toHaveAttribute("data-phx-ref-lock") // form is not locked on submit - await expect(testForm).not.toHaveAttribute("data-phx-ref-lock"); - await expect(submitBtn).toHaveAttribute("data-phx-ref-src"); - await expect(submitBtn).toHaveAttribute("disabled", ""); - await expect(submitBtn).toHaveAttribute("phx-disable-with", "Submitting"); - await ack("save"); - await expect(page.getByText("Form was submitted!")).toBeVisible(); + await expect(testForm).not.toHaveAttribute("data-phx-ref-lock") + await expect(submitBtn).toHaveAttribute("data-phx-ref-src") + await expect(submitBtn).toHaveAttribute("disabled", "") + await expect(submitBtn).toHaveAttribute("phx-disable-with", "Submitting") + await ack("save") + await expect(page.getByText("Form was submitted!")).toBeVisible() // all refs are cleaned up - await expect(testForm).toHaveClass("myformclass"); - await expect(submitBtn).toHaveClass(""); - await expect(submitBtn).not.toHaveAttribute("data-phx-disable-with-restore"); - await expect(submitBtn).not.toHaveAttribute("data-phx-ref-loading"); - await expect(submitBtn).not.toHaveAttribute("data-phx-ref-lock"); - await expect(submitBtn).not.toHaveAttribute("data-phx-ref-src"); - await expect(submitBtn).not.toHaveAttribute("data-phx-ref-loading"); - await expect(submitBtn).not.toHaveAttribute("data-phx-ref-lock"); - await expect(submitBtn).not.toHaveAttribute("data-phx-ref-src"); - await expect(submitBtn).not.toHaveAttribute("disabled"); - await expect(submitBtn).toHaveAttribute("phx-disable-with", "Submitting"); - }); + await expect(testForm).toHaveClass("myformclass") + await expect(submitBtn).toHaveClass("") + await expect(submitBtn).not.toHaveAttribute("data-phx-disable-with-restore") + await expect(submitBtn).not.toHaveAttribute("data-phx-ref-loading") + await expect(submitBtn).not.toHaveAttribute("data-phx-ref-lock") + await expect(submitBtn).not.toHaveAttribute("data-phx-ref-src") + await expect(submitBtn).not.toHaveAttribute("data-phx-ref-loading") + await expect(submitBtn).not.toHaveAttribute("data-phx-ref-lock") + await expect(submitBtn).not.toHaveAttribute("data-phx-ref-src") + await expect(submitBtn).not.toHaveAttribute("disabled") + await expect(submitBtn).toHaveAttribute("phx-disable-with", "Submitting") + }) } -test(`loading and locked states with latent clone`, async ({ page, request }) => { - await page.goto(`/form/stream`); - let formHook = page.locator("#form-stream-hook"); - await syncLV(page); - const { lv_pid } = await evalLV(page, ` +test("loading and locked states with latent clone", async ({page, request}) => { + await page.goto("/form/stream") + let formHook = page.locator("#form-stream-hook") + await syncLV(page) + const {lv_pid} = await evalLV(page, ` <<"#PID"::binary, pid::binary>> = inspect(self()) pid_parts = @@ -271,8 +271,8 @@ test(`loading and locked states with latent clone`, async ({ page, request }) => |> String.split(".") %{lv_pid: pid_parts} - `); - const ack = (event) => evalPlug(request, `send(IEx.Helpers.pid(${lv_pid[0]}, ${lv_pid[1]}, ${lv_pid[2]}), {:sync, "${event}"}); nil`); + `) + const ack = (event) => evalPlug(request, `send(IEx.Helpers.pid(${lv_pid[0]}, ${lv_pid[1]}, ${lv_pid[2]}), {:sync, "${event}"}); nil`) // we serialize the test by letting each event handler wait for a {:sync, event} message // excluding the ping messages from our hook await evalLV(page, ` @@ -283,38 +283,38 @@ test(`loading and locked states with latent clone`, async ({ page, request }) => receive do {:sync, ^event} -> {:cont, socket} end end end) - `); - await expect(formHook).toHaveText("pong"); - let testForm = page.locator("#test-form"); - let testInput = page.locator("#test-form input[name=myname]"); - let submitBtn = page.locator("#test-form button"); + `) + await expect(formHook).toHaveText("pong") + let testForm = page.locator("#test-form") + let testInput = page.locator("#test-form input[name=myname]") + let submitBtn = page.locator("#test-form button") // initial 3 stream items - await expect(page.locator("#form-stream li")).toHaveCount(3); - await testInput.fill("1"); - await testInput.fill("2"); + await expect(page.locator("#form-stream li")).toHaveCount(3) + await testInput.fill("1") + await testInput.fill("2") // form is locked on phx-change and stream remains unchanged - await expect(testForm).toHaveClass("phx-change-loading"); - await expect(testInput).toHaveClass("phx-change-loading"); - await expect(testForm).toHaveAttribute("data-phx-ref-loading"); - await expect(testForm).toHaveAttribute("data-phx-ref-src"); - await expect(testInput).toHaveAttribute("data-phx-ref-loading"); - await expect(testInput).toHaveAttribute("data-phx-ref-src"); + await expect(testForm).toHaveClass("phx-change-loading") + await expect(testInput).toHaveClass("phx-change-loading") + await expect(testForm).toHaveAttribute("data-phx-ref-loading") + await expect(testForm).toHaveAttribute("data-phx-ref-src") + await expect(testInput).toHaveAttribute("data-phx-ref-loading") + await expect(testInput).toHaveAttribute("data-phx-ref-src") // now we submit - await submitBtn.click(); - await expect(testForm).toHaveClass("phx-change-loading phx-submit-loading"); - await expect(submitBtn).toHaveText("Saving..."); - await expect(testInput).toHaveClass("phx-change-loading"); - await expect(testForm).toHaveAttribute("data-phx-ref-loading"); - await expect(testForm).toHaveAttribute("data-phx-ref-src"); - await expect(testInput).toHaveAttribute("data-phx-ref-loading"); - await expect(testInput).toHaveAttribute("data-phx-ref-src"); + await submitBtn.click() + await expect(testForm).toHaveClass("phx-change-loading phx-submit-loading") + await expect(submitBtn).toHaveText("Saving...") + await expect(testInput).toHaveClass("phx-change-loading") + await expect(testForm).toHaveAttribute("data-phx-ref-loading") + await expect(testForm).toHaveAttribute("data-phx-ref-src") + await expect(testInput).toHaveAttribute("data-phx-ref-loading") + await expect(testInput).toHaveAttribute("data-phx-ref-src") // now we ack the two change events - await ack("validate"); + await ack("validate") // the form is still locked, therefore we still have 3 elements - await expect(page.locator("#form-stream li")).toHaveCount(3); - await ack("validate"); + await expect(page.locator("#form-stream li")).toHaveCount(3) + await ack("validate") // on unlock, cloned stream items that are added on each phx-change are applied to DOM - await expect(page.locator("#form-stream li")).toHaveCount(5); + await expect(page.locator("#form-stream li")).toHaveCount(5) // after clones are applied, the stream item hooks are mounted // note that the form still awaits the submit ack, but it is not locked, // therefore the updates from the phx-change are already applied @@ -324,20 +324,20 @@ test(`loading and locked states with latent clone`, async ({ page, request }) => "*%{id: 3}pong", "*%{id: 4}", "*%{id: 5}" - ]); + ]) // still saving - await expect(submitBtn).toHaveText("Saving..."); - await expect(testForm).toHaveClass("phx-submit-loading"); - await expect(testInput).toHaveAttribute("readonly", ""); - await expect(submitBtn).toHaveClass("phx-submit-loading"); - await expect(testForm).toHaveAttribute("data-phx-ref-loading"); - await expect(testForm).toHaveAttribute("data-phx-ref-src"); - await expect(testInput).toHaveAttribute("data-phx-ref-loading"); - await expect(testInput).toHaveAttribute("data-phx-ref-src"); - await expect(submitBtn).toHaveAttribute("data-phx-ref-loading"); - await expect(submitBtn).toHaveAttribute("data-phx-ref-src"); + await expect(submitBtn).toHaveText("Saving...") + await expect(testForm).toHaveClass("phx-submit-loading") + await expect(testInput).toHaveAttribute("readonly", "") + await expect(submitBtn).toHaveClass("phx-submit-loading") + await expect(testForm).toHaveAttribute("data-phx-ref-loading") + await expect(testForm).toHaveAttribute("data-phx-ref-src") + await expect(testInput).toHaveAttribute("data-phx-ref-loading") + await expect(testInput).toHaveAttribute("data-phx-ref-src") + await expect(submitBtn).toHaveAttribute("data-phx-ref-loading") + await expect(submitBtn).toHaveAttribute("data-phx-ref-src") // now we ack the submit - await ack("save"); + await ack("save") // submit adds 1 more stream item and new hook is mounted await expect(page.locator("#form-stream li")).toHaveText([ "*%{id: 1}pong", @@ -346,159 +346,159 @@ test(`loading and locked states with latent clone`, async ({ page, request }) => "*%{id: 4}pong", "*%{id: 5}pong", "*%{id: 6}pong" - ]); - await expect(submitBtn).toHaveText("Submit"); - await expect(submitBtn).toHaveAttribute("phx-disable-with", "Saving..."); - await expect(testForm).not.toHaveClass("phx-submit-loading"); - await expect(testInput).not.toHaveAttribute("readonly"); - await expect(submitBtn).not.toHaveClass("phx-submit-loading"); - await expect(testForm).not.toHaveAttribute("data-phx-ref"); - await expect(testForm).not.toHaveAttribute("data-phx-ref-src"); - await expect(testInput).not.toHaveAttribute("data-phx-ref"); - await expect(testInput).not.toHaveAttribute("data-phx-ref-src"); - await expect(submitBtn).not.toHaveAttribute("data-phx-ref"); - await expect(submitBtn).not.toHaveAttribute("data-phx-ref-src"); -}); - -test("can dynamically add/remove inputs (ecto sort_param/drop_param)", async ({ page }) => { - await page.goto("/form/dynamic-inputs"); - await syncLV(page); - - const formData = () => page.locator("form").evaluate(form => Object.fromEntries(new FormData(form).entries())); - - await expect(await formData()).toEqual({ + ]) + await expect(submitBtn).toHaveText("Submit") + await expect(submitBtn).toHaveAttribute("phx-disable-with", "Saving...") + await expect(testForm).not.toHaveClass("phx-submit-loading") + await expect(testInput).not.toHaveAttribute("readonly") + await expect(submitBtn).not.toHaveClass("phx-submit-loading") + await expect(testForm).not.toHaveAttribute("data-phx-ref") + await expect(testForm).not.toHaveAttribute("data-phx-ref-src") + await expect(testInput).not.toHaveAttribute("data-phx-ref") + await expect(testInput).not.toHaveAttribute("data-phx-ref-src") + await expect(submitBtn).not.toHaveAttribute("data-phx-ref") + await expect(submitBtn).not.toHaveAttribute("data-phx-ref-src") +}) + +test("can dynamically add/remove inputs (ecto sort_param/drop_param)", async ({page}) => { + await page.goto("/form/dynamic-inputs") + await syncLV(page) + + const formData = () => page.locator("form").evaluate(form => Object.fromEntries(new FormData(form).entries())) + + expect(await formData()).toEqual({ "my_form[name]": "", "my_form[users_drop][]": "" - }); + }) - await page.locator("#my-form_name").fill("Test"); - await page.getByRole("button", { name: "add more" }).click(); + await page.locator("#my-form_name").fill("Test") + await page.getByRole("button", {name: "add more"}).click() - await expect(await formData()).toEqual(expect.objectContaining({ + expect(await formData()).toEqual(expect.objectContaining({ "my_form[name]": "Test", "my_form[users][0][name]": "", - })); + })) - await page.locator("#my-form_users_0_name").fill("User A"); - await page.getByRole("button", { name: "add more" }).click(); - await page.getByRole("button", { name: "add more" }).click(); + await page.locator("#my-form_users_0_name").fill("User A") + await page.getByRole("button", {name: "add more"}).click() + await page.getByRole("button", {name: "add more"}).click() - await page.locator("#my-form_users_1_name").fill("User B"); - await page.locator("#my-form_users_2_name").fill("User C"); + await page.locator("#my-form_users_1_name").fill("User B") + await page.locator("#my-form_users_2_name").fill("User C") - await expect(await formData()).toEqual(expect.objectContaining({ + expect(await formData()).toEqual(expect.objectContaining({ "my_form[name]": "Test", "my_form[users_drop][]": "", "my_form[users][0][name]": "User A", "my_form[users][1][name]": "User B", "my_form[users][2][name]": "User C" - })); + })) // remove User B - await page.locator("button[name=\"my_form[users_drop][]\"][value=\"1\"]").click(); + await page.locator("button[name=\"my_form[users_drop][]\"][value=\"1\"]").click() - await expect(await formData()).toEqual(expect.objectContaining({ + expect(await formData()).toEqual(expect.objectContaining({ "my_form[name]": "Test", "my_form[users_drop][]": "", "my_form[users][0][name]": "User A", "my_form[users][1][name]": "User C" - })); -}); + })) +}) -test("can dynamically add/remove inputs using checkboxes", async ({ page }) => { - await page.goto("/form/dynamic-inputs?checkboxes=1"); - await syncLV(page); +test("can dynamically add/remove inputs using checkboxes", async ({page}) => { + await page.goto("/form/dynamic-inputs?checkboxes=1") + await syncLV(page) - const formData = () => page.locator("form").evaluate(form => Object.fromEntries(new FormData(form).entries())); + const formData = () => page.locator("form").evaluate(form => Object.fromEntries(new FormData(form).entries())) - await expect(await formData()).toEqual({ + expect(await formData()).toEqual({ "my_form[name]": "", "my_form[users_drop][]": "" - }); + }) - await page.locator("#my-form_name").fill("Test"); - await page.locator("label", { hasText: "add more" }).click(); + await page.locator("#my-form_name").fill("Test") + await page.locator("label", {hasText: "add more"}).click() - await expect(await formData()).toEqual(expect.objectContaining({ + expect(await formData()).toEqual(expect.objectContaining({ "my_form[name]": "Test", "my_form[users][0][name]": "", - })); + })) - await page.locator("#my-form_users_0_name").fill("User A"); - await page.locator("label", { hasText: "add more" }).click(); - await page.locator("label", { hasText: "add more" }).click(); + await page.locator("#my-form_users_0_name").fill("User A") + await page.locator("label", {hasText: "add more"}).click() + await page.locator("label", {hasText: "add more"}).click() - await page.locator("#my-form_users_1_name").fill("User B"); - await page.locator("#my-form_users_2_name").fill("User C"); + await page.locator("#my-form_users_1_name").fill("User B") + await page.locator("#my-form_users_2_name").fill("User C") - await expect(await formData()).toEqual(expect.objectContaining({ + expect(await formData()).toEqual(expect.objectContaining({ "my_form[name]": "Test", "my_form[users_drop][]": "", "my_form[users][0][name]": "User A", "my_form[users][1][name]": "User B", "my_form[users][2][name]": "User C" - })); + })) // remove User B - await page.locator("input[name=\"my_form[users_drop][]\"][value=\"1\"]").click(); + await page.locator("input[name=\"my_form[users_drop][]\"][value=\"1\"]").click() - await expect(await formData()).toEqual(expect.objectContaining({ + expect(await formData()).toEqual(expect.objectContaining({ "my_form[name]": "Test", "my_form[users_drop][]": "", "my_form[users][0][name]": "User A", "my_form[users][1][name]": "User C" - })); -}); + })) +}) // phx-feedback-for was removed in LiveView 1.0, but we still test the shim applied in // test_helper.exs layout for backwards compatibility -test("phx-no-feedback is applied correctly for backwards-compatible-shims", async ({ page }) => { - await page.goto("/form/feedback"); - await syncLV(page); - - await expect(page.locator("[phx-feedback-for=myfeedback]")).not.toBeVisible(); - await page.getByRole("button", { name: "+" }).click(); - await syncLV(page); - await expect(page.locator("[phx-feedback-for=myfeedback]")).not.toBeVisible(); - await expect(page.getByText("Validate count")).toContainText("0"); - - await page.locator("input[name=name]").fill("Test"); - await syncLV(page); - await expect(page.locator("[phx-feedback-for=myfeedback]")).not.toBeVisible(); - await expect(page.getByText("Validate count")).toContainText("1"); - - await page.locator("input[name=myfeedback]").fill("Test"); - await syncLV(page); - await expect(page.getByText("Validate count")).toContainText("2"); - await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeVisible(); +test("phx-no-feedback is applied correctly for backwards-compatible-shims", async ({page}) => { + await page.goto("/form/feedback") + await syncLV(page) + + await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeHidden() + await page.getByRole("button", {name: "+"}).click() + await syncLV(page) + await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeHidden() + await expect(page.getByText("Validate count")).toContainText("0") + + await page.locator("input[name=name]").fill("Test") + await syncLV(page) + await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeHidden() + await expect(page.getByText("Validate count")).toContainText("1") + + await page.locator("input[name=myfeedback]").fill("Test") + await syncLV(page) + await expect(page.getByText("Validate count")).toContainText("2") + await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeVisible() // feedback appears on submit - await page.reload(); - await syncLV(page); - await expect(page.locator("[phx-feedback-for=myfeedback]")).not.toBeVisible(); + await page.reload() + await syncLV(page) + await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeHidden() - await page.getByRole("button", { name: "Submit" }).click(); - await syncLV(page); - await expect(page.getByText("Submit count")).toContainText("1"); - await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeVisible(); + await page.getByRole("button", {name: "Submit"}).click() + await syncLV(page) + await expect(page.getByText("Submit count")).toContainText("1") + await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeVisible() // feedback hides on reset - await page.getByRole("button", { name: "Reset" }).click(); - await syncLV(page); - await expect(page.locator("[phx-feedback-for=myfeedback]")).not.toBeVisible(); + await page.getByRole("button", {name: "Reset"}).click() + await syncLV(page) + await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeHidden() // can toggle feedback visibility - await page.reload(); - await syncLV(page); - await expect(page.locator("[data-feedback-container]")).not.toBeVisible(); - - await page.getByRole("button", { name: "Toggle feedback" }).click(); - await syncLV(page); - await expect(page.locator("[data-feedback-container]")).toBeVisible(); - - await page.getByRole("button", { name: "Toggle feedback" }).click(); - await syncLV(page); - await expect(page.locator("[data-feedback-container]")).not.toBeVisible(); -}); + await page.reload() + await syncLV(page) + await expect(page.locator("[data-feedback-container]")).toBeHidden() + + await page.getByRole("button", {name: "Toggle feedback"}).click() + await syncLV(page) + await expect(page.locator("[data-feedback-container]")).toBeVisible() + + await page.getByRole("button", {name: "Toggle feedback"}).click() + await syncLV(page) + await expect(page.locator("[data-feedback-container]")).toBeHidden() +}) diff --git a/test/e2e/tests/issues/2787.spec.js b/test/e2e/tests/issues/2787.spec.js index 8d5dfeeff7..81289c7790 100644 --- a/test/e2e/tests/issues/2787.spec.js +++ b/test/e2e/tests/issues/2787.spec.js @@ -1,38 +1,38 @@ -const { test, expect } = require("../../test-fixtures"); -const { syncLV } = require("../../utils"); +const {test, expect} = require("../../test-fixtures") +const {syncLV} = require("../../utils") -const selectOptions = (locator) => locator.evaluateAll(list => list.map(option => option.value)); +const selectOptions = (locator) => locator.evaluateAll(list => list.map(option => option.value)) -test("select is properly cleared on submit", async ({ page }) => { - await page.goto("/issues/2787"); - await syncLV(page); +test("select is properly cleared on submit", async ({page}) => { + await page.goto("/issues/2787") + await syncLV(page) - const select1 = page.locator("#demo_select1"); - const select2 = page.locator("#demo_select2"); + const select1 = page.locator("#demo_select1") + const select2 = page.locator("#demo_select2") // at the beginning, both selects are empty - await expect(select1).toHaveValue(""); - await expect(await selectOptions(select1.locator("option"))).toEqual(["", "greetings", "goodbyes"]); - await expect(select2).toHaveValue(""); - await expect(await selectOptions(select2.locator("option"))).toEqual([""]); + await expect(select1).toHaveValue("") + expect(await selectOptions(select1.locator("option"))).toEqual(["", "greetings", "goodbyes"]) + await expect(select2).toHaveValue("") + expect(await selectOptions(select2.locator("option"))).toEqual([""]) // now we select greetings in the first select - await select1.selectOption("greetings"); - await syncLV(page); + await select1.selectOption("greetings") + await syncLV(page) // now the second select should have some greeting options - await expect(await selectOptions(select2.locator("option"))).toEqual(["", "hello", "hallo", "hei"]); - await select2.selectOption("hei"); - await syncLV(page); + expect(await selectOptions(select2.locator("option"))).toEqual(["", "hello", "hallo", "hei"]) + await select2.selectOption("hei") + await syncLV(page) // now we submit the form - await page.locator("button").click(); + await page.locator("button").click() // now, both selects should be empty again (this was the bug in #2787) - await expect(select1).toHaveValue(""); - await expect(select2).toHaveValue(""); + await expect(select1).toHaveValue("") + await expect(select2).toHaveValue("") // now we select goodbyes in the first select - await select1.selectOption("goodbyes"); - await syncLV(page); - await expect(await selectOptions(select2.locator("option"))).toEqual(["", "goodbye", "auf wiedersehen", "ha det bra"]); -}); + await select1.selectOption("goodbyes") + await syncLV(page) + expect(await selectOptions(select2.locator("option"))).toEqual(["", "goodbye", "auf wiedersehen", "ha det bra"]) +}) diff --git a/test/e2e/tests/issues/2965.spec.js b/test/e2e/tests/issues/2965.spec.js index 1e4b1d2ad3..b944a0de72 100644 --- a/test/e2e/tests/issues/2965.spec.js +++ b/test/e2e/tests/issues/2965.spec.js @@ -1,30 +1,30 @@ -const { test, expect } = require("../../test-fixtures"); -const { syncLV } = require("../../utils"); -const { randomBytes } = require("crypto"); +const {test, expect} = require("../../test-fixtures") +const {syncLV} = require("../../utils") +const {randomBytes} = require("crypto") -test("can upload files with custom chunk hook", async ({ page }) => { - await page.goto("/issues/2965"); - await syncLV(page); +test("can upload files with custom chunk hook", async ({page}) => { + await page.goto("/issues/2965") + await syncLV(page) - const files = []; - for (let i = 1; i <= 20; i++) { + const files = [] + for(let i = 1; i <= 20; i++){ files.push({ name: `file${i}.txt`, mimeType: "text/plain", // random 100 kb buffer: randomBytes(100 * 1024), - }); + }) } - await page.locator("#fileinput").setInputFiles(files); - await syncLV(page); + await page.locator("#fileinput").setInputFiles(files) + await syncLV(page) // wait for uploads to finish - for (let i = 0; i < 20; i++) { - const row = page.locator(`tbody tr`).nth(i); - await expect(row).toContainText(`file${i + 1}.txt`); - await expect(row.locator("progress")).toHaveAttribute("value", "100"); + for(let i = 0; i < 20; i++){ + const row = page.locator("tbody tr").nth(i) + await expect(row).toContainText(`file${i + 1}.txt`) + await expect(row.locator("progress")).toHaveAttribute("value", "100") } // all uploads are finished! -}); +}) diff --git a/test/e2e/tests/issues/3026.spec.js b/test/e2e/tests/issues/3026.spec.js index f5c7838ce6..1439e7a58d 100644 --- a/test/e2e/tests/issues/3026.spec.js +++ b/test/e2e/tests/issues/3026.spec.js @@ -1,39 +1,39 @@ -const { test, expect } = require("../../test-fixtures"); -const { syncLV } = require("../../utils"); +const {test, expect} = require("../../test-fixtures") +const {syncLV} = require("../../utils") -test("LiveComponent is re-rendered when racing destory", async ({ page }) => { - const errors = []; +test("LiveComponent is re-rendered when racing destory", async ({page}) => { + const errors = [] page.on("pageerror", (err) => { - errors.push(err); - }); + errors.push(err) + }) - await page.goto("/issues/3026"); - await syncLV(page); + await page.goto("/issues/3026") + await syncLV(page) - await expect(page.locator("input[name='name']")).toHaveValue("John"); + await expect(page.locator("input[name='name']")).toHaveValue("John") // submitting the form unloads the LiveComponent, but it is re-added shortly after - await page.locator("button").click(); - await syncLV(page); + await page.locator("button").click() + await syncLV(page) // the form elements inside the LC should still be visible - await expect(page.locator("input[name='name']")).toBeVisible(); - await expect(page.locator("input[name='name']")).toHaveValue("John"); + await expect(page.locator("input[name='name']")).toBeVisible() + await expect(page.locator("input[name='name']")).toHaveValue("John") // quickly toggle status - for (let i = 0; i < 5; i++) { - await page.locator("select[name='status']").selectOption("connecting"); - await syncLV(page); + for(let i = 0; i < 5; i++){ + await page.locator("select[name='status']").selectOption("connecting") + await syncLV(page) // now the form is not rendered as status is connecting - await expect(page.locator("input[name='name']")).not.toBeVisible(); + await expect(page.locator("input[name='name']")).toBeHidden() // set back to loading - await page.locator("select[name='status']").selectOption("loaded"); - await syncLV(page); + await page.locator("select[name='status']").selectOption("loaded") + await syncLV(page) // now the form is not rendered as status is connecting - await expect(page.locator("input[name='name']")).toBeVisible(); + await expect(page.locator("input[name='name']")).toBeVisible() } // no js errors should be thrown - await expect(errors).toEqual([]); -}); + expect(errors).toEqual([]) +}) diff --git a/test/e2e/tests/issues/3040.spec.js b/test/e2e/tests/issues/3040.spec.js index bfceb2a531..00891387b4 100644 --- a/test/e2e/tests/issues/3040.spec.js +++ b/test/e2e/tests/issues/3040.spec.js @@ -1,63 +1,63 @@ -const { test, expect } = require("../../test-fixtures"); -const { syncLV } = require("../../utils"); +const {test, expect} = require("../../test-fixtures") +const {syncLV} = require("../../utils") -test("click-away does not fire when triggering form submit", async ({ page }) => { - await page.goto("/issues/3040"); - await syncLV(page); +test("click-away does not fire when triggering form submit", async ({page}) => { + await page.goto("/issues/3040") + await syncLV(page) - await page.getByRole("link", { name: "Add new" }).click(); - await syncLV(page); + await page.getByRole("link", {name: "Add new"}).click() + await syncLV(page) - const modal = page.locator("#my-modal-container"); - await expect(modal).toBeVisible(); + const modal = page.locator("#my-modal-container") + await expect(modal).toBeVisible() // focusFirst should have focused the input - await expect(page.locator("input[name='name']")).toBeFocused(); + await expect(page.locator("input[name='name']")).toBeFocused() // submit the form - await page.keyboard.press("Enter"); - await syncLV(page); + await page.keyboard.press("Enter") + await syncLV(page) - await expect(page.locator("form")).toHaveText("Form was submitted!"); - await expect(modal).toBeVisible(); + await expect(page.locator("form")).toHaveText("Form was submitted!") + await expect(modal).toBeVisible() // now click outside - await page.mouse.click(0, 0); - await syncLV(page); + await page.mouse.click(0, 0) + await syncLV(page) - await expect(modal).not.toBeVisible(); -}); + await expect(modal).toBeHidden() +}) // see also https://github.com/phoenixframework/phoenix_live_view/issues/1920 -test("does not close modal when moving mouse outside while held down", async ({ page }) => { - await page.goto("/issues/3040"); - await syncLV(page); +test("does not close modal when moving mouse outside while held down", async ({page}) => { + await page.goto("/issues/3040") + await syncLV(page) - await page.getByRole("link", { name: "Add new" }).click(); - await syncLV(page); + await page.getByRole("link", {name: "Add new"}).click() + await syncLV(page) - const modal = page.locator("#my-modal-container"); - await expect(modal).toBeVisible(); + const modal = page.locator("#my-modal-container") + await expect(modal).toBeVisible() - await expect(page.locator("input[name='name']")).toBeFocused(); - await page.locator("input[name='name']").fill("test"); + await expect(page.locator("input[name='name']")).toBeFocused() + await page.locator("input[name='name']").fill("test") // we move the mouse inside the input field and then drag it outside // while holding the mouse button down - await page.mouse.move(434, 350); - await page.mouse.down(); - await page.mouse.move(143, 350); - await page.mouse.up(); + await page.mouse.move(434, 350) + await page.mouse.down() + await page.mouse.move(143, 350) + await page.mouse.up() // we expect the modal to still be visible because the mousedown happened // inside, not triggering phx-click-away - await expect(modal).toBeVisible(); - await page.keyboard.press("Backspace"); + await expect(modal).toBeVisible() + await page.keyboard.press("Backspace") - await expect(page.locator("input[name='name']")).toHaveValue(""); - await expect(modal).toBeVisible(); + await expect(page.locator("input[name='name']")).toHaveValue("") + await expect(modal).toBeVisible() // close modal with escape - await page.keyboard.press("Escape"); - await expect(modal).not.toBeVisible(); -}); + await page.keyboard.press("Escape") + await expect(modal).toBeHidden() +}) diff --git a/test/e2e/tests/issues/3047.spec.js b/test/e2e/tests/issues/3047.spec.js index 22d5d8165f..a9fd399a6e 100644 --- a/test/e2e/tests/issues/3047.spec.js +++ b/test/e2e/tests/issues/3047.spec.js @@ -1,31 +1,31 @@ -const { test, expect } = require("../../test-fixtures"); -const { syncLV } = require("../../utils"); +const {test, expect} = require("../../test-fixtures") +const {syncLV} = require("../../utils") -const listItems = async (page) => page.locator('[phx-update="stream"] > span').evaluateAll(list => list.map(el => el.id)); +const listItems = async (page) => page.locator("[phx-update=\"stream\"] > span").evaluateAll(list => list.map(el => el.id)) -test("streams are not cleared in sticky live views", async ({ page }) => { - await page.goto("/issues/3047/a"); - await syncLV(page); - await expect(page.locator("#page")).toContainText("Page A"); +test("streams are not cleared in sticky live views", async ({page}) => { + await page.goto("/issues/3047/a") + await syncLV(page) + await expect(page.locator("#page")).toContainText("Page A") - await expect(await listItems(page)).toEqual([ + expect(await listItems(page)).toEqual([ "items-1", "items-2", "items-3", "items-4", "items-5", "items-6", "items-7", "items-8", "items-9", "items-10" - ]); + ]) - await page.getByRole("button", { name: "Reset" }).click(); - await expect(await listItems(page)).toEqual([ + await page.getByRole("button", {name: "Reset"}).click() + expect(await listItems(page)).toEqual([ "items-5", "items-6", "items-7", "items-8", "items-9", "items-10", "items-11", "items-12", "items-13", "items-14", "items-15" - ]); + ]) - await page.getByRole("link", { name: "Page B" }).click(); - await syncLV(page); + await page.getByRole("link", {name: "Page B"}).click() + await syncLV(page) // stream items should still be visible - await expect(page.locator("#page")).toContainText("Page B"); - await expect(await listItems(page)).toEqual([ + await expect(page.locator("#page")).toContainText("Page B") + expect(await listItems(page)).toEqual([ "items-5", "items-6", "items-7", "items-8", "items-9", "items-10", "items-11", "items-12", "items-13", "items-14", "items-15" - ]); -}); + ]) +}) diff --git a/test/e2e/tests/issues/3083.spec.js b/test/e2e/tests/issues/3083.spec.js index 61f4742457..0284c697e8 100644 --- a/test/e2e/tests/issues/3083.spec.js +++ b/test/e2e/tests/issues/3083.spec.js @@ -1,30 +1,30 @@ -const { test, expect } = require("@playwright/test"); -const { syncLV, evalLV } = require("../../utils"); +const {test, expect} = require("@playwright/test") +const {syncLV, evalLV} = require("../../utils") -test("select multiple handles option updates properly", async ({ page }) => { - await page.goto("/issues/3083?auto=false"); - await syncLV(page); +test("select multiple handles option updates properly", async ({page}) => { + await page.goto("/issues/3083?auto=false") + await syncLV(page) - await expect(page.locator("select")).toHaveValues([]); + await expect(page.locator("select")).toHaveValues([]) - await evalLV(page, "send(self(), {:select, [1,2]}); nil"); - await expect(page.locator("select")).toHaveValues(["1", "2"]); - await evalLV(page, "send(self(), {:select, [2,3]}); nil"); - await expect(page.locator("select")).toHaveValues(["2", "3"]); + await evalLV(page, "send(self(), {:select, [1,2]}); nil") + await expect(page.locator("select")).toHaveValues(["1", "2"]) + await evalLV(page, "send(self(), {:select, [2,3]}); nil") + await expect(page.locator("select")).toHaveValues(["2", "3"]) // now focus the select by interacting with it - await page.locator("select").click({ position: { x: 1, y: 1 }}); - await expect(page.locator("select")).toHaveValues(["1"]); - await evalLV(page, "send(self(), {:select, [1,2]}); nil"); + await page.locator("select").click({position: {x: 1, y: 1}}) + await expect(page.locator("select")).toHaveValues(["1"]) + await evalLV(page, "send(self(), {:select, [1,2]}); nil") // because the select is focused, we do not expect the values to change - await expect(page.locator("select")).toHaveValues(["1"]); + await expect(page.locator("select")).toHaveValues(["1"]) // now blur the select by clicking on the body - await page.locator("body").click(); - await expect(page.locator("select")).toHaveValues(["1"]); + await page.locator("body").click() + await expect(page.locator("select")).toHaveValues(["1"]) // now update the selected values again - await evalLV(page, "send(self(), {:select, [3,4]}); nil"); + await evalLV(page, "send(self(), {:select, [3,4]}); nil") // we had a bug here, where the select was focused, despite the blur - await expect(page.locator("select")).not.toBeFocused(); - await expect(page.locator("select")).toHaveValues(["3", "4"]); - await page.waitForTimeout(1000); -}); + await expect(page.locator("select")).not.toBeFocused() + await expect(page.locator("select")).toHaveValues(["3", "4"]) + await page.waitForTimeout(1000) +}) diff --git a/test/e2e/tests/issues/3107.spec.js b/test/e2e/tests/issues/3107.spec.js index a2ed7b9082..7b2cd6bb2f 100644 --- a/test/e2e/tests/issues/3107.spec.js +++ b/test/e2e/tests/issues/3107.spec.js @@ -1,14 +1,14 @@ -const { test, expect } = require("@playwright/test"); -const { syncLV } = require("../../utils"); +const {test, expect} = require("@playwright/test") +const {syncLV} = require("../../utils") -test("keeps value when updating select", async ({ page }) => { - await page.goto("/issues/3107"); - await syncLV(page); +test("keeps value when updating select", async ({page}) => { + await page.goto("/issues/3107") + await syncLV(page) - await expect(page.locator("select")).toHaveValue("ONE"); + await expect(page.locator("select")).toHaveValue("ONE") // focus the element and change the value, like a user would - await page.locator("select").focus(); - await page.locator("select").selectOption("TWO"); - await syncLV(page); - await expect(page.locator("select")).toHaveValue("TWO"); -}); + await page.locator("select").focus() + await page.locator("select").selectOption("TWO") + await syncLV(page) + await expect(page.locator("select")).toHaveValue("TWO") +}) diff --git a/test/e2e/tests/issues/3117.spec.js b/test/e2e/tests/issues/3117.spec.js index ce9b523e85..39c8bbc538 100644 --- a/test/e2e/tests/issues/3117.spec.js +++ b/test/e2e/tests/issues/3117.spec.js @@ -1,23 +1,23 @@ -const { test, expect } = require("../../test-fixtures"); -const { syncLV } = require("../../utils"); +const {test, expect} = require("../../test-fixtures") +const {syncLV} = require("../../utils") -test("LiveComponent with static FC root is not reset", async ({ page }) => { - const errors = []; - page.on("pageerror", (err) => errors.push(err)); +test("LiveComponent with static FC root is not reset", async ({page}) => { + const errors = [] + page.on("pageerror", (err) => errors.push(err)) - await page.goto("/issues/3117"); - await syncLV(page); + await page.goto("/issues/3117") + await syncLV(page) // clicking the button performs a live navigation - await page.locator("#navigate").click(); - await syncLV(page); + await page.locator("#navigate").click() + await syncLV(page) // the FC root should still be visible and not empty/skipped - await expect(page.locator("#row-1 .static")).toBeVisible(); - await expect(page.locator("#row-2 .static")).toBeVisible(); - await expect(page.locator("#row-1 .static")).toHaveText("static content"); - await expect(page.locator("#row-2 .static")).toHaveText("static content"); + await expect(page.locator("#row-1 .static")).toBeVisible() + await expect(page.locator("#row-2 .static")).toBeVisible() + await expect(page.locator("#row-1 .static")).toHaveText("static content") + await expect(page.locator("#row-2 .static")).toHaveText("static content") // no js errors should be thrown - await expect(errors).toEqual([]); -}); + expect(errors).toEqual([]) +}) diff --git a/test/e2e/tests/issues/3169.spec.js b/test/e2e/tests/issues/3169.spec.js index 366013f119..217f06a52e 100644 --- a/test/e2e/tests/issues/3169.spec.js +++ b/test/e2e/tests/issues/3169.spec.js @@ -1,26 +1,26 @@ -const { test, expect } = require("../../test-fixtures"); -const { syncLV } = require("../../utils"); +const {test, expect} = require("../../test-fixtures") +const {syncLV} = require("../../utils") const inputVals = async (page) => { - return page.locator(`input[type="text"]`).evaluateAll(list => list.map(i => i.value)); + return page.locator("input[type=\"text\"]").evaluateAll(list => list.map(i => i.value)) } -test("updates which add cids back on page are properly magic id change tracked", async ({ page }) => { - await page.goto("/issues/3169"); - await syncLV(page); +test("updates which add cids back on page are properly magic id change tracked", async ({page}) => { + await page.goto("/issues/3169") + await syncLV(page) await page.locator("#select-a").click() - await syncLV(page); - await expect(page.locator("body")).toContainText("FormColumn (c3)"); - await expect(await inputVals(page)).toEqual(["Record a", "Record a", "Record a"]); + await syncLV(page) + await expect(page.locator("body")).toContainText("FormColumn (c3)") + expect(await inputVals(page)).toEqual(["Record a", "Record a", "Record a"]) await page.locator("#select-b").click() - await syncLV(page); - await expect(page.locator("body")).toContainText("FormColumn (c3)"); - await expect(await inputVals(page)).toEqual(["Record b", "Record b", "Record b"]); + await syncLV(page) + await expect(page.locator("body")).toContainText("FormColumn (c3)") + expect(await inputVals(page)).toEqual(["Record b", "Record b", "Record b"]) await page.locator("#select-z").click() - await syncLV(page); - await expect(page.locator("body")).toContainText("FormColumn (c3)"); - await expect(await inputVals(page)).toEqual(["Record z", "Record z", "Record z"]); -}); + await syncLV(page) + await expect(page.locator("body")).toContainText("FormColumn (c3)") + expect(await inputVals(page)).toEqual(["Record z", "Record z", "Record z"]) +}) diff --git a/test/e2e/tests/issues/3194.spec.js b/test/e2e/tests/issues/3194.spec.js index 48943fff80..8ae45b9bfe 100644 --- a/test/e2e/tests/issues/3194.spec.js +++ b/test/e2e/tests/issues/3194.spec.js @@ -1,24 +1,24 @@ -const { test, expect } = require("../../test-fixtures"); -const { syncLV } = require("../../utils"); +const {test, expect} = require("../../test-fixtures") +const {syncLV} = require("../../utils") -test("does not send event to wrong LV when submitting form with debounce blur", async ({ page }) => { - const logs = []; - page.on("console", (e) => logs.push(e.text())); +test("does not send event to wrong LV when submitting form with debounce blur", async ({page}) => { + const logs = [] + page.on("console", (e) => logs.push(e.text())) - await page.goto("/issues/3194"); - await syncLV(page); + await page.goto("/issues/3194") + await syncLV(page) - await page.locator("input").focus(); - await page.keyboard.type("hello"); - await page.keyboard.press("Enter"); - await expect(page).toHaveURL("/issues/3194/other"); + await page.locator("input").focus() + await page.keyboard.type("hello") + await page.keyboard.press("Enter") + await expect(page).toHaveURL("/issues/3194/other") // give it some time for old events to reach the new LV // (this is the failure case!) - await page.waitForTimeout(50); + await page.waitForTimeout(50) // we navigated to another LV - await expect(logs).toEqual(expect.arrayContaining([expect.stringMatching("destroyed: the child has been removed from the parent")])); + expect(logs).toEqual(expect.arrayContaining([expect.stringMatching("destroyed: the child has been removed from the parent")])) // it should not have crashed - await expect(logs).not.toEqual(expect.arrayContaining([expect.stringMatching("view crashed")])); -}); + expect(logs).not.toEqual(expect.arrayContaining([expect.stringMatching("view crashed")])) +}) diff --git a/test/e2e/tests/issues/3200.spec.js b/test/e2e/tests/issues/3200.spec.js index 5817de9b03..b5c02817f6 100644 --- a/test/e2e/tests/issues/3200.spec.js +++ b/test/e2e/tests/issues/3200.spec.js @@ -1,27 +1,27 @@ -const { test, expect } = require("../../test-fixtures"); -const { syncLV } = require("../../utils"); +const {test, expect} = require("../../test-fixtures") +const {syncLV} = require("../../utils") // https://github.com/phoenixframework/phoenix_live_view/issues/3200 -test("phx-target='selector' is used correctly for form recovery", async ({ page }) => { - const errors = []; - page.on("pageerror", (err) => errors.push(err)); +test("phx-target='selector' is used correctly for form recovery", async ({page}) => { + const errors = [] + page.on("pageerror", (err) => errors.push(err)) - await page.goto("/issues/3200/settings"); - await syncLV(page); + await page.goto("/issues/3200/settings") + await syncLV(page) - await page.getByRole("button", { name: "Messages" }).click(); - await syncLV(page); - await expect(page).toHaveURL("/issues/3200/messages"); + await page.getByRole("button", {name: "Messages"}).click() + await syncLV(page) + await expect(page).toHaveURL("/issues/3200/messages") - await page.locator("#new_message_input").fill("Hello"); - await syncLV(page); + await page.locator("#new_message_input").fill("Hello") + await syncLV(page) - await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))); - await expect(page.locator(".phx-loading")).toHaveCount(1); + await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) + await expect(page.locator(".phx-loading")).toHaveCount(1) - await page.evaluate(() => window.liveSocket.connect()); - await syncLV(page); + await page.evaluate(() => window.liveSocket.connect()) + await syncLV(page) - await expect(page.locator("#new_message_input")).toHaveValue("Hello"); - await expect(errors).toEqual([]); -}); + await expect(page.locator("#new_message_input")).toHaveValue("Hello") + expect(errors).toEqual([]) +}) diff --git a/test/e2e/tests/issues/3378.spec.js b/test/e2e/tests/issues/3378.spec.js index 58df126a00..5ae52d6565 100644 --- a/test/e2e/tests/issues/3378.spec.js +++ b/test/e2e/tests/issues/3378.spec.js @@ -1,21 +1,21 @@ -const { test, expect } = require("../../test-fixtures"); -const { syncLV } = require("../../utils"); +const {test, expect} = require("../../test-fixtures") +const {syncLV} = require("../../utils") -test("can rejoin with nested streams without errors", async ({ page }) => { - const errors = []; +test("can rejoin with nested streams without errors", async ({page}) => { + const errors = [] page.on("pageerror", (err) => { - errors.push(err); - }); + errors.push(err) + }) - await page.goto("/issues/3378"); - await syncLV(page); + await page.goto("/issues/3378") + await syncLV(page) - await expect(page.locator("#notifications")).toContainText("big"); - await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))); + await expect(page.locator("#notifications")).toContainText("big") + await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) - await page.evaluate(() => window.liveSocket.connect()); - await syncLV(page); + await page.evaluate(() => window.liveSocket.connect()) + await syncLV(page) // no js errors should be thrown - await expect(errors).toEqual([]); -}); + expect(errors).toEqual([]) +}) diff --git a/test/e2e/tests/issues/3448.spec.js b/test/e2e/tests/issues/3448.spec.js index ce2b2707e9..954a339a78 100644 --- a/test/e2e/tests/issues/3448.spec.js +++ b/test/e2e/tests/issues/3448.spec.js @@ -1,17 +1,17 @@ -const { test, expect } = require("../../test-fixtures"); -const { syncLV } = require("../../utils"); +const {test, expect} = require("../../test-fixtures") +const {syncLV} = require("../../utils") // https://github.com/phoenixframework/phoenix_live_view/issues/3448 -test("focus is handled correctly when patching locked form", async ({ page }) => { - await page.goto("/issues/3448"); - await syncLV(page); +test("focus is handled correctly when patching locked form", async ({page}) => { + await page.goto("/issues/3448") + await syncLV(page) - await page.evaluate(() => window.liveSocket.enableLatencySim(500)); + await page.evaluate(() => window.liveSocket.enableLatencySim(500)) - await page.locator("input[type=checkbox]").first().check(); - await expect(page.locator("input#search")).toBeFocused(); - await syncLV(page); + await page.locator("input[type=checkbox]").first().check() + await expect(page.locator("input#search")).toBeFocused() + await syncLV(page) // after the patch is applied, the input should still be focused - await expect(page.locator("input#search")).toBeFocused(); -}); + await expect(page.locator("input#search")).toBeFocused() +}) diff --git a/test/e2e/tests/issues/3496.spec.js b/test/e2e/tests/issues/3496.spec.js index db7b30f5d2..2e638b6582 100644 --- a/test/e2e/tests/issues/3496.spec.js +++ b/test/e2e/tests/issues/3496.spec.js @@ -1,21 +1,21 @@ -const { test, expect } = require("../../test-fixtures"); -const { syncLV } = require("../../utils"); +const {test, expect} = require("../../test-fixtures") +const {syncLV} = require("../../utils") // https://github.com/phoenixframework/phoenix_live_view/issues/3496 -test("hook is initialized properly when reusing id between sticky and non sticky LiveViews", async ({ page }) => { - const logs = []; - page.on("console", (e) => logs.push(e.text())); - const errors = []; - page.on("pageerror", (err) => errors.push(err)); +test("hook is initialized properly when reusing id between sticky and non sticky LiveViews", async ({page}) => { + const logs = [] + page.on("console", (e) => logs.push(e.text())) + const errors = [] + page.on("pageerror", (err) => errors.push(err)) - await page.goto("/issues/3496/a"); - await syncLV(page); + await page.goto("/issues/3496/a") + await syncLV(page) - await page.getByRole("link", { name: "Go to page B" }).click(); - await syncLV(page); + await page.getByRole("link", {name: "Go to page B"}).click() + await syncLV(page) - expect(logs.filter(e => e.includes("Hook mounted!"))).toHaveLength(2); - expect(logs).not.toEqual(expect.arrayContaining([expect.stringMatching("no hook found for custom element")])); + expect(logs.filter(e => e.includes("Hook mounted!"))).toHaveLength(2) + expect(logs).not.toEqual(expect.arrayContaining([expect.stringMatching("no hook found for custom element")])) // no uncaught exceptions - expect(errors).toEqual([]); -}); + expect(errors).toEqual([]) +}) diff --git a/test/e2e/tests/js.spec.js b/test/e2e/tests/js.spec.js index dd842ffc29..17785c2e04 100644 --- a/test/e2e/tests/js.spec.js +++ b/test/e2e/tests/js.spec.js @@ -1,82 +1,82 @@ -const { test, expect } = require("../test-fixtures"); -const { syncLV, attributeMutations } = require("../utils"); +const {test, expect} = require("../test-fixtures") +const {syncLV, attributeMutations} = require("../utils") -test("toggle_attribute", async ({ page }) => { - await page.goto("/js"); - await syncLV(page); +test("toggle_attribute", async ({page}) => { + await page.goto("/js") + await syncLV(page) - await expect(page.locator("#my-modal")).not.toBeVisible(); + await expect(page.locator("#my-modal")).toBeHidden() - let changes = attributeMutations(page, "#my-modal"); - await page.getByRole("button", { name: "toggle modal" }).click(); + let changes = attributeMutations(page, "#my-modal") + await page.getByRole("button", {name: "toggle modal"}).click() // wait for the transition time (set to 50) - await page.waitForTimeout(100); - await expect(await changes()).toEqual(expect.arrayContaining([ - { attr: "style", oldValue: "display: none;", newValue: "display: block;" }, - { attr: "aria-expanded", oldValue: "false", newValue: "true" }, - { attr: "open", oldValue: null, newValue: "true" }, + await page.waitForTimeout(100) + expect(await changes()).toEqual(expect.arrayContaining([ + {attr: "style", oldValue: "display: none;", newValue: "display: block;"}, + {attr: "aria-expanded", oldValue: "false", newValue: "true"}, + {attr: "open", oldValue: null, newValue: "true"}, // chrome and firefox first transition from null to "" and then to "fade-in"; // safari goes straight from null to "fade-in", therefore we do not perform an exact match - expect.objectContaining({ attr: "class", newValue: "fade-in" }), - expect.objectContaining({ attr: "class", oldValue: "fade-in" }), - ])); - await expect(page.locator("#my-modal")).not.toHaveClass("fade-in"); - await expect(page.locator("#my-modal")).toHaveAttribute("aria-expanded", "true"); - await expect(page.locator("#my-modal")).toHaveAttribute("open", "true"); - await expect(page.locator("#my-modal")).toBeVisible(); + expect.objectContaining({attr: "class", newValue: "fade-in"}), + expect.objectContaining({attr: "class", oldValue: "fade-in"}), + ])) + await expect(page.locator("#my-modal")).not.toHaveClass("fade-in") + await expect(page.locator("#my-modal")).toHaveAttribute("aria-expanded", "true") + await expect(page.locator("#my-modal")).toHaveAttribute("open", "true") + await expect(page.locator("#my-modal")).toBeVisible() - changes = attributeMutations(page, "#my-modal"); - await page.getByRole("button", { name: "toggle modal" }).click(); + changes = attributeMutations(page, "#my-modal") + await page.getByRole("button", {name: "toggle modal"}).click() // wait for the transition time (set to 50) - await page.waitForTimeout(100); - await expect(await changes()).toEqual(expect.arrayContaining([ - { attr: "style", oldValue: "display: block;", newValue: "display: none;" }, - { attr: "aria-expanded", oldValue: "true", newValue: "false" }, - { attr: "open", oldValue: "true", newValue: null }, - expect.objectContaining({ attr: "class", newValue: "fade-out" }), - expect.objectContaining({ attr: "class", oldValue: "fade-out" }), - ])); - await expect(page.locator("#my-modal")).not.toHaveClass("fade-out"); - await expect(page.locator("#my-modal")).toHaveAttribute("aria-expanded", "false"); - await expect(page.locator("#my-modal")).not.toHaveAttribute("open"); - await expect(page.locator("#my-modal")).not.toBeVisible(); -}); + await page.waitForTimeout(100) + expect(await changes()).toEqual(expect.arrayContaining([ + {attr: "style", oldValue: "display: block;", newValue: "display: none;"}, + {attr: "aria-expanded", oldValue: "true", newValue: "false"}, + {attr: "open", oldValue: "true", newValue: null}, + expect.objectContaining({attr: "class", newValue: "fade-out"}), + expect.objectContaining({attr: "class", oldValue: "fade-out"}), + ])) + await expect(page.locator("#my-modal")).not.toHaveClass("fade-out") + await expect(page.locator("#my-modal")).toHaveAttribute("aria-expanded", "false") + await expect(page.locator("#my-modal")).not.toHaveAttribute("open") + await expect(page.locator("#my-modal")).toBeHidden() +}) -test("set and remove_attribute", async ({ page }) => { - await page.goto("/js"); - await syncLV(page); +test("set and remove_attribute", async ({page}) => { + await page.goto("/js") + await syncLV(page) - await expect(page.locator("#my-modal")).not.toBeVisible(); + await expect(page.locator("#my-modal")).toBeHidden() - let changes = attributeMutations(page, "#my-modal"); - await page.getByRole("button", { name: "show modal" }).click(); + let changes = attributeMutations(page, "#my-modal") + await page.getByRole("button", {name: "show modal"}).click() // wait for the transition time (set to 50) - await page.waitForTimeout(100); - await expect(await changes()).toEqual(expect.arrayContaining([ - { attr: "style", oldValue: "display: none;", newValue: "display: block;" }, - { attr: "aria-expanded", oldValue: "false", newValue: "true" }, - { attr: "open", oldValue: null, newValue: "true" }, - expect.objectContaining({ attr: "class", newValue: "fade-in" }), - expect.objectContaining({ attr: "class", oldValue: "fade-in" }), - ])); - await expect(page.locator("#my-modal")).not.toHaveClass("fade-in"); - await expect(page.locator("#my-modal")).toHaveAttribute("aria-expanded", "true"); - await expect(page.locator("#my-modal")).toHaveAttribute("open", "true"); - await expect(page.locator("#my-modal")).toBeVisible(); + await page.waitForTimeout(100) + expect(await changes()).toEqual(expect.arrayContaining([ + {attr: "style", oldValue: "display: none;", newValue: "display: block;"}, + {attr: "aria-expanded", oldValue: "false", newValue: "true"}, + {attr: "open", oldValue: null, newValue: "true"}, + expect.objectContaining({attr: "class", newValue: "fade-in"}), + expect.objectContaining({attr: "class", oldValue: "fade-in"}), + ])) + await expect(page.locator("#my-modal")).not.toHaveClass("fade-in") + await expect(page.locator("#my-modal")).toHaveAttribute("aria-expanded", "true") + await expect(page.locator("#my-modal")).toHaveAttribute("open", "true") + await expect(page.locator("#my-modal")).toBeVisible() - changes = attributeMutations(page, "#my-modal"); - await page.getByRole("button", { name: "hide modal" }).click(); + changes = attributeMutations(page, "#my-modal") + await page.getByRole("button", {name: "hide modal"}).click() // wait for the transition time (set to 50) - await page.waitForTimeout(100); - await expect(await changes()).toEqual(expect.arrayContaining([ - { attr: "style", oldValue: "display: block;", newValue: "display: none;" }, - { attr: "aria-expanded", oldValue: "true", newValue: "false" }, - { attr: "open", oldValue: "true", newValue: null }, - expect.objectContaining({ attr: "class", newValue: "fade-out" }), - expect.objectContaining({ attr: "class", oldValue: "fade-out" }), - ])); - await expect(page.locator("#my-modal")).not.toHaveClass("fade-out"); - await expect(page.locator("#my-modal")).toHaveAttribute("aria-expanded", "false"); - await expect(page.locator("#my-modal")).not.toHaveAttribute("open"); - await expect(page.locator("#my-modal")).not.toBeVisible(); -}); + await page.waitForTimeout(100) + expect(await changes()).toEqual(expect.arrayContaining([ + {attr: "style", oldValue: "display: block;", newValue: "display: none;"}, + {attr: "aria-expanded", oldValue: "true", newValue: "false"}, + {attr: "open", oldValue: "true", newValue: null}, + expect.objectContaining({attr: "class", newValue: "fade-out"}), + expect.objectContaining({attr: "class", oldValue: "fade-out"}), + ])) + await expect(page.locator("#my-modal")).not.toHaveClass("fade-out") + await expect(page.locator("#my-modal")).toHaveAttribute("aria-expanded", "false") + await expect(page.locator("#my-modal")).not.toHaveAttribute("open") + await expect(page.locator("#my-modal")).toBeHidden() +}) diff --git a/test/e2e/tests/navigation.spec.js b/test/e2e/tests/navigation.spec.js index c722dd5505..a3445507b2 100644 --- a/test/e2e/tests/navigation.spec.js +++ b/test/e2e/tests/navigation.spec.js @@ -1,227 +1,227 @@ -const { test, expect } = require("../test-fixtures"); -const { syncLV } = require("../utils"); +const {test, expect} = require("../test-fixtures") +const {syncLV} = require("../utils") -let webSocketEvents = []; -let networkEvents = []; +let webSocketEvents = [] +let networkEvents = [] -test.beforeEach(async ({ page }) => { - networkEvents = []; - webSocketEvents = []; +test.beforeEach(async ({page}) => { + networkEvents = [] + webSocketEvents = [] - page.on("request", request => networkEvents.push({ method: request.method(), url: request.url() })); + 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:4004/navigation/a" }, - { method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js" }, - { method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js" }, - ]); - - await expect(webSocketEvents).toEqual([ - expect.objectContaining({ type: "sent", payload: expect.stringContaining("phx_join") }), - expect.objectContaining({ type: "received", payload: expect.stringContaining("phx_reply") }), - ]); + 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) + + expect(networkEvents).toEqual([ + {method: "GET", url: "http://localhost:4004/navigation/a"}, + {method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js"}, + {method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js"}, + ]) + + 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 = []; + 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 = []; + const length = await page.evaluate(() => window.history.length) + await page.getByRole("link", {name: "Patch this LiveView"}).click() + await syncLV(page) + expect(networkEvents).toEqual([]) + expect(webSocketEvents).toEqual([ + expect.objectContaining({type: "sent", payload: expect.stringContaining("live_patch")}), + expect.objectContaining({type: "received", payload: expect.stringContaining("phx_reply")}), + ]) + 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 page.getByRole("link", {name: "LiveView B"}).click() + await syncLV(page) - await expect(networkEvents).toEqual([]); + 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("popstate", async ({ page }) => { - await page.goto("/navigation/a"); - await syncLV(page); + 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("popstate", async ({page}) => { + await page.goto("/navigation/a") + await syncLV(page) // clear network events - networkEvents = []; + networkEvents = [] - await page.getByRole("link", { name: "Patch this LiveView" }).click(); - await syncLV(page); - await expect(page).toHaveURL(/\/navigation\/a\?/); - await expect(networkEvents).toEqual([]); + await page.getByRole("link", {name: "Patch this LiveView"}).click() + await syncLV(page) + await expect(page).toHaveURL(/\/navigation\/a\?/) + expect(networkEvents).toEqual([]) - await page.getByRole("link", { name: "LiveView B" }).click(), - await syncLV(page); - await expect(page).toHaveURL("/navigation/b"); - await expect(networkEvents).toEqual([]); + await page.getByRole("link", {name: "LiveView B"}).click(), + await syncLV(page) + await expect(page).toHaveURL("/navigation/b") + expect(networkEvents).toEqual([]) - await page.goBack(); - await syncLV(page); - await expect(networkEvents).toEqual([]); - await expect(page).toHaveURL(/\/navigation\/a\?/); + await page.goBack() + await syncLV(page) + expect(networkEvents).toEqual([]) + await expect(page).toHaveURL(/\/navigation\/a\?/) - await page.goBack(); - await syncLV(page); - await expect(networkEvents).toEqual([]); - await expect(page).toHaveURL("/navigation/a"); + await page.goBack() + await syncLV(page) + expect(networkEvents).toEqual([]) + await expect(page).toHaveURL("/navigation/a") // and forward again - await page.goForward(); - await page.goForward(); - await syncLV(page); - await expect(page).toHaveURL("/navigation/b"); + await page.goForward() + await page.goForward() + await syncLV(page) + await expect(page).toHaveURL("/navigation/b") // everything was sent over the websocket, no network requests - await expect(networkEvents).toEqual([]); -}); + expect(networkEvents).toEqual([]) +}) -test("patch with replace replaces history", async ({ page }) => { - await page.goto("/navigation/a"); - await syncLV(page); - const url = page.url(); +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); + const length = await page.evaluate(() => window.history.length) - await page.getByRole("link", { name: "Patch (Replace)" }).click(); - await syncLV(page); + 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); -}); + expect(await page.evaluate(() => window.history.length)).toEqual(length) + 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); +test("falls back to http navigation when navigating between live sessions", async ({page, browserName}) => { + await page.goto("/navigation/a") + await syncLV(page) - networkEvents = []; - webSocketEvents = []; + 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:4004/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.stringMatching(/error.*unauthorized/) }, - ].concat(browserName === "webkit" ? [] : [{ type: "close" }]))); + await page.getByRole("link", {name: "LiveView (other session)"}).click() + await syncLV(page) + + expect(networkEvents).toEqual(expect.arrayContaining([{method: "GET", url: "http://localhost:4004/stream"}])) + expect(webSocketEvents).toEqual(expect.arrayContaining([ + {type: "sent", payload: expect.stringContaining("phx_leave")}, + {type: "sent", payload: expect.stringContaining("phx_join")}, + {type: "received", payload: expect.stringMatching(/error.*unauthorized/)}, + ].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); +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(page.locator("#items")).toContainText("Item 42") - await expect(await page.evaluate(() => document.documentElement.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); + expect(await page.evaluate(() => document.documentElement.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.waitForTimeout(150) - await page.getByRole("link", { name: "Item 42" }).click(); - await syncLV(page); + await page.getByRole("link", {name: "Item 42"}).click() + await syncLV(page) - await page.goBack(); - await syncLV(page); + await page.goBack() + await syncLV(page) // scroll position is restored await expect.poll( async () => { - return await page.evaluate(() => document.documentElement.scrollTop); + return await page.evaluate(() => document.documentElement.scrollTop) }, - { message: 'scrollTop not restored', timeout: 5000 } - ).toBe(offset); -}); + {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); +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(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); + 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.getByRole("link", {name: "Item 42"}).click() + await syncLV(page) - await page.goBack(); - 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); + return await page.locator("#my-scroll-container").evaluate((el) => el.scrollTop) }, - { message: 'scrollTop not restored', timeout: 5000 } - ).toBe(0); -}); + {message: "scrollTop not restored", timeout: 5000} + ).toBe(0) +}) -test("scrolls hash el into view", async ({ page }) => { - await page.goto("/navigation/b"); - await syncLV(page); +test("scrolls hash el into view", async ({page}) => { + await page.goto("/navigation/b") + await syncLV(page) - await expect(page.locator("#items")).toContainText("Item 42"); + 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; + 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.getByRole("link", { name: "Go to 42" }).click(); - await expect(page).toHaveURL("/navigation/b#items-item-42"); + await page.getByRole("link", {name: "Go to 42"}).click() + await expect(page).toHaveURL("/navigation/b#items-item-42") let scrollTop = await page.evaluate(() => document.documentElement.scrollTop) - await expect(scrollTop).not.toBe(0); - await expect(scrollTop).toBeGreaterThanOrEqual(offset - 500); - await expect(scrollTop).toBeLessThanOrEqual(offset + 500); + expect(scrollTop).not.toBe(0) + expect(scrollTop).toBeGreaterThanOrEqual(offset - 500) + expect(scrollTop).toBeLessThanOrEqual(offset + 500) - await page.goto("/navigation/a"); - await page.goto("/navigation/b#items-item-42"); + await page.goto("/navigation/a") + await page.goto("/navigation/b#items-item-42") scrollTop = await page.evaluate(() => document.documentElement.scrollTop) - await expect(scrollTop).not.toBe(0); - await expect(scrollTop).toBeGreaterThanOrEqual(offset - 500); - await expect(scrollTop).toBeLessThanOrEqual(offset + 500); -}); - -test("scrolls hash el into view after live navigation (issue #3452)", async ({ page }) => { - await page.goto("/navigation/a"); - await syncLV(page); - - await page.getByRole("link", { name: "Navigate to 42" }).click(); - await expect(page).toHaveURL("/navigation/b#items-item-42"); - let scrollTop = await page.evaluate(() => document.documentElement.scrollTop); - const offset = (await page.locator("#items-item-42").evaluate((el) => el.offsetTop)) - 200; - await expect(scrollTop).not.toBe(0); - await expect(scrollTop).toBeGreaterThanOrEqual(offset - 500); - await expect(scrollTop).toBeLessThanOrEqual(offset + 500); -}); + expect(scrollTop).not.toBe(0) + expect(scrollTop).toBeGreaterThanOrEqual(offset - 500) + expect(scrollTop).toBeLessThanOrEqual(offset + 500) +}) + +test("scrolls hash el into view after live navigation (issue #3452)", async ({page}) => { + await page.goto("/navigation/a") + await syncLV(page) + + await page.getByRole("link", {name: "Navigate to 42"}).click() + await expect(page).toHaveURL("/navigation/b#items-item-42") + let scrollTop = await page.evaluate(() => document.documentElement.scrollTop) + const offset = (await page.locator("#items-item-42").evaluate((el) => el.offsetTop)) - 200 + expect(scrollTop).not.toBe(0) + expect(scrollTop).toBeGreaterThanOrEqual(offset - 500) + expect(scrollTop).toBeLessThanOrEqual(offset + 500) +}) diff --git a/test/e2e/tests/select.spec.js b/test/e2e/tests/select.spec.js index 7b47386071..8ba89e276d 100644 --- a/test/e2e/tests/select.spec.js +++ b/test/e2e/tests/select.spec.js @@ -1,23 +1,23 @@ -const { test, expect } = require("../test-fixtures"); -const { syncLV } = require("../utils"); +const {test, expect} = require("../test-fixtures") +const {syncLV} = require("../utils") // this tests issue #2659 // https://github.com/phoenixframework/phoenix_live_view/pull/2659 -test("select shows error when invalid option is selected", async ({ page }) => { - await page.goto("/select"); - await syncLV(page); +test("select shows error when invalid option is selected", async ({page}) => { + await page.goto("/select") + await syncLV(page) - const select3 = page.locator("#select_form_select3"); - await expect(select3).toHaveValue("2"); - await expect(select3).not.toHaveClass("has-error"); + const select3 = page.locator("#select_form_select3") + await expect(select3).toHaveValue("2") + await expect(select3).not.toHaveClass("has-error") // 5 or below should be invalid - await select3.selectOption("3"); - await syncLV(page); - await expect(select3).toHaveClass("has-error"); + await select3.selectOption("3") + await syncLV(page) + await expect(select3).toHaveClass("has-error") // 6 or above should be valid - await select3.selectOption("6"); - await syncLV(page); - await expect(select3).not.toHaveClass("has-error"); -}); + await select3.selectOption("6") + await syncLV(page) + await expect(select3).not.toHaveClass("has-error") +}) diff --git a/test/e2e/tests/streams.spec.js b/test/e2e/tests/streams.spec.js index 11cd24013f..e4c786a67f 100644 --- a/test/e2e/tests/streams.spec.js +++ b/test/e2e/tests/streams.spec.js @@ -1,841 +1,841 @@ -const { test, expect } = require("../test-fixtures"); -const { syncLV, evalLV } = require("../utils"); +const {test, expect} = require("../test-fixtures") +const {syncLV, evalLV} = 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() }))); + .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 }) => { - - await page.goto("/stream"); - await syncLV(page); - - await expect(await usersInDom(page, "c_users")).toEqual([ - { id: "c_users-1", text: "chris" }, - { id: "c_users-2", text: "callan" } - ]); - - await page.locator("#c_users-2").getByRole("button", { name: "make first" }).click(); - await expect(await usersInDom(page, "c_users")).toEqual([ - { id: "c_users-2", text: "updated" }, - { id: "c_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("stream reset updates attributes", 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 page.locator("#users-1").getAttribute("data-count")).toEqual("0"); - await expect(await page.locator("#users-2").getAttribute("data-count")).toEqual("0"); - - 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" } - ]); - - await expect(await page.locator("#users-1").getAttribute("data-count")).toEqual("1"); - await expect(await page.locator("#users-3").getAttribute("data-count")).toEqual("1"); - await expect(await page.locator("#users-4").getAttribute("data-count")).toEqual("1"); -}); +test("renders properly", async ({page}) => { + await page.goto("/stream") + await syncLV(page) + + expect(await usersInDom(page, "users")).toEqual([ + {id: "users-1", text: "chris"}, + {id: "users-2", text: "callan"} + ]) + expect(await usersInDom(page, "c_users")).toEqual([ + {id: "c_users-1", text: "chris"}, + {id: "c_users-2", text: "callan"} + ]) + 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) + + expect(await usersInDom(page, "users")).toEqual([ + {id: "users-1", text: "updated"}, + {id: "users-2", text: "callan"} + ]) + expect(await usersInDom(page, "c_users")).toEqual([ + {id: "c_users-1", text: "chris"}, + {id: "c_users-2", text: "callan"} + ]) + 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) + + expect(await usersInDom(page, "users")).toEqual([ + {id: "users-1", text: "updated"}, + {id: "users-2", text: "updated"} + ]) + expect(await usersInDom(page, "c_users")).toEqual([ + {id: "c_users-1", text: "chris"}, + {id: "c_users-2", text: "callan"} + ]) + 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) + + 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) + + expect(await usersInDom(page, "c_users")).toEqual([ + {id: "c_users-1", text: "updated"}, + {id: "c_users-2", text: "callan"} + ]) + expect(await usersInDom(page, "users")).toEqual([ + {id: "users-1", text: "chris"}, + {id: "users-2", text: "callan"} + ]) + 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) + + expect(await usersInDom(page, "c_users")).toEqual([ + {id: "c_users-1", text: "updated"}, + {id: "c_users-2", text: "updated"} + ]) + expect(await usersInDom(page, "users")).toEqual([ + {id: "users-1", text: "chris"}, + {id: "users-2", text: "callan"} + ]) + 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) + + 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}) => { + + await page.goto("/stream") + await syncLV(page) + + expect(await usersInDom(page, "c_users")).toEqual([ + {id: "c_users-1", text: "chris"}, + {id: "c_users-2", text: "callan"} + ]) + + await page.locator("#c_users-2").getByRole("button", {name: "make first"}).click() + expect(await usersInDom(page, "c_users")).toEqual([ + {id: "c_users-2", text: "updated"}, + {id: "c_users-1", text: "chris"} + ]) +}) + +test("stream reset removes items", async ({page}) => { + await page.goto("/stream") + await syncLV(page) + + 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) + + expect(await usersInDom(page, "users")).toEqual([]) +}) + +test("stream reset properly reorders items", async ({page}) => { + await page.goto("/stream") + await syncLV(page) + + 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) + + expect(await usersInDom(page, "users")).toEqual([ + {id: "users-3", text: "peter"}, + {id: "users-1", text: "chris"}, + {id: "users-4", text: "mona"} + ]) +}) + +test("stream reset updates attributes", async ({page}) => { + await page.goto("/stream") + await syncLV(page) + + expect(await usersInDom(page, "users")).toEqual([ + {id: "users-1", text: "chris"}, + {id: "users-2", text: "callan"} + ]) + + await await expect(page.locator("#users-1")).toHaveAttribute("data-count", "0") + await await expect(page.locator("#users-2")).toHaveAttribute("data-count", "0") + + await page.getByRole("button", {name: "Reorder"}).click() + await syncLV(page) + + expect(await usersInDom(page, "users")).toEqual([ + {id: "users-3", text: "peter"}, + {id: "users-1", text: "chris"}, + {id: "users-4", text: "mona"} + ]) + + await await expect(page.locator("#users-1")).toHaveAttribute("data-count", "1") + await await expect(page.locator("#users-3")).toHaveAttribute("data-count", "1") + await await expect(page.locator("#users-4")).toHaveAttribute("data-count", "1") +}) test.describe("Issue #2656", () => { - test("stream reset works when patching", async ({ page }) => { - await page.goto("/healthy/fruits"); - await syncLV(page); + 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 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 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("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 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 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"); - }); -}); + 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") + }) +}) // helper function used below -const listItems = async (page) => page.locator("ul > li").evaluateAll(list => list.map(el => ({ id: el.id, text: el.innerText }))); +const listItems = async (page) => page.locator("ul > li").evaluateAll(list => list.map(el => ({id: el.id, text: el.innerText}))) test.describe("Issue #2994", () => { - test("can filter and reset a stream", async ({ page }) => { - await page.goto("/stream/reset"); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Filter" }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Reset" }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - }); - - test("can reorder stream", async ({ page }) => { - await page.goto("/stream/reset"); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Reorder" }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-b", text: "B" }, - { id: "items-a", text: "A" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "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([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Filter" }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Prepend", exact: true }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: expect.stringMatching(/items-a-.*/), text: expect.any(String) }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Reset" }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Append", exact: true }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" }, - { id: expect.stringMatching(/items-a-.*/), text: expect.any(String) } - ]); - }); -}); + test("can filter and reset a stream", async ({page}) => { + await page.goto("/stream/reset") + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Filter"}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Reset"}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + }) + + test("can reorder stream", async ({page}) => { + await page.goto("/stream/reset") + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Reorder"}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-b", text: "B"}, + {id: "items-a", text: "A"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + }) + + test("can filter and then prepend / append stream", async ({page}) => { + await page.goto("/stream/reset") + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Filter"}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Prepend", exact: true}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: expect.stringMatching(/items-a-.*/), text: expect.any(String)}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Reset"}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Append", exact: true}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"}, + {id: expect.stringMatching(/items-a-.*/), text: expect.any(String)} + ]) + }) +}) test.describe("Issue #2982", () => { - 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([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Reorder" }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-e", text: "E" }, - { id: "items-a", text: "A" }, - { id: "items-f", text: "F" }, - { id: "items-g", text: "G" }, - ]); - }); -}); + test("can reorder a stream with LiveComponents as direct stream children", async ({page}) => { + await page.goto("/stream/reset-lc") + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Reorder"}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-e", text: "E"}, + {id: "items-a", text: "A"}, + {id: "items-f", text: "F"}, + {id: "items-g", text: "G"}, + ]) + }) +}) test.describe("Issue #3023", () => { - test("can bulk insert items at a specific index", async ({ page }) => { - await page.goto("/stream/reset"); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Bulk insert" }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-e", text: "E" }, - { id: "items-f", text: "F" }, - { id: "items-g", text: "G" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - }); -}); + test("can bulk insert items at a specific index", async ({page}) => { + await page.goto("/stream/reset") + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Bulk insert"}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-e", text: "E"}, + {id: "items-f", text: "F"}, + {id: "items-g", text: "G"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + }) +}) test.describe("stream limit - issue #2686", () => { - test("limit is enforced on mount, but not dead render", async ({ page, request }) => { - const html = await (request.get("/stream/limit").then(r => r.text())); - for (let i = 1; i <= 10; i++) { - await expect(html).toContain(`id="items-${i}"`); + test("limit is enforced on mount, but not dead render", async ({page, request}) => { + const html = await (request.get("/stream/limit").then(r => r.text())) + for(let i = 1; i <= 10; i++){ + expect(html).toContain(`id="items-${i}"`) } - await page.goto("/stream/limit"); - await syncLV(page); + await page.goto("/stream/limit") + await syncLV(page) - await expect(await listItems(page)).toEqual([ - { id: "items-6", text: "6" }, - { id: "items-7", text: "7" }, - { id: "items-8", text: "8" }, - { id: "items-9", text: "9" }, - { id: "items-10", text: "10" } - ]); - }); + expect(await listItems(page)).toEqual([ + {id: "items-6", text: "6"}, + {id: "items-7", text: "7"}, + {id: "items-8", text: "8"}, + {id: "items-9", text: "9"}, + {id: "items-10", text: "10"} + ]) + }) - test("removes item at front when appending and limit is negative", async ({ page }) => { - await page.goto("/stream/limit"); - await syncLV(page); + test("removes item at front when appending and limit is negative", async ({page}) => { + await page.goto("/stream/limit") + await syncLV(page) // these are the defaults in the LV - await expect(page.locator("input[name='at']")).toHaveValue("-1"); - await expect(page.locator("input[name='limit']")).toHaveValue("-5"); - - await page.getByRole("button", { name: "add 1", exact: true }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-7", text: "7" }, - { id: "items-8", text: "8" }, - { id: "items-9", text: "9" }, - { id: "items-10", text: "10" }, - { id: "items-11", text: "11" } - ]); - await page.getByRole("button", { name: "add 10", exact: true }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-17", text: "17" }, - { id: "items-18", text: "18" }, - { id: "items-19", text: "19" }, - { id: "items-20", text: "20" }, - { id: "items-21", text: "21" } - ]); - }); - - test("removes item at back when prepending and limit is positive", async ({ page }) => { - await page.goto("/stream/limit"); - await syncLV(page); - - await page.locator("input[name='at']").fill("0"); - await page.locator("input[name='limit']").fill("5"); - await page.getByRole("button", { name: "recreate stream" }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-10", text: "10" }, - { id: "items-9", text: "9" }, - { id: "items-8", text: "8" }, - { id: "items-7", text: "7" }, - { id: "items-6", text: "6" } - ]); - - await page.getByRole("button", { name: "add 1", exact: true }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-11", text: "11" }, - { id: "items-10", text: "10" }, - { id: "items-9", text: "9" }, - { id: "items-8", text: "8" }, - { id: "items-7", text: "7" } - ]); - - await page.getByRole("button", { name: "add 10", exact: true }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-21", text: "21" }, - { id: "items-20", text: "20" }, - { id: "items-19", text: "19" }, - { id: "items-18", text: "18" }, - { id: "items-17", text: "17" } - ]); - }); - - test("does nothing if appending and positive limit is reached", async ({ page }) => { - await page.goto("/stream/limit"); - await syncLV(page); - - await page.locator("input[name='at']").fill("-1"); - await page.locator("input[name='limit']").fill("5"); - await page.getByRole("button", { name: "recreate stream" }).click(); - await syncLV(page); - - await page.getByRole("button", { name: "clear" }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([]); - - const items = []; - for (let i = 1; i <= 5; i++) { - await page.getByRole("button", { name: "add 1", exact: true }).click(); - await syncLV(page); - items.push({ id: `items-${i}`, text: i.toString() }); - await expect(await listItems(page)).toEqual(items); + await expect(page.locator("input[name='at']")).toHaveValue("-1") + await expect(page.locator("input[name='limit']")).toHaveValue("-5") + + await page.getByRole("button", {name: "add 1", exact: true}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-7", text: "7"}, + {id: "items-8", text: "8"}, + {id: "items-9", text: "9"}, + {id: "items-10", text: "10"}, + {id: "items-11", text: "11"} + ]) + await page.getByRole("button", {name: "add 10", exact: true}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-17", text: "17"}, + {id: "items-18", text: "18"}, + {id: "items-19", text: "19"}, + {id: "items-20", text: "20"}, + {id: "items-21", text: "21"} + ]) + }) + + test("removes item at back when prepending and limit is positive", async ({page}) => { + await page.goto("/stream/limit") + await syncLV(page) + + await page.locator("input[name='at']").fill("0") + await page.locator("input[name='limit']").fill("5") + await page.getByRole("button", {name: "recreate stream"}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-10", text: "10"}, + {id: "items-9", text: "9"}, + {id: "items-8", text: "8"}, + {id: "items-7", text: "7"}, + {id: "items-6", text: "6"} + ]) + + await page.getByRole("button", {name: "add 1", exact: true}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-11", text: "11"}, + {id: "items-10", text: "10"}, + {id: "items-9", text: "9"}, + {id: "items-8", text: "8"}, + {id: "items-7", text: "7"} + ]) + + await page.getByRole("button", {name: "add 10", exact: true}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-21", text: "21"}, + {id: "items-20", text: "20"}, + {id: "items-19", text: "19"}, + {id: "items-18", text: "18"}, + {id: "items-17", text: "17"} + ]) + }) + + test("does nothing if appending and positive limit is reached", async ({page}) => { + await page.goto("/stream/limit") + await syncLV(page) + + await page.locator("input[name='at']").fill("-1") + await page.locator("input[name='limit']").fill("5") + await page.getByRole("button", {name: "recreate stream"}).click() + await syncLV(page) + + await page.getByRole("button", {name: "clear"}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([]) + + const items = [] + for(let i = 1; i <= 5; i++){ + await page.getByRole("button", {name: "add 1", exact: true}).click() + await syncLV(page) + items.push({id: `items-${i}`, text: i.toString()}) + expect(await listItems(page)).toEqual(items) } // now adding new items should do nothing, as the limit is reached - await page.getByRole("button", { name: "add 1", exact: true }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-1", text: "1" }, - { id: "items-2", text: "2" }, - { id: "items-3", text: "3" }, - { id: "items-4", text: "4" }, - { id: "items-5", text: "5" } - ]); + await page.getByRole("button", {name: "add 1", exact: true}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-1", text: "1"}, + {id: "items-2", text: "2"}, + {id: "items-3", text: "3"}, + {id: "items-4", text: "4"}, + {id: "items-5", text: "5"} + ]) // same when bulk inserting - await page.getByRole("button", { name: "add 10", exact: true }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-1", text: "1" }, - { id: "items-2", text: "2" }, - { id: "items-3", text: "3" }, - { id: "items-4", text: "4" }, - { id: "items-5", text: "5" } - ]); - }); - - test("does nothing if prepending and negative limit is reached", async ({ page }) => { - await page.goto("/stream/limit"); - await syncLV(page); - - await page.locator("input[name='at']").fill("0"); - await page.locator("input[name='limit']").fill("-5"); - await page.getByRole("button", { name: "recreate stream" }).click(); - await syncLV(page); - - await page.getByRole("button", { name: "clear" }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([]); - - const items = []; - for (let i = 1; i <= 5; i++) { - await page.getByRole("button", { name: "add 1", exact: true }).click(); - await syncLV(page); - items.unshift({ id: `items-${i}`, text: i.toString() }); - await expect(await listItems(page)).toEqual(items); + await page.getByRole("button", {name: "add 10", exact: true}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-1", text: "1"}, + {id: "items-2", text: "2"}, + {id: "items-3", text: "3"}, + {id: "items-4", text: "4"}, + {id: "items-5", text: "5"} + ]) + }) + + test("does nothing if prepending and negative limit is reached", async ({page}) => { + await page.goto("/stream/limit") + await syncLV(page) + + await page.locator("input[name='at']").fill("0") + await page.locator("input[name='limit']").fill("-5") + await page.getByRole("button", {name: "recreate stream"}).click() + await syncLV(page) + + await page.getByRole("button", {name: "clear"}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([]) + + const items = [] + for(let i = 1; i <= 5; i++){ + await page.getByRole("button", {name: "add 1", exact: true}).click() + await syncLV(page) + items.unshift({id: `items-${i}`, text: i.toString()}) + expect(await listItems(page)).toEqual(items) } // now adding new items should do nothing, as the limit is reached - await page.getByRole("button", { name: "add 1", exact: true }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-5", text: "5" }, - { id: "items-4", text: "4" }, - { id: "items-3", text: "3" }, - { id: "items-2", text: "2" }, - { id: "items-1", text: "1" } - ]); + await page.getByRole("button", {name: "add 1", exact: true}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-5", text: "5"}, + {id: "items-4", text: "4"}, + {id: "items-3", text: "3"}, + {id: "items-2", text: "2"}, + {id: "items-1", text: "1"} + ]) // same when bulk inserting - await page.getByRole("button", { name: "add 10", exact: true }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-5", text: "5" }, - { id: "items-4", text: "4" }, - { id: "items-3", text: "3" }, - { id: "items-2", text: "2" }, - { id: "items-1", text: "1" } - ]); - }); - - test("arbitrary index", async ({ page }) => { - await page.goto("/stream/limit"); - await syncLV(page); - - await page.locator("input[name='at']").fill("1"); - await page.locator("input[name='limit']").fill("5"); - await page.getByRole("button", { name: "recreate stream" }).click(); - await syncLV(page); + await page.getByRole("button", {name: "add 10", exact: true}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-5", text: "5"}, + {id: "items-4", text: "4"}, + {id: "items-3", text: "3"}, + {id: "items-2", text: "2"}, + {id: "items-1", text: "1"} + ]) + }) + + test("arbitrary index", async ({page}) => { + await page.goto("/stream/limit") + await syncLV(page) + + await page.locator("input[name='at']").fill("1") + await page.locator("input[name='limit']").fill("5") + await page.getByRole("button", {name: "recreate stream"}).click() + await syncLV(page) // we tried to insert 10 items - await expect(await listItems(page)).toEqual([ - { id: "items-1", text: "1" }, - { id: "items-10", text: "10" }, - { id: "items-9", text: "9" }, - { id: "items-8", text: "8" }, - { id: "items-7", text: "7" } - ]); - - await page.getByRole("button", { name: "add 10", exact: true }).click(); - await syncLV(page); - await expect(await listItems(page)).toEqual([ - { id: "items-1", text: "1" }, - { id: "items-20", text: "20" }, - { id: "items-19", text: "19" }, - { id: "items-18", text: "18" }, - { id: "items-17", text: "17" } - ]); - - await page.locator("input[name='at']").fill("1"); - await page.locator("input[name='limit']").fill("-5"); - await page.getByRole("button", { name: "recreate stream" }).click(); - await syncLV(page); + expect(await listItems(page)).toEqual([ + {id: "items-1", text: "1"}, + {id: "items-10", text: "10"}, + {id: "items-9", text: "9"}, + {id: "items-8", text: "8"}, + {id: "items-7", text: "7"} + ]) + + await page.getByRole("button", {name: "add 10", exact: true}).click() + await syncLV(page) + expect(await listItems(page)).toEqual([ + {id: "items-1", text: "1"}, + {id: "items-20", text: "20"}, + {id: "items-19", text: "19"}, + {id: "items-18", text: "18"}, + {id: "items-17", text: "17"} + ]) + + await page.locator("input[name='at']").fill("1") + await page.locator("input[name='limit']").fill("-5") + await page.getByRole("button", {name: "recreate stream"}).click() + await syncLV(page) // we tried to insert 10 items - await expect(await listItems(page)).toEqual([ - { id: "items-10", text: "10" }, - { id: "items-5", text: "5" }, - { id: "items-4", text: "4" }, - { id: "items-3", text: "3" }, - { id: "items-2", text: "2" } - ]); - - await page.getByRole("button", { name: "add 10", exact: true }).click(); - await syncLV(page); - await expect(await listItems(page)).toEqual([ - { id: "items-20", text: "20" }, - { id: "items-5", text: "5" }, - { id: "items-4", text: "4" }, - { id: "items-3", text: "3" }, - { id: "items-2", text: "2" } - ]); - }); -}); - -test("any stream insert for elements already in the DOM does not reorder", async ({ page }) => { - await page.goto("/stream/reset"); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Prepend C" }).click(); - await syncLV(page); - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Append C" }).click(); - await syncLV(page); - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Insert C at 1" }).click(); - await syncLV(page); - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Insert at 1", exact: true }).click(); - await syncLV(page); - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: expect.stringMatching(/items-a-.*/), text: expect.any(String) }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Reset" }).click(); - await syncLV(page); - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Delete C and insert at 1" }).click(); - await syncLV(page); - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-c", text: "C" }, - { id: "items-b", text: "B" }, - { id: "items-d", text: "D" } - ]); -}); - -test("stream nested in a LiveComponent is properly restored on reset", async ({ page }) => { - await page.goto("/stream/nested-component-reset"); - await syncLV(page); - - const childItems = async (page, id) => page.locator(`#${id} div[phx-update=stream] > *`).evaluateAll(div => div.map(el => ({ id: el.id, text: el.innerText }))); - - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: expect.stringMatching(/A/) }, - { id: "items-b", text: expect.stringMatching(/B/) }, - { id: "items-c", text: expect.stringMatching(/C/) }, - { id: "items-d", text: expect.stringMatching(/D/) } - ]); - - for (let id of ["a", "b", "c", "d"]) { - await expect(await childItems(page, `items-${id}`)).toEqual([ - { id: `nested-items-${id}-a`, text: "N-A" }, - { id: `nested-items-${id}-b`, text: "N-B" }, - { id: `nested-items-${id}-c`, text: "N-C" }, - { id: `nested-items-${id}-d`, text: "N-D" }, + expect(await listItems(page)).toEqual([ + {id: "items-10", text: "10"}, + {id: "items-5", text: "5"}, + {id: "items-4", text: "4"}, + {id: "items-3", text: "3"}, + {id: "items-2", text: "2"} + ]) + + await page.getByRole("button", {name: "add 10", exact: true}).click() + await syncLV(page) + expect(await listItems(page)).toEqual([ + {id: "items-20", text: "20"}, + {id: "items-5", text: "5"}, + {id: "items-4", text: "4"}, + {id: "items-3", text: "3"}, + {id: "items-2", text: "2"} + ]) + }) +}) + +test("any stream insert for elements already in the DOM does not reorder", async ({page}) => { + await page.goto("/stream/reset") + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Prepend C"}).click() + await syncLV(page) + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Append C"}).click() + await syncLV(page) + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Insert C at 1"}).click() + await syncLV(page) + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Insert at 1", exact: true}).click() + await syncLV(page) + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: expect.stringMatching(/items-a-.*/), text: expect.any(String)}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Reset"}).click() + await syncLV(page) + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Delete C and insert at 1"}).click() + await syncLV(page) + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-c", text: "C"}, + {id: "items-b", text: "B"}, + {id: "items-d", text: "D"} + ]) +}) + +test("stream nested in a LiveComponent is properly restored on reset", async ({page}) => { + await page.goto("/stream/nested-component-reset") + await syncLV(page) + + const childItems = async (page, id) => page.locator(`#${id} div[phx-update=stream] > *`).evaluateAll(div => div.map(el => ({id: el.id, text: el.innerText}))) + + expect(await listItems(page)).toEqual([ + {id: "items-a", text: expect.stringMatching(/A/)}, + {id: "items-b", text: expect.stringMatching(/B/)}, + {id: "items-c", text: expect.stringMatching(/C/)}, + {id: "items-d", text: expect.stringMatching(/D/)} + ]) + + for(let id of ["a", "b", "c", "d"]){ + expect(await childItems(page, `items-${id}`)).toEqual([ + {id: `nested-items-${id}-a`, text: "N-A"}, + {id: `nested-items-${id}-b`, text: "N-B"}, + {id: `nested-items-${id}-c`, text: "N-C"}, + {id: `nested-items-${id}-d`, text: "N-D"}, ]) } // now reorder the nested stream of items-a - await page.locator("#items-a button").click(); - await syncLV(page); - - await expect(await childItems(page, "items-a")).toEqual([ - { id: "nested-items-a-e", text: "N-E" }, - { id: "nested-items-a-a", text: "N-A" }, - { id: "nested-items-a-f", text: "N-F" }, - { id: "nested-items-a-g", text: "N-G" }, - ]); + await page.locator("#items-a button").click() + await syncLV(page) + + expect(await childItems(page, "items-a")).toEqual([ + {id: "nested-items-a-e", text: "N-E"}, + {id: "nested-items-a-a", text: "N-A"}, + {id: "nested-items-a-f", text: "N-F"}, + {id: "nested-items-a-g", text: "N-G"}, + ]) // unchanged - for (let id of ["b", "c", "d"]) { - await expect(await childItems(page, `items-${id}`)).toEqual([ - { id: `nested-items-${id}-a`, text: "N-A" }, - { id: `nested-items-${id}-b`, text: "N-B" }, - { id: `nested-items-${id}-c`, text: "N-C" }, - { id: `nested-items-${id}-d`, text: "N-D" }, + for(let id of ["b", "c", "d"]){ + expect(await childItems(page, `items-${id}`)).toEqual([ + {id: `nested-items-${id}-a`, text: "N-A"}, + {id: `nested-items-${id}-b`, text: "N-B"}, + {id: `nested-items-${id}-c`, text: "N-C"}, + {id: `nested-items-${id}-d`, text: "N-D"}, ]) } // now reorder the parent stream - await page.locator("#parent-reorder").click(); - await syncLV(page); - await expect(await listItems(page)).toEqual([ - { id: "items-e", text: expect.stringMatching(/E/) }, - { id: "items-a", text: expect.stringMatching(/A/) }, - { id: "items-f", text: expect.stringMatching(/F/) }, - { id: "items-g", text: expect.stringMatching(/G/) }, - ]); + await page.locator("#parent-reorder").click() + await syncLV(page) + expect(await listItems(page)).toEqual([ + {id: "items-e", text: expect.stringMatching(/E/)}, + {id: "items-a", text: expect.stringMatching(/A/)}, + {id: "items-f", text: expect.stringMatching(/F/)}, + {id: "items-g", text: expect.stringMatching(/G/)}, + ]) // the new children's stream items have the correct order - for (let id of ["e", "f", "g"]) { - await expect(await childItems(page, `items-${id}`)).toEqual([ - { id: `nested-items-${id}-a`, text: "N-A" }, - { id: `nested-items-${id}-b`, text: "N-B" }, - { id: `nested-items-${id}-c`, text: "N-C" }, - { id: `nested-items-${id}-d`, text: "N-D" }, + for(let id of ["e", "f", "g"]){ + expect(await childItems(page, `items-${id}`)).toEqual([ + {id: `nested-items-${id}-a`, text: "N-A"}, + {id: `nested-items-${id}-b`, text: "N-B"}, + {id: `nested-items-${id}-c`, text: "N-C"}, + {id: `nested-items-${id}-d`, text: "N-D"}, ]) } // Item A has the same children as before, still reordered - await expect(await childItems(page, "items-a")).toEqual([ - { id: "nested-items-a-e", text: "N-E" }, - { id: "nested-items-a-a", text: "N-A" }, - { id: "nested-items-a-f", text: "N-F" }, - { id: "nested-items-a-g", text: "N-G" }, - ]); -}); - -test("phx-remove is handled correctly when restoring nodes", async ({ page }) => { - await page.goto("/stream/reset?phx-remove"); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Filter" }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); - - await page.getByRole("button", { name: "Reset" }).click(); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); -}); - -test("issue #3129 - streams asynchronously assigned and rendered inside a comprehension", async ({ page }) => { - await page.goto("/stream/inside-for"); - await syncLV(page); - - await expect(await listItems(page)).toEqual([ - { id: "items-a", text: "A" }, - { id: "items-b", text: "B" }, - { id: "items-c", text: "C" }, - { id: "items-d", text: "D" } - ]); -}); - -test("issue #3260 - supports non-stream items with id in stream container", async ({ page }) => { - await page.goto("/stream?empty_item"); - - await expect(await usersInDom(page, "users")).toEqual([ - { id: "users-1", text: "chris" }, - { id: "users-2", text: "callan" }, - { id: "users-empty", text: "Empty!" } - ]); - - await expect(page.getByText("Empty")).not.toBeVisible(); - await evalLV(page, `socket.view.handle_event("reset-users", %{}, socket)`); - await expect(page.getByText("Empty")).toBeVisible(); - await expect(await usersInDom(page, "users")).toEqual([ - { id: "users-empty", text: "Empty!" } - ]); - - await evalLV(page, `socket.view.handle_event("append-users", %{}, socket)`); - await expect(page.getByText("Empty")).not.toBeVisible(); - await expect(await usersInDom(page, "users")).toEqual([ - { id: "users-4", text: "foo" }, - { id: "users-3", text: "last_user" }, - { id: "users-empty", text: "Empty!" } - ]); -}); - -test("JS commands are applied when re-joining", 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(page.locator("#users-1")).toBeVisible(); - await page.locator("#users-1").getByRole("button", { name: "JS Hide" }).click(); - await expect(page.locator("#users-1")).not.toBeVisible(); - await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))); + expect(await childItems(page, "items-a")).toEqual([ + {id: "nested-items-a-e", text: "N-E"}, + {id: "nested-items-a-a", text: "N-A"}, + {id: "nested-items-a-f", text: "N-F"}, + {id: "nested-items-a-g", text: "N-G"}, + ]) +}) + +test("phx-remove is handled correctly when restoring nodes", async ({page}) => { + await page.goto("/stream/reset?phx-remove") + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Filter"}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) + + await page.getByRole("button", {name: "Reset"}).click() + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) +}) + +test("issue #3129 - streams asynchronously assigned and rendered inside a comprehension", async ({page}) => { + await page.goto("/stream/inside-for") + await syncLV(page) + + expect(await listItems(page)).toEqual([ + {id: "items-a", text: "A"}, + {id: "items-b", text: "B"}, + {id: "items-c", text: "C"}, + {id: "items-d", text: "D"} + ]) +}) + +test("issue #3260 - supports non-stream items with id in stream container", async ({page}) => { + await page.goto("/stream?empty_item") + + expect(await usersInDom(page, "users")).toEqual([ + {id: "users-1", text: "chris"}, + {id: "users-2", text: "callan"}, + {id: "users-empty", text: "Empty!"} + ]) + + await expect(page.getByText("Empty")).toBeHidden() + await evalLV(page, "socket.view.handle_event(\"reset-users\", %{}, socket)") + await expect(page.getByText("Empty")).toBeVisible() + expect(await usersInDom(page, "users")).toEqual([ + {id: "users-empty", text: "Empty!"} + ]) + + await evalLV(page, "socket.view.handle_event(\"append-users\", %{}, socket)") + await expect(page.getByText("Empty")).toBeHidden() + expect(await usersInDom(page, "users")).toEqual([ + {id: "users-4", text: "foo"}, + {id: "users-3", text: "last_user"}, + {id: "users-empty", text: "Empty!"} + ]) +}) + +test("JS commands are applied when re-joining", async ({page}) => { + await page.goto("/stream") + await syncLV(page) + + expect(await usersInDom(page, "users")).toEqual([ + {id: "users-1", text: "chris"}, + {id: "users-2", text: "callan"} + ]) + await expect(page.locator("#users-1")).toBeVisible() + await page.locator("#users-1").getByRole("button", {name: "JS Hide"}).click() + await expect(page.locator("#users-1")).toBeHidden() + await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) // not reconnect - await page.evaluate(() => window.liveSocket.connect()); - await syncLV(page); + await page.evaluate(() => window.liveSocket.connect()) + await syncLV(page) // should still be hidden - await expect(page.locator("#users-1")).not.toBeVisible(); -}); + await expect(page.locator("#users-1")).toBeHidden() +}) diff --git a/test/e2e/tests/uploads.spec.js b/test/e2e/tests/uploads.spec.js index 21f66ea608..34aa71e2be 100644 --- a/test/e2e/tests/uploads.spec.js +++ b/test/e2e/tests/uploads.spec.js @@ -1,96 +1,96 @@ -const { test, expect } = require("../test-fixtures"); -const { syncLV, attributeMutations } = require("../utils"); +const {test, expect} = require("../test-fixtures") +const {syncLV, attributeMutations} = 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 = []; + const chunks = [] - stream.on("data", function (chunk) { - chunks.push(chunk); - }); + 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)); - }); -}); + stream.on("end", function (){ + resolve(Buffer.concat(chunks)) + }) +}) -test("can upload a file", async ({ page }) => { - await page.goto("/upload"); - await syncLV(page); +test("can upload a file", async ({page}) => { + await page.goto("/upload") + await syncLV(page) - let changesForm = attributeMutations(page, "#upload-form"); - let changesInput = attributeMutations(page, "#upload-form input"); + let changesForm = attributeMutations(page, "#upload-form") + let changesInput = attributeMutations(page, "#upload-form input") // wait for the change listeners to be ready - await page.waitForTimeout(50); + await page.waitForTimeout(50) await page.locator("#upload-form input").setInputFiles({ name: "file.txt", mimeType: "text/plain", buffer: Buffer.from("this is a test") - }); - await syncLV(page); - await expect(page.locator("progress")).toHaveAttribute("value", "0"); - await page.getByRole("button", { name: "Upload" }).click(); + }) + await syncLV(page) + await expect(page.locator("progress")).toHaveAttribute("value", "0") + await page.getByRole("button", {name: "Upload"}).click() // we should see one uploaded file in the list - await expect(page.locator("ul li")).toBeVisible(); + await expect(page.locator("ul li")).toBeVisible() - await expect(await changesForm()).toEqual(expect.arrayContaining([ - { attr: "class", oldValue: null, newValue: "phx-submit-loading" }, - { attr: "class", oldValue: "phx-submit-loading", newValue: null }, - ])); + expect(await changesForm()).toEqual(expect.arrayContaining([ + {attr: "class", oldValue: null, newValue: "phx-submit-loading"}, + {attr: "class", oldValue: "phx-submit-loading", newValue: null}, + ])) - await expect(await changesInput()).toEqual(expect.arrayContaining([ - { attr: "class", oldValue: null, newValue: "phx-change-loading" }, - { attr: "class", oldValue: "phx-change-loading", newValue: null }, - ])); + expect(await changesInput()).toEqual(expect.arrayContaining([ + {attr: "class", oldValue: null, newValue: "phx-change-loading"}, + {attr: "class", oldValue: "phx-change-loading", newValue: null}, + ])) // 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; + 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"); -}); + .resolves.toEqual("this is a test") +}) -test("can drop a file", async ({ page }) => { - await page.goto("/upload"); - await syncLV(page); +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(); + 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"); + 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 page.dispatchEvent("section", "drop", {dataTransfer}) - await syncLV(page); - await page.getByRole("button", { name: "Upload" }).click(); + 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(); + 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; + 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"); -}); + .resolves.toEqual("this is a test") +}) -test("can upload multiple files", async ({ page }) => { - await page.goto("/upload"); - await syncLV(page); +test("can upload multiple files", async ({page}) => { + await page.goto("/upload") + await syncLV(page) await page.locator("#upload-form input").setInputFiles([ { @@ -103,17 +103,17 @@ test("can upload multiple files", async ({ page }) => { mimeType: "text/markdown", buffer: Buffer.from("## this is a markdown file") } - ]); - await syncLV(page); - await page.getByRole("button", { name: "Upload" }).click(); + ]) + 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); -}); + 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); +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([ { @@ -131,15 +131,15 @@ test("shows error when there are too many files", async ({ page }) => { mimeType: "text/plain", buffer: Buffer.from("another file") } - ]); - await syncLV(page); + ]) + await syncLV(page) - await expect(page.locator(".alert")).toContainText("You have selected too many files"); -}); + 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); +test("shows error for invalid mimetype", async ({page}) => { + await page.goto("/upload") + await syncLV(page) await page.locator("#upload-form input").setInputFiles([ { @@ -147,42 +147,42 @@ test("shows error for invalid mimetype", async ({ page }) => { mimeType: "text/html", buffer: Buffer.from("