-
Notifications
You must be signed in to change notification settings - Fork 9
Add Playwright E2E tests with sandbox isolation #488
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fd9d6b7
c52c38b
d562517
59de3d7
599ca17
4e39768
1635c40
37c8a24
742fe63
b05ebda
16879e3
8cb6724
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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] | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: This keyword list format is not valid for Prompt for AI agents
Suggested change
|
||||||
|
|
||||||
| {:ok, | ||||||
| socket | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
|
|
||
| # Playwright | ||
| node_modules/ | ||
| /test-results/ | ||
| /playwright-report/ | ||
| /blob-report/ | ||
| /playwright/.cache/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Race condition: no wait between typing the address and clicking Save. The Prompt for AI agents |
||
| 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'); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Use Prompt for AI agents |
||
| }); | ||
| }); | ||
|
|
||
| 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(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The Prompt for AI agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const response = await requestContext.post(`${BASE_URL}/sandbox`, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'Cache-Control': 'no-store' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const sessionId = await response.text(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: The response from the Prompt for AI agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+5
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Request context should be disposed after use to prevent resource leaks. The 🛡️ Proposed fix to dispose request context 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();
+ try {
+ 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;
+ } finally {
+ await requestContext.dispose();
+ }
+}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function teardownSandbox(sessionId: string) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const requestContext = await request.newContext(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await requestContext.delete(`${BASE_URL}/sandbox`, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Same resource leak here — the Prompt for AI agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'x-session-id': sessionId | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+30
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Teardown request context also needs disposal. Same resource leak concern as in 🛡️ Proposed fix async function teardownSandbox(sessionId: string) {
const requestContext = await request.newContext();
- await requestContext.delete(`${BASE_URL}/sandbox`, {
- headers: {
- 'x-session-id': sessionId
- }
- });
+ try {
+ await requestContext.delete(`${BASE_URL}/sandbox`, {
+ headers: {
+ 'x-session-id': sessionId
+ }
+ });
+ } finally {
+ await requestContext.dispose();
+ }
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor grammar: "End to End" should be hyphenated.
Per standard English grammar, compound adjectives before a noun should be hyphenated: "End-to-End testing".
📝 Suggested fix
🧰 Tools
🪛 LanguageTool
[grammar] ~117-~117: Use a hyphen to join words.
Context: ... limitations under the License. ## End to End testing with Playwright E2E tests r...
(QB_NEW_EN_HYPHEN)
🤖 Prompt for AI Agents