From 718cef546eff11e201db5ef1c2feace3c290c219 Mon Sep 17 00:00:00 2001 From: Jon Jensen Date: Thu, 13 Oct 2022 13:47:39 -0600 Subject: [PATCH] test: expand test coverage around forms * Rework all form tests to run both with and without JavaScript. This way we can better detect/prevent regressions or inconsistencies between how
and work. * Dedup and expand form method tests to ensure we cover all supported Form methods and submitter formMethods * Expand coverage around form serialization (tree order, image submit buttons, files in URL-encoded payloads) * Conditionally mark tests as failing (via test.fail): * Non-get/post methods with JavaScript disabled (#4420) * Form serialization problems with JavaScript enabled (#4342) --- integration/form-test.ts | 1181 +++++++++++---------- integration/helpers/playwright-fixture.ts | 2 + 2 files changed, 610 insertions(+), 573 deletions(-) diff --git a/integration/form-test.ts b/integration/form-test.ts index c94560c9d35..c1da0f99f88 100644 --- a/integration/form-test.ts +++ b/integration/form-test.ts @@ -308,9 +308,9 @@ test.describe("Forms", () => { } `, - "app/routes/submitter-formmethod.jsx": js` - import { useActionData, useLoaderData, Form } from "@remix-run/react"; - import { json } from '@remix-run/node' + "app/routes/form-method.jsx": js` + import { Form, useActionData, useLoaderData, useSearchParams } from "@remix-run/react"; + import { json } from "@remix-run/node"; export function action({ request }) { return json(request.method) @@ -320,94 +320,94 @@ test.describe("Forms", () => { return json(request.method) } - export default function Index() { + export default function() { let actionData = useActionData(); let loaderData = useLoaderData(); + let [searchParams] = useSearchParams(); + let formMethod = searchParams.get('method') || 'GET'; + let submitterFormMethod = searchParams.get('submitterFormMethod') || 'GET'; return ( <> - - -
-
- -
-
- + + +
-
{actionData || loaderData}
) } `, - "app/routes/form-method.jsx": js` - import { Form, useActionData, useSearchParams } from "@remix-run/react"; - import { json } from "@remix-run/node"; + "app/routes/submitter.jsx": js` + import { Form } from "@remix-run/react"; - export function action({ request }) { - return json(request.method) - } export default function() { - let actionData = useActionData(); - let [searchParams] = useSearchParams(); - let formMethod = searchParams.get('method') || 'post'; return ( <> -
- + + + + + + + + + + +
-
{actionData}
) } `, - "app/routes/button-form-method.jsx": js` - import { Form, useActionData, useSearchParams } from "@remix-run/react"; - import { json } from "@remix-run/node"; + "app/routes/file-upload.jsx": js` + import { Form, useSearchParams } from "@remix-run/react"; - export function action({ request }) { - return json(request.method) - } export default function() { - let actionData = useActionData(); - let [searchParams] = useSearchParams(); - let formMethod = searchParams.get('method') || 'post'; + const [params] = useSearchParams(); return ( - <> -
- -
-
{actionData}
- +
+ + + +
-
{data}
- - ) + export default function OutputFormData() { + const requestBody = useActionData(); + const searchParams = useSearchParams()[0]; + return ; } `, + "myfile.txt": "stuff", + "app/routes/pathless-layout-parent.jsx": js` import { json } from '@remix-run/server-runtime' import { Form, Outlet, useActionData } from '@remix-run/react' @@ -460,572 +460,607 @@ test.describe("Forms", () => { test.describe("without JavaScript", () => { test.use({ javaScriptEnabled: false }); - test("posts to a loader", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/get-submission"); - await Promise.all([ - page.waitForNavigation(), - app.clickSubmitButton("/get-submission", { wait: false }), - ]); - await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); - }); + + runFormTests(); }); test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); // explicitly set so we don't have to check against undefined + + runFormTests(); + }); + + function runFormTests() { test("posts to a loader", async ({ page }) => { let app = new PlaywrightFixture(appFixture, page); // this indirectly tests that clicking SVG children in buttons works await app.goto("/get-submission"); - await app.clickSubmitButton("/get-submission"); + await app.clickSubmitButton("/get-submission", { wait: true }); await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); }); - }); - - test("posts to a loader with an ", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/get-submission"); - await app.clickElement(`#${FORM_WITH_ACTION_INPUT} button`); - await page.waitForLoadState("load"); - await page.waitForSelector(`pre:has-text("${EAT}")`); - }); - - test("posts to a loader with button data with click", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/get-submission"); - await Promise.all([ - page.waitForNavigation(), - app.clickElement("#buttonWithValue"), - ]); - await page.waitForSelector(`pre:has-text("${LAKSA}")`); - }); - - test("posts to a loader with button data with keyboard", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/get-submission"); - await page.focus(`#${KEYBOARD_INPUT}`); - await page.keyboard.press("Enter"); - await page.waitForLoadState("networkidle"); - await page.waitForSelector(`pre:has-text("${LAKSA}")`); - }); - - test("posts with the correct checkbox data", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/get-submission"); - await app.clickElement(`#${CHECKBOX_BUTTON}`); - await page.waitForSelector(`pre:has-text("${LAKSA}")`); - await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); - }); - - test("posts button data from outside the form", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/get-submission"); - await app.clickElement(`#${ORPHAN_BUTTON}`); - await page.waitForSelector(`pre:has-text("${SQUID_INK_HOTDOG}")`); - }); - - test("when clicking on a submit button as a descendant of an element that stops propagation on click, still passes the clicked submit button's `name` and `value` props to the request payload", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/stop-propagation"); - await app.clickSubmitButton("/stop-propagation"); - expect(await app.getHtml()).toMatch('{"action":"add"}'); - }); - - test.describe("
action", () => { - test.describe("in a static route", () => { - test("no action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/inbox"); - let html = await app.getHtml(); - let el = getElement(html, `#${STATIC_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/inbox"); - }); - - test("no action resolves to URL including search params", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/inbox?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${STATIC_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/inbox?foo=bar"); - }); - - test("absolute action resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/inbox"); - let html = await app.getHtml(); - let el = getElement(html, `#${STATIC_ROUTE_ABSOLUTE_ACTION}`); - expect(el.attr("action")).toMatch("/about"); - }); - - test("'.' action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/inbox"); - let html = await app.getHtml(); - let el = getElement(html, `#${STATIC_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/inbox"); - }); - - test("'.' excludes search params", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/inbox?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${STATIC_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/inbox"); - }); - - test("'..' action resolves relative to the parent route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/inbox"); - let html = await app.getHtml(); - let el = getElement(html, `#${STATIC_ROUTE_PARENT_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); - test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/inbox"); - let html = await app.getHtml(); - let el = getElement(html, `#${STATIC_ROUTE_TOO_MANY_DOTS_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); + test("posts to a loader with an ", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement(`#${FORM_WITH_ACTION_INPUT} button`); + await page.waitForSelector(`pre:has-text("${EAT}")`); }); - test.describe("in a dynamic route", () => { - test("no action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog/abc"); - let html = await app.getHtml(); - let el = getElement(html, `#${DYNAMIC_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/blog/abc"); - }); - - test("no action resolves to URL including search params", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog/abc?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${DYNAMIC_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/blog/abc?foo=bar"); - }); - - test("absolute action resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog/abc"); - let html = await app.getHtml(); - let el = getElement(html, `#${DYNAMIC_ROUTE_ABSOLUTE_ACTION}`); - expect(el.attr("action")).toMatch("/about"); - }); - - test("'.' action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog/abc"); - let html = await app.getHtml(); - let el = getElement(html, `#${DYNAMIC_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog/abc"); - }); - - test("'.' excludes search params", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog/abc?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${DYNAMIC_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog/abc"); - }); - - test("'..' action resolves relative to the parent route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog/abc"); - let html = await app.getHtml(); - let el = getElement(html, `#${DYNAMIC_ROUTE_PARENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); - }); - - test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog/abc"); - let html = await app.getHtml(); - let el = getElement(html, `#${DYNAMIC_ROUTE_TOO_MANY_DOTS_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); + test("posts to a loader with button data with click", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement("#buttonWithValue"); + await page.waitForSelector(`pre:has-text("${LAKSA}")`); }); - test.describe("in an index route", () => { - test("no action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); - }); - - test("no action resolves to URL including search params", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/blog?index&foo=bar"); - }); - - test("absolute action resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_ABSOLUTE_ACTION}`); - expect(el.attr("action")).toMatch("/about"); - }); - - test("'.' action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); - }); - - test("'.' excludes search params", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); - }); - - test("'..' action resolves relative to the parent route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_PARENT_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); - - test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_TOO_MANY_DOTS_ACTION}`); - expect(el.attr("action")).toMatch("/"); + test("posts to a loader with button data with keyboard", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await page.focus(`#${KEYBOARD_INPUT}`); + await app.waitForNetworkAfter(async () => { + await page.keyboard.press("Enter"); + // there can be a delay before the request gets kicked off (worse with JS disabled) + await new Promise((resolve) => setTimeout(resolve, 50)); }); + await page.waitForSelector(`pre:has-text("${LAKSA}")`); + }); - test("handles search params correctly on GET submissions", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - - // Start with a query param - await app.goto("/blog?junk=1"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toBe("/blog?index&junk=1"); - expect(app.page.url()).toMatch(/\/blog\?junk=1$/); - - // On submission, we replace existing parameters (reflected in the - // form action) with the values from the form data. We also do not - // need to preserve the index param in the URL on GET submissions - await app.clickElement(`#${INDEX_ROUTE_NO_ACTION} button`); - html = await app.getHtml(); - el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toBe("/blog?index&foo=1"); - expect(app.page.url()).toMatch(/\/blog\?foo=1$/); - - // Does not append duplicate params on re-submissions - await app.clickElement(`#${INDEX_ROUTE_NO_ACTION} button`); - html = await app.getHtml(); - el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toBe("/blog?index&foo=1"); - expect(app.page.url()).toMatch(/\/blog\?foo=1$/); - }); + test("posts with the correct checkbox data", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement(`#${CHECKBOX_BUTTON}`); + await page.waitForSelector(`pre:has-text("${LAKSA}")`); + await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); + }); - test("handles search params correctly on POST submissions", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - - // Start with a query param - await app.goto("/blog?junk=1"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION_POST}`); - expect(el.attr("action")).toBe("/blog?index&junk=1"); - expect(app.page.url()).toMatch(/\/blog\?junk=1$/); - - // Form action reflects the current params and change them on submission - await app.clickElement(`#${INDEX_ROUTE_NO_ACTION_POST} button`); - html = await app.getHtml(); - el = getElement(html, `#${INDEX_ROUTE_NO_ACTION_POST}`); - expect(el.attr("action")).toBe("/blog?index&junk=1"); - expect(app.page.url()).toMatch(/\/blog\?index&junk=1$/); - }); + test("posts button data from outside the form", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement(`#${ORPHAN_BUTTON}`); + await page.waitForSelector(`pre:has-text("${SQUID_INK_HOTDOG}")`); }); - test.describe("in a layout route", () => { - test("no action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); - }); + test("when clicking on a submit button as a descendant of an element that stops propagation on click, still passes the clicked submit button's `name` and `value` props to the request payload", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/stop-propagation"); + await app.clickSubmitButton("/stop-propagation", { wait: true }); + expect(await app.getHtml()).toMatch('{"action":"add"}'); + }); - test("no action resolves to URL including search params", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/blog?foo=bar"); + test.describe(" action", () => { + test.describe("in a static route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/inbox"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/inbox?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/inbox"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/inbox"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); }); - test("absolute action resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_ABSOLUTE_ACTION}`); - expect(el.attr("action")).toMatch("/about"); + test.describe("in a dynamic route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog/abc"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog/abc?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog/abc"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog/abc"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); }); - test("'.' action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); + test.describe("in an index route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog?index&foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + + test("handles search params correctly on GET submissions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + // Start with a query param + await app.goto("/blog?junk=1"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog?index&junk=1"); + expect(app.page.url()).toMatch(/\/blog\?junk=1$/); + + // On submission, we replace existing parameters (reflected in the + // form action) with the values from the form data. We also do not + // need to preserve the index param in the URL on GET submissions + await app.clickElement(`#${INDEX_ROUTE_NO_ACTION} button`); + html = await app.getHtml(); + el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog?index&foo=1"); + expect(app.page.url()).toMatch(/\/blog\?foo=1$/); + + // Does not append duplicate params on re-submissions + await app.clickElement(`#${INDEX_ROUTE_NO_ACTION} button`); + html = await app.getHtml(); + el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog?index&foo=1"); + expect(app.page.url()).toMatch(/\/blog\?foo=1$/); + }); + + test("handles search params correctly on POST submissions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + // Start with a query param + await app.goto("/blog?junk=1"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION_POST}`); + expect(el.attr("action")).toBe("/blog?index&junk=1"); + expect(app.page.url()).toMatch(/\/blog\?junk=1$/); + + // Form action reflects the current params and change them on submission + await app.clickElement(`#${INDEX_ROUTE_NO_ACTION_POST} button`); + html = await app.getHtml(); + el = getElement(html, `#${INDEX_ROUTE_NO_ACTION_POST}`); + expect(el.attr("action")).toBe("/blog?index&junk=1"); + expect(app.page.url()).toMatch(/\/blog\?index&junk=1$/); + }); }); - test("'.' excludes search params", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); + test.describe("in a layout route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); }); - test("'..' action resolves relative to the parent route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_PARENT_ACTION}`); - expect(el.attr("action")).toMatch("/"); + test.describe("in a splat route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/projects?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); }); + }); - test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_TOO_MANY_DOTS_ACTION}`); - expect(el.attr("action")).toMatch("/"); + let FORM_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; + let NATIVE_FORM_METHODS = ["GET", "POST"]; + + test.describe("uses the Form `method` attribute", () => { + FORM_METHODS.forEach((method) => { + test(`submits with ${method}`, async ({ page, javaScriptEnabled }) => { + test.fail( + !javaScriptEnabled && !NATIVE_FORM_METHODS.includes(method), + `Native doesn't support method ${method} #4420` + ); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/form-method?method=${method}`); + await app.clickElement(`text=Submit`); + expect(await app.getHtml("pre")).toBe(`
${method}
`); + }); }); }); - test.describe("in a splat route", () => { - test("no action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/projects"); + test.describe("overrides the Form `method` attribute with the submitter's `formMethod` attribute", () => { + // NOTE: HTMLButtonElement only supports get/post as formMethod, which is why we don't test put/patch/delete + NATIVE_FORM_METHODS.forEach((overrideMethod) => { + // ensure the form's method is different from the submitter's + let method = overrideMethod === "GET" ? "POST" : "GET"; + test(`submits with ${overrideMethod} instead of ${method}`, async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto( + `/form-method?method=${method}&submitterFormMethod=${overrideMethod}` + ); + await app.clickElement(`text=Submit with ${overrideMethod}`); + expect(await app.getHtml("pre")).toBe(`
${overrideMethod}
`); + }); }); + }); - test("no action resolves to URL including search params", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/projects?foo=bar"); - }); + test("submits the submitter's value(s) in tree order in the form data", async ({ + page, + javaScriptEnabled, + }) => { + test.fail( + Boolean(javaScriptEnabled), + " doesn't serialize submit buttons correctly #4342" + ); + let app = new PlaywrightFixture(appFixture, page); - test("absolute action resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_ABSOLUTE_ACTION}`); - expect(el.attr("action")).toMatch("/about"); - }); + await app.goto("/submitter"); + await app.clickElement("text=Add Task"); + expect((await app.getElement("#formData")).val()).toBe( + "tasks=first&tasks=second&tasks=&tasks=last" + ); - test("'.' action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/projects"); - }); + await app.goto("/submitter"); + await app.clickElement("text=No Name"); + expect((await app.getElement("#formData")).val()).toBe( + "tasks=first&tasks=second&tasks=last" + ); - test("'.' excludes search params", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/projects"); - }); + await app.goto("/submitter"); + await app.clickElement("[alt='Add Task']"); + expect((await app.getElement("#formData")).val()).toMatch( + /^tasks=first&tasks=second&tasks.x=\d+&tasks.y=\d+&tasks=last$/ + ); - test("'..' action resolves relative to the parent route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_PARENT_ACTION}`); - expect(el.attr("action")).toMatch("/projects"); - }); + await app.goto("/submitter"); + await app.clickElement("[alt='No Name']"); + expect((await app.getElement("#formData")).val()).toMatch( + /^tasks=first&tasks=second&x=\d+&y=\d+&tasks=last$/ + ); - test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_TOO_MANY_DOTS_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); + await app.goto("/submitter"); + await app.clickElement("text=Outside"); + expect((await app.getElement("#formData")).val()).toBe( + "tasks=outside&tasks=first&tasks=second&tasks=last" + ); }); - }); - test.describe("with submitter button having `formMethod` attribute", () => { - test.describe("overrides the form `method` attribute with the button `formmethod` attribute", () => { - test("submits with GET instead of POST", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/submitter-formmethod"); - await app.clickElement("text=Replace POST with GET"); - await page.waitForLoadState("load"); - expect(await app.getHtml("pre")).toBe("
GET
"); - }); - - test("submits with POST instead of GET", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/submitter-formmethod"); - await app.clickElement("text=Replace GET with POST"); - await page.waitForLoadState("load"); - expect(await app.getHtml("pre")).toBe("
POST
"); - }); - - test("submits with POST instead of DELETE", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/submitter-formmethod"); - await app.clickElement("text=Replace DELETE with POST"); - await page.waitForLoadState("load"); - expect(await app.getHtml("pre")).toBe("
POST
"); - }); - }); + test("sends file names when submitting via url encoding", async ({ + page, + javaScriptEnabled, + }) => { + test.fail( + Boolean(javaScriptEnabled), + " doesn't handle File entries correctly when url encoding #4342" + ); - test("uses the form `method` attribute", async ({ page }) => { let app = new PlaywrightFixture(appFixture, page); - await app.goto("/form-method?method=delete"); - await app.clickElement("button"); - await page.waitForLoadState("load"); - expect(await app.getHtml("pre")).toMatch("DELETE"); + let myFile = fixture.projectDir + "/myfile.txt"; - await app.goto("/form-method?method=post"); + await app.goto("/file-upload"); + await app.uploadFile(`[name=filey]`, myFile); + await app.uploadFile(`[name=filey2]`, myFile, myFile); await app.clickElement("button"); - await page.waitForLoadState("load"); - expect(await app.getHtml("pre")).toMatch("POST"); - }); - test("uses the button `formmethod` attribute", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/button-form-method?method=get"); - await app.clickElement("button"); - await page.waitForLoadState("load"); - expect(await app.getHtml("pre")).toMatch("POST"); + expect((await app.getElement("#formData")).val()).toBe( + "filey=myfile.txt&filey2=myfile.txt&filey2=myfile.txt&filey3=" + ); - await app.goto("/button-form-method?method=delete"); + await app.goto("/file-upload?method=post"); + await app.uploadFile(`[name=filey]`, myFile); + await app.uploadFile(`[name=filey2]`, myFile, myFile); await app.clickElement("button"); - await page.waitForLoadState("load"); - expect(await app.getHtml("pre")).toMatch("POST"); - }); - }); - test(" submits the submitter's value appended to the form data", async ({ - page, - browserName, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/submitter"); - await app.clickElement("text=Add Task"); - await page.waitForLoadState("load"); - // TODO: remove after playwright ships safari 16 - if (browserName === "webkit") { - expect(await app.getHtml("pre")).toBe( - `
tasks=first&tasks=second&tasks=&tasks=
` - ); - } else { - expect(await app.getHtml("pre")).toBe( - `
tasks=first&tasks=second&tasks=
` + expect((await app.getElement("#formData")).val()).toBe( + "filey=myfile.txt&filey2=myfile.txt&filey2=myfile.txt&filey3=" ); - } - }); + }); - test("pathless layout routes are ignored in form actions", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/pathless-layout-parent"); - let html = await app.getHtml(); - expect(html).toMatch("Pathless Layout Parent"); - expect(html).toMatch("Pathless Layout "); - expect(html).toMatch("Pathless Layout Index"); - - let el = getElement(html, `form`); - expect(el.attr("action")).toMatch("/pathless-layout-parent"); - - expect(await app.getHtml()).toMatch("Submitted - No"); - // This submission should ignore the index route and the pathless layout - // route above it and hit the action in routes/pathless-layout-parent.jsx - await app.clickSubmitButton("/pathless-layout-parent"); - expect(await app.getHtml()).toMatch("Submitted - Yes"); - }); + test("pathless layout routes are ignored in form actions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/pathless-layout-parent"); + let html = await app.getHtml(); + expect(html).toMatch("Pathless Layout Parent"); + expect(html).toMatch("Pathless Layout "); + expect(html).toMatch("Pathless Layout Index"); + + let el = getElement(html, `form`); + expect(el.attr("action")).toMatch("/pathless-layout-parent"); + + expect(await app.getHtml()).toMatch("Submitted - No"); + // This submission should ignore the index route and the pathless layout + // route above it and hit the action in routes/pathless-layout-parent.jsx + await app.clickSubmitButton("/pathless-layout-parent"); + expect(await app.getHtml()).toMatch("Submitted - Yes"); + }); + } }); diff --git a/integration/helpers/playwright-fixture.ts b/integration/helpers/playwright-fixture.ts index 59c6daee254..7a13ab60ad6 100644 --- a/integration/helpers/playwright-fixture.ts +++ b/integration/helpers/playwright-fixture.ts @@ -264,6 +264,7 @@ async function doAndWait( page.on("request", onRequest); page.on("requestfinished", onRequestDone); page.on("requestfailed", onRequestDone); + page.on("load", networkSettledCallback); // e.g. navigation with javascript disabled let timeoutId: NodeJS.Timer | undefined; if (DEBUG) { @@ -287,6 +288,7 @@ async function doAndWait( page.removeListener("request", onRequest); page.removeListener("requestfinished", onRequestDone); page.removeListener("requestfailed", onRequestDone); + page.removeListener("load", networkSettledCallback); if (DEBUG && timeoutId) { clearTimeout(timeoutId);