diff --git a/.gitignore b/.gitignore index 822a1030..6c579593 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,7 @@ config/google-service-account.json /priv/static/images/media_bucket/ /screenshots/ -/tmp/ \ No newline at end of file +/tmp/ + +# Playwright e2e test artifacts +/test-results/ \ No newline at end of file diff --git a/README.md b/README.md index fc489c1c..4e247ffc 100644 --- a/README.md +++ b/README.md @@ -113,3 +113,25 @@ Copyright 2021 The Bike Brigade Inc. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +## End to End testing with Playwright + +E2E tests run against the **test** database (not dev), so your development data is safe. + +To run e2e tests: + +1. Navigate to `/test/e2e` and run `npm install` +2. From the root directory, start the test server: `MIX_ENV=test mix test.e2e` +3. In a new terminal, navigate to `/test/e2e` and run `npm run test:ui` + +The test server runs on port 4002 using the Ecto sandbox for database isolation. + +### Troubleshooting E2E tests + +Sometimes e2e tests will fail due to network calls being made (e.g. Google Maps address fetching). Re-running individual tests often helps. If needed, adjust delays: + +```js +await page + .locator('#location-form-location-input-open') + .pressSequentially("200 Yonge", { delay: 200 }) +``` diff --git a/config/dev.exs b/config/dev.exs index 1e96b884..7e6f4e60 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -66,7 +66,6 @@ config :bike_brigade, BikeBrigadeWeb.Endpoint, # Do not include metadata nor timestamps in development logs config :logger, :console, format: "[$level] $message\n" - # Disable the extremely annoying debug logging for the spreadsheet library config :logger, compile_time_purge_matching: [ diff --git a/lib/bike_brigade_web/live/campaign_live/form_component.ex b/lib/bike_brigade_web/live/campaign_live/form_component.ex index b2ec1374..5f6d608a 100644 --- a/lib/bike_brigade_web/live/campaign_live/form_component.ex +++ b/lib/bike_brigade_web/live/campaign_live/form_component.ex @@ -72,7 +72,7 @@ defmodule BikeBrigadeWeb.CampaignLive.FormComponent do @impl true def mount(socket) do - programs = for p <- Delivery.list_programs(), do: {p.name, p.id} + programs = for p <- Delivery.list_programs(), do: [key: p.name, value: p.id, label: p.name] {:ok, socket diff --git a/lib/bike_brigade_web/live/program_live/form_component.html.heex b/lib/bike_brigade_web/live/program_live/form_component.html.heex index 671b8f9d..439b9398 100644 --- a/lib/bike_brigade_web/live/program_live/form_component.html.heex +++ b/lib/bike_brigade_web/live/program_live/form_component.html.heex @@ -147,7 +147,7 @@ <.input type="text" field={f[:photo_description]} - label="Photo Descriotion" + label="Photo Description" placeholder="Typical delivery size" />
diff --git a/mix.exs b/mix.exs index ce56b378..91ff734a 100644 --- a/mix.exs +++ b/mix.exs @@ -124,7 +124,8 @@ defmodule BikeBrigade.MixProject do "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate", "test"], - "assets.deploy": ["esbuild default --minify", "tailwind default --minify", "phx.digest"] + "assets.deploy": ["esbuild default --minify", "tailwind default --minify", "phx.digest"], + "test.e2e": ["ecto.create --quiet", "ecto.migrate", "run priv/repo/seeds.exs", "phx.server"] ] end diff --git a/test/e2e/.gitignore b/test/e2e/.gitignore new file mode 100644 index 00000000..58786aac --- /dev/null +++ b/test/e2e/.gitignore @@ -0,0 +1,7 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/test/e2e/all.spec.ts b/test/e2e/all.spec.ts new file mode 100644 index 00000000..ff201924 --- /dev/null +++ b/test/e2e/all.spec.ts @@ -0,0 +1,178 @@ +import { Page } from '@playwright/test'; +import { test, expect } from "./helpers/sandbox"; +import { faker } from '@faker-js/faker'; + +test.describe('Login and Logout', () => { + test('Can Login', async ({ page }) => { + await doLogin(page); + await expect(page.locator('#flash')).toContainText('Success! Welcome!'); + }); + + test('Validates phone number', async ({ page }) => { + await page.goto('/login'); + await page.getByRole('textbox', { name: 'Phone Number' }).fill('647555'); + await page.getByRole('button', { name: 'Get Login Code' }).click(); + await expect(page.locator('#login-form')).toContainText('phone number is not valid for Canada'); + }); + + test('Cancel button returns to login page', async ({ page }) => { + await page.goto('/login'); + await page.getByRole('textbox', { name: 'Phone Number' }).fill('6475555555'); + await page.getByRole('button', { name: 'Get Login Code' }).click(); + await page.getByRole('link', { name: 'Cancel' }).click(); + await expect(page.getByRole('button')).toContainText('Get Login Code'); + }); + + test('Can Logout', async ({ page }) => { + await doLogin(page); + await page.getByRole('link', { name: 'Log out' }).click(); + await expect(page.locator('#flash')).toContainText('Success! Goodbye'); + }); +}); + +test.describe('Programs', () => { + test('Can create and edit program', async ({ page }) => { + const programName = faker.company.name(); + await doLogin(page); + + await createProgram(page, programName); + await expect(page.getByRole('link', { name: programName, exact: true })).toBeVisible(); + + // Edit the program + await page.getByRole('link', { name: `Edit , ${programName}` }).click(); + await page.getByRole('textbox', { name: 'Campaign Blurb (please keep' }).fill('This is a test program that was updated'); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Success! program updated')).toBeVisible(); + + // Verify the edit persisted + await page.getByRole('link', { name: `Edit , ${programName}` }).click(); + await expect(page.getByLabel('Campaign Blurb (please keep')).toContainText('This is a test program that was updated'); + }); +}); + +test.describe('Campaigns', () => { + test('Can create a campaign for today', async ({ page }) => { + const programName = faker.company.name(); + await doLogin(page); + await createProgram(page, programName); + + await createCampaign({ page, programName, numDays: 0 }); + await expect(page.locator('#flash')).toContainText('Success! Campaign created successfully'); + await expect(page.getByText(programName)).toBeVisible(); + }); + + test('Can create a campaign for next week', async ({ page }) => { + const programName = faker.company.name(); + await doLogin(page); + await createProgram(page, programName); + + await createCampaign({ page, programName, numDays: 8 }); + await expect(page.locator('#flash')).toContainText('Success! Campaign created successfully'); + + // Verify campaign shows up on next week's view + await page.getByRole('link', { name: 'Campaigns' }).click(); + await page.getByRole('navigation', { name: 'Pagination' }).getByRole('link').nth(2).click(); + await expect(page.getByText(programName)).toBeVisible(); + }); +}); + +test.describe('Riders', () => { + test('Can view the riders list', async ({ page }) => { + await doLogin(page); + await page.goto('/riders'); + await expect(page.getByRole('heading', { name: 'Riders' })).toBeVisible(); + }); + + test('Can search for a rider by name', async ({ page }) => { + await doLogin(page); + await page.goto('/riders'); + const searchBox = page.getByRole('textbox', { name: /search/i }); + await searchBox.fill('Dispatcher'); + await expect(page.getByText('Dispatcher')).toBeVisible(); + }); +}); + +test.describe('Navigation', () => { + test('Sidebar navigation links work', async ({ page }) => { + await doLogin(page); + + // Navigate to Campaigns + await page.getByRole('link', { name: 'Campaigns' }).click(); + await expect(page).toHaveURL(/\/campaigns/); + + // Navigate to Programs + await page.getByRole('link', { name: 'Programs' }).click(); + await expect(page).toHaveURL(/\/programs/); + + // Navigate to Riders + await page.getByRole('link', { name: 'Riders' }).click(); + await expect(page).toHaveURL(/\/riders/); + }); + + test('Unauthenticated user is redirected to login', async ({ page }) => { + await page.goto('/campaigns'); + await expect(page).toHaveURL(/\/login/); + }); +}); + +// --- Helper functions --- + +async function doLogin(page: Page) { + await page.goto('/login'); + await page.getByRole('textbox', { name: 'Phone Number' }).fill('6475555555'); + await page.getByRole('button', { name: 'Get Login Code' }).click(); + await page.getByRole('textbox', { name: 'Authentication Code' }).fill('123456'); + await page.getByRole('button', { name: 'Sign in' }).click(); +} + +function getDatePlusDays(daysToAdd: number): string { + const today = new Date(); + const futureDate = new Date(today); + futureDate.setDate(today.getDate() + daysToAdd); + + const year = futureDate.getFullYear(); + const month = String(futureDate.getMonth() + 1).padStart(2, '0'); + const day = String(futureDate.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; +} + +async function createProgram(page: Page, programName: string) { + await page.goto('/programs'); + await page.getByRole('link', { name: 'New Program' }).click(); + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(programName); + await page.getByRole('textbox', { name: 'Campaign Blurb (please keep' }).fill('This is a test program'); + await page.getByRole('textbox', { name: 'About (internal description)' }).fill('This is an internal description'); + await page.getByRole('textbox', { name: 'Start Date' }).fill('2025-02-12'); + await page.getByRole('checkbox', { name: 'Public' }).check(); + await page.getByRole('checkbox', { name: 'Hide Pickup Address' }).check(); + await page.getByRole('button', { name: 'Add Schedule' }).click(); + + await page.getByRole('textbox', { name: 'Photo Description' }).fill('1 Large Box'); + await page.getByRole('textbox', { name: 'Contact Name' }).fill('Joe Cool'); + await page.getByRole('textbox', { name: 'Contact Email' }).fill('joecool@gmail.com'); + await page.getByRole('textbox', { name: 'Contact Phone' }).fill('6475555554'); + await page.getByRole('button', { name: 'Save' }).click(); + + // Edit the program to add items (can't add items during creation) + await page.getByRole('link', { name: programName, exact: true }).click(); + await page.getByRole('link', { name: 'Edit', exact: true }).click(); + await page.getByRole('link', { name: 'New Item' }).click(); + await page.locator('#program-form_program_0_items_0_name').fill('An item'); + await page.locator('#program-form_program_0_items_0_description').fill('5 lbs'); + await page.getByRole('cell', { name: 'Foodshare Box' }).getByLabel('').selectOption('Food Hamper'); + await page.getByRole('button', { name: 'Save' }).click(); +} + +async function createCampaign({ page, programName, numDays }: { page: Page; programName: string; numDays: number }) { + await page.goto('/campaigns/new'); + await page.waitForSelector("body > .phx-connected"); + await page.getByRole('textbox', { name: 'Delivery Date' }).fill(getDatePlusDays(numDays)); + + const programSelector = page.locator('#user-form_program_id'); + await programSelector.selectOption({ label: programName }); + + await page.locator('#location-form-location-input-open').click(); + await page.locator('#location-form-location-input-open').pressSequentially("200 Yonge", { delay: 200 }); + await page.getByRole('button', { name: 'Save' }).click(); +} diff --git a/test/e2e/helpers/sandbox.ts b/test/e2e/helpers/sandbox.ts new file mode 100644 index 00000000..5560d395 --- /dev/null +++ b/test/e2e/helpers/sandbox.ts @@ -0,0 +1,49 @@ +import { request, test as base } from '@playwright/test'; + +const BASE_URL = 'http://localhost:4002'; + +async function setupSandbox(context: any) { + const requestContext = await request.newContext(); + const response = await requestContext.post(`${BASE_URL}/sandbox`, { + headers: { + 'Cache-Control': 'no-store' + } + }); + + const sessionId = await response.text(); + + // Add sessionId header to all requests for sandbox isolation + await context.route('**/*', async (route: any, request: any) => { + const headers = request.headers(); + headers['x-session-id'] = sessionId; + await route.continue({ headers }); + }); + + // Store sessionId for LiveView WebSocket connections + await context.addInitScript(({ sessionId }: { sessionId: string }) => { + (window as any).sessionId = sessionId; + }, { sessionId }); + + return sessionId; +} + +async function teardownSandbox(sessionId: string) { + const requestContext = await request.newContext(); + await requestContext.delete(`${BASE_URL}/sandbox`, { + headers: { + 'x-session-id': sessionId + } + }); +} + +const test = base.extend({ + context: async ({ context }, use) => { + const sessionId = await setupSandbox(context); + await use(context); + await teardownSandbox(sessionId); + } +}); + +const expect = base.expect; + +export { test, expect }; diff --git a/test/e2e/package-lock.json b/test/e2e/package-lock.json new file mode 100644 index 00000000..72f7e2cf --- /dev/null +++ b/test/e2e/package-lock.json @@ -0,0 +1,115 @@ +{ + "name": "e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "e2e", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@faker-js/faker": "^9.5.0", + "@playwright/test": "^1.50.1", + "@types/node": "^22.13.1" + } + }, + "node_modules/@faker-js/faker": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.5.0.tgz", + "integrity": "sha512-3qbjLv+fzuuCg3umxc9/7YjrEXNaKwHgmig949nfyaTx8eL4FAsvFbu+1JcFUj1YAXofhaDn6JdEUBTYuk0Ssw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.1.tgz", + "integrity": "sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.50.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.13.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", + "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "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/playwright": { + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.1.tgz", + "integrity": "sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.50.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.1.tgz", + "integrity": "sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/test/e2e/package.json b/test/e2e/package.json new file mode 100644 index 00000000..805cf233 --- /dev/null +++ b/test/e2e/package.json @@ -0,0 +1,19 @@ +{ + "name": "e2e", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "npx playwright test", + "test:ui": "npx playwright test --ui", + "test:withreport": "npm run test; npx playwright show-report" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@faker-js/faker": "^9.5.0", + "@playwright/test": "^1.50.1", + "@types/node": "^22.13.1" + } +} diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts new file mode 100644 index 00000000..aa69b9e1 --- /dev/null +++ b/test/e2e/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './.', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: 'html', + use: { + baseURL: 'http://localhost:4002', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +});