Skip to content

Commit 9c0ff34

Browse files
committed
feat: add Playwright e2e testing infrastructure
- Add Playwright configuration with chromium browser support - Add e2e test application with MSW mocking - Add test specs for major flows: - Company onboarding - Employee onboarding - Employee self-onboarding - Contractor onboarding - Contractor payments - Payroll - Extend MSW handlers for e2e scenarios - Add CI job for running e2e tests with artifact upload
1 parent c1ff97e commit 9c0ff34

25 files changed

+1078
-19
lines changed

.github/workflows/ci.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,38 @@ jobs:
150150

151151
- name: Test with coverage
152152
run: npm run test:ci
153+
154+
# E2E job: Run Playwright e2e tests (parallel with other checks)
155+
e2e:
156+
needs: setup
157+
runs-on:
158+
group: gusto-ubuntu-default
159+
steps:
160+
- uses: actions/checkout@v4
161+
162+
- uses: actions/setup-node@v4
163+
with:
164+
node-version-file: '.nvmrc'
165+
166+
- name: Restore node_modules cache
167+
uses: actions/cache/restore@v4
168+
with:
169+
path: node_modules
170+
key: ${{ needs.setup.outputs.cache-key }}
171+
172+
- name: Install Playwright browsers
173+
run: npx playwright install --with-deps chromium
174+
175+
- name: Initialize MSW
176+
run: npx msw init e2e/public --save=false
177+
178+
- name: Run e2e tests
179+
run: npm run test:e2e
180+
181+
- name: Upload test results
182+
if: ${{ !cancelled() }}
183+
uses: actions/upload-artifact@v4
184+
with:
185+
name: playwright-report
186+
path: playwright-report/
187+
retention-days: 7

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,10 @@ dist
189189

190190
# Storybook build output
191191
storybook-static/
192+
193+
# Playwright
194+
playwright-report/
195+
test-results/
196+
197+
# MSW generated service worker
198+
mockServiceWorker.js

e2e/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>E2E Test Harness</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="./main.tsx"></script>
11+
</body>
12+
</html>

e2e/main.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { StrictMode } from 'react'
2+
import { createRoot } from 'react-dom/client'
3+
import { GustoProvider } from '@/contexts'
4+
import { OnboardingFlow } from '@/components/Employee/OnboardingFlow/OnboardingFlow'
5+
import { SelfOnboardingFlow } from '@/components/Employee/SelfOnboardingFlow/SelfOnboardingFlow'
6+
import { OnboardingFlow as CompanyOnboardingFlow } from '@/components/Company/OnboardingFlow/OnboardingFlow'
7+
import { OnboardingFlow as ContractorOnboardingFlow } from '@/components/Contractor/OnboardingFlow/OnboardingFlow'
8+
import { PayrollFlow } from '@/components/Payroll/PayrollFlow/PayrollFlow'
9+
import { PaymentFlow } from '@/components/Contractor/Payments/PaymentFlow/PaymentFlow'
10+
import '@/styles/sdk.scss'
11+
12+
const API_BASE_URL = 'https://api.gusto.com'
13+
14+
type FlowType =
15+
| 'employee-onboarding'
16+
| 'employee-self-onboarding'
17+
| 'company-onboarding'
18+
| 'contractor-onboarding'
19+
| 'payroll'
20+
| 'contractor-payment'
21+
22+
function getFlowFromUrl(): FlowType {
23+
const params = new URLSearchParams(window.location.search)
24+
return (params.get('flow') as FlowType) || 'employee-onboarding'
25+
}
26+
27+
function getPropsFromUrl(): Record<string, string> {
28+
const params = new URLSearchParams(window.location.search)
29+
const props: Record<string, string> = {}
30+
params.forEach((value, key) => {
31+
if (key !== 'flow') {
32+
props[key] = value
33+
}
34+
})
35+
return props
36+
}
37+
38+
function FlowRenderer() {
39+
const flow = getFlowFromUrl()
40+
const urlProps = getPropsFromUrl()
41+
const companyId = urlProps.companyId || '123'
42+
const employeeId = urlProps.employeeId || '456'
43+
44+
const handleEvent = () => {}
45+
46+
switch (flow) {
47+
case 'employee-onboarding':
48+
return <OnboardingFlow companyId={companyId} onEvent={handleEvent} />
49+
case 'employee-self-onboarding':
50+
return (
51+
<SelfOnboardingFlow companyId={companyId} employeeId={employeeId} onEvent={handleEvent} />
52+
)
53+
case 'company-onboarding':
54+
return <CompanyOnboardingFlow companyId={companyId} onEvent={handleEvent} />
55+
case 'contractor-onboarding':
56+
return <ContractorOnboardingFlow companyId={companyId} onEvent={handleEvent} />
57+
case 'payroll':
58+
return <PayrollFlow companyId={companyId} onEvent={handleEvent} />
59+
case 'contractor-payment':
60+
return <PaymentFlow companyId={companyId} onEvent={handleEvent} />
61+
default:
62+
return <div>Unknown flow: {flow}</div>
63+
}
64+
}
65+
66+
function App() {
67+
return (
68+
<StrictMode>
69+
<GustoProvider config={{ baseUrl: API_BASE_URL }}>
70+
<FlowRenderer />
71+
</GustoProvider>
72+
</StrictMode>
73+
)
74+
}
75+
76+
async function startApp() {
77+
const { worker } = await import('./mocks/browser')
78+
await worker.start({
79+
onUnhandledRequest: 'bypass',
80+
})
81+
82+
const container = document.getElementById('root')
83+
if (container) {
84+
createRoot(container).render(<App />)
85+
}
86+
}
87+
88+
startApp()

