diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..531d422 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,37 @@ +name: Playwright Tests +permissions: read-all +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] +jobs: + test: + name: Run Playwright Tests + environment: dev3 + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + env: + NEXT_PUBLIC_MESHDB_URL: ${{ secrets.BETA_MESHDB_URL }} + S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }} + S3_BASE_NAME: ${{ secrets.TEST_S3_BASE_NAME }} + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + JOIN_FORM_LOG: /tmp/join.csv + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 829a822..0e004fa 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,8 @@ yarn-error.log* *.tsbuildinfo # IDEs -.idea/ \ No newline at end of file +.idea/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/README.md b/README.md index 08e78cf..c966e86 100644 --- a/README.md +++ b/README.md @@ -36,4 +36,38 @@ npm install npm run dev ``` -Finally, open `http://127.0.0.1:3000` in your web browser to interact with your copy of the application \ No newline at end of file +Finally, open `http://127.0.0.1:3000` in your web browser to interact with your copy of the application + +# Testing + +We use `playwright` to do integration tests. You can run them with the following instructions: + +1. Setup a dev instance of [meshdb](https://github.com/nycmeshnet/meshdb) + +2. Copy `.env.sample` into `.env.local` and fill it out + +3. Run the integration tests with `npx playwright test` + +You can see what playwright is doing with `--headed`, and you can pause a test to +examine the browser by inserting `page.pause()` in your test. + +To run a specific test, you can use `-g`: + +`npx playwright test -g 'missing name'` + +See the [docs](https://playwright.dev/docs/running-tests) for more information about playwright. + +If you get an error like this one: + +``` +[WebServer] [Error: ENOENT: no such file or directory, open '/home/wilnil/Code/nycmesh/meshforms/.next/BUILD_ID'] { + errno: -2, + code: 'ENOENT', + syscall: 'open', + path: '/home/wilnil/Code/nycmesh/meshforms/.next/BUILD_ID' +} + +Error: Process from config.webServer was not able to start. Exit code: 1 +``` + +Try running `npm run build` diff --git a/app/api.ts b/app/api.ts index eebf471..c74f6ad 100644 --- a/app/api.ts +++ b/app/api.ts @@ -1,3 +1,4 @@ +import { JoinFormInput, JoinFormResponse, NNAssignFormInput, NNAssignFormResponse, QueryFormInput, QueryFormResponse } from "@/app/io"; import { z } from "zod"; if (process.env.NEXT_PUBLIC_MESHDB_URL === undefined) { @@ -10,78 +11,6 @@ if (process.env.NEXT_PUBLIC_MESHDB_URL === undefined) { const API_BASE = new URL(process.env.NEXT_PUBLIC_MESHDB_URL as string + "/api/v1/"); -export const JoinFormInput = z.object({ - first_name: z.string(), - last_name: z.string(), - email: z.string(), - phone: z.string(), - street_address: z.string(), - apartment: z.string(), - city: z.string(), - state: z.string(), - zip: z.number(), - roof_access: z.boolean(), - referral: z.string(), - ncl: z.boolean(), -}) -export type JoinFormInput = z.infer - -export const JoinFormResponse = z.object({ - message: z.string().optional(), - building_id: z.number(), - member_id: z.number(), - install_number: z.number(), - member_exists: z.boolean(), -}) -export type JoinFormResponse = z.infer - -export const NNAssignFormInput = z.object({ - install_number: z.number(), - password: z.string(), // TODO: Salt/hash/whatever this -}) -export type NNAssignFormInput = z.infer - -export const NNAssignFormResponse = z.object({ - message: z.string().optional(), - building_id: z.number(), - install_number: z.number(), - network_number: z.number(), - created: z.boolean(), -}) -export type NNAssignFormResponse = z.infer - -export const QueryFormInput = z.object({ - //route: z.string(), - legacy: z.string().optional(), - query_type: z.string(), - data: z.string(), - password: z.string(), // TODO: Salt/hash/whatever this -}) -export type QueryFormInput = z.infer - -export const QueryFormResponse = z.object({ - count: z.number(), - next: z.string().nullable(), - previous: z.string().nullable(), - results: z.array(z.object({ - install_number: z.number(), - street_address: z.string().nullable(), - unit: z.string(), - city: z.string(), - state: z.string(), - zip_code: z.string(), - name: z.string(), - phone_number: z.string().nullable(), - primary_email_address: z.string().nullable(), - stripe_email_address: z.string().nullable(), - additional_email_addresses: z.array(z.string()).nullable(), - notes: z.string(), - network_number: z.number().nullable(), - status: z.string(), - })) -}) -export type QueryFormResponse = z.infer - const get = async (url: string, schema: S, auth?: string, nextOptions?: NextFetchRequestConfig): Promise> => { const res = await fetch(new URL(url, API_BASE), { headers: { diff --git a/app/data.ts b/app/data.ts index 9ac13ec..32e423a 100644 --- a/app/data.ts +++ b/app/data.ts @@ -1,12 +1,13 @@ 'use server' import { access, constants, appendFileSync, readFile } from 'node:fs'; -import { JoinFormInput } from "@/app/api"; +import { JoinFormInput } from "@/app/io"; import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; const JOIN_FORM_LOG = process.env.JOIN_FORM_LOG as string; const S3_REGION = process.env.S3_REGION as string; const S3_ENDPOINT = process.env.S3_ENDPOINT as string; const S3_BUCKET_NAME = process.env.S3_BUCKET_NAME as string; +const S3_BASE_NAME = process.env.S3_BASE_NAME as string; const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY as string; const S3_SECRET_KEY = process.env.S3_SECRET_KEY as string; @@ -29,33 +30,21 @@ export async function recordJoinFormSubmissionToCSV(submission: JoinFormInput) { // Records the submission we just got as a JSON object in an S3 bucket. export async function recordJoinFormSubmissionToS3(submission: JoinFormInput) { - // Don't define endpoint if one is not passed - let s3Client; - if (S3_ENDPOINT != "" && S3_ENDPOINT != null) { - s3Client = new S3Client({ - region: S3_REGION, - endpoint: S3_ENDPOINT, - credentials: { - accessKeyId: S3_ACCESS_KEY, - secretAccessKey: S3_SECRET_KEY, - }, - }); - } else { - s3Client = new S3Client({ - region: S3_REGION, - credentials: { - accessKeyId: S3_ACCESS_KEY, - secretAccessKey: S3_SECRET_KEY, - }, - }); - } + const s3Client = new S3Client({ + region: S3_REGION != undefined ? S3_REGION : "us-east-1", + endpoint: S3_ENDPOINT != undefined ? S3_ENDPOINT : "https://s3.us-east-1.amazonaws.com", + credentials: { + accessKeyId: S3_ACCESS_KEY, + secretAccessKey: S3_SECRET_KEY, + }, + }); // Thanks ChatGPT, eww... const submissionKey = new Date().toISOString().replace(/[-:T]/g, '/').slice(0, 19); const command = new PutObjectCommand({ Bucket: S3_BUCKET_NAME, - Key: `join-form-submissions/${submissionKey}.json`, + Key: `${S3_BASE_NAME}/${submissionKey}.json`, Body: JSON.stringify(submission), }); @@ -66,6 +55,7 @@ export async function recordJoinFormSubmissionToS3(submission: JoinFormInput) { console.error(err); // Record the submission to a local CSV file *just in case* recordJoinFormSubmissionToCSV(submission); + throw err; } } diff --git a/app/io.ts b/app/io.ts new file mode 100644 index 0000000..58cbce3 --- /dev/null +++ b/app/io.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +export const JoinFormInput = z.object({ + first_name: z.string(), + last_name: z.string(), + email: z.string(), + phone: z.string(), + street_address: z.string(), + apartment: z.string(), + city: z.string(), + state: z.string(), + zip: z.number(), + roof_access: z.boolean(), + referral: z.string(), + ncl: z.boolean(), +}) +export type JoinFormInput = z.infer + +export const JoinFormResponse = z.object({ + message: z.string().optional(), + building_id: z.number(), + member_id: z.number(), + install_number: z.number(), + member_exists: z.boolean(), +}) +export type JoinFormResponse = z.infer + +export const NNAssignFormInput = z.object({ + install_number: z.number(), + password: z.string(), // TODO: Salt/hash/whatever this +}) +export type NNAssignFormInput = z.infer + +export const NNAssignFormResponse = z.object({ + message: z.string().optional(), + building_id: z.number(), + install_number: z.number(), + network_number: z.number(), + created: z.boolean(), +}) +export type NNAssignFormResponse = z.infer + +export const QueryFormInput = z.object({ + //route: z.string(), + legacy: z.string().optional(), + query_type: z.string(), + data: z.string(), + password: z.string(), // TODO: Salt/hash/whatever this +}) +export type QueryFormInput = z.infer + +export const QueryFormResponse = z.object({ + count: z.number(), + next: z.string().nullable(), + previous: z.string().nullable(), + results: z.array(z.object({ + install_number: z.number(), + street_address: z.string().nullable(), + unit: z.string(), + city: z.string(), + state: z.string(), + zip_code: z.string(), + name: z.string(), + phone_number: z.string().nullable(), + primary_email_address: z.string().nullable(), + stripe_email_address: z.string().nullable(), + additional_email_addresses: z.array(z.string()).nullable(), + notes: z.string(), + network_number: z.number().nullable(), + status: z.string(), + })) +}) +export type QueryFormResponse = z.infer diff --git a/app/join/page.tsx b/app/join/page.tsx index d18742a..9304109 100644 --- a/app/join/page.tsx +++ b/app/join/page.tsx @@ -1,10 +1,12 @@ import { Footer } from "@/components/Footer/Footer"; import { Header } from "@/components/Header/Header"; import JoinForm from "@/components/JoinForm/JoinForm"; +import Head from 'next/head'; -// TODO: -// https://www.npmjs.com/package/react-phone-number-input -// https://www.npmjs.com/package/react-error-boundary +export const metadata = { + title: "Join Our Community Network!", + description: "Use this form to sign up for NYC Mesh", +}; export default async function Join() { return <> diff --git a/components/JoinForm/JoinForm.tsx b/components/JoinForm/JoinForm.tsx index 183bceb..3c928bf 100644 --- a/components/JoinForm/JoinForm.tsx +++ b/components/JoinForm/JoinForm.tsx @@ -1,6 +1,7 @@ "use client"; -import { JoinFormInput, submitJoinForm } from "@/app/api"; +import { submitJoinForm } from "@/app/api"; +import { JoinFormInput } from "@/app/io"; import { recordJoinFormSubmissionToS3 } from "@/app/data"; import 'react-phone-number-input/style.css'; import PhoneInput from 'react-phone-number-input/input'; @@ -143,13 +144,16 @@ const JoinForm = () => { variant="contained" size="large" sx={{ width: "12rem", fontSize: "1rem", m:"1rem"}} + name="submit_join_form" > { isLoading ? "Loading..." : (submitted ? "Thanks!" : "Submit") } +
+
} diff --git a/components/NNAssignForm/NNAssignForm.tsx b/components/NNAssignForm/NNAssignForm.tsx index 2f1db24..21fdbad 100644 --- a/components/NNAssignForm/NNAssignForm.tsx +++ b/components/NNAssignForm/NNAssignForm.tsx @@ -1,11 +1,9 @@ "use client"; import Button from "@mui/material/Button"; -import { useFormState } from "react-dom"; -import { useFormStatus } from "react-dom"; -import { NNAssignFormInput, submitNNAssignForm } from "@/app/api"; +import { NNAssignFormInput } from "@/app/io"; +import { submitNNAssignForm } from "@/app/api"; import { useRouter } from 'next/navigation' -import { ErrorBoundary } from "react-error-boundary"; import { ToastContainer, toast } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; diff --git a/components/QueryForm/QueryForm.tsx b/components/QueryForm/QueryForm.tsx index 4ce6242..d9ae6e4 100644 --- a/components/QueryForm/QueryForm.tsx +++ b/components/QueryForm/QueryForm.tsx @@ -1,6 +1,7 @@ 'use client' -import { QueryFormInput, QueryFormResponse, submitQueryForm } from "@/app/api"; +import { submitQueryForm } from "@/app/api"; +import { QueryFormInput, QueryFormResponse } from "@/app/io"; import Button from "@mui/material/Button"; import { toastErrorMessage } from "@/app/utils/toastErrorMessage"; import { ToastContainer, toast } from 'react-toastify'; diff --git a/package-lock.json b/package-lock.json index 6b18ddf..03ede2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "zod": "^3.22.4" }, "devDependencies": { + "@playwright/test": "^1.45.3", "@types/react": "^18.2.48" } }, @@ -1611,6 +1612,22 @@ "node": ">= 10" } }, + "node_modules/@playwright/test": { + "version": "1.45.3", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.3.tgz", + "integrity": "sha512-UKF4XsBfy+u3MFWEH44hva1Q8Da28G6RFtR2+5saw+jgAFQV5yYnB1fu68Mz7fO+5GJF3wgwAIs0UelU8TxFrA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.45.3" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -3320,6 +3337,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.45.3", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.3.tgz", + "integrity": "sha512-QhVaS+lpluxCaioejDZ95l4Y4jSFCsBvl2UZkpeXlzxmqS+aABr5c82YmfMHrL6x27nvrvykJAFpkzT2eWdJww==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.45.3" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.45.3", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.3.tgz", + "integrity": "sha512-+ym0jNbcjikaOwwSZycFbwkWgfruWvYlJfThKYAlImbxUgdWFO2oW70ojPm4OpE4t6TAo2FY/smM+hpVTtkhDA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", diff --git a/package.json b/package.json index b2b0b83..ecac01c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "zod": "^3.22.4" }, "devDependencies": { + "@playwright/test": "^1.45.3", "@types/react": "^18.2.48" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..4d56fd0 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,84 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + // XXX (willnilges): I don't care so much about testing per-browser functionality + // right now as much as I care about testing basic logic and such, so I'm gonna + // use what WOMM. I invite someone to get the others working locally in Docker + // or something. + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + /* + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + */ + + /* + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + */ + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run dev', + url: 'http://127.0.0.1:3000', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/tests-examples/demo-todo-app.spec.ts b/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 0000000..8641cb5 --- /dev/null +++ b/tests-examples/demo-todo-app.spec.ts @@ -0,0 +1,437 @@ +import { test, expect, type Page } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('https://demo.playwright.dev/todomvc'); +}); + +const TODO_ITEMS = [ + 'buy some cheese', + 'feed the cat', + 'book a doctors appointment' +] as const; + +test.describe('New Todo', () => { + test('should allow me to add todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Make sure the list only has one todo item. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0] + ]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + + // Make sure the list now has two todo items. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[1] + ]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test('should clear text input field when an item is added', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test('should append new items to the bottom of the list', async ({ page }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + // Check test using different methods. + await expect(page.getByText('3 items left')).toBeVisible(); + await expect(todoCount).toHaveText('3 items left'); + await expect(todoCount).toContainText('3'); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe('Mark all as completed', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should allow me to mark all items as completed', async ({ page }) => { + // Complete all todos. + await page.getByLabel('Mark all as complete').check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test('should allow me to clear the complete state of all items', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']); + }); + + test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); +}); + +test.describe('Item', () => { + + test('should allow me to mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + // Check first item. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').check(); + await expect(firstTodo).toHaveClass('completed'); + + // Check second item. + const secondTodo = page.getByTestId('todo-item').nth(1); + await expect(secondTodo).not.toHaveClass('completed'); + await secondTodo.getByRole('checkbox').check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).toHaveClass('completed'); + }); + + test('should allow me to un-mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const firstTodo = page.getByTestId('todo-item').nth(0); + const secondTodo = page.getByTestId('todo-item').nth(1); + const firstTodoCheckbox = firstTodo.getByRole('checkbox'); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test('should allow me to edit an item', async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId('todo-item'); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]); + await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter'); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); +}); + +test.describe('Editing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should hide other controls when editing', async ({ page }) => { + const todoItem = page.getByTestId('todo-item').nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole('checkbox')).not.toBeVisible(); + await expect(todoItem.locator('label', { + hasText: TODO_ITEMS[1], + })).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should save edits on blur', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should trim entered text', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages '); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should remove the item if an empty text string was entered', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(''); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[2], + ]); + }); + + test('should cancel edits on escape', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape'); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe('Counter', () => { + test('should display the current number of todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + await expect(todoCount).toContainText('1'); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + await expect(todoCount).toContainText('2'); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe('Clear completed button', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test('should display the correct text', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible(); + }); + + test('should remove completed items when clicked', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).getByRole('checkbox').check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should be hidden when there are no items that are completed', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden(); + }); +}); + +test.describe('Persistence', () => { + test('should persist its data', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const todoItems = page.getByTestId('todo-item'); + const firstTodoCheck = todoItems.nth(0).getByRole('checkbox'); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + }); +}); + +test.describe('Routing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test('should allow me to display active items', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should respect the back button', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step('Showing all items', async () => { + await page.getByRole('link', { name: 'All' }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step('Showing active items', async () => { + await page.getByRole('link', { name: 'Active' }).click(); + }); + + await test.step('Showing completed items', async () => { + await page.getByRole('link', { name: 'Completed' }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test('should allow me to display completed items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Completed' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(1); + }); + + test('should allow me to display all items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await page.getByRole('link', { name: 'Completed' }).click(); + await page.getByRole('link', { name: 'All' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(3); + }); + + test('should highlight the currently applied filter', async ({ page }) => { + await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected'); + + //create locators for active and completed links + const activeLink = page.getByRole('link', { name: 'Active' }); + const completedLink = page.getByRole('link', { name: 'Completed' }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass('selected'); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass('selected'); + }); +}); + +async function createDefaultTodos(page: Page) { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } +} + +async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).length === e; + }, expected); +} + +async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e; + }, expected); +} + +async function checkTodosInLocalStorage(page: Page, title: string) { + return await page.waitForFunction(t => { + return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t); + }, title); +} diff --git a/tests/join_form.spec.ts b/tests/join_form.spec.ts new file mode 100644 index 0000000..5ede083 --- /dev/null +++ b/tests/join_form.spec.ts @@ -0,0 +1,107 @@ +import { JoinFormInput } from '@/app/io'; +import { test, expect } from '@playwright/test'; + +import { sampleData, fillOutJoinForm, submitSuccessExpected, submitFailureExpected } from '@/tests/util'; + +const joinFormTimeout = 20000; + +// Integration tests for the Join Form. +// +// These tests can hit either a self-hosted dev instance of MeshDB +// (See https://github.com/nycmeshnet/meshdb) or it can hit the beta +// env. + +// TODO (wdn): I want to test +// - Full cooperation with real address X +// - Missing address +// - Missing phone +// - Missing email +// - Missing phone + Missing email +// - Missing last name +// - Bad phone +// - Bad email +// Can we mirror what meshdb does? + +test('happy join form', async ({ page }) => { + test.setTimeout(joinFormTimeout); + await page.goto('/join'); + + // Is the page title correct? + await expect(page).toHaveTitle(/Join Our Community Network!/); + + // Set up sample data. + await fillOutJoinForm(page, sampleData); + + await submitSuccessExpected(page); +}); + +// Tests missing both first and last name +test('fail missing name', async ({ page }) => { + test.setTimeout(joinFormTimeout); + await page.goto('/join'); + + // Is the page title correct? + await expect(page).toHaveTitle(/Join Our Community Network!/); + + let missingNameData: JoinFormInput; + + missingNameData = sampleData; + missingNameData.first_name = ""; + + // Set up sample data. + await fillOutJoinForm(page, missingNameData); + + // Shouldn't go through + await submitFailureExpected(page); + + // Do it again with last name + missingNameData = sampleData; + missingNameData.last_name = ""; + await fillOutJoinForm(page, missingNameData); + await submitFailureExpected(page); +}); + + +test('fail missing address', async ({ page }) => { + test.setTimeout(joinFormTimeout); + await page.goto('/join'); + + // Is the page title correct? + await expect(page).toHaveTitle(/Join Our Community Network!/); + + // Set up sample data. + let missingAddressData: JoinFormInput; + + missingAddressData = sampleData; + missingAddressData.street_address = ""; + await fillOutJoinForm(page, missingAddressData); + await submitFailureExpected(page); + + missingAddressData = sampleData; + missingAddressData.city = ""; + await fillOutJoinForm(page, missingAddressData); + await submitFailureExpected(page); +}); + +// This one should fail and here's why: It's really annoying when people +// don't give us their apartment #, so we make it required at the expense +// of those who live in houses. They can just write "house" or "N/A" or +// something +// TODO (wdn): Add a checkbox for my house-havers +test('fail missing unit number', async ({ page }) => { + test.setTimeout(joinFormTimeout); + await page.goto('/join'); + + // Is the page title correct? + await expect(page).toHaveTitle(/Join Our Community Network!/); + + // Set up sample data. + let missingAddressData: JoinFormInput; + + missingAddressData = sampleData; + missingAddressData.apartment = ""; + await fillOutJoinForm(page, missingAddressData); + await submitFailureExpected(page); +}); + + diff --git a/tests/util.ts b/tests/util.ts new file mode 100644 index 0000000..5cbc236 --- /dev/null +++ b/tests/util.ts @@ -0,0 +1,97 @@ +import { JoinFormInput } from '@/app/io'; +import { expect, Page } from '@playwright/test'; + +export const sampleData: JoinFormInput = JoinFormInput.parse({ + first_name: "Jon", + last_name: "Smith", + email: "js@gmail.com", + phone: "585-475-2411", + street_address: "197 Prospect Pl", + apartment: "1", + city: "Brooklyn", + state: "NY", + zip: 11238, + roof_access: true, + referral: "I googled it.", + ncl: true, +}); + +export async function fillOutJoinForm(page: Page, sampleData: JoinFormInput) { + // Set up some sample data + + // Personal info + await page + .getByPlaceholder('First Name') + .fill(sampleData.first_name); + await page + .getByPlaceholder('Last Name') + .fill(sampleData.last_name); + await page + .getByPlaceholder('Email Address') + .fill(sampleData.email); + await page + .getByPlaceholder('Phone Number') + .fill(sampleData.phone); + + // Address Info + await page + .getByPlaceholder('Street Address') + .fill(sampleData.street_address); + + await page + .getByPlaceholder('Unit #') + .fill(sampleData.apartment); + + await page + .getByPlaceholder('City') + .fill(sampleData.city); + + await page + .getByPlaceholder('Zip Code') + .fill(sampleData.zip.toString()); + + // How did you hear about us? + await page + .getByPlaceholder('How did you hear about us?') + .fill(sampleData.referral); + + // Agree to the NCL + if (sampleData.ncl) { + await page.locator("[name='ncl']").check(); + } + + // Roof Access + if (sampleData.roof_access) { + await page.locator("[name='roof_access']").check(); + } +} + +export async function submitFailureExpected(page: Page) { + // Submit the join form + await page + .getByRole('button', { name: /Submit/i }) + .click(); + + await page.waitForTimeout(1000); + + // The submission should've been stopped + await expect( + page.locator("[name='submit_join_form']") + ).toHaveText('Submit'); +} + +export async function submitSuccessExpected(page: Page) { + // Listen for all console logs + page.on('console', msg => console.log(msg.text())); + + // Submit the join form + await page + .getByRole('button', { name: /Submit/i }) + .click(); + + // Make sure that the submit button says "Thanks!" + await page.waitForTimeout(10000); + await expect( + page.locator("[name='submit_join_form']") + ).toHaveText('Thanks!'); +}