diff --git a/README.md b/README.md index 370d5d2..acdafb6 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ 👉 **[Follow the Playwright E2E tutorial: From Cowboy to Confidence →](https://endform.dev/docs/tutorial/playwright-e2e-from-cowboy-to-confidence)** [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fendformdev%2Fplaywright-tutorial&project-name=endform-playwright-example&repository-name=endform-playwright-example&products=%5B%7B%22type%22%3A%22integration%22%2C%22integrationSlug%22%3A%22endform%22%2C%22productSlug%22%3A%22endform%22%2C%22protocol%22%3A%22other%22%7D%5D) +[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/endformdev/playwright-tutorial) ## About the tutorial @@ -33,7 +34,14 @@ You can either: ## Coming from "Deploy with Vercel"? - Don't forget to connect your project with the Endform vercel integration -- You will need the `copy-me-github-workflows` copied to `.github/workflows` to get going with running your tests in github actions +- You will need to rename `rename-me-to-.github-for-workflows` to `.github` to get going with running your tests in GitHub Actions + +## Coming from "Deploy to Netlify"? + +- [Install the Endform extension](https://app.netlify.com/extensions/5exms8e7-endform) on your Netlify team, connect it to your Endform organization, then click **Configure project** on the new project +- The extension runs the tests against every Deploy Preview, so you don't need the GitHub Actions workflows +- If your Deploy Previews are password protected, add the password as a secret environment variable named `E2E_NETLIFY_PREVIEW_PASSWORD`, scoped to Deploy Previews, and add it to the project's `SECRETS_SCAN_OMIT_KEYS` so Netlify's secrets scan doesn't fail the build. The tests use it to get past Netlify's password page (see [`setup-utils.ts`](setup-utils.ts) and [`tests/test.ts`](tests/test.ts)). To do the same in your own tests, follow the [guide in the Endform docs](https://endform.dev/docs/guides/netlify-integration/password-protected-deploy-previews). +- To also see the results as a check on your pull requests, [install the Endform GitHub App](https://endform.dev/app/docs-link?link=settings-integrations) on the repository ## About the SaaS @@ -127,9 +135,15 @@ When you're ready to deploy your SaaS application to production, follow these st 2. Connect your repository to [Vercel](https://vercel.com/) and deploy it. 3. Follow the Vercel deployment process, which will guide you through setting up your project. +### Deploy to Netlify + +1. Push your code to a GitHub repository. +2. Import the repository on [Netlify](https://app.netlify.com/start). Netlify detects Next.js and pnpm, so the default build settings work. +3. [Install the Endform extension](https://app.netlify.com/extensions/5exms8e7-endform) to run the tests against each Deploy Preview. + ### Add environment variables -In your Vercel project settings (or during deployment), add the necessary environment variables: +In your Vercel or Netlify project settings (or during deployment), add the necessary environment variables: 1. `BASE_URL`: Set this to your production domain. 2. `DATABASE_URL`: Leave unset to use the shared Durable Object proxy, set it to a local SQLite path, set it to your own proxy URL, or use a Turso connection string (`libsql://your-database.turso.io`) diff --git a/setup-utils.ts b/setup-utils.ts index 9edb223..a382b3c 100644 --- a/setup-utils.ts +++ b/setup-utils.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import type { BrowserContext } from "@playwright/test"; export interface ApiUser { email: string; @@ -17,6 +18,7 @@ export async function createUser(baseURL: string): Promise { if (process.env.VERCEL_BYPASS_TOKEN) headers.set("x-vercel-protection-bypass", process.env.VERCEL_BYPASS_TOKEN); + await setNetlifyPasswordCookie(headers, baseURL); const response = await fetch(`${baseURL}/api/internal/user`, { method: "POST", @@ -50,6 +52,7 @@ export async function deleteUser(baseURL: string, email: string) { if (process.env.VERCEL_BYPASS_TOKEN) headers.set("x-vercel-protection-bypass", process.env.VERCEL_BYPASS_TOKEN); + await setNetlifyPasswordCookie(headers, baseURL); const response = await fetch(`${baseURL}/api/internal/user`, { method: "DELETE", @@ -90,6 +93,53 @@ export async function deleteUserFromFile( } } +let netlifyPasswordCookie: Promise<{ name: string; value: string } | null>; + +// Netlify answers every request to a password-protected deploy with its +// password page until the request carries the cookie that page sets. Posting +// the password once gets that cookie, which works for the whole deploy. +export function getNetlifyPasswordCookie(baseURL: string) { + const password = process.env.E2E_NETLIFY_PREVIEW_PASSWORD; + if (!password) return Promise.resolve(null); + + netlifyPasswordCookie ??= fetch(baseURL, { + method: "POST", + body: new URLSearchParams({ password }), + redirect: "manual", + }).then((response) => { + const [cookie] = response.headers.getSetCookie(); + if (!cookie) { + throw new Error( + `Netlify rejected E2E_NETLIFY_PREVIEW_PASSWORD (${response.status})`, + ); + } + + const [name, value] = cookie.split(";")[0]!.split("="); + + return { name: name!, value: value! }; + }); + + return netlifyPasswordCookie; +} + +export async function addNetlifyPasswordCookie( + context: BrowserContext, + baseURL: string, +) { + const cookie = await getNetlifyPasswordCookie(baseURL); + if (!cookie) return; + + await context.addCookies([ + { + ...cookie, + domain: new URL(baseURL).hostname, + path: "/", + httpOnly: true, + secure: true, + }, + ]); +} + export function getCookieForSession(session: string, baseURL: string) { return { name: "session", @@ -121,3 +171,8 @@ function writeSessionFile(session: string, baseURL: string, filePath: string) { }), ); } + +async function setNetlifyPasswordCookie(headers: Headers, baseURL: string) { + const cookie = await getNetlifyPasswordCookie(baseURL); + if (cookie) headers.set("cookie", `${cookie.name}=${cookie.value}`); +} diff --git a/tests/check-setup.spec.ts b/tests/check-setup.spec.ts index 70a2ba7..894fad8 100644 --- a/tests/check-setup.spec.ts +++ b/tests/check-setup.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./test"; test("has title", async ({ page }) => { await page.goto("/"); diff --git a/tests/setup.spec.ts b/tests/setup.spec.ts index 46e705b..5f9b4f3 100644 --- a/tests/setup.spec.ts +++ b/tests/setup.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./test"; const authFile = ".auth/shared-ui-user.json"; @@ -20,7 +20,8 @@ test("user signup and login flow", async ({ page }) => { }); await test.step("verify successful signup redirects to dashboard", async () => { - await expect(page).toHaveURL("/dashboard"); + // The first sign-up can hit a cold server right after a deploy. + await expect(page).toHaveURL("/dashboard", { timeout: 15_000 }); await expect( page.getByRole("heading", { name: "Team Settings" }), ).toBeVisible(); diff --git a/tests/teardown.spec.ts b/tests/teardown.spec.ts index 85390aa..50713cd 100644 --- a/tests/teardown.spec.ts +++ b/tests/teardown.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./test"; test("delete current user account", async ({ page }) => { const testPassword = "testpassword123"; diff --git a/tests/test.ts b/tests/test.ts index 8b3a37e..388d912 100644 --- a/tests/test.ts +++ b/tests/test.ts @@ -1,5 +1,6 @@ import { test as base } from "@playwright/test"; import { test as playwrightOpentelemetryTest } from "playwright-opentelemetry/fixture"; +import { addNetlifyPasswordCookie } from "../setup-utils"; import { installFaultsForTest } from "./support/faults"; const playwrightOtelEnabled = Boolean( @@ -10,6 +11,11 @@ const playwrightOtelEnabled = Boolean( const testBase = playwrightOtelEnabled ? playwrightOpentelemetryTest : base; export const test = testBase.extend({ + context: async ({ context, baseURL }, use) => { + if (baseURL) await addNetlifyPasswordCookie(context, baseURL); + + await use(context); + }, page: async ({ page }, use, testInfo) => { await installFaultsForTest(page, testInfo);