e2e/mocks/browser.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { setupWorker } from 'msw/browser'
2+
import { handlers } from '@/test/mocks/handlers'
3+
4+
export const worker = setupWorker(...handlers)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
test.describe('CompanyOnboardingFlow', () => {
4+
test('displays the onboarding overview with all steps', async ({ page }) => {
5+
await page.goto('/?flow=company-onboarding&companyId=123')
6+
7+
// Page - Onboarding Overview - should show the list of steps
8+
await page.getByRole('heading', { name: /get started|let's get started/i }).waitFor()
9+
await expect(
10+
page.getByRole('heading', { name: /get started|let's get started/i }),
11+
).toBeVisible()
12+
13+
// Verify steps are displayed (using headings to be more specific)
14+
await expect(
15+
page.getByRole('heading', { name: /company addresses|add company/i }),
16+
).toBeVisible()
17+
await expect(page.getByRole('heading', { name: /federal tax/i })).toBeVisible()
18+
await expect(page.getByRole('heading', { name: /industry/i })).toBeVisible()
19+
await expect(page.getByRole('heading', { name: /payroll account|bank/i })).toBeVisible()
20+
await expect(page.getByRole('heading', { name: /employees/i })).toBeVisible()
21+
await expect(page.getByRole('heading', { name: /pay schedule/i })).toBeVisible()
22+
await expect(page.getByRole('heading', { name: /state tax/i })).toBeVisible()
23+
await expect(page.getByRole('heading', { name: /sign documents/i })).toBeVisible()
24+
25+
// Verify the start button exists
26+
await expect(page.getByRole('button', { name: /start onboarding/i })).toBeVisible()
27+
})
28+
29+
test('can navigate to first step (Company addresses)', async ({ page }) => {
30+
await page.goto('/?flow=company-onboarding&companyId=123')
31+
32+
// Page - Onboarding Overview
33+
await page.getByRole('button', { name: /start onboarding/i }).waitFor()
34+
await page.getByRole('button', { name: /start onboarding/i }).click()
35+
36+
// Page - Locations (Company addresses)
37+
await page.getByRole('heading', { name: /address/i }).waitFor()
38+
await expect(page.getByRole('heading', { name: /address/i })).toBeVisible()
39+
40+
// Verify the progress bar shows step 1
41+
await expect(page.getByRole('progressbar')).toBeVisible()
42+
})
43+
44+
test('can continue through locations to federal taxes', async ({ page }) => {
45+
await page.goto('/?flow=company-onboarding&companyId=123')
46+
47+
// Page - Onboarding Overview
48+
await page.getByRole('button', { name: /start onboarding/i }).waitFor()
49+
await page.getByRole('button', { name: /start onboarding/i }).click()
50+
51+
// Page - Locations (Company addresses)
52+
await page.getByRole('heading', { name: /address/i }).waitFor()
53+
await page.getByRole('button', { name: /continue/i }).click()
54+
55+
// Page - Federal Taxes
56+
await page.getByRole('heading', { name: /federal tax/i }).waitFor()
57+
await expect(page.getByRole('heading', { name: /federal tax/i })).toBeVisible()
58+
})
59+
60+
test('can navigate through federal taxes to industry', async ({ page }) => {
61+
await page.goto('/?flow=company-onboarding&companyId=123')
62+
63+
// Navigate through to Federal Taxes
64+
await page.getByRole('button', { name: /start onboarding/i }).waitFor()
65+
await page.getByRole('button', { name: /start onboarding/i }).click()
66+
await page.getByRole('heading', { name: /address/i }).waitFor()
67+
await page.getByRole('button', { name: /continue/i }).click()
68+
await page.getByRole('heading', { name: /federal tax/i }).waitFor()
69+
await page.getByRole('button', { name: /continue/i }).click()
70+
71+
// Page - Industry
72+
await page.getByRole('heading', { name: /industry/i }).waitFor()
73+
await expect(page.getByRole('heading', { name: /industry/i })).toBeVisible()
74+
})
75+
})
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
async function fillDate(
4+
page: import('@playwright/test').Page,
5+
name: string,
6+
date: { month: number; day: number; year: number },
7+
) {
8+
const dateGroup = page.getByRole('group', { name })
9+
await dateGroup.getByRole('spinbutton', { name: /month/i }).fill(String(date.month))
10+
await dateGroup.getByRole('spinbutton', { name: /day/i }).fill(String(date.day))
11+
await dateGroup.getByRole('spinbutton', { name: /year/i }).fill(String(date.year))
12+
}
13+
14+
test.describe('ContractorOnboardingFlow', () => {
15+
test('displays the contractor list and can navigate to add contractor', async ({ page }) => {
16+
await page.goto('/?flow=contractor-onboarding&companyId=123')
17+
18+
// Page - Contractor List
19+
await page.getByRole('heading', { name: /contractor/i }).waitFor()
20+
21+
// Verify list is visible
22+
await expect(page.getByRole('heading', { name: /contractor/i })).toBeVisible()
23+
24+
// Click Add Contractor button
25+
const addButton = page.getByRole('button', { name: /add/i })
26+
await addButton.waitFor()
27+
await addButton.click()
28+
29+
// Page - Profile
30+
await page.getByRole('heading', { name: /profile|contractor/i }).waitFor()
31+
await expect(page.getByRole('heading', { name: /profile|contractor/i })).toBeVisible()
32+
})
33+
34+
test('can fill out the contractor profile form', async ({ page }) => {
35+
await page.goto('/?flow=contractor-onboarding&companyId=123')
36+
37+
// Page - Contractor List
38+
await page.getByRole('heading', { name: /contractor/i }).waitFor()
39+
await page.getByRole('button', { name: /add/i }).click()
40+
41+
// Page - Profile
42+
await page.getByRole('heading', { name: /profile|contractor/i }).waitFor()
43+
44+
// Select contractor type - Individual
45+
const individualRadio = page.getByRole('radio', { name: /individual/i })
46+
if (await individualRadio.isVisible().catch(() => false)) {
47+
await individualRadio.click()
48+
}
49+
50+
// Fill profile information
51+
await page.getByLabel(/first name/i).fill('Jane')
52+
await page.getByLabel(/last name/i).fill('Contractor')
53+
54+
// SSN field
55+
const ssnField = page.getByLabel(/social security/i)
56+
if (await ssnField.isVisible().catch(() => false)) {
57+
await ssnField.fill('456789012')
58+
}
59+
60+
// Start date
61+
await fillDate(page, 'Start Date', { month: 1, day: 15, year: 2025 })
62+
63+
// Verify form is filled
64+
await expect(page.getByLabel(/first name/i)).toHaveValue('Jane')
65+
await expect(page.getByLabel(/last name/i)).toHaveValue('Contractor')
66+
67+
// Create contractor
68+
await page.getByRole('button', { name: /create contractor/i }).click()
69+
70+
// Should proceed to next step (Address)
71+
await expect(page.getByRole('heading', { name: /address/i })).toBeVisible({ timeout: 10000 })
72+
})
73+
74+
test('can navigate back to contractor list from profile', async ({ page }) => {
75+
await page.goto('/?flow=contractor-onboarding&companyId=123')
76+
77+
// Page - Contractor List
78+
await page.getByRole('heading', { name: /contractor/i }).waitFor()
79+
await page.getByRole('button', { name: /add/i }).click()
80+
81+
// Page - Profile
82+
await page.getByRole('heading', { name: /profile|contractor/i }).waitFor()
83+
84+
// Click back button
85+
const backButton = page.getByRole('button', { name: /back/i })
86+
if (await backButton.isVisible().catch(() => false)) {
87+
await backButton.click()
88+
89+
// Should return to contractor list
90+
await expect(page.getByRole('heading', { name: /contractor/i })).toBeVisible({
91+
timeout: 5000,
92+
})
93+
}
94+
})
95+
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
test.describe('ContractorPaymentFlow', () => {
4+
test('loads the payment flow page', async ({ page }) => {
5+
await page.goto('/?flow=contractor-payment&companyId=123')
6+
7+
// Wait for the page to load - check for any content
8+
await page.waitForTimeout(2000)
9+
10+
// The page should show either:
11+
// - A heading with "payment"
12+
// - A create button
13+
// - An error (which we can report)
14+
// - A table/grid
15+
const pageContent = page.locator('article')
16+
await expect(pageContent).toBeVisible({ timeout: 30000 })
17+
})
18+
19+
test('shows create payment button', async ({ page }) => {
20+
await page.goto('/?flow=contractor-payment&companyId=123')
21+
22+
// Wait for initial load
23+
await page.waitForTimeout(2000)
24+
25+
// Look for "New payment" button specifically
26+
const newPaymentButton = page.getByRole('button', { name: /new payment/i })
27+
await expect(newPaymentButton).toBeVisible({ timeout: 30000 })
28+
})
29+
})

0 commit comments

Comments
 (0)